diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 72549161..ce5a9cdd 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible Spool for Jellyfin defect +description: Report a reproducible Spool defect title: "[Bug]: " labels: [bug] body: diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 9c2be0b5..79a516e5 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -4,6 +4,7 @@ on: push: branches: - "**" + - "!feature/tvos" pull_request: workflow_dispatch: inputs: @@ -32,8 +33,13 @@ on: required: true default: false type: boolean + build_tvos: + description: "Build unsigned Apple TV device IPA and run simulator smoke" + required: true + default: true + type: boolean release: - description: "Take the release path: sign with the release key and verify the signing identity. Publishes nothing." + description: "Use release signing and identity gates where applicable; tvOS remains unsigned. Publishes nothing." required: false default: false type: boolean @@ -305,10 +311,10 @@ jobs: - name: Lint compiled QML - run: nix develop .#native -c cmake --build build/linux-release/app --target jellyfin-native_qmllint_strict + run: nix develop .#native -c cmake --build build/linux-release/app --target spool_qmllint_strict - name: Launch-test host app UI - run: nix develop .#native -c bash tools/launch-native-app.sh build/linux-release/install/bin/jellyfin-native + run: nix develop .#native -c bash tools/launch-native-app.sh build/linux-release/install/bin/spool # A page switch is meant to cost one frame. That is a number, so it is # measured every commit rather than noticed once it has got bad. @@ -327,7 +333,7 @@ jobs: SPOOL_BENCH=routes \ SPOOL_BENCH_OUT="$PWD/dist/render-benchmark/warm.json" \ SPOOL_BENCH_ITERATIONS=4 \ - bash tools/launch-native-app.sh build/linux-release/install/bin/jellyfin-native + bash tools/launch-native-app.sh build/linux-release/install/bin/spool # The same walk with every page dropped between switches: what a # memory-starved device pays, tracked but not gated. nix develop .#native -c env \ @@ -335,7 +341,7 @@ jobs: SPOOL_BENCH_COLD=1 \ SPOOL_BENCH_OUT="$PWD/dist/render-benchmark/cold.json" \ SPOOL_BENCH_ITERATIONS=4 \ - bash tools/launch-native-app.sh build/linux-release/install/bin/jellyfin-native + bash tools/launch-native-app.sh build/linux-release/install/bin/spool - name: Report render latency run: | @@ -394,8 +400,8 @@ jobs: echo 'error: extracted AppImage contains qmltypes metadata' >&2 exit 1 fi - elf_args=(elf "$extracted" --root usr/bin/jellyfin-native) - test ! -f "$extracted/usr/bin/secret-tool" || elf_args+=(--root usr/bin/secret-tool) + elf_args=(elf "$extracted" --root usr/bin/spool) + test ! -f "$extracted/usr/libexec/secret-tool" || elf_args+=(--root usr/libexec/secret-tool) while IFS= read -r elf; do elf_args+=(--root "${elf#"$extracted/"}"); done \ < <(find "$extracted/usr/lib" -type f -name 'libmpv.so.*' | sort) for root in usr/plugins usr/qml; do @@ -403,8 +409,8 @@ jobs: elf_args+=(--root "${elf#"$extracted/"}") done < <(find "$extracted/$root" -type f -name '*.so*' | sort) done - for soname in ld-linux-x86-64.so.2 libc.so.6 libdl.so.2 libm.so.6 libmvec.so.1 \ - libpthread.so.0 libresolv.so.2 librt.so.1 libutil.so.1 libGLESv2.so.2 libgbm.so.1 libglapi.so.0 + # glibc is bundled; only these come from the host. + for soname in libGLESv2.so.2 libgbm.so.1 libglapi.so.0 do elf_args+=(--allow-system "$soname") done @@ -420,7 +426,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-linux + name: spool-linux path: | dist/*.AppImage dist/*-linux-x86_64.tar.zst @@ -577,7 +583,7 @@ jobs: --parallel "$(sysctl -n hw.ncpu)" --output-on-failure - name: Lint compiled QML - run: nix develop .#native --no-write-lock-file -c cmake --build build/macos/app --target jellyfin-native_qmllint_strict + run: nix develop .#native --no-write-lock-file -c cmake --build build/macos/app --target spool_qmllint_strict - name: Load bundled Vulkan runtime run: | @@ -613,7 +619,7 @@ jobs: - name: Launch-test macOS app UI env: - JELLYFIN_LAUNCH_TEST_QPA_PLATFORM: cocoa + SPOOL_LAUNCH_TEST_QPA_PLATFORM: cocoa run: nix develop .#native --no-write-lock-file -c bash tools/launch-native-app.sh build/macos/install/Spool.app - name: Sign macOS application @@ -650,7 +656,7 @@ jobs: if [[ '${{ inputs.enable_macos_signing }}' == true ]]; then codesign --verify --strict --deep --verbose=2 "$app" fi - JELLYFIN_LAUNCH_TEST_QPA_PLATFORM=cocoa \ + SPOOL_LAUNCH_TEST_QPA_PLATFORM=cocoa \ bash tools/launch-native-app.sh "$app" bash tools/detach-dmg.sh "$mountpoint" trap - EXIT @@ -670,7 +676,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-macos-dmg-${{ matrix.arch }} + name: spool-macos-dmg-${{ matrix.arch }} path: | dist/*.dmg dist/*.dmg.app.tar.gz @@ -688,6 +694,117 @@ jobs: ~/Library/Caches/ccache key: ${{ steps.macos-build-cache.outputs.cache-primary-key }} + tvos: + name: tvOS (${{ matrix.sdk }} arm64) + runs-on: macos-15 + timeout-minutes: 180 + needs: [lint, toolchain] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_tvos }} + strategy: + fail-fast: false + matrix: + sdk: [appletvos, appletvsimulator] + env: + APPLE_SDK: ${{ matrix.sdk }} + APPLE_ARCH: arm64 + APPLE_DEPLOYMENT_TARGET: "16.0" + CODE_SIGNING_ALLOWED: "NO" + JOBS: "3" + BUILD_JOBS: "3" + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2 + with: + persist-credentials: false + submodules: recursive + - name: Install native source-build tools + run: brew install cmake ninja meson pkg-config bash python imagemagick gpatch + - name: Identify the exact Apple source toolchain + id: apple-toolchain + run: | + set -euo pipefail + # No prefix restore: patched source trees and configured cross-builds + # must never survive a source, SDK, compiler, or toolchain change. + identity="$( + { + xcodebuild -version + xcrun --sdk "$APPLE_SDK" --show-sdk-build-version + xcrun --sdk "$APPLE_SDK" --show-sdk-path + xcrun --sdk "$APPLE_SDK" clang --version + brew list --versions + git -C mpv rev-parse HEAD + } | shasum -a 256 + )" + echo "key=${identity%% *}" >>"$GITHUB_OUTPUT" + echo "QT_HOST_PATH=$GITHUB_WORKSPACE/build/apple/host-qt/${{ needs.toolchain.outputs.qt_version }}/macos" >>"$GITHUB_ENV" + - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3 + id: tvos-source-cache + with: + path: | + build/apple/host-qt + build/apple/qmlimportscanner + build/apple/downloads + build/apple/qt-sources + build/apple/sources + build/apple/qt-${{ matrix.sdk }}-arm64 + build/apple/${{ matrix.sdk }}-arm64 + build/downloads + key: tvos-source1-${{ runner.os }}-${{ runner.arch }}-${{ matrix.sdk }}-16.0-${{ steps.apple-toolchain.outputs.key }}-${{ hashFiles('tools/manifests/toolchain.json', 'tools/manifests/android-third-party.json', 'tools/manifests/ffmpeg-capabilities.json', 'tools/apple/**', 'tools/build-tvos.sh', 'tools/ffmpeg-capabilities.py', 'tools/lib/manifest-sources.sh', 'tools/webos-native/patches/qtdeclarative-*-qmlimportscanner-exclude-subtrees.patch', 'third_party/**', 'mpv/meson.build', 'mpv/meson.options') }} + - name: Install matching macOS Qt host tools + if: ${{ steps.tvos-source-cache.outputs.cache-hit != 'true' }} + env: + QT_VERSION: ${{ needs.toolchain.outputs.qt_version }} + run: | + python3 -m venv "$RUNNER_TEMP/aqt" + "$RUNNER_TEMP/aqt/bin/pip" install aqtinstall + "$RUNNER_TEMP/aqt/bin/aqt" install-qt mac desktop "$QT_VERSION" clang_64 \ + -O "$GITHUB_WORKSPACE/build/apple/host-qt" \ + -m qtshadertools qttasktree qtwebsockets qtimageformats + - name: Build actual source Qt, media closure, and tvOS app + run: bash tools/build-tvos.sh + - name: Exercise native Apple TV simulator UI and playback + if: ${{ matrix.sdk == 'appletvsimulator' }} + run: bash tools/apple/smoke-tvos.sh build/tvos/appletvsimulator-arm64/install/Spool.app + - name: Package validated unsigned device IPA + if: ${{ matrix.sdk == 'appletvos' }} + run: bash tools/package-tvos.sh build/tvos/appletvos-arm64/install/Spool.app dist/tvos + - name: Upload only the device release package + if: ${{ matrix.sdk == 'appletvos' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: spool-tvos-device-arm64 + path: dist/tvos/Spool-*-tvOS-arm64.ipa + if-no-files-found: error + retention-days: ${{ inputs.release && 1 || 7 }} + - name: Archive simulator proof without losing executable permissions + if: ${{ matrix.sdk == 'appletvsimulator' }} + run: | + tar -czf "$RUNNER_TEMP/tvos-simulator-arm64.tar.gz" \ + -C build/tvos/appletvsimulator-arm64 install + - name: Keep simulator artifacts internal + if: ${{ always() && matrix.sdk == 'appletvsimulator' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: internal-tvos-simulator-arm64 + path: | + ${{ runner.temp }}/tvos-simulator-arm64.tar.gz + build/tvos/smoke/*.png + build/tvos/smoke/result.json + retention-days: ${{ inputs.release && 1 || 7 }} + - name: Save coherent Apple dependency cache + if: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release && steps.tvos-source-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3 + with: + path: | + build/apple/host-qt + build/apple/qmlimportscanner + build/apple/downloads + build/apple/qt-sources + build/apple/sources + build/apple/qt-${{ matrix.sdk }}-arm64 + build/apple/${{ matrix.sdk }}-arm64 + build/downloads + key: ${{ steps.tvos-source-cache.outputs.cache-primary-key }} + webos-ipk: name: webOS IPK runs-on: ubuntu-24.04 @@ -798,7 +915,7 @@ jobs: test -n "$data_tar" tar -tf "$data_tar" | tee "$work/data-files.txt" >/dev/null grep -Eq '(^|/)appinfo\.json$' "$work/data-files.txt" - grep -Eq '(^|/)bin/jellyfin-native$' "$work/data-files.txt" + grep -Eq '(^|/)bin/spool$' "$work/data-files.txt" mkdir "$work/data" tar -xf "$data_tar" -C "$work/data" @@ -824,12 +941,12 @@ jobs: appinfo = json.load(handle) assert appinfo["id"] == "com.sachk.spool", appinfo.get("id") - assert appinfo["main"] == "bin/jellyfin-native", appinfo.get("main") + assert appinfo["main"] == "bin/spool", appinfo.get("main") assert re.fullmatch(r"\d+\.\d+\.\d+", appinfo["version"]), appinfo.get("version") print(f"Validated {appinfo['id']} {appinfo['version']} IPK") PY - launcher="$(find "$work/data" -path '*/bin/jellyfin-native' -type f -print -quit)" + launcher="$(find "$work/data" -path '*/bin/spool' -type f -print -quit)" test -n "$launcher" test -x "$launcher" python3 - "$launcher" <<'PY' @@ -843,9 +960,9 @@ jobs: header = handle.read(20) if header[:4] != b"\x7fELF": - binary = binary.with_name("jellyfin-native.real") - assert binary.is_file(), "launcher is not ELF and jellyfin-native.real is missing" - assert os.access(binary, os.X_OK), "jellyfin-native.real is not executable" + binary = binary.with_name("spool.real") + assert binary.is_file(), "launcher is not ELF and spool.real is missing" + assert os.access(binary, os.X_OK), "spool.real is not executable" with binary.open("rb") as handle: header = handle.read(20) @@ -860,7 +977,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-webos-ipk + name: spool-webos-ipk path: build/*.ipk if-no-files-found: error retention-days: ${{ inputs.release && 1 || 7 }} @@ -1028,7 +1145,7 @@ jobs: if: ${{ failure() && matrix.abi == 'x86_64' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-android-launch-test + name: spool-android-launch-test path: | build/android/launch-test build/android/emulator.log @@ -1036,7 +1153,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-android-apks-${{ matrix.abi }} + name: spool-android-apks-${{ matrix.abi }} path: dist/android/*.apk if-no-files-found: error retention-days: ${{ inputs.release && 1 || 7 }} @@ -1083,12 +1200,12 @@ jobs: # will wait for and one they will not. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: spool-for-jellyfin-android-apks-arm64-v8a + name: spool-android-apks-arm64-v8a path: dist/android - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: spool-for-jellyfin-android-apks-armeabi-v7a + name: spool-android-apks-armeabi-v7a path: dist/android - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -1152,7 +1269,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-android-apk-universal + name: spool-android-apk-universal path: dist/android/spool-universal.apk if-no-files-found: error retention-days: ${{ inputs.release && 1 || 7 }} @@ -1166,8 +1283,8 @@ jobs: env: QT_VERSION: ${{ needs.toolchain.outputs.qt_version }} QT_ARCH: win64_${{ needs.toolchain.outputs.qt_windows_kit }} - JELLYFIN_QT_ROOT: C:\Qt\${{ needs.toolchain.outputs.qt_version }}\${{ needs.toolchain.outputs.qt_windows_kit }} - JELLYFIN_QCORO_ROOT: C:\Qt\${{ needs.toolchain.outputs.qcoro_windows_prefix }} + SPOOL_QT_ROOT: C:\Qt\${{ needs.toolchain.outputs.qt_version }}\${{ needs.toolchain.outputs.qt_windows_kit }} + SPOOL_QCORO_ROOT: C:\Qt\${{ needs.toolchain.outputs.qcoro_windows_prefix }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2 @@ -1265,7 +1382,7 @@ jobs: - name: Upload portable executable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-windows-x64-portable + name: spool-windows-x64-portable path: | dist/*-Portable.exe if-no-files-found: error @@ -1274,7 +1391,7 @@ jobs: - name: Upload installer executable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-windows-x64-installer + name: spool-windows-x64-installer path: | dist/*-Setup.exe if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1aaf067..df8a35ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: - name: Upload corresponding source uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-corresponding-source + name: spool-corresponding-source path: release-source/* retention-days: 1 @@ -137,6 +137,7 @@ jobs: } add_row 'macOS — Apple Silicon' '*-macOS-arm64.dmg' add_row 'macOS — Intel' '*-macOS-x86_64.dmg' + add_row 'Apple TV — unsigned (re-sign required)' '*-tvOS-arm64.ipa' add_row 'Windows — installer' '*-Windows-x64-Setup.exe' add_row 'Windows — portable' '*-Windows-x64-Portable.exe' add_row 'Linux — AppImage' '*-x86_64.AppImage' @@ -167,6 +168,9 @@ jobs: if [[ "$MACOS_SIGNED" != true ]]; then echo 'The macOS DMG is **not Developer ID signed or notarized**.' fi + echo + echo 'The Apple TV IPA is **unsigned**: it has no Apple signing identity or provisioning profile, and tvOS will not install it as downloaded. It is not distributed through the App Store or TestFlight and has not been reviewed by Apple.' + echo "Build and sign from source with your own Apple Account, or use your own tvOS re-signing setup. See [Apple TV build and signing directions](https://github.com/$REPO/tree/$TAG#apple-tv)." } >release-notes.md - name: Publish GitHub prerelease env: @@ -280,7 +284,7 @@ jobs: fi gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --pattern SHA256SUMS.txt --dir "$RUNNER_TEMP" - bundle="Spool-for-Jellyfin-$version-linux-x86_64.tar.zst" + bundle="Spool-$version-linux-x86_64.tar.zst" checksum="$(awk -v want="$bundle" \ '{ sub(/^\*/, "", $2); if ($2 == want) print $1 }' \ "$RUNNER_TEMP/SHA256SUMS.txt")" diff --git a/.github/workflows/rescan-release-sboms.yml b/.github/workflows/rescan-release-sboms.yml index 75acc7ef..f30e85be 100644 --- a/.github/workflows/rescan-release-sboms.yml +++ b/.github/workflows/rescan-release-sboms.yml @@ -24,11 +24,11 @@ jobs: mkdir sboms tag="$(gh release list --exclude-drafts --limit 1 --json tagName --jq '.[0].tagName')" test -n "$tag" - gh release download "$tag" --pattern '*.cdx.json' --dir sboms - test -n "$(find sboms -type f -name '*.cdx.json' -print -quit)" + gh release download "$tag" --pattern '*.syft.json' --pattern 'ffmpeg-vex.json' --dir sboms + test -n "$(find sboms -type f -name '*.syft.json' -print -quit)" - name: Enforce vulnerability policy run: | set -euo pipefail - for sbom in sboms/*.cdx.json; do - "$RUNNER_TEMP/release-tools/grype" "sbom:$sbom" --fail-on high --only-fixed + for sbom in sboms/*.syft.json; do + "$RUNNER_TEMP/release-tools/grype" "sbom:$sbom" --vex sboms/ffmpeg-vex.json --fail-on high --only-fixed done diff --git a/.github/workflows/tvos.yml b/.github/workflows/tvos.yml new file mode 100644 index 00000000..8dfa1ef5 --- /dev/null +++ b/.github/workflows/tvos.yml @@ -0,0 +1,56 @@ +name: tvOS source build + +on: + workflow_dispatch: + push: + branches: [feature/tvos] + +permissions: + contents: read + +concurrency: + group: tvos-${{ github.ref }} + cancel-in-progress: true + +jobs: + simulator: + name: Qt mpv tvOS ${{ matrix.sdk }} build and smoke + runs-on: macos-15 + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + sdk: [appletvsimulator, appletvos] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + submodules: recursive + persist-credentials: false + - name: Install native build tools + run: brew install cmake ninja meson pkg-config bash python imagemagick gpatch + - name: Install pinned macOS Qt host tools + run: | + python3 -m venv "$RUNNER_TEMP/aqt" + "$RUNNER_TEMP/aqt/bin/pip" install aqtinstall + version="$(python3 -c 'import json;print(json.load(open("tools/manifests/toolchain.json"))["qt"]["version"])')" + "$RUNNER_TEMP/aqt/bin/aqt" install-qt mac desktop "$version" clang_64 \ + -O "$RUNNER_TEMP/Qt" -m qtshadertools qtwebsockets qtimageformats qttasktree + echo "QT_HOST_PATH=$RUNNER_TEMP/Qt/$version/macos" >> "$GITHUB_ENV" + - name: Build Qt and media libraries for Apple TV simulator + env: + APPLE_SDK: ${{ matrix.sdk }} + APPLE_ARCH: arm64 + JOBS: 3 + BUILD_JOBS: 3 + run: bash tools/build-tvos.sh + - name: Launch isolated Apple TV simulator smoke + if: matrix.sdk == 'appletvsimulator' + run: bash tools/apple/smoke-tvos.sh build/tvos/appletvsimulator-arm64/install/Spool.app + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: always() + with: + name: internal-tvos-${{ matrix.sdk }}-arm64 + path: | + build/tvos/${{ matrix.sdk }}-arm64/install/Spool.app + build/tvos/smoke/*.png + build/tvos/smoke/result.json diff --git a/.qmlls.ini b/.qmlls.ini index 86b224af..83ced9d7 100644 --- a/.qmlls.ini +++ b/.qmlls.ini @@ -1,4 +1,4 @@ [General] -; QML files live at qml/ and qml/*/, so include both relative depths. -buildDir=../build/linux-dev/app:../../build/linux-dev/app +; QML files live at qml/, qml/*/ and qml/providers/*/, so include every relative depth. +buildDir=../build/linux-dev/app:../../build/linux-dev/app:../../../build/linux-dev/app no-cmake-calls=true diff --git a/AGENTS.md b/AGENTS.md index 5fef2129..f7b3a52d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project -- Spool for Jellyfin client for webOS and desktop. +- Spool: a media player for webOS, desktop and Android. - Prefer a smaller, faster, easier-to-extend codebase over compatibility scaffolding or speculative abstractions. - The app is prerelease software: bump/reset caches on schema changes instead of adding migrations or fallback readers. - `mpv/` is a submodule. Commit mpv changes inside `mpv/`, then commit its pointer here. @@ -29,18 +29,33 @@ ## Module Seam -The goal is one core that several backends can sit on (spool-jellyfin, -spool-plex, spool-stremio). `CMakeLists.txt` groups sources into -`SPOOL_PLATFORM_SOURCES`, `SPOOL_PLAYER_SOURCES`, `SPOOL_SHELL_SOURCES` and -`SPOOL_JELLYFIN_SOURCES` to mark where that cut goes. They still build as one -library; the grouping exists so the split stays mechanical. +The app knows no media backend. Every source is a provider package (JS logic +and QML screens) run by `src/provider/`; `docs/providers.md` describes the +pieces and `sdk/` is the provider contract (API 0.2). Jellyfin, Emby, Plex and +Stremio live in their `spool-player/spool-*` repositories; Open Movies lives in +`spool-player/spool-provider-example`. All five are bundled from published release +pins in `providers/lock.json`. Providers and host cut over together; do not add +compatibility shims for older provider/host builds. -- Do not add a `src/api/` or `src/discovery/` include to the platform, player - or shell groups. Four such dependencies already exist and are the work a - split has to undo first: `PlayerController`, `PlaybackReporter`, - `PlayQueueController` and `SettingsController` each take a - `JellyfinApiFacade`, and `configurePlatformPlaybackCapabilities()` is - handed one. +- Core is everything under `src/` except `src/providers/` (native providers; + only `LocalProvider` today) and `src/main.cpp`, the composition root. Core + never includes `src/providers/`; `tools/check-module-seam.sh` is the + `module-seam` ctest. +- The app sees one `Provider`, `SourceHub`, which routes to every enabled + account. IDs leaving it are scoped (`<8 hex>:`) and stay opaque to + routes, caches and QML. Pages reach media through `Catalog`, + `SearchSource`, `UserItemStateSink`, `ArtworkSource`, `PlaybackSource` and + `StreamQualityControl`, never a provider type. +- Shared QML gates optional controls on the `ProviderCapabilities` singleton + (search, userItemState, playbackReporting, segments, groupPlayback, + remoteControl, streamQuality, trickplay): what the enabled accounts can do + between them. Provider-specific UI is the provider's own QML, mounted by + `shell/ProviderSurface` with a `ProviderUiContext`; item-menu entries come + from the manifest's `actions`. +- Group playback and remote control are generic (`GroupPlaybackController`, + `AppControllerRemote.cpp`); providers translate their protocol into + `group` and `remote` events. Do not touch `src/player` or + `qml/pages/Player*` for provider work beyond renames. - `qml/primitives` and `qml/theme` reach exactly two singletons, `Art.url` and `Settings.uiScalePercent`. Keep it that way; page- and shell-level QML is where backend-shaped data belongs. @@ -52,6 +67,12 @@ library; the grouping exists so the split stays mechanical. - `tools/manifests/toolchain.json` sets the Qt and FFmpeg versions for every platform. Nothing else may name one; `tools/toolchain-versions.sh --check` is what CI runs to keep the Qt module manifests honest. +- Qt source manifests use the official-listed FAU archive mirror rather than a + geo-selected redirect. Mirror changes must preserve version/module SHA-256 pins; + verify the full archive digest before changing a source URL. +- webOS GCC coroutine calls use named argument maps before `co_await`; nesting + a member expression inside a temporary argument aggregate triggered a compiler + internal error. Keep the wire arguments unchanged when restructuring such calls. - The launch screen carries the version, so it is rendered per build by `tools/generate-splash.sh` from `tools/manifests/splash.json` and is not committed. Configuring the app is enough to get it on every platform. @@ -63,10 +84,28 @@ library; the grouping exists so the split stays mechanical. - Build without launching with `nix run .#build`. - Run the test suite the way CI does with `nix run .#tests` (release build, then the same ctest invocation and exclusions as the workflow). - Leave interactive UI testing to the user. Do not drive their desktop with xdotool/xdgtool or similar input automation, or launch visible smoke tests unless explicitly requested. Use builds and isolated/offscreen checks for verification. -- Use `nix develop .#native -c ...` for targeted development commands (e.g. `cmake --preset linux-dev`, then `cmake --build build/linux-dev/app --target jellyfin-native`). +- Use `nix develop .#native -c ...` for targeted development commands (e.g. `cmake --preset linux-dev`, then `cmake --build build/linux-dev/app --target spool`). - The image-diagnostics equivalents remain `nix run .#image-debug-build` followed by `nix run .#image-debug`. - Batch coherent edits, then run one build and one `qmlformat`/`clang-format` invocation over all touched files; don't build or format file-by-file. +### Filtered preview diagnostics + +- Trace is opt-in: `QT_LOGGING_RULES='spool.provider.trace.debug=true'`. + Enable it only for the shortest necessary inspection, then unset it. +- Never print or share unfiltered app logs, diagnostics JSON, provider responses, + credential stores, URLs, request headers, or account configuration. Restoration + must use the real app's existing credential path, never pasted tokens. +- Always filter trace output before displaying it: select only `preview metadata`, + `preview session`, `preview fetch failed`, and `preview load failed` messages + from `spool.provider.trace`. Prefer an explicit field allowlist (item ID, + enabled, availability, format, dimensions/count, numeric network/status codes, + frame/speculative). Redact authentication, URL/query, user/account and path + values even if upstream logging is already sanitized. +- `spoolet` may inspect restored-account library rows and drive an explicitly + requested offscreen preview smoke. Filter JSON to the required titles, IDs, + player/preview state and dimensions. Never dump raw provider payloads or + support reports to discover preview availability. + ## webOS - Do not build, install, launch, deploy, or test on a TV unless the user explicitly requests it. "Deploy" means install-only; never launch unless separately asked. diff --git a/CMakeLists.txt b/CMakeLists.txt index 9900d5a8..47fa8700 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,41 +1,58 @@ cmake_minimum_required(VERSION 3.22) -file(READ "${CMAKE_CURRENT_LIST_DIR}/VERSION" JELLYFIN_VERSION) -string(STRIP "${JELLYFIN_VERSION}" JELLYFIN_VERSION) -if(NOT JELLYFIN_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") +file(READ "${CMAKE_CURRENT_LIST_DIR}/VERSION" SPOOL_VERSION) +string(STRIP "${SPOOL_VERSION}" SPOOL_VERSION) +if(NOT SPOOL_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") message(FATAL_ERROR "VERSION must contain a semantic version such as 0.2.1") endif() -project(JellyfinNativeWebOS VERSION "${JELLYFIN_VERSION}" LANGUAGES C CXX) +project(SpoolWebOS VERSION "${SPOOL_VERSION}" LANGUAGES C CXX) + +set(SPOOL_APPLE_MOBILE OFF) +if(CMAKE_SYSTEM_NAME STREQUAL "tvOS") + set(SPOOL_APPLE_MOBILE ON) +endif() if(WIN32) add_compile_definitions(NOMINMAX WIN32_LEAN_AND_MEAN) endif() -set(JELLYFIN_NATIVE_WEBOS_DEFAULT OFF) -if(CMAKE_CROSSCOMPILING AND NOT ANDROID) - set(JELLYFIN_NATIVE_WEBOS_DEFAULT ON) +set(SPOOL_WEBOS_DEFAULT OFF) +if(CMAKE_CROSSCOMPILING AND NOT ANDROID AND NOT SPOOL_APPLE_MOBILE) + set(SPOOL_WEBOS_DEFAULT ON) endif() -option(JELLYFIN_NATIVE_WEBOS "Build the webOS Starfish/native-surface client" ${JELLYFIN_NATIVE_WEBOS_DEFAULT}) +option(SPOOL_WEBOS "Build the webOS Starfish/native-surface client" ${SPOOL_WEBOS_DEFAULT}) option(TOUCHSCREEN "Enable mobile touchscreen interactions (runtime-disabled on TVs)" ${ANDROID}) -option(JELLYFIN_NATIVE_DEV_BUILD "Favor faster local development builds over smallest release artifacts" OFF) -option(JELLYFIN_DIAGNOSTICS "Build debug lifecycle/process/watchdog diagnostics" OFF) -option(JELLYFIN_DIAGNOSTICS_STACKDUMP "Allow diagnostics to request gdb stack dumps" OFF) -option(JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG "Abort after watchdog hang diagnostics are written" OFF) -option(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS "Log artwork decode requests whose target aspect would stretch the source" OFF) -option(JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS +option(SPOOL_DEV_BUILD "Favor faster local development builds over smallest release artifacts" OFF) +option(SPOOL_DIAGNOSTICS "Build debug lifecycle/process/watchdog diagnostics" OFF) +option(SPOOL_DIAGNOSTICS_STACKDUMP "Allow diagnostics to request gdb stack dumps" OFF) +option(SPOOL_DIAGNOSTICS_ABORT_ON_HANG "Abort after watchdog hang diagnostics are written" OFF) +option(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS "Log artwork decode requests whose target aspect would stretch the source" OFF) +option(SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS "Log image subtitle crop, segmentation, geometry, and recoloring per subtitle event" OFF) -option(JELLYFIN_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) +option(SPOOL_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) set(SPOOL_MACOS_CREDENTIAL_SERVICE "com.sachk.spool.dev" CACHE STRING "macOS Keychain service used for persisted session tokens") -option(JELLYFIN_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +option(SPOOL_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +# Where the app may take provider code from. Release builds are open; a build +# submitted to Google Play or the App Store is curated (store catalogues only) +# or bundled (no downloads). See docs/providers.md. +set(SPOOL_PROVIDER_SOURCES "open" CACHE STRING "Provider sources: open, curated or bundled") +set_property(CACHE SPOOL_PROVIDER_SOURCES PROPERTY STRINGS open curated bundled) +option(SPOOL_APPLE_APP_STORE "Bundle only providers approved for Apple App Store builds" OFF) +if(SPOOL_APPLE_APP_STORE) + set(SPOOL_PROVIDER_SOURCES "bundled" CACHE STRING "Provider sources: open, curated or bundled" FORCE) +endif() +if(NOT SPOOL_PROVIDER_SOURCES MATCHES "^(open|curated|bundled)$") + message(FATAL_ERROR "SPOOL_PROVIDER_SOURCES must be open, curated or bundled, not '${SPOOL_PROVIDER_SOURCES}'") +endif() include(CTest) set(MPV_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/mpv" CACHE PATH "mpv source tree") set(MPV_ACTIVE_SOURCE_DIR "${MPV_SOURCE_DIR}") set(MPV_WEBOS_BUILD_DIR "${MPV_SOURCE_DIR}/build/webos-libmpv" CACHE PATH "webOS libmpv build directory") -if(JELLYFIN_NATIVE_DEV_BUILD) +if(SPOOL_DEV_BUILD) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF) find_program(CCACHE_PROGRAM ccache) @@ -56,7 +73,7 @@ if(NOT WIN32) find_package(PkgConfig REQUIRED) endif() -set(JELLYFIN_QT_COMPONENTS +set(SPOOL_QT_COMPONENTS Core Gui Network @@ -69,30 +86,42 @@ set(JELLYFIN_QT_COMPONENTS WebSockets Svg ) -if(JELLYFIN_NATIVE_WEBOS) +if(SPOOL_WEBOS) set(OPENSSL_USE_STATIC_LIBS FALSE) - set(JELLYFIN_QT_MIN_VERSION 6.11) - list(APPEND JELLYFIN_QT_COMPONENTS + set(SPOOL_QT_MIN_VERSION 6.11) + list(APPEND SPOOL_QT_COMPONENTS GuiPrivate QuickLayouts WaylandClient ) else() - set(JELLYFIN_QT_MIN_VERSION 6.11) + set(SPOOL_QT_MIN_VERSION 6.11) # rhi/qrhi.h is how the video item reaches the graphics device it shares # with libplacebo, and how the swapchain is asked what the display can show. if(NOT ANDROID) - list(APPEND JELLYFIN_QT_COMPONENTS GuiPrivate) + list(APPEND SPOOL_QT_COMPONENTS GuiPrivate) endif() if(UNIX AND NOT APPLE AND NOT ANDROID) - list(APPEND JELLYFIN_QT_COMPONENTS DBus) + list(APPEND SPOOL_QT_COMPONENTS DBus) + endif() +endif() +find_package(Qt6 ${SPOOL_QT_MIN_VERSION} REQUIRED COMPONENTS ${SPOOL_QT_COMPONENTS}) +if(SPOOL_APPLE_MOBILE) + set(SPOOL_APPLE_QML_IMPORT_SCANNER "" CACHE FILEPATH "Patched pinned macOS QML import scanner") + if(NOT EXISTS "${SPOOL_APPLE_QML_IMPORT_SCANNER}") + message(FATAL_ERROR "Build tvOS with tools/build-tvos.sh to supply the patched host QML import scanner") endif() + set_target_properties(Qt6::qmlimportscanner PROPERTIES + IMPORTED_LOCATION "${SPOOL_APPLE_QML_IMPORT_SCANNER}" + IMPORTED_LOCATION_RELEASE "${SPOOL_APPLE_QML_IMPORT_SCANNER}" + IMPORTED_LOCATION_DEBUG "${SPOOL_APPLE_QML_IMPORT_SCANNER}" + IMPORTED_LOCATION_RELWITHDEBINFO "${SPOOL_APPLE_QML_IMPORT_SCANNER}" + IMPORTED_LOCATION_MINSIZEREL "${SPOOL_APPLE_QML_IMPORT_SCANNER}") endif() -find_package(Qt6 ${JELLYFIN_QT_MIN_VERSION} REQUIRED COMPONENTS ${JELLYFIN_QT_COMPONENTS}) find_package(QCoro6 0.13 REQUIRED COMPONENTS Core Network) qt_standard_project_setup( - REQUIRES ${JELLYFIN_QT_MIN_VERSION} + REQUIRES ${SPOOL_QT_MIN_VERSION} I18N_TRANSLATED_LANGUAGES en_US en_GB en_AU ) include(cmake/platform/Common.cmake) @@ -102,7 +131,7 @@ include(cmake/platform/Common.cmake) # configuring is enough to get it, so no platform needs a second command. if(ANDROID) set(spool_splash_platform "android") -elseif(JELLYFIN_NATIVE_WEBOS) +elseif(SPOOL_WEBOS) set(spool_splash_platform "webos") else() set(spool_splash_platform "desktop") @@ -130,17 +159,11 @@ endif() message(STATUS "${spool_splash_output}") -jellyfin_resolve_platform_dependencies() +spool_resolve_platform_dependencies() -# The source groups below mark the seam a spool-jellyfin / spool-plex / -# spool-stremio split would cut along. They all compile into one library for -# now; keeping them apart here is what makes the split mechanical later. -# -# The rule that matters: nothing in the platform, player or shell groups may -# include src/api/ or src/discovery/. Four places still break it and are the -# work a split has to do first — PlayerController, PlaybackReporter, -# PlayQueueController and SettingsController each take a JellyfinApiFacade, -# and configurePlatformPlaybackCapabilities() is handed one. +# Core never includes src/providers/ (native providers); every media source +# is reached through src/provider/. tools/check-module-seam.sh enforces it +# as the module-seam test. # Per-OS abstractions. No backend and no UI knowledge. set(SPOOL_PLATFORM_SOURCES @@ -193,14 +216,51 @@ set(SPOOL_PLAYER_SOURCES src/player/PlayerController.h src/player/RenderTargetProfile.cpp src/player/RenderTargetProfile.h + src/common/SeriesAudioSelection.cpp + src/common/SeriesAudioSelection.h ) # Application plumbing that does not name a media backend. set(SPOOL_SHELL_SOURCES + src/app/ArtworkImageProvider.cpp + src/app/ArtworkImageProvider.h + src/app/ArtworkPrefetcher.h + src/app/ArtworkService.cpp + src/app/ArtworkService.h + src/app/TrickplayDecoder.cpp + src/app/TrickplayDecoder.h + src/app/TrickplayService.cpp + src/app/TrickplayService.h + src/app/TrickplayPreviewItem.cpp + src/app/TrickplayPreviewItem.h + src/app/BrowseSessionController.cpp + src/app/BrowseSessionController.h + src/app/ContentModelController.cpp + src/app/ContentModelController.h + src/app/CollectionEditingController.cpp + src/app/CollectionEditingController.h + src/app/DownloadManager.cpp + src/app/DownloadManager.h + src/platform/AndroidDownloadStorage.cpp + src/platform/AndroidDownloadStorage.h + src/app/RemoteTargetsController.cpp + src/app/RemoteTargetsController.h + src/app/HomeModelController.cpp + src/app/HomeModelController.h + src/app/LocalThumbnail.cpp + src/app/LocalThumbnail.h + src/app/LibraryPrefetchController.cpp + src/app/LibraryPrefetchController.h + src/app/SearchController.cpp + src/app/SearchController.h + src/app/UserItemStateController.cpp + src/app/UserItemStateController.h src/app/CpuTopology.cpp src/app/CpuTopology.h src/app/LocalizationManager.cpp src/app/LocalizationManager.h + src/app/LibraryQuery.cpp + src/app/LibraryQuery.h src/app/MemoryBudget.cpp src/app/MemoryBudget.h src/app/RouterController.cpp @@ -209,8 +269,19 @@ set(SPOOL_SHELL_SOURCES src/app/SettingsController.h src/app/SettingsSchema.cpp src/app/SettingsSchema.h + src/app/SettingsSyncController.cpp + src/app/SettingsSyncController.h + src/app/SettingsSyncDocument.cpp + src/app/SettingsSyncDocument.h src/app/UpdateManifest.cpp src/app/UpdateManifest.h + src/app/AppController.cpp + src/app/AppController.h + src/app/AppControllerRemote.cpp + src/app/GroupClock.cpp + src/app/GroupClock.h + src/app/GroupPlaybackController.cpp + src/app/GroupPlaybackController.h src/cache/DatabaseManager.cpp src/cache/DatabaseManager.h src/common/AsyncTask.h @@ -221,6 +292,54 @@ set(SPOOL_SHELL_SOURCES src/common/TlsTrust.h src/common/VariantUtils.cpp src/common/VariantUtils.h + src/media/MediaTypes.cpp + src/media/MediaTypes.h + src/models/LibraryListModel.cpp + src/models/LibraryListModel.h + src/models/MovieGridModel.cpp + src/models/MovieGridModel.h + src/provider/ArtworkSource.h + src/provider/Catalog.h + src/provider/GroupPlayback.h + src/provider/PlaybackSource.h + src/provider/PortableProvider.cpp + src/provider/PortableProvider.h + src/provider/Provider.h + src/provider/ProviderCapabilities.h + src/provider/ProviderExtensions.cpp + src/provider/ProviderExtensions.h + src/provider/ProviderExtensionData.cpp + src/provider/ProviderExtensionData.h + src/provider/ProviderLogging.h + src/provider/ProviderMediaPage.cpp + src/provider/ProviderMediaPage.h + src/provider/ProviderPackage.cpp + src/provider/ProviderPackage.h + src/provider/ProviderQmlCache.cpp + src/provider/ProviderQmlCache.h + src/provider/ProviderRegistry.cpp + src/provider/ProviderRegistry.h + src/provider/ProviderStore.cpp + src/provider/ProviderStore.h + src/provider/ProviderUiContext.cpp + src/provider/ProviderUiContext.h + src/provider/ScriptBridge.cpp + src/provider/ScriptBridge.h + src/provider/LanProbe.cpp + src/provider/LanProbe.h + src/provider/ScriptRuntime.cpp + src/provider/ScriptRuntime.h + src/provider/SpeedTest.cpp + src/provider/SpeedTest.h + src/provider/SearchSource.h + src/provider/DownloadSource.h + src/provider/SourceHub.cpp + src/provider/SourceHubRemote.cpp + src/provider/SourceHub.h + src/provider/StreamQualityControl.h + src/provider/UserItemStateSink.h + third_party/zstd/bounded_zstd.c + third_party/zstd/bounded_zstd.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h src/diagnostics/InputLatencyMonitor.cpp @@ -231,94 +350,73 @@ set(SPOOL_SHELL_SOURCES src/diagnostics/SystemPerformanceMonitor.h ) -# Jellyfin REST, server discovery, and the controllers and models shaped -# by them. This is the group a second backend would grow a sibling to. -set(SPOOL_JELLYFIN_SOURCES - src/api/HttpRequestPolicy.h - src/api/ItemsQuery.cpp - src/api/ItemsQuery.h - src/api/JellyfinApiFacade.cpp - src/api/JellyfinApiFacade.h - src/api/PlaybackBandwidthPolicy.cpp - src/api/PlaybackBandwidthPolicy.h - src/api/PlaybackNegotiation.cpp - src/api/PlaybackNegotiation.h - src/app/AccountProfile.cpp - src/app/AccountProfile.h - src/app/AppController.cpp - src/app/AppController.h - src/app/ArtworkImageProvider.cpp - src/app/ArtworkImageProvider.h - src/app/ArtworkPrefetcher.h - src/app/ArtworkService.cpp - src/app/ArtworkService.h - src/app/BrowseSessionController.cpp - src/app/BrowseSessionController.h - src/app/ContentModelController.cpp - src/app/ContentModelController.h - src/app/HomeModelController.cpp - src/app/HomeModelController.h - src/app/LibraryManagementController.cpp - src/app/LibraryManagementController.h - src/app/LibraryPrefetchController.cpp - src/app/LibraryPrefetchController.h - src/app/LibraryQuery.cpp - src/app/LibraryQuery.h - src/app/QuickConnectController.cpp - src/app/QuickConnectController.h - src/app/RemoteControlController.cpp - src/app/RemoteControlController.h - src/app/SpoolLink.cpp - src/app/SpoolLink.h - src/app/SpoolLinkCodec.cpp - src/app/SpoolLinkCodec.h - src/app/SpoolRemoteProtocol.cpp - src/app/SpoolRemoteProtocol.h - src/app/SearchController.cpp - src/app/SearchController.h - src/app/SessionController.cpp - src/app/SessionController.h - src/app/SyncPlayClock.cpp - src/app/SyncPlayClock.h - src/app/SyncPlayController.cpp - src/app/SyncPlayController.h - src/app/UserItemStateController.cpp - src/app/UserItemStateController.h - src/common/JellyfinTypes.cpp - src/common/JellyfinTypes.h - src/common/SeriesAudioSelection.cpp - src/common/SeriesAudioSelection.h - src/discovery/DiscoveryController.cpp - src/discovery/DiscoveryController.h - src/models/DiscoveredServerModel.cpp - src/models/DiscoveredServerModel.h - src/models/LibraryListModel.cpp - src/models/LibraryListModel.h - src/models/MovieGridModel.cpp - src/models/MovieGridModel.h +# The one native provider: media in a folder, and the contract's fixture. +set(SPOOL_LOCAL_SOURCES + src/providers/local/LocalProvider.cpp + src/providers/local/LocalProvider.h ) -set(JELLYFIN_CORE_SOURCES +set(SPOOL_CORE_SOURCES ${SPOOL_PLATFORM_SOURCES} ${SPOOL_PLAYER_SOURCES} ${SPOOL_SHELL_SOURCES} - ${SPOOL_JELLYFIN_SOURCES} + ${SPOOL_LOCAL_SOURCES} ) -add_library(jellyfin-core STATIC ${JELLYFIN_CORE_SOURCES}) +add_library(spool-core STATIC ${SPOOL_CORE_SOURCES}) + +# Raw preview decoding uses the same libjpeg selected by the Qt toolchain, +# never a bundled second codec. The compact scene-graph textures use QRhi. +find_package(Qt6 REQUIRED COMPONENTS GuiPrivate) +find_package(Qt6BundledLibjpeg QUIET CONFIG) +if(TARGET Qt6::BundledLibjpeg) + find_package(Qt6 REQUIRED COMPONENTS JpegPrivate) + target_link_libraries(spool-core PUBLIC Qt6::BundledLibjpeg Qt6::JpegPrivate Qt6::GuiPrivate) + target_compile_definitions(spool-core PUBLIC SPOOL_QT_BUNDLED_JPEG=1) +else() + find_package(JPEG REQUIRED) + target_link_libraries(spool-core PUBLIC JPEG::JPEG Qt6::GuiPrivate) +endif() -qt_add_executable(jellyfin-native +qt_add_executable(spool src/main.cpp src/app/GraphicsStartup.cpp src/app/GraphicsStartup.h ) -set(JELLYFIN_NATIVE_TARGETS jellyfin-core jellyfin-native) -jellyfin_configure_platform_targets(jellyfin-native jellyfin-core) -foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) +if(NOT ANDROID AND NOT SPOOL_WEBOS AND NOT SPOOL_APPLE_MOBILE) + target_sources(spool PRIVATE + src/automation/LocalCommandServer.cpp + src/automation/LocalCommandServer.h + src/automation/LocalControlProtocol.cpp + src/automation/LocalControlProtocol.h + ) + add_executable(spoolet + src/automation/SpooletMain.cpp + src/automation/LocalControlProtocol.cpp + src/automation/LocalControlProtocol.h + ) + target_link_libraries(spoolet PRIVATE Qt6::Core Qt6::Network) + target_include_directories(spoolet PRIVATE src) + if(UNIX AND NOT APPLE) + set_target_properties(spoolet PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") + endif() + install(TARGETS spoolet RUNTIME DESTINATION bin) +endif() + +find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) +include(cmake/ProviderBundle.cmake) +spool_bundle_providers(spool) + +set(SPOOL_TARGETS spool-core spool) +if(TARGET spoolet) + list(APPEND SPOOL_TARGETS spoolet) +endif() +spool_configure_platform_targets(spool spool-core) +foreach(target IN LISTS SPOOL_TARGETS) if(MSVC) target_compile_options(${target} PRIVATE /W4) - if(JELLYFIN_WARNINGS_AS_ERRORS) + if(SPOOL_WARNINGS_AS_ERRORS) target_compile_options(${target} PRIVATE /WX) endif() target_link_options(${target} PRIVATE @@ -327,35 +425,35 @@ foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) ) elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic) - if(JELLYFIN_WARNINGS_AS_ERRORS) + if(SPOOL_WARNINGS_AS_ERRORS) target_compile_options(${target} PRIVATE -Werror) endif() - if(JELLYFIN_SANITIZERS) + if(SPOOL_SANITIZERS) target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) target_link_options(${target} PRIVATE -fsanitize=address,undefined) endif() endif() endforeach() -if(JELLYFIN_SANITIZERS AND MSVC) - message(FATAL_ERROR "JELLYFIN_SANITIZERS requires GCC or Clang") +if(SPOOL_SANITIZERS AND MSVC) + message(FATAL_ERROR "SPOOL_SANITIZERS requires GCC or Clang") endif() -if(NOT JELLYFIN_NATIVE_DEV_BUILD) +if(NOT SPOOL_DEV_BUILD) include(CheckIPOSupported) - check_ipo_supported(RESULT JELLYFIN_IPO_SUPPORTED OUTPUT JELLYFIN_IPO_ERROR) - if(JELLYFIN_IPO_SUPPORTED) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) + check_ipo_supported(RESULT SPOOL_IPO_SUPPORTED OUTPUT SPOOL_IPO_ERROR) + if(SPOOL_IPO_SUPPORTED) + foreach(target IN LISTS SPOOL_TARGETS) set_property(TARGET ${target} PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) set_property(TARGET ${target} PROPERTY INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL TRUE) endforeach() else() - message(STATUS "Interprocedural optimization unavailable: ${JELLYFIN_IPO_ERROR}") + message(STATUS "Interprocedural optimization unavailable: ${SPOOL_IPO_ERROR}") endif() if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) + foreach(target IN LISTS SPOOL_TARGETS) target_compile_options(${target} PRIVATE -ffunction-sections -fdata-sections) if(ANDROID) target_compile_options(${target} PRIVATE -fPIC) @@ -363,7 +461,7 @@ if(NOT JELLYFIN_NATIVE_DEV_BUILD) elseif(UNIX AND NOT APPLE) if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm|ARM)") target_compile_options(${target} PRIVATE -fPIC) - if(target STREQUAL "jellyfin-native") + if(target STREQUAL "spool") target_link_options(${target} PRIVATE -fPIC) endif() else() @@ -390,45 +488,45 @@ endif() # translation unit against the new base class while leaving the moc output # generated for the old one in place. That mismatch fails the build with # `use of undeclared identifier 'QQuickFramebufferObject'`. -if(ANDROID OR JELLYFIN_NATIVE_WEBOS) - set(JELLYFIN_MPV_ITEM_RHI_ENABLED 0) +if(ANDROID OR SPOOL_WEBOS OR SPOOL_APPLE_MOBILE) + set(SPOOL_MPV_ITEM_RHI_ENABLED 0) else() - set(JELLYFIN_MPV_ITEM_RHI_ENABLED 1) + set(SPOOL_MPV_ITEM_RHI_ENABLED 1) endif() file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated/MpvVideoItemBase.h" CONTENT "// Generated by CMake. Do not edit. #pragma once -#define JELLYFIN_MPV_ITEM_RHI @JELLYFIN_MPV_ITEM_RHI_ENABLED@ +#define SPOOL_MPV_ITEM_RHI @SPOOL_MPV_ITEM_RHI_ENABLED@ " @ONLY) -foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) +foreach(target IN LISTS SPOOL_TARGETS) target_include_directories(${target} PUBLIC "${CMAKE_CURRENT_BINARY_DIR}/generated") endforeach() -if(JELLYFIN_DIAGNOSTICS) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_DIAGNOSTICS=1) +if(SPOOL_DIAGNOSTICS) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_DIAGNOSTICS=1) endforeach() endif() -if(JELLYFIN_DIAGNOSTICS_STACKDUMP) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_DIAGNOSTICS_STACKDUMP=1) +if(SPOOL_DIAGNOSTICS_STACKDUMP) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_DIAGNOSTICS_STACKDUMP=1) endforeach() endif() -if(JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG=1) +if(SPOOL_DIAGNOSTICS_ABORT_ON_HANG) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_DIAGNOSTICS_ABORT_ON_HANG=1) endforeach() endif() -if(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS=1) +if(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_ARTWORK_ASPECT_DIAGNOSTICS=1) endforeach() endif() -if(JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS=1) +if(SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS=1) endforeach() endif() @@ -441,23 +539,23 @@ set_source_files_properties( PROPERTIES QT_QML_SINGLETON_TYPE TRUE ) -qt_add_translations(jellyfin-native +qt_add_translations(spool TS_FILES i18n/qtfin_en_US.ts i18n/qtfin_en_GB.ts RESOURCE_PREFIX /i18n ) -set(JELLYFIN_QML_MODULE_OPTIONS) +set(SPOOL_QML_MODULE_OPTIONS) if(QT6_IS_SHARED_LIBS_BUILD) - list(APPEND JELLYFIN_QML_MODULE_OPTIONS NO_IMPORT_SCAN) + list(APPEND SPOOL_QML_MODULE_OPTIONS NO_IMPORT_SCAN) else() - list(APPEND JELLYFIN_QML_MODULE_OPTIONS DISCARD_QML_CONTENTS) + list(APPEND SPOOL_QML_MODULE_OPTIONS DISCARD_QML_CONTENTS) endif() -qt_add_qml_module(jellyfin-native - ${JELLYFIN_QML_MODULE_OPTIONS} - URI JellyfinWebOS +qt_add_qml_module(spool + ${SPOOL_QML_MODULE_OPTIONS} + URI Spool VERSION 1.0 QML_FILES qml/Main.qml @@ -466,20 +564,17 @@ qt_add_qml_module(jellyfin-native qml/browse/RowStackView.qml qml/pages/HomePage.qml qml/pages/ItemDetailsPage.qml + qml/pages/CollectionEditorPage.qml + qml/pages/LocalFolders.qml + qml/pages/RemoteControlPage.qml qml/pages/LibraryGridPage.qml qml/pages/LibraryNavigation.js qml/shell/ItemActivation.js qml/primitives/ModelAccess.js - qml/pages/LoginPage.qml - qml/pages/LoginAccountStep.qml - qml/pages/LoginBrandPanel.qml - qml/pages/LoginProfileDialogs.qml - qml/pages/LoginProfilePicker.qml - qml/pages/LoginServerStep.qml - qml/pages/ProfileNavigation.js qml/pages/OpenSourceNoticesPage.qml qml/pages/PersonDetailsPage.qml qml/pages/PlayerBrowsePanel.qml + qml/pages/SettingsSyncPage.qml qml/pages/PlayerAudioSyncPanel.qml qml/pages/PlayerMusicStage.qml qml/pages/PlayerOverlayChrome.qml @@ -492,16 +587,17 @@ qt_add_qml_module(jellyfin-native qml/pages/PlayerStatsPanel.qml qml/pages/PlayerTransportBar.qml qml/pages/PlayerTrickplayPreview.qml - qml/pages/RemoteControlPage.qml qml/pages/SearchPage.qml qml/pages/SettingsPage.qml qml/pages/SettingsNavigation.js qml/pages/SubtitleSettingsPanel.qml + qml/pages/AccountsPage.qml + qml/pages/AddProviderPage.qml + qml/pages/ProviderScreenPage.qml qml/primitives/ActionButton.qml qml/primitives/AppText.qml qml/primitives/AtomicViewReveal.qml qml/primitives/BusySpinner.qml - qml/primitives/ChoiceStrip.qml qml/primitives/ConfirmationDialog.qml qml/primitives/EmptyPlaceholder.qml qml/primitives/FastWheelHandler.qml @@ -527,7 +623,9 @@ qt_add_qml_module(jellyfin-native qml/primitives/PopupMenuPanel.qml qml/primitives/PopupShield.qml qml/primitives/ProfileTile.qml + qml/primitives/ProviderIcon.qml qml/primitives/SectionHeader.qml + qml/primitives/SourceBadge.qml qml/primitives/ServerCard.qml qml/primitives/SelectRow.qml qml/primitives/SettingRow.qml @@ -542,20 +640,29 @@ qt_add_qml_module(jellyfin-native qml/shell/KeyRouter.qml qml/shell/BusyOverlay.qml qml/shell/DiagnosticsOverlay.qml + qml/shell/GroupMenu.qml qml/shell/InputLatencyWarning.qml qml/shell/ItemContextMenu.qml - qml/shell/ManagementDialog.qml + qml/shell/DownloadsDialog.qml qml/shell/RememberedCertificatesDialog.qml qml/shell/MediaInfoOverlay.qml qml/shell/OverlayDialog.qml + qml/shell/ProviderSurface.qml + qml/shell/ServerLogin.qml + qml/shell/ServerIdentityRow.qml + qml/shell/ProviderCodePanel.qml + qml/shell/ProviderLinkScreen.qml + qml/shell/ProviderCompatibilityNotice.qml + qml/shell/ProviderActionPicker.qml + qml/shell/ProviderRemoteControls.qml + qml/shell/RemoteNowPlaying.qml + qml/shell/ProviderUpdatePrompt.qml qml/shell/PageReadiness.js qml/shell/RoutePolicy.js qml/shell/RouteStack.qml - qml/shell/RemoteControlMenu.qml - qml/shell/RemoteNowPlayingBar.qml - qml/shell/SyncPlayMenu.qml qml/shell/TlsTrustDialog.qml qml/shell/UpdateDialog.qml + qml/shell/ProviderInstallDialog.qml qml/shell/ToastLayer.qml qml/shell/TopBar.qml qml/shell/VideoSurface.qml @@ -566,20 +673,31 @@ qt_add_qml_module(jellyfin-native # Qt Quick consumes baked shader packages on every RHI backend. Keep the # resource independent of the QML module so the early startup frame can use it. -qt_add_shaders(jellyfin-native "hdr_ui_shaders" +qt_add_shaders(spool "hdr_ui_shaders" PREFIX "/shaders" BASE "${CMAKE_CURRENT_SOURCE_DIR}/qml/shell/shaders" FILES qml/shell/shaders/sdr-ui-to-scrgb.frag ) -if(NOT JELLYFIN_NATIVE_WEBOS) - qt_target_qml_sources(jellyfin-native + +qt_add_shaders(spool "trickplay_preview_shaders" + PREFIX "/trickplay/shaders" + BASE "${CMAKE_CURRENT_SOURCE_DIR}/src/app/shaders" + BATCHABLE + GLSL "100 es,120,150" + FILES + src/app/shaders/preview.vert + src/app/shaders/rgb.frag + src/app/shaders/yuv.frag +) +if(NOT SPOOL_WEBOS) + qt_target_qml_sources(spool QML_FILES qml/pages/DesktopFolderDialog.qml ) endif() -if(JELLYFIN_NATIVE_WEBOS) - qt_add_resources(jellyfin-native "platform_branding" +if(SPOOL_WEBOS) + qt_add_resources(spool "platform_branding" PREFIX "/branding" BASE "${CMAKE_CURRENT_SOURCE_DIR}/app/icons/png/spool" FILES app/icons/png/spool/64.png @@ -588,7 +706,7 @@ endif() # The mark is part of the interface now, not only the desktop entry, so every # platform carries its SVG source and requires QtSvg to render it. -qt_add_resources(jellyfin-native "application_icons" +qt_add_resources(spool "application_icons" PREFIX "/icons" BASE "${CMAKE_CURRENT_SOURCE_DIR}/app/icons" FILES @@ -608,17 +726,17 @@ if(ANDROID) # does. See splashCoreWidthDp() in main.cpp. string(JSON spool_splash_tv_dp GET "${spool_splash_manifest}" android tvCoreWidthDp) string(JSON spool_splash_phone_dp GET "${spool_splash_manifest}" android phoneCoreWidthDp) - target_compile_definitions(jellyfin-native PRIVATE + target_compile_definitions(spool PRIVATE SPOOL_SPLASH_CORE_WIDTH_DP_TV=${spool_splash_tv_dp} SPOOL_SPLASH_CORE_WIDTH_DP_PHONE=${spool_splash_phone_dp} ) endif() -target_compile_definitions(jellyfin-native PRIVATE +target_compile_definitions(spool PRIVATE SPOOL_SPLASH_CORE_ASPECT=${spool_splash_core_width}.0/${spool_splash_core_height}.0 SPOOL_SPLASH_CORE_WIDTH_FRACTION=${spool_splash_fraction} ) -qt_add_resources(jellyfin-native "startup_splash_qml" +qt_add_resources(spool "startup_splash_qml" PREFIX "/startup" BASE "${CMAKE_CURRENT_SOURCE_DIR}/qml/shell" FILES @@ -627,8 +745,8 @@ qt_add_resources(jellyfin-native "startup_splash_qml" qml/shell/HdrUiLayer.qml ) # webOS reads its launch screen off disk, where appinfo.json already names it. -if(NOT JELLYFIN_NATIVE_WEBOS) - qt_add_resources(jellyfin-native "startup_splash_image" +if(NOT SPOOL_WEBOS) + qt_add_resources(spool "startup_splash_image" PREFIX "/startup" BASE "${SPOOL_SPLASH_DIR}" FILES "${SPOOL_SPLASH_DIR}/splash-core.png" @@ -636,7 +754,7 @@ if(NOT JELLYFIN_NATIVE_WEBOS) endif() if(ANDROID) - qt_add_resources(jellyfin-native "android_fonts" + qt_add_resources(spool "android_fonts" PREFIX "/fonts" BASE "${CMAKE_CURRENT_SOURCE_DIR}/qml/fonts" FILES @@ -649,26 +767,26 @@ if(ANDROID) endif() -add_custom_target(jellyfin-native_qmllint_strict +add_custom_target(spool_qmllint_strict COMMAND Qt6::qmllint --unqualified disable --missing-property disable --property-override warning - "@${CMAKE_CURRENT_BINARY_DIR}/.rcc/qmllint/jellyfin-native.rsp" + "@${CMAKE_CURRENT_BINARY_DIR}/.rcc/qmllint/spool.rsp" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" COMMENT "Linting QML property overrides" VERBATIM ) -add_dependencies(jellyfin-native_qmllint_strict all_qmltyperegistrations) +add_dependencies(spool_qmllint_strict all_qmltyperegistrations) -set_target_properties(jellyfin-native PROPERTIES +set_target_properties(spool PROPERTIES QT_QMLCACHEGEN_DIRECT_CALLS ON ) # qmlimportscanner also scans the target's SOURCE_DIR recursively. This project # keeps build trees and vendored sources below the repository root, so exclude # them and let the generated QRC files provide the application's exact QML set. -set_property(TARGET jellyfin-native APPEND PROPERTY +set_property(TARGET spool APPEND PROPERTY QT_QML_IMPORT_SCANNER_EXTRA_ARGS -exclude build -exclude build-arm @@ -679,33 +797,38 @@ set_property(TARGET jellyfin-native APPEND PROPERTY -exclude squashfs-root -exclude third_party ) -if(JELLYFIN_NATIVE_WEBOS) - set_property(TARGET jellyfin-native APPEND PROPERTY +if(SPOOL_WEBOS) + set_property(TARGET spool APPEND PROPERTY QT_QML_IMPORT_SCANNER_EXTRA_ARGS -exclude qml/pages/DesktopFolderDialog.qml ) endif() +if(TARGET spool-tvos-playback-smoke) + get_target_property(SPOOL_APPLE_SMOKE_SCAN_ARGS spool QT_QML_IMPORT_SCANNER_EXTRA_ARGS) + set_property(TARGET spool-tvos-playback-smoke PROPERTY + QT_QML_IMPORT_SCANNER_EXTRA_ARGS ${SPOOL_APPLE_SMOKE_SCAN_ARGS}) +endif() -get_target_property(JELLYFIN_NATIVE_INCLUDE_DIRS jellyfin-native INCLUDE_DIRECTORIES) -if(JELLYFIN_NATIVE_INCLUDE_DIRS) - list(REMOVE_ITEM JELLYFIN_NATIVE_INCLUDE_DIRS +get_target_property(SPOOL_INCLUDE_DIRS spool INCLUDE_DIRECTORIES) +if(SPOOL_INCLUDE_DIRS) + list(REMOVE_ITEM SPOOL_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}" ) - set_property(TARGET jellyfin-native PROPERTY - INCLUDE_DIRECTORIES "${JELLYFIN_NATIVE_INCLUDE_DIRS}" + set_property(TARGET spool PROPERTY + INCLUDE_DIRECTORIES "${SPOOL_INCLUDE_DIRS}" ) endif() -target_include_directories(jellyfin-core PUBLIC +target_include_directories(spool-core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src" "${CMAKE_CURRENT_SOURCE_DIR}/src/player" "${MPV_ACTIVE_SOURCE_DIR}" "${MPV_ACTIVE_SOURCE_DIR}/include" ) -target_link_libraries(jellyfin-core PUBLIC +target_link_libraries(spool-core PUBLIC QCoro::Core QCoro::Network Qt6::Core @@ -717,76 +840,102 @@ target_link_libraries(jellyfin-core PUBLIC Qt6::OpenGL Qt6::WebSockets ) +if(ANDROID) + target_link_libraries(spool-core PUBLIC Qt6::CorePrivate) +endif() if(NOT ANDROID) - target_link_libraries(jellyfin-core PUBLIC Qt6::GuiPrivate) + target_link_libraries(spool-core PUBLIC Qt6::GuiPrivate) endif() -target_link_libraries(jellyfin-native PRIVATE - jellyfin-core +target_link_libraries(spool PRIVATE + spool-core Qt6::Svg ) if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) + if(TARGET spoolet) + find_package(Threads REQUIRED) + add_executable(spoolet-protocol-tests + tests/automation/LocalControlProtocolTest.cpp + src/automation/LocalControlProtocol.cpp + ) + target_include_directories(spoolet-protocol-tests PRIVATE src) + target_link_libraries(spoolet-protocol-tests PRIVATE Qt6::Core Qt6::Network Threads::Threads) + add_test(NAME spoolet-protocol COMMAND spoolet-protocol-tests) + endif() # Tests are grouped into one executable per directory. Each test keeps its # own ctest entry; tests/TestRunner.cpp dispatches on the selector passed # by add_test(). One executable per test used to cost more to build on # Windows than the application itself, because every one of them repeated a - # whole-program-optimized link against all of jellyfin-core. - set(JELLYFIN_TEST_GROUPS api app cache common diagnostics discovery platform player) - - set(JELLYFIN_TESTS_api - http-request-policy tests/api/HttpRequestPolicyTest.cpp - playback-bandwidth-policy tests/api/PlaybackBandwidthPolicyTest.cpp - items-query tests/api/ItemsQueryTest.cpp - playback-negotiation tests/api/PlaybackNegotiationTest.cpp - jellyfin-api-facade-url tests/api/JellyfinApiFacadeUrlTest.cpp - ) - set(JELLYFIN_TESTS_app + # whole-program-optimized link against all of spool-core. + set(SPOOL_TEST_GROUPS app cache common diagnostics platform player providers) + + set(SPOOL_TESTS_app browse-session-controller tests/app/BrowseSessionControllerTest.cpp settings-schema tests/app/SettingsSchemaTest.cpp settings-controller tests/app/SettingsControllerTest.cpp - session-controller tests/app/SessionControllerTest.cpp + settings-sync-controller tests/app/SettingsSyncControllerTest.cpp + settings-sync-document tests/app/SettingsSyncDocumentTest.cpp router-controller tests/app/RouterControllerTest.cpp library-query tests/app/LibraryQueryTest.cpp + library-list-model tests/app/LibraryListModelTest.cpp content-model-controller tests/app/ContentModelControllerTest.cpp - remote-control-controller tests/app/RemoteControlControllerTest.cpp - spool-remote-protocol tests/app/SpoolRemoteProtocolTest.cpp - spool-link-codec tests/app/SpoolLinkCodecTest.cpp - sync-play-clock tests/app/SyncPlayClockTest.cpp - sync-play-queue-handoff tests/app/SyncPlayQueueHandoffTest.cpp - sync-play-drift-policy tests/app/SyncPlayDriftPolicyTest.cpp + collection-editing tests/app/CollectionEditingControllerTest.cpp + download-manager tests/app/DownloadManagerTest.cpp + remote-targets tests/app/RemoteTargetsControllerTest.cpp + search-controller tests/app/SearchControllerTest.cpp + group-clock tests/app/GroupClockTest.cpp + group-playback-policy tests/app/GroupPlaybackPolicyTest.cpp update-manifest tests/app/UpdateManifestTest.cpp artwork-url tests/app/ArtworkUrlTest.cpp + trickplay tests/app/TrickplayTest.cpp ) - set(JELLYFIN_TESTS_cache + set(SPOOL_TESTS_cache database-manager tests/cache/DatabaseManagerTest.cpp ) - set(JELLYFIN_TESTS_common + set(SPOOL_TESTS_common request-generation tests/common/RequestGenerationTest.cpp diagnostic-redaction tests/common/DiagnosticRedactionTest.cpp tls-trust tests/common/TlsTrustTest.cpp browse-descriptor tests/common/BrowseDescriptorTest.cpp meta-json tests/common/MetaJsonTest.cpp ) - set(JELLYFIN_TESTS_diagnostics + set(SPOOL_TESTS_diagnostics input-latency-monitor tests/diagnostics/InputLatencyMonitorTest.cpp + route-transition-timing tests/diagnostics/RouteTransitionTimingTest.cpp system-performance-monitor tests/diagnostics/SystemPerformanceMonitorTest.cpp ) - set(JELLYFIN_TESTS_discovery - discovery-controller tests/discovery/DiscoveryControllerTest.cpp - ) - set(JELLYFIN_TESTS_platform + set(SPOOL_TESTS_platform screensaver-inhibitor tests/platform/ScreenSaverInhibitorTest.cpp native-window-input tests/platform/NativeAppWindowInputTest.cpp platform-paths tests/platform/PlatformPathsTest.cpp ) if(APPLE) - list(APPEND JELLYFIN_TESTS_platform + list(APPEND SPOOL_TESTS_platform macos-titlebar tests/platform/MacOSTitlebarTest.mm ) endif() - set(JELLYFIN_TESTS_player + set(SPOOL_TESTS_providers + local-provider tests/providers/LocalProviderTest.cpp + script-runtime tests/providers/ScriptRuntimeTest.cpp + provider-logging tests/providers/ProviderLoggingTest.cpp + script-runtime-network tests/providers/ScriptRuntimeNetworkTest.cpp + provider-media-page tests/providers/ProviderMediaPageTest.cpp + provider-registry tests/providers/ProviderRegistryTest.cpp + provider-activation tests/providers/ProviderActivationTest.cpp + artwork-authentication tests/providers/ArtworkAuthenticationTest.cpp + provider-extension-data tests/providers/ProviderExtensionDataTest.cpp + source-hub tests/providers/SourceHubTest.cpp + source-hub-speed-test tests/providers/SourceHubSpeedTest.cpp + provider-store tests/providers/ProviderStoreTest.cpp + provider-ui-context tests/providers/ProviderUiContextTest.cpp + provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp + provider-package-unpack tests/providers/ProviderPackageTest.cpp + stream-quality-control tests/providers/StreamQualityControlTest.cpp + live-jellyfin tests/providers/LiveJellyfinTest.cpp + ) + set(SPOOL_TESTS_player playback-timeline tests/player/PlaybackTimelineTest.cpp playback-track-parser tests/player/PlaybackTrackParserTest.cpp playback-track-state tests/player/PlaybackTrackStateTest.cpp @@ -795,6 +944,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) mpv-option-profile tests/player/MpvOptionProfileTest.cpp render-target-profile tests/player/RenderTargetProfileTest.cpp playback-failure-policy tests/player/PlaybackFailurePolicyTest.cpp + player-controller tests/player/PlayerControllerTest.cpp play-queue-controller tests/player/PlayQueueControllerTest.cpp play-queue-outline-model tests/player/PlayQueueOutlineModelTest.cpp series-audio-selection tests/player/SeriesAudioSelectionTest.cpp @@ -802,47 +952,52 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # Both of these assert nothing away from Linux: the memory policy parses # /proc and cgroup text, and the mpv item needs a real GL surface. if(UNIX AND NOT APPLE) - list(APPEND JELLYFIN_TESTS_platform + list(APPEND SPOOL_TESTS_platform linux-memory-policy tests/platform/LinuxMemoryPolicyTest.cpp ) - list(APPEND JELLYFIN_TESTS_player + list(APPEND SPOOL_TESTS_player mpv-video-item tests/player/MpvVideoItemTest.cpp ) # The same check over the Vulkan scene graph, which is the one an HDR # swapchain can be asked for. Its own entry rather than a loop inside # the test, so a machine with no Vulkan driver fails only this one. - list(APPEND JELLYFIN_TESTS_player + list(APPEND SPOOL_TESTS_player mpv-video-item-vulkan tests/player/MpvVideoItemTest.cpp + mpv-video-item-fullscreen tests/player/MpvFullscreenTest.cpp + mpv-video-item-fullscreen-vulkan tests/player/MpvFullscreenTest.cpp ) endif() # WebOSAudioSyncPolicy is a webOS platform source, so desktop builds of - # jellyfin-core do not carry it; the player group compiles it directly. - set(JELLYFIN_TEST_EXTRA_SOURCES_player src/platform/webos/WebOSAudioSyncPolicy.cpp) + # spool-core do not carry it; the player group compiles it directly. + set(SPOOL_TEST_EXTRA_SOURCES_player src/platform/webos/WebOSAudioSyncPolicy.cpp) - get_target_property(JELLYFIN_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION_RELEASE) - if(NOT JELLYFIN_QT_CORE_LOCATION) - get_target_property(JELLYFIN_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION) + get_target_property(SPOOL_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION_RELEASE) + if(NOT SPOOL_QT_CORE_LOCATION) + get_target_property(SPOOL_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION) endif() - set(JELLYFIN_TEST_ENV) - if(JELLYFIN_QT_CORE_LOCATION) - get_filename_component(JELLYFIN_QT_LIB_DIR "${JELLYFIN_QT_CORE_LOCATION}" DIRECTORY) - get_filename_component(JELLYFIN_QT_PREFIX "${JELLYFIN_QT_LIB_DIR}" DIRECTORY) - set(JELLYFIN_QT_PLUGIN_DIR "${JELLYFIN_QT_PREFIX}/lib/qt-6/plugins") - if(EXISTS "${JELLYFIN_QT_PLUGIN_DIR}") - list(APPEND JELLYFIN_TEST_ENV "QT_PLUGIN_PATH=${JELLYFIN_QT_PLUGIN_DIR}") + # Tests load both bundled resources with reproducible timestamps and + # mutable fixture packages. Keep every test process independent of the + # persistent QML/JavaScript cache, not only the provider test group. + set(SPOOL_TEST_ENV "QML_DISABLE_DISK_CACHE=1") + if(SPOOL_QT_CORE_LOCATION) + get_filename_component(SPOOL_QT_LIB_DIR "${SPOOL_QT_CORE_LOCATION}" DIRECTORY) + get_filename_component(SPOOL_QT_PREFIX "${SPOOL_QT_LIB_DIR}" DIRECTORY) + set(SPOOL_QT_PLUGIN_DIR "${SPOOL_QT_PREFIX}/lib/qt-6/plugins") + if(EXISTS "${SPOOL_QT_PLUGIN_DIR}") + list(APPEND SPOOL_TEST_ENV "QT_PLUGIN_PATH=${SPOOL_QT_PLUGIN_DIR}") endif() endif() - foreach(test_group IN LISTS JELLYFIN_TEST_GROUPS) + foreach(test_group IN LISTS SPOOL_TEST_GROUPS) set(group_target "${test_group}-tests") - set(group_sources tests/TestRunner.cpp ${JELLYFIN_TEST_EXTRA_SOURCES_${test_group}}) + set(group_sources tests/TestRunner.cpp ${SPOOL_TEST_EXTRA_SOURCES_${test_group}}) set(group_names) - list(LENGTH JELLYFIN_TESTS_${test_group} group_entry_count) + list(LENGTH SPOOL_TESTS_${test_group} group_entry_count) math(EXPR group_entry_last "${group_entry_count} - 1") foreach(test_index RANGE 0 ${group_entry_last} 2) math(EXPR source_index "${test_index} + 1") - list(GET JELLYFIN_TESTS_${test_group} ${test_index} test_name) - list(GET JELLYFIN_TESTS_${test_group} ${source_index} test_source) + list(GET SPOOL_TESTS_${test_group} ${test_index} test_name) + list(GET SPOOL_TESTS_${test_group} ${source_index} test_source) list(APPEND group_sources "${test_source}") list(APPEND group_names "${test_name}") endforeach() @@ -859,27 +1014,43 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) INTERPROCEDURAL_OPTIMIZATION OFF INTERPROCEDURAL_OPTIMIZATION_RELEASE OFF INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL OFF) - target_link_libraries(${group_target} PRIVATE jellyfin-core) + target_link_libraries(${group_target} PRIVATE spool-core) foreach(test_name IN LISTS group_names) add_test(NAME ${test_name} COMMAND ${group_target} ${test_name}) - if(JELLYFIN_TEST_ENV) - set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "${JELLYFIN_TEST_ENV}") + if(SPOOL_TEST_ENV) + set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "${SPOOL_TEST_ENV}") endif() endforeach() endforeach() - find_package(Python3 REQUIRED COMPONENTS Interpreter) - if(JELLYFIN_NATIVE_WEBOS) - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM webos) + + add_test(NAME provider-media-page-interpreter COMMAND providers-tests provider-media-page) + set_tests_properties(provider-media-page-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1;${SPOOL_TEST_ENV}") + target_sources(providers-tests PRIVATE ${SPOOL_PROVIDER_BUNDLE_SOURCES}) + add_test(NAME provider-sdk-tool + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") + add_test(NAME local-provider-overrides + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/LocalProviderOverridesTest.py") + add_executable(provider-contract-runner sdk/provider-contract-runner.cpp) + target_link_libraries(provider-contract-runner PRIVATE Qt6::Core Qt6::Qml) + target_sources(provider-contract-runner PRIVATE ${SPOOL_PROVIDER_BUNDLE_SOURCES}) + add_test(NAME bundled-jellyfin + COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") + add_test(NAME bundled-jellyfin-interpreter + COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") + set_tests_properties(bundled-jellyfin bundled-jellyfin-interpreter PROPERTIES ENVIRONMENT "QML_DISABLE_DISK_CACHE=1") + set_property(TEST bundled-jellyfin-interpreter APPEND PROPERTY ENVIRONMENT "QV4_FORCE_INTERPRETER=1") + if(SPOOL_WEBOS) + set(SPOOL_FFMPEG_AUDIT_PLATFORM webos) elseif(APPLE) - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM macos) + set(SPOOL_FFMPEG_AUDIT_PLATFORM macos) elseif(WIN32) - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM windows) + set(SPOOL_FFMPEG_AUDIT_PLATFORM windows) else() - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM linux) + set(SPOOL_FFMPEG_AUDIT_PLATFORM linux) endif() - set(JELLYFIN_FFMPEG_CAPABILITY_HEADER + set(SPOOL_FFMPEG_CAPABILITY_HEADER "${CMAKE_CURRENT_BINARY_DIR}/generated/FfmpegCapabilities.h") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg-capabilities.py" @@ -888,13 +1059,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg-capabilities.py" cpp-header - --platform "${JELLYFIN_FFMPEG_AUDIT_PLATFORM}" - --output "${JELLYFIN_FFMPEG_CAPABILITY_HEADER}" - RESULT_VARIABLE JELLYFIN_FFMPEG_HEADER_RESULT - ERROR_VARIABLE JELLYFIN_FFMPEG_HEADER_ERROR + --platform "${SPOOL_FFMPEG_AUDIT_PLATFORM}" + --output "${SPOOL_FFMPEG_CAPABILITY_HEADER}" + RESULT_VARIABLE SPOOL_FFMPEG_HEADER_RESULT + ERROR_VARIABLE SPOOL_FFMPEG_HEADER_ERROR ) - if(NOT JELLYFIN_FFMPEG_HEADER_RESULT EQUAL 0) - message(FATAL_ERROR "FFmpeg capability header generation failed: ${JELLYFIN_FFMPEG_HEADER_ERROR}") + if(NOT SPOOL_FFMPEG_HEADER_RESULT EQUAL 0) + message(FATAL_ERROR "FFmpeg capability header generation failed: ${SPOOL_FFMPEG_HEADER_ERROR}") endif() if(NOT WIN32) @@ -915,8 +1086,8 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # prefix is the documented way to make the lookup happen again; the # capability manifest is already a configure dependency, so this is what # makes the audit follow it. - get_cmake_property(JELLYFIN_CACHE_VARIABLES CACHE_VARIABLES) - foreach(cache_variable IN LISTS JELLYFIN_CACHE_VARIABLES) + get_cmake_property(SPOOL_CACHE_VARIABLES CACHE_VARIABLES) + foreach(cache_variable IN LISTS SPOOL_CACHE_VARIABLES) if(cache_variable MATCHES "^(pkgcfg_lib_FFMPEG_AUDIT_|__pkg_config_checked_FFMPEG_AUDIT|FFMPEG_AUDIT_)") unset(${cache_variable} CACHE) endif() @@ -942,10 +1113,16 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg-capabilities.py" "${CMAKE_CURRENT_SOURCE_DIR}/tools/manifests/ffmpeg-capabilities.json" ) - add_test(NAME package-audit - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/PackageAuditTest.py" - "${CMAKE_CURRENT_SOURCE_DIR}/tools/package-audit.py" + # Builds ELF fixtures with cc; the AppImage is the only package it audits. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_test(NAME package-audit + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/PackageAuditTest.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/package-audit.py" + ) + endif() + add_test(NAME module-seam + COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tools/check-module-seam.sh" ) add_test(NAME network-shaper @@ -962,7 +1139,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) find_program(QMLTESTRUNNER_EXECUTABLE NAMES qmltestrunner) if(QMLTESTRUNNER_EXECUTABLE) - set(JELLYFIN_QML_TESTS + set(SPOOL_QML_TESTS route-policy tests/qml/tst_RoutePolicy.qml key-router tests/qml/tst_KeyRouter.qml player-overlay-input tests/qml/tst_PlayerOverlayInput.qml @@ -978,8 +1155,10 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) viewport-focus-recovery tests/qml/tst_ViewportFocusRecovery.qml hold-navigation-controller tests/qml/tst_HoldNavigationController.qml library-navigation tests/qml/tst_LibraryNavigation.qml - profile-navigation tests/qml/tst_ProfileNavigation.qml settings-navigation tests/qml/tst_SettingsNavigation.qml + remote-control-page tests/qml/tst_RemoteControlPage.qml + provider-install-dialog tests/qml/tst_ProviderInstallDialog.qml + provider-shell tests/qml/tst_ProviderShell.qml settings-disclosure tests/qml/tst_SettingsDisclosure.qml settings-row-identity tests/qml/tst_SettingsRowIdentity.qml atomic-view-reveal tests/qml/tst_AtomicViewReveal.qml @@ -991,84 +1170,143 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # The subtitle panel test has never run on macOS; it was narrowed to # Windows alone while chasing that, which silently dropped Linux too. if(NOT APPLE) - list(APPEND JELLYFIN_QML_TESTS + list(APPEND SPOOL_QML_TESTS subtitle-settings-panel tests/qml/tst_SubtitleSettingsPanel.qml ) endif() - get_filename_component(JELLYFIN_QML_PREFIX "${Qt6Qml_DIR}/../../.." ABSOLUTE) - set(JELLYFIN_QML_IMPORT_DIR "${JELLYFIN_QML_PREFIX}/lib/qt-6/qml") - set(JELLYFIN_QML_TEST_ENV + # These tests instantiate production surfaces and primitives that need + # registered singletons or native items. Filesystem imports bypass the + # application's generated singleton qmldirs and native registrations. + find_package(Qt6 REQUIRED COMPONENTS QuickTest) + qt_add_executable(spool-qml-tests tests/QmlTestRunner.cpp tests/ExtensionIntegration.h tests/ArtworkIntegration.h) + target_compile_definitions(spool-qml-tests PRIVATE TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_link_libraries(spool-qml-tests PRIVATE spool-core Qt6::QuickTest) + if(QT6_IS_SHARED_LIBS_BUILD) + set_property(TARGET spool-qml-tests PROPERTY QT_QML_MODULE_NO_IMPORT_SCAN TRUE) + else() + get_target_property(SPOOL_TEST_SCAN_ARGS spool QT_QML_IMPORT_SCANNER_EXTRA_ARGS) + set_property(TARGET spool-qml-tests PROPERTY QT_QML_IMPORT_SCANNER_EXTRA_ARGS ${SPOOL_TEST_SCAN_ARGS}) + endif() + get_target_property(SPOOL_APP_SOURCES spool SOURCES) + set(SPOOL_TEST_PLATFORM_SOURCES ${SPOOL_APP_SOURCES}) + list(FILTER SPOOL_TEST_PLATFORM_SOURCES INCLUDE REGEX "/[A-Za-z]+PlatformCapabilities\\.cpp$") + set(SPOOL_TEST_QML_RESOURCES ${SPOOL_APP_SOURCES}) + list(FILTER SPOOL_TEST_QML_RESOURCES INCLUDE REGEX "/qrc_(qmake_Spool|application_icons|trickplay_preview_shaders|spool_raw_qml_[0-9]+(_extra_qmldirs)?)\\.cpp$") + # Reuse the application's generated resources rather than declaring a + # second Spool module (which would also overwrite qrc_qmake_Spool.cpp). + target_sources(spool-qml-tests PRIVATE ${SPOOL_TEST_PLATFORM_SOURCES} ${SPOOL_TEST_QML_RESOURCES}) + qt_add_resources(spool-qml-tests "component_qml_tests" + PREFIX "/qt/qml/Spool" + FILES + tests/qml/tst_MediaRow.qml + tests/qml/tst_SubtitleSettingsPanel.qml + tests/qml/tst_RemoteControlPage.qml + tests/qml/tst_ProviderInstallDialog.qml + tests/qml/tst_ProviderShell.qml + tests/qml/tst_ExtensionIntegration.qml + tests/qml/tst_ProviderForms.qml + tests/qml/tst_ArtworkIntegration.qml + tests/qml/tst_PlayerSeekBarHover.qml + ) + + get_filename_component(SPOOL_QML_PREFIX "${Qt6Qml_DIR}/../../.." ABSOLUTE) + set(SPOOL_QML_IMPORT_DIR "${SPOOL_QML_PREFIX}/lib/qt-6/qml") + set(SPOOL_QML_TEST_ENV "QT_QPA_PLATFORM=offscreen" "QT_QUICK_BACKEND=software" - "QML2_IMPORT_PATH=${JELLYFIN_QML_IMPORT_DIR}" - "QML_IMPORT_PATH=${JELLYFIN_QML_IMPORT_DIR}" - ${JELLYFIN_TEST_ENV} + "QML_DISABLE_DISK_CACHE=1" + "QML2_IMPORT_PATH=${SPOOL_QML_IMPORT_DIR}" + "QML_IMPORT_PATH=${SPOOL_QML_IMPORT_DIR}" + ${SPOOL_TEST_ENV} ) - list(LENGTH JELLYFIN_QML_TESTS JELLYFIN_QML_TEST_COUNT) - math(EXPR JELLYFIN_QML_TEST_LAST "${JELLYFIN_QML_TEST_COUNT} - 1") - foreach(qml_index RANGE 0 ${JELLYFIN_QML_TEST_LAST} 2) + add_test(NAME extension-integration + COMMAND spool-qml-tests -input ":/qt/qml/Spool/tests/qml/tst_ExtensionIntegration.qml") + set_tests_properties(extension-integration PROPERTIES + ENVIRONMENT "${SPOOL_QML_TEST_ENV};SPOOL_EXTENSION_INTEGRATION=1" TIMEOUT 90) + add_test(NAME provider-forms + COMMAND spool-qml-tests -input ":/qt/qml/Spool/tests/qml/tst_ProviderForms.qml") + set_tests_properties(provider-forms PROPERTIES ENVIRONMENT "${SPOOL_QML_TEST_ENV}" TIMEOUT 30) + add_test(NAME artwork-integration + COMMAND spool-qml-tests -input ":/qt/qml/Spool/tests/qml/tst_ArtworkIntegration.qml") + set_tests_properties(artwork-integration PROPERTIES + ENVIRONMENT "${SPOOL_QML_TEST_ENV};SPOOL_ARTWORK_INTEGRATION=1" TIMEOUT 30) + + list(LENGTH SPOOL_QML_TESTS SPOOL_QML_TEST_COUNT) + math(EXPR SPOOL_QML_TEST_LAST "${SPOOL_QML_TEST_COUNT} - 1") + foreach(qml_index RANGE 0 ${SPOOL_QML_TEST_LAST} 2) math(EXPR qml_source_index "${qml_index} + 1") - list(GET JELLYFIN_QML_TESTS ${qml_index} qml_test_name) - list(GET JELLYFIN_QML_TESTS ${qml_source_index} qml_test_source) - add_test(NAME ${qml_test_name} - COMMAND "${QMLTESTRUNNER_EXECUTABLE}" - -input "${CMAKE_CURRENT_SOURCE_DIR}/${qml_test_source}" - ) - set_tests_properties(${qml_test_name} PROPERTIES ENVIRONMENT "${JELLYFIN_QML_TEST_ENV}") + list(GET SPOOL_QML_TESTS ${qml_index} qml_test_name) + list(GET SPOOL_QML_TESTS ${qml_source_index} qml_test_source) + if(qml_test_name STREQUAL "subtitle-settings-panel" + OR qml_test_name STREQUAL "remote-control-page" OR qml_test_name STREQUAL "provider-install-dialog" + OR qml_test_name STREQUAL "provider-shell" OR qml_test_name STREQUAL "player-seek-bar-hover" + OR qml_test_name STREQUAL "media-row") + add_test(NAME ${qml_test_name} + COMMAND spool-qml-tests -input ":/qt/qml/Spool/${qml_test_source}" + ) + else() + add_test(NAME ${qml_test_name} + COMMAND "${QMLTESTRUNNER_EXECUTABLE}" + -input "${CMAKE_CURRENT_SOURCE_DIR}/${qml_test_source}" + ) + endif() + set_tests_properties(${qml_test_name} PROPERTIES ENVIRONMENT "${SPOOL_QML_TEST_ENV}") endforeach() + set_tests_properties(provider-shell PROPERTIES ENVIRONMENT "${SPOOL_QML_TEST_ENV};SPOOL_EXTENSION_INTEGRATION=1") endif() endif() # Static Qt: import plugins that the linker can't auto-discover. BUILD_SHARED_LIBS # controls this project's libraries, not whether the imported Qt build is static. -get_target_property(JELLYFIN_QT_CORE_TARGET_TYPE Qt6::Core TYPE) -if(JELLYFIN_QT_CORE_TARGET_TYPE STREQUAL "STATIC_LIBRARY") - jellyfin_import_static_platform_plugins(jellyfin-native) +get_target_property(SPOOL_QT_CORE_TARGET_TYPE Qt6::Core TYPE) +if(SPOOL_QT_CORE_TARGET_TYPE STREQUAL "STATIC_LIBRARY") + spool_import_static_platform_plugins(spool) # Let qmlimportscanner derive the static QML plugin link set from the # application's imports. New imports now fail when their plugin is absent. - qt_import_qml_plugins(jellyfin-native) + qt_import_qml_plugins(spool) # Add linker GC to strip unused code from static libs. # macOS ld64 doesn't accept --gc-sections (uses -dead_strip instead, on by # default at -O), so only apply this on non-Apple platforms. if(NOT APPLE) - target_link_options(jellyfin-native PRIVATE + target_link_options(spool PRIVATE "-Wl,--gc-sections" ) - target_compile_options(jellyfin-native PRIVATE + target_compile_options(spool PRIVATE "-ffunction-sections" "-fdata-sections" ) endif() endif() -set_target_properties(jellyfin-native PROPERTIES - OUTPUT_NAME "jellyfin-native" +set_target_properties(spool PROPERTIES + OUTPUT_NAME "spool" ) if(APPLE) - set_property(TARGET jellyfin-native PROPERTY OUTPUT_NAME "Spool") + set_property(TARGET spool PROPERTY OUTPUT_NAME "Spool") endif() if(UNIX AND NOT APPLE AND NOT ANDROID) - set_property(TARGET jellyfin-native PROPERTY INSTALL_RPATH "$ORIGIN/../lib") + set_property(TARGET spool PROPERTY INSTALL_RPATH "$ORIGIN/../lib") endif() -target_compile_definitions(jellyfin-native PRIVATE - JELLYFIN_VERSION="${PROJECT_VERSION}" +target_compile_definitions(spool PRIVATE + SPOOL_VERSION="${PROJECT_VERSION}" + SPOOL_PROVIDER_SOURCES="${SPOOL_PROVIDER_SOURCES}" $<$:TOUCHSCREEN> ) -install(TARGETS jellyfin-native +install(TARGETS spool LIBRARY DESTINATION lib BUNDLE DESTINATION . RUNTIME DESTINATION bin ) -if(NOT ANDROID) - set(JELLYFIN_FONT_INSTALL_DIRECTORY "fonts") +if(NOT ANDROID AND NOT SPOOL_APPLE_MOBILE) + set(SPOOL_FONT_INSTALL_DIRECTORY "fonts") if(APPLE) - set(JELLYFIN_FONT_INSTALL_DIRECTORY "Spool.app/Contents/fonts") + set(SPOOL_FONT_INSTALL_DIRECTORY "Spool.app/Contents/fonts") endif() install(FILES qml/fonts/AtkinsonHyperlegible-Bold.otf @@ -1076,11 +1314,11 @@ if(NOT ANDROID) qml/fonts/IBMPlexSans-Variable.ttf qml/fonts/PTRootUI-Variable.ttf qml/fonts/MaterialIcons-Regular.ttf - DESTINATION "${JELLYFIN_FONT_INSTALL_DIRECTORY}" + DESTINATION "${SPOOL_FONT_INSTALL_DIRECTORY}" ) endif() -if(UNIX AND NOT APPLE AND NOT ANDROID AND NOT JELLYFIN_NATIVE_WEBOS) +if(UNIX AND NOT APPLE AND NOT ANDROID AND NOT SPOOL_WEBOS) install(FILES app/com.sachk.spool.desktop DESTINATION share/applications ) diff --git a/CMakePresets.json b/CMakePresets.json index e049486b..f0f5cb9c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -11,7 +11,7 @@ "hidden": true, "generator": "Ninja", "cacheVariables": { - "JELLYFIN_NATIVE_WEBOS": "OFF" + "SPOOL_WEBOS": "OFF" } }, { @@ -20,7 +20,7 @@ "binaryDir": "${sourceDir}/build/linux-dev/app", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo", - "JELLYFIN_NATIVE_DEV_BUILD": "ON" + "SPOOL_DEV_BUILD": "ON" } }, { @@ -29,7 +29,7 @@ "binaryDir": "${sourceDir}/build/linux-release/app", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "JELLYFIN_NATIVE_DEV_BUILD": "OFF" + "SPOOL_DEV_BUILD": "OFF" } }, { @@ -46,9 +46,9 @@ "binaryDir": "${sourceDir}/build/windows-release/app", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_PREFIX_PATH": "$env{JELLYFIN_WINDOWS_PREFIX_PATH}", - "JELLYFIN_NATIVE_DEV_BUILD": "OFF", - "MPV_ROOT": "$env{JELLYFIN_MPV_ROOT}" + "CMAKE_PREFIX_PATH": "$env{SPOOL_WINDOWS_PREFIX_PATH}", + "SPOOL_DEV_BUILD": "OFF", + "MPV_ROOT": "$env{SPOOL_MPV_ROOT}" } } ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6cce9c8..ef8e9403 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Spool for Jellyfin welcomes focused bug reports and feature proposals. Before submitting code, open or find an issue for consequential behavior changes so the user-facing contract is clear. +Spool welcomes focused bug reports and feature proposals. Before submitting code, open or find an issue for consequential behavior changes so the user-facing contract is clear. ## Privacy @@ -32,7 +32,7 @@ Describe the problem, the chosen behavior, affected platforms, privacy/security ### Reviewing CI artifacts Open the PR's **build artifacts** check, follow **Details** to the workflow run, -and download a `spool-for-jellyfin-*` package from **Artifacts** (GitHub sign-in +and download a `spool-*` package from **Artifacts** (GitHub sign-in required). PRs use the same build, package, audit, and launch-test jobs as releases: Linux AppImage/portable tarball/Arch package, macOS DMGs for Apple Silicon and Intel, webOS IPK, Android per-ABI/universal APKs, and Windows diff --git a/README.md b/README.md index 8d26f2dc..d3099081 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Spool for Jellyfin +# Spool - libmpv: We've forked this and made it compatible in the directory above. keep libmpv behind a thin PlayerController / PlaybackController facade and do not let Jellyfin/network/UI code know about mpv internals. - Qt6.11 @@ -11,6 +11,301 @@ 10-foot / remote navigation: build around GridView / ListView + FocusScope + KeyNavigation + Keys. KeyNavigation is specifically for arrow/tab-based focus jumps, and FocusScope exists to keep reusable focus regions sane, which is exactly the problem space for D-pad TV UIs. HTTP asset caching: QNetworkDiskCache for posters, backdrops, and image responses. It is basic, but it plugs directly into QNetworkAccessManager; just remember it is basic by design and defaults to a 50 MB limit, so you will probably want to raise that. +## Local sibling providers + +Keep the app checkout and provider repositories together, for example +`~/Documents/spool/spool` and `~/Documents/spool/`. From the app +checkout, these commands discover providers in immediate sibling directories +with valid `manifest.json` files; repository folder names do not matter: + +```sh +nix run .#local-providers -- --dry-run # Show discovery and paths; no build or launch +nix run .#local-providers # Build the checkout and launch natively +nix run .#local-providers-build # Build natively without launching +nix run .#local-providers-ipk # Full local IPK build; no TV install or launch +``` + +The build-only and IPK commands also accept `-- --dry-run`. Discovery refuses +parents with more than 200 immediate directories and duplicate provider IDs. +These workflows enforce bundled-only provider loading and use discovered +checkouts instead of their locked versions; undiscovered locked providers retain +their pins. Native builds always use the working checkout, even on a clean Git +revision, rather than an immutable Cachix app package. They rebuild incrementally +on every invocation so sibling edits are included, using separate +`build/linux-release-local-providers` or `build/macos-local-providers` outputs. +Ordinary native builds continue to default to open provider loading without +local overrides. Set `SPOOL_REPO` to the app checkout when invoking outside it. + +Use the Nix launcher rather than invoking the installed binary from an unrelated +development shell: it supplies the matching Qt plugins and, on Linux, +`libsecret`'s `secret-tool`. Account configuration stays in the platform +credential store; the SQLite database contains account metadata, not a copy of +the credentials. If a scripted provider's saved configuration is missing, +unreadable, malformed JSON, or not a JSON object, that account reports a failed +connection and offers sign-in instead of starting with unavailable settings. +A stored empty JSON object (`{}`) is available configuration: public or anonymous +providers can restore it normally. Providers remain responsible for validating +their own authentication configuration; an empty object does not bypass +first-party activation validation. +Unlock the credential store and restart Spool, or sign in again. Changing or +removing another account does not overwrite unavailable credential records. +A missing SQLite driver fails startup without resetting account data or clearing +the credential store. + +Ordinary startup opens Home, combining the selected saved viewer from every +independent server. **Profiles & servers** in the navigation bar groups saved +profiles by server and lets you switch who's watching, or add another watching +profile through that provider's sign-in screen. A switch stays on the profile +page while it connects or asks for a PIN; cancellation or failure keeps the +previous viewer and offers retry/reconnect instead of pretending the switch +succeeded. Protected profiles that need interaction stay locked on startup. +Search uses only the viewers selected for Home, never the combined permissions +of an adult and child profile saved on the same server. + +### Offline downloads + +Open an item's menu and choose **Download…**. Original media is offered where +the item's provider supports it; **Server-converted** quality choices appear +only when that provider offers a complete server-encoded file. Spool never +encodes media on the device or saves an online playlist as an offline movie. +**Settings → Downloads** shows preparation, byte progress, completion and +actionable errors, with Cancel, Retry, Play offline and Remove actions. + +Completed media and its local metadata persist across restarts and account +removal. The built-in **Downloads** LocalProvider library plays the local copy +without server headers or an active connection; offline resume/played state is +stored locally. Interrupted operations become retryable failures after restart +and discard incomplete files. A retry negotiates fresh access with the provider. + +Desktop defaults to `Movies/Spool` and has a native folder chooser. On Android, +the destination control is under Advanced: choose internal/external app storage +or a Storage Access Framework folder with a persisted read/write grant. +Document providers must permit creation/rename and provide seekable media; +revoked grants or unavailable storage surface errors rather than buffering a +copy into memory. iOS/tvOS use app-managed sandbox storage without an unsupported +folder chooser; webOS defaults to app-managed writable storage and accepts an +accessible folder path. Changing destination affects new downloads only. +Downloaded media can be large: check free space and remove copies when finished. + +### Built-in local automation (`spoolet`) + +Native desktop builds also build/install `spoolet`, a small Qt Core/Network CLI +from this checkout, with no browser or external control service. The ordinary +Spool desktop app starts its private local command server automatically: + +```sh +nix run .#local-providers-build +nix run .#local-providers -- --instance plex-check +# In another terminal; uses the already-built checkout, without rebuilding: +nix run .#spoolet -- help +nix run .#spoolet -- instances +nix run .#spoolet -- --instance plex-check status +nix run .#spoolet -- --instance plex-check navigate home +nix run .#spoolet -- --instance plex-check key right +nix run .#spoolet -- --instance plex-check key ok +nix run .#spoolet -- --instance plex-check back +nix run .#spoolet -- --instance plex-check items libraries +nix run .#spoolet -- --instance plex-check library ACCOUNT_PREFIX:LIBRARY_ID +nix run .#spoolet -- --instance plex-check items browse 0 100 +nix run .#spoolet -- --instance plex-check play ACCOUNT_PREFIX:ITEM_ID +nix run .#spoolet -- --instance plex-check qualities +nix run .#spoolet -- --instance plex-check quality 0 +nix run .#spoolet -- --instance plex-check pause +nix run .#spoolet -- --instance plex-check seek 120 +nix run .#spoolet -- --instance plex-check resume +nix run .#spoolet -- --instance plex-check screenshot /tmp/spool-playback.png +nix run .#spoolet -- --instance plex-check stop +``` + +Use the qualified IDs returned by `items`, not guessed raw provider IDs. +`items resume`, `items next-up`, and `load-more` expose the existing loaded +models; browsing and playback requests are asynchronous, so poll `status` +(`initialized`, `busy`, `homeLoading`, `browseLoading`, `playback.loaded`) before +acting on their results. `qualities` reads the actual application's current +options, including selected Auto/Original/fixed ceilings, rather than a separate +CLI ladder. `quality INDEX` selects one of those options using the real playback +API; subsequent stream negotiation can take time. +The credential-free `source` snapshot reports the actually resolved edition's +bitrate, pixel dimensions, aspect-normalized quality height, resolution label, +and playback method. For example, a 3840×1608 edition is a 2160-class source +despite its cropped pixel height; the CLI never reconstructs quality choices +from the negotiated output or invents a source ladder. + +Preview inspection uses real window input, without seeking the video. For this +latency-sensitive sequence, invoke the installed `spoolet` binary directly: +the Nix wrapper can take about three seconds to start, enough for playback +controls to hide between commands. The Nix launcher above remains available +for ordinary CLI use. From the checkout root on Linux: + +```sh +SPOOLET=./build/linux-release-local-providers/install/bin/spoolet +"$SPOOLET" --instance plex-check key up +"$SPOOLET" --instance plex-check preview 120 +"$SPOOLET" --instance plex-check state +# Poll state until visual.preview.active is true, selectionPending is false, +# and visual.previewTexture.ready is true, then capture immediately: +"$SPOOLET" --instance plex-check screenshot /tmp/spool-preview.png +"$SPOOLET" --instance plex-check settings get +"$SPOOLET" --instance plex-check settings set playback/seekPreviews false +"$SPOOLET" --instance plex-check settings set playback/seekPreviews true +"$SPOOLET" --instance plex-check settings set playback/accurateTrickplay true +"$SPOOLET" --instance plex-check settings set playback/trickplayPreviewScalePercent 150 +"$SPOOLET" --instance plex-check settings set appearance/uiScalePercent 125 +"$SPOOLET" --instance plex-check settings set playback/renderQuality '"high"' +``` + +`preview` needs available trickplay and visible playback controls; it hovers the +real seek bar. `pointer move X Y` / `pointer click X Y` use window logical +coordinates for other precise UI interactions. Settings go through the app's +existing schema validation and persisted user-change transaction. Local UI +scale changes do not make this device-specific setting remotely syncable. +Seek preview size is relative to interface scale and does not change the +server's thumbnail resolution; the two scale settings are independent. +The CLI exposes only the five settings shown above, not credentials or arbitrary +configuration. `state` is a small allowlisted route/playback/visual snapshot, +not unrestricted QObject inspection or script evaluation. + +Download automation uses the same provider negotiation, quality options and +native transfer manager as the UI: + +```sh +"$SPOOLET" downloads options ACCOUNT_PREFIX:ITEM_ID +"$SPOOLET" downloads start ACCOUNT_PREFIX:ITEM_ID 0 +"$SPOOLET" downloads list +"$SPOOLET" downloads cancel JOB_ID +"$SPOOLET" downloads retry JOB_ID +"$SPOOLET" downloads play JOB_ID +"$SPOOLET" downloads remove JOB_ID +``` + +Choose the index returned by `downloads options`; zero is Original. +Starting is asynchronous and can open the provider's edition/stream picker. +Poll `downloads list` for job state and transferred/total bytes. The output +excludes destination paths, media URLs, credentials and server cleanup data. +`downloads play` launches a completed local copy without remote playback relay. + +For pointer testing, `pointer press X Y`, frame-paced `pointer move X Y`, +and `pointer release X Y` perform a real held-button drag; `pointer right-click +X Y` opens context menus. Coordinates are window-logical pixels. A normal library +drag scrolls; choose Move from its menu before testing drag-and-drop reordering. + +Every command writes one machine-readable JSON result to stdout; failures exit +nonzero and never prompt. `--timeout MS` (100–30000, default 10000) bounds the CLI +request. The server limits each connection to one newline-framed JSON request, +64 KiB input, 1 MiB output, eight concurrent connections, and a ten-second +deadline. Specify `--instance ID` when more than one app is running; unnamed +launches get a unique discoverable ID. An explicitly named duplicate launch +fails rather than silently controlling another instance. `SPOOL_INSTANCE` +sets the launch identifier; `--no-local-control` disables the server. + +This is **same-user local IPC only**: Unix-domain sockets or Windows named pipes +with Qt's user-access restriction, plus a private discovery capability. On Unix, +the runtime directory is owner-only and discovery files are owner-only regular +files; unsafe/symlink discovery is rejected. Discovery lives in the user's +runtime directory (`spool-control`, user data directory on Windows), is removed +on clean shutdown, and stale entries are ignored after probing. No TCP/WebSocket +listener is opened. Endpoint capabilities and provider credentials are not +returned by the CLI. Anyone already running code as your user can control the +app; treat screenshots and media titles as private. + +Screenshots wait for a newly swapped Qt frame, read the actual window framebuffer, +and atomically save an owner-only PNG with path, pixel dimensions, file size and +state metadata. Scene-graph embedded video is included; video on a separate +native plane/external window is excluded and explicitly reported with +`videoIncluded: false`. PNG is not a calibrated HDR capture. The window must be +exposed; screenshot completion does not mean a provider image has finished +loading—check the reported playback/preview readiness first. The server and CLI +are desktop facilities, not a public remote-control API or a TV deployment tool. + +Without Nix, use `spoolet` beside `spool` in the native build directory or installed +`bin/`. Local-provider Nix builds install it to +`build/linux-release-local-providers/install/bin/spoolet` (macOS: +`build/macos-local-providers/run-install/bin/spoolet`); the `.#spoolet` launcher +uses these exact checkout outputs. + +### Local playback URL diagnostics + +URL logging is redacted by default. To inspect the complete failed playback URL +from a local checkout, explicitly opt in for that process: + +```sh +nix run .#local-providers -- --unredacted-urls +``` + +The launcher forwards this flag to the real app, which warns at startup. +Playback failure and mpv/curl messages in `spool.log` retain the full URL, +including the server address, encoded client-profile parameters and any URL +credentials. On Linux this log is normally +`~/.local/share/spool/Spool/logs/spool.log`; startup prints the actual location. +Other password/token fields and authorization headers stay redacted. The +unsanitized standalone mpv file sink is disabled: mpv messages use the same Qt +sanitizer and application log. Existing `spool-mpv.log` files from older runs are +not sanitized retroactively. + +**Keep opted-in logs private. URLs may contain working credentials.** Do not +upload or share them without removing secrets, addresses and identifiers. +Restart without the flag to restore default URL redaction. For providers that +authenticate media through HTTP headers (including Plex), reproducing the +request also requires the account's private media headers; a URL alone is not +an authenticated request. + +## Sign-in controls + +Passwords and account PINs start hidden. Select the eye beside the input to show +or hide its contents; it also supports mouse/touch, Tab, and **OK/Enter**. On a +TV, **Right** from the input row focuses the eye and **Left** returns to the row. +Submitting or leaving the form hides the input again. + +Device-link and Quick Connect codes have their instructions below the code box. +On desktop and mobile, **Copy** copies the code; TVs show it for entry on another +device without a clipboard control. + +## Homepage libraries + +Drag normally to scroll the library row horizontally. Right-click a library, +long-press its card, or hold **OK/Enter** (or press **Menu**) on a focused library +to open its menu: **Move**, **Hide library**, and **Show hidden libraries**. + +Choose **Move** to enable drag-and-drop ordering. Hold the pointer near the left +or right edge while dragging to scroll to offscreen libraries, then release to +drop. In move mode, **Left/Right** on a remote or keyboard and the visible arrow +buttons also move the selected library. Press **OK/Enter**, **Back/Escape**, or +**Done** to finish and return to normal scrolling. Moves are saved immediately; +Back does not undo them. Ordering does not reorder recently added shelves. + +**Hide library** removes that library from the home row, its recently added +shelf, and the library switcher without deleting its contents. Choose **Show +hidden libraries** in the menu or beside the home library heading to restore +individual libraries. That control remains available even when every library +is hidden. Order and visibility are saved separately for each account's library +IDs, including while an account is temporarily disconnected. +Library badges use a descriptive server name when available. Default or +machine-generated server names are shown as the server's host and port instead; +usernames are not appended to library names. + +## Local media folders + +On desktop, choose **Add provider → This computer**, then add one or more folders +and confirm **Add library**. No Movies directory or other media source is added +implicitly. The selected folders and their subfolders form one library; overlapping +roots are deduplicated by canonical file path. Account settings let you change the +folder list later, including while the account is disabled. + +Video thumbnails are extracted on demand with the bundled libmpv software renderer, +one file at a time off the UI thread. Qt encodes and caches the bounded thumbnail; +no external `ffmpeg` installation or visible playback window is needed. Changing a +file invalidates its thumbnail. Local favourite/resume state remains process-local. + + +## Episode details + +Season details open the episode row at an in-progress episode, or the next +playable episode after the last watched one. A completed season starts at its +last watched episode. Episode details instead start at the episode being viewed. +That episode is aligned to the row's left edge, including at the end of a season; +earlier episodes remain available by scrolling left. The initial positioning +does not take focus from the main action or reset subsequent manual navigation. + ## Desktop mpv configuration and keys In expert playback settings, **mpv configuration** can be Off, Standard mpv @@ -52,6 +347,17 @@ native-window commands, renderer replacement and standalone mpv playlist management are unsupported. Config errors and embedding ownership are reported in player/mpv diagnostics. +Desktop SDR/HDR selection follows the **current OS output mode**, not merely +the monitor's capabilities. SDR uses an RGBA8 video target with BT.709/BT.1886 +output; mpv tone-maps HDR video into that SDR target, and the UI does not enter +the HDR conversion layer. FP16/scRGB is reserved for an HDR-enabled output. +Wayland uses the output's active transfer function, Windows its desktop DXGI +color space, and macOS its current EDR headroom. Unknown output state stays SDR. +Even the **Always** preference cannot force HDR into an SDR desktop. +The window's encoding is selected at startup: restart Spool after changing the +desktop HDR mode. + + On webOS, supported codecs still use Starfish. For software-decoded codecs, **Software video renderer** in advanced playback settings offers Automatic (the established OpenGL `gpu` path), `gpu`, or experimental OpenGL `gpu-next`. @@ -97,6 +403,246 @@ backend. `tools/compare-render-benchmark.py` supports strict local comparisons; CI passes `--warn-only`. Empty or broken measurements still fail rather than being mistaken for good performance. +Provider form layouts are precompiled in the host. Settings creates only visible +rows and opens its native folder chooser on demand; initial viewport readiness is +checked at the end of the current event-loop turn, with timed retries only when +delegates are still missing. Playback previews prime the resume-position +texture on a dedicated network/decode path, independent of poster queues. +Sprite sheets within the original 50,000,000-byte RGB32 admission envelope +(12.5 megapixels) stay resident: moving between tiles changes only GPU crop +coordinates, without another decode, CPU crop, or texture upload. Ordinary +8-bit YCbCr 4:2:0 JPEGs decode directly into immutable Y/Cb/Cr planes using +Qt's toolchain-selected libjpeg/libjpeg-turbo, with `JDCT_IFAST` by default. +Advanced **Playback → Accurate seek previews** (`playback/accurateTrickplay`) +selects `JDCT_ISLOW` while retaining the same planar path and full encoded +resolution. Changing it revokes the previous generation and decoded cache. +The faster transform is a deliberate IDCT quality tradeoff, not a reduced +resolution or another chroma downsample. Grayscale, 4:2:2, 4:4:4 and other +unsupported JPEG layouts retain Qt's RGB decoder. Oversized sheets retain +codec-side RGB frame clipping. Whole BIF sequences use their real timestamp +indexes and JPEG payloads; no video-player process is used to decode previews. + +`TrickplayPreviewItem` integrates with Qt Quick's existing scene graph and the +window's QRhi backend; it does not replace the application renderer or create +a separate GPU context. Qt still owns render scheduling, command submission +and presentation. The item owns three resident compact R8 textures +(RED_OR_ALPHA8 on backends requiring alpha-channel sampling), including on +webOS/GLES. Its QRhi integration uses Qt's private Gui API, so the headers and +runtime must come from the same pinned Qt toolchain on every platform. +The visible quad reconstructs full-range JPEG/601 colour using the source 4:2:0 +sampling; odd visible dimensions and raw MCU padding are accounted separately. +Software scene graphs and unsupported GPU texture paths use RGB fallback. +GPU uploads retain immutable backing until the resource-update batch owns it. +No reusable decode pool is added: existing foreground/prefetch caching and +in-flight render/upload ownership are retained rather than overwritten. + +The GPU filter is normalized, separable windowed **Lanczos2**: +`sinc(x) * sinc(x/2)` with a fixed two-source-pixel radius. Bilinear pairing of +the two positive middle weights reduces its 4×4 kernel to nine sample +positions (27 compact-plane texture reads for YUV, nine for RGB). Source size, +reciprocal size, chroma geometry and crop bounds are CPU-computed uniforms; +only position-dependent weights are calculated by the fragment shader. +Each tap is clamped to the selected tile's luma pixel centres to avoid +neighbour bleed, while chroma reconstruction retains the whole-JPEG neighbours +used by a full-JPEG RGB decoder. Clamping to the sampled neighbourhood's +colour envelope limits negative-lobe ringing. Work touches only the visible +quad: no full-sheet RGB framebuffer, mipmap chain or resampling pass exists. +Its footprint does not widen at strong minification, deliberately bounding +cost; it is not an ideal scale-adaptive low-pass filter for extreme shrinking. +Software/backend fallback uses that backend's RGB filter, not Lanczos. + +**Playback → Seek previews** (`playback/seekPreviews`, default on) controls +thumbnails globally, including the remote-player timeline. Turning it off +immediately hides existing previews, cancels preview fetch/decode work, clears +preview caches, and tells providers to skip preview-only metadata requests. +Turning it on restores the current descriptor when one is available; a provider +that omitted metadata while disabled supplies it on the next playback resolve +or remote-state refresh. The advanced accuracy and size controls are hidden +while previews are off. This preference persists as a device default. + +Local and remote previews share a nominal **320 dp** layout width, independent +of encoded thumbnail resolution, preserving the actual source aspect ratio +and fitting the viewport (including the remote page's actual content margins). +The overall interface scale applies, then advanced +**Playback → Seek preview size** (`playback/trickplayPreviewScalePercent`, +25–200%, default 100%) adjusts the display size only. Crop coordinates always +remain in encoded-source pixels. Both settings persist as device defaults +through the ordinary settings schema. + +The server-free native fixture smoke exercises real JPEG sprite and BIF pixels, +odd-sized raw 4:2:0 planes with both transforms, grayscale/4:2:2/4:4:4 RGB +fallbacks, accuracy cache retirement, malformed BIF boundaries, resident-sheet +crops, the 50 MB boundary, directional prefetch, and stale-session/late-request +cancellation: + +```sh +nix develop .#native -c cmake --build build/linux-dev/app --target app-tests providers-tests +nix develop .#native -c env SPOOL_TRICKPLAY_SMOKE_OUTPUT=/tmp/spool-preview-frame.png \ + build/linux-dev/app/app-tests trickplay +nix develop .#native -c ctest --test-dir build/linux-dev/app \ + -R '^(trickplay|artwork-authentication|playback-timeline)$' --output-on-failure +``` + +The smoke prints cold response time, 24 explicit warm image-provider response +times, full-sheet decode time, and texture byte counts. Normal hover within a +resident sheet does not issue those additional image-provider requests. +These measurements exclude actual GPU transfer and presentation duration. +Each local/remote session caps retained decoded textures at 50,000,000 bytes +and encoded sheets (or one BIF sequence) at 32 MiB. Cache charging is at least +the original RGB32 cost (and includes raw MCU padding when that is larger), so +smaller planes do not expand historical caching. The decoded budget is not +a total RAM/VRAM limit: decoder workspace, active image delivery, optional +software RGB reconstruction and GPU textures add to it. Sequences over 32 MiB +are rejected. The fixture smoke's RGB image readback is a fallback exercise, +not evidence of the production GPU path. + +Hover image selection consumes the latest input once per rendered frame; +the timestamp and preview position update immediately. Prefetch follows hover +direction to one neighbouring sheet or at most two BIF frames. Foreground +requests preempt speculative work; a neighbouring sheet is decoded only when +it fits beside the current retained textures, otherwise only its encoded bytes +are prefetched. The preview item bypasses Qt's image cache so historical sheets +do not accumulate there. Switching sessions cancels outstanding delivery and +drops caches. +Remote resource revisions also change when a target switches preview URL or +credentials for the same item. Retired revisions cannot reuse cached frames; +unchanged resources keep stable identities across state polling. + +To observe the real renderer during an application preview, set +`QT_LOGGING_RULES='spool.trickplay.render.debug=true'` before launching Spool. +The `planar Lanczos2` texture-format messages identify the compact path; RGB +and software/backend fallbacks are explicitly labelled. `upload` messages +occur on decoded-output replacement, not on same-sheet crop movement. + + + +## Apple TV + +Releases include `Spool--tvOS-arm64.ipa`, an **unsigned** device build +for Apple TV running tvOS 16 or later. It contains the real `Payload/Spool.app`, +but no Apple signing identity or provisioning profile. **tvOS will not install +it as downloaded.** Spool is not distributed through the App Store or TestFlight +and has not been reviewed by Apple. If you already have a tvOS re-signing setup, +use your own certificate, matching entitlements, and provisioning profile; Spool +does not supply a re-signing command or signing credentials. + +### Build the device package + +Use a Mac with Xcode and the Apple TV SDK installed. Install the build tools and +the matching macOS Qt host tools (the version comes only from the shared pin): + +```sh +brew install cmake ninja meson pkg-config bash python imagemagick gpatch +QT_VERSION="$(python3 -c 'import json; print(json.load(open("tools/manifests/toolchain.json"))["qt"]["version"])')" +python3 -m venv build/apple/aqt +build/apple/aqt/bin/pip install aqtinstall +build/apple/aqt/bin/aqt install-qt mac desktop "$QT_VERSION" clang_64 \ + -O "$PWD/build/apple/host-qt" \ + -m qtshadertools qttasktree qtwebsockets qtimageformats +export QT_HOST_PATH="$PWD/build/apple/host-qt/$QT_VERSION/macos" +APPLE_SDK=appletvos APPLE_ARCH=arm64 bash tools/build-tvos.sh +bash tools/package-tvos.sh build/tvos/appletvos-arm64/install/Spool.app dist/tvos +``` + +The build compiles the pinned source Qt tvOS port and static media dependencies; +a desktop Qt kit is only used for matching host generators, never as a target +SDK. Qt does not list tvOS as an officially supported platform. Target Qt and +media prefixes live under `build/apple/appletvos-arm64/`; the app lives under +`build/tvos/appletvos-arm64/install/`. Apple builds enforce bundled-only loading +and explicit `appleAppStore: true` provider approval; local provider checkout +overrides are not accepted. + +The packager performs no signing. It rejects simulator/wrong-architecture +binaries, mismatched bundle versions, signed/provisioned bundles, and unsafe +archive paths before publishing the IPA. Its optional third argument must match +`VERSION`; the embedded bundle versions must match it too. + +### Sign and run from source + +1. Add your Apple Account in Xcode's account settings, and pair your Apple TV + with the Mac on the same network. On the TV, open **Settings → Remotes and + Devices → Remote App and Devices**; use Xcode's **Manage Devices** (or + **Window → Devices and Simulators** on older Xcode) to pair it. +2. Generate the device project using a unique reverse-DNS identifier you control + (replace `com.yourname.spool` with your identifier): + + ```sh + APPLE_SDK=appletvos APPLE_ARCH=arm64 \ + APPLE_BUNDLE_IDENTIFIER=com.yourname.spool bash tools/build-tvos.sh + open build/tvos/appletvos-arm64/app/SpoolWebOS.xcodeproj + ``` + + The source build configures the app's bundle identifier and matching Keychain + service together; do not manually replace only the generated plist identifier. + Select the `spool` application target, not the playback-smoke target. +3. In the application target's **Build Settings**, set **Code Signing Allowed** + to **Yes**. Under **Signing & Capabilities**, enable **Automatically manage + signing**, choose your team, and retain the identifier configured above. +4. Select your paired Apple TV and the `spool` scheme, then **Product → Run**. + Xcode creates the development provisioning profile and signs the app using + your account. These generated-project changes are local; regenerating the + project with the unsigned build command resets its build settings. + +Once Xcode has configured your signing identity and provisioning profile, +command-line device rebuilds can use `CODE_SIGNING_ALLOWED=YES` and +`APPLE_DEVELOPMENT_TEAM` set to your team ID. Keep `APPLE_BUNDLE_IDENTIFIER` +set to the same identifier. The public release pipeline does not enable this +signing path or obtain credentials from your account. + +Follow Apple's current [device signing and run directions](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices) +and [device pairing directions](https://developer.apple.com/documentation/xcode/managing-your-simulated-and-physical-devices-in-device-hub). +Account eligibility, provisioning, and signature expiry are Apple's policies. +Simulator success is not a claim of physical-device playback or App Store approval. + +The target reuses TV focus, hold-to-open-options, and directional navigation. +Clickpad directions/Select arrive through Qt's UIKit mapping; indirect swipes +step focus without turning the remote into a pointer. Back/Menu dismisses +overlays or navigates back and remains unhandled at the root for tvOS to return +to the launcher. Play/Pause toggles playback. Siri, TV/Home, and volume remain +system-owned. Video pauses when hidden/suspended; audio uses the playback +session and system Now Playing/remote controls. Idle inhibition applies only +while media is active. Account secrets use device-only Keychain records. +Database/artwork/log storage is purgeable on tvOS; local filesystem browsing +and self-updating are not enabled. + +### Simulator proof and CI artifacts + +```sh +APPLE_SDK=appletvsimulator APPLE_ARCH=arm64 bash tools/build-tvos.sh +bash tools/apple/smoke-tvos.sh build/tvos/appletvsimulator-arm64/install/Spool.app +``` + +The isolated simulator smoke checks the native app UI, video orientation and +OSD rendering, AudioUnit playback-time advancement and exclusive-session +behavior, plus a real Keychain roundtrip and sandbox file persistence. It writes +`result.json` and `simulator.png` under `build/tvos/smoke/`. The multi-platform +workflow builds both device and simulator on normal branches and PRs under the +existing duplicate-build policy; manual dispatch can select `build_tvos`. Reusable +release builds always include both. Dependency caches are exact-keyed to source +pins, patches, build policy, SDK, architecture, and compiler/build-tool identity; +PRs restore without publishing caches. Only `spool-tvos-device-arm64` is a public +release artifact. Simulator apps and smoke results use +`internal-tvos-simulator-arm64` and are never offered as installable downloads. +The GLES framebuffer renderer resets shared OpenGL state at context handoffs and +frame rendering boundaries so Qt's clipping/blend state cannot leak into mpv +when the video viewport is resized or a playback context is replaced. +The pixel consumer waits for a real window swap before readback; grabbing an +offscreen frame cannot substitute for native presentation feedback. +Those waits enter Qt's native event loop: on UIKit a manual event pump does not +transfer control to `UIApplicationMain` for normal system presentation. +The identical red/blue fixture is a finite 30-second clip; the consumer does not +reset playback with rapid EOF seeks while renderer initialization is in flight. +Each consumer selector launches a fresh native process, terminating any previous +instance of the harness before dispatching its next argument vector. + +Simulator builds embed matching local application and Keychain access-group +entitlements in the Mach-O XML/DER sections used by the simulator. Before +installation, the smoke helper ad-hoc signs both bundles with only the host +debug entitlement: putting restricted application entitlements in that macOS +signature prevents launch. This requires no Apple developer certificate, +exercises the real sandboxed Keychain consumer, and does not sign or provision +the separately built device IPA. + ## Android development The Android toolchain is pinned to SDK 36, Build Tools 36.0.0 and NDK @@ -144,6 +690,10 @@ and zoom rather than a fixed phone grid. The Android Qt build carries a live-density notification patch so fold/configuration changes update the UI without requiring an app restart. +New installations start at 100% interface scale on every platform, including +webOS and Android TV. Existing saved percentages are preserved. Scale always +stays device-local and is excluded from settings synchronization. + `tools/android/build-universal-apk.sh` merges per-ABI APKs from one build into the single `spool-universal.apk` the release page offers, for people who do not know their device's architecture. The in-app updater never fetches it: the @@ -176,7 +726,7 @@ not require ADB or broad storage permissions. # Name -## Fast Jellyfin client for LG TVs and desktop +## Fast media player for LG TVs and desktop // Download link box diff --git a/SECURITY.md b/SECURITY.md index 306fa9d1..9da7637c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -Spool for Jellyfin is prerelease software. Security fixes are provided only for the latest published `v0.x` prerelease and the current `master` branch. Older prereleases are unsupported. +Spool is prerelease software. Security fixes are provided only for the latest published `v0.x` prerelease and the current `master` branch. Older prereleases are unsupported. ## Reporting a vulnerability @@ -21,3 +21,9 @@ Please allow a reasonable remediation window before public disclosure. Once a fi HTTPS is the default and recommended server transport. Certificate validation is never disabled globally. A certificate exception is scoped to the exact server origin and certificate fingerprint that the user confirms. Remembered certificates can be reviewed and removed from **Settings → Network → Remembered certificates**. Removing an entry restores normal platform trust validation on the next connection. HTTP may be used only for an explicitly approved local address. Credentials are denied for public HTTP destinations and are not forwarded across an origin-changing redirect. + +## Release dependency scans + +Release packages carry CycloneDX, SPDX and Syft SBOMs plus Grype results. Publication fails on fixed High or Critical findings. Scheduled rescans use the same policy. + +`tools/manifests/ffmpeg-vex.json` records upstream fixes present in the SHA-256-pinned FFmpeg 9.0.2 archive for CVE-2026-75146, CVE-2026-75142 and CVE-2026-75147. Grype's commit-based advisory ranges currently report those fixes as missing from release versions. The generated `ffmpeg-vex.json` scopes this evidence to the Spool release and exact FFmpeg package version; other versions, components and advisories remain subject to the severity gate. diff --git a/SUPPORT.md b/SUPPORT.md index a7f83ea7..f696dbee 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,6 +1,6 @@ # Support Policy -Spool for Jellyfin is prerelease software. The latest `v0.x` prerelease is the only supported binary release; `master` is development code. +Spool is prerelease software. The latest `v0.x` prerelease is the only supported binary release; `master` is development code. Use GitHub issues for reproducible project defects and feature requests. General Jellyfin server administration, media encoding, network configuration, and unsupported operating-system questions belong in the appropriate Jellyfin community support channel. diff --git a/VERSION b/VERSION index 8bf8ecb0..ac39a106 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.18 +0.9.0 diff --git a/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java b/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java index e0ef19e7..76016b49 100644 --- a/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java +++ b/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java @@ -169,7 +169,7 @@ private Notification buildNotification(String title, String targetName) Notification.Builder builder = new Notification.Builder(activity, CHANNEL_ID) .setSmallIcon(com.sachk.spool.R.mipmap.ic_launcher) .setContentTitle(emptyFallback(title, "Remote playback")) - .setContentText("Playing on " + emptyFallback(targetName, "Jellyfin client")) + .setContentText("Playing on " + emptyFallback(targetName, "another device")) .setOnlyAlertOnce(true) .setOngoing(playing) .setVisibility(Notification.VISIBILITY_PUBLIC) @@ -210,7 +210,7 @@ private void createNotificationChannel() { NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Remote playback", NotificationManager.IMPORTANCE_LOW); - channel.setDescription("Controls the Jellyfin client selected in Spool"); + channel.setDescription("Controls the device selected in Spool"); channel.setShowBadge(false); notifications.createNotificationChannel(channel); } diff --git a/app/appinfo.json.in b/app/appinfo.json.in index 379a4763..0f73321b 100644 --- a/app/appinfo.json.in +++ b/app/appinfo.json.in @@ -3,12 +3,12 @@ "version": "@VERSION@", "vendor": "Spool", "type": "native", - "main": "bin/jellyfin-native", - "title": "Spool for Jellyfin", + "main": "bin/spool", + "title": "Spool", "icon": "icon.png", "largeIcon": "large-icon.png", "splashBackground": "splash.png", - "appDescription": "Very fast mpv-based Jellyfin client for LG webOS.", + "appDescription": "Very fast mpv-based media player for LG webOS.", "tileSize": "normal", "supportTouchMode": "full", "spinnerOnLaunch": false, diff --git a/app/com.sachk.spool.desktop b/app/com.sachk.spool.desktop index 77ba8dea..3d9c7f80 100644 --- a/app/com.sachk.spool.desktop +++ b/app/com.sachk.spool.desktop @@ -1,10 +1,10 @@ [Desktop Entry] Type=Application -Name=Spool for Jellyfin -Comment=Browse and play media from a Jellyfin server -Exec=jellyfin-native +Name=Spool +Comment=Browse and play your media +Exec=spool Icon=com.sachk.spool Categories=AudioVideo;Video; -Keywords=Jellyfin;Media;Player;TV; +Keywords=Media;Player;TV;Jellyfin;Emby;Plex; StartupWMClass=com.sachk.spool Terminal=false diff --git a/app/com.sachk.spool.metainfo.xml b/app/com.sachk.spool.metainfo.xml index 6132eb70..4478572d 100644 --- a/app/com.sachk.spool.metainfo.xml +++ b/app/com.sachk.spool.metainfo.xml @@ -3,14 +3,14 @@ com.sachk.spool CC0-1.0 MPL-2.0 - Spool for Jellyfin - Fast Jellyfin client for TVs and desktop + Spool + Fast media player for TVs and desktop -

Browse and play media from a Jellyfin server with a native mpv playback pipeline.

+

Browse and play media from Jellyfin, Emby, Plex and more with a native mpv playback pipeline.

com.sachk.spool.desktop - jellyfin-native + spool AudioVideo @@ -21,6 +21,17 @@ Spool + +

Add a Stremio add-on provider with its own stream and torrent-file chooser using an external streaming server. Download original or server-converted media for offline playback, with platform-appropriate destination selection. Simplify settings sync to a recommended preset with one status and recovery screen. Add a global seek-preview switch and filtered provider logging. Remove synchronous mpv render waits from desktop fullscreen transitions. Bundle compatible Jellyfin, Emby, Plex, Stremio and Open Movies providers, with explicit Apple App Store inclusion policy. Retain automatic episode continuation, full-resolution GPU-scaled seek previews, Home watching-profile selection and native spoolet controls.

+
+ +

Rearrange homepage libraries by dragging or with TV-remote move controls. Improve PIN, password and Quick Connect screens. Show clearer server labels and source icons, and deduplicate search results across servers. Bundle updated Jellyfin, Emby and Plex providers, which remain independently upgradable. Update FFmpeg to its security-fixed bugfix release.

+
+ +

Install and update providers from the startup chooser with download progress, and enable optional provider features.

+
+ + diff --git a/app/tvos/Info.plist.in b/app/tvos/Info.plist.in new file mode 100644 index 00000000..ae77c059 --- /dev/null +++ b/app/tvos/Info.plist.in @@ -0,0 +1,18 @@ + + + +CFBundleExecutable${MACOSX_BUNDLE_EXECUTABLE_NAME} +CFBundleIdentifier${MACOSX_BUNDLE_GUI_IDENTIFIER} +CFBundleNameSpool +CFBundleDisplayNameSpool +CFBundlePackageTypeAPPL +CFBundleVersion${MACOSX_BUNDLE_BUNDLE_VERSION} +CFBundleShortVersionString${MACOSX_BUNDLE_SHORT_VERSION_STRING} +LSRequiresIPhoneOS +UIDeviceFamily3 +UILaunchScreen +UIBackgroundModesaudio +NSLocalNetworkUsageDescriptionSpool connects to media servers on your home network so you can browse and play your library. +NSAppTransportSecurityNSAllowsLocalNetworkingNSAllowsArbitraryLoads +ITSAppUsesNonExemptEncryption + diff --git a/app/tvos/PrivacyInfo.xcprivacy b/app/tvos/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..04c13f0b --- /dev/null +++ b/app/tvos/PrivacyInfo.xcprivacy @@ -0,0 +1,11 @@ + + + +NSPrivacyTracking +NSPrivacyCollectedDataTypes +NSPrivacyAccessedAPITypes +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaultsNSPrivacyAccessedAPITypeReasonsCA92.1 +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestampNSPrivacyAccessedAPITypeReasonsC617.1 +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryDiskSpaceNSPrivacyAccessedAPITypeReasonsE174.1 +NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategorySystemBootTimeNSPrivacyAccessedAPITypeReasons35F9.1 + diff --git a/build-ipk.sh b/build-ipk.sh index ea21ff51..68bf6997 100755 --- a/build-ipk.sh +++ b/build-ipk.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ -z "${IN_NIX_SHELL:-}" && "${JELLYFIN_BUILD_IPK_ENTERED_NIX:-0}" != "1" ]]; then +if [[ -z "${IN_NIX_SHELL:-}" && "${SPOOL_BUILD_IPK_ENTERED_NIX:-0}" != "1" ]]; then WORKSPACE_ROOT="$(cd "$ROOT/.." && pwd)" DEFAULT_SDK_ROOT="$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot" if [[ -z "${WEBOS_SDK_ROOT:-}" && -d "$WORKSPACE_ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot" ]]; then @@ -11,7 +11,7 @@ if [[ -z "${IN_NIX_SHELL:-}" && "${JELLYFIN_BUILD_IPK_ENTERED_NIX:-0}" != "1" ]] fi cd "$ROOT" exec nix develop "$ROOT" -c env \ - JELLYFIN_BUILD_IPK_ENTERED_NIX=1 \ + SPOOL_BUILD_IPK_ENTERED_NIX=1 \ WEBOS_SDK_ROOT="${WEBOS_SDK_ROOT:-$DEFAULT_SDK_ROOT}" \ bash -c 'cd "$1" && shift && exec bash ./build-ipk.sh "$@"' bash "$ROOT" "$@" fi @@ -70,7 +70,7 @@ MPV_LTO_CROSS_FILE="$BUILD_DIR/webos-lto.cross.ini" WEBOS_BUILD_MEMORY_PER_JOB_MIB="${WEBOS_BUILD_MEMORY_PER_JOB_MIB:-1536}" WEBOS_BUILD_MEMORY_RESERVE_MIB="${WEBOS_BUILD_MEMORY_RESERVE_MIB:-2048}" WEBOS_BUILD_JOBS="$(recommended_parallel_jobs "$WEBOS_BUILD_MEMORY_PER_JOB_MIB" "$WEBOS_BUILD_MEMORY_RESERVE_MIB")" -IMAGE_SUBTITLE_DIAGNOSTICS="${JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS:-0}" +IMAGE_SUBTITLE_DIAGNOSTICS="${SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS:-0}" if [[ "$IMAGE_SUBTITLE_DIAGNOSTICS" == "0" ]]; then MPV_IMAGE_SUBTITLE_DIAGNOSTICS_FLAG= CMAKE_IMAGE_SUBTITLE_DIAGNOSTICS=OFF @@ -93,11 +93,11 @@ if (( DO_BUILD || DO_STAGE )); then echo "Using static Qt6 build at $QT6_PREFIX" # Reject stale cached Qt prefixes that predate required local patches. - QT_PATCH_MARKERS=(JELLYFIN_QT_NO_CURSOR_SURFACE) + QT_PATCH_MARKERS=(SPOOL_QT_NO_CURSOR_SURFACE) for marker in "${QT_PATCH_MARKERS[@]}"; do if ! grep -rqal "$marker" "$QT6_PREFIX/lib" 2>/dev/null; then echo "error: Qt at $QT6_PREFIX is missing patch marker '$marker'." >&2 - echo " Rerun: bash $WEBOS_TOOLS_ROOT/build-qt6-611.sh" >&2 + echo " Rerun: ./build-ipk.sh" >&2 exit 1 fi done @@ -259,7 +259,9 @@ cmake -S "$ROOT" -B "$CMAKE_BUILD_DIR" -GNinja \ -DQt6_DIR="$QT6_PREFIX/lib/cmake/Qt6" \ -DQT_HOST_PATH="$QT6_HOST_PREFIX" \ -DCMAKE_INSTALL_PREFIX=/usr/palm/applications/com.sachk.spool \ - -DJELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS="$CMAKE_IMAGE_SUBTITLE_DIAGNOSTICS" + -DSPOOL_IMAGE_SUBTITLE_DIAGNOSTICS="$CMAKE_IMAGE_SUBTITLE_DIAGNOSTICS" \ + -DSPOOL_PROVIDER_SOURCES="${SPOOL_PROVIDER_SOURCES:-open}" \ + -DSPOOL_PROVIDER_OVERRIDES="${SPOOL_PROVIDER_OVERRIDES:-}" cmake --build "$CMAKE_BUILD_DIR" --parallel "$WEBOS_BUILD_JOBS" cmake --install "$CMAKE_BUILD_DIR" --prefix "$INSTALL_DIR" @@ -292,11 +294,11 @@ cp -f "$ROOT/app/notices/OPEN_SOURCE_NOTICES.txt" "$ROOT/LICENSE" \ "$APP_DIR/notices/" PATCHELF_BIN="$(command -v patchelf)" -[[ -x "$INSTALL_DIR/bin/jellyfin-native" ]] || { +[[ -x "$INSTALL_DIR/bin/spool" ]] || { echo "error: app install missing; run '$0 app' first" >&2 exit 1 } -cp -f "$INSTALL_DIR/bin/jellyfin-native" "$STAGE_BIN/jellyfin-native" +cp -f "$INSTALL_DIR/bin/spool" "$STAGE_BIN/spool" # mpv, curl, FFmpeg, and OpenSSL shared libraries are always needed. Stage # their real files and derive compatibility symlinks from each ELF SONAME so @@ -335,7 +337,7 @@ done # segment URL by protocol name before mpv's libcurl backend is consulted, and # an FFmpeg without a TLS backend registers no https protocol to find. It # moves no bytes; libcurl still carries playback. -for tls_consumer in "$STAGE_BIN/jellyfin-native" "$CURL_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY"; do +for tls_consumer in "$STAGE_BIN/spool" "$CURL_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY"; do TLS_DYNAMIC_SECTION="$("$READELF_BIN" -d "$tls_consumer")" if [[ "$TLS_DYNAMIC_SECTION" != *"[libssl.so"* && "$TLS_DYNAMIC_SECTION" != *"[libcrypto.so"* ]]; then echo "error: $(basename "$tls_consumer") does not use the packaged shared OpenSSL build" >&2 @@ -354,14 +356,14 @@ fi "$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN' "$MPV_STAGED_LIBRARY" "$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN' "$CURL_STAGED_LIBRARY" "$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN' "$AVFORMAT_STAGED_LIBRARY" -"$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN/../lib' "$STAGE_BIN/jellyfin-native" +"$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN/../lib' "$STAGE_BIN/spool" "$ROOT/tools/webos-native/audit-arm-binaries.sh" \ "$READELF_BIN" "$OBJDUMP_BIN" --thumb-archive \ "$QT6_PREFIX/lib/libQt6Core.a" "$ROOT/tools/webos-native/audit-arm-binaries.sh" \ "$READELF_BIN" "$OBJDUMP_BIN" --thumb \ - "$STAGE_BIN/jellyfin-native" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ + "$STAGE_BIN/spool" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ "$FFMPEG_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY" echo "Stripping every staged webOS ELF" @@ -376,13 +378,13 @@ done < <(find "$APP_DIR" -type f -print0) "$ROOT/tools/webos-native/audit-arm-binaries.sh" \ "$READELF_BIN" "$OBJDUMP_BIN" --stripped \ - "$STAGE_BIN/jellyfin-native" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ + "$STAGE_BIN/spool" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ "$FFMPEG_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY" "$OPENSSL_STAGED_LIBRARY" \ "$OPENSSL_CRYPTO_STAGED_LIBRARY" WEBOS_ELF_AUDIT_ARGS=( elf "$APP_DIR" - --root bin/jellyfin-native + --root bin/spool --readelf "$READELF_BIN" ) for runtime_root in \ @@ -412,7 +414,7 @@ python3 "$ROOT/tools/package-audit.py" inventory "$APP_DIR" \ fi if (( DO_PACKAGE )); then -[[ -f "$APP_DIR/appinfo.json" && -x "$STAGE_BIN/jellyfin-native" ]] || { +[[ -f "$APP_DIR/appinfo.json" && -x "$STAGE_BIN/spool" ]] || { echo "error: staged webOS app missing; run '$0 stage' first" >&2 exit 1 } diff --git a/cmake/ProviderBundle.cmake b/cmake/ProviderBundle.cmake new file mode 100644 index 00000000..44aa380d --- /dev/null +++ b/cmake/ProviderBundle.cmake @@ -0,0 +1,122 @@ +# Bundled providers: the pinned packages in providers/lock.json, unpacked at +# configure time into a Qt resource at qrc:/providers//. Nothing is +# downloaded here; a digest mismatch stops the configure. +# +# For provider development, SPOOL_PROVIDER_OVERRIDES takes id=/path/to/checkout +# pairs, replacing matching pins and adding providers not present in the lock. + +set(SPOOL_PROVIDER_OVERRIDES "" CACHE STRING "id=/path pairs bundling local providers, replacing matching pins") + +function(spool_bundle_providers target) + if(SPOOL_APPLE_APP_STORE AND SPOOL_PROVIDER_OVERRIDES) + message(FATAL_ERROR "Apple App Store builds cannot use provider checkout overrides") + endif() + set(lock "${CMAKE_CURRENT_SOURCE_DIR}/providers/lock.json") + set(out "${CMAKE_CURRENT_BINARY_DIR}/bundled-providers") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${lock}") + file(READ "${lock}" json) + string(JSON count LENGTH "${json}" providers) + set(pinned_ids "") + set(ids "") + if(count GREATER 0) + math(EXPR last "${count} - 1") + foreach(index RANGE 0 ${last}) + string(JSON id GET "${json}" providers ${index} id) + list(APPEND pinned_ids "${id}") + if(SPOOL_APPLE_APP_STORE) + string(JSON eligible_type ERROR_VARIABLE eligibility_error + TYPE "${json}" providers ${index} appleAppStore) + if(eligibility_error OR NOT eligible_type STREQUAL "BOOLEAN") + message(STATUS "Excluding ${id}: no Apple App Store approval") + continue() + endif() + string(JSON eligible GET "${json}" providers ${index} appleAppStore) + if(NOT eligible) + message(STATUS "Excluding ${id}: not approved for Apple App Store builds") + continue() + endif() + endif() + list(APPEND ids "${id}") + endforeach() + endif() + set(override_ids "") + set(override_paths "") + foreach(pair IN LISTS SPOOL_PROVIDER_OVERRIDES) + if(NOT pair MATCHES "^([a-z][a-z0-9]*([.-][a-z0-9]+)+)=(.+)$") + message(FATAL_ERROR "Invalid provider override '${pair}'; expected id=/path/to/checkout") + endif() + set(id "${CMAKE_MATCH_1}") + set(path "${CMAKE_MATCH_3}") + if(id IN_LIST override_ids) + message(FATAL_ERROR "Duplicate provider override ID: ${id}") + endif() + if(NOT IS_ABSOLUTE "${path}" OR NOT EXISTS "${path}/manifest.json") + message(FATAL_ERROR "Provider override ${id} needs an absolute checkout path with manifest.json: ${path}") + endif() + file(READ "${path}/manifest.json" manifest) + string(JSON manifest_id GET "${manifest}" id) + if(NOT manifest_id STREQUAL id) + message(FATAL_ERROR "Provider override ${id} does not match manifest ID ${manifest_id}: ${path}") + endif() + list(APPEND override_ids "${id}") + list(APPEND override_paths "${path}") + if(NOT id IN_LIST ids) + list(APPEND ids "${id}") + endif() + endforeach() + set(qrc "\n") + set(digest "") + foreach(id IN LISTS ids) + set(root "${out}/${id}") + set(override "") + list(FIND override_ids "${id}" override_index) + if(NOT override_index EQUAL -1) + list(GET override_paths ${override_index} override) + endif() + file(REMOVE_RECURSE "${root}") + if(override) + file(GLOB_RECURSE sources CONFIGURE_DEPENDS RELATIVE "${override}" + "${override}/manifest.json" "${override}/LICENSE" "${override}/NOTICE" + "${override}/logic/*" "${override}/ui/*" "${override}/assets/*") + foreach(source IN LISTS sources) + get_filename_component(directory "${root}/${source}" DIRECTORY) + file(COPY "${override}/${source}" DESTINATION "${directory}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${override}/${source}") + endforeach() + else() + list(FIND pinned_ids "${id}" index) + string(JSON archive GET "${json}" providers ${index} archive) + string(JSON expected GET "${json}" providers ${index} sha256) + set(package "${CMAKE_CURRENT_SOURCE_DIR}/providers/${archive}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${package}") + file(SHA256 "${package}" actual) + if(NOT actual STREQUAL expected) + message(FATAL_ERROR "${package} does not match its pin in providers/lock.json") + endif() + file(ARCHIVE_EXTRACT INPUT "${package}" DESTINATION "${root}") + # Archive times are zeroed for reproducibility; restamp them so rcc + # sees a changed pin as newer than the resource it built before. + file(GLOB_RECURSE extracted "${root}/*") + file(TOUCH_NOCREATE ${extracted}) + endif() + file(GLOB_RECURSE files RELATIVE "${root}" "${root}/*") + list(SORT files) + string(APPEND qrc " \n") + foreach(file IN LISTS files) + string(APPEND qrc " ${root}/${file}\n") + file(SHA256 "${root}/${file}" file_digest) + string(APPEND digest "${id}/${file}:${file_digest}\n") + endforeach() + string(APPEND qrc " \n") + endforeach() + string(APPEND qrc "\n") + file(CONFIGURE OUTPUT "${out}/providers.qrc" CONTENT "${qrc}" @ONLY) + qt_add_resources(resources "${out}/providers.qrc") + target_sources(${target} PRIVATE ${resources}) + # Reproducible builds give every resource the same timestamp, so the QML + # disk cache cannot tell one bundle from the next; this names the bundle. + string(SHA256 digest "${digest}") + string(SUBSTRING "${digest}" 0 12 digest) + target_compile_definitions(${target} PRIVATE SPOOL_PROVIDER_BUNDLE_DIGEST="${digest}") + set(SPOOL_PROVIDER_BUNDLE_SOURCES ${resources} PARENT_SCOPE) +endfunction() diff --git a/cmake/platform/Android.cmake b/cmake/platform/Android.cmake index 9c0d6665..04b8ba42 100644 --- a/cmake/platform/Android.cmake +++ b/cmake/platform/Android.cmake @@ -1,4 +1,4 @@ -function(jellyfin_resolve_android_dependencies) +function(spool_resolve_android_dependencies) set(ANDROID_DEPS_PREFIX "" CACHE PATH "Android media dependency prefix") find_path(MPV_INCLUDE_DIR mpv/client.h HINTS "${ANDROID_DEPS_PREFIX}/include" @@ -22,7 +22,7 @@ function(jellyfin_resolve_android_dependencies) endif() endfunction() -function(jellyfin_configure_android_targets native_target core_target) +function(spool_configure_android_targets native_target core_target) target_sources(${core_target} PRIVATE src/platform/android/AndroidPlatform.cpp # Android is Linux, and every file this reads -- /proc/stat, @@ -34,6 +34,7 @@ function(jellyfin_configure_android_targets native_target core_target) src/platform/common/LinuxPerformanceSampler.cpp src/platform/desktop/DesktopMpvConfigPolicy.cpp src/platform/android/AndroidPlaybackSurface.cpp + src/platform/desktop/DesktopPlaybackRuntime.cpp ) target_sources(${native_target} PRIVATE src/platform/android/AndroidNativeAppWindow.cpp @@ -45,7 +46,6 @@ function(jellyfin_configure_android_targets native_target core_target) src/platform/UpdateController.h src/platform/android/AndroidUpdateInstaller.cpp src/platform/android/AndroidUpdateInstaller.h - src/platform/desktop/DesktopPlaybackRuntime.cpp ) target_link_libraries(${core_target} PUBLIC MPV::MPV) # liblog carries diagnostics to logcat, the only readable output on Android. @@ -87,7 +87,7 @@ function(jellyfin_configure_android_targets native_target core_target) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_LEGACY_PACKAGING TRUE) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR "${android_package_source}") set_property(TARGET ${native_target} PROPERTY QT_ANDROID_PACKAGE_NAME "${android_package_name}") - set_property(TARGET ${native_target} PROPERTY QT_ANDROID_APP_NAME "Spool for Jellyfin") + set_property(TARGET ${native_target} PROPERTY QT_ANDROID_APP_NAME "Spool") set_property(TARGET ${native_target} PROPERTY QT_ANDROID_MIN_SDK_VERSION 28) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_TARGET_SDK_VERSION 36) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_VERSION_NAME "${PROJECT_VERSION}") diff --git a/cmake/platform/AppleMobile.cmake b/cmake/platform/AppleMobile.cmake new file mode 100644 index 00000000..149c139f --- /dev/null +++ b/cmake/platform/AppleMobile.cmake @@ -0,0 +1,34 @@ +function(spool_resolve_apple_mobile_dependencies) + if(NOT SPOOL_APPLE_APP_STORE) + message(FATAL_ERROR "Apple mobile builds require SPOOL_APPLE_APP_STORE=ON") + endif() + set(PKG_CONFIG_ARGN --static) + pkg_check_modules(MPV REQUIRED IMPORTED_TARGET GLOBAL mpv) +endfunction() + +function(spool_configure_apple_mobile_targets native_target core_target) + set(SPOOL_APPLE_CREDENTIAL_SERVICE "com.sachk.spool" CACHE STRING "Apple mobile Keychain service") + target_sources(${core_target} PRIVATE + src/platform/apple/AppleCredentialStore.mm + src/platform/apple/AppleMobilePaths.mm + src/platform/apple/AppleMobilePlatform.mm + src/platform/apple/AppleMobileApplicationServices.mm + src/platform/desktop/UnsupportedPerformanceSampler.cpp + src/platform/desktop/DesktopNativeAppWindow.cpp + src/platform/desktop/DesktopMpvConfigPolicy.cpp + src/platform/desktop/DesktopPlaybackSurface.cpp + src/platform/desktop/DesktopPlaybackRuntime.cpp + ) + target_sources(${native_target} PRIVATE src/platform/common/UnixProcessIntegration.cpp) + target_compile_definitions(${core_target} PUBLIC SPOOL_APPLE_MOBILE=1 + "SPOOL_APPLE_CREDENTIAL_SERVICE=\"${SPOOL_APPLE_CREDENTIAL_SERVICE}\"") + set_source_files_properties( + src/platform/apple/AppleCredentialStore.mm src/platform/apple/AppleMobilePaths.mm + src/platform/apple/AppleMobilePlatform.mm src/platform/apple/AppleMobileApplicationServices.mm + PROPERTIES COMPILE_OPTIONS "-fobjc-arc") + target_link_libraries(${core_target} PUBLIC PkgConfig::MPV + "-framework Security" "-framework UIKit" "-framework AVFoundation" "-framework MediaPlayer") + file(GLOB fonts "${CMAKE_CURRENT_SOURCE_DIR}/qml/fonts/*.otf" "${CMAKE_CURRENT_SOURCE_DIR}/qml/fonts/*.ttf") + set_source_files_properties(${fonts} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/fonts") + target_sources(${native_target} PRIVATE ${fonts}) +endfunction() diff --git a/cmake/platform/Common.cmake b/cmake/platform/Common.cmake index e0cfd7d4..94ba4f90 100644 --- a/cmake/platform/Common.cmake +++ b/cmake/platform/Common.cmake @@ -2,48 +2,56 @@ include(${CMAKE_CURRENT_LIST_DIR}/Android.cmake) include(${CMAKE_CURRENT_LIST_DIR}/Desktop.cmake) include(${CMAKE_CURRENT_LIST_DIR}/Linux.cmake) include(${CMAKE_CURRENT_LIST_DIR}/MacOS.cmake) +include(${CMAKE_CURRENT_LIST_DIR}/AppleMobile.cmake) +include(${CMAKE_CURRENT_LIST_DIR}/TVOS.cmake) include(${CMAKE_CURRENT_LIST_DIR}/Windows.cmake) include(${CMAKE_CURRENT_LIST_DIR}/WebOS.cmake) -macro(jellyfin_resolve_platform_dependencies) +macro(spool_resolve_platform_dependencies) if(ANDROID) - jellyfin_resolve_android_dependencies() - elseif(JELLYFIN_NATIVE_WEBOS) - jellyfin_resolve_webos_dependencies() + spool_resolve_android_dependencies() + elseif(SPOOL_WEBOS) + spool_resolve_webos_dependencies() elseif(WIN32) - jellyfin_resolve_windows_dependencies() + spool_resolve_windows_dependencies() + elseif(CMAKE_SYSTEM_NAME STREQUAL "tvOS") + spool_resolve_apple_mobile_dependencies() elseif(APPLE) - jellyfin_resolve_macos_dependencies() + spool_resolve_macos_dependencies() else() - jellyfin_resolve_linux_dependencies() + spool_resolve_linux_dependencies() endif() endmacro() -function(jellyfin_configure_platform_targets native_target core_target) +function(spool_configure_platform_targets native_target core_target) if(ANDROID) - jellyfin_configure_android_targets(${native_target} ${core_target}) + spool_configure_android_targets(${native_target} ${core_target}) return() endif() - if(JELLYFIN_NATIVE_WEBOS) - jellyfin_configure_webos_targets(${native_target} ${core_target}) + if(SPOOL_WEBOS) + spool_configure_webos_targets(${native_target} ${core_target}) + return() + endif() + if(CMAKE_SYSTEM_NAME STREQUAL "tvOS") + spool_configure_tvos_targets(${native_target} ${core_target}) return() endif() - jellyfin_add_desktop_sources(${native_target} ${core_target}) + spool_add_desktop_sources(${native_target} ${core_target}) if(WIN32) - jellyfin_configure_windows_targets(${native_target} ${core_target}) + spool_configure_windows_targets(${native_target} ${core_target}) elseif(APPLE) - jellyfin_configure_macos_targets(${native_target} ${core_target}) + spool_configure_macos_targets(${native_target} ${core_target}) else() - jellyfin_configure_linux_targets(${native_target} ${core_target}) + spool_configure_linux_targets(${native_target} ${core_target}) endif() endfunction() -function(jellyfin_import_static_platform_plugins native_target) - if(ANDROID) +function(spool_import_static_platform_plugins native_target) + if(ANDROID OR SPOOL_APPLE_MOBILE) return() - elseif(JELLYFIN_NATIVE_WEBOS) - jellyfin_import_static_webos_plugins(${native_target}) + elseif(SPOOL_WEBOS) + spool_import_static_webos_plugins(${native_target}) else() find_library(XKBCOMMON_LIB xkbcommon REQUIRED) target_link_libraries(${native_target} PRIVATE ${XKBCOMMON_LIB}) diff --git a/cmake/platform/Desktop.cmake b/cmake/platform/Desktop.cmake index cfdb1f9e..78c97e8c 100644 --- a/cmake/platform/Desktop.cmake +++ b/cmake/platform/Desktop.cmake @@ -1,11 +1,11 @@ -function(jellyfin_add_desktop_sources native_target core_target) +function(spool_add_desktop_sources native_target core_target) target_sources(${core_target} PRIVATE src/platform/desktop/DesktopNativeAppWindow.cpp src/platform/desktop/DesktopMpvConfigPolicy.cpp src/platform/desktop/DesktopPlaybackSurface.cpp + src/platform/desktop/DesktopPlaybackRuntime.cpp ) target_sources(${native_target} PRIVATE src/platform/desktop/DesktopApplicationServices.cpp - src/platform/desktop/DesktopPlaybackRuntime.cpp ) endfunction() diff --git a/cmake/platform/Linux.cmake b/cmake/platform/Linux.cmake index dbe6c888..54d0e8e4 100644 --- a/cmake/platform/Linux.cmake +++ b/cmake/platform/Linux.cmake @@ -1,4 +1,4 @@ -function(jellyfin_resolve_linux_dependencies) +function(spool_resolve_linux_dependencies) pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) find_package(OpenGL REQUIRED) # The compositor is the only thing on Linux that knows what the display can @@ -9,7 +9,7 @@ function(jellyfin_resolve_linux_dependencies) REQUIRED NO_CMAKE_FIND_ROOT_PATH) endfunction() -function(jellyfin_configure_linux_targets native_target core_target) +function(spool_configure_linux_targets native_target core_target) # Keep the pinned upstream XML in source; scanner output belongs to the # build tree. Provenance and its retained license live beside the XML. set(color_management_xml diff --git a/cmake/platform/MacOS.cmake b/cmake/platform/MacOS.cmake index 1b478ba5..39819f71 100644 --- a/cmake/platform/MacOS.cmake +++ b/cmake/platform/MacOS.cmake @@ -4,11 +4,11 @@ if(APPLE) set(CMAKE_OBJCXX_STANDARD_REQUIRED ON) endif() -function(jellyfin_resolve_macos_dependencies) +function(spool_resolve_macos_dependencies) pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) endfunction() -function(jellyfin_configure_macos_targets native_target core_target) +function(spool_configure_macos_targets native_target core_target) if(NOT SPOOL_MACOS_CREDENTIAL_SERVICE MATCHES "^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$") message(FATAL_ERROR "SPOOL_MACOS_CREDENTIAL_SERVICE must be a non-empty reverse-DNS service name") @@ -20,6 +20,8 @@ function(jellyfin_configure_macos_targets native_target core_target) src/platform/macos/MacOSCredentialStore.cpp src/platform/macos/MacOSSystemProbes.cpp src/platform/macos/MacOSTitlebar.mm + src/platform/macos/MacOSDisplayOutput.mm + src/platform/macos/MacOSDisplayOutput.h src/platform/desktop/UnsupportedPerformanceSampler.cpp ) target_compile_definitions(${core_target} PRIVATE @@ -31,17 +33,17 @@ function(jellyfin_configure_macos_targets native_target core_target) src/platform/macos/MacOSPlatformStartup.cpp src/platform/common/UnixProcessIntegration.cpp ) - if(JELLYFIN_MACOS_ICON) - set_source_files_properties("${JELLYFIN_MACOS_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) - target_sources(${native_target} PRIVATE "${JELLYFIN_MACOS_ICON}") + if(SPOOL_MACOS_ICON) + set_source_files_properties("${SPOOL_MACOS_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(${native_target} PRIVATE "${SPOOL_MACOS_ICON}") endif() target_link_libraries(${core_target} PUBLIC PkgConfig::MPV "-framework Security" "-framework AppKit") target_link_libraries(${native_target} PRIVATE "-framework IOKit") set_target_properties(${native_target} PROPERTIES MACOSX_BUNDLE TRUE - MACOSX_BUNDLE_BUNDLE_NAME "Spool for Jellyfin" + MACOSX_BUNDLE_BUNDLE_NAME "Spool" MACOSX_BUNDLE_GUI_IDENTIFIER "com.sachk.spool" - MACOSX_BUNDLE_ICON_FILE "jellyfin-native.icns" + MACOSX_BUNDLE_ICON_FILE "spool.icns" MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" INSTALL_RPATH "@executable_path/../Frameworks" diff --git a/cmake/platform/TVOS.cmake b/cmake/platform/TVOS.cmake new file mode 100644 index 00000000..1061135e --- /dev/null +++ b/cmake/platform/TVOS.cmake @@ -0,0 +1,74 @@ +function(spool_configure_tvos_targets native_target core_target) + set(SPOOL_APPLE_BUNDLE_IDENTIFIER "com.sachk.spool" CACHE STRING "Apple TV application identifier") + if(NOT SPOOL_APPLE_BUNDLE_IDENTIFIER MATCHES "^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$") + message(FATAL_ERROR "SPOOL_APPLE_BUNDLE_IDENTIFIER must be a reverse-DNS application identifier") + endif() + spool_configure_apple_mobile_targets(${native_target} ${core_target}) + target_sources(${native_target} PRIVATE + src/platform/tvos/TvOSRemoteInput.mm + src/platform/tvos/TvOSRemoteInput.h + ) + set_source_files_properties(src/platform/tvos/TvOSRemoteInput.mm + PROPERTIES COMPILE_OPTIONS "-fobjc-arc") + set_target_properties(${native_target} PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/app/tvos/Info.plist.in" + MACOSX_BUNDLE_GUI_IDENTIFIER "${SPOOL_APPLE_BUNDLE_IDENTIFIER}" + MACOSX_BUNDLE_BUNDLE_NAME "Spool" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "3" + XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "App Icon & Top Shelf Image" + ) + set_source_files_properties(app/tvos/PrivacyInfo.xcprivacy + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(${native_target} PRIVATE app/tvos/PrivacyInfo.xcprivacy) + if(SPOOL_TVOS_ASSETS) + set_source_files_properties("${SPOOL_TVOS_ASSETS}" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(${native_target} PRIVATE "${SPOOL_TVOS_ASSETS}") + endif() + # Exercise the existing renderer consumer test on the actual UIKit/EAGL + # target, not a Linux/macOS substitute or an import-only Qt probe. + qt_add_executable(spool-tvos-playback-smoke + tests/TestRunner.cpp + tests/player/MpvVideoItemTest.cpp + tests/platform/TvOSRuntimeSmoke.mm + ) + target_include_directories(spool-tvos-playback-smoke PRIVATE src tests "${CMAKE_CURRENT_BINARY_DIR}/generated") + target_link_libraries(spool-tvos-playback-smoke PRIVATE ${core_target}) + set_target_properties(spool-tvos-playback-smoke PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_GUI_IDENTIFIER "com.sachk.spool.playback-smoke" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "3" + ) + install(TARGETS spool-tvos-playback-smoke BUNDLE DESTINATION smoke) + set_source_files_properties(tests/platform/TvOSRuntimeSmoke.mm PROPERTIES COMPILE_OPTIONS "-fobjc-arc") + string(TOLOWER "${CMAKE_OSX_SYSROOT}" tvos_sysroot) + if(tvos_sysroot MATCHES "appletvsimulator") + # Simulator application/Keychain entitlements belong in Mach-O sections, + # not the macOS code signature, where these are restricted entitlements. + foreach(target IN ITEMS ${native_target} spool-tvos-playback-smoke) + get_target_property(identifier ${target} MACOSX_BUNDLE_GUI_IDENTIFIER) + set(xml "${CMAKE_CURRENT_BINARY_DIR}/${target}-simulator.xcent") + set(der "${xml}.der") + file(CONFIGURE OUTPUT "${xml}" CONTENT +" + + +application-identifierSPOOLSMOKE.${identifier} +com.apple.developer.team-identifierSPOOLSMOKE +keychain-access-groupsSPOOLSMOKE.${identifier} + +") + add_custom_command(OUTPUT "${der}" + COMMAND xcrun derq query -f xml -i "${xml}" -o "${der}" --raw + DEPENDS "${xml}" + VERBATIM) + target_sources(${target} PRIVATE "${der}") + target_link_options(${target} PRIVATE + "LINKER:-sectcreate,__TEXT,__entitlements,${xml}" + "LINKER:-sectcreate,__TEXT,__ents_der,${der}") + set_property(TARGET ${target} APPEND PROPERTY LINK_DEPENDS "${xml}" "${der}") + endforeach() + endif() +endfunction() diff --git a/cmake/platform/WebOS.cmake b/cmake/platform/WebOS.cmake index 14a3527e..7bf8b542 100644 --- a/cmake/platform/WebOS.cmake +++ b/cmake/platform/WebOS.cmake @@ -34,7 +34,7 @@ function(find_webos_sysroot_library out_var library_name) set(${out_var} "${${out_var}}" PARENT_SCOPE) endfunction() -function(jellyfin_resolve_webos_dependencies) +function(spool_resolve_webos_dependencies) pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) set(WEBOS_NATIVE_LIB_DIR "${CMAKE_SYSROOT}/usr/local/webos-native/lib") set(WEBOS_SYSROOT_LIB_DIR "${CMAKE_SYSROOT}/usr/lib") @@ -86,7 +86,7 @@ function(jellyfin_resolve_webos_dependencies) endforeach() endfunction() -function(jellyfin_configure_webos_targets native_target core_target) +function(spool_configure_webos_targets native_target core_target) target_sources(${core_target} PRIVATE src/platform/webos/WebOSAudioSyncPolicy.cpp src/platform/webos/WebOSCredentialStore.cpp @@ -117,8 +117,8 @@ function(jellyfin_configure_webos_targets native_target core_target) src/platform/webos/WebOSScreenSaverInhibitor.cpp src/platform/common/UnixProcessIntegration.cpp ) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_NATIVE_WEBOS=1) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_WEBOS=1) endforeach() message(STATUS "Using firmware-derived webOS input protocol tables; do not regenerate them from host Wayland XML") @@ -162,7 +162,7 @@ function(jellyfin_configure_webos_targets native_target core_target) ) endfunction() -function(jellyfin_import_static_webos_plugins native_target) +function(spool_import_static_webos_plugins native_target) find_webos_sysroot_library(XKBCOMMON_LIB xkbcommon) target_link_libraries(${native_target} PRIVATE ${XKBCOMMON_LIB}) qt_import_plugins(${native_target} diff --git a/cmake/platform/Windows.cmake b/cmake/platform/Windows.cmake index 37d2f898..760e575d 100644 --- a/cmake/platform/Windows.cmake +++ b/cmake/platform/Windows.cmake @@ -1,4 +1,4 @@ -function(jellyfin_resolve_windows_dependencies) +function(spool_resolve_windows_dependencies) set(MPV_ROOT "" CACHE PATH "Windows libmpv SDK prefix") find_path(MPV_INCLUDE_DIR mpv/client.h HINTS "${MPV_ROOT}/include" @@ -32,7 +32,7 @@ function(jellyfin_resolve_windows_dependencies) endif() endfunction() -function(jellyfin_configure_windows_targets native_target core_target) +function(spool_configure_windows_targets native_target core_target) # Packaged desktop launches must not allocate a console. Diagnostics are # written to the app-owned log directory even when no stderr handle exists. set_target_properties(${native_target} PROPERTIES WIN32_EXECUTABLE TRUE) @@ -54,9 +54,9 @@ function(jellyfin_configure_windows_targets native_target core_target) ) configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/platform/windows-version.rc.in" - "${CMAKE_CURRENT_BINARY_DIR}/jellyfin-native-version.rc" + "${CMAKE_CURRENT_BINARY_DIR}/spool-version.rc" @ONLY ) - target_sources(${native_target} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/jellyfin-native-version.rc") - target_link_libraries(${core_target} PUBLIC MPV::MPV Advapi32 PRIVATE User32) + target_sources(${native_target} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/spool-version.rc") + target_link_libraries(${core_target} PUBLIC MPV::MPV Advapi32 PRIVATE User32 dxgi) endfunction() diff --git a/cmake/platform/windows-version.rc.in b/cmake/platform/windows-version.rc.in index 33f215d9..1965deb2 100644 --- a/cmake/platform/windows-version.rc.in +++ b/cmake/platform/windows-version.rc.in @@ -13,12 +13,12 @@ BEGIN BEGIN BLOCK "040904b0" BEGIN - VALUE "CompanyName", "Spool for Jellyfin contributors\0" - VALUE "FileDescription", "Spool for Jellyfin client\0" + VALUE "CompanyName", "Spool contributors\0" + VALUE "FileDescription", "Spool client\0" VALUE "FileVersion", "@PROJECT_VERSION@\0" - VALUE "InternalName", "jellyfin-native\0" - VALUE "OriginalFilename", "jellyfin-native.exe\0" - VALUE "ProductName", "Spool for Jellyfin\0" + VALUE "InternalName", "spool\0" + VALUE "OriginalFilename", "spool.exe\0" + VALUE "ProductName", "Spool\0" VALUE "ProductVersion", "@PROJECT_VERSION@\0" END END diff --git a/docs/debug/lifecycle-diagnostics.md b/docs/debug/lifecycle-diagnostics.md index e4f0409f..f783763f 100644 --- a/docs/debug/lifecycle-diagnostics.md +++ b/docs/debug/lifecycle-diagnostics.md @@ -1,11 +1,11 @@ # Lifecycle Diagnostics -Build with `-DJELLYFIN_DIAGNOSTICS=ON` to enable debug-only evidence for stale process and slow relaunch issues. +Build with `-DSPOOL_DIAGNOSTICS=ON` to enable debug-only evidence for stale process and slow relaunch issues. Optional flags: -- `-DJELLYFIN_DIAGNOSTICS_STACKDUMP=ON` requests `gdb` stack dumps when available. -- `-DJELLYFIN_DIAGNOSTICS_ABORT_ON_HANG=ON` aborts after watchdog evidence is written. +- `-DSPOOL_DIAGNOSTICS_STACKDUMP=ON` requests `gdb` stack dumps when available. +- `-DSPOOL_DIAGNOSTICS_ABORT_ON_HANG=ON` aborts after watchdog evidence is written. Generated files: @@ -24,9 +24,9 @@ webOS helpers: Simulation knobs: -- `JELLYFIN_DIAGNOSTICS_DIR=/tmp/com.sachk.spool-diagnostics` overrides the output directory. -- `JELLYFIN_DIAGNOSTICS_BLOCK_GUI_MS=8000` blocks the GUI thread after startup to test watchdog capture. -- `JELLYFIN_DIAGNOSTICS_SHUTDOWN_HANG_MS=8000` blocks shutdown to test shutdown-stall capture. +- `SPOOL_DIAGNOSTICS_DIR=/tmp/com.sachk.spool-diagnostics` overrides the output directory. +- `SPOOL_DIAGNOSTICS_BLOCK_GUI_MS=8000` blocks the GUI thread after startup to test watchdog capture. +- `SPOOL_DIAGNOSTICS_SHUTDOWN_HANG_MS=8000` blocks shutdown to test shutdown-stall capture. Typical stale-process flow: @@ -34,3 +34,42 @@ Typical stale-process flow: 2. Launch, wait for home, close, then relaunch after the slow/no-op symptom. 3. Run `tools/webos/collect-diagnostics-bundle.sh root@tv.local`. 4. Inspect `stale-processes.json`, `current-instance.json`, `lifecycle.jsonl`, and `watchdog/watchdog.jsonl` first. + +## Desktop Wayland fullscreen + +Native fullscreen requests must not synchronously set mpv's `fullscreen` +property on the GUI thread. Qt's threaded scene graph depends on that thread +for synchronization and queued video updates; mpv may wait for the scene graph +to render or report a swap. The resulting circular wait ends at mpv's VO timeout, +not when the compositor configures the window. Queue the property change and +renew its observation after the latest reply so stale echoes cannot reverse a +newer window request. Custom mpv fullscreen bindings still control the native +window. No timer, forced redraw loop, SDR fallback, or surface recreation is +needed. + +Runtime color-diagnostic snapshots have the same dependency and are collected +on the existing mpv event thread, using that event loop's lifetime-owned handle. +They read no GUI state and retain the same color/HDR properties; a playback +restart can no longer hold up a simultaneous window resize to collect them. + +The `mpv-video-item-fullscreen` and `mpv-video-item-fullscreen-vulkan` consumer +tests exercise native enter/exit while idle, playing, paused, and stopped, plus +rapid requests and custom mpv bindings. Each transition prints the synchronous +call time and time to a resized swapped frame. They require a real graphics +surface and follow the existing `mpv-video-item*` GPU-test exclusion in headless +CI; `offscreen` alone does not verify compositor configure/presentation behavior. + +To measure without opening a window on the user's desktop, start an isolated +headless compositor (for example `nix-shell -p weston --run +'weston --backend=headless --renderer=gl --width=1920 --height=1080 +--socket=spool-fullscreen-test --no-config --idle-time=0'`), then run the built +tests from another shell: + +```sh +nix develop .#native -c env QT_QPA_PLATFORM=wayland WAYLAND_DISPLAY=spool-fullscreen-test \ + ctest --test-dir build/linux-dev/app -R '^mpv-video-item-fullscreen' -V +``` + +A headless compositor exercises actual Wayland configuration, swapchain resize, +and embedded video rendering, but not the user's compositor animations, physical +display presentation, or HDR output. Those require a separate hardware check. diff --git a/docs/debug/memory-profiling.md b/docs/debug/memory-profiling.md index 77d7eeb3..a9e63270 100644 --- a/docs/debug/memory-profiling.md +++ b/docs/debug/memory-profiling.md @@ -16,16 +16,16 @@ for breaking down a resident heap footprint. webOS sysroot has no elfutils/libdw, so the device only records a *raw* trace; symbolisation happens on the desktop. Runtime deps (libunwind, libstdc++ with `CXXABI_1.3.15`) are satisfied by the TV + the app's bundled `libstdc++.so.6.0.33`. -- **Launch shim:** `app/bin/jellyfin-native` is a tiny shell shim. It is a - transparent passthrough to `jellyfin-native.real` **unless** the marker file - `/tmp/jellyfin-heaptrack.on` exists, in which case it `LD_PRELOAD`s heaptrack +- **Launch shim:** `app/bin/spool` is a tiny shell shim. It is a + transparent passthrough to `spool.real` **unless** the marker file + `/tmp/spool-heaptrack.on` exists, in which case it `LD_PRELOAD`s heaptrack for that one launch (one-shot) and writes the trace path to - `/tmp/jellyfin-heaptrack.last`. A failed/absent preload is non-fatal, so normal + `/tmp/spool-heaptrack.last`. A failed/absent preload is non-fatal, so normal playback can never break. - **Desktop side (analysis):** `heaptrack_interpret` + `heaptrack_gui` from nixpkgs. Symbols are resolved against a sysroot assembled from the live process's own `/proc//maps` (exactly the loaded modules) plus the - unstripped `build/jellyfin-native.unstripped` via `--extra-paths` (build-id + unstripped `build/spool.unstripped` via `--extra-paths` (build-id matched). ## Build and install the recorder @@ -59,7 +59,7 @@ Useful options: `--no-stop` (leave the app running; partial-but-live trace), instead of from the first play event, or `PLAY_WAIT=SEC` to change that wait. Output lands in `build/memory/heaptrack//`: -`heaptrack.jellyfin.gz` (open in `heaptrack_gui`), `summary.txt` +`heaptrack.spool.gz` (open in `heaptrack_gui`), `summary.txt` (`heaptrack_print`), `maps.txt`, and `smaps_rollup.txt` (RSS breakdown). ## Reading the result for the +350 MB question diff --git a/docs/provider-parity-verification.md b/docs/provider-parity-verification.md new file mode 100644 index 00000000..2c9a1bf0 --- /dev/null +++ b/docs/provider-parity-verification.md @@ -0,0 +1,72 @@ +# Provider parity verification + +The provider parity plan is implemented across Spool and the sibling Jellyfin, +Emby and Plex providers. The API remains 0.2; optional features negotiate exact +extension versions per account. Local provider builds use the sibling working +trees. Published provider pins are unchanged until a separately authorized release. + +Verified again on 2026-09-30 after the provider UI/local-library cutover: the +local-provider release build succeeded, all 98 non-GPU tests passed, all six +provider contract runs passed, and all three provider archives passed validation. + +Additional isolated checks exercised the real Jellyfin password form, Emby Connect +PIN/membership selection and protected Plex Home flow against scripted provider +contexts. A loopback server required the generated Jellyfin trickplay URL's account +token, and Qt fetched and rendered the expected colored pixels. Local-provider tests +exercise opt-in setup/cancellation, overlapping folders, configuration while disabled, +re-enabling the changed library, and decoded/cached video thumbnail pixels. +The artwork bridge regression additionally checks inherited image ownership and +rendered pixels for native/JavaScript-backed rows through first population, +replacement and resizing. It also passed with the application's compiled QML +cache objects linked into the isolated runner. Enabled and disabled login button +fills were checked as blue in an offscreen render. + +The same three-pass, cold-route offscreen software benchmark measured Settings median +wall time at 44.0 ms before and 31.1 ms after, and GUI CPU time at 22.8 ms before and +13.6 ms after. Settings constructed 9 rather than 13 delegates. Other routes' construction +costs were lower or similar, but presentation waits varied and did not improve uniformly. +These are isolated empty-library measurements, not a claim about GPU presentation, +large live-server libraries, or physical input-to-display latency. + +## Reproduce the checks + +From the Spool repository: + +```sh +nix run .#local-providers-build +nix develop .#native -c ctest --test-dir build/linux-release-local-providers/app \ + -E '^mpv-video-item' --parallel 8 --output-on-failure +``` + +The two excluded mpv rendering tests require a GPU. Provider contracts also run +against each sibling's `tests/contract.mjs` with `provider-contract-runner`, both +normally and with `QV4_FORCE_INTERPRETER=1`. + +`extension-integration` uses real Settings, SettingsSync, ProviderRegistry, +SourceHub and RemoteTargets controllers, temporary storage, a stateful loopback +HTTP server and actual QML surfaces. It verifies native and Spool document writes, +confirmed read-back states, keyboard sync opt-out, stale responses after an account +change, remote target selection without playback, removal of the second duplicate +queue entry, and rejected/successful PIN submission through a provider surface. +Existing native and provider tests cover service-specific protocols, activation +families, generation races, old-host compatibility and settings convergence. + +For screenshots, supply an output directory: + +```sh +nix develop .#native -c env SPOOL_INTEGRATION_CAPTURES=/tmp/spool-extension-captures \ + ctest --test-dir build/linux-release-local-providers/app \ + -R '^extension-integration$' --output-on-failure +``` + +The test captures both Settings and Subtitle Appearance at 1280×720, 1920×1080, +and 3840×2160, at 100% and 150% zoom, in confirmed, pending, error and opted-out +states: 48 images. It uses the application's bundled fonts and offscreen software +rendering. It does not open a desktop window or contact a real media server. + +## Limits + +Loopback and protocol fixtures verify host behavior and request contracts; they do +not establish support on every server/device version. Live Jellyfin/Emby/Plex, +Plex Companion devices, two physical devices and TVs were not exercised. No TV +build/deployment, push, tag or release is part of this completion. diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 00000000..a511a01a --- /dev/null +++ b/docs/providers.md @@ -0,0 +1,452 @@ +# Providers + +Everything Spool plays comes from a provider: a package of JavaScript and QML that knows one kind of +source (a Jellyfin server, a service, a folder). The app itself knows none of them. This page is how +the pieces fit; `sdk/README.md` and `sdk/provider.d.ts` are the provider author's side. + + +## Release installation flow + +Spool 0.8.12 bundles Jellyfin 0.2.9, Emby 0.1.5 and Plex 0.1.6, pinned to their +published release archives. Saved accounts normally open Home. The provider +chooser is used to add a server or sign in as another viewer. Selecting an +uninstalled provider downloads, verifies and installs it, then opens sign-in; +selecting an available update likewise continues into sign-in. +A modal reports download progress and the verification/installation stage, then +closes automatically on success or failure. Settings retain the existing provider +update policy; background updates use the same progress display. + +## Moving parts (`src/provider/`) + +| | | +| --- | --- | +| `ProviderPackage` | Reads a `.tar.zst` (ustar + zstd, vendored decoder in `third_party/zstd`), validates manifest format 2 and every path, installs versions through a staging directory | +| `ProviderRegistry` | Every module (bundled at `qrc:/providers//`, installed under the data directory; newest wins) and every account. Starts enabled accounts, owns setup drafts, screens (`ProviderUiContext`) and `pick()` | +| `ScriptRuntime` / `ScriptBridge` | One worker thread and QJSEngine per module; `createSource(configuration, host)` per account; host HTTP, sockets, timers, discovery, events. Only snake_case error codes cross back | +| `PortableProvider` | One running account as a `Provider`: catalogue, search, item state, playback and artwork from its operations and URL templates | +| `SourceHub` | The single `Provider` the app sees. Scopes IDs as `<8 hex of account>:`, fans list requests out to every account in parallel and interleaves them, routes everything else to the owning account. Search is planned per server (below) and shown as each answer lands | +| `ProviderStore` | `official.json` and `index.json` from spool-player/spool-providers (Pages), install by link, update checks and the `providers/updates` policy | +| `app/GroupPlaybackController` | Watching together over whichever account the group is on: clock, drift, buffering, queue handoff. Providers translate their protocol into `group` events | + +`src/providers/local/LocalProvider` is the one native provider: explicitly +selected desktop folders form a library, with no implicit Movies-folder account. +The download service also supplies a dedicated **Downloads** instance on every +platform, using its durable inventory (including Android content documents) +and credential-free media metadata. That inventory remains usable offline and +after removing the originating account; local playback progress persists in +the application data directory. +Native modules may register a compiled QML root for setup/settings; their drafts do +not create a JavaScript runtime. Canonical-path item IDs distinguish same-named files +and deduplicate overlapping roots; the local cache scope is versioned for this cutover. +Local video artwork is extracted on demand through `ArtworkService`'s serial thumbnail +worker using libmpv software rendering and Qt image encoding. +Core never includes `src/providers/`; `tools/check-module-seam.sh` (ctest `module-seam`) enforces it. + +`DownloadSource` negotiates original or finite server-transcoded media independently +of playback `resolve` and reporting. Manifest `downloads` and `downloadTranscode` +capabilities gate choices per owning account; the latter is not inferred from +ordinary streaming quality. `DownloadManager` streams bounded native chunks into +incomplete files/documents, cancels requests, applies account origin/TLS policy, +rejects redirects/playlists, and commits a complete offline inventory only at EOF. +Server session cleanup is memory-only and calls `downloadRelease` after terminal +transfers. See `sdk/README.md` for the exact operation and picker contract. +Download negotiation scopes also own pending provider pickers. Cancelling a +download closes only its picker and rejects stale answers before any second +server operation; ordinary playback and concurrent download pickers are unchanged. +Closing the provider's own choice screen also produces a terminal Cancelled job, +not a server failure. Preparing status covers both viewer choice and negotiation. + +Library grids request rendered artwork only for tiles intersecting the viewport. +Buffered and pooled delegates do not occupy the render queue; scrolling cancels +out-of-view loads so newly visible tiles can load without waiting for earlier rows. +Speculative artwork prefetch remains separate from visible render requests. + +## Accounts + +An account is one sign-in on one provider: its module, a key the provider chose (`user@server`), +a group, a label, the origins it may reach and its configuration. Metadata lives in the durable +database (`providers/accounts/2`); configuration, which holds tokens, lives in the platform credential +store and is read off the GUI thread. Accounts in the same group (users of one server) are +alternatives: using one sets the others aside. Accounts in different groups are shown together. + +Saved accounts open Home by default, combining the selected viewer on each +independent server. A startup activation requiring a PIN remains locked without +opening a chooser. **Profiles & servers** explicitly switches watching users; +pending selection returns Home only after successful activation. A cancelled +switch leaves the current viewer unchanged. Search uses these same selected +identities, never saved alternative profiles or a union of their permissions. + +Changing the browsed account set immediately removes unavailable accounts from the +library list, Continue Watching, Next Up and Recently Added while retaining the +remaining accounts' rows. Home content then refreshes with the current libraries. +Pending home/cache and library-list responses from an older account set cannot +restore removed content. + +Library visibility and ordering are local presentation preferences keyed by +opaque scoped library IDs. Hiding a library does not change the retained catalog, +Continue Watching, Next Up, or all-media query scope. Ordinary mouse dragging +scrolls the library row. Right-click or hold opens Move, Hide library, and Show +hidden libraries; only explicit Move enables reordering. Hidden libraries can +be restored individually from this menu or the library heading, even when all +libraries are hidden. Direct navigation resolves IDs against the full catalog. + +Episode completion immediately removes the played episode from Continue Watching +and Next Up, replacing it with a known chronological successor from that same +account-scoped series when available. After the played mutation is acknowledged, +the two rows refresh from the server. Older home/cache requests and eventually +consistent responses cannot restore the completed episode or erase a newer local +resume update. + +Search dedupes same-server item IDs, then dedupes across independent sources using +database identifiers scoped to the media type. Identifier namespaces are case-insensitive; +IMDb, TMDb and TVDb values normalize whitespace, supported URL/protocol forms and numeric +padding. Matching identifiers merge translated titles, but conflicting identifiers in any +shared database keep items separate. Movies and series without a conflicting identifier +can also match an identical case-insensitive title and known year; punctuation and accents +are not removed. Episodes and seasons never merge by title alone, and unknown years do not +trigger title fallback. Results rank by how closely the title matches and then by each +server's own order. The best-ranked original item retains its source-scoped activation and +artwork; its provider icon and server label appear on every search card. A new query cancels +the last one's operations. + +A provider's login screen completes with the account; the registry keeps the origins the viewer +allowed during setup, never ones the provider claims. Authentication and incomplete-configuration +failures (`http_401`, `auth_required`, `invalid_token`, `invalid_config`) expose an actionable +sign-in state without saving failed candidate credentials. Incorrect PIN answers stay retryable. +On first launch after upgrading, sign-ins saved by the old native Jellyfin client become Jellyfin accounts. + +## Where providers come from + +- **Bundled**: `providers/lock.json` pins each package by SHA-256 (Jellyfin, Emby, Plex, + Stremio and Open Movies, from their `spool-player` repositories); CMake checks and unpacks it into + a resource at configure time. `-DSPOOL_PROVIDER_OVERRIDES=id=/path/to/checkout` replaces matching + pins with working trees and adds supplied provider IDs absent from the lock. Unspecified pins + remain unchanged; empty overrides preserve release behavior. Pin published release assets. +- **Store**: spool-player/spool-providers lists reviewed releases; first-party ids (`spool.*`) follow + their own releases automatically, community ones change through pull requests. +- **Link**: a GitHub or GitLab project, a release's `.tar.zst`, or any site serving + `spool-provider.json`. Updated from the same feed. + +Every download is installed only when its SHA-256 matches the entry. Installing a newer version +restarts that module's accounts in place. + +Stremio owns its add-on setup/settings and stream/torrent-file picker. It reads +trusted Stremio add-on catalogues and metadata; HTTP streams play directly, +while torrent choices require a configured external Stremio-compatible streaming +server. Spool does not embed a torrent engine. Connection origins are approved +explicitly, including discovered add-on catalogue redirects, without wildcard +grants. Stremio offers Original downloads only for finite files, not HLS/DASH +playlists or server-converted quality options. + +Downloaded code is interpreted JS and QML in the app's process, not a sandbox. No store forbids +the interpreter itself (Qt runs QML without a JIT on iOS), but store review decides what downloaded +code may add. `-DSPOOL_PROVIDER_SOURCES=` chooses what a build accepts: + +| Value | Store catalogues | By link | Installed from disk | For | +| --- | --- | --- | --- | --- | +| `open` (default) | yes | yes | yes | Every release build: GitHub, AUR, webOS, direct APKs | +| `curated` | yes | no | yes | Reviewed catalogues where the target store accepts this model | +| `bundled` | no | no | no | A first store submission, or a store that rejects `curated` | + +A bundled build never loads installed packages or their store-origin metadata, schedules no +provider update check, and makes no store/feed requests. Provider update prompts remain empty, +regardless of a saved automatic-update preference. Existing downloaded packages are left on disk +but cannot override the bundled copies; switching back to an open or curated build restores the +usual installed-package selection. + +For webOS, `build-ipk.sh` accepts `SPOOL_PROVIDER_SOURCES` and `SPOOL_PROVIDER_OVERRIDES` +as environment variables and passes them to CMake on each app configure. Overrides are a +semicolon-separated list of `id=/absolute/path` pairs. With neither variable set, each configure +explicitly restores `open` and the pinned bundles, even when reusing a build directory previously +configured for local providers. Run `./build-ipk.sh` without a phase argument for the full pipeline. + +A curated build also ignores `--provider-store`/`SPOOL_PROVIDER_STORE` and never follows the feed +of a provider an earlier open build added by link. Curation is not an App Store approval guarantee: +downloaded JS/QML still needs review against Apple's downloaded-software rules and the conditions +of guideline 4.7, including native-API exposure. Initial Apple submissions should use bundled +providers, with provider code changes delivered through application updates. + +Apple App Store builds additionally set `-DSPOOL_APPLE_APP_STORE=ON`. This forces +`bundled` provider loading and includes only pins whose `appleAppStore` field is +explicitly `true`; missing or false approval excludes a provider. Checkout overrides +are rejected, so a local checkout cannot bypass the reviewed release pin. +The curated catalogue maintains the same required boolean independently of provider +release feeds; pin updates must copy that reviewed value into `providers/lock.json`. +Jellyfin, Emby, Plex and Open Movies are included. Stremio is excluded by default. + +## Catalogue continuation and queues + +Browse pages retain provider cursors verbatim through the catalogue, hub, browse session +and warm cache. The first request omits `cursor`; subsequent requests return the last opaque +token, independently of the UI row offset. Only `exhausted` ends a listing: short or empty +pages may continue, but each nonterminal page must supply a nonempty, previously unseen +cursor or fail with `invalid_pagination`. Changing account, descriptor, filters or sort resets +the continuation. + +Requested-count lists collect enough pages to fill their limit. Episodes and seasons collect +all pages, with a 10,000-row ceiling. Every collector has a 256-page ceiling and reports +`response_limit` if it cannot finish within its bounds; ordinary browse appends stay incremental. +Metadata lookup requests contain at most 50 unique IDs, with at most two requests in flight +per account. Results restore the caller's order and duplicate occurrences, omitting missing IDs. +Server-bound group and remote-play queues reject foreign account IDs with `mixed_source_queue` +before invoking the provider. Media/container IDs are account-scoped; playlist entry IDs remain +opaque within their container. + +Normal episode EOF advances the local queue unpaused. If no queued successor is +available, Spool expands that episode's account-scoped series context and selects +the next playable episode. Explicit Stop or replacement playback cancels pending +lookup and negotiation. Queue edits retain a pending lookup or immediately advance +an explicitly queued successor; a final episode ends the transition without replay. +Group playback remains server-authoritative and can deliberately start paused. + +Stream metadata uses a nonnegative file index or `-1` for unindexed analysis and +sidecars; no other negative metadata integer is accepted. Providers normalize +decimal-string frame rates and ratings to finite numbers, and preserve exact +signed-64-bit ticks and byte sizes as decimal strings. + +### Item menus and collection editing + +Menus request `spool.item-actions` policy only when opened, using the item's owning +account. A negotiated list replaces manifest actions; older providers without that +declaration retain their type-filtered manifest list. Closing a menu cancels its +request, and execution checks the current policy again. Disabled actions retain their +reason rather than claiming that server permissions require an app update. + +`spool.collection-editing` exposes **Manage entries** on playlist/collection menus. +The shared editor reads native-order pages of at most 50 entries, retaining duplicate +media as distinct opaque entry IDs. Remove and move controls follow `collectionInfo`; +smart/read-only lists cannot gain controls from another account's capabilities. +Moving down across the loaded boundary first reads the adjacent page, not the whole +container. Both `index` and `afterEntryId` describe the destination after removing the +moving occurrence. + +One mutation runs at a time. Success and uncertain errors both refresh the loaded +prefix and permissions; the host never retries an uncertain mutation. Selection stays +on its occurrence, or the nearest surviving row when removed. Refreshes obey the +256-page/10,000-entry bounds and cursor checks, and support loss or account removal +cancels the editor and clears stale entries. + +## Artwork ownership + +Inherited thumbnails and backdrops retain their opaque `thumbItemId` and +`backdropItemId` beside the image tag. `SourceHub` scopes these IDs to the owning +account, and `ArtworkService` selects that owner rather than the child row. +Jellyfin and Emby populate them from parent-image metadata. Home payload schema +13 discards older ownerless cached rows; no account migration or data wipe is needed. + +The QML artwork boundary accepts native items, JavaScript values and materialized +item maps without dropping series/album artwork, thumbnails, backdrops, logos, +banners or their owner IDs. `artwork-integration` renders loopback images through +the real image provider before and after first population, row recreation and +card resizing, and checks the rendered pixels as well as inherited ownership. + +## Screens + +Generic provider forms live in the app's precompiled `Spool` module: +`ServerLogin`, `ServerIdentityRow`, `ProviderLinkScreen`, `ProviderCodePanel`, +`ProviderActionPicker`, `ProviderRemoteControls`, and `ProviderCompatibilityNotice`. +Provider QML supplies protocol operation names, labels, capabilities and genuinely +service-specific flows (such as Connect membership selection and Home activation), +not duplicate form, list, navigation or PIN layouts. Providers using these forms +require the matching Spool build; there is no duplicate runtime-QML fallback. +`ServerLogin` keeps discovery, origin approval and asynchronous sign-in generations +separate; cancellation invalidates pending password/code results. Its selected +server name and address stay together above the account fields. +Login action buttons use a blue fill, including a darker blue disabled state, +so actions remain visually distinct from charcoal username/password fields. + + +Every packaged `*.qml` file from the registry's selected packages (including installed overrides) +is queued for compilation on the window's own `QQmlEngine`. Warmup starts 2.5 seconds after the +first rendered frame, then schedules package enumeration and one asynchronous `QQmlComponent` +load at a time through low-priority events, with 100 ms gaps. Normal-priority foreground events +run before each queued job; Qt owns the asynchronous type-loader work. No engine or component +is moved across threads. + +The cache retains compiled components, never instances: warming does not evaluate screen +bindings, run `Component.onCompleted`, start timers or authenticate. This includes every declared +UI role and nested helpers loaded dynamically, not just `manifest.ui` entries. Enumeration stays +inside selected local/resource package roots without following symlinks; validated packages +contain at most 512 files. Registry changes enqueue new packages and evict obsolete versions. +Remote URLs are not warmed, and a malformed component does not stop the remaining queue. + +This is best-effort latency reduction, not a guarantee that opening a screen never compiles: +viewers may open it before warmup, and URLs outside the selected packages may still be cold. +Compilation completion still requires engine-thread work; low-priority scheduling +does not impose a real-time frame budget on Qt's compiler. Aggressive memory pressure and +shutdown cancel pending warmup and release retained components; pressure does not restart it. + +Provider update prompts and toast feedback use the shared body typography and +viewport/user scale. Long feedback wraps inside content-sized panels instead +of inheriting Qt's unscaled default text size or clipping inside a fixed-height toast. + +## Connection speed + +New providers with a download-test endpoint declare `spool.speed-test: 1` in +`extensions` and implement `speedTest(args, host)` with `host.speedTest({url, headers})`. +The URL contains `{bytes}` and `{nonce}` placeholders; endpoint paths and authentication +stay inside the provider package. The native worker applies the account's +origin allowlist and TLS trust policy, never follows redirects, and drains +bounded buffers instead of decoding test data into JavaScript strings. + +Jellyfin and Emby supply their authenticated `/Playback/BitrateTest` endpoints. +Plex instead selects an accessible media part of at least 4 MiB and requests +`host.speedTest({url, headers, range: true})`; the native worker sends bounded +byte ranges and requires HTTP 206 with an exact `Content-Range`. No stream is +played or transcoded, and a missing eligible file leaves speed unavailable. +Range mode also supports future fixed-origin file services without requiring +an artificial download endpoint. It never falls back to downloading a whole +file when a server ignores Range. + +`SourceHub` probes enabled accounts one at a time after five idle seconds. +Starting playback or other foreground loading cancels an in-flight probe; +pending probes resume when idle. Account removal/restart discards its result. +Measurements are session-local. Failure leaves playback usable and keeps any +earlier successful measurement for that running account. + +The benchmark warms 512 KiB and compares 4 MiB transfers over one and two +connections, adding four when latency or dual-connection improvement warrants +it. It chooses the fewest connections within 85% of the best rate, then reserves +25% headroom on that connection count. The conservative ceiling and lane count +reach `resolve` as `measuredBitrate` and `parallelRequests`; the native player +uses the same lane count. Providers must let explicit quality choices override +the measurement. + +The player's Quality → Auto detail shows the active account's measured limit. +Settings → Streaming → Connection speed shows each enabled account and offers +“Measure again”; this remains deferred during playback. Providers without the +capability do not acquire a speed-test control or a measured ceiling. + +Quality ceilings remain provider-neutral: explicit player bitrate, then +server-proven unlimited LAN, then the settings preference, then measured +throughput and a documented fallback. Height limits are independent. Providers +translate those ceilings into server negotiation or variant selection; remux +preferences never authorize serving an original above the ceiling. See +[`sdk/README.md`](../sdk/README.md#quality-policy) for Plex's unit conversion and +the source-selection policy for future Stremio-style providers. A catalogue +host speed test cannot stand in for an unrelated CDN or torrent route. + +## Settings synchronization + +Sync defaults on and selects the first eligible active account in persistent +connection order, waiting for earlier starting accounts. The chosen account is +independent of playback and is never replaced automatically on failure, +disablement, locking or removal. A source change requires confirmation because +it discards unsent intent and starts a fresh remote-first bootstrap. + +Writable native audio/subtitle preferences take precedence. Other eligible +settings use the provider's optional Spool-specific document storage, never both +channels for one key. Plex has no such writer/store. Interface scale, credentials, +trust, device identity, paths, playback sessions, caches and sync controls never +leave the device. Portable settings default on; device-specific scalar settings +require per-account opt-in. System subtitle fonts require opt-in in both directions. + +The Spool document uses decimal logical counters and random per-edit nonces, +not clocks or device identifiers. Retained per-key maxima repair replacement-store +races when clients reconnect, including edits overwritten after acknowledgment. +DisplayPreferences provides eventual convergence, not atomic distributed writes +or a durability guarantee for a permanently disconnected client. Real CAS stores +use their revisions; unsupported conditions are never simulated. + +Local values and intent commit transactionally. Edits during bootstrap remain +provisional until a remote read establishes the counter baseline. Opt-outs keep +local values and discard unsent intent without deleting remote data. Offline +intent remains durable; malformed/future documents stop writes rather than reset +cloud data. Locale/latency retain their canonical stores and recover only unfinished +application journals, never old replicas merely because local values differ. + +Changes debounce for 500 ms; one cycle runs at a time. Foreground refresh is +bounded to once per minute, failures have a 30-second automatic retry floor, and +suspended/off/locked sources do not poll. Editing rows defer remote applications; +active playback defers the four track defaults until idle or the next explicit +new-item handoff. Existing session and remembered-series selections remain prior. + +Settings presents one **Settings sync** entry in Accounts rather than repeating +connection badges beside each preference. Its dedicated page shows the selected +account, one overall status, and an appropriate sign-in, profile switch, account +selection or retry action. Switching profiles explicitly warns that it changes +who is watching on that server; account changes retain the existing confirmation. +The account picker includes inactive capable accounts using their manifest +declarations, with a state label, without pretending retry can reconnect them. + +The **Recommended** preset includes portable preferences. Streaming limits, like +other device-specific settings, require opt-in. **What syncs** discloses category +toggles only on request; category updates invalidate and persist once, retaining +per-account consent. **Use recommended settings** clears that account's overrides +and performs a fresh remote-first bootstrap for affected keys. Hidden/playback +preferences are included by category without duplicate value editors. + +Normal settings and subtitle appearance retain one vertical navigation stop per +value. Horizontal keys edit values rather than entering a sync badge. OK/Back +enter/leave slider or text editing; edit locks still defer remote applications. +The sync page's recovery buttons support D-pad traversal into and out of its +settings list. Buttons and setting/toggle rows expose accessible names, state +and activation. Host login uses one primary action with local feedback and +secondary alternatives. Account/provider removal requires a Cancel-first +confirmation; destructive provider action pickers also start on Cancel. + + +## Outbound playback devices + +`spool.remote-targets` is independent of inbound `remoteControl` and the local +“Allow remote control” preference. The chooser lists This device first and +loads enabled accounts progressively. Selecting a peer never starts or transfers +media. Transfer is explicit; failed remote starts leave the local queue intact. +Incoming commands, SyncPlay, automatic local advancement and CLI `--play` stay local. + +Only the selected peer is polled: one second while controls are visible, three +seconds while attached but hidden, never while suspended. Queue pages are read +on demand/revision changes, or at most every five seconds for an open unversioned +queue. Optimistic commands reject older snapshots. Target IDs are account-scoped; +entry IDs remain opaque, and mixed-account remote play is rejected before dispatch. + +Jellyfin/Emby queue editing replaces the target queue and therefore restarts +playback while preserving the current occurrence/position and paused state where +possible. Plex uses verified Companion identity, exact-origin consent and PMS +entry IDs. Peer playback receives only a transient delegation token, never +account/Home/PMS credentials. Missing play support leaves available transport +controls intact. Ambiguous duplicate selection is not advertised on key-only peers. +Advanced navigation/text commands remain provider-owned operations; their thin +picker adapters configure the shared, precompiled `ProviderRemoteControls` surface. + +Protected playback previews use account-scoped `spool-artwork://` resources, +resolved privately by `SourceHub`. `TrickplayService` loads local and remote +sprite sheets or whole BIF sequences on a dedicated worker. Sheets fitting +the 50,000,000-byte decoded RGB32 budget expose stable sheet `image://` URLs +and per-thumbnail crop offsets; larger JPEGs and BIF use single-frame URLs. +Local descriptors use the owning account's playback headers; remote descriptors +may supply their account/device `headers`. +Remote previews do not require starting local playback. Requests stay on the +approved origin, reject foreign-origin redirects, disable cookies and bypass +Qt's URL-only disk cache. Encoded and decoded texture caches are bounded and +session-isolated; removed accounts cannot resolve old resources, even from memory. +Directional prefetch is limited to one neighbouring sheet or two BIF frames +and yields to foreground requests. Both preview surfaces consume the latest +selection once per rendered frame and clip resident sheets to the selected tile. + +Raw JPEG decoding links the codec selected by the Qt toolchain. Official SDKs +using Qt's bundled libjpeg expose its C API at `QtJpeg/jpeglib.h`, through +`Qt6::BundledLibjpeg` and `Qt6::JpegPrivate`; system-codec builds use `jpeglib.h` +and `JPEG::JPEG`. Keep the headers paired with their selected library—do not +copy unrelated JPEG headers or disable planar decoding for a platform. + +These adapters do not implement Plex watch-together or native SpoolLink peer +enhancements. Protocol/loopback verification does not imply live-device support +for an unadvertised backend command. + +## Testing + +- `providers-tests` (ctest `provider-*`, `source-hub`, `script-runtime`, `local-provider`): package + format, registry, hub, store (against a local HTTP server) and screens, with the fixture provider in + `tests/providers/fixtures/`. +- `bundled-jellyfin` runs a small contract against the pinned package in this Qt; each provider + repository carries its full `tests/contract.mjs`. +- `live-jellyfin` runs sign-in to playback against a real server when `SPOOL_LIVE_JELLYFIN`, + `SPOOL_LIVE_USER` and `SPOOL_LIVE_PASSWORD` are set; otherwise it skips. + +The top-right playback-device menu consumes `spool.remote-targets` data and can +mount provider QML for advanced controls in place. `ProviderSurface.embedded` +omits shell page chrome for those sections; `overlay` retains the underlying page +and adds a modal scrim for playback choices and item actions. Successful login +waits for account activation before revealing the library, avoiding a return to +the provider chooser between authentication and activation. diff --git a/flake.nix b/flake.nix index 19c1000a..6b71871d 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Jellyfin webOS native build environment"; + description = "Spool build environment"; nixConfig = { extra-substituters = [ "https://spool.cachix.org" ]; extra-trusted-public-keys = [ "spool.cachix.org-1:yx+E3raAGXiyUNoMEscSu9c85b+WbgoV7swqyY8oL6s=" ]; @@ -555,7 +555,7 @@ native_qt_cmake_path="${pkgs.spoolQt6.qtbase}:${pkgs.spoolQt6.qtdeclarative}:${pkgs.spoolQt6.qtsvg}:${pkgs.spoolQt6.qttools}:${pkgs.spoolQt6.qtwebsockets}:${pkgs.spoolQt6.qtshadertools}" export CMAKE_PREFIX_PATH="$native_qt_cmake_path''${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}" unset native_qt_cmake_path - export JELLYFIN_NATIVE_SHELL=1 + export SPOOL_SHELL=1 # macdeployqt and linuxdeploy-plugin-qt both discover plugins under one # Qt prefix, and every Qt module is a separate store path here. That @@ -644,7 +644,7 @@ runHook preBuild export HOME="$TMPDIR/home" export XDG_CACHE_HOME="$TMPDIR/cache" - export JELLYFIN_NATIVE_SHELL=1 + export SPOOL_SHELL=1 export SPOOL_QT_CMAKE_DIR="${pkgs.spoolQt6.qtbase}/lib/cmake/Qt6" export CMAKE_PREFIX_PATH="${pkgs.spoolQt6.qtbase}:${pkgs.spoolQt6.qtdeclarative}:${pkgs.spoolQt6.qtsvg}:${pkgs.spoolQt6.qttools}:${pkgs.spoolQt6.qtwebsockets}:${pkgs.spoolQt6.qtshadertools}''${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}" mkdir -p "$HOME" "$XDG_CACHE_HOME" @@ -666,7 +666,7 @@ ''; meta = { - description = "Spool for Jellyfin"; + description = "Spool"; license = pkgs.lib.licenses.gpl3Plus; platforms = systems; }; @@ -788,11 +788,11 @@ binaryPath = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/run-install/Spool.app/Contents/MacOS/Spool" - else "build/linux-release/install/bin/jellyfin-native"; + else "build/linux-release/install/bin/spool"; nativeBuildStamp = if pkgs.stdenv.hostPlatform.isDarwin - then "build/macos/.jellyfin-nix-source-id" - else "build/linux-release/.jellyfin-nix-source-id"; + then "build/macos/.spool-nix-source-id" + else "build/linux-release/.spool-nix-source-id"; mpvLibraryPath = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/mpv-prefix/lib" @@ -823,7 +823,7 @@ if pkgs.stdenv.hostPlatform.isDarwin then "$(sysctl -n hw.ncpu)" else "$(nproc)"; - testScript = pkgs.writeShellScript "jellyfin-native-ctest" '' + testScript = pkgs.writeShellScript "spool-ctest" '' set -euo pipefail cd "$1" shift @@ -839,20 +839,25 @@ buildBeforeRun ? false, buildOnly ? false, runTests ? false, + localProviders ? false, + controlCli ? false, + checkoutScript ? "", }: let runnerBinaryPath = + if controlCli then "${buildRoot}/" + (if pkgs.stdenv.hostPlatform.isDarwin then "run-install" else "install") + "/bin/spoolet" + else if buildRoot == "" then binaryPath else if pkgs.stdenv.hostPlatform.isDarwin then "${buildRoot}/run-install/Spool.app/Contents/MacOS/Spool" - else "${buildRoot}/install/bin/jellyfin-native"; + else "${buildRoot}/install/bin/spool"; runnerMpvLibraryPath = if buildRoot == "" then mpvLibraryPath else "${buildRoot}/mpv-prefix/lib"; runnerBuildStamp = - if buildRoot != "" then "${buildRoot}/.jellyfin-nix-source-id" - else if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/.jellyfin-nix-source-id" - else "build/linux-release/.jellyfin-nix-source-id"; + if buildRoot != "" then "${buildRoot}/.spool-nix-source-id" + else if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/.spool-nix-source-id" + else "build/linux-release/.spool-nix-source-id"; buildRootExport = pkgs.lib.optionalString (buildRoot != "") ''export BUILD_ROOT="$REPO_ROOT/${buildRoot}"; ''; runnerBuildCommand = @@ -860,7 +865,7 @@ then ''APP_INSTALL="$REPO_ROOT/${buildRoot}/run-install" DEPLOY_APP=0 exec bash ${buildScript}'' else buildCommand; in pkgs.writeShellScriptBin name '' - export PATH="${pkgs.lib.makeBinPath [ pkgs.nix pkgs.bashInteractive pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH" + export PATH="${pkgs.lib.makeBinPath ([ pkgs.nix pkgs.bashInteractive pkgs.coreutils pkgs.gnugrep pkgs.gnused ] ++ pkgs.lib.optional (localProviders || checkoutScript != "") pkgs.python3)}:$PATH" set -euo pipefail FLAKE_SOURCE="${self}" @@ -871,9 +876,9 @@ } stage_flake_source() { - cache_base="''${XDG_CACHE_HOME:-$HOME/.cache}/jellyfin-native/nix-run" + cache_base="''${XDG_CACHE_HOME:-$HOME/.cache}/spool/nix-run" staged="$cache_base/${stagedSourceId}" - marker="$staged/.jellyfin-staged-source" + marker="$staged/.spool-staged-source" if [ ! -f "$marker" ]; then tmp="$cache_base/.${stagedSourceId}.$$" rm -rf "$tmp" @@ -882,25 +887,39 @@ chmod -R u+w "$tmp" rm -rf "$tmp/mpv" ln -s "$MPV_SOURCE" "$tmp/mpv" - printf '%s\n' "${stagedSourceId}" > "$tmp/.jellyfin-staged-source" + printf '%s\n' "${stagedSourceId}" > "$tmp/.spool-staged-source" rm -rf "$staged" mv "$tmp" "$staged" fi printf '%s\n' "$staged" } - if [ -n "''${JELLYFIN_REPO:-}" ]; then - REPO_ROOT="$JELLYFIN_REPO" + if [ -n "''${SPOOL_REPO:-}" ]; then + REPO_ROOT="$SPOOL_REPO" if ! is_repo_root "$REPO_ROOT"; then - echo "error: JELLYFIN_REPO does not point to a usable jellyfin-webos checkout: $REPO_ROOT" >&2 + echo "error: SPOOL_REPO does not point to a usable spool checkout: $REPO_ROOT" >&2 exit 1 fi elif is_repo_root "$PWD"; then REPO_ROOT="$PWD" else + ${pkgs.lib.optionalString (localProviders || checkoutScript != "") '' + echo "error: run this local-provider workflow from the checkout root or set SPOOL_REPO" >&2 + exit 1 + ''} REPO_ROOT="$(stage_flake_source)" fi cd "$REPO_ROOT" + export REPO_ROOT + + ${pkgs.lib.optionalString (checkoutScript != "") '' + exec bash "$REPO_ROOT/${checkoutScript}" "$@" + ''} + ${pkgs.lib.optionalString localProviders '' + if [ "''${1:-}" = "--dry-run" ]; then + exec bash "$REPO_ROOT/tools/build-local-providers.sh" "$@" + fi + ''} BIN="$REPO_ROOT/${runnerBinaryPath}" BUILD_STAMP="$REPO_ROOT/${runnerBuildStamp}" @@ -910,16 +929,21 @@ scrub='PATH=$(printf %s "$PATH" | tr ":" "\n" | grep -v webos-sdk | paste -sd:); export PATH; unset WEBOS_SDK_ROOT QT_PLUGIN_PATH QML2_IMPORT_PATH QML_IMPORT_PATH' if ${if buildBeforeRun then "true" else "false"}; then - if [ -x "$BIN" ] && [ -f "$BUILD_STAMP" ] && [ "$(cat "$BUILD_STAMP")" = "${stagedSourceId}" ]; then + if ${if localProviders then "false" else "true"} && [ -x "$BIN" ] && [ -f "$BUILD_STAMP" ] && [ "$(cat "$BUILD_STAMP")" = "${stagedSourceId}" ]; then echo "native build is current (${stagedSourceId}); skipping rebuild" else - nix develop "$REPO_ROOT#native" -c bash -c "$scrub; ${buildRootExport}export JELLYFIN_CMAKE_EXTRA_ARGS='${cmakeExtraArgs}'; ${runnerBuildCommand}" + ${if localProviders then '' + # Sibling changes are not part of the flake source ID. + nix develop "$REPO_ROOT#native" -c bash -c "$scrub"'; exec bash "$REPO_ROOT/tools/build-local-providers.sh"' + '' else '' + nix develop "$REPO_ROOT#native" -c bash -c "$scrub; ${buildRootExport}export SPOOL_CMAKE_EXTRA_ARGS='${cmakeExtraArgs}'; ${runnerBuildCommand}" + ''} mkdir -p "$(dirname "$BUILD_STAMP")" printf '%s\n' "${stagedSourceId}" > "$BUILD_STAMP" fi elif [ ! -x "$BIN" ]; then echo "error: native app is not built: $BIN" >&2 - echo "build it first with: nix run .#${if buildRoot == "" then "build" else "image-debug-build"}" >&2 + echo "build it first with: nix run .#${if localProviders then "local-providers-build" else if buildRoot == "" then "build" else "image-debug-build"}" >&2 exit 1 fi @@ -938,16 +962,43 @@ ''; builder = makeRunner { - name = "jellyfin-native-build"; + name = "spool-build"; buildBeforeRun = true; buildOnly = true; }; runner = makeRunner { - name = "jellyfin-native-run"; + name = "spool-run"; buildBeforeRun = true; }; + localProviderBuildRoot = if pkgs.stdenv.hostPlatform.isDarwin + then "build/macos-local-providers" + else "build/linux-release-local-providers"; + localProviderRunner = makeRunner { + name = "spool-local-providers"; + buildRoot = localProviderBuildRoot; + buildBeforeRun = true; + localProviders = true; + }; + localProviderBuilder = makeRunner { + name = "spool-local-providers-build"; + buildRoot = localProviderBuildRoot; + buildBeforeRun = true; + buildOnly = true; + localProviders = true; + }; + spooletRunner = makeRunner { + name = "spoolet"; + buildRoot = localProviderBuildRoot; + localProviders = true; + controlCli = true; + }; + localProviderIpkBuilder = makeRunner { + name = "spool-local-providers-ipk"; + checkoutScript = "local-docs/build-local-providers-ipk.sh"; + }; + # A commit does not change the source tree the checkout build was # made from. Reuse that exact build before asking Cachix for the # immutable package; if there is no matching checkout build, clean @@ -955,7 +1006,7 @@ # retain the incremental build path. defaultRunner = assert builtins.getContext cachedPackageDerivation == {}; - pkgs.writeShellScriptBin "jellyfin-native-default" '' + pkgs.writeShellScriptBin "spool-default" '' export PATH="${pkgs.lib.makeBinPath [ pkgs.nix pkgs.bashInteractive pkgs.coreutils ]}:$PATH" set -euo pipefail @@ -963,10 +1014,10 @@ [ -f "$1/CMakeLists.txt" ] && [ -f "$1/tools/build-macos.sh" ] && [ -f "$1/mpv/meson.build" ] } - if [ -n "''${JELLYFIN_REPO:-}" ]; then - CHECKOUT="$JELLYFIN_REPO" + if [ -n "''${SPOOL_REPO:-}" ]; then + CHECKOUT="$SPOOL_REPO" if ! is_repo_root "$CHECKOUT"; then - echo "error: JELLYFIN_REPO does not point to a usable jellyfin-webos checkout: $CHECKOUT" >&2 + echo "error: SPOOL_REPO does not point to a usable spool checkout: $CHECKOUT" >&2 exit 1 fi elif is_repo_root "$PWD"; then @@ -981,12 +1032,12 @@ if [ -x "$BIN" ] && [ -f "$BUILD_STAMP" ] && [ "$(cat "$BUILD_STAMP")" = "${stagedSourceId}" ]; then echo "native checkout build is current (${stagedSourceId}); reusing it" cd "$CHECKOUT" - exec "${runner}/bin/jellyfin-native-run" "$@" + exec "${runner}/bin/spool-run" "$@" fi fi if ! ${if immutableRevision then "true" else "false"}; then - exec "${runner}/bin/jellyfin-native-run" "$@" + exec "${runner}/bin/spool-run" "$@" fi echo "no current checkout build; checking immutable package caches" @@ -1003,27 +1054,27 @@ export QML_IMPORT_PATH="$QML2_IMPORT_PATH" exec "$CACHED_OUT/Applications/Spool.app/Contents/MacOS/Spool" "$@" '' else '' - exec "$CACHED_OUT/bin/jellyfin-native" "$@" + exec "$CACHED_OUT/bin/spool" "$@" ''} ''; # Same build and ctest invocation the release workflow runs. tester = makeRunner { - name = "jellyfin-native-tests"; + name = "spool-tests"; buildBeforeRun = true; runTests = true; }; - noBuildRunner = makeRunner { name = "jellyfin-native-run-no-build"; }; + noBuildRunner = makeRunner { name = "spool-run-no-build"; }; imageDebugRunner = makeRunner { - name = "jellyfin-native-image-debug"; - cmakeExtraArgs = "-DJELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS=ON"; + name = "spool-image-debug"; + cmakeExtraArgs = "-DSPOOL_ARTWORK_ASPECT_DIAGNOSTICS=ON"; buildRoot = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos-image-debug" else "build/linux-release-image-debug"; }; imageDebugBuilder = makeRunner { - name = "jellyfin-native-image-debug-build"; - cmakeExtraArgs = "-DJELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS=ON"; + name = "spool-image-debug-build"; + cmakeExtraArgs = "-DSPOOL_ARTWORK_ASPECT_DIAGNOSTICS=ON"; buildRoot = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos-image-debug" else "build/linux-release-image-debug"; @@ -1036,7 +1087,7 @@ # nixpkgs `gammaray` probe must match the app's Qt; the #native shell # builds the app against nixpkgs Qt, so they line up. gammarayRunner = makeRunner { - name = "jellyfin-native-gammaray"; + name = "spool-gammaray"; # QuickInspector updates its scene-graph model on every render and # crashes GammaRay 3.4 during the mpv overlay transition. Keep the # default profiling runner stable; use .#gammaray-full for Quick Scenes. @@ -1044,13 +1095,30 @@ }; gammarayFullRunner = makeRunner { - name = "jellyfin-native-gammaray-full"; + name = "spool-gammaray-full"; launchPrefix = "${pkgs.gammaray}/bin/gammaray "; }; in { build = { type = "app"; - program = "${builder}/bin/jellyfin-native-build"; + program = "${builder}/bin/spool-build"; + }; + + local-providers = { + type = "app"; + program = "${localProviderRunner}/bin/spool-local-providers"; + }; + local-providers-build = { + type = "app"; + program = "${localProviderBuilder}/bin/spool-local-providers-build"; + }; + local-providers-ipk = { + type = "app"; + program = "${localProviderIpkBuilder}/bin/spool-local-providers-ipk"; + }; + spoolet = { + type = "app"; + program = "${spooletRunner}/bin/spoolet"; }; # Prefer an exact checkout build even after its source is committed. @@ -1058,27 +1126,27 @@ # realise the immutable package that release CI publishes to Cachix. default = { type = "app"; - program = "${defaultRunner}/bin/jellyfin-native-default"; + program = "${defaultRunner}/bin/spool-default"; }; run = { type = "app"; - program = "${noBuildRunner}/bin/jellyfin-native-run-no-build"; + program = "${noBuildRunner}/bin/spool-run-no-build"; }; tests = { type = "app"; - program = "${tester}/bin/jellyfin-native-tests"; + program = "${tester}/bin/spool-tests"; }; image-debug = { type = "app"; - program = "${imageDebugRunner}/bin/jellyfin-native-image-debug"; + program = "${imageDebugRunner}/bin/spool-image-debug"; }; image-debug-build = { type = "app"; - program = "${imageDebugBuilder}/bin/jellyfin-native-image-debug-build"; + program = "${imageDebugBuilder}/bin/spool-image-debug-build"; }; } // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { windows-proton-build = { @@ -1107,12 +1175,12 @@ gammaray = { type = "app"; - program = "${gammarayRunner}/bin/jellyfin-native-gammaray"; + program = "${gammarayRunner}/bin/spool-gammaray"; }; gammaray-full = { type = "app"; - program = "${gammarayFullRunner}/bin/jellyfin-native-gammaray-full"; + program = "${gammarayFullRunner}/bin/spool-gammaray-full"; }; }); }; diff --git a/i18n/qtfin_en_US.ts b/i18n/qtfin_en_US.ts index 2e062fb0..59a1246f 100644 --- a/i18n/qtfin_en_US.ts +++ b/i18n/qtfin_en_US.ts @@ -68,8 +68,8 @@ Skip Recap - Spool for Jellyfin - Spool for Jellyfin + Spool + Spool Native Qt 6.11 client for LG webOS diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index b3a6b165..3905f42f 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = spool-bin - pkgdesc = Jellyfin client for desktop and TV, built on Qt Quick and mpv - pkgver = 0.7.18 + pkgdesc = Fast media player for desktop and TV, built on Qt Quick and mpv + pkgver = 0.9.0 pkgrel = 1 url = https://github.com/spool-player/spool arch = x86_64 @@ -9,12 +9,12 @@ pkgbase = spool-bin depends = glibc depends = libglvnd depends = mesa - provides = spool=0.7.18 + provides = spool=0.9.0 conflicts = spool options = !strip options = !debug options = !lto - source = https://github.com/spool-player/spool/releases/download/v0.7.18/Spool-for-Jellyfin-0.7.18-linux-x86_64.tar.zst - sha256sums = 2fc5c8cc36b0c1714360bc1b6151730e1ef5fb396bed0068253f38923d52c9e6 + source = https://github.com/spool-player/spool/releases/download/v0.9.0/Spool-0.9.0-linux-x86_64.tar.zst + sha256sums = SKIP pkgname = spool-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index fe9564d1..272d18b2 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,9 +1,9 @@ # Maintainer: Sacha Korban pkgname=spool-bin -pkgver=0.7.18 +pkgver=0.9.0 pkgrel=1 -pkgdesc='Jellyfin client for desktop and TV, built on Qt Quick and mpv' +pkgdesc='Fast media player for desktop and TV, built on Qt Quick and mpv' arch=('x86_64') url='https://github.com/spool-player/spool' license=('MPL-2.0') @@ -16,11 +16,11 @@ provides=("spool=${pkgver}") conflicts=('spool') # Already stripped, and patchelf has set the RPATHs the bundle needs. options=('!strip' '!debug' '!lto') -_bundle="Spool-for-Jellyfin-${pkgver}-linux-x86_64" +_bundle="Spool-${pkgver}-linux-x86_64" source=("https://github.com/spool-player/spool/releases/download/v${pkgver}/${_bundle}.tar.zst") # Written by tools/update-aur-checksums.sh from SHA256SUMS.txt once the release # above is published; SKIP until then. -sha256sums=('2fc5c8cc36b0c1714360bc1b6151730e1ef5fb396bed0068253f38923d52c9e6') +sha256sums=('SKIP') package() { install -dm755 "$pkgdir/opt/spool" @@ -54,8 +54,8 @@ LAUNCHER "$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/com.sachk.spool.png" done - install -Dm644 "$_bundle/usr/share/jellyfin-native/notices/LICENSE" \ + install -Dm644 "$_bundle/usr/share/spool/notices/LICENSE" \ "$pkgdir/usr/share/licenses/$pkgname/LICENSE" - install -Dm644 "$_bundle/usr/share/jellyfin-native/notices/OPEN_SOURCE_NOTICES.txt" \ + install -Dm644 "$_bundle/usr/share/spool/notices/OPEN_SOURCE_NOTICES.txt" \ "$pkgdir/usr/share/licenses/$pkgname/OPEN_SOURCE_NOTICES.txt" } diff --git a/providers/bundled/example.open-movies-1.1.0.tar.zst b/providers/bundled/example.open-movies-1.1.0.tar.zst new file mode 100644 index 00000000..9111facd Binary files /dev/null and b/providers/bundled/example.open-movies-1.1.0.tar.zst differ diff --git a/providers/bundled/spool.emby-0.1.6.tar.zst b/providers/bundled/spool.emby-0.1.6.tar.zst new file mode 100644 index 00000000..4ff360d4 Binary files /dev/null and b/providers/bundled/spool.emby-0.1.6.tar.zst differ diff --git a/providers/bundled/spool.jellyfin-0.2.10.tar.zst b/providers/bundled/spool.jellyfin-0.2.10.tar.zst new file mode 100644 index 00000000..8d3c6a52 Binary files /dev/null and b/providers/bundled/spool.jellyfin-0.2.10.tar.zst differ diff --git a/providers/bundled/spool.plex-0.1.7.tar.zst b/providers/bundled/spool.plex-0.1.7.tar.zst new file mode 100644 index 00000000..52c1268d Binary files /dev/null and b/providers/bundled/spool.plex-0.1.7.tar.zst differ diff --git a/providers/bundled/spool.stremio-0.1.1.tar.zst b/providers/bundled/spool.stremio-0.1.1.tar.zst new file mode 100644 index 00000000..5bfde8cf Binary files /dev/null and b/providers/bundled/spool.stremio-0.1.1.tar.zst differ diff --git a/providers/lock.json b/providers/lock.json new file mode 100644 index 00000000..9669d320 --- /dev/null +++ b/providers/lock.json @@ -0,0 +1,40 @@ +{ + "format": 2, + "providers": [ + { + "id": "spool.jellyfin", + "appleAppStore": true, + "version": "0.2.10", + "archive": "bundled/spool.jellyfin-0.2.10.tar.zst", + "sha256": "18d83631a5eb2592481d8234aebaf665896ec1649bbb4c70eec20c7beee677ec" + }, + { + "id": "spool.emby", + "appleAppStore": true, + "version": "0.1.6", + "archive": "bundled/spool.emby-0.1.6.tar.zst", + "sha256": "210e682ea8d9d967ca49f6835a37ac7450e2cea609a94a45c6f8a27945c4fb9d" + }, + { + "id": "spool.plex", + "appleAppStore": true, + "version": "0.1.7", + "archive": "bundled/spool.plex-0.1.7.tar.zst", + "sha256": "ff85ef3d4cd1717ede4a719998eef38fcc44aaa53b4056f0644230fa9028b379" + }, + { + "id": "spool.stremio", + "appleAppStore": false, + "version": "0.1.1", + "archive": "bundled/spool.stremio-0.1.1.tar.zst", + "sha256": "4a71de7a02b8aad51676f977c02b852f4fa0f0e6de67979bbe97854ca1fdb4b4" + }, + { + "id": "example.open-movies", + "appleAppStore": true, + "version": "1.1.0", + "archive": "bundled/example.open-movies-1.1.0.tar.zst", + "sha256": "68c8460941bb9182890869bd8b307a23a9fef5a970241b8295698c30fe22bb19" + } + ] +} diff --git a/qml/browse/BrowseSurface.qml b/qml/browse/BrowseSurface.qml index 6dc0d6d8..1fd05b0b 100644 --- a/qml/browse/BrowseSurface.qml +++ b/qml/browse/BrowseSurface.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import JellyfinWebOS +import Spool import "../theme" import "../primitives" import "../primitives/ModelAccess.js" as ModelAccess @@ -43,26 +43,26 @@ FocusScope { // which is the row the home page gives them too. readonly property var destinations: { const places = [ - { - "key": "resume", - "title": "Continue Watching", - "icon": "play_circle", - "model": Home.resumeItems, - "kind": "landscape" - }, - { - "key": "nextUp", - "title": "Next Up", - "icon": "skip_next", - "model": Home.nextUpItems, - "kind": "landscape" - } - ] + { + "key": "resume", + "title": "Continue Watching", + "icon": "play_circle", + "model": Home.resumeItems, + "kind": "landscape" + }, + { + "key": "nextUp", + "title": "Next Up", + "icon": "skip_next", + "model": Home.nextUpItems, + "kind": "landscape" + } + ] const latest = Home.latestLibraryRows || [] for (let index = 0; index < latest.length; ++index) { const row = latest[index] if (!row || !row.model) - continue + continue places.push({ "key": "latest" + index, "title": String(row.title || "Recently Added"), diff --git a/qml/browse/RowStackView.qml b/qml/browse/RowStackView.qml index e90d50b7..22669976 100644 --- a/qml/browse/RowStackView.qml +++ b/qml/browse/RowStackView.qml @@ -17,7 +17,7 @@ import "../primitives/ModelAccess.js" as ModelAccess // // Sections are plain descriptors, so a caller can build them from anything: // { key, title, model, kind, useSeriesPoster, preferEpisodeTitle, -// contextSource } +// contextSource, moveItem, contextMenu, headerAction, headerActionText } FocusScope { id: root @@ -70,9 +70,10 @@ FocusScope { function isPopulated(index) { const row = rowAt(index) if (row) - return row.count > 0 + return row.rowVisible && (row.count > 0 || row.hasHeaderAction) const section = sectionAt(index) - return Boolean(section) && ModelAccess.count(section.model) > 0 + return Boolean(section) && section.enabled !== false && (ModelAccess.count(section.model) > 0 || typeof section.headerAction + === "function") } function nextPopulated(start, direction) { @@ -218,6 +219,8 @@ FocusScope { const row = currentRow() if (!row) return false + if (row.moveMode) + return row.routeKey(key, phase, repeat) if (key === Qt.Key_Up || key === Qt.Key_Down) return moveSection(key === Qt.Key_Down ? 1 : -1) return row.routeKey(key, phase, repeat) @@ -225,8 +228,8 @@ FocusScope { function activate() { const row = currentRow() - if (row && row.currentIndex >= 0) - root.activated(sectionAt(currentSection), row.currentIndex, currentItem()) + if (row) + row.activate() } function longPress() { @@ -267,7 +270,7 @@ FocusScope { } onDraggingChanged: if (dragging) - root.beginPointerNavigation(null) + root.beginPointerNavigation(null) delegate: MediaRow { id: mediaRow @@ -286,7 +289,15 @@ FocusScope { reserveWhenEmpty: Boolean(modelData.reserveWhenEmpty) loading: Boolean(modelData.loading) emptyText: String(modelData.emptyText || "") + headerBadge: modelData.headerBadge || null + cardBadge: modelData.cardBadge || null focusVisible: root.navigationFocusVisible + moveItem: modelData.moveItem || null + contextMenu: modelData.contextMenu || null + headerAction: modelData.headerAction || null + headerActionText: String(modelData.headerActionText || "") + onCountChanged: Qt.callLater(root.repair) + onHasHeaderActionChanged: Qt.callLater(root.repair) // This view is already inset by its host; use its usable width. cardWidth: Math.round(Metrics.rowCardWidth(root.width) * (cardKind === "poster" || cardKind === "square" ? 1 : Metrics.landscapeCardRatio)) @@ -303,7 +314,7 @@ FocusScope { root.currentSection = index } onDelegatesPresentedChanged: if (root.measureFirstRow && index === 0 && delegatesPresented) - root.firstRowReady = true + root.firstRowReady = true onActivated: (itemIndex, item) => { root.currentSection = index root.activated(modelData, itemIndex, item) diff --git a/qml/fonts/MaterialIcons-Regular.ttf b/qml/fonts/MaterialIcons-Regular.ttf index 48c69b5b..9d09b0fe 100644 Binary files a/qml/fonts/MaterialIcons-Regular.ttf and b/qml/fonts/MaterialIcons-Regular.ttf differ diff --git a/qml/pages/AccountsPage.qml b/qml/pages/AccountsPage.qml new file mode 100644 index 00000000..5950e3b6 --- /dev/null +++ b/qml/pages/AccountsPage.qml @@ -0,0 +1,475 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +// Every account on every provider. Choosing one makes it the one in use for +// its server (other people on that server step aside); accounts on different +// servers and providers stay on home together. The last tile adds a provider. +FocusScope { + id: root + + property var shell + property string serverFilter: "" + readonly property var serverGroups: { + const groups = [] + for (const account of Providers.accounts) { + const key = JSON.stringify([account.moduleId, account.group || account.id]) + if (!groups.some(group => group.key === key)) + groups.push({ + key: key, + label: String(account.detail || account.address || account.providerName), + provider: String(account.providerName) + }) + } + return groups + } + readonly property var accounts: Providers.accounts.filter(account => !serverFilter || JSON.stringify([account.moduleId, + account.group + || account.id]) + === serverFilter).slice().sort((a, b) => ( + a.moduleId + "/" + (a.group + || a.id)).localeCompare( + b.moduleId + "/" + (b.group || b.id))) + readonly property int count: accounts.length + 1 + readonly property int tileSize: Metrics.scaled(Metrics.laneAtLeast(width, "wide") ? 148 : Metrics.laneAtLeast(width, "regular") ? 132 : + 104) + property var menuAccount: null + property var removingAccount: null + property string pendingAccountId: "" + property string selectionMessage: "" + property bool choosingServer: false + readonly property bool selecting: pendingAccountId.length > 0 + readonly property var selectedAccount: accountAt(grid.currentIndex) + + Connections { + target: Providers + function onAccountSelectionFinished(accountId, selected) { + if (accountId !== root.pendingAccountId) + return + root.pendingAccountId = "" + if (selected) + root.shell.goHome() + else { + const account = Providers.accounts.find(account => account.id === accountId) + root.selectionMessage = account && account.errorText ? account.errorText : + "Profile switch cancelled. Your current profile is unchanged." + } + } + } + + function openServerMenu() { + choosingServer = true + menuLoader.active = true + } + + focus: true + + function accountAt(index) { + return index < accounts.length ? accounts[index] : null + } + + function activateAt(index) { + if (selecting) + return + selectionMessage = "" + const account = accountAt(index) + if (!account) { + shell.pushRoute("addProvider") + return + } + if (account.needsSignIn) { + shell.openProviderScreen(Providers.beginSetup(account.moduleId)) + return + } + pendingAccountId = account.id + Providers.useAccount(account.id) + } + + function openMenu(index) { + choosingServer = false + const account = accountAt(index) + if (!account) + return false + menuAccount = account + menuLoader.active = true + return true + } + + function activate() { + if (removeConfirmation.item) + return removeConfirmation.item.activate() + if (menuLoader.item) + return menuLoader.item.activate() + if (serverButton.activeFocus) { + openServerMenu() + return + } + if (addProfileButton.activeFocus) { + if (addProfileButton.enabled && selectedAccount) + shell.openProviderScreen(Providers.beginSetup(selectedAccount.moduleId)) + return + } + if (optionsButton.activeFocus) { + openMenu(grid.currentIndex) + return + } + activateAt(grid.currentIndex) + } + + function longPress() { + return !serverButton.activeFocus && !addProfileButton.activeFocus && !optionsButton.activeFocus && openMenu( + grid.currentIndex) + } + + function back() { + if (removingAccount) { + removingAccount = null + InputKeys.focus(grid) + return true + } + if (!menuLoader.item) + return false + menuLoader.active = false + InputKeys.focus(root) + return true + } + + function routeKey(key, phase, repeat) { + if (removeConfirmation.item) + return removeConfirmation.item.routeKey(key, phase, repeat) + if (menuLoader.item) + return menuLoader.item.routeKey(key, phase, repeat) + if (phase !== "press") + return InputKeys.isDirection(key) + const buttons = [serverButton, addProfileButton, optionsButton].filter(button => button.visible + && button.enabled) + + const focusedButton = buttons.findIndex(button => button.activeFocus) + if (focusedButton >= 0) { + if (key === Qt.Key_Down) { + InputKeys.focus(focusedButton + 1 < buttons.length ? buttons[focusedButton + 1] : grid) + return true + } + if (key === Qt.Key_Up) { + if (focusedButton > 0) + InputKeys.focus(buttons[focusedButton - 1]) + else if (shell) + shell.focusNavBar() + return true + } + return InputKeys.isDirection(key) + } + const columns = Math.max(1, Math.floor(grid.width / grid.cellWidth)) + if (key === Qt.Key_Left) + grid.currentIndex = Math.max(0, grid.currentIndex - 1) + else if (key === Qt.Key_Right) + grid.currentIndex = Math.min(count - 1, grid.currentIndex + 1) + else if (key === Qt.Key_Down) + grid.currentIndex = Math.min(count - 1, grid.currentIndex + columns) + else if (key === Qt.Key_Up) { + if (grid.currentIndex < columns && buttons.length) + InputKeys.focus(buttons[buttons.length - 1]) + else if (grid.currentIndex < columns && shell) + shell.focusNavBar() + else + grid.currentIndex = Math.max(0, grid.currentIndex - columns) + } else if (key === Qt.Key_Menu || key === Qt.Key_M) + return openMenu(grid.currentIndex) + else + return false + return true + } + + Rectangle { + anchors.fill: parent + color: Theme.bg + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.pageMarginPx + spacing: Metrics.scaled(24) + + AppText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Metrics.scaled(12) + text: "Profiles & servers" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + } + + AppText { + Layout.fillWidth: true + text: "Choose who's watching on a server. Independent servers appear together on Home." + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + color: Theme.textSecondary + } + + ActionButton { + id: serverButton + Layout.alignment: Qt.AlignHCenter + visible: root.serverGroups.length > 1 + text: { + const group = root.serverGroups.find(group => group.key === root.serverFilter) + return group ? group.label + " · " + group.provider : "All servers" + } + kind: "secondary" + onClicked: root.openServerMenu() + } + + ActionButton { + id: addProfileButton + Layout.alignment: Qt.AlignHCenter + visible: Boolean(root.selectedAccount) + enabled: !root.selecting + text: "Add another watching profile" + kind: "secondary" + onClicked: root.shell.openProviderScreen(Providers.beginSetup(root.selectedAccount.moduleId)) + } + ActionButton { + id: optionsButton + Layout.alignment: Qt.AlignHCenter + visible: Boolean(root.selectedAccount) + enabled: !root.selecting + text: "Account options" + kind: "secondary" + onClicked: root.openMenu(grid.currentIndex) + } + + SecondaryText { + Layout.fillWidth: true + visible: Boolean(root.selectedAccount && root.selectedAccount.errorText) + text: root.selectedAccount ? String(root.selectedAccount.errorText || "") : "" + font.pixelSize: Metrics.bodySizePx + color: Theme.errorText + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + AppText { + Layout.fillWidth: true + visible: root.selecting || root.selectionMessage.length > 0 + text: root.selecting ? "Switching profile… Complete any sign-in or PIN request to continue." : + root.selectionMessage + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + color: Theme.textSecondary + } + + AppText { + readonly property var selectedAccount: root.accountAt(grid.currentIndex) + Layout.fillWidth: true + visible: Boolean(selectedAccount && selectedAccount.missingHostExtensions + && selectedAccount.missingHostExtensions.length > 0) + text: "Update Spool to use all features of this provider." + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + font.pixelSize: Metrics.bodySizePx + color: Theme.textSecondary + } + + GridView { + id: grid + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + readonly property int columns: Math.max(1, Math.min(root.count, Math.floor(parent.width / cellWidth))) + Layout.preferredWidth: columns * cellWidth + cellWidth: root.tileSize + Metrics.scaled(28) + cellHeight: root.tileSize + Metrics.scaled(104) + model: root.count + interactive: contentHeight > height + boundsBehavior: Flickable.StopAtBounds + currentIndex: 0 + focus: true + + delegate: Item { + id: cell + required property int index + readonly property var account: root.accountAt(index) + readonly property string connectionState: account ? String(account.connectionState || "starting") : "" + readonly property string stateLabel: !account ? "" : account.needsSignIn ? "Sign in to reconnect" : + !account.enabled && + !account.pendingEnabled + ? "Switch profile" : + connectionState + === "starting" && ( + account.enabled + || account.pendingEnabled) + ? "Connecting…" : + connectionState + === "failed" + ? "Unavailable · select to retry" : + account.enabled + && connectionState + === "active" + ? "Watching" : + connectionState + === "locked" + ? "Select profile / unlock" : + "Switch profile" + width: grid.cellWidth + height: grid.cellHeight + + ProfileTile { + id: profileTile + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Metrics.scaled(8) + tileSize: root.tileSize + focused: cell.GridView.isCurrentItem && Metrics.keyboardFocusActive + addTile: !cell.account + username: cell.account ? cell.account.label : "Add" + serverName: cell.account ? String(cell.account.detail || cell.account.address + || cell.account.providerName) : "" + needsSignIn: Boolean(cell.account && cell.account.needsSignIn) + opacity: !cell.account || cell.account.enabled || cell.account.pendingEnabled ? 1 : 0.75 + onAccepted: { + grid.currentIndex = cell.index + root.activateAt(cell.index) + } + onContextRequested: { + grid.currentIndex = cell.index + root.openMenu(cell.index) + } + } + + // Which provider an account is on, at a glance. + ProviderIcon { + visible: Boolean(cell.account) + x: parent.width / 2 + root.tileSize / 2 - width + Metrics.scaled(6) + y: Metrics.scaled(8) + root.tileSize - height + Metrics.scaled(6) + width: Metrics.scaled(34) + height: width + border.width: Metrics.scaled(3) + border.color: Theme.bg + source: cell.account ? cell.account.iconUrl : "" + name: cell.account ? cell.account.providerName : "" + seed: cell.account ? cell.account.moduleId : "" + } + + MaterialIcon { + visible: Boolean(cell.account) && cell.account.enabled && cell.connectionState !== "active" + x: parent.width / 2 - root.tileSize / 2 + Metrics.scaled(8) + y: Metrics.scaled(16) + iconSize: Metrics.scaled(18) + name: cell.connectionState === "locked" ? "lock" : cell.connectionState === "failed" + ? "error_outline" : "hourglass_empty" + iconColor: cell.connectionState === "failed" ? Theme.errorText : Theme.pending + } + AppText { + anchors.top: profileTile.bottom + anchors.topMargin: Metrics.scaled(4) + anchors.horizontalCenter: parent.horizontalCenter + width: root.tileSize + visible: cell.stateLabel.length > 0 + text: cell.stateLabel + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Metrics.bodySizePx + color: cell.connectionState === "failed" ? Theme.errorText : Theme.textSecondary + elide: Text.ElideRight + } + } + } + } + + readonly property var menuActions: { + const account = menuAccount + if (!account) + return [] + const out = [] + if (account.needsSignIn || account.connectionState === "failed") + out.push({ + "label": "Sign in / reconnect", + "value": "reconnect" + }) + if (account.hasSettings) + out.push({ + "label": "Account settings", + "value": "settings" + }) + out.push({ + "label": account.enabled || account.pendingEnabled ? "Hide from home" : "Show on home", + "value": "toggle" + }) + out.push({ + "label": "Remove", + "value": "remove" + }) + return out + } + + function choose(index) { + const account = menuAccount + const action = menuActions[index] ? menuActions[index].value : "" + menuLoader.active = false + InputKeys.focus(root) + if (action === "reconnect") + shell.openProviderScreen(Providers.beginSetup(account.moduleId)) + else if (action === "settings") + shell.openProviderScreen(Providers.openSettings(account.id)) + else if (action === "toggle") + Providers.setAccountEnabled(account.id, !(account.enabled || account.pendingEnabled)) + else if (action === "remove") + removingAccount = account + } + + Loader { + id: menuLoader + anchors.fill: parent + active: false + sourceComponent: OptionPickerDialog { + visible: true + anchorItem: grid.currentItem + title: root.choosingServer ? "Choose server" : root.menuAccount ? root.menuAccount.label : "" + options: root.choosingServer ? ["All servers"].concat(root.serverGroups.map(group => group.label + " · " + + group.provider)) : + root.menuActions.map(action => action.label) + currentIndex: root.choosingServer ? Math.max(0, root.serverGroups.findIndex(group => group.key + === root.serverFilter) + + 1) : 0 + onSelected: index => { + if (root.choosingServer) { + root.serverFilter = index > 0 ? root.serverGroups[index - 1].key : "" + grid.currentIndex = 0 + menuLoader.active = false + InputKeys.focus(grid) + } else + root.choose(index) + } + onDismissed: { + menuLoader.active = false + InputKeys.focus(root) + } + } + } + + Loader { + id: removeConfirmation + anchors.fill: parent + active: Boolean(root.removingAccount) + z: 210 + sourceComponent: ConfirmationDialog { + title: "Remove account?" + message: "Remove " + root.removingAccount.label + + " from Spool on this device? Your server account and media will not be deleted." + ( + typeof SettingsSync !== "undefined" && root.removingAccount.id === SettingsSync.accountId + ? " This is your settings sync account. Sync will pause until you choose another account." : + "") + confirmText: "Remove account" + destructive: true + onAccepted: { + Providers.removeAccount(root.removingAccount.id) + root.removingAccount = null + InputKeys.focus(grid) + } + onDismissed: { + root.removingAccount = null + InputKeys.focus(grid) + } + } + } +} diff --git a/qml/pages/AddProviderPage.qml b/qml/pages/AddProviderPage.qml new file mode 100644 index 00000000..a21d5826 --- /dev/null +++ b/qml/pages/AddProviderPage.qml @@ -0,0 +1,566 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +// Choosing where media comes from: official providers first, then the +// community's, then any provider by link. Installing and signing in are one +// step from here, and the same page updates or removes what is installed. +// A curated build has no link row, and a bundled one only its own providers. +FocusScope { + id: root + + property var shell + property string pendingSetup: "" + property bool linkEditing: false + property var menuEntry: null + property var removingEntry: null + + // Section headers only once there is more than one section. + readonly property var rows: { + const community = Store.storeAvailable && (Store.community.length > 0 || Store.loading) + const out = community ? [ + { + "kind": "header", + "title": "By Spool" + } + ] : [] + out.push(...Store.official) + if (community) { + out.push({ + "kind": "header", + "title": "Community", + "loading": Store.loading + }) + out.push(...Store.community) + } + if (Store.storeAvailable && Store.linksAllowed) + out.push({ + "kind": "link", + "id": "link" + }) + return out + } + + // Headers come and go as the store loads; stay on the same entry. + property string currentId: "" + onRowsChanged: { + const index = rows.findIndex(row => row.kind !== "header" && row.id === currentId) + list.currentIndex = index >= 0 ? index : step(-1, 1) + } + + function step(from, delta) { + let index = from + delta + while (index >= 0 && index < rows.length && rows[index].kind === "header") + index += delta + return index >= 0 && index < rows.length ? index : from + } + + focus: true + Component.onCompleted: { + Store.refresh(true) + list.currentIndex = step(-1, 1) + InputKeys.focus(list) + } + + function stateOf(entry) { + if (entry.kind === "link") + return Store.busy.url ? "busy" : "link" + if (entry.busy) + return "busy" + if (!entry.compatible) + return "incompatible" + if (entry.updateAvailable) + return "update" + return entry.installed ? "installed" : "get" + } + + function beginSetup(id) { + pendingSetup = "" + const context = Providers.beginSetup(id) + if (context) + shell.openProviderScreen(context) + } + + function activateRow(index) { + const entry = rows[index] + if (!entry) + return + switch (stateOf(entry)) { + case "link": + linkEditing = true + Qt.callLater(() => { + const item = list.itemAtIndex(index) + if (item) + item.focusField() + }) + break + case "update": + pendingSetup = entry.id + Store.update(entry.id) + break + case "installed": + beginSetup(entry.id) + break + case "get": + pendingSetup = entry.id + Store.install(entry.id) + break + } + } + + function submitLink(text) { + const url = String(text || "").trim() + if (url.length === 0) + return + pendingSetup = "*" + Store.addFromUrl(url) + linkEditing = false + InputKeys.focus(list) + } + + function openMenu(index) { + const entry = rows[index] + if (!entry || !entry.installed) + return false + menuEntry = entry + menuLoader.active = true + return true + } + + readonly property var menuActions: { + const entry = menuEntry + if (!entry) + return [] + const out = [ + { + "label": "Add an account", + "value": "setup" + } + ] + if (entry.updateAvailable) + out.push({ + "label": "Update to " + entry.version, + "value": "update" + }) + if (entry.removable !== false && !entry.bundled) + out.push({ + "label": "Remove", + "value": "remove" + }) + return out + } + + function choose(index) { + const entry = menuEntry + const action = menuActions[index] ? menuActions[index].value : "" + closeMenu() + if (action === "setup") + beginSetup(entry.id) + else if (action === "update") + Store.update(entry.id) + else if (action === "remove") + removingEntry = entry + } + + function closeMenu() { + menuLoader.active = false + InputKeys.focus(list) + } + + function activate() { + if (removeConfirmation.item) + return removeConfirmation.item.activate() + if (menuLoader.item) + return menuLoader.item.activate() + activateRow(list.currentIndex) + } + + function longPress() { + return openMenu(list.currentIndex) + } + + function back() { + if (removingEntry) { + removingEntry = null + InputKeys.focus(list) + return true + } + if (menuLoader.item) { + closeMenu() + return true + } + if (linkEditing) { + linkEditing = false + InputKeys.focus(list) + return true + } + return false + } + + function routeKey(key, phase, repeat) { + if (removeConfirmation.item) + return removeConfirmation.item.routeKey(key, phase, repeat) + if (menuLoader.item) + return menuLoader.item.routeKey(key, phase, repeat) + if (linkEditing) + return false + if (phase !== "press") + return InputKeys.isDirection(key) + if (key === Qt.Key_Down) + list.currentIndex = step(list.currentIndex, 1) + else if (key === Qt.Key_Up) + list.currentIndex = step(list.currentIndex, -1) + else if (key === Qt.Key_Menu || key === Qt.Key_M) + return openMenu(list.currentIndex) + else + return InputKeys.isDirection(key) + return true + } + + Connections { + target: Store + function onInstalled(id) { + if (root.pendingSetup === id || root.pendingSetup === "*") + root.beginSetup(id) + } + function onProblem() { + root.pendingSetup = "" + } + } + + Rectangle { + anchors.fill: parent + color: Theme.bg + } + + Item { + id: column + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(880)) + + ColumnLayout { + id: header + y: Math.max(Metrics.pageMarginPx, Metrics.scaled(56)) + width: parent.width + spacing: Metrics.scaled(10) + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.scaled(14) + + IconButton { + visible: Providers.hasAccounts + iconName: "arrow_back" + accessibleName: "Back" + onClicked: Router.pop("accounts") + } + + AppText { + Layout.fillWidth: true + text: "Add a provider" + font.pixelSize: Metrics.scaled(44) + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + + AppText { + Layout.fillWidth: true + text: "Choose where your media comes from. You can add as many as you like." + color: Theme.textSecondary + font.pixelSize: Metrics.scaled(20) + wrapMode: Text.WordWrap + } + AppText { + Layout.fillWidth: true + visible: Store.error.length > 0 + text: Store.error + color: Theme.errorText + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap + } + } + + AppText { + id: compatibilityNotice + readonly property var selectedModule: Providers.modules.find(module => module.id === root.currentId) + anchors.top: header.bottom + anchors.topMargin: visible ? Metrics.scaled(12) : 0 + width: parent.width + visible: Boolean(selectedModule && selectedModule.missingHostExtensions + && selectedModule.missingHostExtensions.length > 0) + height: visible ? implicitHeight + Metrics.scaled(12) : 0 + text: "Update Spool to use all features of this provider." + wrapMode: Text.WordWrap + font.pixelSize: Metrics.bodySizePx + color: Theme.textSecondary + } + + ListView { + id: list + anchors.top: compatibilityNotice.bottom + anchors.topMargin: Metrics.scaled(32) + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + clip: true + model: root.rows + currentIndex: 0 + onCurrentIndexChanged: root.currentId = root.rows[currentIndex] ? String(root.rows[currentIndex].id || "") : + "" + focus: true + keyNavigationEnabled: false + boundsBehavior: Flickable.StopAtBounds + highlightMoveDuration: 0 + spacing: Metrics.scaled(14) + footer: Item { + width: list.width + height: Metrics.pageMarginPx + } + + delegate: Item { + id: slot + required property int index + required property var modelData + readonly property bool isHeader: modelData.kind === "header" + + function focusField() { + linkField.focusRow() + } + + width: list.width + height: isHeader ? Metrics.scaled(index === 0 ? 32 : 56) : row.height + + AppText { + visible: slot.isHeader + anchors.left: parent.left + anchors.leftMargin: Metrics.scaled(4) + anchors.bottom: parent.bottom + anchors.bottomMargin: Metrics.scaled(4) + text: String(slot.modelData.title || "").toUpperCase() + color: Theme.textMuted + font.pixelSize: Metrics.scaled(15) + font.weight: Font.DemiBold + font.letterSpacing: Metrics.scaled(1) + } + + BusySpinner { + anchors.right: parent.right + anchors.bottom: parent.bottom + width: Metrics.scaled(18) + height: width + running: slot.isHeader && Boolean(slot.modelData.loading) + visible: running + } + + Rectangle { + id: row + visible: !slot.isHeader + readonly property int index: slot.index + readonly property var modelData: slot.modelData + readonly property string state: root.stateOf(modelData) + readonly property bool current: slot.ListView.isCurrentItem && Metrics.keyboardFocusActive + && list.activeFocus + readonly property bool isLink: modelData.kind === "link" + + width: list.width + height: isLink && root.linkEditing ? linkForm.implicitHeight + Metrics.scaled(40) : Math.max( + Metrics.touchTargetPx, Metrics.scaled(104)) + radius: Theme.radiusPanel + color: current ? Theme.focusedFill : hover.hovered ? Theme.bgHover : Theme.bgRaised + border.width: current ? Theme.focusBorderWidth : Theme.hoverBorderWidth + border.color: current ? Theme.accent : Theme.border + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.scaled(24) + anchors.rightMargin: Metrics.scaled(24) + spacing: Metrics.scaled(22) + visible: !(row.isLink && root.linkEditing) + + Loader { + Layout.preferredWidth: Metrics.scaled(64) + Layout.preferredHeight: Metrics.scaled(64) + sourceComponent: row.isLink ? linkIcon : providerIcon + Component { + id: providerIcon + ProviderIcon { + source: row.modelData.iconUrl || "" + name: row.modelData.name || "" + seed: row.modelData.id || "" + // The folders provider is built in and has no package icon. + symbol: row.modelData.id === "spool.local" ? "computer" : "" + } + } + Component { + id: linkIcon + Rectangle { + radius: Math.round(width * 0.22) + color: Theme.bgPanel + MaterialIcon { + anchors.centerIn: parent + name: "link" + iconSize: Metrics.scaled(32) + iconColor: Theme.textSecondary + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.scaled(4) + + AppText { + Layout.fillWidth: true + text: row.isLink ? "Add from a link" : row.modelData.name || "" + font.pixelSize: Metrics.scaled(24) + font.weight: Font.DemiBold + elide: Text.ElideRight + maximumLineCount: 1 + } + + SecondaryText { + Layout.fillWidth: true + text: row.state === "incompatible" ? "Needs a newer Spool" : row.isLink + ? "Install a provider someone has shared with you" : + row.modelData.summary || "" + visible: text.length > 0 + color: Theme.textSecondary + font.pixelSize: Metrics.scaled(17) + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + // One action per row, named for what pressing the row does. + Rectangle { + Layout.preferredHeight: Metrics.scaled(44) + Layout.preferredWidth: Math.max(Metrics.scaled(120), chipText.implicitWidth + Metrics.scaled( + 40)) + visible: row.state !== "incompatible" && row.state !== "busy" + radius: height / 2 + color: row.state === "get" || row.state === "update" ? Theme.accentDim : Theme.bgPanel + border.width: Theme.hoverBorderWidth + border.color: row.state === "get" || row.state === "update" ? Theme.accentDim : Theme.border + + AppText { + id: chipText + anchors.centerIn: parent + text: ({ + "get": "Install", + "update": "Update", + "installed": "Add account", + "link": "Paste link" + })[row.state] || "" + font.pixelSize: Metrics.scaled(17) + font.weight: Font.DemiBold + } + } + + BusySpinner { + Layout.preferredWidth: Metrics.scaled(24) + Layout.preferredHeight: Metrics.scaled(24) + running: row.state === "busy" + visible: running + } + } + + ColumnLayout { + id: linkForm + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Metrics.scaled(20) + visible: row.isLink && root.linkEditing + spacing: Metrics.scaled(10) + + TextFieldRow { + id: linkField + Layout.fillWidth: true + label: "Link" + placeholderText: "https://github.com/you/spool-provider" + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + onAccepted: root.submitLink(text) + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Metrics.scaled(10) + ActionButton { + text: "Cancel" + kind: "flat" + onClicked: root.back() + } + ActionButton { + text: "Add" + kind: "primary" + enabled: linkField.text.trim().length > 0 + onClicked: root.submitLink(linkField.text) + } + } + } + + HoverHandler { + id: hover + enabled: !(row.isLink && root.linkEditing) + } + + TapHandler { + enabled: !(row.isLink && root.linkEditing) + acceptedButtons: Qt.LeftButton | Qt.RightButton + onTapped: (point, button) => { + list.currentIndex = row.index + if (button === Qt.RightButton) + root.openMenu(row.index) + else + root.activateRow(row.index) + } + onLongPressed: root.openMenu(row.index) + } + } + } + } + } + + Loader { + id: menuLoader + anchors.fill: parent + active: false + sourceComponent: OptionPickerDialog { + visible: true + anchorItem: list.currentItem + title: root.menuEntry ? root.menuEntry.name : "" + options: root.menuActions.map(action => action.label) + onSelected: index => root.choose(index) + onDismissed: root.closeMenu() + } + } + Loader { + id: removeConfirmation + anchors.fill: parent + active: Boolean(root.removingEntry) + z: 210 + sourceComponent: ConfirmationDialog { + title: "Remove provider?" + message: "Remove " + root.removingEntry.name + + " and its saved accounts from this device? Your server accounts and media will not be deleted." + confirmText: "Remove provider" + destructive: true + onAccepted: { + Store.uninstall(root.removingEntry.id) + root.removingEntry = null + InputKeys.focus(list) + } + onDismissed: { + root.removingEntry = null + InputKeys.focus(list) + } + } + } +} diff --git a/qml/pages/CollectionEditorPage.qml b/qml/pages/CollectionEditorPage.qml new file mode 100644 index 00000000..0d1a2808 --- /dev/null +++ b/qml/pages/CollectionEditorPage.qml @@ -0,0 +1,222 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +FocusScope { + id: root + property var shell + readonly property bool contentReady: !CollectionEditing.busy || CollectionEditing.entries.length > 0 + property int actionIndex: -1 + property int pendingIndex: -1 + readonly property var actions: { + const result = [] + if (CollectionEditing.movable) { + result.push({ + id: "up", + label: "Move up", + icon: "arrow_upward" + }) + result.push({ + id: "down", + label: "Move down", + icon: "arrow_downward" + }) + } + if (CollectionEditing.removable) + result.push({ + id: "remove", + label: "Remove", + icon: "remove_circle_outline" + }) + result.push({ + id: "refresh", + label: "Refresh", + icon: "refresh" + }) + return result + } + onActionsChanged: actionIndex = Math.min(actionIndex, actions.length - 1) + + function enter() { + if (!visible || Router.route !== "collectionEditor") + return + if (CollectionEditing.containerId === String(Router.args.containerId || "")) + return + actionIndex = -1 + pendingIndex = -1 + CollectionEditing.open(String(Router.args.containerId || ""), String(Router.args.title || "Collection")) + InputKeys.focus(root) + } + Component.onCompleted: enter() + onVisibleChanged: { + if (visible) + enter() + else + CollectionEditing.close() + } + Component.onDestruction: { + if (Router.route === "collectionEditor") + CollectionEditing.close() + } + Connections { + target: CollectionEditing + function onChanged() { + if (root.pendingIndex >= 0 && !CollectionEditing.busy) { + const index = Math.min(root.pendingIndex, CollectionEditing.entries.length - 1) + root.pendingIndex = -1 + if (index >= 0) + CollectionEditing.selectEntry(CollectionEditing.entries[index].entryId) + } + if (CollectionEditing.selectedIndex >= 0) + list.positionViewAtIndex(CollectionEditing.selectedIndex, ListView.Contain) + } + } + + function actionEnabled(id) { + if (CollectionEditing.busy) + return false + const index = CollectionEditing.selectedIndex + if (id === "refresh") + return CollectionEditing.containerId.length > 0 + if (index < 0) + return false + if (id === "up") + return index > 0 + if (id === "down") + return index + 1 < CollectionEditing.entries.length || CollectionEditing.hasMore + return true + } + function perform(id) { + if (!actionEnabled(id)) + return + const entry = CollectionEditing.selectedEntryId + if (id === "up" || id === "down") + CollectionEditing.moveEntry(entry, id === "up" ? -1 : 1) + else if (id === "remove") + CollectionEditing.removeEntry(entry) + else + CollectionEditing.refresh() + } + function activate() { + if (actionIndex < 0) + actionIndex = 0 + else + perform(actions[actionIndex].id) + } + function back() { + if (actionIndex < 0) + return false + actionIndex = -1 + return true + } + function routeKey(key, phase, repeat) { + if (phase === "release") + return InputKeys.isDirection(key) + if (key === Qt.Key_Left || key === Qt.Key_Right) { + actionIndex = Math.max(-1, Math.min(actions.length - 1, actionIndex + (key === Qt.Key_Right ? 1 : -1))) + return true + } + if (key === Qt.Key_Up || key === Qt.Key_Down) { + actionIndex = -1 + const index = CollectionEditing.selectedIndex + (key === Qt.Key_Down ? 1 : -1) + if (index >= 0 && index < CollectionEditing.entries.length) + CollectionEditing.selectEntry(CollectionEditing.entries[index].entryId) + else if (key === Qt.Key_Down && CollectionEditing.hasMore && !CollectionEditing.busy) { + pendingIndex = index + CollectionEditing.requestNextPage() + } else if (key === Qt.Key_Up && index < 0 && shell) + shell.focusNavBar() + return true + } + return false + } + + Rectangle { + anchors.fill: parent + color: Theme.bg + } + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.pageMarginPx + spacing: Metrics.gapPx + AppText { + Layout.fillWidth: true + text: CollectionEditing.title || "Collection entries" + font.pixelSize: Metrics.titleSizePx + elide: Text.ElideRight + } + AppText { + Layout.fillWidth: true + text: CollectionEditing.problem || (CollectionEditing.busy ? "Loading…" : CollectionEditing.entries.length + === 0 ? "No entries." : + "Select an entry. Right opens its actions; Up and Down select entries.") + color: Theme.textSecondary + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.WordWrap + } + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: CollectionEditing.entries + currentIndex: CollectionEditing.selectedIndex + boundsBehavior: Flickable.StopAtBounds + delegate: MenuRow { + required property int index + required property var modelData + // Media IDs may repeat; this identity is the exact occurrence. + readonly property string entryId: modelData.entryId + width: list.width + label: String(index + 1) + ". " + modelData.title + highlighted: entryId === CollectionEditing.selectedEntryId + onActivated: { + CollectionEditing.selectEntry(entryId) + root.actionIndex = 0 + InputKeys.focus(root) + } + Accessible.name: label + Accessible.selected: entryId === CollectionEditing.selectedEntryId + } + footer: MenuRow { + width: list.width + visible: CollectionEditing.hasMore + height: visible ? Math.max(Metrics.touchTargetPx, Metrics.scaled(46)) : 0 + label: CollectionEditing.busy ? "Loading…" : "Load more entries" + iconName: "expand_more" + actionable: !CollectionEditing.busy + onActivated: CollectionEditing.requestNextPage() + } + FastWheelHandler { + flickable: list + } + } + RowLayout { + Layout.fillWidth: true + spacing: Metrics.gapPx + Repeater { + model: root.actions + delegate: MenuRow { + required property int index + required property var modelData + Layout.fillWidth: true + label: modelData.label + iconName: modelData.icon + highlighted: root.actionIndex === index + actionable: root.actionEnabled(modelData.id) + onActivated: { + root.actionIndex = index + root.perform(modelData.id) + InputKeys.focus(root) + } + Accessible.role: Accessible.Button + Accessible.name: label + Accessible.onPressAction: root.perform(modelData.id) + } + } + } + } +} diff --git a/qml/pages/DesktopFolderDialog.qml b/qml/pages/DesktopFolderDialog.qml index 5fc3f026..2317ca08 100644 --- a/qml/pages/DesktopFolderDialog.qml +++ b/qml/pages/DesktopFolderDialog.qml @@ -3,6 +3,7 @@ import QtQuick.Dialogs Item { id: root + property alias title: dialog.title signal folderSelected(url folder) signal dismissed diff --git a/qml/pages/HomePage.qml b/qml/pages/HomePage.qml index 9da34a23..e5614c82 100644 --- a/qml/pages/HomePage.qml +++ b/qml/pages/HomePage.qml @@ -10,17 +10,46 @@ FocusScope { property var shell property var uiTransitionToken: 0 - readonly property bool contentReady: rows.firstRowReady + // An all-hidden library row has management controls instead of cards. + readonly property bool contentReady: rows.firstRowReady || !Providers.hasAccounts || (Libraries.count === 0 && + !Home.loading) focus: true + // With one server every library is obviously its; with several, each + // says whose it is: the provider's mark, the server, and where it is. + function sourceBadge(scopedId, withDetail) { + const origin = Sources.originOf(String(scopedId || "")) + if (!origin || !origin.serverName) + return null + return { + "iconUrl": origin.iconUrl, + "text": origin.serverName, + "detail": withDetail ? (origin.address || origin.providerName) : "" + } + } + function buildSections() { + const multiple = Sources.multipleSources + const hidden = Libraries.hiddenLibraries const sections = [ { "key": "libraries", "title": "Libraries", "model": Libraries, - "kind": "library" + "kind": "library", + "moveItem": (from, to) => Libraries.moveLibrary(from, to), + "contextMenu": (library, anchor, context) => root.shell ? root.shell.openLibraryMenu(library, + anchor, context) : + false, + "reserveWhenEmpty": hidden.length > 0, + "headerActionText": "Show hidden libraries", + "headerAction": hidden.length > 0 ? (() => root.shell ? root.shell.openLibraryMenu(null, null, { + "showHidden": + true + }) : false) : + null, + "cardBadge": multiple ? (library => root.sourceBadge(library.libraryId, true)) : null } ] sections.push({ @@ -46,7 +75,9 @@ FocusScope { "title": row && row.title ? row.title : "Recently Added", "model": row && row.model ? row.model : null, "kind": row && row.kind ? row.kind : "poster", + "enabled": !Libraries.isHidden(String(row && row.libraryId || "")), "reserveWhenEmpty": Home.loading, + "headerBadge": multiple && row ? root.sourceBadge(row.libraryId, false) : null, "useSeriesPoster": true, "preferEpisodeTitle": true, // The row's position is part of its identity for @@ -120,9 +151,9 @@ FocusScope { focus: true onEdgeUp: if (root.shell) - root.shell.focusNavBar() + root.shell.focusNavBar() onActivated: (section, index, item) => root.activateAt(section, index, item) onFirstRowReadyChanged: if (firstRowReady) - InputLatency.mark(root.uiTransitionToken, "first_delegate") + InputLatency.mark(root.uiTransitionToken, "first_delegate") } } diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 4a51ce8c..a7f7da00 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -134,16 +134,61 @@ FocusScope { property bool favoriteState: false property bool playedState: false property bool overflowOpen: false + property int providerActionsRequest: -1 + property var providerActions: [] + property string providerActionsStatus: "" + property bool canEditCollection: false + readonly property string actionContainerId: itemModel === Browse.items ? String(Browse.containerId || "") : "" + readonly property string actionEntryId: String(routeItem.playlistItemId || "") + onOverflowOpenChanged: { + if (overflowOpen) + loadProviderActions() + else { + if (providerActionsRequest >= 0) + Sources.cancelItemActions() + providerActionsRequest = -1 + providerActions = [] + } + } + function loadProviderActions() { + canEditCollection = (typeText === "Playlist" || typeText === "BoxSet") && Sources.collectionEditingAvailable(String( + item.movieId + || "")) + providerActions = [] + providerActionsStatus = "Loading provider actions…" + providerActionsRequest = Sources.requestItemActions(String(item.movieId || ""), typeText, actionContainerId, + actionEntryId) + } + Connections { + target: Sources + function onItemActionsReady(requestId, actions, problem) { + if (!root.overflowOpen || requestId !== root.providerActionsRequest) + return + root.providerActions = actions + root.providerActionsStatus = problem + Qt.callLater(function () { + if (root.overflowOpen && root.focusZone === "overflow") + root.focusOverflow(root.overflowIndex) + }) + } + function onExtensionSupportChanged(accountId) { + if (root.overflowOpen) + root.loadProviderActions() + } + } property point overflowAnchorPoint: Qt.point(0, 0) property string focusZone: "actions" property int actionIndex: 0 property int overflowIndex: 0 property string loadedDetailKey: "" + property bool contextPositionPending: true property bool seasonPickerOpen: false property int seasonPickerIndex: 0 property var seasonEntries: [] property bool routeRefreshScheduled: false + onContextRowChanged: Qt.callLater(positionContextRow) + focus: true component DetailAction: ActionButton { @@ -453,8 +498,10 @@ FocusScope { onRouteActiveChanged: { if (routeActive) enterRoute(false) - else + else { + overflowOpen = false App.cancelEpisodicPlaybackSelection() + } } onActiveFocusChanged: { @@ -580,6 +627,17 @@ FocusScope { if (similarRow) similarRow.currentIndex = similarCount > 0 ? Math.max(0, Math.min(similarRow.currentIndex, similarCount - 1)) : 0 + Qt.callLater(positionContextRow) + } + + function positionContextRow() { + if (!routeActive || !contextPositionPending || !contextRow || contextCount <= 0 || !loadedDetailKey.length) + return + if (compactEpisodicDetail) { + if (!contextRow.positionIndexAtStart(Content.detailContextInitialIndex)) + return + } + contextPositionPending = false } function refreshDetailRows() { @@ -588,6 +646,7 @@ FocusScope { if (key === loadedDetailKey) return loadedDetailKey = key + contextPositionPending = true if (contextRow) contextRow.currentIndex = 0 if (similarRow) @@ -818,6 +877,13 @@ FocusScope { const options = [] if (showContextPlaybackActions) options.push(playAllOption, shuffleOption) + if (canEditCollection) + options.push(collectionEditorOption) + for (let index = 0; index < providerActionOptions.count; ++index) { + const option = providerActionOptions.itemAt(index) + if (option) + options.push(option) + } if (mediaInfoAvailable) options.push(mediaInfoOption) if (showLetterboxdAction) @@ -827,7 +893,7 @@ FocusScope { if (option) options.push(option) } - return options + return options.filter(option => option.enabled) } function focusOverflow(index) { @@ -853,6 +919,17 @@ FocusScope { } } + function runProviderAction(actionId) { + overflowOpen = false + Sources.runItemAction(actionId, String(item.movieId || ""), typeText, actionContainerId, actionEntryId) + } + + function openCollectionEditor() { + overflowOpen = false + if (shell) + shell.openCollectionEditor(String(item.movieId || ""), titleText) + } + function openMediaInfo() { overflowOpen = false if (shell) @@ -994,10 +1071,14 @@ FocusScope { playDetailContext(false) else if (option === shuffleOption) playDetailContext(true) + else if (option === collectionEditorOption) + openCollectionEditor() else if (option === mediaInfoOption) openMediaInfo() else if (option === letterboxdOption) openLetterboxd() + else if (option && option.providerAction) + runProviderAction(option.providerAction) else if (option) openExternalUrl(option.externalUrl) } else { @@ -1021,7 +1102,10 @@ FocusScope { if (focusZone === "similar") return similarRow.longPress() return focusZone === "actions" && shell ? shell.openItemMenu(item, orderedActions()[actionIndex], { - "deferBackdropDismissal": true + "deferBackdropDismissal": true, + "containerId": actionContainerId, + "containerTitle": String(Browse.title || ""), + "entryId": actionEntryId }) : false } @@ -1383,8 +1467,7 @@ FocusScope { iconName: "menu" label: "More" checked: root.overflowOpen - visible: root.showContextPlaybackActions || root.mediaInfoAvailable - || root.showLetterboxdAction || root.showExternalActions + visible: String(root.item.movieId || "").length > 0 enabledButton: root.selectedIndex >= 0 onActivated: root.toggleOverflow() } @@ -1513,6 +1596,8 @@ FocusScope { cardWidth: root.contextPosterCards ? root.rowPosterWidth : root.rowLandscapeWidth cardKind: root.contextPosterCards ? "poster" : "landscape" cardGap: root.rowGap + allowTrailingSpace: root.compactEpisodicDetail + onWidthChanged: Qt.callLater(root.positionContextRow) enabledRow: root.showContextRow reserveWhenEmpty: root.reserveContextRow loading: root.reserveContextRow @@ -1624,6 +1709,36 @@ FocusScope { onActivated: root.playDetailContext(true) } + MenuOption { + id: collectionEditorOption + visible: root.canEditCollection + iconName: "edit" + label: "Manage entries" + onActivated: root.openCollectionEditor() + } + AppText { + width: parent.width + visible: root.providerActionsStatus.length > 0 + text: root.providerActionsStatus + color: Theme.textSecondary + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.WordWrap + } + Repeater { + id: providerActionOptions + model: root.overflowOpen ? root.providerActions : [] + + delegate: MenuOption { + required property var modelData + readonly property string providerAction: String(modelData.id || "") + iconName: String(modelData.icon || "more_horiz") + label: String(modelData.label || "") + enabled: modelData.enabled !== false + Accessible.description: String(modelData.reason || "") + onActivated: root.runProviderAction(providerAction) + } + } + MenuOption { id: mediaInfoOption visible: root.mediaInfoAvailable diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index d731a0cb..84ea25c1 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -164,12 +164,23 @@ FocusScope { return count > 0 ? 0 : -1 } + // With several servers, the same library name can mean different places. + function libraryOrigin(libraryId) { + if (!Sources.multipleSources) + return null + const origin = Sources.originOf(String(libraryId || "")) + return origin && origin.serverName ? origin : null + } + function buildLibraryEntries() { const entries = [] for (let index = 0; index < libraryCount(); ++index) { const library = Libraries.get(index) + const origin = libraryOrigin(library && library.libraryId) entries.push({ "label": String(library && library.name || ""), + "detail": origin ? [origin.serverName, origin.address || origin.providerName].filter( + Boolean).join(" · ") : "", "libraryId": String(library && library.libraryId || ""), "index": index }) @@ -177,6 +188,26 @@ FocusScope { return entries } + function refreshLibraryEntries() { + const selected = libraryEntries[libraryIndex] + libraryEntries = buildLibraryEntries() + const index = selected ? libraryEntries.findIndex(entry => entry.libraryId === selected.libraryId) : -1 + libraryIndex = Math.max(0, Math.min(index >= 0 ? index : libraryIndex, libraryEntries.length - 1)) + } + + Connections { + target: Libraries + function onModelReset() { + root.refreshLibraryEntries() + } + function onCountChanged() { + root.refreshLibraryEntries() + } + function onRowsMoved() { + root.refreshLibraryEntries() + } + } + function headerDetail() { const total = Browse.totalCount const count = Browse.items ? Browse.items.count : 0 @@ -325,7 +356,23 @@ FocusScope { }) } + // A provider may say which filters a library honours; one that does not + // is offered everything but HDR, which no server answered before. + function filterSupported(key, value) { + const supported = filterOptions ? filterOptions.supported : undefined + if (!supported || supported.length === undefined) + return key !== "isHdr" + for (let i = 0; i < supported.length; ++i) { + const entry = String(supported[i]) + if (entry === key || (value !== undefined && entry === key + ":" + value)) + return true + } + return false + } + function addListFilter(entries, section, label, key, value) { + if (!filterSupported(key, String(value))) + return entries.push({ section: false, sectionName: section, @@ -338,6 +385,8 @@ FocusScope { } function addBoolFilter(entries, section, label, key) { + if (!filterSupported(key)) + return entries.push({ section: false, sectionName: section, @@ -349,6 +398,8 @@ FocusScope { } function addNullableBoolFilter(entries, section, label, key, value) { + if (!filterSupported(key)) + return entries.push({ section: false, sectionName: section, @@ -375,6 +426,7 @@ FocusScope { addNullableBoolFilter(entries, "Video", "HD", "isHd", true) addNullableBoolFilter(entries, "Video", "SD", "isHd", false) addBoolFilter(entries, "Video", "4K", "is4K") + addBoolFilter(entries, "Video", "HDR", "isHdr") addBoolFilter(entries, "Video", "3D", "is3D") addListFilter(entries, "Video", "DVD", "videoTypes", "Dvd") addListFilter(entries, "Video", "Blu-ray", "videoTypes", "BluRay") @@ -441,6 +493,8 @@ FocusScope { ++selected; ++index } + if (options.length === 0) + continue grouped.push({ label: sectionName, detail: selected > 0 ? selected + " selected" : "", @@ -571,17 +625,19 @@ FocusScope { } function activateLibraryIndex(index) { - if (index < 0 || index >= libraryCount()) + const entry = libraryEntries[index] + if (!entry) return libraryOpen = false sortOpen = false filtersOpen = false savedIndex = 0 gridReveal.reset() - App.openLibrary(index) + if (!App.openLibraryById(String(entry.libraryId))) + return if (hasShell()) shell.replaceRoute("libraryGrid", { - libraryId: String(Libraries.get(index).libraryId || ""), + libraryId: String(entry.libraryId), focusIndex: 0 }) InputKeys.focus(grid) @@ -642,986 +698,992 @@ FocusScope { // through key handling means an automated run covers the same distance // every time whatever happens to hold focus, and the false at the end is // how it knows it has reached the bottom. - // Test and benchmark hook: what an automated run needs to know about the - // view without reaching into it -- whether the library has arrived, how - // far there is to scroll, and which layout is showing. - function viewState(): var { - return { - "count": grid.count, - "contentHeight": grid.contentHeight, - "height": grid.height, - "contentY": grid.contentY, - "listMode": root.listMode -} -} - function scrollPageDown(): bool { const maximum = Math.max(0, grid.contentHeight - grid.height) if (maximum <= 0 || grid.contentY >= maximum - 1) - return false + return false grid.contentY = Math.min(maximum, grid.contentY + grid.height) grid.requestMoreIfNeeded() return true } - // The preview controls stay resident. Only their bound item changes, so - // title and synopsis update immediately while artwork decodes separately. - readonly property var paneItem: { - const index = grid.currentIndex - if (!listMode || !Browse.items || index < 0 || index >= Browse.items.count) - return null - return Browse.items.get(index) - } + // The preview controls stay resident. Only their bound item changes, so + // title and synopsis update immediately while artwork decodes separately. + readonly property var paneItem: { + const index = grid.currentIndex + if (!listMode || !Browse.items || index < 0 || index >= Browse.items.count) + return null + return Browse.items.get(index) + } - onListModeChanged: { - touchPreviewItemId = "" - Qt.callLater(function () { - grid.forceLayout() - grid.ensureCurrentVisible() - grid.requestMoreIfNeeded() - }) - } + onListModeChanged: { + touchPreviewItemId = "" + Qt.callLater(function () { + grid.forceLayout() + grid.ensureCurrentVisible() + grid.requestMoreIfNeeded() + }) + } - // Kodi-style media info for the focused item, formatted natively in one - // call so scrolling never iterates stream lists from JS. - readonly property var mediaInfo: { - if (grid.currentIndex < 0 || grid.count <= 0) - return null - const smart = String(Settings.values["audio/trackMode"] || "Default") === "Smart" - return Browse.mediaInfoFor(grid.currentIndex, smart ? String(Settings.values["subtitles/language"] || "") : - "") - } + // Kodi-style media info for the focused item, formatted natively in one + // call so scrolling never iterates stream lists from JS. + readonly property var mediaInfo: { + if (grid.currentIndex < 0 || grid.count <= 0) + return null + const smart = String(Settings.values["audio/trackMode"] || "Default") === "Smart" + return Browse.mediaInfoFor(grid.currentIndex, smart ? String(Settings.values["subtitles/language"] || "") : "") + } - Connections { - target: Browse - function onChanged() { - if (root.filtersOpen) - root.filterEntries = root.buildFilterEntries() - if (root.sortOpen) - root.sortEntries = root.buildSortEntries() - } + Connections { + target: Browse + function onChanged() { + if (root.filtersOpen) + root.filterEntries = root.buildFilterEntries() + if (root.sortOpen) + root.sortEntries = root.buildSortEntries() } + } - AtomicViewReveal { - id: gridReveal - - view: grid - latencyMonitor: InputLatency - transitionToken: root.uiTransitionToken - enabled: Browse.items.count > 0 - firstIndex: Math.min(grid.count - 1, Math.max(0, Math.floor((grid.contentY - grid.originY) - / grid.cellHeight) * root.columns)) - lastIndex: Math.min(grid.count - 1, Math.max(firstIndex, Math.ceil((grid.contentY - grid.originY - + grid.height) / grid.cellHeight) - * root.columns - 1)) - onDelegatesReadyChanged: if (delegatesReady) - grid.requestMoreIfNeeded() - } + AtomicViewReveal { + id: gridReveal + + view: grid + latencyMonitor: InputLatency + transitionToken: root.uiTransitionToken + enabled: Browse.items.count > 0 + firstIndex: Math.min(grid.count - 1, Math.max(0, Math.floor((grid.contentY - grid.originY) / grid.cellHeight) + * root.columns)) + lastIndex: Math.min(grid.count - 1, Math.max(firstIndex, Math.ceil((grid.contentY - grid.originY + grid.height) + / grid.cellHeight) * root.columns - 1)) + onDelegatesReadyChanged: if (delegatesReady) + grid.requestMoreIfNeeded() + } - function activateCurrent() { - openDetailsAt(grid.currentIndex) - } + function activateCurrent() { + openDetailsAt(grid.currentIndex) + } - function activatePointerIndex(index, button) { - if (index < 0 || index >= grid.count) - return - const item = Browse.items.get(index) - if (button === Qt.RightButton && root.shell) { - root.shell.openItemMenu(item, grid.itemAtIndex(index)) - return - } - const itemId = String(item.movieId || "") - const selected = index === grid.currentIndex && (!Metrics.coarsePointer || (itemId.length > 0 - && touchPreviewItemId - === itemId)) - if (String(item.itemType || "") === "Audio" || !listMode || selected) { - openDetailsAt(index) - return - } - touchPreviewItemId = Metrics.coarsePointer ? itemId : "" - // List selection feeds the bio pane, but is not global navigation - // focus. Preserve the viewport even for a partly visible tapped row. - const contentY = grid.contentY - clearPendingPointerNavigation() - navigationFocusVisible = true - grid.currentIndex = index - InputKeys.focus(grid) - grid.contentY = contentY + function activatePointerIndex(index, button) { + if (index < 0 || index >= grid.count) + return + const item = Browse.items.get(index) + if (button === Qt.RightButton && root.shell) { + root.shell.openItemMenu(item, grid.itemAtIndex(index), itemMenuContext(false)) + return } - - function openDetailsAt(index) { - if (index < 0 || index >= grid.count) - return - savedIndex = index - const item = Browse.items ? (Browse.items.get(index) || ({})) : ({}) - // browseRoute is empty because this page already is the browse route: - // playing a container here should not push another one on top. - ItemActivation.open(item, { - "source": "movies", - "returnRoute": "libraryGrid", - "browseRoute": "" - }, App, hasShell() ? shell : null, Browse.items, index) + const itemId = String(item.movieId || "") + const selected = index === grid.currentIndex && (!Metrics.coarsePointer || (itemId.length > 0 + && touchPreviewItemId === itemId)) + if (String(item.itemType || "") === "Audio" || !listMode || selected) { + openDetailsAt(index) + return } + touchPreviewItemId = Metrics.coarsePointer ? itemId : "" + // List selection feeds the bio pane, but is not global navigation + // focus. Preserve the viewport even for a partly visible tapped row. + const contentY = grid.contentY + clearPendingPointerNavigation() + navigationFocusVisible = true + grid.currentIndex = index + InputKeys.focus(grid) + grid.contentY = contentY + } - function currentCard() { - return grid.currentItem - } + function openDetailsAt(index) { + if (index < 0 || index >= grid.count) + return + savedIndex = index + const item = Browse.items ? (Browse.items.get(index) || ({})) : ({}) + // browseRoute is empty because this page already is the browse route: + // playing a container here should not push another one on top. + ItemActivation.open(item, { + "source": "movies", + "returnRoute": "libraryGrid", + "browseRoute": "" + }, App, hasShell() ? shell : null, Browse.items, index) + } - function routeKey(key, phase, repeat) { - if (phase === "release" && InputKeys.isDirection(key)) - return grid.routeKey(key, phase, repeat) - if (phase !== "release" && InputKeys.isDirection(key) && grid.activeFocus && pointerNavigationPending) - return applyPendingPointerNavigation() - if (libraryList && libraryList.activeFocus) - return libraryList.routeKey(key, phase, repeat) - if (sortList && sortList.activeFocus) - return sortList.routeKey(key, phase, repeat) - if (filterList && filterList.activeFocus) - return filterList.routeKey(key, phase, repeat) - - const toolbar = toolbarButtons() - const toolbarIndex = toolbar.findIndex(function (button) { - return button.activeFocus - }) - if (toolbarIndex >= 0) { - if (key === Qt.Key_Up) { - if (hasShell()) - shell.focusNavBar() - return true - } - if (key === Qt.Key_Down) { - InputKeys.focus(grid) - return true - } - if (key === Qt.Key_Left || key === Qt.Key_Right) { - const next = toolbarIndex + (key === Qt.Key_Right ? 1 : -1) - if (next >= 0 && next < toolbar.length) - InputKeys.focus(toolbar[next]) - return true - } - return false + function currentCard() { + return grid.currentItem + } + + function routeKey(key, phase, repeat) { + if (phase === "release" && InputKeys.isDirection(key)) + return grid.routeKey(key, phase, repeat) + if (phase !== "release" && InputKeys.isDirection(key) && grid.activeFocus && pointerNavigationPending) + return applyPendingPointerNavigation() + if (libraryList && libraryList.activeFocus) + return libraryList.routeKey(key, phase, repeat) + if (sortList && sortList.activeFocus) + return sortList.routeKey(key, phase, repeat) + if (filterList && filterList.activeFocus) + return filterList.routeKey(key, phase, repeat) + + const toolbar = toolbarButtons() + const toolbarIndex = toolbar.findIndex(function (button) { + return button.activeFocus + }) + if (toolbarIndex >= 0) { + if (key === Qt.Key_Up) { + if (hasShell()) + shell.focusNavBar() + return true + } + if (key === Qt.Key_Down) { + InputKeys.focus(grid) + return true + } + if (key === Qt.Key_Left || key === Qt.Key_Right) { + const next = toolbarIndex + (key === Qt.Key_Right ? 1 : -1) + if (next >= 0 && next < toolbar.length) + InputKeys.focus(toolbar[next]) + return true } - return grid.count > 0 && grid.routeKey(key, phase, repeat) + return false } + return grid.count > 0 && grid.routeKey(key, phase, repeat) + } - function clearPendingPointerNavigation() { - pointerNavigationPending = false - pendingScrollController = null - } + function clearPendingPointerNavigation() { + pointerNavigationPending = false + pendingScrollController = null + } - function beginPointerNavigation(controller) { - if (controller) - pendingScrollController = controller - pointerNavigationPending = true - navigationFocusVisible = false - } + function beginPointerNavigation(controller) { + if (controller) + pendingScrollController = controller + pointerNavigationPending = true + navigationFocusVisible = false + } - function applyPendingPointerNavigation() { - if (pendingScrollController && pendingScrollController.stopScrolling) - pendingScrollController.stopScrolling() - grid.cancelFlick() - grid.forceLayout() - const visibleIndex = grid.topLeftVisibleIndex() - if (visibleIndex < 0) { - clearPendingPointerNavigation() - navigationFocusVisible = true - return true - } - const contentX = grid.contentX - const contentY = grid.contentY - InputKeys.focusIndexWithoutScrolling(grid, visibleIndex) - grid.contentX = contentX - grid.contentY = contentY - navigationFocusVisible = true + function applyPendingPointerNavigation() { + if (pendingScrollController && pendingScrollController.stopScrolling) + pendingScrollController.stopScrolling() + grid.cancelFlick() + grid.forceLayout() + const visibleIndex = grid.topLeftVisibleIndex() + if (visibleIndex < 0) { clearPendingPointerNavigation() + navigationFocusVisible = true return true } + const contentX = grid.contentX + const contentY = grid.contentY + InputKeys.focusIndexWithoutScrolling(grid, visibleIndex) + grid.contentX = contentX + grid.contentY = contentY + navigationFocusVisible = true + clearPendingPointerNavigation() + return true + } - function activate() { - if (libraryList && libraryList.activeFocus) - libraryList.activate() - else if (sortList && sortList.activeFocus) - sortList.activate() - else if (filterList && filterList.activeFocus) - filterList.activate() - else if (libraryButton.activeFocus) - openLibraryMenu() - else if (viewButton.activeFocus) - toggleViewMode() - else if (sortButton.activeFocus) - openSortMenu() - else if (filterButton.activeFocus) - openFilterMenu() - else if (clearFiltersButton.activeFocus) - Browse.clearFilters() - else - grid.activate() - } + function activate() { + if (libraryList && libraryList.activeFocus) + libraryList.activate() + else if (sortList && sortList.activeFocus) + sortList.activate() + else if (filterList && filterList.activeFocus) + filterList.activate() + else if (libraryButton.activeFocus) + openLibraryMenu() + else if (viewButton.activeFocus) + toggleViewMode() + else if (sortButton.activeFocus) + openSortMenu() + else if (filterButton.activeFocus) + openFilterMenu() + else if (clearFiltersButton.activeFocus) + Browse.clearFilters() + else + grid.activate() + } - function longPress() { - if (!grid.activeFocus || grid.currentIndex < 0 || !hasShell()) - return false - return shell.openItemMenu(Browse.items.get(grid.currentIndex) || ({}), currentCard(), { - "deferBackdropDismissal": true - }) + function itemMenuContext(deferBackdrop) { + return { + containerId: String(Browse.containerId || ""), + containerTitle: String(Browse.title || ""), + deferBackdropDismissal: deferBackdrop } + } - function back() { - if (typeAheadBuffer.length > 0) { - typeAheadBuffer = "" - typeAheadReset.stop() - return true - } - if (libraryOpen || sortOpen || filtersOpen) { - closeMenus() - return true - } + function longPress() { + if (!grid.activeFocus || grid.currentIndex < 0 || !hasShell()) return false + return shell.openItemMenu(Browse.items.get(grid.currentIndex) || ({}), currentCard(), itemMenuContext(true)) + } + + function back() { + if (typeAheadBuffer.length > 0) { + typeAheadBuffer = "" + typeAheadReset.stop() + return true } - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Metrics.pageMarginPx - anchors.rightMargin: Metrics.pageMarginPx - anchors.topMargin: root.contentTopMargin - anchors.bottomMargin: 0 - spacing: 12 - RowLayout { + if (libraryOpen || sortOpen || filtersOpen) { + closeMenus() + return true + } + return false + } + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.pageMarginPx + anchors.rightMargin: Metrics.pageMarginPx + anchors.topMargin: root.contentTopMargin + anchors.bottomMargin: 0 + spacing: 12 + RowLayout { + Layout.fillWidth: true + Layout.rightMargin: -Metrics.pageMarginPx + Metrics.scaled(14) + spacing: 10 + + FocusScope { + id: libraryButton Layout.fillWidth: true - Layout.rightMargin: -Metrics.pageMarginPx + Metrics.scaled(14) - spacing: 10 - - FocusScope { - id: libraryButton - Layout.fillWidth: true - Layout.preferredHeight: Metrics.scaled(44) - focus: true - - Rectangle { - anchors.left: titleRow.left - anchors.right: titleRow.right - anchors.verticalCenter: titleRow.verticalCenter - height: Metrics.scaled(36) - radius: Theme.radiusSmall - color: libraryButton.activeFocus || root.libraryOpen ? Theme.accentPanel : "transparent" - border.width: libraryButton.activeFocus ? 2 : 0 - border.color: Theme.textPrimary - } - - Row { - id: titleRow - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 4 - anchors.rightMargin: 8 - spacing: 6 - width: Math.min(titleText.implicitWidth + 30, Math.max(160, parent.width - - headerDetailText.width - 32)) + Layout.preferredHeight: Metrics.scaled(44) + focus: true + + Rectangle { + anchors.left: titleRow.left + anchors.right: titleRow.right + anchors.verticalCenter: titleRow.verticalCenter + height: Metrics.scaled(36) + radius: Theme.radiusSmall + color: libraryButton.activeFocus || root.libraryOpen ? Theme.accentPanel : "transparent" + border.width: libraryButton.activeFocus ? 2 : 0 + border.color: Theme.textPrimary + } - AppText { - id: titleText - anchors.verticalCenter: parent.verticalCenter - width: Math.max(0, titleRow.width - 30) - text: Browse.title.length > 0 ? Browse.title : "Library" - font.pixelSize: Metrics.bodySizePx + 4 - font.weight: Font.DemiBold - maximumLineCount: 1 - elide: Text.ElideRight - } + Row { + id: titleRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 4 + anchors.rightMargin: 8 + spacing: 6 + width: Math.min(titleText.implicitWidth + 30, Math.max(160, parent.width - headerDetailText.width + - 32)) - MaterialIcon { - anchors.verticalCenter: parent.verticalCenter - visible: !root.isFixedBrowseView - name: root.libraryOpen ? "expand_less" : "expand_more" - iconSize: 24 - iconColor: root.libraryOpen || libraryButton.activeFocus ? Theme.textPrimary : - Theme.textSecondary - } - } - - SecondaryText { - id: headerDetailText - anchors.right: parent.right + AppText { + id: titleText anchors.verticalCenter: parent.verticalCenter - text: root.headerDetail() - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx + width: Math.max(0, titleRow.width - 30) + text: Browse.title.length > 0 ? Browse.title : "Library" + font.pixelSize: Metrics.bodySizePx + 4 + font.weight: Font.DemiBold maximumLineCount: 1 elide: Text.ElideRight - width: Math.min(520, Math.max(0, parent.width * 0.52)) } - BusySpinner { - anchors.right: headerDetailText.left - anchors.rightMargin: 10 + MaterialIcon { + id: libraryChevron anchors.verticalCenter: parent.verticalCenter - width: 28 - height: 28 - running: root.browseLoading - visible: running + visible: !root.isFixedBrowseView + name: root.libraryOpen ? "expand_less" : "expand_more" + iconSize: 24 + iconColor: root.libraryOpen || libraryButton.activeFocus ? Theme.textPrimary : + Theme.textSecondary } + } - MouseArea { - anchors.fill: parent - onClicked: root.openLibraryMenu() - } + SourceBadge { + readonly property var origin: root.libraryOrigin(Browse.libraryId) + anchors.left: titleRow.right + anchors.leftMargin: Metrics.scaled(10) + anchors.verticalCenter: parent.verticalCenter + visible: !!origin && titleRow.x + titleRow.width + width + Metrics.scaled(20) < headerDetailText.x + iconUrl: origin ? origin.iconUrl : "" + text: origin ? origin.serverName : "" } - ToolbarButton { - id: viewButton - iconName: root.listMode ? "view_list" : "grid_view" - label: root.listMode ? "List" : "Posters" - onActivated: root.toggleViewMode() + SecondaryText { + id: headerDetailText + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.headerDetail() + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + maximumLineCount: 1 + elide: Text.ElideRight + width: Math.min(520, Math.max(0, parent.width * 0.52)) } - ToolbarButton { - id: sortButton - visible: !root.isFixedBrowseView - iconName: root.currentSortOrder() === "Descending" ? "south" : "north" - label: root.currentSortLabel() - checked: root.sortOpen - onActivated: root.openSortMenu() + BusySpinner { + anchors.right: headerDetailText.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + running: root.browseLoading + visible: running } - ToolbarButton { - id: filterButton - visible: !root.isFixedBrowseView - iconName: "filter_list" - label: "Filter" - checked: root.filtersOpen - badge: root.activeFilterCount > 0 - onActivated: root.filtersOpen ? root.closeMenus() : root.openFilterMenu() + MouseArea { + anchors.fill: parent + onClicked: root.openLibraryMenu() } + } - ToolbarButton { - id: clearFiltersButton - iconName: "close" - label: "Clear" - visible: !root.isFixedBrowseView && root.activeFilterCount > 0 && root.lane !== "compact" - onActivated: { - root.savedIndex = 0 - gridReveal.reset() - Browse.clearFilters() - } + ToolbarButton { + id: viewButton + iconName: root.listMode ? "view_list" : "grid_view" + label: root.listMode ? "List" : "Posters" + onActivated: root.toggleViewMode() + } + + ToolbarButton { + id: sortButton + visible: !root.isFixedBrowseView + iconName: root.currentSortOrder() === "Descending" ? "south" : "north" + label: root.currentSortLabel() + checked: root.sortOpen + onActivated: root.openSortMenu() + } + + ToolbarButton { + id: filterButton + visible: !root.isFixedBrowseView + iconName: "filter_list" + label: "Filter" + checked: root.filtersOpen + badge: root.activeFilterCount > 0 + onActivated: root.filtersOpen ? root.closeMenus() : root.openFilterMenu() + } + + ToolbarButton { + id: clearFiltersButton + iconName: "close" + label: "Clear" + visible: !root.isFixedBrowseView && root.activeFilterCount > 0 && root.lane !== "compact" + onActivated: { + root.savedIndex = 0 + gridReveal.reset() + Browse.clearFilters() } } + } - RowLayout { - Layout.fillWidth: true - Layout.rightMargin: -Metrics.pageMarginPx + RowLayout { + Layout.fillWidth: true + Layout.rightMargin: -Metrics.pageMarginPx + Layout.fillHeight: true + Layout.topMargin: root.listMode ? 0 : Metrics.scaled(6) + Layout.bottomMargin: mediaInfoBar.visible ? mediaInfoBar.height + Metrics.scaled(10) : 0 + spacing: root.listMode ? Metrics.sectionGapPx : 0 + + // List mode's detail controls stay resident and rebind to the + // current item; no adjacent or off-screen preview panes exist. + Item { + id: listPane + // The pane is a companion column, so it only exists where there is + // room for one beside the list. + visible: root.listMode && root.lane !== "compact" + Layout.preferredWidth: Math.round(root.width * (root.lane === "wide" ? 0.28 : 0.36)) Layout.fillHeight: true - Layout.topMargin: root.listMode ? 0 : Metrics.scaled(6) - Layout.bottomMargin: mediaInfoBar.visible ? mediaInfoBar.height + Metrics.scaled(10) : 0 - spacing: root.listMode ? Metrics.sectionGapPx : 0 - // List mode's detail controls stay resident and rebind to the - // current item; no adjacent or off-screen preview panes exist. + ImageCard { + id: panePoster + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + height: Math.round(Math.min(parent.width * root.artworkAspect, parent.height * (root.largeZoom + ? 0.64 : 0.56))) + width: Math.round(height / root.artworkAspect) + imageUrl: root.paneItem && root.paneItem.movieId ? Art.url(root.paneItem, root.squareArtwork + ? "square" : "poster") : "" + fallbackText: String(root.paneItem && root.paneItem.title || "") + } + Item { - id: listPane - // The pane is a companion column, so it only exists where there is - // room for one beside the list. - visible: root.listMode && root.lane !== "compact" - Layout.preferredWidth: Math.round(root.width * (root.lane === "wide" ? 0.28 : 0.36)) - Layout.fillHeight: true - - ImageCard { - id: panePoster - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - height: Math.round(Math.min(parent.width * root.artworkAspect, parent.height * (root.largeZoom - ? 0.64 : 0.56))) - width: Math.round(height / root.artworkAspect) - imageUrl: root.paneItem && root.paneItem.movieId ? Art.url(root.paneItem, root.squareArtwork - ? "square" : "poster") : "" - fallbackText: String(root.paneItem && root.paneItem.title || "") + id: paneHeading + readonly property bool metaInline: paneMeta.text.length > 0 && paneTitle.implicitWidth + + paneMeta.implicitWidth + Metrics.scaled(10) <= width + anchors.top: panePoster.bottom + anchors.topMargin: Metrics.scaled(root.smallZoom ? 30 : 22) + anchors.left: parent.left + anchors.right: parent.right + height: metaInline ? Math.max(paneTitle.contentHeight, paneMeta.implicitHeight) : + paneTitle.contentHeight + (paneMeta.text.length > 0 ? Metrics.scaled(4) + + paneMeta.implicitHeight : + 0) + + TextMetrics { + id: paneTitleMetrics + font: paneTitle.font + text: paneTitle.text } - Item { - id: paneHeading - readonly property bool metaInline: paneMeta.text.length > 0 && paneTitle.implicitWidth - + paneMeta.implicitWidth + Metrics.scaled(10) <= width - anchors.top: panePoster.bottom - anchors.topMargin: Metrics.scaled(root.smallZoom ? 30 : 22) - anchors.left: parent.left - anchors.right: parent.right - height: metaInline ? Math.max(paneTitle.contentHeight, paneMeta.implicitHeight) : - paneTitle.contentHeight + (paneMeta.text.length > 0 ? Metrics.scaled(4) - + paneMeta.implicitHeight : - 0) - - TextMetrics { - id: paneTitleMetrics - font: paneTitle.font - text: paneTitle.text - } - - TextMetrics { - id: paneMetaMetrics - font: paneMeta.font - text: paneMeta.text - } - - AppText { - id: paneTitle - anchors.top: parent.top - anchors.left: parent.left - width: paneHeading.metaInline ? implicitWidth : parent.width - text: String(root.paneItem && root.paneItem.title || "") - font.pixelSize: Metrics.bodySizePx + 4 - font.weight: Font.DemiBold - maximumLineCount: 2 - wrapMode: Text.Wrap - elide: Text.ElideRight - } - - SecondaryText { - id: paneMeta - anchors.left: paneHeading.metaInline ? paneTitle.right : parent.left - anchors.leftMargin: paneHeading.metaInline ? Metrics.scaled(10) : 0 - y: paneHeading.metaInline ? Math.round(paneTitle.baselineOffset - + paneTitleMetrics.tightBoundingRect.y - + paneTitleMetrics.tightBoundingRect.height / 2 - - paneMeta.baselineOffset - - paneMetaMetrics.tightBoundingRect.y - - paneMetaMetrics.tightBoundingRect.height / 2) : - paneTitle.contentHeight + Metrics.scaled(4) - text: String(root.paneItem && root.paneItem.subtitle || "") - visible: text.length > 0 - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - maximumLineCount: 1 - elide: Text.ElideRight - } + TextMetrics { + id: paneMetaMetrics + font: paneMeta.font + text: paneMeta.text } AppText { - anchors.top: paneHeading.bottom - anchors.topMargin: Metrics.scaled(10) + id: paneTitle + anchors.top: parent.top anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - visible: text.length > 0 - text: String(root.paneItem && root.paneItem.overview || "") - color: Theme.textSecondary - font.pixelSize: Metrics.bodySizePx - lineHeight: 1.2 + width: paneHeading.metaInline ? implicitWidth : parent.width + text: String(root.paneItem && root.paneItem.title || "") + font.pixelSize: Metrics.bodySizePx + 4 + font.weight: Font.DemiBold + maximumLineCount: 2 wrapMode: Text.Wrap elide: Text.ElideRight } + + SecondaryText { + id: paneMeta + anchors.left: paneHeading.metaInline ? paneTitle.right : parent.left + anchors.leftMargin: paneHeading.metaInline ? Metrics.scaled(10) : 0 + y: paneHeading.metaInline ? Math.round(paneTitle.baselineOffset + + paneTitleMetrics.tightBoundingRect.y + + paneTitleMetrics.tightBoundingRect.height / 2 + - paneMeta.baselineOffset + - paneMetaMetrics.tightBoundingRect.y + - paneMetaMetrics.tightBoundingRect.height / 2) : + paneTitle.contentHeight + Metrics.scaled(4) + text: String(root.paneItem && root.paneItem.subtitle || "") + visible: text.length > 0 + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + maximumLineCount: 1 + elide: Text.ElideRight + } } - NavGrid { - id: grid - readonly property int memoryMiB: NativeWindow.systemMemoryBytes > 0 ? Math.round( - NativeWindow.systemMemoryBytes - / 1048576) : 2048 - readonly property int artworkMarginRows: Platform.isTV ? (memoryMiB >= 3000 ? 8 : memoryMiB >= 1800 - ? 5 : 3) : 12 - readonly property int focusPadding: Math.max(2, Metrics.scaled(2)) - property int geometryAnchorIndex: -1 - holdTraversalSeconds: root.listMode && count > 200 ? 3.5 : 5 - holdSpeedMultiplier: root.listMode ? 1 : 0.5 - Layout.fillWidth: true - Layout.fillHeight: true - focus: true - clip: true - keyNavigationEnabled: false - reuseItems: true - boundsBehavior: Flickable.StopAtBounds - model: Browse.items - leftMargin: focusPadding - rightMargin: focusPadding + Math.max(Metrics.scaled(10), 10) + Metrics.scaled(6) - // Each cell carries its own trailing gap (delegates are - // cellWidth - gapPx), so the track must not be charged for the - // gaps a second time: divide the whole run evenly and let the - // posters grow into the space the double count used to waste. - cellWidth: Math.floor((width - leftMargin - rightMargin) / columns) - cellHeight: root.listMode ? Metrics.scaled(root.largeZoom ? 60 : 54) : cellWidth - * root.artworkAspect + Metrics.scaled(64) - - cacheBuffer: gridReveal.delegatesReady ? cellHeight * artworkMarginRows : 0 - Component.onCompleted: { + AppText { + anchors.top: paneHeading.bottom + anchors.topMargin: Metrics.scaled(10) + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: text.length > 0 + text: String(root.paneItem && root.paneItem.overview || "") + color: Theme.textSecondary + font.pixelSize: Metrics.bodySizePx + lineHeight: 1.2 + wrapMode: Text.Wrap + elide: Text.ElideRight + } + } + + NavGrid { + id: grid + readonly property int memoryMiB: NativeWindow.systemMemoryBytes > 0 ? Math.round( + NativeWindow.systemMemoryBytes + / 1048576) : 2048 + readonly property int artworkMarginRows: Platform.isTV ? (memoryMiB >= 3000 ? 8 : memoryMiB >= 1800 ? 5 : 3) : + 12 + readonly property int focusPadding: Math.max(2, Metrics.scaled(2)) + property int geometryAnchorIndex: -1 + holdTraversalSeconds: root.listMode && count > 200 ? 3.5 : 5 + holdSpeedMultiplier: root.listMode ? 1 : 0.5 + Layout.fillWidth: true + Layout.fillHeight: true + focus: true + clip: true + keyNavigationEnabled: false + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + model: Browse.items + leftMargin: focusPadding + rightMargin: focusPadding + Math.max(Metrics.scaled(10), 10) + Metrics.scaled(6) + // Each cell carries its own trailing gap (delegates are + // cellWidth - gapPx), so the track must not be charged for the + // gaps a second time: divide the whole run evenly and let the + // posters grow into the space the double count used to waste. + cellWidth: Math.floor((width - leftMargin - rightMargin) / columns) + cellHeight: root.listMode ? Metrics.scaled(root.largeZoom ? 60 : 54) : cellWidth * root.artworkAspect + Metrics.scaled( + 64) + + cacheBuffer: gridReveal.delegatesReady ? cellHeight * artworkMarginRows : 0 + Component.onCompleted: { + restoreIndex() + requestMoreIfNeeded() + gridReveal.reset() + } + onCellWidthChanged: scheduleGeometryRelayout() + onCellHeightChanged: scheduleGeometryRelayout() + onCountChanged: { + if (count <= 0) + currentIndex = -1 + else if (currentIndex < 0 || currentIndex >= count) restoreIndex() - requestMoreIfNeeded() - gridReveal.reset() - } - onCellWidthChanged: scheduleGeometryRelayout() - onCellHeightChanged: scheduleGeometryRelayout() - onCountChanged: { - if (count <= 0) - currentIndex = -1 - else if (currentIndex < 0 || currentIndex >= count) - restoreIndex() - requestMoreIfNeeded() - } - onContentYChanged: { - requestPageIfNeeded() - artworkWindowDebounce.restart() - alphabetFeedback.restart() - } - onCurrentIndexChanged: { - if (currentIndex >= 0) - root.savedIndex = currentIndex - alphabetFeedback.restart() - requestPageIfNeeded() - routeCheckpoint.restart() - } + requestMoreIfNeeded() + } + onContentYChanged: { + requestPageIfNeeded() + artworkWindowDebounce.restart() + alphabetFeedback.restart() + } + onCurrentIndexChanged: { + if (currentIndex >= 0) + root.savedIndex = currentIndex + alphabetFeedback.restart() + requestPageIfNeeded() + routeCheckpoint.restart() + } - FastWheelHandler { - id: gridWheelHandler - onScrolled: root.beginPointerNavigation(gridWheelHandler) - flickable: grid - } - onDraggingChanged: if (dragging) - root.beginPointerNavigation(null) - ListScrollBar { - id: libraryScrollBar - anchors.top: parent.top - anchors.right: parent.right - anchors.bottom: parent.bottom - width: Math.ceil(Math.max(Metrics.scaled(10), 10) * 3) - // Above the delegate MouseArea below, which fills the view - // and would otherwise grab every press over the bar. - z: 4 - flickable: grid - // The magic remote points and drags, so a television is - // not on its own a reason to make the bar inert. - interactive: Platform.hasPointer - minimumSize: 0.04 - onScrolled: root.beginPointerNavigation(null) - } - onEdgeUp: root.focusToolbar() - onAccepted: root.activateCurrent() - onHoldStarted: Browse.prefetchNextPage() + FastWheelHandler { + id: gridWheelHandler + onScrolled: root.beginPointerNavigation(gridWheelHandler) + flickable: grid + } + onDraggingChanged: if (dragging) + root.beginPointerNavigation(null) + ListScrollBar { + id: libraryScrollBar + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + width: Math.ceil(Math.max(Metrics.scaled(10), 10) * 3) + // Above the delegate MouseArea below, which fills the view + // and would otherwise grab every press over the bar. + z: 4 + flickable: grid + // The magic remote points and drags, so a television is + // not on its own a reason to make the bar inert. + interactive: Platform.hasPointer + minimumSize: 0.04 + onScrolled: root.beginPointerNavigation(null) + } + onEdgeUp: root.focusToolbar() + onAccepted: root.activateCurrent() + onHoldStarted: Browse.prefetchNextPage() - function restoreIndex() { - currentIndex = count > 0 ? Math.max(0, Math.min(root.savedIndex, count - 1)) : -1 - ensureCurrentVisible() - } - function scheduleGeometryRelayout() { - if (!geometryRelayout.running) - geometryAnchorIndex = topLeftVisibleIndex() - geometryRelayout.restart() - } + function restoreIndex() { + currentIndex = count > 0 ? Math.max(0, Math.min(root.savedIndex, count - 1)) : -1 + ensureCurrentVisible() + } + function scheduleGeometryRelayout() { + if (!geometryRelayout.running) + geometryAnchorIndex = topLeftVisibleIndex() + geometryRelayout.restart() + } - function applyGeometryRelayout() { - forceLayout() - const anchor = count > 0 ? Math.max(0, Math.min(geometryAnchorIndex, count - 1)) : -1 - if (anchor === 0) - positionViewAtBeginning() - else if (anchor > 0) - positionViewAtIndex(anchor, GridView.Beginning) - geometryAnchorIndex = -1 - requestMoreIfNeeded() - } + function applyGeometryRelayout() { + forceLayout() + const anchor = count > 0 ? Math.max(0, Math.min(geometryAnchorIndex, count - 1)) : -1 + if (anchor === 0) + positionViewAtBeginning() + else if (anchor > 0) + positionViewAtIndex(anchor, GridView.Beginning) + geometryAnchorIndex = -1 + requestMoreIfNeeded() + } - Timer { - id: geometryRelayout - interval: 0 - onTriggered: grid.applyGeometryRelayout() - } + Timer { + id: geometryRelayout + interval: 0 + onTriggered: grid.applyGeometryRelayout() + } - function ensureCurrentVisible() { - if (currentIndex >= 0) - positionViewAtIndex(currentIndex, GridView.Contain) - } + function ensureCurrentVisible() { + if (currentIndex >= 0) + positionViewAtIndex(currentIndex, GridView.Contain) + } - function lastLikelyVisibleIndex() { - if (count <= 0 || cellHeight <= 0 || columns <= 0) - return -1 - const firstRow = Math.max(0, Math.floor((contentY - originY) / cellHeight)) - const visibleRows = Math.ceil(height / cellHeight) + 3 - return Math.min(count - 1, (firstRow + visibleRows) * columns - 1) - } + function lastLikelyVisibleIndex() { + if (count <= 0 || cellHeight <= 0 || columns <= 0) + return -1 + const firstRow = Math.max(0, Math.floor((contentY - originY) / cellHeight)) + const visibleRows = Math.ceil(height / cellHeight) + 3 + return Math.min(count - 1, (firstRow + visibleRows) * columns - 1) + } + + function firstLikelyVisibleIndex() { + if (count <= 0 || cellHeight <= 0 || columns <= 0) + return -1 + return Math.min(count - 1, Math.max(0, Math.floor((contentY - originY) / cellHeight) * columns)) + } - function firstLikelyVisibleIndex() { - if (count <= 0 || cellHeight <= 0 || columns <= 0) - return -1 - return Math.min(count - 1, Math.max(0, Math.floor((contentY - originY) / cellHeight) * columns)) + function requestMoreIfNeeded() { + if (count <= 0 || !gridReveal.delegatesReady) + return + const visibleHead = firstLikelyVisibleIndex() + const visibleTail = Math.max(currentIndex, lastLikelyVisibleIndex()) + Browse.prefetchVisibleRange(visibleHead, visibleTail) + } + + function requestPageIfNeeded() { + if (count <= 0) + return + Browse.prefetchPageForIndex(Math.max(currentIndex, lastLikelyVisibleIndex())) + } + + Timer { + id: artworkWindowDebounce + interval: 60 + repeat: false + onTriggered: { + grid.requestMoreIfNeeded() } + } + + Timer { + id: routeCheckpoint + interval: 250 + repeat: false + onTriggered: if (root.shell) + Router.checkpoint({ + libraryId: Browse.libraryId, + focusIndex: Math.max(0, grid.currentIndex) + }) + } + + Timer { + id: alphabetFeedback + interval: 700 + repeat: false + } - function requestMoreIfNeeded() { - if (count <= 0 || !gridReveal.delegatesReady) - return - const visibleHead = firstLikelyVisibleIndex() - const visibleTail = Math.max(currentIndex, lastLikelyVisibleIndex()) - Browse.prefetchVisibleRange(visibleHead, visibleTail) + // Scrub letter callout: rounded on its left, top and bottom, with + // no right edge — the top-right and bottom-right shoulders funnel + // into the scroll bar so the tag reads as emanating from it. + Shape { + id: alphabetCallout + + readonly property real stroke: Math.max(2, Metrics.scaled(2)) + readonly property real inset: stroke / 2 + readonly property real cornerRadius: Metrics.scaled(12) + readonly property real neckWidth: Metrics.scaled(16) + readonly property real mouthHalf: Metrics.scaled(13) + readonly property real bodyRight: width - neckWidth + readonly property real midY: height / 2 + readonly property real desiredY: libraryScrollBar.handleCenterY - height / 2 + + anchors.right: libraryScrollBar.right + anchors.rightMargin: libraryScrollBar.visualWidth + y: Math.max(0, Math.min(grid.height - height, desiredY)) + width: Metrics.scaled(52) + neckWidth + height: Metrics.scaled(48) + // Directional navigation scrolls the view without touching + // the bar, so the bar says where it has gone: the callout + // reads the same whether a remote walked the grid there or + // a pointer dragged the handle. + opacity: alphabetFeedback.running || libraryScrollBar.pressed ? 1 : 0 + visible: opacity > 0 + preferredRendererType: Shape.CurveRenderer + z: 21 + + // The stroke runs mouth -> top -> left -> bottom -> mouth and + // stops there; only the fill closes the contour across the + // bar, which is what leaves the right edge open. + ShapePath { + fillColor: Theme.accentPanel + strokeColor: Theme.accent + strokeWidth: alphabetCallout.stroke + capStyle: ShapePath.FlatCap + joinStyle: ShapePath.RoundJoin + startX: alphabetCallout.width + startY: alphabetCallout.midY - alphabetCallout.mouthHalf + + PathCubic { + control1X: alphabetCallout.width - alphabetCallout.neckWidth * 0.35 + control1Y: alphabetCallout.midY - alphabetCallout.mouthHalf + control2X: alphabetCallout.bodyRight + alphabetCallout.neckWidth * 0.55 + control2Y: alphabetCallout.inset + x: alphabetCallout.bodyRight + y: alphabetCallout.inset + } + PathLine { + x: alphabetCallout.cornerRadius + alphabetCallout.inset + y: alphabetCallout.inset + } + PathArc { + radiusX: alphabetCallout.cornerRadius + radiusY: alphabetCallout.cornerRadius + direction: PathArc.Counterclockwise + x: alphabetCallout.inset + y: alphabetCallout.cornerRadius + alphabetCallout.inset + } + PathLine { + x: alphabetCallout.inset + y: alphabetCallout.height - alphabetCallout.cornerRadius - alphabetCallout.inset + } + PathArc { + radiusX: alphabetCallout.cornerRadius + radiusY: alphabetCallout.cornerRadius + direction: PathArc.Counterclockwise + x: alphabetCallout.cornerRadius + alphabetCallout.inset + y: alphabetCallout.height - alphabetCallout.inset + } + PathLine { + x: alphabetCallout.bodyRight + y: alphabetCallout.height - alphabetCallout.inset + } + PathCubic { + control1X: alphabetCallout.bodyRight + alphabetCallout.neckWidth * 0.55 + control1Y: alphabetCallout.height - alphabetCallout.inset + control2X: alphabetCallout.width - alphabetCallout.neckWidth * 0.35 + control2Y: alphabetCallout.midY + alphabetCallout.mouthHalf + x: alphabetCallout.width + y: alphabetCallout.midY + alphabetCallout.mouthHalf + } } - function requestPageIfNeeded() { - if (count <= 0) - return - Browse.prefetchPageForIndex(Math.max(currentIndex, lastLikelyVisibleIndex())) + AppText { + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + anchors.horizontalCenterOffset: -alphabetCallout.neckWidth / 2 + width: alphabetCallout.bodyRight - Metrics.scaled(8) + height: alphabetCallout.height - Metrics.scaled(8) + text: root.currentAlphabetLabel + font.pixelSize: Metrics.scaled(26) + fontSizeMode: Text.Fit + minimumPixelSize: Metrics.scaled(8) + elide: Text.ElideRight + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: Theme.textPrimary } - Timer { - id: artworkWindowDebounce - interval: 60 - repeat: false - onTriggered: { - grid.requestMoreIfNeeded() + Behavior on opacity { + NumberAnimation { + duration: Theme.reducedMotion ? 0 : 100 } } + } - Timer { - id: routeCheckpoint - interval: 250 - repeat: false - onTriggered: if (root.shell) - Router.checkpoint({ - libraryId: Browse.libraryId, - focusIndex: Math.max(0, grid.currentIndex) - }) + delegate: root.listMode ? listRowComponent : posterCardComponent + + Component { + id: posterCardComponent + + MediaItemCard { + id: gridDelegate + + required property int index + property bool artworkPooled: false + GridView.onPooled: artworkPooled = true + GridView.onReused: artworkPooled = false + + width: grid.cellWidth - Metrics.gapPx + height: grid.cellHeight + shell: root.shell + kind: root.squareArtwork ? "square" : "poster" + preferEpisodeTitle: false + useSeriesPoster: true + focused: Metrics.keyboardFocusActive && root.navigationFocusVisible && grid.activeFocus + && gridDelegate.GridView.isCurrentItem + artworkVisible: true + // Buffered delegates must not enter the render queue: + // scrolling cancels old requests and loads the viewport + // first. Browse prefetch warms artwork separately. + // Delegate y is initially zero while GridView lays out + // buffered/reused cells. Use the model row and origin + // instead so those transient positions cannot start IO. + artworkEnabled: !artworkPooled && grid.cellHeight > 0 && root.columns > 0 && (Math.floor(index + / root.columns) + + 1) * grid.cellHeight + > grid.contentY - grid.originY && Math.floor(index / root.columns) + * grid.cellHeight < grid.contentY - grid.originY + grid.height + + Component.onCompleted: gridReveal.schedule() + onArtworkReadyChanged: gridReveal.schedule() } + } - Timer { - id: alphabetFeedback - interval: 700 - repeat: false - } + Component { + id: listRowComponent - // Scrub letter callout: rounded on its left, top and bottom, with - // no right edge — the top-right and bottom-right shoulders funnel - // into the scroll bar so the tag reads as emanating from it. - Shape { - id: alphabetCallout - - readonly property real stroke: Math.max(2, Metrics.scaled(2)) - readonly property real inset: stroke / 2 - readonly property real cornerRadius: Metrics.scaled(12) - readonly property real neckWidth: Metrics.scaled(16) - readonly property real mouthHalf: Metrics.scaled(13) - readonly property real bodyRight: width - neckWidth - readonly property real midY: height / 2 - readonly property real desiredY: libraryScrollBar.handleCenterY - height / 2 - - anchors.right: libraryScrollBar.right - anchors.rightMargin: libraryScrollBar.visualWidth - y: Math.max(0, Math.min(grid.height - height, desiredY)) - width: Metrics.scaled(52) + neckWidth - height: Metrics.scaled(48) - // Directional navigation scrolls the view without touching - // the bar, so the bar says where it has gone: the callout - // reads the same whether a remote walked the grid there or - // a pointer dragged the handle. - opacity: alphabetFeedback.running || libraryScrollBar.pressed ? 1 : 0 - visible: opacity > 0 - preferredRendererType: Shape.CurveRenderer - z: 21 - - // The stroke runs mouth -> top -> left -> bottom -> mouth and - // stops there; only the fill closes the contour across the - // bar, which is what leaves the right edge open. - ShapePath { - fillColor: Theme.accentPanel - strokeColor: Theme.accent - strokeWidth: alphabetCallout.stroke - capStyle: ShapePath.FlatCap - joinStyle: ShapePath.RoundJoin - startX: alphabetCallout.width - startY: alphabetCallout.midY - alphabetCallout.mouthHalf - - PathCubic { - control1X: alphabetCallout.width - alphabetCallout.neckWidth * 0.35 - control1Y: alphabetCallout.midY - alphabetCallout.mouthHalf - control2X: alphabetCallout.bodyRight + alphabetCallout.neckWidth * 0.55 - control2Y: alphabetCallout.inset - x: alphabetCallout.bodyRight - y: alphabetCallout.inset - } - PathLine { - x: alphabetCallout.cornerRadius + alphabetCallout.inset - y: alphabetCallout.inset - } - PathArc { - radiusX: alphabetCallout.cornerRadius - radiusY: alphabetCallout.cornerRadius - direction: PathArc.Counterclockwise - x: alphabetCallout.inset - y: alphabetCallout.cornerRadius + alphabetCallout.inset - } - PathLine { - x: alphabetCallout.inset - y: alphabetCallout.height - alphabetCallout.cornerRadius - alphabetCallout.inset - } - PathArc { - radiusX: alphabetCallout.cornerRadius - radiusY: alphabetCallout.cornerRadius - direction: PathArc.Counterclockwise - x: alphabetCallout.cornerRadius + alphabetCallout.inset - y: alphabetCallout.height - alphabetCallout.inset - } - PathLine { - x: alphabetCallout.bodyRight - y: alphabetCallout.height - alphabetCallout.inset - } - PathCubic { - control1X: alphabetCallout.bodyRight + alphabetCallout.neckWidth * 0.55 - control1Y: alphabetCallout.height - alphabetCallout.inset - control2X: alphabetCallout.width - alphabetCallout.neckWidth * 0.35 - control2Y: alphabetCallout.midY + alphabetCallout.mouthHalf - x: alphabetCallout.width - y: alphabetCallout.midY + alphabetCallout.mouthHalf - } - } + Item { + id: listRow + + required property int index + required property string displayTitle + required property string displaySubtitle + + // Consumed by the shared highlight delegate. + readonly property real focusOutlineHeight: height - Metrics.scaled(6) + + width: grid.cellWidth - Metrics.gapPx + height: grid.cellHeight AppText { + anchors.left: parent.left + anchors.leftMargin: Metrics.scaled(14) + anchors.right: rowSubtitle.left + anchors.rightMargin: Metrics.scaled(16) anchors.verticalCenter: parent.verticalCenter - anchors.horizontalCenter: parent.horizontalCenter - anchors.horizontalCenterOffset: -alphabetCallout.neckWidth / 2 - width: alphabetCallout.bodyRight - Metrics.scaled(8) - height: alphabetCallout.height - Metrics.scaled(8) - text: root.currentAlphabetLabel - font.pixelSize: Metrics.scaled(26) - fontSizeMode: Text.Fit - minimumPixelSize: Metrics.scaled(8) + text: listRow.displayTitle + font.pixelSize: Metrics.bodySizePx + Metrics.scaled(2) + font.weight: listRow.GridView.isCurrentItem ? Font.DemiBold : Font.Medium + color: listRow.GridView.isCurrentItem ? Theme.textPrimary : Theme.textSecondary + maximumLineCount: 1 elide: Text.ElideRight - font.weight: Font.Bold - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - color: Theme.textPrimary } - Behavior on opacity { - NumberAnimation { - duration: Theme.reducedMotion ? 0 : 100 - } + SecondaryText { + id: rowSubtitle + anchors.right: parent.right + anchors.rightMargin: Metrics.scaled(14) + anchors.verticalCenter: parent.verticalCenter + text: listRow.displaySubtitle + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + Metrics.scaled(1) + maximumLineCount: 1 } - } - delegate: root.listMode ? listRowComponent : posterCardComponent - - Component { - id: posterCardComponent - - MediaItemCard { - id: gridDelegate - - required property int index - - width: grid.cellWidth - Metrics.gapPx - height: grid.cellHeight - shell: root.shell - kind: root.squareArtwork ? "square" : "poster" - preferEpisodeTitle: false - useSeriesPoster: true - focused: Metrics.keyboardFocusActive && root.navigationFocusVisible && grid.activeFocus - && gridDelegate.GridView.isCurrentItem - artworkVisible: true - // Use the delegate's actual layout coordinates. Model - // resets and list/poster switches can move GridView's - // origin without changing the item's model index. - artworkEnabled: y + height >= grid.contentY - grid.artworkMarginRows * grid.cellHeight && y - <= grid.contentY + grid.height + grid.artworkMarginRows * grid.cellHeight - - Component.onCompleted: gridReveal.schedule() - onArtworkReadyChanged: gridReveal.schedule() - } + Component.onCompleted: gridReveal.schedule() } + } + highlightFollowsCurrentItem: true + highlight: Item { + z: 2 - Component { - id: listRowComponent - - Item { - id: listRow - - required property int index - required property string displayTitle - required property string displaySubtitle - - // Consumed by the shared highlight delegate. - readonly property real focusOutlineHeight: height - Metrics.scaled(6) - - width: grid.cellWidth - Metrics.gapPx - height: grid.cellHeight - - AppText { - anchors.left: parent.left - anchors.leftMargin: Metrics.scaled(14) - anchors.right: rowSubtitle.left - anchors.rightMargin: Metrics.scaled(16) - anchors.verticalCenter: parent.verticalCenter - text: listRow.displayTitle - font.pixelSize: Metrics.bodySizePx + Metrics.scaled(2) - font.weight: listRow.GridView.isCurrentItem ? Font.DemiBold : Font.Medium - color: listRow.GridView.isCurrentItem ? Theme.textPrimary : Theme.textSecondary - maximumLineCount: 1 - elide: Text.ElideRight - } - - SecondaryText { - id: rowSubtitle - anchors.right: parent.right - anchors.rightMargin: Metrics.scaled(14) - anchors.verticalCenter: parent.verticalCenter - text: listRow.displaySubtitle - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx + Metrics.scaled(1) - maximumLineCount: 1 - } - - Component.onCompleted: gridReveal.schedule() - } + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: grid.currentItem ? grid.currentItem.focusOutlineHeight : parent.height + color: "transparent" + radius: Math.max(0, Theme.radiusMedium - Theme.focusBorderWidth) + border.width: Theme.focusBorderWidth + border.color: root.navigationFocusVisible && ((Metrics.keyboardFocusActive && grid.activeFocus) + || (root.listMode && Metrics.coarsePointer + && root.touchPreviewItemId.length > 0 + && root.touchPreviewItemId === String( + root.paneItem ? root.paneItem.movieId : + ""))) ? Theme.accent : + "transparent" } - highlightFollowsCurrentItem: true - highlight: Item { - z: 2 + } - Rectangle { - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: grid.currentItem ? grid.currentItem.focusOutlineHeight : parent.height - color: "transparent" - radius: Math.max(0, Theme.radiusMedium - Theme.focusBorderWidth) - border.width: Theme.focusBorderWidth - border.color: root.navigationFocusVisible && ((Metrics.keyboardFocusActive - && grid.activeFocus) || (root.listMode - && Metrics.coarsePointer - && root.touchPreviewItemId.length - > 0 && root.touchPreviewItemId - === String( - root.paneItem - ? root.paneItem.movieId : - ""))) ? Theme.accent : - "transparent" - } - } + GridPointerArea { + view: grid + onActivated: (index, button) => root.activatePointerIndex(index, button) + onContextRequested: index => { + if (root.shell) + root.shell.openItemMenu(Browse.items.get(index), grid.itemAtIndex(index), root.itemMenuContext( + true)) - GridPointerArea { - view: grid - onActivated: (index, button) => root.activatePointerIndex(index, button) - onContextRequested: index => { - if (root.shell) - root.shell.openItemMenu(Browse.items.get(index), grid.itemAtIndex(index), { - "deferBackdropDismissal": true - }) - } - onContextGestureEnded: if (root.shell) - root.shell.finishItemMenuOpeningGesture() } + onContextGestureEnded: if (root.shell) + root.shell.finishItemMenuOpeningGesture() } } } + } - Rectangle { - anchors.right: parent.right - anchors.top: parent.top - anchors.rightMargin: Metrics.pageMarginPx - anchors.topMargin: root.contentTopMargin + 54 - width: typeAheadText.implicitWidth + 30 - height: 40 - radius: height / 2 - visible: root.typeAheadBuffer.length > 0 - color: Theme.floatingPanel - border.width: 1 - border.color: Theme.accent - z: 30 - - AppText { - id: typeAheadText - anchors.centerIn: parent - text: root.typeAheadBuffer - font.pixelSize: Metrics.bodySizePx - font.weight: Font.DemiBold - } - } - - Timer { - id: typeAheadReset - interval: 1250 - repeat: false - onTriggered: root.typeAheadBuffer = "" + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: Metrics.pageMarginPx + anchors.topMargin: root.contentTopMargin + 54 + width: typeAheadText.implicitWidth + 30 + height: 40 + radius: height / 2 + visible: root.typeAheadBuffer.length > 0 + color: Theme.floatingPanel + border.width: 1 + border.color: Theme.accent + z: 30 + + AppText { + id: typeAheadText + anchors.centerIn: parent + text: root.typeAheadBuffer + font.pixelSize: Metrics.bodySizePx + font.weight: Font.DemiBold } + } - // Kodi-style single-line media info for the focused item. - TechnicalDetailsBar { - id: mediaInfoBar - visible: root.mediaInfo !== null && Theme.technicalMetadataMode === "Always" && hasContent - anchors.bottom: parent.bottom - anchors.bottomMargin: 0 - anchors.right: parent.right - anchors.rightMargin: 0 - width: root.listMode ? grid.cellWidth - Metrics.gapPx : Math.max(0, grid.width - grid.leftMargin - - grid.rightMargin) + Timer { + id: typeAheadReset + interval: 1250 + repeat: false + onTriggered: root.typeAheadBuffer = "" + } - height: implicitHeight - info: root.mediaInfo - z: 18 - } - MouseArea { - anchors.fill: parent - visible: root.libraryOpen || root.sortOpen || root.filtersOpen - z: 19 - onClicked: root.closeMenus() - } + // Kodi-style single-line media info for the focused item. + TechnicalDetailsBar { + id: mediaInfoBar + visible: root.mediaInfo !== null && Theme.technicalMetadataMode === "Always" && hasContent + anchors.bottom: parent.bottom + anchors.bottomMargin: 0 + anchors.right: parent.right + anchors.rightMargin: 0 + width: root.listMode ? grid.cellWidth - Metrics.gapPx : Math.max(0, grid.width - grid.leftMargin + - grid.rightMargin) + + height: implicitHeight + info: root.mediaInfo + z: 18 + } + MouseArea { + anchors.fill: parent + visible: root.libraryOpen || root.sortOpen || root.filtersOpen + z: 19 + onClicked: root.closeMenus() + } - LazyMenuPanel { - id: libraryPanel - open: root.libraryOpen && !root.isFixedBrowseView - anchors.left: parent.left - anchors.topMargin: root.contentTopMargin + 52 - anchors.leftMargin: Metrics.pageMarginPx - width: Metrics.menuPanelWidth(root.width, 360) - maximumHeight: Metrics.scaled(420) - z: 22 - model: root.libraryEntries - currentIndex: root.libraryIndex - edgeEscapeItem: libraryButton - checkedFor: function (entry) { - return entry && String(entry.libraryId) === String(Browse.libraryId || "") - } - onCurrentIndexChanged: root.libraryIndex = currentIndex - onDismissed: root.closeMenus() - onAccepted: entry => root.activateLibraryIndex(entry.index) + LazyMenuPanel { + id: libraryPanel + open: root.libraryOpen && !root.isFixedBrowseView + anchors.left: parent.left + anchors.topMargin: root.contentTopMargin + 52 + anchors.leftMargin: Metrics.pageMarginPx + width: Metrics.menuPanelWidth(root.width, 360) + maximumHeight: Metrics.scaled(420) + z: 22 + model: root.libraryEntries + currentIndex: root.libraryIndex + edgeEscapeItem: libraryButton + checkedFor: function (entry) { + return entry && String(entry.libraryId) === String(Browse.libraryId || "") } + onCurrentIndexChanged: root.libraryIndex = currentIndex + onDismissed: root.closeMenus() + onAccepted: entry => root.activateLibraryIndex(entry.index) + } - LazyMenuPanel { - id: sortPanel - anchors.top: parent.top - anchors.right: parent.right - anchors.topMargin: root.contentTopMargin + 52 - anchors.rightMargin: Metrics.pageMarginPx - width: Metrics.menuPanelWidth(root.width, 320) - open: root.sortOpen - maximumHeight: Metrics.scaled(430) - z: 20 - model: root.sortEntries - currentIndex: root.sortIndex - edgeEscapeItem: sortButton - checkedFor: function (entry) { - return String(entry && entry.value || "").indexOf("order:") === 0 ? String(entry.value).split(":")[1] - === root.currentSortOrder() : entry - && entry.value - === root.currentSortBy() - } - onCurrentIndexChanged: root.sortIndex = currentIndex - onDismissed: root.closeMenus() - onAccepted: entry => root.activateSortEntry(entry) + LazyMenuPanel { + id: sortPanel + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: root.contentTopMargin + 52 + anchors.rightMargin: Metrics.pageMarginPx + width: Metrics.menuPanelWidth(root.width, 320) + open: root.sortOpen + maximumHeight: Metrics.scaled(430) + z: 20 + model: root.sortEntries + currentIndex: root.sortIndex + edgeEscapeItem: sortButton + checkedFor: function (entry) { + return String(entry && entry.value || "").indexOf("order:") === 0 ? String(entry.value).split(":")[1] === root.currentSortOrder() : + entry && entry.value + === root.currentSortBy() } + onCurrentIndexChanged: root.sortIndex = currentIndex + onDismissed: root.closeMenus() + onAccepted: entry => root.activateSortEntry(entry) + } - LazyMenuPanel { - id: filterPanel - anchors.top: parent.top - anchors.right: parent.right - anchors.topMargin: root.contentTopMargin + 52 - anchors.rightMargin: Metrics.pageMarginPx - width: Metrics.menuPanelWidth(root.width, 380) - open: root.filtersOpen - maximumHeight: Math.min(root.height - Metrics.pageMarginPx * 2 - 70, 620) - z: 21 - model: root.filterEntries - currentIndex: root.filterIndex - edgeEscapeItem: filterButton - title: "Filters" - selectionStyle: "check" - resetVisible: true - resetEnabled: root.activeFilterCount > 0 - onCurrentIndexChanged: root.filterIndex = currentIndex - onDismissed: root.closeMenus() - onAccepted: entry => root.activateFilterEntry(entry) - onReset: { - root.savedIndex = 0 - Browse.clearFilters() - } + LazyMenuPanel { + id: filterPanel + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: root.contentTopMargin + 52 + anchors.rightMargin: Metrics.pageMarginPx + width: Metrics.menuPanelWidth(root.width, 440) + open: root.filtersOpen + maximumHeight: Math.min(root.height - Metrics.pageMarginPx * 2 - 70, 760) + z: 21 + model: root.filterEntries + currentIndex: root.filterIndex + edgeEscapeItem: filterButton + title: root.activeFilterCount > 0 ? "Filters · " + root.activeFilterCount + " on" : "Filters" + selectionStyle: "check" + resetVisible: true + resetEnabled: root.activeFilterCount > 0 + onCurrentIndexChanged: root.filterIndex = currentIndex + onDismissed: root.closeMenus() + onAccepted: entry => root.activateFilterEntry(entry) + onReset: { + root.savedIndex = 0 + Browse.clearFilters() } } +} diff --git a/qml/pages/LocalFolders.qml b/qml/pages/LocalFolders.qml new file mode 100644 index 00000000..21bace38 --- /dev/null +++ b/qml/pages/LocalFolders.qml @@ -0,0 +1,129 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Spool + +FocusScope { + id: root + property var provider + property var folders: provider && provider.arguments.configuration ? (provider.arguments.configuration.folders || []).slice( + ) : [] + property string error: "" + + function addFolder(value) { + const folder = String(value || "").trim() + if (!folder.length) + return + if (folders.indexOf(folder) < 0) + folders = folders.concat([folder]) + path.text = "" + error = "" + } + function save() { + if (!folders.length) { + error = "Choose at least one folder. Nothing is added automatically." + return + } + provider.complete({ + account: "local-library", + label: "Local files", + configuration: { + folders: folders + } + }) + } + function activate() { + const item = Window.activeFocusItem + if (item && typeof item.activate === "function") + item.activate() + else if (item && typeof item.clicked === "function") + item.clicked() + } + Component.onCompleted: Qt.callLater(() => path.focusRow()) + + ColumnLayout { + anchors.centerIn: parent + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(720)) + spacing: Metrics.scaled(12) + AppText { + text: "Local files" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + } + SecondaryText { + Layout.fillWidth: true + text: "Choose the folders to combine into one library. Subfolders are included; overlapping folders do not duplicate files." + wrapMode: Text.WordWrap + } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, root.height * 0.35) + clip: true + model: root.folders + delegate: RowLayout { + required property string modelData + required property int index + width: ListView.view.width + SecondaryText { + Layout.fillWidth: true + text: modelData + elide: Text.ElideMiddle + } + ActionButton { + text: "Remove" + kind: "flat" + onClicked: root.folders = root.folders.filter((_, i) => i !== index) + } + } + } + TextFieldRow { + id: path + Layout.fillWidth: true + label: "Folder path" + onAccepted: root.addFolder(text) + } + RowLayout { + ActionButton { + text: "Add folder" + enabled: path.text.trim().length > 0 + onClicked: root.addFolder(path.text) + } + ActionButton { + text: "Browse…" + onClicked: folderDialog.active = true + } + } + SecondaryText { + Layout.fillWidth: true + visible: root.error.length > 0 + text: root.error + color: Theme.errorText + wrapMode: Text.WordWrap + } + ActionButton { + Layout.fillWidth: true + text: root.provider && root.provider.role === "settings" ? "Save folders" : "Add library" + kind: "primary" + onClicked: root.save() + } + } + Loader { + id: folderDialog + active: false + source: "DesktopFolderDialog.qml" + onLoaded: { + item.title = "Choose a media folder" + item.open() + } + Connections { + target: folderDialog.item + function onFolderSelected(folder) { + root.addFolder(folder) + folderDialog.active = false + } + function onDismissed() { + folderDialog.active = false + } + } + } +} diff --git a/qml/pages/LoginAccountStep.qml b/qml/pages/LoginAccountStep.qml deleted file mode 100644 index 6b1c1e1c..00000000 --- a/qml/pages/LoginAccountStep.qml +++ /dev/null @@ -1,211 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" - -// Signing in to the server the previous step settled on. -// -// The server it is about stays on screen as the same row that chose it, and -// stays reachable, because getting it wrong is the failure this form actually -// has: a password rejected by the wrong machine reads exactly like a password -// typed wrong. -FocusScope { - id: root - - property bool dense: false - property string serverName: "" - property string serverAddress: "" - - signal changeServerRequested - signal signInRequested - - function controls() { - return [serverRow, usernameRow, passwordRow, signInButton, quickConnectButton] - } - - function focusControl(item) { - if (item === usernameRow || item === passwordRow) - item.focusRow() - else - InputKeys.focus(item) - } - - function focusDefault() { - usernameRow.focusField() - } - - function moveInside(control, key) { - return false - } - - function activateControl(control) { - if (control === serverRow) - root.changeServerRequested() - else if (control === usernameRow || control === passwordRow) - control.focusField() - else if (control === quickConnectButton) - quickConnectButton.clicked() - else - root.signInRequested() - } - - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - spacing: Metrics.scaled(10) - - AppText { - Layout.fillWidth: true - Layout.bottomMargin: Metrics.scaled(8) - visible: !root.dense - text: "Sign in" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - elide: Text.ElideRight - } - - ServerCard { - id: serverRow - Layout.fillWidth: true - Layout.bottomMargin: Metrics.scaled(6) - title: root.serverName - serverAddress: root.serverAddress - status: "Change" - onAccepted: { - InputKeys.focus(serverRow) - root.changeServerRequested() - } - } - - AppText { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(4) - text: "Username" - font.pixelSize: Metrics.bodySizePx - font.weight: Font.Medium - } - - TextFieldRow { - id: usernameRow - Layout.fillWidth: true - accessibleName: "Username" - text: Session.username - inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - enterKeyType: Qt.EnterKeyNext - onTextEdited: text => Session.username = text - onAccepted: passwordRow.focusField() - } - - AppText { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(4) - text: "Password" - font.pixelSize: Metrics.bodySizePx - font.weight: Font.Medium - } - - TextFieldRow { - id: passwordRow - Layout.fillWidth: true - Layout.bottomMargin: Metrics.scaled(10) - accessibleName: "Password" - text: Session.password - echoMode: TextInput.Password - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - enterKeyType: Qt.EnterKeyGo - onTextEdited: text => Session.password = text - onAccepted: root.signInRequested() - } - - ActionButton { - id: signInButton - Layout.fillWidth: true - Layout.preferredHeight: Math.max(Metrics.controlHeightPx, Metrics.scaled(56)) - text: "Sign in" - iconName: "login" - kind: "primary" - onClicked: root.signInRequested() - } - - // Quick Connect is the other way in, not a second half of this one, so - // it sits below the rule rather than beside the primary button. - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(6) - Layout.bottomMargin: Metrics.scaled(6) - visible: !root.dense - spacing: Metrics.scaled(12) - - Rectangle { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Math.max(1, Metrics.scaled(1)) - color: Theme.border - } - - SecondaryText { - text: "or" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - } - - Rectangle { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Math.max(1, Metrics.scaled(1)) - color: Theme.border - } - } - - ActionButton { - id: quickConnectButton - Layout.fillWidth: true - Layout.preferredHeight: Math.max(Metrics.controlHeightPx, Metrics.scaled(52)) - text: QuickConnect.active ? "Cancel Quick Connect" : "Use Quick Connect" - iconName: QuickConnect.active ? "close" : "bolt" - onClicked: { - if (QuickConnect.active) { - QuickConnect.cancel() - } else { - App.clearError() - QuickConnect.start(Session.serverUrl) - } - } - } - - Surface { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(6) - Layout.preferredHeight: QuickConnect.active ? quickConnectPanel.implicitHeight + Metrics.scaled(34) : 0 - visible: QuickConnect.active - clip: true - baseColor: Theme.accentPanel - - ColumnLayout { - id: quickConnectPanel - anchors.centerIn: parent - width: Math.min(parent.width - Metrics.scaled(40), Metrics.scaled(420)) - spacing: Metrics.scaled(8) - - AppText { - Layout.alignment: Qt.AlignHCenter - text: QuickConnect.code - font.pixelSize: Metrics.scaled(32) - font.weight: Font.Bold - font.letterSpacing: Metrics.scaled(7) - } - - SecondaryText { - Layout.fillWidth: true - text: QuickConnect.phase === "server" ? "Approved. Signing you in…" : - "Enter this code on a device that is already signed in to this server." - color: Theme.textSecondary - font.pixelSize: Metrics.metaSizePx + Metrics.scaled(1) - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - } - } - } -} diff --git a/qml/pages/LoginBrandPanel.qml b/qml/pages/LoginBrandPanel.qml deleted file mode 100644 index efb7f4ac..00000000 --- a/qml/pages/LoginBrandPanel.qml +++ /dev/null @@ -1,79 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" - -// The header of the sign-in screen: where you are, and the way back out. -// Nothing else in here takes focus, so a remote never walks through it on the -// way to the form. -Item { - id: root - - // Room is short — a landscape phone, or an on-screen keyboard eating half - // the panel — so the version and some of the weight give up their space. - property bool dense: false - property bool backVisible: false - - readonly property alias backControl: backButton - - signal backRequested - - implicitHeight: headerRow.implicitHeight - - // Back sits at the head of the identity rather than floating over the - // page, so the form below never has to leave a hole for it. The slot keeps - // its width whether or not there is anywhere to go back to. - RowLayout { - id: headerRow - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Metrics.scaled(12) - - Item { - Layout.preferredWidth: backButton.width - Layout.preferredHeight: backButton.height - - IconButton { - id: backButton - anchors.centerIn: parent - iconName: "arrow_back" - visible: root.backVisible - enabled: visible - onClicked: root.backRequested() - } - } - - Image { - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: Metrics.scaled(root.dense ? 24 : 30) - Layout.preferredHeight: Layout.preferredWidth - source: "qrc:/icons/spool.svg" - sourceSize.width: Layout.preferredWidth - sourceSize.height: Layout.preferredHeight - fillMode: Image.PreserveAspectFit - smooth: true - asynchronous: true - } - - AppText { - Layout.alignment: Qt.AlignVCenter - text: "Spool" - font.pixelSize: Metrics.scaled(root.dense ? 19 : 23) - font.weight: Font.DemiBold - } - - AppText { - Layout.alignment: Qt.AlignBaseline - visible: !root.dense - text: Qt.application.version - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - font.weight: Font.Medium - } - - Item { - Layout.fillWidth: true - } - } -} diff --git a/qml/pages/LoginPage.qml b/qml/pages/LoginPage.qml deleted file mode 100644 index 052a07ca..00000000 --- a/qml/pages/LoginPage.qml +++ /dev/null @@ -1,360 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import "../theme" -import "../primitives" - -// The way into the app: pick an account, or add one by finding a server and -// signing in to it. -// -// This file is the frame — which of the three steps is showing, and where a -// key press goes. Each step owns its own state and answers four questions: -// which controls it has, how to focus one, how to move inside one, and what -// Select does to one. That contract is what lets the same single column read -// at arm's length on a laptop, across a room on a television, and pinned above -// an on-screen keyboard on a phone, without any of the steps knowing which it -// is looking at. -FocusScope { - id: root - - property var shell - property bool addMode: Session.accountProfiles.length === 0 || Session.profileSignInRequired - property int addStep: Session.profileSignInRequired ? 2 : 1 - property string selectedServerName: Session.serverName - property string selectedServerAddress: Session.serverUrl - property Item backReturnControl: null - - readonly property bool hasSavedAccounts: Session.accountProfiles.length > 0 - // A real on-screen keyboard, not merely a focused field: on desktop the - // form must not rearrange itself the moment you click into it. - readonly property bool keyboardVisible: Qt.inputMethod.visible - // Room, not hardware. A keyboard covering half the panel and a phone held - // sideways are the same problem and take the same answer: drop everything - // that is not the form and pin what is left to the top. - // A step is laid out at its natural size and then shrunk to fit if that - // does not leave room, rather than being clipped at the bottom edge. The - // interface scale is a user setting and defaults to 150% on a television, - // so a form that fits at 100% can easily not fit here -- and the part that - // falls off is the bottom, which is where Quick Connect's code appears. - // Scaling keeps every control reachable at any zoom instead of trusting - // that the content happened to be short enough. - readonly property real stepFitMargin: Metrics.scaled(12) - - function stepFitScale(needed, available) { - if (!(needed > 0) || !(available > 0)) - return 1 - const room = available - root.stepFitMargin - // Below this the text stops being legible across a room, and something - // has gone wrong that shrinking further will not rescue. - return Math.max(0.65, Math.min(1, room / needed)) - } - - readonly property bool dense: keyboardVisible || Metrics.units(height) < 620 - - readonly property string fallbackServerName: "Jellyfin Server" - readonly property string chosenServerName: selectedServerName.length > 0 ? selectedServerName : fallbackServerName - readonly property string chosenServerAddress: selectedServerAddress.length > 0 ? selectedServerAddress : - Session.serverUrl - readonly property bool canNavigateBack: Boolean(shell && shell.canCancelSwitchUser) || (addMode && ( - hasSavedAccounts - || addStep === 2)) - - focus: true - - function currentStep() { - if (!addMode) - return profilePicker - return addStep === 2 ? accountStep : serverStep - } - - function enterProfile(profileId) { - App.useProfile(profileId) - } - - function openAddAccount() { - addMode = true - addStep = 1 - Qt.callLater(function () { - serverStep.focusDefault() - }) - } - - function showProfiles() { - if (!hasSavedAccounts) - return - addMode = false - Qt.callLater(function () { - profilePicker.focusDefault() - }) - } - - function useServer(name, address) { - selectedServerName = name && name.length > 0 ? name : fallbackServerName - selectedServerAddress = address - addStep = 2 - Qt.callLater(function () { - accountStep.focusDefault() - }) - } - - function changeServer() { - addStep = 1 - Qt.callLater(function () { - serverStep.focusDefault() - }) - } - - function signIn() { - App.clearError() - Session.login() - } - - function controls() { - return currentStep().controls() - } - - function focusedControl() { - const items = controls() - for (let i = 0; i < items.length; ++i) - if (items[i].activeFocus) - return items[i] - return items.length > 0 ? items[0] : null - } - - function focusControl(item) { - if (item) - currentStep().focusControl(item) - } - - function focusBackButton(returnControl) { - if (!brand.backControl.visible) - return false - backReturnControl = returnControl - InputKeys.focus(brand.backControl) - return true - } - - function restoreBackButtonFocus() { - const items = controls() - const target = items.indexOf(backReturnControl) >= 0 ? backReturnControl : items.length > 0 ? items[0] : null - backReturnControl = null - focusControl(target) - } - - function moveControl(delta) { - const items = controls() - const currentIndex = items.indexOf(focusedControl()) - const nextIndex = Math.max(0, Math.min(items.length - 1, currentIndex + delta)) - if (nextIndex === currentIndex || nextIndex < 0) - return false - focusControl(items[nextIndex]) - return true - } - - function back() { - if (profileDialogs.open) - return profileDialogs.back() - if (addMode && addStep === 2) { - changeServer() - return true - } - if (addMode && hasSavedAccounts) { - showProfiles() - return true - } - if (!addMode && shell && shell.canCancelSwitchUser) - return shell.cancelSwitchUser() - return false - } - - function routeKey(key, phase, repeat) { - if (profileDialogs.open) - return profileDialogs.routeKey(key, phase, repeat) - if (InputKeys.isMedia(key) && phase === "press") { - activate() - return true - } - if (!InputKeys.isDirection(key)) - return false - if (brand.backControl.activeFocus) { - if (key === Qt.Key_Right || key === Qt.Key_Down) - restoreBackButtonFocus() - return true - } - - const current = focusedControl(); - // A step gets first refusal, because a grid or a list moves inside - // itself before the page moves between the things it holds. - if (currentStep().moveInside(current, key)) - return true - if (InputKeys.isVertical(key)) { - const moved = moveControl(key === Qt.Key_Down ? 1 : -1) - if (!moved && key === Qt.Key_Up) - focusBackButton(current) - return true - } - // The steps are single columns, so sideways has nowhere to go but out - // to the one control that is not part of one. - if (key === Qt.Key_Left) - focusBackButton(current) - return true - } - - function activate() { - if (profileDialogs.open) { - profileDialogs.activate() - return - } - if (brand.backControl.activeFocus) { - brand.backControl.clicked() - return - } - currentStep().activateControl(focusedControl()) - } - - // A remote has no right mouse button, so the account menu hangs off a long - // press of Select the way it does everywhere else in the app. - function longPress() { - if (profileDialogs.open || addMode) - return false - return profilePicker.openContextMenu() - } - - Connections { - target: Session - - function onProfileSignInRequiredChanged() { - if (!Session.profileSignInRequired) - return - root.selectedServerName = Session.serverName - root.selectedServerAddress = Session.serverUrl - root.addMode = true - root.addStep = 2 - Qt.callLater(function () { - accountStep.focusDefault() - }) - } - } - - Component.onCompleted: Qt.callLater(function () { - currentStep().focusDefault() - }) - - Rectangle { - anchors.fill: parent - color: Theme.bg - } - - LoginBrandPanel { - id: brand - x: Metrics.pageMarginPx - y: root.dense ? Metrics.scaled(8) : Metrics.pageMarginPx - width: Math.max(0, root.width - Metrics.pageMarginPx * 2) - height: implicitHeight - dense: root.dense - backVisible: root.canNavigateBack - onBackRequested: root.back() - - Behavior on y { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - easing.type: Easing.OutCubic - } - } - } - - Item { - id: stepHost - - // One column at every size. It is capped where a line of text stops - // being comfortable to scan and centred in whatever is left, so a - // television and a phone are the same screen at two magnifications - // rather than two designs. - readonly property int columnWidth: { - const preferred = Metrics.scaled(root.addMode ? 620 : 940) - const available = root.width - Metrics.pageMarginPx * 2 - return Math.max(Metrics.scaled(200), Math.min(preferred, available)) - } - - x: Math.round((root.width - columnWidth) / 2) - y: brand.y + brand.height + Metrics.scaled(root.dense ? 14 : 34) - width: columnWidth - height: Math.max(0, root.height - y - Metrics.pageMarginPx) - - Behavior on y { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - easing.type: Easing.OutCubic - } - } - - LoginProfilePicker { - id: profilePicker - anchors.fill: parent - visible: !root.addMode - enabled: visible - dense: root.dense - onProfileChosen: profileId => root.enterProfile(profileId) - onAddRequested: root.openAddAccount() - onContextRequested: (profileId, anchor, serverName, serverUrl) => profileDialogs.show(profileId, anchor, serverName, - serverUrl) - } - - // Both forms are short enough to sit off the top edge on a tall screen - // rather than climbing it, and pinned when there is no room to spare. - LoginServerStep { - id: serverStep - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: root.dense ? 0 : Math.max(0, Math.round((parent.height - height) * 0.16)) - readonly property real fitScale: root.stepFitScale(implicitHeight, parent.height) - height: Math.min(parent.height, implicitHeight * fitScale) - implicitHeight: childrenRect.height - scale: fitScale - transformOrigin: Item.Top - visible: root.addMode && root.addStep === 1 - enabled: visible - dense: root.dense - onServerChosen: (name, address) => root.useServer(name, address) - } - - LoginAccountStep { - id: accountStep - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: root.dense ? 0 : Math.max(0, Math.round((parent.height - height) * 0.16)) - readonly property real fitScale: root.stepFitScale(implicitHeight, parent.height) - height: Math.min(parent.height, implicitHeight * fitScale) - implicitHeight: childrenRect.height - scale: fitScale - transformOrigin: Item.Top - visible: root.addMode && root.addStep === 2 - enabled: visible - dense: root.dense - serverName: root.chosenServerName - serverAddress: root.chosenServerAddress - onChangeServerRequested: root.changeServer() - onSignInRequested: root.signIn() - } - } - - LoginProfileDialogs { - id: profileDialogs - anchors.fill: parent - z: 200 - onSignInAgainRequested: { - root.addMode = true - root.addStep = 2 - } - onClosed: Qt.callLater(function () { - if (!root.addMode) - profilePicker.focusDefault() - if (Session.accountProfiles.length === 0) - root.openAddAccount() - }) - } -} diff --git a/qml/pages/LoginProfileDialogs.qml b/qml/pages/LoginProfileDialogs.qml deleted file mode 100644 index 5f246a7f..00000000 --- a/qml/pages/LoginProfileDialogs.qml +++ /dev/null @@ -1,225 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" - -// The three things you can do to a saved account: sign in again, correct the -// server it points at, or forget it. They share one anchor and one key route, -// so the picker only has to open and close this. -Item { - id: root - - property string profileId: "" - property string serverName: "" - property string serverUrl: "" - property Item anchorItem: null - - // "" | "menu" | "edit" | "remove" - property string mode: "" - readonly property bool open: mode.length > 0 - - signal signInAgainRequested - signal closed - - function show(id, anchor, name, url) { - profileId = id - anchorItem = anchor - serverName = name - serverUrl = url - mode = "menu" - } - - function close() { - mode = "" - profileId = "" - anchorItem = null - root.closed() - } - - function routeKey(key, phase, repeat) { - return loader.item ? loader.item.routeKey(key, phase, repeat) : false - } - - function activate() { - if (loader.item) - loader.item.activate() - } - - function back() { - if (!open) - return false - close() - return true - } - - Loader { - id: loader - anchors.fill: parent - active: root.open - sourceComponent: root.mode === "menu" ? actionMenu : root.mode === "remove" ? removeDialog : editDialog - } - - Component { - id: actionMenu - - OptionPickerDialog { - title: "Account actions" - options: ["Sign in again", "Edit server", "Remove from this device"] - currentIndex: 0 - anchorItem: root.anchorItem - onSelected: index => { - if (index === 0) { - Session.prepareProfileSignIn(root.profileId) - root.close() - root.signInAgainRequested() - return - } - root.mode = index === 1 ? "edit" : "remove" - } - onDismissed: root.close() - } - } - - Component { - id: removeDialog - - ConfirmationDialog { - title: "Remove " + root.serverName + "?" - message: "This removes the saved account and token for " + root.serverName - + " on this device. Other accounts are unchanged." - confirmText: "Remove" - destructive: true - onAccepted: { - Session.removeProfile(root.profileId) - root.close() - } - onDismissed: root.close() - } - } - - Component { - id: editDialog - - FocusScope { - id: editScope - - property string nameDraft: root.serverName - property string urlDraft: root.serverUrl - readonly property var controls: [nameField, addressField, cancelButton, saveButton] - - anchors.fill: parent - focus: true - - function focusControl(item) { - if (item === nameField || item === addressField) - item.focusRow() - else - InputKeys.focus(item) - } - - function routeKey(key, phase, repeat) { - if (InputKeys.isBack(key, false, false)) { - if (phase === "release") - root.close() - return true - } - if (!InputKeys.isDirection(key)) - return InputKeys.isAccept(key) - if (phase !== "press") - return true - - let index = controls.findIndex(item => item.activeFocus) - if (index < 0) - index = 0 - if (key === Qt.Key_Down || key === Qt.Key_Right) - index = Math.min(controls.length - 1, index + 1) - else - index = Math.max(0, index - 1) - focusControl(controls[index]) - return true - } - - function activate() { - if (saveButton.activeFocus) - saveButton.clicked() - else if (cancelButton.activeFocus) - root.close() - } - - Component.onCompleted: Qt.callLater(function () { - nameField.focusRow() - }) - - Rectangle { - anchors.fill: parent - color: Theme.overlayScrimStrong - } - - Surface { - anchors.centerIn: parent - width: Math.min(parent.width - Metrics.scaled(96), Metrics.scaled(620)) - height: editContent.implicitHeight + Metrics.scaled(48) - elevated: true - baseColor: Theme.floatingPanel - - ColumnLayout { - id: editContent - anchors.fill: parent - anchors.margins: Metrics.scaled(24) - spacing: Metrics.scaled(14) - - AppText { - Layout.fillWidth: true - text: "Edit server" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - } - - TextFieldRow { - id: nameField - Layout.fillWidth: true - label: "Server label" - text: editScope.nameDraft - onTextEdited: text => editScope.nameDraft = text - } - - TextFieldRow { - id: addressField - Layout.fillWidth: true - label: "Server address" - text: editScope.urlDraft - inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - onTextEdited: text => editScope.urlDraft = text - } - - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(12) - - Item { - Layout.fillWidth: true - } - - ActionButton { - id: cancelButton - text: "Cancel" - onClicked: root.close() - } - - ActionButton { - id: saveButton - text: "Save" - kind: "primary" - onClicked: { - Session.updateProfileServer(root.profileId, editScope.nameDraft, editScope.urlDraft) - root.close() - } - } - } - } - } - } - } -} diff --git a/qml/pages/LoginProfilePicker.qml b/qml/pages/LoginProfilePicker.qml deleted file mode 100644 index da8a8934..00000000 --- a/qml/pages/LoginProfilePicker.qml +++ /dev/null @@ -1,195 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import "../theme" -import "../primitives" -import "ProfileNavigation.js" as ProfileNavigation - -// The accounts already saved on this device, plus the way to add another. -// -// One grid holds both: the add tile is the cell after the last account, so it -// wraps with everything else and a phone showing two to a row navigates by the -// same rules as a television showing six. -FocusScope { - id: root - - property bool dense: false - - readonly property var profiles: Session.accountProfiles - readonly property int profileCount: profiles.length - // Tiles take the room a lane offers, but never more room than there is: - // a pane narrower than one tile shrinks the tile rather than clipping it. - readonly property int tileSize: { - const preferred = Metrics.scaled(Metrics.laneAtLeast(width, "wide") ? 168 : Metrics.laneAtLeast(width, - "regular") - ? 148 : 124) - return Math.max(Metrics.scaled(72), Math.min(preferred, width - cellPadding * 2)) - } - readonly property int cellPadding: Metrics.scaled(14) - - signal profileChosen(string profileId) - signal addRequested - signal contextRequested(string profileId, Item anchor, string serverName, string serverUrl) - - function controls() { - return [grid] - } - - function focusControl(item) { - InputKeys.focus(item) - } - - function focusDefault() { - if (grid.currentIndex < 0 && grid.count > 0) - grid.currentIndex = 0 - InputKeys.focus(grid) - } - - function moveInside(control, key) { - if (control !== grid || grid.count <= 0) - return false - const direction = key === Qt.Key_Left ? "left" : key === Qt.Key_Right ? "right" : key === Qt.Key_Up ? "up" : - "down" - const next = ProfileNavigation.move(grid.currentIndex, root.profileCount, grid.columnCount(), direction) - if (next < 0 || next === grid.currentIndex) - return false - grid.currentIndex = next - grid.positionViewAtIndex(next, GridView.Contain) - return true - } - - function activateControl(control) { - if (control === grid) - grid.activateCurrent() - } - - function openContextMenu() { - return grid.requestContextMenu() - } - - // Heading and tiles are one block, centred in the pane rather than pinned - // to its top corner: with a couple of accounts the page is mostly empty, - // and a chooser adrift in the top left of it reads as a layout that ran - // out of content rather than one that is waiting for an answer. It only - // fills the pane, and lets the grid scroll, when the accounts outgrow it. - Item { - id: content - - readonly property int headingSpan: heading.visible ? heading.height + Metrics.scaled(26) : 0 - readonly property int naturalHeight: headingSpan + grid.contentHeight - - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - width: parent.width - height: Math.min(parent.height, naturalHeight) - - AppText { - id: heading - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - visible: !root.dense - text: "Choose an account" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - } - - GridView { - id: grid - - readonly property int cellSpan: root.tileSize + root.cellPadding * 2 - // Whole cells, and never more of them than there are tiles to put in - // them: sizing to the columns the pane could hold left a row of two - // accounts hugging the left edge of four columns of empty space. - readonly property int columnsInUse: Math.max(1, Math.min(root.profileCount + 1, Math.floor(parent.width - / cellSpan))) - - anchors.top: parent.top - anchors.topMargin: content.headingSpan - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - width: cellSpan * columnsInUse - cellWidth: cellSpan - cellHeight: root.tileSize + Metrics.scaled(root.dense ? 52 : 84) + root.cellPadding - clip: true - focus: false - keyNavigationEnabled: false - boundsBehavior: Flickable.StopAtBounds - model: root.profileCount + 1 - currentIndex: 0 - onCountChanged: if (currentIndex >= count) - currentIndex = Math.max(0, count - 1) - - function columnCount() { - return Math.max(1, Math.floor(width / Math.max(1, cellWidth))) - } - - function activateCurrent() { - if (currentItem) - currentItem.accepted() - } - - function requestContextMenu() { - if (!currentItem || currentIndex >= root.profileCount) - return false - currentItem.contextRequested() - return true - } - - FastWheelHandler { - flickable: grid - } - - delegate: Item { - id: cell - - required property int index - - readonly property var profile: cell.index < root.profileCount ? root.profiles[cell.index] : null - - width: grid.cellWidth - height: grid.cellHeight - - function accepted() { - tile.accepted() - } - - function contextRequested() { - tile.contextRequested() - } - - ProfileTile { - id: tile - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: root.cellPadding - tileSize: root.tileSize - addTile: cell.profile === null - username: cell.profile ? String(cell.profile.userName || "Saved account") : "Add account" - serverName: cell.profile ? String(cell.profile.serverName || "Jellyfin Server") : "" - serverAddress: cell.profile ? String(cell.profile.serverHost || cell.profile.serverUrl || "") : "" - needsSignIn: cell.profile ? Boolean(cell.profile.needsAuthentication) : false - focused: cell.GridView.isCurrentItem && grid.activeFocus - onAccepted: { - grid.currentIndex = cell.index - InputKeys.focus(grid) - if (cell.profile) - root.profileChosen(String(cell.profile.profileId || "")) - else - root.addRequested() - } - onContextRequested: { - if (!cell.profile) - return - grid.currentIndex = cell.index - InputKeys.focus(grid) - root.contextRequested(String(cell.profile.profileId || ""), tile, tile.serverName, String( - cell.profile.serverUrl || "")) - } - } - } - } - } -} diff --git a/qml/pages/LoginServerStep.qml b/qml/pages/LoginServerStep.qml deleted file mode 100644 index 119afbbd..00000000 --- a/qml/pages/LoginServerStep.qml +++ /dev/null @@ -1,419 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" - -// Choosing a server: type an address, or take one off the network. -// -// The two are one list. What you have typed resolves into the same row shape -// as anything discovered, directly under the field, so there is never a card -// on the page saying one thing and a list underneath saying another. -FocusScope { - id: root - - // Room is short: the heading and the hint give up their space first. - property bool dense: false - readonly property bool scanning: Discovery.active - readonly property string fallbackServerName: "Jellyfin Server" - - // Live probe of whatever is in the address field. `probeInput` is the text - // handed to Discovery, so comparing it with the current draft tells us - // whether the result on screen still describes what the user has typed. - property string manualDraft: "" - property string probeInput: "" - // idle | checking | online | offline - property string probeState: "idle" - property string probeAddress: "" - property string probeServerName: "" - property string probeStatus: "" - property string probeDetail: "" - // Set when the user commits to an address that is still being checked, so - // the wait costs them nothing: the moment it answers, we move on. - property bool advanceWhenOnline: false - - readonly property string draftAddress: String(manualDraft || "").trim() - readonly property bool probeRowVisible: probeInput.length > 0 && probeInput === draftAddress - readonly property bool probeOnline: probeRowVisible && probeState === "online" - - signal serverChosen(string name, string address) - - // Reaching a server is the slow part of adding an account, so the field - // starts it as soon as the text names somewhere reachable. Enter still - // forces an attempt for addresses too unusual to recognise. - function probeDraft(force) { - const address = draftAddress - if (address.length === 0) { - clearProbe() - return - } - if (!force) { - if (address === probeInput) - return - if (!Discovery.looksLikeServerAddress(address)) { - clearProbe() - return - } - } - probeInput = address - probeAddress = address - advanceWhenOnline = false - probeServerName = "" - probeStatus = "" - probeDetail = "" - probeState = "checking" - Discovery.probeServer(address) - } - - function clearProbe() { - Discovery.cancelServerProbe() - probeInput = "" - advanceWhenOnline = false - probeState = "idle" - probeAddress = "" - probeServerName = "" - probeStatus = "" - probeDetail = "" - } - - // Enter or a click means "use this one", whatever the probe is doing. If - // the answer has not landed yet the intent is queued rather than refused. - function commitProbedServer() { - if (probeOnline) { - advanceWhenOnline = false - App.rememberServer(probeServerName, probeAddress) - root.serverChosen(probeServerName, probeAddress) - return - } - if (probeState !== "checking") - probeDraft(true) - advanceWhenOnline = probeState === "checking" - } - - // Picking a server that is already listed goes straight to sign-in. A - // saved entry has been reached before, and re-checking it first would sit - // the user in front of a spinner to be told what the row already said. - function chooseDiscoveredServer(index, name, address) { - if (index < 0) - return - App.chooseDiscoveredServer(index) - root.serverChosen(name, address) - } - - function controls() { - const items = [addressRow] - if (probeRowVisible) - items.push(probeRow) - if (discoveredList.count > 0) - items.push(discoveredList) - return items - } - - function focusControl(item) { - if (item === addressRow) - addressRow.focusRow() - else - InputKeys.focus(item) - } - - function focusDefault() { - if (probeRowVisible) - InputKeys.focus(probeRow) - else if (discoveredList.count > 0) { - if (discoveredList.currentIndex < 0) - discoveredList.currentIndex = 0 - InputKeys.focus(discoveredList) - } else { - // The row decides whether that means the caret or the D-pad stop. - addressRow.focusRow() - } - } - - function moveInside(control, key) { - if (control !== discoveredList || !InputKeys.isVertical(key)) - return false - const next = discoveredList.currentIndex + (key === Qt.Key_Down ? 1 : -1) - if (next < 0 || next >= discoveredList.count) - return false - discoveredList.currentIndex = next - return true - } - - function activateControl(control) { - if (control === addressRow) - addressRow.focusField() - else if (control === probeRow) - commitProbedServer() - else if (control === discoveredList && discoveredList.currentItem) - discoveredList.currentItem.accepted() - } - - Connections { - target: Discovery - - function onServerProbeSucceeded(input, server, version, plainHttp) { - if (input !== root.probeInput) - return - root.probeState = "online" - root.probeAddress = server.address - root.probeServerName = server.name - root.probeStatus = plainHttp ? "Online · HTTP" : "Online" - Session.serverUrl = server.address - if (root.advanceWhenOnline) - root.commitProbedServer() - } - - function onServerProbeFailed(input, message) { - if (input !== root.probeInput) - return - root.probeState = "offline" - root.advanceWhenOnline = false - root.probeStatus = TlsTrust.pending ? "Certificate not trusted" : "Couldn't connect" - root.probeDetail = TlsTrust.pending ? TlsTrust.pendingFingerprint : message - } - } - - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - spacing: 0 - - // Typing an address and the result of typing it are one thing, so - // they share one frame. What you get back appears inside the box you - // typed into rather than as a second card underneath it. - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: connectGroup.implicitHeight + Metrics.scaled(40) - radius: Theme.radiusMedium - color: "transparent" - border.width: Math.max(1, Metrics.scaled(1)) - border.color: Theme.border - - ColumnLayout { - id: connectGroup - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Metrics.scaled(20) - anchors.rightMargin: Metrics.scaled(20) - spacing: Metrics.scaled(14) - - AppText { - Layout.fillWidth: true - visible: !root.dense - text: "Sign in to your server" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - elide: Text.ElideRight - } - - TextFieldRow { - id: addressRow - Layout.fillWidth: true - placeholderText: "Server address" - text: root.manualDraft - inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - enterKeyType: Qt.EnterKeyGo - onTextEdited: text => { - root.manualDraft = text - probeDebounce.restart() - } - // Enter before the debounce has even fired still means - // "go": it starts the check and rides it in when it - // answers. - onAccepted: { - probeDebounce.stop() - root.commitProbedServer() - } - } - - // Grows out of the field rather than appearing beneath it, so - // the frame stretches once instead of the page jumping. - ServerCard { - id: probeRow - Layout.fillWidth: true - Layout.preferredHeight: root.probeRowVisible ? implicitHeight : 0 - opacity: root.probeRowVisible ? 1 : 0 - clip: true - focus: false - // Clickable while it is still checking too: committing - // early queues the move rather than turning the click away. - selectable: root.probeOnline || root.probeState === "checking" - tone: root.probeState === "checking" ? "pending" : root.probeState === "online" ? "positive" : - "negative" - - title: root.probeState === "online" && root.probeServerName.length > 0 ? root.probeServerName : - root.probeAddress - serverAddress: root.probeState === "online" ? root.probeAddress : "" - status: root.probeState === "checking" ? "Connecting…" : root.probeStatus - detail: root.probeState === "offline" ? root.probeDetail : "" - onAccepted: { - InputKeys.focus(probeRow) - root.commitProbedServer() - } - - Behavior on Layout.preferredHeight { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - easing.type: Easing.OutCubic - } - } - - Behavior on opacity { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - } - } - } - } - } - - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(34) - Layout.bottomMargin: Metrics.scaled(10) - spacing: Metrics.scaled(10) - - AppText { - text: "On your network" - color: Theme.textSecondary - font.pixelSize: Metrics.metaSizePx - font.weight: Font.DemiBold - font.capitalization: Font.AllUppercase - font.letterSpacing: Math.max(1, Metrics.scaled(1)) - } - - Rectangle { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Math.max(1, Metrics.scaled(1)) - color: Theme.border - } - - BusySpinner { - Layout.alignment: Qt.AlignVCenter - implicitWidth: Metrics.scaled(13) - implicitHeight: Metrics.scaled(13) - visible: root.scanning - color: Theme.textPrimary - } - - AppText { - Layout.alignment: Qt.AlignVCenter - text: root.scanning ? "Searching…" : discoveredList.count > 0 ? discoveredList.count + " found" : "" - color: Theme.textPrimary - font.pixelSize: Metrics.metaSizePx - font.weight: Font.Medium - } - } - - // The well keeps its frame whether or not anything has been found yet, - // so a server arriving mid-scan slides into a slot that was already - // there instead of shoving the page around. Two rows is the size of - // nearly every home network; past that it scrolls rather than growing - // until the form leaves the screen. - Rectangle { - readonly property int rowSpan: Metrics.scaled(64) - - Layout.fillWidth: true - Layout.minimumHeight: Metrics.scaled(88) - Layout.preferredHeight: Math.max(Metrics.scaled(88), Math.min(discoveredList.contentHeight + Metrics.scaled(8), - Math.round(rowSpan * 2.15))) - Layout.maximumHeight: Math.round(rowSpan * 2.15) - radius: Theme.radiusMedium - color: "transparent" - border.width: Math.max(1, Metrics.scaled(1)) - border.color: Theme.border - - ListView { - id: discoveredList - anchors.fill: parent - anchors.margins: Metrics.scaled(4) - visible: count > 0 - clip: true - spacing: 0 - focus: false - keyNavigationEnabled: false - boundsBehavior: Flickable.StopAtBounds - model: DiscoveredServers - currentIndex: count > 0 ? 0 : -1 - onCountChanged: { - if (count > 0 && !addressRow.editing && !probeRow.activeFocus) - Qt.callLater(root.focusDefault) - } - onCurrentIndexChanged: if (currentIndex >= 0) - positionViewAtIndex(currentIndex, ListView.Contain) - - FastWheelHandler { - flickable: discoveredList - } - - delegate: ServerCard { - id: discoveredRow - - required property int index - required property string name - required property string address - required property bool online - - width: discoveredList.width - inset: true - title: name.length > 0 ? name : root.fallbackServerName - serverAddress: address - status: online ? "Online" : "Saved" - tone: online ? "positive" : "neutral" - focused: ListView.isCurrentItem && discoveredList.activeFocus - onAccepted: { - discoveredList.currentIndex = index - InputKeys.focus(discoveredList) - root.chooseDiscoveredServer(index, title, address) - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.leftMargin: Metrics.scaled(16) - anchors.rightMargin: Metrics.scaled(16) - height: Math.max(1, Metrics.scaled(1)) - visible: discoveredRow.index < discoveredList.count - 1 && !discoveredRow.focused - color: Theme.border - } - } - } - - Column { - anchors.centerIn: parent - spacing: Metrics.scaled(7) - visible: discoveredList.count === 0 - - MaterialIcon { - anchors.horizontalCenter: parent.horizontalCenter - name: root.scanning ? "wifi_tethering" : "wifi_off" - iconSize: Metrics.scaled(24) - iconColor: Theme.textDisabled - } - - SecondaryText { - anchors.horizontalCenter: parent.horizontalCenter - width: Math.min(implicitWidth, root.width - Metrics.scaled(48)) - text: root.scanning ? "Searching your network…" : "No servers found" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - } - } - } - - Timer { - id: probeDebounce - interval: 450 - onTriggered: root.probeDraft(false) - } -} diff --git a/qml/pages/PlayerAudioSyncPanel.qml b/qml/pages/PlayerAudioSyncPanel.qml index e6a3a302..6f048c9a 100644 --- a/qml/pages/PlayerAudioSyncPanel.qml +++ b/qml/pages/PlayerAudioSyncPanel.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" diff --git a/qml/pages/PlayerBrowsePanel.qml b/qml/pages/PlayerBrowsePanel.qml index 6b6c500a..42b490b0 100644 --- a/qml/pages/PlayerBrowsePanel.qml +++ b/qml/pages/PlayerBrowsePanel.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../primitives" import "../browse" import "../theme" diff --git a/qml/pages/PlayerMusicStage.qml b/qml/pages/PlayerMusicStage.qml index 9ff26909..61eb4734 100644 --- a/qml/pages/PlayerMusicStage.qml +++ b/qml/pages/PlayerMusicStage.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" @@ -18,7 +18,7 @@ Item { readonly property string trackTitle: { const queued = String(root.item.title || "").trim() if (queued.length > 0) - return queued + return queued return overlay.hasPlayer ? overlay.player.title : "" } readonly property string artistText: String(root.item.albumArtist || "").trim() @@ -27,7 +27,7 @@ Item { readonly property string queuePositionText: { const queue = overlay.playQueue if (!queue || queue.count <= 1) - return "" + return "" return "Track " + (queue.currentIndex + 1) + " of " + queue.count } readonly property real viewportScale: Math.max(1, Math.min(1.8, Math.min(root.width / root.dp(1600), root.height diff --git a/qml/pages/PlayerOverlayChrome.qml b/qml/pages/PlayerOverlayChrome.qml index 9fb1f858..27e22d43 100644 --- a/qml/pages/PlayerOverlayChrome.qml +++ b/qml/pages/PlayerOverlayChrome.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" import "../shell" as Shell @@ -11,7 +11,7 @@ Item { id: root required property var overlay - readonly property bool syncPlayMenuOpen: syncPlayMenu.menuOpen + readonly property bool syncPlayMenuOpen: syncPlayMenu.item ? syncPlayMenu.item.menuOpen : false property int lastPointerX: -1 property int lastPointerY: -1 @@ -55,13 +55,6 @@ Item { overlay.showControlsFromPointer() } - function artworkSource(url) { - if (!url) - return "" - return url.indexOf("http://") === 0 || url.indexOf("https://") === 0 ? "image://artwork/" + encodeURIComponent( - url) : url - } - function resetMenu(index) { Qt.callLater(function () { menuList.currentIndex = menuList.count > 0 ? Math.min(index, menuList.count - 1) : -1 @@ -72,20 +65,24 @@ Item { } function openSyncPlayMenu() { - SyncPlay.refreshGroups() - syncPlayMenu.openMenu() + if (!syncPlayMenu.item) + return + root.overlay.syncPlay.refreshGroups() + syncPlayMenu.item.openMenu() } function closeSyncPlayMenu() { - syncPlayMenu.closeMenu() + if (syncPlayMenu.item) + syncPlayMenu.item.closeMenu() } function routeSyncPlayMenuKey(key, repeat) { - return syncPlayMenu.routeKey(key, "press", repeat) + return syncPlayMenu.item ? syncPlayMenu.item.routeKey(key, "press", repeat) : false } function activateSyncPlayMenu() { - syncPlayMenu.activate() + if (syncPlayMenu.item) + syncPlayMenu.item.activate() } function routeMenuKey(key, repeat) { @@ -100,19 +97,6 @@ Item { menuList.activate() } - Item { - visible: false - Repeater { - model: root.overlay.visible && root.overlay.hasPlayer ? root.overlay.player.trickplaySheetUrls : [] - delegate: Image { - required property string modelData - source: root.artworkSource(modelData) - asynchronous: true - cache: true - } - } - } - TapHandler { property bool surfaceTap: false acceptedButtons: Qt.LeftButton @@ -244,7 +228,7 @@ Item { anchors.centerIn: parent width: root.dp(116) height: width - visible: SyncPlay.enabled && SyncPlay.waitingForPlayback + visible: root.overlay.syncPlayWaiting z: 20 Rectangle { @@ -416,7 +400,9 @@ Item { } } - Shell.SyncPlayMenu { + // The group menu exists only for a source with SyncPlay; the loader keeps + // its bindings from ever running otherwise. + Loader { id: syncPlayMenu anchors.right: parent.right anchors.bottom: hud.top @@ -424,7 +410,10 @@ Item { anchors.bottomMargin: root.dp(18) width: root.dp(420) z: 55 - onRequestClose: root.overlay.closeMenu() + active: root.overlay.syncPlay !== null + sourceComponent: Shell.GroupMenu { + onRequestClose: root.overlay.closeMenu() + } } PlayerAudioSyncPanel { @@ -547,7 +536,7 @@ Item { checked: root.overlay.menuItemSelected(index) highlighted: menuList.currentIndex === index stepperVisible: root.overlay.debugAction(index) === "speed" - stepperEnabled: !SyncPlay.enabled + stepperEnabled: !root.overlay.syncPlayActive stepperText: root.overlay.formatPlaybackSpeed(root.overlay.player.effectivePlaybackSpeed) stepperEditText: Number(root.overlay.player.effectivePlaybackSpeed || 1).toFixed(2) stepperEditable: stepperVisible && root.overlay.desktopControlsAvailable diff --git a/qml/pages/PlayerOverlayInput.qml b/qml/pages/PlayerOverlayInput.qml index ca1840ea..8ba013d0 100644 --- a/qml/pages/PlayerOverlayInput.qml +++ b/qml/pages/PlayerOverlayInput.qml @@ -235,8 +235,10 @@ Item { return true } if (InputKeys.isMedia(key) && overlay.hasPlayer) { - overlay.togglePlayback() - overlay.showControls("actions") + if (!repeat) { + overlay.togglePlayback() + overlay.showControls("actions") + } return true } if (key === Qt.Key_S) { diff --git a/qml/pages/PlayerOverlayPage.qml b/qml/pages/PlayerOverlayPage.qml index dad08b67..43ec2025 100644 --- a/qml/pages/PlayerOverlayPage.qml +++ b/qml/pages/PlayerOverlayPage.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../theme" import "../primitives" @@ -63,6 +63,12 @@ FocusScope { // get(currentIndex) went on titling the overlay with what had been playing // before -- which is how an episode lost the series line above it. readonly property var currentQueueItem: playQueue ? playQueue.currentEntry : ({}) + // SyncPlay is only read behind its capability, so a source without group + // watch never has the singleton evaluated; the chrome and the transport + // bar take it from here rather than reaching for it themselves. + readonly property var syncPlay: ProviderCapabilities.groupPlayback ? Group : null + readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false + readonly property bool syncPlayWaiting: syncPlayActive && syncPlay.waitingForPlayback readonly property bool syncPlayMenuOpen: chrome.syncPlayMenuOpen readonly property string episodeContextText: { if (!episodeQueue) @@ -126,7 +132,8 @@ FocusScope { values.push("audio") if (playQueue && playQueue.count > 0) values.push("queue") - values.push("syncplay") + if (syncPlay) + values.push("syncplay") if (desktopControlsAvailable) values.push("fullscreen") values.push("debug") @@ -140,12 +147,13 @@ FocusScope { { "action": "speed", "label": "Playback speed" - }, - { - "action": "quality", - "label": "Quality" } ] + if (ProviderCapabilities.streamQuality) + values.push({ + "action": "quality", + "label": "Quality" + }) if (!audioOnly) values.push({ "action": "subtitleSettings", @@ -199,7 +207,7 @@ FocusScope { function actionIcon(action) { if (action === "back") return "fast_rewind" - if (action === "pause" && SyncPlay.enabled && SyncPlay.waitingForPlayback) + if (action === "pause" && syncPlayWaiting) return "schedule" if (action === "pause") return hasPlayer && player.paused ? "play_arrow" : "pause" @@ -231,7 +239,7 @@ FocusScope { return "Back 10 seconds" if (action === "forward") return "Forward 10 seconds" - if (action === "pause" && SyncPlay.enabled && SyncPlay.waitingForPlayback) + if (action === "pause" && syncPlayWaiting) return "Waiting for group playback" if (action === "pause") return hasPlayer && player.paused ? "Resume" : "Pause" @@ -246,7 +254,7 @@ FocusScope { if (action === "queue") return "Play queue" if (action === "syncplay") - return SyncPlay.enabled ? "SyncPlay group" : "Join or create a SyncPlay group" + return syncPlayActive ? "SyncPlay group" : "Join or create a SyncPlay group" if (action === "fullscreen") return NativeWindow.fullScreen ? "Exit fullscreen" : "Fullscreen" return "Playback settings" @@ -262,11 +270,6 @@ FocusScope { return scrubbing || remoteScrubbing ? scrubSeconds : hasPlayer ? player.positionSeconds : 0 } - function positionRatio() { - return hasPlayer && player.durationSeconds > 0 ? Math.max(0, Math.min(1, positionSeconds() - / player.durationSeconds)) : 0 - } - function isMenuOpen() { return menuKind.length > 0 || syncPlayMenuOpen } @@ -426,7 +429,7 @@ FocusScope { if (menuKind === "quality") return String(qualityOptions[index] && qualityOptions[index].detail || "") // Playback speed is the one row a SyncPlay group takes away. - return debugAction(index) === "speed" && SyncPlay.enabled ? "Disabled by SyncPlay" : "" + return debugAction(index) === "speed" && syncPlayActive ? "Disabled by SyncPlay" : "" } function menuItemSelected(index) { @@ -456,6 +459,8 @@ FocusScope { } function openSyncPlayMenu() { + if (!syncPlay) + return if (syncPlayMenuOpen) { closeMenu() return @@ -649,7 +654,7 @@ FocusScope { } function adjustPlaybackSpeed(direction) { - if (!hasPlayer || SyncPlay.enabled || direction === 0) + if (!hasPlayer || syncPlayActive || direction === 0) return player.setPlaybackSpeed(Math.max(0.25, Math.min(4, Number(player.playbackSpeed) + direction * 0.25))) } @@ -748,22 +753,22 @@ FocusScope { } function togglePlayback() { - if (SyncPlay.enabled) - SyncPlay.requestTogglePause() + if (syncPlayActive) + syncPlay.requestTogglePause() else player.togglePause() } function seekTo(seconds) { - if (SyncPlay.enabled) - SyncPlay.requestSeek(clampSeconds(seconds)) + if (syncPlayActive) + syncPlay.requestSeek(clampSeconds(seconds)) else player.seek(clampSeconds(seconds)) } function seekRelative(seconds) { - if (SyncPlay.enabled) - SyncPlay.requestRelativeSeek(seconds) + if (syncPlayActive) + syncPlay.requestRelativeSeek(seconds) else if (seconds < 0) player.seekBack() else diff --git a/qml/pages/PlayerQueuePanel.qml b/qml/pages/PlayerQueuePanel.qml index a1a898d1..6fb23a7d 100644 --- a/qml/pages/PlayerQueuePanel.qml +++ b/qml/pages/PlayerQueuePanel.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../primitives" import "../theme" @@ -277,7 +277,7 @@ FocusScope { anchors.right: parent.right anchors.top: headingText.bottom height: visible ? implicitHeight : 0 - visible: root.reordering || (SyncPlay.enabled && list.count > 0) + visible: root.reordering || (root.overlay.syncPlayActive && list.count > 0) text: root.reordering ? "Moving — Up/Down to place, OK to drop, Back to cancel" : "Changes apply to everyone in the group" color: root.reordering ? Theme.accent : Theme.textMuted diff --git a/qml/pages/PlayerQueueRow.qml b/qml/pages/PlayerQueueRow.qml index 9eb018a5..8a0d2e9c 100644 --- a/qml/pages/PlayerQueueRow.qml +++ b/qml/pages/PlayerQueueRow.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../primitives" import "../theme" diff --git a/qml/pages/PlayerSeekBar.qml b/qml/pages/PlayerSeekBar.qml index 66de4348..d027f1d6 100644 --- a/qml/pages/PlayerSeekBar.qml +++ b/qml/pages/PlayerSeekBar.qml @@ -7,6 +7,7 @@ import "../primitives" Item { id: root + objectName: "playerSeekBar" required property var overlay readonly property double ratio: overlay.hasPlayer && overlay.player.durationSeconds > 0 ? Math.max(0, Math.min(1, diff --git a/qml/pages/PlayerStatsPanel.qml b/qml/pages/PlayerStatsPanel.qml index 546091ea..8080bd59 100644 --- a/qml/pages/PlayerStatsPanel.qml +++ b/qml/pages/PlayerStatsPanel.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" diff --git a/qml/pages/PlayerTransportBar.qml b/qml/pages/PlayerTransportBar.qml index d1db549a..2522a391 100644 --- a/qml/pages/PlayerTransportBar.qml +++ b/qml/pages/PlayerTransportBar.qml @@ -25,7 +25,7 @@ RowLayout { readonly property bool emphasized: focused || selected || (hover.hovered && Metrics.pointerActive) readonly property string tooltip: Settings.playerControlTooltipsEnabled ? root.overlay.actionTooltip(action) : "" - readonly property bool waitingForSyncPlay: action === "pause" && SyncPlay.enabled && SyncPlay.waitingForPlayback + readonly property bool waitingForSyncPlay: action === "pause" && root.overlay.syncPlayWaiting readonly property string badge: action === "prevQueue" || action === "nextQueue" ? "EP" : action === "prevChapter" || action === "nextChapter" ? "CH" : "" @@ -154,7 +154,7 @@ RowLayout { TapHandler { onTapped: { if (!root.overlay.hasPlayer) - return + return const volume = Number(root.overlay.player.volume) if (volume > 0) { volumeControls.lastAudibleVolume = volume diff --git a/qml/pages/PlayerTrickplayPreview.qml b/qml/pages/PlayerTrickplayPreview.qml index df89b6a2..0d11469c 100644 --- a/qml/pages/PlayerTrickplayPreview.qml +++ b/qml/pages/PlayerTrickplayPreview.qml @@ -1,49 +1,92 @@ import QtQuick +import Spool import "../theme" import "../primitives" Item { id: root + objectName: "playerTrickplayPreview" required property var overlay + readonly property var previewPlayer: overlay.hasPlayer ? overlay.player : null readonly property bool localHovering: overlay.timelineHovering && overlay.controlsReason !== "remote" - readonly property bool active: overlay.hasPlayer && overlay.controlsVisible && overlay.player.trickplayAvailable && ( - overlay.scrubbing || overlay.remoteScrubbing || localHovering - || overlay.previewing) + readonly property bool active: previewPlayer !== null && overlay.controlsVisible + && previewPlayer.trickplayAvailable && (overlay.scrubbing || overlay.remoteScrubbing + || localHovering || overlay.previewing) readonly property double previewSeconds: overlay.scrubbing || overlay.remoteScrubbing ? overlay.scrubSeconds : localHovering ? overlay.timelineHoverSeconds : overlay.positionSeconds() - readonly property var trickplayData: active ? overlay.player.trickplayForSeconds(previewSeconds) : ({}) + property var trickplayData: ({}) + property bool selectionPending: active readonly property bool ready: trickplayData && trickplayData.available === true - readonly property real scaleFactor: overlay.uiScale * 1.4 + readonly property real previewScale: typeof Settings !== "undefined" ? Math.max(25, Math.min(200, Number( + Settings.values["playback/trickplayPreviewScalePercent"]) + || 100)) / 100 : 1 + readonly property real aspect: ready && trickplayData.height > 0 ? trickplayData.width / trickplayData.height : 16 + / 9 + readonly property real displayWidth: ready ? Math.max(0, Math.min(Metrics.scaled(320) * previewScale, width - dp(104), + Math.max(0, overlay.height - dp(252)) * aspect)) : + 0 + readonly property real displayHeight: displayWidth / aspect readonly property real previewRatio: overlay.hasPlayer && overlay.player.durationSeconds > 0 ? Math.max(0, Math.min( 1, previewSeconds / overlay.player.durationSeconds)) : 0 + onActiveChanged: { + selectionPending = active + if (!active) + trickplayData = ({}) + } + onPreviewSecondsChanged: if (active) + selectionPending = true + onPreviewPlayerChanged: { + trickplayData = ({}) + selectionPending = active + } + + Connections { + target: root.previewPlayer + ignoreUnknownSignals: true + function onTrickplayChanged() { + if (!root.active) + root.trickplayData = ({}) + root.selectionPending = root.active + } + } + + // Keep the scheduler outside the image-ready container so a pending + // asynchronous image cannot prevent the first selection from running. + FrameAnimation { + running: root.active && root.selectionPending + onTriggered: { + root.selectionPending = false + root.trickplayData = root.previewPlayer.trickplayForSeconds(root.previewSeconds) + } + } + function dp(value) { return overlay.dp(value) } - function artworkSource(url) { - if (!url) - return "" - return url.indexOf("http://") === 0 || url.indexOf("https://") === 0 ? "image://artwork/" + encodeURIComponent( - url) : url + function sourceCrop() { + return ready ? Qt.rect(-trickplayData.offsetX, -trickplayData.offsetY, trickplayData.width, + trickplayData.height) : Qt.rect(0, 0, 0, 0) } anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.bottomMargin: dp(200) - height: ready ? Math.round((trickplayData.height || 0) * scaleFactor) + dp(32) : 0 - visible: ready + height: displayHeight + dp(32) + visible: active z: 22 Item { - readonly property real imageWidth: root.ready ? root.trickplayData.width * root.scaleFactor : 0 - readonly property real imageHeight: root.ready ? root.trickplayData.height * root.scaleFactor : 0 + visible: root.ready && previewImage.ready + readonly property real imageWidth: root.displayWidth + readonly property real imageHeight: root.displayHeight x: Math.max(root.dp(52), Math.min(parent.width - imageWidth - root.dp(52), root.previewRatio * parent.width - imageWidth / 2)) width: imageWidth @@ -53,21 +96,18 @@ Item { id: frame width: parent.imageWidth height: parent.imageHeight - color: "black" + color: "transparent" border.color: Theme.borderStrong border.width: 1 radius: Theme.radiusLarge clip: true - Image { - source: root.ready ? root.artworkSource(root.trickplayData.url) : "" - x: root.ready ? root.trickplayData.offsetX * root.scaleFactor : 0 - y: root.ready ? root.trickplayData.offsetY * root.scaleFactor : 0 - width: root.ready ? root.trickplayData.sheetWidth * root.scaleFactor : 0 - height: root.ready ? root.trickplayData.sheetHeight * root.scaleFactor : 0 - fillMode: Image.Stretch - cache: true - asynchronous: true + TrickplayPreviewItem { + id: previewImage + objectName: "playerTrickplayTexture" + anchors.fill: parent + source: root.ready ? root.trickplayData.url : "" + crop: root.sourceCrop() } } diff --git a/qml/pages/ProfileNavigation.js b/qml/pages/ProfileNavigation.js deleted file mode 100644 index f340a52a..00000000 --- a/qml/pages/ProfileNavigation.js +++ /dev/null @@ -1,28 +0,0 @@ -.pragma library - -// Where a directional press lands in the account grid. -// -// The add-account tile is simply the cell after the last profile, so the grid -// is one uniform run of `profileCount + 1` cells and nothing has to carry a -// special case for it. Left and right walk that run; up and down step by a -// row. Returns -1 when the press has walked off the top or the left of the -// grid and belongs to whatever the page keeps above it. -function move(index, profileCount, columns, direction) { - const total = Math.max(1, Math.round(profileCount) + 1) - const columnCount = Math.max(1, Math.round(columns)) - const current = Math.max(0, Math.min(total - 1, Math.round(index))) - - switch (direction) { - case "left": - return current > 0 ? current - 1 : -1 - case "right": - return Math.min(total - 1, current + 1) - case "up": - return current >= columnCount ? current - columnCount : -1 - case "down": - // The last row is ragged, so stepping down from above it lands on the - // add tile rather than refusing to move because that column is short. - return current + columnCount < total ? current + columnCount : total - 1 - } - return current -} diff --git a/qml/pages/ProviderScreenPage.qml b/qml/pages/ProviderScreenPage.qml new file mode 100644 index 00000000..2e07fbc6 --- /dev/null +++ b/qml/pages/ProviderScreenPage.qml @@ -0,0 +1,55 @@ +import QtQuick +import "../shell" +import "../primitives" +import "../theme" + +// The route for a provider's sign-in or settings screen. +ProviderSurface { + id: page + + property var shell + + context: shell ? shell.routeArgs.context : null + property bool completing: false + property string completionError: "" + onFinished: (result, cancelled) => { + if (!cancelled && context && context.role === "login") { + completing = true + return + } + // Signing in lands on home through Providers.accountAdded; anything + // else goes back to where it was opened from. + if (Router.route === "providerScreen" && Router.canPop) + Router.pop("accounts") + } + Connections { + target: Providers + function onProblem(message) { + if (page.completing) + page.completionError = message + } + } + Rectangle { + anchors.fill: parent + visible: page.completing + color: Theme.bg + Column { + anchors.centerIn: parent + spacing: Metrics.scaled(20) + BusySpinner { + anchors.horizontalCenter: parent.horizontalCenter + width: Metrics.scaled(36) + height: width + running: page.completing && !page.completionError + } + AppText { + text: page.completionError || "Opening your library…" + } + ActionButton { + visible: page.completionError.length > 0 + text: "Back" + onClicked: Router.pop("accounts") + } + } + } +} diff --git a/qml/pages/RemoteControlPage.qml b/qml/pages/RemoteControlPage.qml index a3ced644..365709fd 100644 --- a/qml/pages/RemoteControlPage.qml +++ b/qml/pages/RemoteControlPage.qml @@ -1,680 +1,582 @@ pragma ComponentBehavior: Bound import QtQuick +import Spool import QtQuick.Layouts import "../theme" import "../primitives" +import "../shell" FocusScope { id: root - + property bool compact: false + readonly property real preferredMenuHeight: customContext ? Metrics.scaled(600) : Math.min(Metrics.scaled(680), Metrics.scaled( + 150) + list.contentHeight) + property var customContext: null property var shell - property real shownPositionTicks: RemoteControl.positionTicks + property var remote: RemoteTargets + property var app: App + property var localPlayer: Player readonly property bool contentReady: true - readonly property var item: RemoteControl.nowPlayingItem || ({}) - readonly property bool hasMedia: Boolean(item.movieId || item.title) - readonly property real remoteButtonSize: Math.max(Metrics.touchTargetPx, Math.min(Metrics.scaled(96), (width - 2 - * Metrics.pageMarginPx - - Metrics.scaled( - 52)) / 3.17)) - readonly property real primaryRemoteButtonSize: Math.min(Metrics.scaled(112), remoteButtonSize * 1.17) - readonly property var seekPreviewData: positionSlider.dragging && RemoteControl.trickplayAvailable - ? RemoteControl.trickplayForTicks(shownPositionTicks) : ({}) - readonly property bool seekPreviewReady: seekPreviewData && seekPreviewData.available === true - - focus: true - - function formatTicks(ticks) { - const total = Math.max(0, Math.floor(Number(ticks || 0) / 10000000)) - const hours = Math.floor(total / 3600) - const minutes = Math.floor((total % 3600) / 60) - const seconds = total % 60 - const minuteText = hours > 0 && minutes < 10 ? "0" + minutes : String(minutes) - const secondText = seconds < 10 ? "0" + seconds : String(seconds) - return hours > 0 ? hours + ":" + minuteText + ":" + secondText : minuteText + ":" + secondText + property bool choosing: true + property bool queueOpen: false + property string focusedKey: compact ? "target:" : "chooser" + property string editingKey: "" + property real editedValue: 0 + readonly property var snapshot: remote.state || ({}) + readonly property var selected: remote.selectedTarget || ({}) + readonly property bool attached: remote.selectedTargetId.length > 0 + readonly property var commands: snapshot.commands || [] + // Position notifications update only the reading, not the row/queue model. + readonly property bool seekKnown: remote.positionTicks !== "" && remote.runtimeTicks !== "" && Number( + remote.runtimeTicks) > 0 + readonly property var rows: buildRows() + readonly property int focusedIndex: rows.findIndex(row => row.key === focusedKey) + property int lastFocusedIndex: 0 + onFocusedIndexChanged: if (focusedIndex >= 0) + lastFocusedIndex = focusedIndex + readonly property var preview: snapshot.preview || null + readonly property bool previewActive: visible && !customContext && editingKey === "seek" && preview !== null + && remote.trickplayAvailable + property var previewFrame: ({}) + property bool previewSelectionPending: previewActive + onPreviewActiveChanged: { + previewSelectionPending = previewActive + if (!previewActive) + previewFrame = ({}) + } + onEditedValueChanged: if (previewActive) + previewSelectionPending = true + onRemoteChanged: { + previewFrame = ({}) + previewSelectionPending = previewActive } - function selectedTrackLabel(tracks, fallback) { - for (let index = 0; index < tracks.length; ++index) - if (tracks[index].selected) - return tracks[index].label - return fallback + // Selection belongs to the page, not the image-ready item below. + FrameAnimation { + running: root.previewActive && root.previewSelectionPending + onTriggered: { + root.previewSelectionPending = false + root.previewFrame = root.remote.trickplayForSeconds(root.editedValue) + } } - function cycleTrack(tracks, select) { - if (!tracks || tracks.length === 0) + function supports(action) { + return attached && commands.indexOf(action) >= 0 + } + function row(key, label, icon, extra) { + return Object.assign({ + key: key, + label: label, + icon: icon || "", + enabled: true + }, extra || {}) + } + function buildRows() { + const result = compact ? [] : [row("chooser", choosing ? "Hide device chooser" : "Choose playback device", + "devices")] + if (choosing) { + // Local is always first, independent of progressive discovery order. + result.push(row("target:", "This device", "tv", { + targetId: "", + checked: !attached + })) + for (const target of remote.targets || []) { + if (!target.id) + continue + result.push(row("target:" + target.id, target.name, "cast", { + targetId: target.id, + detail: target.detail || "", + checked: target.id === remote.selectedTargetId + })) + } + result.push(row("refresh", "Refresh devices", "refresh", { + enabled: !remote.busy + })) + } + if (!attached) { + result.push(row("local", "Browse and play on this device", "home")) + return result + } + if (localPlayer.sessionActive && supports("play")) + result.push(row("transfer", "Transfer this device’s playback here", "cast", { + enabled: !remote.busy + })) + const transport = [["previous", "Previous", "skip_previous"], ["pause", "Pause", "pause"], ["unpause", "Play", + "play_arrow"], + ["stop", "Stop", "stop"], ["next", "Next", "skip_next"]] + for (const item of transport) { + if (!supports(item[0])) + continue + if (item[0] === "pause" && snapshot.state === "paused") + continue + if (item[0] === "unpause" && snapshot.state !== "paused" && snapshot.state !== "stopped") + continue + result.push(row(item[0], item[1], item[2], { + command: { + action: item[0] + }, + enabled: !remote.busy + })) + } + if (supports("seek") && seekKnown) + result.push(row("seek", "Position", "schedule", { + adjustable: true + })) + if (supports("volume") && snapshot.volume !== undefined) + result.push(row("volume", "Volume", "volume_up", { + adjustable: true + })) + if (supports("mute") && snapshot.muted !== undefined) + result.push(row("mute", snapshot.muted ? "Unmute" : "Mute", "volume_off", { + command: { + action: "mute", + value: !snapshot.muted + } + })) + if (supports("repeat")) { + for (const mode of ["RepeatNone", "RepeatAll", "RepeatOne"]) + result.push(row("repeat:" + mode, mode === "RepeatNone" ? "Repeat off" : mode === "RepeatAll" + ? "Repeat all" : "Repeat one", "repeat", { + checked: snapshot.repeatMode === mode, + command: { + action: "repeat", + mode: mode + } + })) + } + if (supports("shuffle") && snapshot.shuffled !== undefined) + result.push(row("shuffle", snapshot.shuffled ? "Disable shuffle" : "Enable shuffle", "shuffle", { + checked: snapshot.shuffled, + command: { + action: "shuffle", + value: !snapshot.shuffled + } + })) + for (const kind of ["audio", "subtitle"]) { + if (!supports(kind + "Track")) + continue + const tracks = snapshot[kind + "Tracks"] || [] + if (kind === "subtitle") + result.push(row("subtitle:off", "Subtitles: Off", "subtitles", { + checked: tracks.length > 0 && !tracks.some(track => track.selected), + command: { + action: "subtitleTrack", + trackId: null + } + })) + for (const track of tracks) + result.push(row(kind + ":" + track.id, (kind === "audio" ? "Audio: " : "Subtitles: ") + track.label, + kind === "audio" ? "audiotrack" : "subtitles", { + checked: track.selected, + command: { + action: kind + "Track", + trackId: track.id + } + })) + } + result.push(row("queue", queueOpen ? "Hide queue" : "Show queue", "queue_music")) + if (queueOpen) { + const entries = remote.queue || [] + for (let i = 0; i < entries.length; ++i) { + const entry = entries[i] + const prefix = "entry:" + entry.entryId + result.push(row(prefix, String(i + 1) + ". " + entry.title, "play_arrow", { + entryId: entry.entryId, + checked: snapshot.currentEntryId === entry.entryId, + enabled: supports("queuePlay"), + command: { + action: "queuePlay", + entryId: entry.entryId + } + })) + if (supports("queueRemove") && selected.queueEditing !== "none") + result.push(row(prefix + ":remove", "Remove “" + entry.title + "”", "remove_circle_outline", { + entryId: entry.entryId, + command: { + action: "queueRemove", + entryId: entry.entryId + } + })) + if (supports("queueMove") && selected.queueEditing !== "none") { + if (i > 0) + result.push(row(prefix + ":up", "Move up", "arrow_upward", { + entryId: entry.entryId, + command: { + action: "queueMove", + entryId: entry.entryId, + index: i - 1, + afterEntryId: i > 1 ? entries[i - 2].entryId : null + } + })) + if (i + 1 < entries.length) + result.push(row(prefix + ":down", "Move down", "arrow_downward", { + entryId: entry.entryId, + command: { + action: "queueMove", + entryId: entry.entryId, + index: i + 1, + afterEntryId: entries[i + 1].entryId + } + })) + else if (remote.queueHasMore) + result.push(row(prefix + ":down", "Load next queue page to move down", "expand_more", { + entryId: entry.entryId, + loadAdjacent: true, + enabled: !remote.queueBusy + })) + } + } + if (remote.queueHasMore) + result.push(row("more", "Load more queue entries", "expand_more", { + enabled: !remote.queueBusy + })) + result.push(row("queueRefresh", "Refresh queue", "refresh", { + enabled: !remote.queueBusy + })) + } + if (selected.customControls) + result.push(row("advanced", "Advanced device controls", "settings_remote")) + result.push(row("disconnect", "Control this device instead", "tv")) + return result + } + function syncVisibility() { + remote.setChooserVisible(visible && choosing) + remote.setQueueVisible(visible && attached && queueOpen) + } + onVisibleChanged: syncVisibility() + onChoosingChanged: syncVisibility() + onQueueOpenChanged: syncVisibility() + onAttachedChanged: { + editingKey = "" + if (!attached) { + choosing = true + queueOpen = false + } + syncVisibility() + } + Connections { + target: root.remote + ignoreUnknownSignals: true + function onTrickplayChanged() { + if (!root.previewActive) + root.previewFrame = ({}) + root.previewSelectionPending = root.previewActive + } + function onSelectionChanged() { + root.editingKey = "" + root.customContext = null + root.syncVisibility() + } + } + Component.onCompleted: syncVisibility() + Component.onDestruction: { + if (customContext) + customContext.close() + remote.setChooserVisible(false) + remote.setQueueVisible(false) + } + onRowsChanged: { + if (!rows.some(entry => entry.key === focusedKey)) { + focusedKey = rows.length ? rows[Math.min(lastFocusedIndex, rows.length - 1)].key : "" + editingKey = "" + } + Qt.callLater(revealFocusedRow) + } + function revealFocusedRow() { + if (visible && Metrics.keyboardFocusActive && focusedIndex >= 0) { + list.forceLayout() + list.positionViewAtIndex(focusedIndex, ListView.Contain) + } + } + function actionable(entry) { + if (!entry || !entry.enabled) + return false + if (entry.entryId && remote.queueBusy) + return false + return !remote.busy || (!entry.command && !entry.adjustable && entry.key !== "transfer" && !entry.targetId) + } + function timeLabel(seconds) { + if (!isFinite(seconds)) + return "Unknown" + const whole = Math.max(0, Math.floor(seconds)) + return Math.floor(whole / 60) + ":" + (whole % 60 < 10 ? "0" : "") + whole % 60 + } + function adjustmentValue(key) { + return key === editingKey ? editedValue : key === "seek" ? Number(remote.positionTicks) / 10000000 : Number( + snapshot.volume) + } + function stepperLabel(key) { + return key === "seek" ? timeLabel(adjustmentValue(key)) + " / " + timeLabel(Number(remote.runtimeTicks) + / 10000000) : Math.round( + adjustmentValue(key)) + "%" + } + function beginEditing(key) { + if (editingKey === key) return - let selected = -1 - for (let index = 0; index < tracks.length; ++index) - if (tracks[index].selected) - selected = index - const next = tracks[(selected + 1) % tracks.length] - select(Number(next.index)) + editingKey = "" + editedValue = adjustmentValue(key) + editingKey = key } - function openTargetKeyboard() { - remoteKeyboard.text = "" - InputKeys.focus(remoteKeyboard) - Qt.inputMethod.show() + function adjust(key, direction) { + beginEditing(key) + const maximum = key === "seek" ? Math.floor(Number(remote.runtimeTicks) / 10000000) : 100 + editedValue = Math.max(0, Math.min(maximum, editedValue + direction * (key === "seek" ? 10 : 5))) } - - function commitTargetKeyboard() { - const text = remoteKeyboard.text - remoteKeyboard.text = "" - if (text.length > 0) - RemoteControl.sendGeneralCommand("SendString", { - "String": text - }) - Qt.inputMethod.hide() - Qt.callLater(function () { - InputKeys.focus(keyboardButton) - }) + function commitEditing() { + if (!editingKey) + return + const seconds = Math.floor(editedValue) + const command = editingKey === "seek" ? { + action: "seek", + positionTicks: seconds === 0 ? "0" : String(seconds) + "0000000" + } : { + action: "volume", + value: Math.round(editedValue) + } + editingKey = "" + remote.send(command) } - - function routeKey(key, phase, repeat) { - if (phase !== "press" || repeat || !InputKeys.isDirection(key)) - return InputKeys.isDirection(key) - const window = root.Window.window - const current = window ? window.activeFocusItem : null - const backwards = key === Qt.Key_Left || key === Qt.Key_Up - const next = current && current.nextItemInFocusChain ? current.nextItemInFocusChain(backwards) : - disconnectButton - InputKeys.focus(next) - if (next && next.mapToItem) { - const point = next.mapToItem(content, 0, 0) - flick.contentY = Math.max(0, Math.min(flick.contentHeight - flick.height, point.y - Metrics.scaled(80))) + function perform(entry) { + if (!actionable(entry)) + return + focusedKey = entry.key + if (entry.adjustable) { + if (editingKey === entry.key) + commitEditing() + else + beginEditing(entry.key) + } else if (entry.targetId !== undefined) { + remote.selectTarget(entry.targetId) + } else if (entry.command) + remote.send(entry.command) + else if (entry.loadAdjacent) + remote.requestQueuePage(false) + else if (entry.key === "chooser") + choosing = !choosing + else if (entry.key === "refresh") + remote.refreshTargets() + else if (entry.key === "transfer") + app.transferPlaybackToRemote() + else if (entry.key === "queue") + queueOpen = !queueOpen + else if (entry.key === "more") + remote.requestQueuePage(false) + else if (entry.key === "queueRefresh") + remote.requestQueuePage(true) + else if (entry.key === "advanced") { + if (compact && remote.createAdvancedControls) + customContext = remote.createAdvancedControls() + else + remote.openAdvancedControls() + } else if (entry.key === "disconnect" || entry.key === "local") { + remote.disconnectTarget() + if (entry.key === "local" && shell) + shell.goHome() } - return true } - function activate() { - const window = root.Window.window - const current = window ? window.activeFocusItem : null - if (current && current.clicked) - current.clicked() + if (customContext) + customSurface.activate() + else + perform(rows[focusedIndex]) } - - onActiveFocusChanged: if (activeFocus) - InputKeys.focus(RemoteControl.targetSelected ? disconnectButton : refreshButton) - - Connections { - target: RemoteControl - function onStateChanged() { - if (!positionSlider.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() + function back() { + if (customContext) { + customContext.close() + customContext = null + return true + } + if (editingKey) { + editingKey = "" + return true } - function onTargetChanged() { - if (RemoteControl.targetSelected) - RemoteControl.refreshTargets() + if (queueOpen) { + queueOpen = false + focusedKey = "queue" + return true } + return false } - Connections { - target: Qt.inputMethod - function onVisibleChanged() { - if (!Qt.inputMethod.visible && remoteKeyboard.activeFocus) - root.commitTargetKeyboard() + function routeKey(key, phase, repeat) { + if (customContext) + return customSurface.routeKey(key, phase, repeat) + if (!InputKeys.isDirection(key)) + return false + if (phase === "release") + return true + if (InputKeys.isHorizontal(key)) { + const entry = rows[focusedIndex] + if (entry && entry.adjustable && editingKey === entry.key) + adjust(entry.key, key === Qt.Key_Right ? 1 : -1) + return true } + if (editingKey) + commitEditing() + const next = focusedIndex + (key === Qt.Key_Down ? 1 : -1) + if (next < 0 && shell) + shell.focusNavBar() + else if (rows.length) { + focusedKey = rows[Math.max(0, Math.min(rows.length - 1, next))].key + list.positionViewAtIndex(focusedIndex, ListView.Contain) + } + return true } - Timer { - interval: 1000 - repeat: true - running: root.visible && RemoteControl.targetSelected && !RemoteControl.paused - onTriggered: if (!positionSlider.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() + Rectangle { + anchors.fill: parent + color: root.compact ? Theme.bgRaised : Theme.bg + radius: root.compact ? Theme.radiusPanel : 0 + border.width: root.compact ? Theme.hoverBorderWidth : 0 + border.color: Theme.borderStrong + MouseArea { + anchors.fill: parent + enabled: root.compact + acceptedButtons: Qt.AllButtons + onWheel: wheel => wheel.accepted = true + } } - - Flickable { - id: flick + ColumnLayout { + visible: !root.customContext anchors.fill: parent - anchors.leftMargin: Metrics.pageMarginPx - anchors.rightMargin: Metrics.pageMarginPx - contentWidth: width - contentHeight: content.implicitHeight + Metrics.pageMarginPx * 2 - boundsBehavior: Flickable.StopAtBounds - flickDeceleration: Metrics.flickDecelerationPx - maximumFlickVelocity: Metrics.maximumFlickVelocityPx - clip: true - - ColumnLayout { - id: content - width: flick.width - spacing: Metrics.scaled(18) - - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Metrics.pageMarginPx - spacing: Metrics.scaled(12) - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(2) - - AppText { - Layout.fillWidth: true - text: RemoteControl.targetSelected ? RemoteControl.selectedTargetName : "Remote Control" - color: Theme.textPrimary - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - } - - SecondaryText { - Layout.fillWidth: true - text: RemoteControl.targetSelected ? RemoteControl.selectedTargetDetail : - "Choose a Jellyfin client" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - } - } - - ActionButton { - id: refreshButton - text: "Refresh" - iconName: "refresh" - onClicked: RemoteControl.refreshTargets() - } - - ActionButton { - id: disconnectButton - visible: RemoteControl.targetSelected - text: "Disconnect" - iconName: "cast" - onClicked: RemoteControl.clearTarget() - } + anchors.margins: root.compact ? Metrics.scaled(16) : Metrics.pageMarginPx + spacing: Metrics.gapPx + RowLayout { + Layout.fillWidth: true + Image { + visible: source.toString().length > 0 + source: root.snapshot.artwork || "" + Layout.preferredWidth: Metrics.scaled(90) + Layout.preferredHeight: Metrics.scaled(90) + fillMode: Image.PreserveAspectFit + asynchronous: true } - ColumnLayout { Layout.fillWidth: true - visible: !RemoteControl.targetSelected - EmptyPlaceholder { + AppText { Layout.fillWidth: true - Layout.preferredHeight: Metrics.scaled(120) - title: RemoteControl.busy ? "Looking for clients…" : "No client selected" - detail: RemoteControl.busy ? "Checking active Jellyfin sessions" : - "Use the Cast button or choose below" + text: root.attached ? root.selected.name || "Remote playback" : "Playback devices" + font.pixelSize: root.compact ? Metrics.bodySizePx + Metrics.scaled(2) : Metrics.titleSizePx + elide: Text.ElideRight } - - Repeater { - model: RemoteControl.targets - - delegate: ActionButton { - required property var modelData - Layout.fillWidth: true - text: (modelData.deviceName || modelData.client || "Jellyfin client") + (modelData.userName - ? " — " + modelData.userName : - "") - iconName: modelData.deviceType === "TV" ? "tv" : "devices" - onClicked: RemoteControl.selectTarget(modelData.sessionId) - } - } - } - - Surface { - Layout.fillWidth: true - Layout.preferredHeight: nowPlayingLayout.implicitHeight + Metrics.scaled(32) - visible: RemoteControl.targetSelected - elevated: true - baseColor: Theme.bgRaised - - RowLayout { - id: nowPlayingLayout - anchors.fill: parent - anchors.margins: Metrics.scaled(16) - spacing: Metrics.scaled(16) - - ImageCard { - Layout.preferredWidth: Metrics.scaled(root.hasMedia ? 128 : 84) - Layout.preferredHeight: width * 9 / 16 - imageUrl: root.hasMedia ? Art.url(root.item, "landscape") : "" - fallbackText: root.hasMedia ? String(root.item.itemType || "Media") : "Idle" - fallbackIcon: root.hasMedia ? "movie" : "cast_connected" - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(4) - - AppText { - Layout.fillWidth: true - text: RemoteControl.playbackPending ? "Starting " + (RemoteControl.pendingTitle - || "playback") : root.hasMedia ? String( - root.item.title - || "Playing") : - "Nothing playing" - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx + Metrics.scaled(5) - font.weight: Font.DemiBold - maximumLineCount: 2 - elide: Text.ElideRight - } - - SecondaryText { - Layout.fillWidth: true - text: RemoteControl.playbackPending ? "Waiting for " + RemoteControl.selectedTargetName : - String(root.item.seriesName || root.item.albumArtist - || root.item.album || "") - font.pixelSize: Metrics.bodySizePx - visible: text.length > 0 - } - } + AppText { + Layout.fillWidth: true + text: root.attached ? (root.snapshot.title || (root.snapshot.state === "stopped" ? "Nothing playing" : + "Remote media")) + + " · " + (root.snapshot.state || "Connecting") : + "Choosing a device does not start or transfer playback." + wrapMode: Text.WordWrap + font.pixelSize: Metrics.bodySizePx } } - - Surface { - Layout.fillWidth: true - Layout.preferredHeight: playbackColumn.implicitHeight + Metrics.scaled(32) - visible: RemoteControl.targetSelected && root.hasMedia - elevated: true - baseColor: Theme.bgRaised - - ColumnLayout { - id: playbackColumn - anchors.fill: parent - anchors.margins: Metrics.scaled(16) - spacing: Metrics.scaled(12) - - SectionHeader { - Layout.fillWidth: true - title: "Playback" - } - - Item { - Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: Math.min(content.width - Metrics.scaled(16), Metrics.scaled(320)) - Layout.preferredHeight: 0 - visible: root.seekPreviewReady - z: 10 - - Column { - anchors.bottom: parent.top - anchors.bottomMargin: Metrics.scaled(8) - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - spacing: Metrics.scaled(4) - - Rectangle { - id: previewFrame - readonly property real imageScale: root.seekPreviewReady && root.seekPreviewData.width - > 0 ? width / root.seekPreviewData.width : 1 - width: parent.width - height: root.seekPreviewReady && root.seekPreviewData.width > 0 ? width - * root.seekPreviewData.height - / root.seekPreviewData.width : - 0 - radius: Theme.radiusLarge - color: "black" - border.width: 1 - border.color: Theme.borderStrong - clip: true - - Image { - source: root.seekPreviewReady ? "image://artwork/" + encodeURIComponent( - root.seekPreviewData.url) : "" - x: root.seekPreviewReady ? root.seekPreviewData.offsetX * previewFrame.imageScale : - 0 - y: root.seekPreviewReady ? root.seekPreviewData.offsetY * previewFrame.imageScale : - 0 - width: root.seekPreviewReady ? root.seekPreviewData.sheetWidth - * previewFrame.imageScale : 0 - height: root.seekPreviewReady ? root.seekPreviewData.sheetHeight - * previewFrame.imageScale : 0 - fillMode: Image.Stretch - cache: true - asynchronous: true - } - } - - AppText { - id: previewClock - anchors.horizontalCenter: parent.horizontalCenter - text: root.formatTicks(root.shownPositionTicks) - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - font.weight: Font.Medium - } - } - } - - InlineSlider { - id: positionSlider - Layout.leftMargin: Metrics.scaled(8) - Layout.rightMargin: Metrics.scaled(8) - from: 0 - to: Math.max(1, RemoteControl.runtimeTicks) - value: root.shownPositionTicks - interactionMargin: Metrics.scaled(16) - barHeight: Metrics.scaled(8) - handleSize: Metrics.scaled(22) - accented: true - onMoved: newValue => { - root.shownPositionTicks = newValue - if (dragging) - RemoteControl.previewSeek(Math.round(newValue), true) - } - onCommitted: newValue => { - RemoteControl.cancelSeekPreview() - RemoteControl.seek(Math.round(newValue)) - } - } - - RowLayout { - - SecondaryText { - text: root.formatTicks(root.shownPositionTicks) - } - Item {} - SecondaryText { - text: root.formatTicks(RemoteControl.runtimeTicks) - } - } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Metrics.scaled(8) - - IconButton { - iconName: "skip_previous" - accessibleName: "Previous" - onClicked: RemoteControl.previousTrack() - } - IconButton { - iconName: "replay_10" - accessibleName: "Back 10 seconds" - onClicked: RemoteControl.seekRelative(-100000000) - } - IconButton { - width: Math.max(Metrics.touchTargetPx, Metrics.scaled(62)) - height: width - iconName: RemoteControl.paused ? "play_arrow" : "pause" - accessibleName: RemoteControl.paused ? "Play" : "Pause" - selected: true - onClicked: RemoteControl.togglePause() - } - IconButton { - iconName: "forward_10" - accessibleName: "Forward 10 seconds" - onClicked: RemoteControl.seekRelative(100000000) - } - IconButton { - iconName: "skip_next" - accessibleName: "Next" - onClicked: RemoteControl.nextTrack() - } - IconButton { - iconName: "stop" - accessibleName: "Stop" - onClicked: RemoteControl.stopPlayback() - } - } - - RowLayout { - spacing: Metrics.scaled(12) - - IconButton { - iconName: RemoteControl.muted ? "volume_off" : "volume_up" - accessibleName: RemoteControl.muted ? "Unmute" : "Mute" - onClicked: RemoteControl.toggleMute() - } - - InlineSlider { - id: volumeSlider - from: 0 - to: 100 - value: RemoteControl.volume - stepSize: 1 - interactionMargin: Metrics.scaled(16) - onCommitted: newValue => RemoteControl.setVolume(Math.round(newValue)) - } - - SecondaryText { - text: RemoteControl.volume + "%" - color: Theme.textSecondary - } - } - - RowLayout { - spacing: Metrics.scaled(8) - - ActionButton { - visible: RemoteControl.audioTracks.length > 1 - text: "Audio: " + root.selectedTrackLabel(RemoteControl.audioTracks, "Default") - iconName: "audiotrack" - onClicked: root.cycleTrack(RemoteControl.audioTracks, function (index) { - RemoteControl.selectAudioTrack(index) - }) - } - ActionButton { - visible: RemoteControl.subtitleTracks.length > 0 - text: "Subtitles: " + root.selectedTrackLabel(RemoteControl.subtitleTracks, "Off") - iconName: "subtitles" - onClicked: root.cycleTrack(RemoteControl.subtitleTracks, function (index) { - RemoteControl.selectSubtitleTrack(index) - }) - } - ActionButton { - text: RemoteControl.repeatMode === "RepeatNone" ? "Repeat off" : RemoteControl.repeatMode - iconName: "repeat" - onClicked: RemoteControl.setRepeatMode(RemoteControl.repeatMode === "RepeatNone" - ? "RepeatAll" : RemoteControl.repeatMode - === "RepeatAll" ? "RepeatOne" : "RepeatNone") - } - ActionButton { - text: RemoteControl.shuffled ? "Shuffled" : "Shuffle" - iconName: "shuffle" - onClicked: RemoteControl.setShuffled(!RemoteControl.shuffled) - } - } - } + } + AppText { + Layout.fillWidth: true + visible: !root.compact || root.remote.problem.length > 0 || root.remote.busy || root.editingKey.length > 0 + text: root.remote.problem || (root.remote.busy ? "Working…" : root.editingKey + ? "Left and Right adjust. OK saves; Back cancels." : + "Up and Down choose a control. OK activates or edits.") + color: root.remote.problem ? Theme.errorText : Theme.textSecondary + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.WordWrap + } + AppText { + visible: root.queueOpen && root.selected.queueEditing === "replace" + Layout.fillWidth: true + text: "Queue changes restart remote playback. The current position is preserved when its entry remains." + font.pixelSize: Metrics.bodySizePx + color: Theme.textSecondary + wrapMode: Text.WordWrap + } + Item { + id: seekPreview + readonly property bool ready: root.previewFrame.available === true + readonly property real previewScale: typeof Settings !== "undefined" ? Math.max(25, Math.min(200, Number( + Settings.values["playback/trickplayPreviewScalePercent"]) + || 100)) / 100 : + 1 + readonly property real aspect: ready && root.previewFrame.height > 0 ? root.previewFrame.width + / root.previewFrame.height : 16 / 9 + + visible: ready && seekPreviewImage.ready + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: Math.min(Metrics.scaled(320) * previewScale, Math.max(0, parent.width), Math.max(0, + root.height + * 0.4) * aspect) + Layout.preferredHeight: ready ? width / aspect : 0 + clip: true + + TrickplayPreviewItem { + id: seekPreviewImage + objectName: "remoteTrickplayTexture" + anchors.fill: parent + source: seekPreview.ready ? root.previewFrame.url : "" + crop: seekPreview.ready ? Qt.rect(-root.previewFrame.offsetX, -root.previewFrame.offsetY, + root.previewFrame.width, root.previewFrame.height) : Qt.rect(0, 0, 0, + 0) } - - Surface { - Layout.fillWidth: true - Layout.preferredHeight: navigationColumn.implicitHeight + Metrics.scaled(32) - visible: RemoteControl.targetSelected - elevated: true - baseColor: Theme.bgRaised - - ColumnLayout { - id: navigationColumn - anchors.fill: parent - anchors.margins: Metrics.scaled(16) - spacing: Metrics.scaled(14) - - SectionHeader { - Layout.fillWidth: true - title: "Navigation" - } - - SecondaryText { - Layout.fillWidth: true - text: "Control the screen on " + RemoteControl.selectedTargetName - } - - GridLayout { - Layout.alignment: Qt.AlignHCenter - columns: 3 - rowSpacing: Metrics.scaled(10) - columnSpacing: Metrics.scaled(10) - - Item { - Layout.preferredWidth: root.remoteButtonSize - Layout.preferredHeight: root.remoteButtonSize - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_up" - accessibleName: "Up" - onClicked: RemoteControl.sendGeneralCommand("MoveUp") - } - Item { - Layout.preferredWidth: root.remoteButtonSize - Layout.preferredHeight: root.remoteButtonSize - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_left" - accessibleName: "Left" - onClicked: RemoteControl.sendGeneralCommand("MoveLeft") - } - IconButton { - width: root.primaryRemoteButtonSize - height: width - iconName: "radio_button_checked" - accessibleName: "Select" - selected: true - onClicked: RemoteControl.sendGeneralCommand("Select") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_right" - accessibleName: "Right" - onClicked: RemoteControl.sendGeneralCommand("MoveRight") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "arrow_back" - accessibleName: "Back" - onClicked: RemoteControl.sendGeneralCommand("Back") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_down" - accessibleName: "Down" - onClicked: RemoteControl.sendGeneralCommand("MoveDown") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "menu" - accessibleName: "Menu" - onClicked: RemoteControl.sendGeneralCommand("ToggleContextMenu") - } - } - - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(8) - - ActionButton { - Layout.fillWidth: true - text: "Home" - iconName: "home" - onClicked: RemoteControl.sendGeneralCommand("GoHome") - } - ActionButton { - Layout.fillWidth: true - text: "Search" - iconName: "search" - onClicked: RemoteControl.sendGeneralCommand("GoToSearch") - } - ActionButton { - id: keyboardButton - Layout.fillWidth: true - visible: !Platform.isTV - text: "Keyboard" - iconName: "keyboard" - onClicked: root.openTargetKeyboard() - } - } - - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(8) - - ActionButton { - Layout.fillWidth: true - text: "Settings" - iconName: "settings" - onClicked: RemoteControl.sendGeneralCommand("GoToSettings") - } - ActionButton { - Layout.fillWidth: true - text: "Player overlay" - iconName: "picture_in_picture_alt" - onClicked: RemoteControl.sendGeneralCommand("ToggleOsd") - } - } - - TextInput { - id: remoteKeyboard - Layout.preferredWidth: 1 - Layout.preferredHeight: 1 - opacity: 0 - inputMethodHints: Qt.ImhNoPredictiveText - onAccepted: root.commitTargetKeyboard() - } + } + ListView { + id: list + objectName: "remoteControlsList" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.rows + currentIndex: root.focusedIndex + boundsBehavior: Flickable.StopAtBounds + delegate: MenuRow { + required property var modelData + width: list.width + label: modelData.label + detail: modelData.detail || "" + iconName: modelData.icon + checked: Boolean(modelData.checked) + highlighted: root.focusedKey === modelData.key + actionable: root.actionable(modelData) + stepperVisible: Boolean(modelData.adjustable) + stepperEnabled: actionable + stepperText: modelData.adjustable ? root.stepperLabel(modelData.key) : "" + onActivated: { + root.perform(modelData) + InputKeys.focus(root) } - } - - ColumnLayout { - Layout.fillWidth: true - visible: RemoteControl.targetSelected && RemoteControl.queue.length > 0 - spacing: Metrics.scaled(6) - - SectionHeader { - Layout.fillWidth: true - title: "Playback queue" + onDecreaseRequested: { + root.adjust(modelData.key, -1) + root.commitEditing() } - - Repeater { - model: RemoteControl.queue - - delegate: Surface { - required property int index - required property var modelData - Layout.fillWidth: true - Layout.preferredHeight: Metrics.controlHeightPx + Metrics.scaled(10) - baseColor: modelData.current ? Theme.accentPanel : Theme.bgPanel - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Metrics.scaled(14) - anchors.rightMargin: Metrics.scaled(8) - spacing: Metrics.scaled(6) - - AppText { - Layout.fillWidth: true - text: (modelData.current ? "Now playing · " : "") + (modelData.title - || modelData.seriesName - || "Queue item " + (index + 1)) - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - elide: Text.ElideRight - } - IconButton { - iconName: "play_arrow" - accessibleName: "Play queue item" - onClicked: RemoteControl.playQueueItem(index) - } - IconButton { - enabled: index > 0 - iconName: "keyboard_arrow_up" - accessibleName: "Move up" - onClicked: RemoteControl.moveQueueItem(index, index - 1) - } - IconButton { - enabled: index + 1 < RemoteControl.queue.length - iconName: "keyboard_arrow_down" - accessibleName: "Move down" - onClicked: RemoteControl.moveQueueItem(index, index + 1) - } - IconButton { - iconName: "delete" - accessibleName: "Remove" - onClicked: RemoteControl.removeQueueItem(index) - } - } - } + onIncreaseRequested: { + root.adjust(modelData.key, 1) + root.commitEditing() } + Accessible.name: label + Accessible.description: detail + Accessible.selected: checked } - - Item { - Layout.fillWidth: true - Layout.preferredHeight: Metrics.pageMarginPx + FastWheelHandler { + flickable: list + } + } + } + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.scaled(12) + visible: root.customContext !== null + ActionButton { + text: "Back to devices" + kind: "flat" + iconName: "arrow_back" + onClicked: root.back() + } + ProviderSurface { + id: customSurface + Layout.fillWidth: true + Layout.fillHeight: true + embedded: true + context: root.customContext + onFinished: { + root.customContext = null + InputKeys.focus(root) } } } diff --git a/qml/pages/SearchPage.qml b/qml/pages/SearchPage.qml index e7d0b438..123e53d2 100644 --- a/qml/pages/SearchPage.qml +++ b/qml/pages/SearchPage.qml @@ -22,24 +22,28 @@ FocusScope { { "key": "movies", "title": "Movies", - "model": search ? search.movieResults : null + "model": search ? search.movieResults : null, + "cardBadge": item => root.sourceBadge(item) }, { "key": "series", "title": "Series", - "model": search ? search.seriesResults : null + "model": search ? search.seriesResults : null, + "cardBadge": item => root.sourceBadge(item) }, { "key": "episodes", "title": "Episodes", "model": search ? search.episodeResults : null, "useSeriesPoster": true, - "preferEpisodeTitle": true + "preferEpisodeTitle": true, + "cardBadge": item => root.sourceBadge(item) }, { "key": "other", "title": "More", - "model": search ? search.otherResults : null + "model": search ? search.otherResults : null, + "cardBadge": item => root.sourceBadge(item) } ] // Result kind the user last interacted with; picks the row to focus @@ -72,6 +76,14 @@ FocusScope { field.focusField() } + function sourceBadge(item) { + const origin = Sources.originOf(String(item.movieId || "")) + return origin && origin.serverName ? { + "iconUrl": origin.iconUrl, + "text": origin.serverName + } : null + } + function activateResult(section, index, item) { if (!section) return @@ -212,13 +224,14 @@ FocusScope { title: "Suggestions" model: root.search ? root.search.suggestions : null shell: root.shell + cardBadge: item => root.sourceBadge(item) cardWidth: Metrics.cardWidth(root.width) cardGap: Metrics.gapPx enabledRow: root.query.length < 2 reserveWhenEmpty: root.query.length < 2 && root.suggestionsBusy loading: root.suggestionsBusy emptyText: "Loading suggestions..." - visible: root.query.length < 2 + visible: root.showSuggestions onActivated: index => root.activateSuggestion(index) } diff --git a/qml/pages/SettingsNavigation.js b/qml/pages/SettingsNavigation.js index 40bfc44e..7add0ed0 100644 --- a/qml/pages/SettingsNavigation.js +++ b/qml/pages/SettingsNavigation.js @@ -3,7 +3,7 @@ // `platform` is the Platform singleton. It is passed whole rather than as a // growing list of booleans, because every caller has it and each new // form-factor question would otherwise add another positional argument. -function rowAvailable(row, platform, hdrPlayback, valueForKey) { +function platformSupported(row, platform) { if (!row) return false if (row.platform === "desktop" && platform.isTV) @@ -12,6 +12,12 @@ function rowAvailable(row, platform, hdrPlayback, valueForKey) { return false if (row.platform === "android" && !platform.isAndroid) return false + return true +} + +function rowAvailable(row, platform, hdrPlayback, valueForKey) { + if (!platformSupported(row, platform)) + return false if (row.requiresHdrPlayback && !hdrPlayback) return false if (row.dependsOnKey && String(valueForKey(row.dependsOnKey)) !== String(row.dependsOnValue)) @@ -136,3 +142,53 @@ function reconcileRows(model, nextRows) { } } } + +function valueRoute(mode, action, editsValue) { + if (action === "up" || action === "down") + return { "mode": "row", "effect": action === "up" ? "move-up" : "move-down" } + if (mode === "value-editing") { + if (action === "activate" || action === "back") + return { "mode": "row", "effect": "end-edit" } + return { "mode": mode, "effect": action === "left" || action === "right" ? "value" : "none" } + } + if (action === "activate") + return editsValue ? { "mode": "value-editing", "effect": "begin-edit" } + : { "mode": "row", "effect": "activate" } + if (action === "back") + return { "mode": "row", "effect": "back" } + return { "mode": "row", "effect": action === "left" || action === "right" ? "value" : "none" } +} + +var subtitleSections = [ + { "title": "Size and position", "keys": ["subtitles/scalePercent", "subtitles/verticalPositionPercent", + "subtitles/alwaysOverridePositionAndSize", "subtitles/allowInBlackBars"] }, + { "title": "Colour", "keys": ["subtitles/overrideTextColor", "subtitles/textColor"] }, + { "title": "Which subtitles", "keys": ["subtitles/language", "subtitles/mode"] } +] +var subtitleAdvancedSections = [ + { "title": "Text style", "keys": ["subtitles/styling", "subtitles/textWeight", "subtitles/font", + "subtitles/dropShadow", "subtitles/textBackground"] }, + { "title": "Image subtitles", "keys": ["subtitles/recolorImageSubtitles", "subtitles/bitmapSharpnessPercent", + "subtitles/bitmapShadowEnabled"] }, + { "title": "Image subtitle shadow", "keys": ["subtitles/bitmapShadowCoreSize", "subtitles/bitmapShadowCoreGrow", + "subtitles/bitmapShadowCoreOpacityPercent", "subtitles/bitmapShadowSpreadEnabled", + "subtitles/bitmapShadowSpreadSize", "subtitles/bitmapShadowSpreadGrow", "subtitles/bitmapShadowSpreadX", + "subtitles/bitmapShadowSpreadY", "subtitles/bitmapShadowSpreadOpacityPercent", "subtitles/bitmapShadowDither"] }, + { "title": "HDR", "keys": ["subtitles/hdrBrightnessPercent"] }, + { "title": "Start over", "keys": ["action/resetSubtitleAppearance"] } +] + +function subtitleReachableKeys(schema, platform, hdrPlayback, valueForKey) { + const keys = {} + const sections = subtitleSections.concat(subtitleAdvancedSections) + for (let s = 0; s < sections.length; ++s) + for (let k = 0; k < sections[s].keys.length; ++k) + keys[sections[s].keys[k]] = true + const result = [] + for (let index = 0; index < schema.length; ++index) { + const row = schema[index] + if (keys[row.key] && rowAvailable(row, platform, hdrPlayback, valueForKey)) + result.push(row.key) + } + return result +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index ffc27ed1..9086e43f 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -34,12 +34,37 @@ FocusScope { property bool diagnosticsExportVisible: false property string diagnosticsExportPreview: "" property bool pendingCustomMpvMode: false + property string navigationMode: "row" + property string editingKey: "" + property var editingInitialValue + function beginRowEdit(row) { + if (!row || editingKey === row.key) + return + finishRowEdit() + editingKey = row.key + editingInitialValue = Settings.values[row.key] + SettingsSync.beginEdit(row.key) + } + + function finishRowEdit() { + if (!editingKey.length) + return + const key = editingKey + editingKey = "" + SettingsSync.endEdit(key, Settings.values[key] !== editingInitialValue) + } ListModel { id: settingsRows } function rowAvailable(row) { + if (row.key === "action/connectionSpeed" && !ProviderCapabilities.speedTest) + return false + if (row.key === "action/downloadDestination") + return Downloads.supported && !Downloads.mobile && Downloads.canChooseFolder + if (row.key === "action/mobileDownloadDestination") + return Downloads.supported && Downloads.mobile && Downloads.canChooseFolder return SettingsNavigation.rowAvailable(row, Platform, Player.hdrPlayback, function (key) { return settingsValue({ "key": key, @@ -126,6 +151,13 @@ FocusScope { const schema = Settings.settingsSchema const rowMap = {} const sourceRows = [] + rowMap["action/settingsSync"] = { + "key": "action/settingsSync", + "group": "Accounts", + "title": "Settings sync", + "description": "", + "type": "action" + } for (let index = 0; index < schema.length; ++index) { const row = schema[index] if (row.group === "Subtitle Appearance" || row.key === "settings/audioDelayMs") @@ -137,6 +169,13 @@ FocusScope { "group": row.group, "sourceIndex": index * 2 }) + if (row.key === "action/accounts") + sourceRows.push({ + "rowKey": "action/settingsSync", + "detailLevel": 0, + "group": row.group, + "sourceIndex": index * 2 + 1 + }) const key = disclosureKey(row.group) if (!rowMap[key]) { rowMap[key] = { @@ -158,6 +197,10 @@ FocusScope { reconcilingSettingsRows = true SettingsNavigation.reconcileRows(settingsRows, nextRows) const target = SettingsNavigation.indexForRowKey(settingsRows, targetKey) + if (selectedRowKey !== targetKey) { + finishRowEdit() + navigationMode = "row" + } currentIndex = target selectedRowKey = target >= 0 ? targetKey : "" settingsList.currentIndex = target @@ -201,6 +244,13 @@ FocusScope { } function selectRow(index, takeFocus) { + if (index !== currentIndex) { + const focused = root.Window.window ? root.Window.window.activeFocusItem : null + if (editingKey.length && InputKeys.isTextInputItem(focused)) + InputKeys.focus(settingsList) + finishRowEdit() + navigationMode = "row" + } const target = SettingsNavigation.clampIndex(index, settingsRows.count) if (target < 0) { currentIndex = -1 @@ -235,28 +285,10 @@ FocusScope { function settingsValue(row) { switch (row.key) { - case "i18n/locale": - return I18n.useSystemLocale ? "system" : I18n.currentLocale - case "theme/accent": - return Theme.accentIndex - case "theme/railLabels": - return Theme.sideRailLabels - case "theme/reducedMotion": - return Theme.reducedMotion - case "theme/renderMode": - return Theme.normalTextRenderType - case "theme/antialiasedText": - return Theme.antialiasedText - case "theme/technicalMetadata": - return Theme.technicalMetadataMode - case "shell/diagnostics": - return shell ? shell.diagnosticsVisible : false - case "shell/latencyGuard": - return InputLatency.enabled - case "shell/latencyOverlay": - return InputLatency.overlayEnabled case "subtitles/language": return Settings.subtitleLanguageIndex + case "audio/language": + return Settings.audioLanguageIndex default: const value = Settings.values[row.key] return value === undefined ? row.defaultValue : value @@ -264,8 +296,21 @@ FocusScope { } function rowDescription(row) { - if (row.key === "session/account") - return Session.serverUrl + if (row.key === "action/settingsSync") + return SettingsSync.summary + if (row.key === "action/connectionSpeed") + return App.connectionSpeedDescription + if (row.key === "action/downloads") + return Downloads.jobs.length ? Downloads.jobs.length + " downloads · Play saved files offline" : + "Manage downloads and play saved files offline" + if (row.key === "action/downloadDestination" || row.key === "action/mobileDownloadDestination") + return Downloads.destination + " · Applies only to new downloads" + if (row.key === "action/accounts") { + const count = Providers.accounts.length + return count === 1 ? "1 account" : count + " accounts" + } + if (row.key === "action/providers" && Store.updates.length > 0) + return Store.updates.length === 1 ? "1 update" : Store.updates.length + " updates" if (row.key === "subtitles/mode" || row.key === "audio/trackMode") { const index = rowCurrentIndex(row) const labels = rowOptions(row) @@ -293,17 +338,22 @@ FocusScope { } function rowValueText(row) { - if (row.key === "action/switchUser") - return "Choose" - if (row.key === "action/logout") - return "Sign out" + if (row.key === "action/settingsSync") + return "Manage" + if (row.key === "action/connectionSpeed") + return "Measure again" + if (row.key === "action/downloads") + return "Manage" + if (row.key === "action/downloadDestination" || row.key === "action/mobileDownloadDestination") + return "Change…" + if (row.key === "action/accounts" || row.key === "action/providers") + return "Manage" if (row.key === "action/openSourceNotices" || row.key === "action/exportDiagnostics" || row.key === "action/subtitleSettings" || row.key === "action/manageCertificates") return "Open" if (row.key === "action/clearLatencyStatistics" || row.key === "action/clearLogs") return "Clear" - if (row.key === "session/account") - return Session.activeProfileLabel.length > 0 ? Session.activeProfileLabel : "Offline" + if (row.key === "about/version") return "v" + Qt.application.version if (row.key === "about/locale") @@ -318,7 +368,7 @@ FocusScope { result.push(I18n.displayNameFor(I18n.availableLocales[index])) return result } - if (row.key === "subtitles/language") + if (row.key === "subtitles/language" || row.key === "audio/language") return Settings.subtitleLanguageOptions if (row.key === "subtitles/mode" || row.key === "audio/trackMode") return substitutedLabels(row.choiceLabels || []) @@ -328,7 +378,7 @@ FocusScope { function rowChoiceValues(row) { if (row.key === "i18n/locale") return I18n.availableLocales - if (row.key === "subtitles/language") + if (row.key === "subtitles/language" || row.key === "audio/language") return Settings.subtitleLanguageOptions return row.choiceValues || [] } @@ -343,45 +393,19 @@ FocusScope { function rowCurrentIndex(row) { if (row.key === "subtitles/language") return Settings.subtitleLanguageIndex + if (row.key === "audio/language") + return Settings.audioLanguageIndex return valueIndex(rowChoiceValues(row), settingsValue(row)) } function setRowValue(row, value, index) { switch (row.key) { - case "i18n/locale": - I18n.setLocale(value) - break - case "theme/accent": - Theme.accentIndex = Number(value) - break - case "theme/railLabels": - Theme.sideRailLabels = value - break - case "theme/reducedMotion": - Theme.reducedMotion = value - break - case "theme/renderMode": - Theme.normalTextRenderType = Number(value) - break - case "theme/antialiasedText": - Theme.antialiasedText = value - break - case "theme/technicalMetadata": - Theme.technicalMetadataMode = value - break - case "shell/diagnostics": - if (shell) - shell.diagnosticsVisible = value - break - case "shell/latencyGuard": - InputLatency.enabled = value - break - case "shell/latencyOverlay": - InputLatency.overlayEnabled = value - break case "subtitles/language": Settings.setSubtitleLanguageIndex(index) break + case "audio/language": + Settings.setAudioLanguageIndex(index) + break default: Settings.setValue(row.key, value) } @@ -392,9 +416,9 @@ FocusScope { if (index < 0 || index >= values.length) return if (row.key === "playback/mpvConfigMode" && values[index] === "custom" && !String( - Settings.values["playback/mpvConfigDirectory"] || "").length && mpvFolderDialog) { + Settings.values["playback/mpvConfigDirectory"] || "").length && !Platform.isTV) { pendingCustomMpvMode = true - mpvFolderDialog.open() + openMpvFolderDialog() return } setRowValue(row, values[index], index) @@ -436,16 +460,26 @@ FocusScope { return } if (row.type === "action") { - if (row.key === "action/switchUser" && shell) - shell.switchUser() - else if (row.key === "action/logout") - App.logout() + if (row.key === "action/settingsSync" && shell) + shell.pushRoute("settingsSync") + else if (row.key === "action/accounts" && shell) + shell.pushRoute("accounts") + else if (row.key === "action/providers" && shell) + shell.pushRoute("addProvider") + else if (row.key === "action/downloads" && shell) + shell.openDownloads("", settingsList) + else if ((row.key === "action/downloadDestination" || row.key === "action/mobileDownloadDestination") + && shell) + + shell.openDownloads("", settingsList, true) else if (row.key === "action/manageCertificates") certificateManagerVisible = true else if (row.key === "action/clearLatencyStatistics") InputLatency.clearStatistics() else if (row.key === "action/clearLogs") App.clearLogs() + else if (row.key === "action/connectionSpeed") + App.refreshConnectionSpeed() else if (row.key === "action/exportDiagnostics") { diagnosticsExportPreview = App.diagnosticsPreview() diagnosticsExportVisible = true @@ -456,11 +490,15 @@ FocusScope { } else if (row.type === "toggle") { setRowValue(row, !Boolean(settingsValue(row)), -1) } else if (row.type === "select") { + if (!rowOptions(row).length) + return + beginRowEdit(row) settingsList.positionViewAtIndex(index, ListView.Contain) Qt.callLater(function () { const anchor = rowControlAt(index) if (!anchor) return + navigationMode = "row" choiceDialogRow = row choiceDialogAnchor = anchor choiceDialogVisible = true @@ -499,6 +537,7 @@ FocusScope { function closeChoiceDialog() { choiceDialogVisible = false + finishRowEdit() choiceDialogAnchor = null choiceDialogRow = null Qt.callLater(function () { @@ -514,6 +553,8 @@ FocusScope { } function back() { + if (navigationMode !== "row") + return routeAction("back") if (certificateManagerVisible) { certificateManagerVisible = false InputKeys.focus(settingsList) @@ -542,24 +583,70 @@ FocusScope { return false } + function routeAction(action) { + const row = currentRow() + const previousMode = navigationMode + const route = SettingsNavigation.valueRoute(navigationMode, action, Boolean(row && (row.type === "slider" + || row.type === "text"))) + navigationMode = route.mode + if (previousMode === "value-editing" && route.mode !== "value-editing") { + const control = rowControlAt(currentIndex) + if (row && row.type === "text" && control && control.finishEditing) + control.finishEditing(action === "activate") + // Numeric fields commit on blur. End the sync edit only after that + // commit, so a deferred remote value cannot replace typed intent. + InputKeys.focus(settingsList) + finishRowEdit() + } + switch (route.effect) { + case "begin-edit": + beginRowEdit(row) + if (row && row.type === "text") { + const control = rowControlAt(currentIndex) + if (control && control.activate) + control.activate() + } + return true + case "end-edit": + return true + case "activate": + activateRow(row, currentIndex) + return true + case "value": + // A focused text field owns its caret; the page must not turn a + // horizontal key into a sync action or another focus target. + const focused = root.Window.window ? root.Window.window.activeFocusItem : null + if (previousMode === "value-editing" && (InputKeys.isTextInputItem(focused) || (row && row.type + === "text"))) + return false + return adjustRow(row, action === "right" ? 1 : -1) + case "move-up": + case "move-down": + InputKeys.focus(settingsList) + return settingsList.moveSelection(route.effect === "move-up" ? -1 : 1) + case "back": + return false + default: + return true + } + } + function routeKey(key, phase, repeat) { if (certificateManagerVisible) return certificateManagerLoader.item.routeKey(key, phase, repeat) if (choiceDialogVisible) - return choiceDialog.routeKey(key, phase, repeat) + return choiceDialog ? choiceDialog.routeKey(key, phase, repeat) : true if (phase === "release" && InputKeys.isDirection(key)) return true - const row = currentRow() - if (InputKeys.isHorizontal(key) && adjustRow(row, key === Qt.Key_Right ? 1 : -1)) - return true - if (InputKeys.isVertical(key) && !settingsList.activeFocus) - InputKeys.focus(settingsList) - if (key === Qt.Key_Up && settingsList.currentIndex <= 0) { - if (shell) - shell.focusNavBar() - return true - } - return settingsList.routeKey(key, phase, repeat) + if (key === Qt.Key_Right) + return routeAction("right") + if (key === Qt.Key_Left) + return routeAction("left") + if (key === Qt.Key_Up) + return routeAction("up") + if (key === Qt.Key_Down) + return routeAction("down") + return false } function activate() { @@ -570,17 +657,22 @@ FocusScope { if (choiceDialogVisible) choiceDialog.activate() else - activateRow(currentRow(), settingsList.currentIndex) + routeAction("activate") } focus: true onActiveFocusChanged: if (activeFocus) - focusEntry() + focusEntry() onVisibleChanged: { - if (visible) - ensureRowsBuilt() + if (visible) { + ensureRowsBuilt() + SettingsSync.refresh() + } else { + finishRowEdit() + navigationMode = "row" + } if (visible && activeFocus) - Qt.callLater(focusEntry) + Qt.callLater(focusEntry) } property bool rowsBuilt: false @@ -595,8 +687,9 @@ FocusScope { // Only take focus if the page is actually active: the route host // prewarms an invisible instance, which must not steal focus. Component.onCompleted: Qt.callLater(function () { - Settings.loadRemote() ensureRowsBuilt() + if (visible) + SettingsSync.refresh() if (activeFocus) focusEntry() }) @@ -624,10 +717,16 @@ FocusScope { root.refreshSettingsFilter(false) } } + Connections { + target: ProviderCapabilities + function onChanged() { + root.refreshSettingsFilter(false) + } + } Connections { target: Player function onHdrPlaybackChanged() { - root.refreshSettingsFilter(true) + root.refreshSettingsFilter(false) } } @@ -646,23 +745,30 @@ FocusScope { dismissOnBack: false dismissOnHorizontal: false spacing: Metrics.scaled(10) + // Settings rows are costly composites. Do not construct an extra + // viewport of hidden controls while opening the page. + cacheBuffer: 0 onCurrentIndexChanged: { if (root.reconcilingSettingsRows) - return + return + if (root.currentIndex !== currentIndex) { + root.finishRowEdit() + root.navigationMode = "row" + } root.currentIndex = currentIndex root.selectedRowKey = currentIndex >= 0 && currentIndex < settingsRows.count ? settingsRows.get( currentIndex).rowKey : "" } - onAccepted: index => root.activateRow(root.rowAtVisibleIndex(index), index) + onAccepted: index => root.routeAction("activate") onEdgeUp: if (root.shell) - root.shell.focusNavBar() + root.shell.focusNavBar() delegate: Column { id: settingsDelegate required property int index required property string rowKey required property bool showHeader required property bool advanced - required property int sourceIndex + required property real sourceIndex readonly property var rowData: root.rowsByKey[rowKey] // The view marks exactly one delegate as current, so the highlight // cannot land on two rows at once. Comparing a per-row copy of the @@ -681,21 +787,25 @@ FocusScope { visible: parent.showHeader title: rowData.group } - Loader { - id: rowLoader - width: Math.max(0, parent.width - (parent.advanced ? Metrics.scaled(24) : 0)) - x: parent.advanced ? Metrics.scaled(24) : 0 - // Bindings, not assignments: the loaded row reads these back - // through its parent so a model change reaches it. Copying - // them into the item once at load time froze them for the - // life of a delegate that outlives several model shapes. - readonly property var row: settingsDelegate.rowData - readonly property int rowIndex: settingsDelegate.index - readonly property bool rowCurrent: settingsDelegate.rowCurrent - sourceComponent: rowData.type === "toggle" ? toggleComponent : rowData.type === "select" - ? selectComponent : rowData.type === "slider" - ? sliderComponent : rowData.type === "text" - ? textComponent : settingComponent + Item { + width: Math.max(0, parent.width - (settingsDelegate.advanced ? Metrics.scaled(24) : 0)) + x: settingsDelegate.advanced ? Metrics.scaled(24) : 0 + height: rowLoader.height + Loader { + id: rowLoader + width: parent.width + readonly property var row: settingsDelegate.rowData + readonly property int rowIndex: settingsDelegate.index + readonly property bool rowCurrent: settingsDelegate.rowCurrent + sourceComponent: row.type === "toggle" ? toggleComponent : row.type === "select" ? selectComponent : + row.type + === "slider" + ? sliderComponent : + row.type + === "text" + ? textComponent : + settingComponent + } } } } @@ -745,6 +855,7 @@ FocusScope { checked: row ? Boolean(root.settingsValue(row)) : false onToggled: checked => { root.selectRow(rowIndex, true) + root.navigationMode = "row" root.setRowValue(row, checked, -1) } } @@ -763,6 +874,7 @@ FocusScope { description: row ? root.rowDescription(row) : "" onOpened: { root.selectRow(rowIndex, true) + root.navigationMode = "row" root.activateRow(row, rowIndex) } options: row ? root.rowOptions(row) : [] @@ -786,14 +898,24 @@ FocusScope { logarithmic: Boolean(row && row.key === "playback/forwardCacheSizeMiB") unitText: row ? String(row.unitText || "") : "" value: row ? Number(root.settingsValue(row)) : 0 - onValueEdited: value => root.setRowValue(row, value, -1) - onInteractionStarted: root.selectRow(rowIndex, false) + onValueEdited: value => { + root.setRowValue(row, value, -1) + root.finishRowEdit() + root.navigationMode = "row" + InputKeys.focus(settingsList) + } + onInteractionStarted: { + root.selectRow(rowIndex, false) + root.beginRowEdit(row) + root.navigationMode = "value-editing" + } } } Component { id: textComponent Surface { + id: textRow readonly property var row: parent ? parent.row : null readonly property int rowIndex: parent ? parent.rowIndex : -1 width: parent ? parent.width : settingsList.width @@ -803,7 +925,7 @@ FocusScope { function activate() { if (browseButton.visible && browseButton.activeFocus) - root.mpvFolderDialog.open() + root.openMpvFolderDialog() else pathField.focusField() } @@ -815,6 +937,13 @@ FocusScope { pathField.focusField() return true } + function finishEditing(commitValue) { + if (commitValue) + root.setRowValue(row, pathField.text, -1) + else + pathField.text = String(root.settingsValue(row) || "") + Qt.inputMethod.hide() + } Column { id: textContent @@ -852,8 +981,17 @@ FocusScope { text: row ? String(root.settingsValue(row) || "") : "" placeholderText: "/absolute/path/to/mpv" inputMethodHints: Qt.ImhNoPredictiveText + onEditingChanged: { + if (editing) { + root.selectRow(rowIndex, false) + root.beginRowEdit(row) + root.navigationMode = "value-editing" + } + } onAccepted: { - root.setRowValue(row, text, -1) + textRow.finishEditing(true) + root.finishRowEdit() + root.navigationMode = "row" Qt.callLater(function () { root.selectRow(rowIndex, true) }) @@ -862,22 +1000,32 @@ FocusScope { ActionButton { id: browseButton - visible: root.mpvFolderDialog !== null + visible: !Platform.isTV width: visible ? Metrics.scaled(132) : 0 height: pathField.height text: "Browse" iconName: "folder" - onClicked: root.mpvFolderDialog.open() + onClicked: root.openMpvFolderDialog() } } } } } + function openMpvFolderDialog() { + if (Platform.isTV) + return + if (mpvFolderDialog) + mpvFolderDialog.open() + else + mpvFolderDialogLoader.active = true + } + Loader { id: mpvFolderDialogLoader - active: !Platform.isTV + active: false source: active ? Qt.resolvedUrl("DesktopFolderDialog.qml") : "" + onLoaded: item.open() } Connections { diff --git a/qml/pages/SettingsSyncPage.qml b/qml/pages/SettingsSyncPage.qml new file mode 100644 index 00000000..71c9d049 --- /dev/null +++ b/qml/pages/SettingsSyncPage.qml @@ -0,0 +1,375 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +FocusScope { + id: root + property var shell + property var syncController: SettingsSync + property var settingsController: Settings + property var accountController: Providers + readonly property bool contentReady: true + property bool choosingAccount: false + property bool advancedExpanded: false + readonly property var account: syncController.accounts.find(candidate => candidate.id === syncController.accountId) + || null + readonly property var accounts: syncController.accounts.filter(candidate => candidate.syncSupported) + readonly property string accountLabel: account ? String(account.syncLabel) : syncController.accountId.length ? "Account removed" : + "Choose an account" + readonly property string status: syncController.status + readonly property string recoveryLabel: ({ + off: "Turn on sync", + noAccount: accounts.length ? "Choose account" : "Add account", + removed: accounts.length ? "Choose account" : "Add account", + signedOut: "Sign in again", + inactive: "Switch to this profile", + failed: "Reconnect", + unsupported: "Choose account", + offline: "Retry now", + error: "Retry now", + saveFailed: "Retry saving" + })[status] || "" + readonly property var categories: { + const groups = [] + for (const spec of settingsController.settingsSchema) { + const state = syncController.states[spec.key] + if (!state || !state.eligible) + continue + const device = spec.syncPolicy === "device" || spec.key === "subtitles/font" && String( + settingsController.values[spec.key]).startsWith("system:") + const name = device ? "Device-specific" : spec.group === "Appearance" ? "Interface" : spec.group + let group = groups.find(candidate => candidate.title === name) + if (!group) { + group = { + title: name, + keys: [], + enabled: 0, + device: device + } + groups.push(group) + } + group.keys.push(spec.key) + if (state.enabled) + ++group.enabled + } + return groups + } + readonly property var rows: { + const result = ["enabled", "account", "preset"] + if (advancedExpanded && account) + for (let index = 0; index < categories.length; ++index) + result.push("category/" + index) + result.push("syncNow") + return result + } + + function focusEntry() { + InputKeys.focus(recovery.visible ? recovery : list) + } + function openAccountPicker() { + if (accounts.length) + choosingAccount = true + else if (shell) + shell.pushRoute("addProvider") + } + function recover() { + if (status === "off") + syncController.setEnabled(true) + else if (status === "signedOut" && account && shell) + shell.openProviderScreen(accountController.beginSetup(account.moduleId)) + else if ((status === "inactive" || status === "failed") && account) + accountController.useAccount(account.id) + else if (status === "offline" || status === "error" || status === "saveFailed") + syncController.retry() + else + openAccountPicker() + } + function activateRow(index) { + const key = rows[index] + if (key === "enabled") + syncController.setEnabled(!syncController.enabled) + else if (key === "account") + openAccountPicker() + else if (key === "preset") { + advancedExpanded = !advancedExpanded + list.currentIndex = index + } else if (key === "syncNow" && syncController.enabled && status !== "syncing" && account + && account.connectionState === "active") + syncController.retry() + else if (key && key.startsWith("category/")) { + const category = categories[Number(key.substring(9))] + syncController.setSettingsEnabled(category.keys, category.enabled !== category.keys.length) + } + } + function back() { + if (syncController.accountChangePending) { + syncController.confirmAccountChange(false) + InputKeys.focus(list) + return true + } + if (choosingAccount) { + choosingAccount = false + InputKeys.focus(list) + return true + } + if (advancedExpanded) { + advancedExpanded = false + list.currentIndex = 2 + InputKeys.focus(list) + return true + } + return false + } + function activate() { + if (confirmation.item) + confirmation.item.activate() + else if (picker.item) + picker.item.activate() + else if (recovery.activeFocus) + recover() + else if (chooseAnother.activeFocus) + openAccountPicker() + else if (recommended.activeFocus) + syncController.resetSettingOverrides() + else + activateRow(list.currentIndex) + } + function routeKey(key, phase, repeat) { + if (confirmation.item) + return confirmation.item.routeKey(key, phase, repeat) + if (picker.item) + return picker.item.routeKey(key, phase, repeat) + if (phase !== "press") + return InputKeys.isDirection(key) + const buttons = [recovery, chooseAnother, recommended].filter(button => button.visible) + const focusedButton = buttons.findIndex(button => button.activeFocus) + if (focusedButton >= 0) { + if (key === Qt.Key_Down) + InputKeys.focus(list) + else if (key === Qt.Key_Up && shell) + shell.focusNavBar() + else if (key === Qt.Key_Right) + InputKeys.focus(buttons[Math.min(buttons.length - 1, focusedButton + 1)]) + else if (key === Qt.Key_Left) + InputKeys.focus(buttons[Math.max(0, focusedButton - 1)]) + return InputKeys.isDirection(key) + } + if (key === Qt.Key_Up && list.currentIndex === 0) { + if (recovery.visible || recommended.visible) + InputKeys.focus(recovery.visible ? recovery : recommended) + else if (shell) + shell.focusNavBar() + return true + } + if (key === Qt.Key_Up || key === Qt.Key_Down) + return list.moveSelection(key === Qt.Key_Up ? -1 : 1) + return InputKeys.isDirection(key) + } + focus: true + onActiveFocusChanged: if (activeFocus) + Qt.callLater(focusEntry) + Component.onCompleted: if (activeFocus) + Qt.callLater(focusEntry) + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.pageMarginPx + spacing: Metrics.scaled(16) + AppText { + Layout.fillWidth: true + text: "Settings sync" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + } + Surface { + Layout.fillWidth: true + implicitHeight: statusContent.implicitHeight + Metrics.scaled(32) + ColumnLayout { + id: statusContent + anchors.fill: parent + anchors.margins: Metrics.scaled(16) + spacing: Metrics.scaled(10) + AppText { + objectName: "syncSummary" + Layout.fillWidth: true + text: root.syncController.summary + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap + Accessible.role: Accessible.StaticText + Accessible.name: text + } + SecondaryText { + Layout.fillWidth: true + visible: Boolean(root.account) + text: root.accountLabel + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap + } + SecondaryText { + Layout.fillWidth: true + visible: root.status === "inactive" + text: "Switching resumes sync and changes who is watching on this server. You can choose a different sync account instead." + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap + } + Flow { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + visible: root.recoveryLabel.length > 0 || root.syncController.customized + spacing: Metrics.scaled(12) + ActionButton { + id: recovery + objectName: "syncRecovery" + width: Math.min(implicitWidth, parent.width) + text: root.recoveryLabel + visible: text.length > 0 + kind: "primary" + onClicked: root.recover() + } + ActionButton { + id: chooseAnother + width: Math.min(implicitWidth, parent.width) + text: "Choose another account" + kind: "secondary" + visible: Boolean(root.account) && root.accounts.length > 1 && root.recoveryLabel.length > 0 + onClicked: root.openAccountPicker() + } + ActionButton { + id: recommended + width: Math.min(implicitWidth, parent.width) + objectName: "syncRecommended" + text: "Use recommended settings" + kind: "secondary" + visible: root.syncController.customized + onClicked: root.syncController.resetSettingOverrides() + } + } + SecondaryText { + Layout.fillWidth: true + visible: root.advancedExpanded && root.syncController.problemDetail.length > 0 + text: root.syncController.problemDetail + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap + } + } + } + MenuListView { + id: list + objectName: "syncSettingsList" + Layout.fillWidth: true + Layout.fillHeight: true + model: root.rows + dismissOnBack: false + dismissOnHorizontal: false + spacing: Metrics.scaled(10) + onAccepted: index => root.activateRow(index) + delegate: Loader { + id: rowLoader + required property int index + required property string modelData + readonly property bool rowCurrent: ListView.isCurrentItem && list.activeFocus + readonly property var category: modelData.startsWith("category/") ? root.categories[Number(modelData.substring( + 9))] : null + width: list.width + sourceComponent: modelData === "enabled" || category ? toggleRow : settingRow + Component { + id: toggleRow + ToggleRow { + width: rowLoader.width + focus: false + focusPolicy: Qt.NoFocus + rowFocus: rowLoader.rowCurrent + title: rowLoader.category ? rowLoader.category.title : "Sync settings" + description: rowLoader.category ? rowLoader.category.device + ? "Usually differs between devices. Off in the recommended preset." : + rowLoader.category.enabled > 0 + && rowLoader.category.enabled + < rowLoader.category.keys.length + ? "Some settings included. Turn on to include this whole category." : + "Include this category between devices" : + "Uses one account, independent of playback" + checked: rowLoader.category ? rowLoader.category.enabled === rowLoader.category.keys.length : + root.syncController.enabled + onToggled: root.activateRow(rowLoader.index) + } + } + Component { + id: settingRow + SettingRow { + width: rowLoader.width + focus: false + focusPolicy: Qt.NoFocus + rowFocus: rowLoader.rowCurrent + title: rowLoader.modelData === "account" ? "Account" : rowLoader.modelData === "preset" + ? "What syncs" : "Sync now" + description: rowLoader.modelData === "preset" + ? "Recommended: language, look, audio and subtitle preferences. Device-specific settings stay local." : + rowLoader.modelData === "account" ? root.accountLabel : + "Changes are saved locally even while sync is paused" + valueText: rowLoader.modelData === "account" ? "Choose" : rowLoader.modelData === "preset" + ? root.advancedExpanded ? "Less" : + root.syncController.customized + ? "Custom · manage" : + "Recommended · customize" : + root.status + === "syncing" + ? "Syncing…" : + root.status + === "synced" + ? "Up to date" : + "Sync" + enabled: rowLoader.modelData !== "syncNow" || root.syncController.enabled && root.status !== "syncing" + && Boolean(root.account && root.account.connectionState === "active") + onClicked: root.activateRow(rowLoader.index) + } + } + } + } + } + Loader { + id: picker + anchors.fill: parent + active: root.choosingAccount + z: 200 + sourceComponent: OptionPickerDialog { + visible: true + title: "Choose sync account" + options: root.accounts.map(account => String(account.syncLabel) + (account.connectionState === "active" + ? "" : account.needsSignIn + ? " · sign in needed" : + " · not active")) + currentIndex: Math.max(0, root.accounts.findIndex(account => account.id === root.syncController.accountId)) + onSelected: index => { + root.choosingAccount = false + root.syncController.setAccountId(String(root.accounts[index].id)) + InputKeys.focus(list) + } + onDismissed: { + root.choosingAccount = false + InputKeys.focus(list) + } + } + } + Loader { + id: confirmation + anchors.fill: parent + active: root.syncController.accountChangePending + z: 210 + sourceComponent: ConfirmationDialog { + title: "Change settings sync account?" + message: root.syncController.accountChangeWarning + confirmText: "Change account" + onAccepted: { + root.syncController.confirmAccountChange(true) + InputKeys.focus(list) + } + onDismissed: { + root.syncController.confirmAccountChange(false) + InputKeys.focus(list) + } + } + } +} diff --git a/qml/pages/SubtitleSettingsPanel.qml b/qml/pages/SubtitleSettingsPanel.qml index 1afa3c6e..ee7949fa 100644 --- a/qml/pages/SubtitleSettingsPanel.qml +++ b/qml/pages/SubtitleSettingsPanel.qml @@ -1,8 +1,10 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Window import "../theme" import "../primitives" +import "../shell" import "SettingsNavigation.js" as SettingsNavigation FocusScope { @@ -10,6 +12,18 @@ FocusScope { property var shell property var uiTransitionToken: 0 + property var settingsController: Settings + property var syncController: typeof SettingsSync !== "undefined" ? SettingsSync : null + property var platformInfo: Platform + property bool hdrPlayback: Player.hdrPlayback + property string navigationMode: "row" + property string editingKey: "" + property bool editingChanged: false + property bool pointerEditing: false + readonly property int currentRowIndex: list.currentIndex + readonly property var reachableSettingKeys: SettingsNavigation.subtitleReachableKeys( + settingsController.settingsSchema, platformInfo, hdrPlayback, name + => settingsController.values[name]) // Over playback the real subtitles are the preview, so the panel steps // aside as a sheet instead of drawing an imitation of a film still. property bool overVideo: false @@ -25,49 +39,8 @@ FocusScope { property int pendingFocusIndex: -1 property var appearanceSnapshot: ({}) - readonly property var sections: [ - { - "title": "Size and position", - "keys": ["subtitles/scalePercent", "subtitles/verticalPositionPercent", - "subtitles/alwaysOverridePositionAndSize", "subtitles/allowInBlackBars"] - }, - { - "title": "Colour", - "keys": ["subtitles/overrideTextColor", "subtitles/textColor"] - }, - { - "title": "Which subtitles", - "keys": ["subtitles/language", "subtitles/mode"] - } - ] - readonly property var advancedSections: [ - { - "title": "Text style", - "keys": ["subtitles/styling", "subtitles/textWeight", "subtitles/font", "subtitles/dropShadow", - "subtitles/textBackground"] - }, - { - "title": "Image subtitles", - "keys": ["subtitles/recolorImageSubtitles", "subtitles/bitmapSharpnessPercent", - "subtitles/bitmapShadowEnabled"] - }, - { - "title": "Image subtitle shadow", - "keys": ["subtitles/bitmapShadowCoreSize", "subtitles/bitmapShadowCoreGrow", - "subtitles/bitmapShadowCoreOpacityPercent", "subtitles/bitmapShadowSpreadEnabled", - "subtitles/bitmapShadowSpreadSize", "subtitles/bitmapShadowSpreadGrow", "subtitles/bitmapShadowSpreadX", - "subtitles/bitmapShadowSpreadY", "subtitles/bitmapShadowSpreadOpacityPercent", - "subtitles/bitmapShadowDither"] - }, - { - "title": "HDR", - "keys": ["subtitles/hdrBrightnessPercent"] - }, - { - "title": "Start over", - "keys": ["action/resetSubtitleAppearance"] - } - ] + readonly property var sections: SettingsNavigation.subtitleSections + readonly property var advancedSections: SettingsNavigation.subtitleAdvancedSections readonly property var appearanceKeys: ["subtitles/styling", "subtitles/textWeight", "subtitles/font", "subtitles/textColor", "subtitles/overrideTextColor", "subtitles/dropShadow", "subtitles/textBackground", @@ -80,19 +53,19 @@ FocusScope { function specValue(spec) { if (spec.key === "subtitles/language") - return Settings.subtitleLanguageIndex - const value = Settings.values[spec.key] + return settingsController.subtitleLanguageIndex + const value = settingsController.values[spec.key] return value === undefined || value === null ? spec.defaultValue : value } // Desktop can offer whatever fonts are installed on top of the bundled ones. function expandedSpec(spec) { - if (!spec || spec.key !== "subtitles/font" || !Platform.hasSystemFonts) + if (!spec || spec.key !== "subtitles/font" || !platformInfo.hasSystemFonts) return spec const expanded = Object.assign({}, spec) expanded.choiceLabels = spec.choiceLabels.slice() expanded.choiceValues = spec.choiceValues.slice() - const families = Settings.systemSubtitleFonts + const families = settingsController.systemSubtitleFonts for (let index = 0; index < families.length; ++index) { expanded.choiceLabels.push("System — " + families[index]) expanded.choiceValues.push("system:" + families[index]) @@ -105,15 +78,17 @@ FocusScope { // closes, so a list that just lost rows cannot strand the selection at the // top of the page. function rebuildRows(followAdvanced) { - const schema = Settings.settingsSchema + const focusedEntry = list.entryAt(list.currentIndex) + const focusedKey = focusedEntry ? focusedEntry.rowKey : "" + const schema = settingsController.settingsSchema const byKey = {} for (let index = 0; index < schema.length; ++index) byKey[schema[index].key] = schema[index] const resolve = function (key) { const spec = byKey[key] - const available = SettingsNavigation.rowAvailable(spec, Platform, Player.hdrPlayback, function (name) { - const value = Settings.values[name] + const available = SettingsNavigation.rowAvailable(spec, platformInfo, hdrPlayback, function (name) { + const value = root.settingsController.values[name] return value === undefined ? "" : value }) return available ? root.expandedSpec(spec) : null @@ -141,16 +116,28 @@ FocusScope { } else if (followAdvanced) { pendingFocusIndex = advancedIndex } + for (let index = 0; index < visibleRows.length; ++index) { + const entry = visibleRows[index] + entry.rowKey = entry.section ? "section/" + entry.spec.title : entry.spec.key + } rows = visibleRows + SettingsNavigation.reconcileRows(rowsModel, visibleRows) + if (!followAdvanced) { + const retainedIndex = SettingsNavigation.indexForRowKey(rowsModel, focusedKey) + if (retainedIndex >= 0) + list.currentIndex = retainedIndex + else + list.clampEnabled() + } } function choiceLabels(spec) { if (spec.key === "subtitles/language") - return Settings.subtitleLanguageOptions + return settingsController.subtitleLanguageOptions // Some labels carry a "%1" placeholder for the preferred language name. if (spec.key === "subtitles/mode") { - const options = Settings.subtitleLanguageOptions - const index = Settings.subtitleLanguageIndex + const options = settingsController.subtitleLanguageOptions + const index = settingsController.subtitleLanguageIndex const word = index > 0 && index < options.length ? String(options[index]).split(" ")[0] : "your language" const result = [] for (let i = 0; i < spec.choiceLabels.length; ++i) @@ -161,12 +148,14 @@ FocusScope { } function choiceValues(spec) { - return spec.key === "subtitles/language" ? Settings.subtitleLanguageOptions : (spec.choiceValues || []) + return spec.key === "subtitles/language" ? settingsController.subtitleLanguageOptions : (spec.choiceValues + || []) + } function currentChoice(spec) { if (spec.key === "subtitles/language") - return Settings.subtitleLanguageIndex + return settingsController.subtitleLanguageIndex const values = choiceValues(spec) const value = String(specValue(spec)) for (let index = 0; index < values.length; ++index) @@ -176,10 +165,13 @@ FocusScope { } function setValue(spec, value, index) { + const before = specValue(spec) if (spec.key === "subtitles/language") - Settings.setSubtitleLanguageIndex(index) + settingsController.setSubtitleLanguageIndex(index) else - Settings.setValue(spec.key, value) + settingsController.setValue(spec.key, value) + if (editingKey === spec.key && before !== (spec.key === "subtitles/language" ? index : value)) + editingChanged = true } function setChoice(spec, index) { @@ -193,7 +185,92 @@ FocusScope { return delegate ? delegate.control : null } + function beginEdit(spec, pointer) { + if (editingKey === spec.key) + return + finishEdit() + editingKey = spec.key + editingChanged = false + pointerEditing = pointer === true + navigationMode = "value-editing" + if (syncController) + syncController.beginEdit(spec.key) + } + + function finishEdit() { + const key = editingKey + const changed = editingChanged + if (key && pointerEditing && !changed) + settingsController.previewValue(key, settingsController.values[key]) + editingKey = "" + editingChanged = false + pointerEditing = false + navigationMode = "row" + if (key && syncController) + syncController.endEdit(key, changed) + } + + function routeAction(action) { + const focused = root.Window.window ? root.Window.window.activeFocusItem : null + if (pointerEditing && InputKeys.isTextInputItem(focused)) { + if (action === "left" || action === "right") + return false + // The numeric field's existing editingFinished handler commits + // before releasing the sync edit lock. + focused.focus = false + finishEdit() + InputKeys.focus(list) + if (action === "activate" || action === "back") + return true + } + const entry = list.entryAt(list.currentIndex) + const spec = entry && !entry.section ? entry.spec : null + const previousMode = navigationMode + const result = SettingsNavigation.valueRoute(navigationMode, action, spec && (spec.type === "slider" + || spec.type === "text")) + if (previousMode === "value-editing" && result.mode !== "value-editing") + finishEdit() + navigationMode = result.mode + switch (result.effect) { + case "begin-edit": + beginEdit(spec, false) + return true + case "end-edit": + return true + case "activate": + activateRow(list.currentIndex) + return true + case "value": + return adjustRow(list.currentIndex, action === "right" ? 1 : -1) + case "move-up": + case "move-down": + if (action === "up" && list.currentIndex <= list.firstEnabled(0, 1)) { + if (!overVideo && shell) + shell.focusNavBar() + } else { + list.moveSelection(action === "up" ? -1 : 1) + } + return true + case "back": + return false + default: + return true + } + } + + function rowDescription(spec, selected) { + const description = spec ? String(spec.description || "") : "" + if (spec && spec.key === "subtitles/font" && String(specValue(spec)).startsWith("system:")) + return description + (description ? " " : "") + + "System fonts stay on this device unless explicitly included in sync." + return description + } + function focusRow(index) { + if (index !== list.currentIndex) { + finishEdit() + navigationMode = "row" + } list.currentIndex = SettingsNavigation.clampIndex(index, list.count) list.clampEnabled() InputKeys.focus(list) @@ -202,21 +279,21 @@ FocusScope { function beginReset() { const snapshot = {} for (let index = 0; index < appearanceKeys.length; ++index) - snapshot[appearanceKeys[index]] = Settings.values[appearanceKeys[index]] + snapshot[appearanceKeys[index]] = settingsController.values[appearanceKeys[index]] appearanceSnapshot = snapshot resetVisible = true } function confirmReset() { const snapshot = appearanceSnapshot - Settings.resetSubtitleAppearance() + settingsController.resetSubtitleAppearance() resetVisible = false InputKeys.focus(list) if (shell && shell.showToastAction) { shell.showToastAction("Subtitle appearance reset", "Undo", function () { for (let index = 0; index < root.appearanceKeys.length; ++index) { const key = root.appearanceKeys[index] - Settings.setValue(key, snapshot[key]) + root.settingsController.setValue(key, snapshot[key]) } }) } @@ -235,11 +312,14 @@ FocusScope { } else if (spec.type === "toggle") { setValue(spec, !Boolean(specValue(spec)), -1) } else if (spec.type === "select") { + beginEdit(spec, false) list.positionViewAtIndex(index, ListView.Contain) Qt.callLater(function () { const anchor = root.rowControlAt(index) - if (!anchor) + if (!anchor) { + root.finishEdit() return + } root.choiceRow = spec root.choiceAnchor = anchor root.choiceVisible = true @@ -267,6 +347,7 @@ FocusScope { } function closeChoice() { + finishEdit() choiceVisible = false choiceAnchor = null choiceRow = null @@ -282,6 +363,7 @@ FocusScope { back() return } + finishEdit() if (overVideo) dismissed() else if (shell && shell.back) @@ -298,6 +380,8 @@ FocusScope { closeChoice() return true } + if (navigationMode !== "row") + return routeAction("back") if (advancedExpanded) { advancedExpanded = false rebuildRows(true) @@ -315,16 +399,19 @@ FocusScope { return resetLoader.item.routeKey(key, phase, repeat) if (choiceVisible) return choiceLoader.item.routeKey(key, phase, repeat) - if (phase === "release" && InputKeys.isDirection(key)) - return true - if (InputKeys.isHorizontal(key) && adjustRow(list.currentIndex, key === Qt.Key_Right ? 1 : -1)) - return true - if (key === Qt.Key_Up && list.currentIndex <= list.firstEnabled(0, 1)) { - if (!overVideo && shell) - shell.focusNavBar() + const action = key === Qt.Key_Right ? "right" : key === Qt.Key_Left ? "left" : key === Qt.Key_Up ? "up" : key + === Qt.Key_Down + ? "down" : + InputKeys.isAccept( + key) ? "activate" : + InputKeys.isBack( + key) ? "back" : + "" + if (!action) + return false + if (phase === "release") return true - } - return list.routeKey(key, phase, repeat) + return action === "back" ? back() : routeAction(action) } function activate() { @@ -333,24 +420,35 @@ FocusScope { else if (choiceVisible) choiceLoader.item.activate() else - activateRow(list.currentIndex) + routeAction("activate") } focus: true onActiveFocusChanged: if (activeFocus) - focusRow(list.currentIndex) + focusRow(list.currentIndex) Component.onCompleted: { - Settings.loadRemote() rebuildRows() } + Component.onDestruction: finishEdit() + onVisibleChanged: if (!visible) + finishEdit() Connections { - target: Player - function onHdrPlaybackChanged() { + target: root.settingsController + ignoreUnknownSignals: true + function onSettingsValuesChanged() { + root.rebuildRows() + } + function onValuesChanged() { + root.rebuildRows() + } + function onSettingsSchemaChanged() { root.rebuildRows() } } + onHdrPlaybackChanged: Qt.callLater(root.rebuildRows) + Surface { anchors.left: list.left anchors.right: list.right @@ -388,7 +486,7 @@ FocusScope { // button up here would just be one more stop on the way to the rows. IconButton { id: closeButton - visible: !Platform.isTV + visible: !root.platformInfo.isTV anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter focusPolicy: Qt.NoFocus @@ -399,6 +497,11 @@ FocusScope { } } + ListModel { + id: rowsModel + dynamicRoles: true + } + MenuListView { id: list @@ -415,7 +518,7 @@ FocusScope { anchors.topMargin: Metrics.scaled(12) anchors.bottomMargin: inset anchors.rightMargin: inset - model: root.rows + model: rowsModel entryProvider: function (index) { return index >= 0 && index < root.rows.length ? root.rows[index] : null } @@ -427,25 +530,25 @@ FocusScope { bottomMargin: root.choiceVisible && choiceLoader.item ? choiceLoader.item.panelHeight + Metrics.scaled(16) : 0 onCountChanged: { if (root.pendingFocusIndex < 0 || count !== root.rows.length) - return + return const targetIndex = root.pendingFocusIndex root.pendingFocusIndex = -1 Qt.callLater(function () { root.focusRow(targetIndex) }) } - onAccepted: index => root.activateRow(index) + onAccepted: index => root.routeAction("activate") onEdgeUp: if (!root.overVideo && root.shell) - root.shell.focusNavBar() + root.shell.focusNavBar() delegate: Item { id: delegateItem required property int index - required property var modelData + required property var spec + required property bool section - readonly property var spec: modelData.spec - readonly property bool isSection: modelData.section === true + readonly property bool isSection: section === true readonly property Item control: rowLoader.item // The view guarantees a single current delegate; a per-row copy of // the index does not survive the model changing shape underneath @@ -467,7 +570,9 @@ FocusScope { Loader { id: rowLoader - anchors.fill: delegateItem.isSection ? undefined : parent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter active: !delegateItem.isSection // Bound rather than assigned once at load: the loaded row // reads these back through its parent, so it keeps up when @@ -518,7 +623,8 @@ FocusScope { height: sampleText.height + Metrics.scaled(12) radius: Theme.radiusSmall color: { - const value = String(Settings.values["subtitles/textBackground"] || "transparent") + const value = String(root.settingsController.values["subtitles/textBackground"] + || "transparent") return value === "opaque" ? "#ff000000" : value === "translucent" ? "#a0000000" : "transparent" } } @@ -530,16 +636,21 @@ FocusScope { text: "We can read this on either side." horizontalAlignment: Text.AlignHCenter font.family: { - const value = String(Settings.values["subtitles/font"] || "") + const value = String(root.settingsController.values["subtitles/font"] || "") if (value.indexOf("system:") === 0) - return value.slice(7) + return value.slice(7) return value === "interface" ? Typography.sans : Typography.subtitle } - font.pixelSize: Metrics.scaled(26) * Number(Settings.values["subtitles/scalePercent"] || 100) / 100 - font.weight: Settings.values["subtitles/textWeight"] === "bold" ? Font.Bold : Font.Normal - style: Settings.values["subtitles/dropShadow"] === "none" ? Text.Normal : Text.Outline + font.pixelSize: Metrics.scaled(26) * Number( + root.settingsController.values["subtitles/scalePercent"] || 100) / 100 + font.weight: root.settingsController.values["subtitles/textWeight"] === "bold" ? Font.Bold : + Font.Normal + + style: root.settingsController.values["subtitles/dropShadow"] === "none" ? Text.Normal : + Text.Outline + styleColor: "#e6000000" - color: Settings.values["subtitles/textColor"] || "white" + color: root.settingsController.values["subtitles/textColor"] || "white" } } } @@ -554,7 +665,7 @@ FocusScope { // The chevron says "this row unfolds in place" the way the select // rows do, so the submenu needs no word for it. readonly property bool isSubmenu: spec !== undefined && spec !== null && spec.type === "submenu" - width: list.width + width: parent ? parent.width : 0 focus: false focusPolicy: Qt.NoFocus rowFocus: parent ? parent.rowCurrent : false @@ -582,12 +693,12 @@ FocusScope { ToggleRow { readonly property var spec: parent ? parent.spec : null readonly property int rowIndex: parent ? parent.rowIndex : -1 - width: list.width + width: parent ? parent.width : 0 focus: false focusPolicy: Qt.NoFocus rowFocus: parent ? parent.rowCurrent : false title: spec ? spec.title : "" - description: spec ? spec.description : "" + description: root.rowDescription(spec, rowFocus) checked: spec ? Boolean(root.specValue(spec)) : false onToggled: checked => { root.focusRow(rowIndex) @@ -601,12 +712,12 @@ FocusScope { SelectRow { readonly property var spec: parent ? parent.spec : null readonly property int rowIndex: parent ? parent.rowIndex : -1 - width: list.width + width: parent ? parent.width : 0 focus: false focusPolicy: Qt.NoFocus rowFocus: parent ? parent.rowCurrent : false title: spec ? spec.title : "" - description: spec ? spec.description : "" + description: root.rowDescription(spec, rowFocus) options: spec ? root.choiceLabels(spec) : [] currentIndex: spec ? root.currentChoice(spec) : 0 onOpened: { @@ -620,20 +731,44 @@ FocusScope { Component { id: sliderComponent SliderRow { + id: sliderRow readonly property var spec: parent ? parent.spec : null readonly property int rowIndex: parent ? parent.rowIndex : -1 - width: list.width + width: parent ? parent.width : 0 selected: parent ? parent.rowCurrent : false title: spec ? spec.title : "" - description: spec ? spec.description : "" + description: root.rowDescription(spec, selected) from: spec ? Number(spec.from) : 0 to: spec ? Number(spec.to) : 100 step: spec ? Number(spec.step || 1) : 1 unitText: spec ? String(spec.unitText || "") : "" value: spec ? Number(root.specValue(spec)) : 0 - onValuePreviewed: value => Settings.previewValue(spec.key, value) - onValueEdited: value => root.setValue(spec, value, -1) - onInteractionStarted: root.focusRow(rowIndex) + onValuePreviewed: value => root.settingsController.previewValue(spec.key, value) + onValueEdited: value => { + if (root.editingKey !== spec.key) + root.beginEdit(spec, true) + root.setValue(spec, value, -1) + if (root.pointerEditing) + root.finishEdit() + } + onInteractionStarted: { + list.currentIndex = rowIndex + root.beginEdit(spec, true) + } + + Connections { + target: sliderRow.trailing[0] + function onDraggingChanged() { + if (target.dragging) + return + // Release commits synchronously; a cancelled grab has no + // commit and must release its sync edit lock as well. + Qt.callLater(function () { + if (root.pointerEditing && root.editingKey === sliderRow.spec.key) + root.finishEdit() + }) + } + } } } diff --git a/qml/primitives/ActionButton.qml b/qml/primitives/ActionButton.qml index 63873036..7e558f40 100644 --- a/qml/primitives/ActionButton.qml +++ b/qml/primitives/ActionButton.qml @@ -9,32 +9,43 @@ T.Control { property bool pointerHovered: hover.hovered property string text: "" signal clicked + readonly property color foreground: !enabled ? Theme.textMuted : kind === "primary" || kind === "blue" + ? Theme.accentText : kind === "danger" ? Theme.errorText : + Theme.textPrimary implicitWidth: Math.max(Metrics.scaled(132), buttonContent.implicitWidth + Metrics.scaled(34)) implicitHeight: Metrics.controlHeightPx focusPolicy: Metrics.keyboardFocusActive ? Qt.StrongFocus : Qt.NoFocus + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: if (enabled) + clicked() background: Rectangle { radius: Theme.radiusMedium - color: root.kind === "primary" ? (tap.pressed ? Theme.accentDim : (root.pointerHovered || ( - Metrics.keyboardFocusActive - && root.activeFocus) ? Theme.accent : - Theme.accentDim)) : - root.kind === "danger" ? (tap.pressed ? Theme.bgRaised : Theme.errorPanel) : - tap.pressed ? Theme.bgRaised : root.kind === "flat" - ? "transparent" : Theme.bgPanel + color: root.kind === "blue" ? Qt.darker(Theme.paletteBlue, !root.enabled ? 2.8 : tap.pressed ? 1.25 : 1) : + !root.enabled ? Theme.bgPanel : root.kind === "primary" ? Theme.accent : + root.kind === "danger" + ? Theme.errorPanel : + tap.pressed + ? Theme.bgRaised : + root.kind + === "flat" + ? "transparent" : + Theme.bgPanel border.width: Metrics.keyboardFocusActive && root.activeFocus ? Theme.focusBorderWidth : root.pointerHovered ? Theme.hoverBorderWidth : root.kind === "flat" ? 0 : Theme.hoverBorderWidth - border.color: Metrics.keyboardFocusActive && root.activeFocus ? Theme.textPrimary : root.pointerHovered ? Theme.borderStrong : - root.kind - === "primary" - ? Theme.accentDim : - root.kind - === "danger" - ? Theme.errorText : - Theme.border + border.color: root.kind === "blue" ? (Metrics.keyboardFocusActive && root.activeFocus ? Theme.textPrimary : Qt.darker(Theme.paletteBlue, + 1.4)) : !root.enabled + ? Theme.border : Metrics.keyboardFocusActive && root.activeFocus + ? Theme.textPrimary : root.pointerHovered ? Theme.borderStrong : + root.kind === "primary" + ? Theme.accentDim : + root.kind === "danger" + ? Theme.errorText : + Theme.border antialiasing: true } @@ -50,17 +61,17 @@ T.Control { visible: root.iconName.length > 0 name: root.iconName iconSize: Metrics.bodySizePx + 6 - iconColor: root.enabled ? Theme.textPrimary : Theme.textDisabled + iconColor: root.foreground } AppText { anchors.verticalCenter: parent.verticalCenter text: root.text - color: root.enabled ? (root.kind === "danger" ? Theme.errorText : Theme.textPrimary) : - Theme.textDisabled + color: root.foreground font.pixelSize: Metrics.bodySizePx - font.weight: root.kind === "primary" || root.kind === "danger" ? Font.DemiBold : Font.Medium + font.weight: root.kind === "primary" || root.kind === "blue" || root.kind === "danger" ? Font.DemiBold : + Font.Medium elide: Text.ElideRight maximumLineCount: 1 } @@ -69,6 +80,7 @@ T.Control { TapHandler { id: tap + enabled: root.enabled onTapped: { InputKeys.focus(root) root.clicked() diff --git a/qml/primitives/AtomicViewReveal.qml b/qml/primitives/AtomicViewReveal.qml index 4581738f..4a17cd6a 100644 --- a/qml/primitives/AtomicViewReveal.qml +++ b/qml/primitives/AtomicViewReveal.qml @@ -24,8 +24,11 @@ QtObject { } function schedule() { + // Coalesce model changes at the end of this event-loop turn. Waiting + // a full frame before the first layout adds latency even when all + // delegates are immediately available; only retries need a timer. if (enabled && (!firstDelegateReady || !delegatesReady || !artworkReady)) - updateTimer.restart() + Qt.callLater(root.update) } function update() { diff --git a/qml/primitives/ChoiceStrip.qml b/qml/primitives/ChoiceStrip.qml deleted file mode 100644 index 12c0ed0b..00000000 --- a/qml/primitives/ChoiceStrip.qml +++ /dev/null @@ -1,146 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtQuick.Templates as T -import "../theme" - -T.Control { - id: root - - property string title: "" - property string description: "" - property var options: [] - property int currentIndex: 0 - signal selected(int index) - - focusPolicy: Qt.StrongFocus - implicitHeight: Math.max(Metrics.scaled(78), implicitContentHeight + Metrics.scaled(28)) - leftPadding: Metrics.scaled(14) - rightPadding: Metrics.scaled(14) - topPadding: Metrics.scaled(14) - bottomPadding: Metrics.scaled(14) - - function move(direction) { - if (options.length <= 0) - return false - const next = Math.max(0, Math.min(options.length - 1, currentIndex + direction)) - if (next !== currentIndex) - selected(next) - return true - } - - background: Surface { - focused: root.activeFocus - hovered: hover.hovered - } - - HoverHandler { - id: hover - } - - contentItem: RowLayout { - spacing: Metrics.scaled(20) - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(3) - - AppText { - Layout.fillWidth: true - text: root.title - font.pixelSize: Metrics.bodySizePx - font.weight: Font.DemiBold - maximumLineCount: 1 - elide: Text.ElideRight - } - - AppText { - Layout.fillWidth: true - visible: text.length > 0 - text: root.description - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - maximumLineCount: 1 - elide: Text.ElideRight - } - } - - // One track holding a moving pill, rather than a row of separate - // buttons whose borders would double up where they meet. - Rectangle { - id: track - readonly property int inset: Math.max(2, Metrics.scaled(3)) - readonly property real cellWidth: root.options.length > 0 ? (width - inset * 2) / root.options.length : 0 - - Layout.preferredWidth: Math.min(root.width * 0.5, Metrics.scaled(430)) - Layout.minimumWidth: Metrics.scaled(230) - Layout.preferredHeight: Math.max(Metrics.controlHeightPx, Metrics.scaled(42)) - Layout.alignment: Qt.AlignVCenter - radius: Theme.radiusMedium - color: Theme.bgRaised - border.width: Theme.hoverBorderWidth - border.color: Theme.borderStrong - antialiasing: true - - Rectangle { - x: track.inset + track.cellWidth * root.currentIndex - y: track.inset - width: track.cellWidth - height: track.height - track.inset * 2 - radius: Theme.radiusSmall - color: root.activeFocus ? Theme.accent : Theme.accentPanel - border.width: Theme.hoverBorderWidth - border.color: Theme.accent - antialiasing: true - - Behavior on x { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 130 - easing.type: Easing.OutCubic - } - } - } - - Row { - anchors.fill: parent - anchors.margins: track.inset - - Repeater { - model: root.options - - delegate: Item { - id: cell - required property int index - required property var modelData - readonly property bool chosen: index === root.currentIndex - - width: track.cellWidth - height: track.height - track.inset * 2 - - AppText { - anchors.fill: parent - anchors.leftMargin: Metrics.scaled(6) - anchors.rightMargin: Metrics.scaled(6) - text: String(cell.modelData) - color: !cell.chosen ? Theme.textSecondary : root.activeFocus ? Theme.accentText : - Theme.textPrimary - - font.pixelSize: Metrics.metaSizePx - font.weight: cell.chosen ? Font.DemiBold : Font.Medium - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - - TapHandler { - onTapped: { - InputKeys.focus(root) - root.selected(cell.index) - } - } - } - } - } - } - } -} diff --git a/qml/primitives/ImageCard.qml b/qml/primitives/ImageCard.qml index 85780c9b..1ed27715 100644 --- a/qml/primitives/ImageCard.qml +++ b/qml/primitives/ImageCard.qml @@ -16,7 +16,7 @@ Item { === Image.Error function artworkSource(url) { - if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0) + if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0 || url.indexOf("spool-thumbnail:") === 0) return "image://artwork/" + encodeURIComponent(url) return url } diff --git a/qml/primitives/InputKeys.qml b/qml/primitives/InputKeys.qml index 45764f6d..75b7c4fe 100644 --- a/qml/primitives/InputKeys.qml +++ b/qml/primitives/InputKeys.qml @@ -142,7 +142,7 @@ QtObject { } function isMedia(key) { - return key === Qt.Key_MediaPlay || key === Qt.Key_Play + return key === Qt.Key_MediaPlay || key === Qt.Key_Play || key === Qt.Key_MediaTogglePlayPause } function isMediaNext(key) { diff --git a/qml/primitives/MediaItemCard.qml b/qml/primitives/MediaItemCard.qml index a1c12912..fcb0ec56 100644 --- a/qml/primitives/MediaItemCard.qml +++ b/qml/primitives/MediaItemCard.qml @@ -14,6 +14,7 @@ Item { property string fallbackIcon: "" property color fallbackTint: "transparent" property bool focused: false + property bool moving: false property bool showSubtitle: true property bool emphasizedTitle: false property bool useSeriesPoster: false @@ -21,6 +22,9 @@ Item { property real progress: -1 property bool artworkVisible: true property bool artworkEnabled: true + // Where the item comes from, shown over its artwork when set. + property url badgeIcon + property string badgeText: "" readonly property bool posterKind: kind === "poster" readonly property bool squareKind: kind === "square" @@ -122,6 +126,46 @@ Item { color: Theme.accent } + SourceBadge { + anchors.left: art.left + anchors.top: art.top + anchors.margins: Metrics.scaled(8) + overlay: true + maximumWidth: Math.max(0, art.width - Metrics.scaled(16)) + iconUrl: root.badgeIcon + text: root.badgeText + } + + Rectangle { + anchors.fill: parent + anchors.bottomMargin: root.height - root.focusOutlineHeight + visible: root.moving + color: "transparent" + radius: Theme.radiusMedium + border.width: Theme.focusBorderWidth + border.color: Theme.accent + } + + Rectangle { + anchors.right: art.right + anchors.bottom: art.bottom + anchors.margins: Metrics.scaled(8) + width: moveLabel.implicitWidth + Metrics.scaled(16) + height: moveLabel.implicitHeight + Metrics.scaled(8) + radius: Theme.radiusSmall + color: Theme.bgPanel + visible: root.moving + + AppText { + id: moveLabel + anchors.centerIn: parent + text: "↔ Move" + color: Theme.accent + font.pixelSize: Metrics.metaSizePx + font.weight: Font.DemiBold + } + } + AppText { id: titleLabel anchors.top: art.bottom diff --git a/qml/primitives/MediaRow.qml b/qml/primitives/MediaRow.qml index c4cb9b4c..028071fa 100644 --- a/qml/primitives/MediaRow.qml +++ b/qml/primitives/MediaRow.qml @@ -6,8 +6,13 @@ import "ModelAccess.js" as ModelAccess FocusScope { id: root + objectName: cardKind === "library" ? "libraryMediaRow" : "mediaRow" property string title: "" + // Where the row, or each of its cards, comes from, when the page says so: + // a map of iconUrl, text and (for a card) an optional detail line. + property var headerBadge: null + property var cardBadge: null property var model property var shell property string cardKind: "poster" // poster, square, landscape, library, or person @@ -27,6 +32,24 @@ FocusScope { property bool focusVisible: true property bool keyboardFocusActive: Metrics.keyboardFocusActive property int pointerPressedIndex: -1 + // Reordering is opt-in; the host owns the model and persistence. + property var moveItem: null + readonly property bool reorderEnabled: typeof moveItem === "function" + property var headerAction: null + property string headerActionText: "" + readonly property bool hasHeaderAction: typeof headerAction === "function" + property var contextMenu: null + property bool moveMode: false + property bool dragActive: false + property int dragSourceIndex: -1 + property int dragTargetIndex: -1 + property real dragPointerX: 0 + property real dragPointerY: 0 + property real dragOffsetX: 0 + property real dragOffsetY: 0 + property var dragCardData: ({}) + // Lets even the final card be positioned at the start of the viewport. + property bool allowTrailingSpace: false property int modelRevision: 0 readonly property bool delegatesPresented: presentation.delegatesReady @@ -39,32 +62,44 @@ FocusScope { readonly property int cardHeight: Math.round(cardWidth * cardAspect + Metrics.scaled(60)) readonly property int focusPadding: Math.max(2, Metrics.scaled(2)) readonly property int pageStep: Math.max(1, Math.floor((listView.width + cardGap) / (cardWidth + cardGap))) + readonly property real contentX: listView.contentX signal verticalWheelScrolled(var controller) signal pointerSelected signal activated(int index, var item) width: parent ? parent.width : implicitWidth - height: rowVisible ? headerHeight + Metrics.scaled(10) + cardHeight : 0 + height: rowVisible ? headerHeight + (count > 0 || loading || !hasHeaderAction ? Metrics.scaled(10) + cardHeight : + 0) : 0 implicitHeight: height visible: rowVisible + onVisibleChanged: if (!visible && (moveMode || dragActive)) + finishMove() + onActiveFocusChanged: if (!activeFocus && (moveMode || dragActive)) + finishMove() focus: true Component.onCompleted: resetPresentation() onAtomicPopulateChanged: Qt.callLater(resetPresentation) onModelChanged: { ++modelRevision + if (moveMode || dragActive) + finishMove() Qt.callLater(resetPresentation) } onCountChanged: { ++modelRevision currentIndex = count > 0 ? Math.max(0, Math.min(currentIndex, count - 1)) : -1 + if (moveMode || dragActive) + finishMove() // The view rewrites its currentIndex internally on model changes; // re-assert ours once it has processed them. Qt.callLater(syncViewCurrentIndex) Qt.callLater(resetPresentation) } onCurrentIndexChanged: syncViewCurrentIndex() + onReorderEnabledChanged: if (!reorderEnabled && (moveMode || dragActive)) + finishMove() // listView.currentIndex must never be a declarative binding: the view // writes the property itself (model resets, item removal), after which a @@ -86,6 +121,9 @@ FocusScope { } function onModelReset() { ++root.modelRevision + root.finishMove() + root.currentIndex = root.count > 0 ? Math.max(0, Math.min(root.currentIndex, root.count - 1)) : -1 + Qt.callLater(root.syncViewCurrentIndex) } function onRowsInserted() { ++root.modelRevision @@ -109,8 +147,11 @@ FocusScope { } function focusList() { - if (count <= 0) - return false + if (count <= 0) { + if (hasHeaderAction) + InputKeys.focus(headerButton) + return hasHeaderAction + } currentIndex = Math.max(0, Math.min(currentIndex, count - 1)) syncViewCurrentIndex() InputKeys.focus(listView) @@ -128,6 +169,16 @@ FocusScope { return true } + function positionIndexAtStart(index) { + if (count <= 0 || index < 0 || index >= count || listView.width <= 0) + return false + currentIndex = index + syncViewCurrentIndex() + listView.forceLayout() + listView.positionViewAtIndex(index, ListView.Beginning) + return true + } + function currentCard() { return listView.currentItem } @@ -148,6 +199,20 @@ FocusScope { } function routeKey(key, phase, repeat) { + if (moveMode) { + if (InputKeys.isBack(key, false)) { + if (phase !== "release") + finishMove() + return true + } + if (phase !== "release") { + if (key === Qt.Key_Left) + moveSelected(-1) + else if (key === Qt.Key_Right) + moveSelected(1) + } + return true + } if (phase === "release") return true if (key === Qt.Key_Left) @@ -158,6 +223,14 @@ FocusScope { } function activateIndex(index) { + if (hasHeaderAction && (count <= 0 || headerButton.activeFocus)) { + headerAction() + return + } + if (moveMode) { + finishMove() + return + } if (index < 0 || index >= count) return currentIndex = index @@ -183,7 +256,9 @@ FocusScope { } function longPress() { - if (cardKind === "library" || cardKind === "person" || currentIndex < 0 || !shell) + if (hasHeaderAction && (count <= 0 || headerButton.activeFocus)) + return Boolean(headerAction()) + if (cardKind === "person" || currentIndex < 0 || !shell) return false return openItemContext(currentIndex, currentCard(), true) } @@ -191,6 +266,13 @@ FocusScope { function openItemContext(index, anchor, deferBackdropDismissal) { if (!shell || index < 0 || index >= count) return false + if (typeof contextMenu === "function") + return Boolean(contextMenu(itemAt(index), anchor, { + "row": root, + "deferBackdropDismissal": Boolean(deferBackdropDismissal) + })) + if (cardKind === "library") + return false return Boolean(shell.openItemMenu(itemAt(index), anchor, { "model": model, "index": index, @@ -199,6 +281,85 @@ FocusScope { "deferBackdropDismissal": Boolean(deferBackdropDismissal) })) } + function beginMoveById(libraryId) { + for (let index = 0; index < count; ++index) { + if (String(itemAt(index).libraryId || "") === libraryId) + return beginMove(index) + } + return false + } + + function beginMove(index) { + if (!reorderEnabled || index < 0 || index >= count) + return false + pointerPressedIndex = -1 + pointerSelected() + currentIndex = index + moveMode = true + focusList() + return true + } + + function finishMove() { + moveMode = false + dragActive = false + dragSourceIndex = -1 + dragTargetIndex = -1 + pointerPressedIndex = -1 + syncViewCurrentIndex() + } + + function moveTo(from, to) { + if (!moveMode || !reorderEnabled || from < 0 || to < 0 || from >= count || to >= count) + return false + if (from !== to && !moveItem(from, to)) + return false + currentIndex = to + syncViewCurrentIndex() + listView.forceLayout() + listView.positionViewAtIndex(to, ListView.Contain) + focusList() + return true + } + + function moveSelected(direction) { + return moveTo(currentIndex, Math.max(0, Math.min(count - 1, currentIndex + direction))) + } + + function beginDrag(index, x, y, pressX, pressY) { + if (!moveMode || !reorderEnabled || index < 0 || index >= count) + return false + listView.cancelFlick() + pointerSelected() + currentIndex = index + dragSourceIndex = index + dragTargetIndex = index + dragCardData = itemAt(index) + const card = listView.itemAtIndex(index) + dragOffsetX = card ? pressX + listView.contentX - card.x : cardWidth / 2 + dragOffsetY = pressY + dragActive = true + updateDrag(x, y) + return true + } + + function updateDrag(x, y) { + dragPointerX = x + dragPointerY = y + // The nearest card stays a valid target even over gaps or an edge. + const contentPosition = Math.max(0, Math.min(listView.width, x)) + listView.contentX - listView.originX + dragTargetIndex = Math.max(0, Math.min(count - 1, Math.floor(contentPosition / (cardWidth + cardGap)))) + } + + function dropDrag() { + const from = dragSourceIndex + const to = dragTargetIndex + dragActive = false + dragSourceIndex = -1 + dragTargetIndex = -1 + pointerPressedIndex = -1 + moveTo(from, to) + } function resetPresentation() { presentation.reset() @@ -230,6 +391,7 @@ FocusScope { readonly property var cardItem: root.cardKind === "library" ? (cardData.item || ({})) : cardData readonly property bool libraryCard: root.cardKind === "library" readonly property bool personCard: root.cardKind === "person" + readonly property var badge: root.cardBadge ? root.cardBadge(cardData) : null width: root.cardWidth height: listView.height @@ -237,10 +399,11 @@ FocusScope { kind: libraryCard ? "landscape" : personCard ? "poster" : root.cardKind item: cardItem || ({}) titleOverride: libraryCard ? String(cardData.name || "") : personCard ? String(cardData.name || "") : "" - subtitleOverride: libraryCard ? String(cardData.collectionType || "") : personCard ? String(cardData.role - || cardData.type - || "") : "" - showSubtitle: !libraryCard + subtitleOverride: libraryCard ? String(badge && badge.detail || cardData.collectionType || "") : personCard + ? String(cardData.role || cardData.type || "") : "" + showSubtitle: !libraryCard || Boolean(badge && badge.detail) + badgeIcon: badge ? badge.iconUrl : "" + badgeText: badge ? String(badge.text || "") : "" emphasizedTitle: libraryCard imageOverride: libraryCard ? Art.url(cardItem, "landscape") : personCard ? Art.url(cardItem, "poster") : "" fallbackIcon: libraryCard ? Theme.libraryIcon(cardData.collectionType) : personCard ? "person" : "" @@ -248,6 +411,9 @@ FocusScope { useSeriesPoster: root.useSeriesPoster preferEpisodeTitle: root.preferEpisodeTitle focused: root.keyboardFocusActive && root.focusVisible && card.index === listView.currentIndex + moving: (root.moveMode && card.index === root.currentIndex) || (root.dragActive && card.index + === root.dragTargetIndex) + opacity: root.dragActive && card.index === root.dragSourceIndex ? 0.3 : 1 artworkVisible: true Component.onCompleted: root.schedulePresentation() @@ -258,10 +424,33 @@ FocusScope { SectionHeader { id: rowHeader anchors.left: parent.left - anchors.right: carouselButtons.visible ? carouselButtons.left : parent.right + anchors.right: moveButtons.visible ? moveButtons.left : headerButton.visible ? headerButton.left : carouselButtons.visible + ? carouselButtons.left : + parent.right + anchors.top: parent.top height: root.headerHeight - title: root.title + title: root.moveMode ? "Move · Left/Right · OK or Back to finish" : root.title + badgeIcon: root.headerBadge ? root.headerBadge.iconUrl : "" + badgeText: root.headerBadge ? String(root.headerBadge.text || "") : "" + } + + ActionButton { + id: headerButton + objectName: "showHiddenLibrariesButton" + anchors.top: parent.top + anchors.right: carouselButtons.visible ? carouselButtons.left : parent.right + height: root.headerHeight + visible: root.hasHeaderAction && !root.moveMode + text: root.headerActionText + iconName: "visibility" + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: root.headerAction() + onClicked: { + root.pointerSelected() + root.headerAction() + } } Row { @@ -270,7 +459,7 @@ FocusScope { anchors.right: parent.right height: root.headerHeight spacing: Metrics.scaled(4) - visible: root.count > root.pageStep + visible: !root.moveMode && root.count > root.pageStep IconButton { width: root.headerHeight @@ -294,6 +483,78 @@ FocusScope { onClicked: root.pageBy(1) } } + Row { + id: moveButtons + anchors.top: parent.top + anchors.right: parent.right + height: root.headerHeight + spacing: Metrics.scaled(4) + visible: root.moveMode + + IconButton { + width: root.headerHeight + height: width + iconName: "chevron_left" + accessibleName: "Move selected library left" + focusOnClick: false + Accessible.role: Accessible.Button + Accessible.name: accessibleName + Accessible.onPressAction: if (enabled) + root.moveSelected(-1) + enabled: root.currentIndex > 0 + opacity: enabled ? 1 : 0.35 + onClicked: root.moveSelected(-1) + } + + IconButton { + width: root.headerHeight + height: width + iconName: "chevron_right" + accessibleName: "Move selected library right" + focusOnClick: false + enabled: root.currentIndex < root.count - 1 + Accessible.role: Accessible.Button + Accessible.name: accessibleName + Accessible.onPressAction: if (enabled) + root.moveSelected(1) + opacity: enabled ? 1 : 0.35 + onClicked: root.moveSelected(1) + } + + ActionButton { + width: implicitWidth + height: root.headerHeight + text: "Done" + iconName: "check" + Accessible.role: Accessible.Button + Accessible.name: "Done moving " + root.title + Accessible.onPressAction: { + root.finishMove() + root.focusList() + } + onClicked: { + root.finishMove() + root.focusList() + } + } + } + + Timer { + interval: 30 + repeat: true + running: root.moveMode && root.dragActive + onTriggered: { + const edge = Math.min(Metrics.scaled(64), listView.width / 4) + const direction = root.dragPointerX < edge ? -1 : root.dragPointerX > listView.width - edge ? 1 : 0 + if (!direction) + return + const minimum = listView.originX - listView.leftMargin + const maximum = Math.max(minimum, listView.originX + listView.contentWidth - listView.width + + listView.rightMargin) + listView.contentX = Math.max(minimum, Math.min(maximum, listView.contentX + direction * Metrics.scaled(14))) + root.updateDrag(root.dragPointerX, root.dragPointerY) + } + } ListView { id: listView @@ -309,13 +570,15 @@ FocusScope { clip: true orientation: ListView.Horizontal flickableDirection: Flickable.HorizontalFlick + acceptedButtons: Qt.LeftButton boundsBehavior: Flickable.StopAtBounds + interactive: !root.moveMode flickDeceleration: Metrics.flickDecelerationPx maximumFlickVelocity: Metrics.maximumFlickVelocityPx spacing: root.cardGap cacheBuffer: root.atomicPopulate ? 0 : Math.round(root.cardWidth + root.cardGap) leftMargin: root.focusPadding - rightMargin: root.focusPadding + rightMargin: root.allowTrailingSpace ? Math.max(root.focusPadding, width - root.cardWidth) : root.focusPadding reuseItems: true model: root.model delegate: cardDelegate @@ -340,7 +603,7 @@ FocusScope { Component.onCompleted: root.syncViewCurrentIndex() onCurrentIndexChanged: if (currentIndex >= 0) - positionViewAtIndex(currentIndex, ListView.Contain) + positionViewAtIndex(currentIndex, ListView.Contain) FastWheelHandler { id: wheelHandler @@ -349,40 +612,153 @@ FocusScope { onScrolled: root.verticalWheelScrolled(wheelHandler) } - MouseArea { + Item { objectName: "mediaRowPointerArea" - property bool longPressed: false + // Like GridPointerArea, observe taps passively on the viewport so + // Flickable retains the drag and cancels activation past threshold. + parent: listView + width: listView.width + height: listView.height + z: 3 + + TapHandler { + enabled: !root.moveMode + acceptedButtons: Qt.LeftButton | Qt.RightButton + gesturePolicy: TapHandler.DragThreshold + longPressThreshold: 0.52 + property int pressedIndex: -1 + property bool pressedWhileMoving: false + property bool held: false + + onPressedChanged: { + if (pressed) { + held = false + pressedWhileMoving = listView.moving + pressedIndex = listView.indexAt(point.position.x + listView.contentX, point.position.y + + listView.contentY) + } else if (held && root.shell) { + root.shell.finishItemMenuOpeningGesture() + } + } + onTapped: (eventPoint, button) => { + if (held || pressedWhileMoving || listView.moving || pressedIndex < 0) + return + root.pointerSelected() + root.currentIndex = pressedIndex + if (button === Qt.RightButton) + root.openItemContext(pressedIndex, listView.itemAtIndex(pressedIndex)) + else + root.activateIndex(pressedIndex) + } + onLongPressed: { + if (pressedWhileMoving || listView.moving || pressedIndex < 0) + return + root.pointerSelected() + root.currentIndex = pressedIndex + held = root.openItemContext(pressedIndex, listView.itemAtIndex(pressedIndex), true) + } + } + } + + MouseArea { + id: pointerArea + objectName: "mediaRowMovePointerArea" + parent: listView + enabled: root.moveMode + property bool gestureConsumed: false + property bool menuHeld: false + property real pressX: 0 + property real pressY: 0 anchors.fill: parent z: 3 acceptedButtons: Qt.LeftButton | Qt.RightButton pressAndHoldInterval: 520 + preventStealing: root.moveMode onPressed: mouse => { - longPressed = false + gestureConsumed = false + menuHeld = false + pressX = mouse.x + pressY = mouse.y root.beginPointerSelection(listView.indexAt(mouse.x + listView.contentX, mouse.y + listView.contentY)) } - onReleased: if (longPressed && root.shell) - root.shell.finishItemMenuOpeningGesture() + onPositionChanged: mouse => { + if (!(pressedButtons & Qt.LeftButton) || !root.moveMode) + return + const dx = Math.abs(mouse.x - pressX) + const dy = Math.abs(mouse.y - pressY) + if (!root.dragActive && dy >= Qt.styleHints.startDragDistance && dy > dx) { + // Leave vertical swipes to the containing homepage Flickable. + gestureConsumed = true + root.pointerPressedIndex = -1 + } else if (!root.dragActive && !gestureConsumed && dx >= Qt.styleHints.startDragDistance && dx >= dy) { + gestureConsumed = root.beginDrag(root.pointerPressedIndex, mouse.x, mouse.y, pressX, pressY) + } else if (root.dragActive) { + root.updateDrag(mouse.x, mouse.y) + } + } + onReleased: { + if (root.dragActive) + root.dropDrag() + if (menuHeld && root.shell) + root.shell.finishItemMenuOpeningGesture() + } onCanceled: { + root.dragActive = false + root.dragSourceIndex = -1 + root.dragTargetIndex = -1 root.pointerPressedIndex = -1 - if (longPressed && root.shell) - root.shell.finishItemMenuOpeningGesture() + if (menuHeld && root.shell) + root.shell.finishItemMenuOpeningGesture() } onClicked: mouse => { + if (gestureConsumed) { + root.pointerPressedIndex = -1 + return + } const selectedIndex = root.commitPointerSelection() if (selectedIndex < 0) return - if (longPressed) { - longPressed = false - return - } - if (mouse.button === Qt.RightButton && root.shell) + if (mouse.button === Qt.RightButton) { root.openItemContext(selectedIndex, listView.itemAtIndex(selectedIndex)) - else + } else { root.activateIndex(selectedIndex) + } + } + onPressAndHold: { + if (root.dragActive || root.pointerPressedIndex < 0) + return + menuHeld = root.openItemContext(root.pointerPressedIndex, listView.itemAtIndex(root.pointerPressedIndex), + true) + gestureConsumed = menuHeld } - onPressAndHold: if (root.pointerPressedIndex >= 0 && root.shell) { - longPressed = true - root.openItemContext(root.pointerPressedIndex, listView.itemAtIndex(root.pointerPressedIndex), true) + } + + Loader { + x: Math.max(0, Math.min(listView.width - width, root.dragPointerX - root.dragOffsetX)) + y: Math.max(0, Math.min(listView.height - height, root.dragPointerY - root.dragOffsetY)) + width: root.cardWidth + height: root.cardHeight + z: 4 + active: root.dragActive + + sourceComponent: MediaItemCard { + readonly property bool libraryCard: root.cardKind === "library" + readonly property var previewData: root.dragCardData + readonly property var badge: root.cardBadge ? root.cardBadge(previewData) : null + kind: libraryCard ? "landscape" : root.cardKind + item: libraryCard ? previewData.item || ({}) : previewData + titleOverride: libraryCard ? String(previewData.name || "") : "" + subtitleOverride: libraryCard ? String(badge && badge.detail || "") : "" + showSubtitle: !libraryCard || Boolean(badge && badge.detail) + badgeIcon: badge ? badge.iconUrl : "" + badgeText: badge ? String(badge.text || "") : "" + emphasizedTitle: libraryCard + fallbackIcon: libraryCard ? Theme.libraryIcon(previewData.collectionType) : "" + fallbackTint: libraryCard ? Theme.libraryTint(previewData.name) : "transparent" + useSeriesPoster: root.useSeriesPoster + preferEpisodeTitle: root.preferEpisodeTitle + moving: true + opacity: 0.9 } } } diff --git a/qml/primitives/ProfileTile.qml b/qml/primitives/ProfileTile.qml index 9975bafa..26f05acf 100644 --- a/qml/primitives/ProfileTile.qml +++ b/qml/primitives/ProfileTile.qml @@ -1,21 +1,18 @@ import QtQuick import "../theme" -// A saved account in the picker: initial-avatar tile, name, and the server it -// belongs to. Everything it draws is derived from the account itself, so the -// picker only has to hand it the profile fields. +// An account: initial-avatar tile, name, and the server it belongs to. FocusScope { id: root property int tileSize: Metrics.scaled(152) property string username: "" property string serverName: "" - property string serverAddress: "" property bool needsSignIn: false property bool addTile: false property bool focused: activeFocus - readonly property int labelHeight: Metrics.scaled(addTile ? 34 : 66) + readonly property int labelHeight: Metrics.scaled(addTile || serverName.length === 0 ? 34 : 52) readonly property string initial: { const name = String(username).trim() @@ -48,7 +45,8 @@ FocusScope { height: root.tileSize radius: Theme.radiusLarge color: root.addTile ? Theme.bgRaised : root.avatarColor - border.width: root.focused ? Theme.focusBorderWidth : root.addTile || (hover.hovered && Metrics.pointerActive) + border.width: root.focused ? Theme.focusBorderWidth : (root.addTile || (hover.hovered + && Metrics.pointerActive)) ? Theme.hoverBorderWidth : 0 border.color: root.focused ? Theme.accent : Theme.border antialiasing: true @@ -139,17 +137,6 @@ FocusScope { maximumLineCount: 1 elide: Text.ElideRight } - - SecondaryText { - width: parent.width - visible: !root.addTile && root.serverAddress.length > 0 - text: root.serverAddress - color: Theme.textDisabled - font.pixelSize: Metrics.scaled(12) - horizontalAlignment: Text.AlignHCenter - maximumLineCount: 1 - elide: Text.ElideRight - } } MouseArea { diff --git a/qml/primitives/ProviderIcon.qml b/qml/primitives/ProviderIcon.qml new file mode 100644 index 00000000..b0bbab6b --- /dev/null +++ b/qml/primitives/ProviderIcon.qml @@ -0,0 +1,47 @@ +import QtQuick +import "../theme" + +// A provider's icon from its package, or its initial on a tint derived from +// its id while the image loads or when there is none. +Rectangle { + id: root + + property url source + property string name: "" + property string seed: name + // A Material icon for providers without an image of their own. + property string symbol: "" + + radius: Math.round(width * 0.22) + color: image.status === Image.Ready ? Theme.bgPanel : symbol ? Theme.accentDim : Theme.libraryTint(seed) + antialiasing: true + + AppText { + anchors.centerIn: parent + visible: image.status !== Image.Ready && !root.symbol + text: root.name.length > 0 ? root.name.charAt(0).toUpperCase() : "?" + color: "white" + font.pixelSize: Math.round(root.height * 0.46) + font.weight: Font.DemiBold + } + + MaterialIcon { + anchors.centerIn: parent + visible: image.status !== Image.Ready && !!root.symbol + name: root.symbol + iconColor: "white" + iconSize: Math.round(root.height * 0.56) + } + + Image { + id: image + anchors.fill: parent + anchors.margins: Math.round(root.width * 0.14) + source: root.source + sourceSize.width: width * 2 + sourceSize.height: height * 2 + fillMode: Image.PreserveAspectFit + asynchronous: true + smooth: true + } +} diff --git a/qml/primitives/SectionHeader.qml b/qml/primitives/SectionHeader.qml index b40674da..6999f172 100644 --- a/qml/primitives/SectionHeader.qml +++ b/qml/primitives/SectionHeader.qml @@ -4,17 +4,28 @@ import "../theme" Item { id: root property string title: "" - implicitHeight: titleText.implicitHeight + property url badgeIcon + property string badgeText: "" + implicitHeight: Math.max(titleText.implicitHeight, badge.visible ? badge.implicitHeight : 0) AppText { id: titleText anchors.left: parent.left - anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width - (badge.visible ? badge.width + Metrics.scaled(12) : 0)) text: root.title font.pixelSize: Metrics.bodySizePx + 4 font.weight: Font.DemiBold elide: Text.ElideRight maximumLineCount: 1 } + + SourceBadge { + id: badge + anchors.left: titleText.right + anchors.leftMargin: Metrics.scaled(12) + anchors.verticalCenter: parent.verticalCenter + iconUrl: root.badgeIcon + text: root.badgeText + } } diff --git a/qml/primitives/ServerCard.qml b/qml/primitives/ServerCard.qml index 72bf11ae..3e36db8d 100644 --- a/qml/primitives/ServerCard.qml +++ b/qml/primitives/ServerCard.qml @@ -2,53 +2,31 @@ import QtQuick import QtQuick.Layouts import "../theme" -// One server, as a list row. Discovered servers, the address being typed and -// the server already chosen all use this, so the trailing column has to carry -// a live connection attempt as well as a settled result; `tone` colours it. +// One server, as a row to pick: its name and address. FocusScope { id: root property string title: "" property string serverAddress: "" - property string detail: "" - property string status: "" - // neutral | pending | positive | negative - property string tone: "neutral" - property bool selectable: true property bool focused: activeFocus - // Rows inside a list well take their frame from the well. A row standing - // on its own is a panel and has to draw one. - property bool inset: false - - readonly property color toneColor: { - switch (tone) { - case "positive": - return Theme.online - case "pending": - return Theme.pending - case "negative": - return Theme.errorText - default: - return Theme.textSecondary - } - } signal accepted - implicitHeight: Math.max(Metrics.touchTargetPx, Metrics.scaled(detail.length > 0 ? 82 : 64)) + function activate() { + accepted() + } + + implicitHeight: Math.max(Metrics.touchTargetPx, Metrics.scaled(64)) focusPolicy: Qt.StrongFocus Rectangle { anchors.fill: parent - anchors.margins: root.inset ? Metrics.scaled(3) : 0 radius: Theme.radiusMedium - color: root.focused ? Theme.accentPanel : hover.hovered && Metrics.pointerActive ? Theme.bgHover : root.inset - ? "transparent" : - Theme.bgRaised - border.width: root.focused ? Theme.focusBorderWidth : root.inset ? 0 : 1 + color: root.focused ? Theme.accentPanel : hover.hovered && Metrics.pointerActive ? Theme.bgHover : + Theme.bgRaised + + border.width: root.focused ? Theme.focusBorderWidth : 1 border.color: root.focused ? Theme.accent : Theme.border - opacity: root.selectable ? 1 : 0.62 - antialiasing: true } RowLayout { @@ -57,33 +35,14 @@ FocusScope { anchors.rightMargin: Metrics.scaled(14) spacing: Metrics.scaled(14) - Item { - Layout.preferredWidth: Metrics.scaled(24) - Layout.preferredHeight: Metrics.scaled(24) - Layout.alignment: Qt.AlignVCenter - - MaterialIcon { - anchors.centerIn: parent - visible: root.tone !== "pending" - name: root.tone === "negative" ? "error_outline" : "dns" - iconSize: Metrics.scaled(22) - iconColor: root.focused ? Theme.accent : root.tone === "negative" ? Theme.errorText : Theme.textMuted - } - - // Reaching a server is the one thing on this screen that takes - // long enough to need saying so while it happens. - BusySpinner { - anchors.centerIn: parent - width: Metrics.scaled(18) - height: width - visible: root.tone === "pending" - color: Theme.pending - } + MaterialIcon { + name: "dns" + iconSize: Metrics.scaled(22) + iconColor: root.focused ? Theme.accent : Theme.textMuted } ColumnLayout { Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter spacing: Metrics.scaled(2) AppText { @@ -97,60 +56,17 @@ FocusScope { SecondaryText { Layout.fillWidth: true - visible: root.serverAddress.length > 0 + visible: root.serverAddress.length > 0 && root.serverAddress !== root.title text: root.serverAddress color: Theme.textMuted font.pixelSize: Metrics.metaSizePx maximumLineCount: 1 elide: Text.ElideRight } - - SecondaryText { - Layout.fillWidth: true - visible: root.detail.length > 0 - text: root.detail - color: root.tone === "negative" ? Theme.errorText : Theme.textMuted - font.pixelSize: Metrics.metaSizePx - maximumLineCount: 1 - elide: Text.ElideRight - } - } - - RowLayout { - Layout.alignment: Qt.AlignVCenter - visible: root.status.length > 0 - spacing: Metrics.scaled(7) - - Rectangle { - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: Metrics.scaled(6) - Layout.preferredHeight: Metrics.scaled(6) - radius: width / 2 - visible: root.tone === "positive" - color: root.toneColor - antialiasing: true - } - - AppText { - text: root.status - color: root.tone === "positive" ? Theme.textSecondary : root.toneColor - font.pixelSize: Metrics.metaSizePx - font.weight: Font.Medium - maximumLineCount: 1 - } - } - - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - visible: root.selectable - name: "chevron_right" - iconSize: Metrics.scaled(20) - iconColor: root.focused ? Theme.accent : Theme.textDisabled } } TapHandler { - enabled: root.selectable onTapped: root.accepted() } diff --git a/qml/primitives/SettingRow.qml b/qml/primitives/SettingRow.qml index 2f0dc260..fecdf196 100644 --- a/qml/primitives/SettingRow.qml +++ b/qml/primitives/SettingRow.qml @@ -22,6 +22,12 @@ T.Control { rightPadding: Metrics.scaled(14) topPadding: Metrics.scaled(14) bottomPadding: Metrics.scaled(14) + Accessible.role: Accessible.Button + Accessible.name: title + Accessible.description: description + (valueTextVisible && valueText.length ? ". " + valueText : "") + Accessible.focused: rowFocus + Accessible.onPressAction: if (enabled && pointerActivationEnabled) + clicked() background: Surface { focused: root.rowFocus diff --git a/qml/primitives/SourceBadge.qml b/qml/primitives/SourceBadge.qml new file mode 100644 index 00000000..9f044a59 --- /dev/null +++ b/qml/primitives/SourceBadge.qml @@ -0,0 +1,57 @@ +import QtQuick +import "../theme" + +// Where something comes from, when there is more than one place it could: +// a provider's mark beside a server's name, as a quiet pill that reads over +// artwork as well as over the page. +Rectangle { + id: root + + property url iconUrl + property string text: "" + // Over artwork the pill needs its own dark ground; on the page it does not. + property bool overlay: false + property int iconSize: Metrics.scaled(18) + property real maximumWidth: Metrics.scaled(260) + + visible: text.length > 0 + implicitWidth: content.implicitWidth + Metrics.scaled(overlay ? 16 : 14) + implicitHeight: Math.max(iconSize, label.implicitHeight) + Metrics.scaled(10) + radius: height / 2 + color: overlay ? "#C8101010" : Theme.bgPanel + border.width: 1 + border.color: overlay ? "#33FFFFFF" : Theme.border + antialiasing: true + + Row { + id: content + anchors.centerIn: parent + spacing: Metrics.scaled(7) + + Image { + anchors.verticalCenter: parent.verticalCenter + width: root.iconSize + height: root.iconSize + visible: status === Image.Ready + source: root.iconUrl + sourceSize.width: width * 2 + sourceSize.height: height * 2 + fillMode: Image.PreserveAspectFit + asynchronous: true + smooth: true + } + + AppText { + id: label + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, Math.min(implicitWidth, Metrics.scaled(220), root.maximumWidth - Metrics.scaled( + root.overlay ? 16 : 14) - root.iconSize - content.spacing)) + text: root.text + color: Theme.textPrimary + font.pixelSize: Metrics.metaSizePx + font.weight: Font.DemiBold + elide: Text.ElideRight + maximumLineCount: 1 + } + } +} diff --git a/qml/primitives/TextFieldRow.qml b/qml/primitives/TextFieldRow.qml index eb55357c..59746fd4 100644 --- a/qml/primitives/TextFieldRow.qml +++ b/qml/primitives/TextFieldRow.qml @@ -10,13 +10,22 @@ T.Control { property alias text: field.text property alias placeholderText: field.placeholderText - property alias inputMethodHints: field.inputMethodHints - property alias echoMode: field.echoMode + property int inputMethodHints: Qt.ImhNone + property int echoMode: TextInput.Normal + property bool secretVisible: false + readonly property bool secret: echoMode === TextInput.Password || echoMode === TextInput.PasswordEchoOnEdit property int enterKeyType: Qt.EnterKeyDefault property string label: "" property string accessibleName: label.length > 0 ? label : placeholderText readonly property bool editing: field.activeFocus - readonly property bool masked: field.echoMode === TextInput.Password + readonly property bool masked: secret && !secretVisible + + onTextChanged: if (!text.length) + secretVisible = false + onVisibleChanged: if (!visible) + secretVisible = false + onEchoModeChanged: secretVisible = false + KeyNavigation.right: secret && !focusEntersField ? reveal : null // Where there is no on-screen keyboard to defer, the row is a waypoint // rather than a stop: anything that focuses it — Tab, D-pad navigation, a @@ -91,10 +100,13 @@ T.Control { T.TextField { id: field Accessible.name: row.accessibleName + echoMode: row.secret ? (row.secretVisible ? TextInput.Normal : TextInput.Password) : row.echoMode + inputMethodHints: row.inputMethodHints | (row.secret ? Qt.ImhHiddenText | Qt.ImhSensitiveData + | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase : 0) EnterKey.type: row.enterKeyType anchors.fill: parent anchors.leftMargin: Metrics.scaled(15) - anchors.rightMargin: Metrics.scaled(15) + anchors.rightMargin: reveal.visible ? reveal.width + Metrics.scaled(16) : Metrics.scaled(15) anchors.topMargin: row.label.length > 0 ? Metrics.scaled(22) : Metrics.scaled(9) anchors.bottomMargin: Metrics.scaled(9) background: Item {} @@ -119,6 +131,34 @@ T.Control { onTextEdited: row.textEdited(text) onAccepted: row.accepted() + // Qt's own caret is one pixel wide, which vanishes on a television + // and is easy to lose on a dense screen. It holds steady while typing + // and blinks once the text is left alone. + cursorDelegate: Rectangle { + id: caret + property bool lit: true + width: Math.max(2, Metrics.scaled(2)) + color: Theme.accent + visible: field.activeFocus && field.selectionStart === field.selectionEnd && lit + + Timer { + id: blink + interval: 530 + repeat: true + running: field.activeFocus + onRunningChanged: caret.lit = true + onTriggered: caret.lit = !caret.lit + } + + Connections { + target: field + function onCursorPositionChanged() { + caret.lit = true + blink.restart() + } + } + } + // Templates carry the placeholder text but draw nothing for it, and a // hint is the difference between a labelled box and a guess. SecondaryText { @@ -138,4 +178,29 @@ T.Control { onClicked: row.focusField() propagateComposedEvents: true } + + IconButton { + id: reveal + anchors.right: parent.right + anchors.rightMargin: Metrics.scaled(8) + anchors.verticalCenter: parent.verticalCenter + visible: row.secret + focusOnClick: false + focusPolicy: Qt.TabFocus + iconName: row.secretVisible ? "visibility_off" : "visibility" + accessibleName: (row.secretVisible ? "Hide " : "Show ") + (row.accessibleName || "password") + Accessible.role: Accessible.Button + Accessible.name: accessibleName + Accessible.checkable: true + Accessible.checked: row.secretVisible + onClicked: row.secretVisible = !row.secretVisible + Accessible.onPressAction: clicked() + KeyNavigation.left: row.focusEntersField ? field : row + Keys.onPressed: event => { + if (InputKeys.isAccept(event.key)) { + clicked() + event.accepted = true + } + } + } } diff --git a/qml/primitives/ToggleRow.qml b/qml/primitives/ToggleRow.qml index 9d23ac04..3c81fb53 100644 --- a/qml/primitives/ToggleRow.qml +++ b/qml/primitives/ToggleRow.qml @@ -10,6 +10,11 @@ SettingRow { // The switch itself says on or off; the word next to it only repeats it. valueTextVisible: false + Accessible.role: Accessible.CheckBox + Accessible.checkable: true + Accessible.checked: checked + Accessible.onToggleAction: if (enabled) + toggle() function toggle() { animateChange = true diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 5e8b170f..3f853fd8 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -58,6 +58,43 @@ KeyRouter { restoreMode: Binding.RestoreNone } + Binding { + target: Theme + property: "accentIndex" + value: Number(Settings.values["theme/accent"]) + restoreMode: Binding.RestoreNone + } + Binding { + target: Theme + property: "reducedMotion" + value: Boolean(Settings.values["theme/reducedMotion"]) + restoreMode: Binding.RestoreNone + } + Binding { + target: Theme + property: "technicalMetadataMode" + value: String(Settings.values["theme/technicalMetadata"]) + restoreMode: Binding.RestoreNone + } + Binding { + target: Theme + property: "sideRailLabels" + value: String(Settings.values["theme/railLabels"]) + restoreMode: Binding.RestoreNone + } + Binding { + target: Theme + property: "antialiasedText" + value: Boolean(Settings.values["theme/antialiasedText"]) + restoreMode: Binding.RestoreNone + } + Binding { + target: Theme + property: "normalTextRenderType" + value: Number(Settings.values["theme/renderMode"]) + restoreMode: Binding.RestoreNone + } + Binding { target: Metrics property: "coarsePointer" @@ -123,21 +160,15 @@ KeyRouter { focus: true backspaceNavigatesInTextInput: Platform.isTV webOsScanCodes: Platform.isTV + tvOsRemote: Boolean(Platform.isTVOS) platformMayPairHolds: Platform.isAndroid && Platform.isTV readonly property string route: Router.route readonly property var routeArgs: Router.args || ({}) - property bool remoteConnectionKnown: false - property string lastRemoteTargetName: "" - onRouteChanged: { - root.exitArmedAt = 0 - if (route === "remoteControl" && !RemoteControl.targetSelected) { - Qt.callLater(function () { - if (root.route === "remoteControl" && !RemoteControl.targetSelected) - root.goHome() - }) - } - } + // Routes that stand outside browsing: no rail, no now-playing chrome. + readonly property bool setupRoute: chromeRoute === "addProvider" || chromeRoute === "providerScreen" + readonly property bool signedIn: Providers.hasAccounts || Downloads.jobs.some(job => job.state === "complete") + onRouteChanged: root.exitArmedAt = 0 // Back is how an Android app is left, and at the top of the stack there is // nowhere further to go. Nothing here ever exited, so the press did @@ -150,12 +181,7 @@ KeyRouter { readonly property bool backExitsAtRoot: Platform.isAndroid property int exitConfirmWindowMs: 3000 property double exitArmedAt: 0 - property bool diagnosticsVisible: false - property string switchUserReturnProfileId: "" - property string switchUserReturnRoute: "" - property var switchUserReturnArgs: ({}) - property bool switchUserReturnPending: false - readonly property bool canCancelSwitchUser: switchUserReturnProfileId.length > 0 + readonly property bool diagnosticsVisible: Boolean(Settings.values["shell/diagnostics"]) property bool mediaInfoVisible: false property bool itemMenuLoaded: false property int uiScaleShortcutKey: 0 @@ -163,22 +189,38 @@ KeyRouter { property int settingsShortcutKey: 0 readonly property bool itemMenuOpen: itemContextMenuLoader.item ? itemContextMenuLoader.item.opened : false readonly property bool tlsTrustPending: TlsTrust.pending + readonly property var networkConsent: Providers.networkConsent || ({}) + readonly property bool networkConsentPending: Boolean(networkConsent.id) + readonly property bool remoteGroupConfirmationPending: RemoteTargets.groupLeaveConfirmationPending + readonly property bool remoteControlsVisible: contentLayer.visible && (chromeRoute === "remoteControl" + || remoteMini.visible) + onRemoteControlsVisibleChanged: RemoteTargets.setControlsVisible(remoteControlsVisible) property var mediaInfoItem: ({}) - property bool managementOverlayVisible: false - property string managementMode: "" - property var managementItem: ({}) + // A provider screen asked for while something plays (a release picker). + property var providerOverlay: null + property var downloadsReturnFocus: null + property bool downloadsDestinationRequested: false property var personItem: ({}) property var pendingPlaybackBackItem: ({}) textInputActive: Qt.inputMethod.visible || InputKeys.isTextInputItem(root.Window.window ? root.Window.window.activeFocusItem : null) property var navigationTarget: routeStack - activeTarget: updateDialog.open ? updateDialog : tlsTrustPending ? tlsTrustDialog : managementOverlayVisible - ? managementOverlayLoader.item : itemMenuOpen - ? itemContextMenuLoader.item : - mediaInfoVisible - ? mediaInfoOverlayLoader.item : hasPlayer - && player.visible ? videoSurface : - navigationTarget + activeTarget: updateDialog.open ? updateDialog : tlsTrustPending ? tlsTrustDialog : networkConsentPending + ? networkConsentLoader.item : + providerInstallDialog.visible + ? providerInstallDialog : + remoteGroupConfirmationPending + ? remoteGroupConfirmationLoader.item : + providerOverlay + ? providerOverlayLoader.item : + Downloads.opened + ? downloadsDialogLoader.item : + itemMenuOpen + ? itemContextMenuLoader.item : + mediaInfoVisible + ? mediaInfoOverlayLoader.item : + hasPlayer && player.visible + ? videoSurface : navigationTarget backHandler: function () { return root.back() } @@ -324,42 +366,47 @@ KeyRouter { "returnRoute": root.route }) } - function onRemoteSeekPreviewRequested(positionTicks, active) { - videoSurface.showRemoteSeekPreview(Number(positionTicks) / 10000000, active) - } } Connections { - target: RemoteControl - function onTargetChanged() { - if (RemoteControl.targetSelected) { - const name = RemoteControl.selectedTargetName || "remote device" - if (!root.remoteConnectionKnown || root.lastRemoteTargetName !== name) - toast.show("Connected to " + name, toast.briefDurationMs) - root.remoteConnectionKnown = true - root.lastRemoteTargetName = name - return - } - if (!root.remoteConnectionKnown) - return - const targetName = root.lastRemoteTargetName || "remote device" - root.remoteConnectionKnown = false - root.lastRemoteTargetName = "" - if (root.route === "remoteControl") - root.goHome() - toast.show("Disconnected from " + targetName, toast.briefDurationMs) + target: Providers + function onRestoredChanged() { + if (App.initialized) + root.applyInitializedRoute() + } + function onAccountAdded() { + root.goHome() + } + function onComponentRequested(context) { + root.providerOverlay = context } } + + function resolveNetworkConsent(approved) { + if (networkConsentPending) + Providers.resolveNetworkConsent(String(networkConsent.id), approved) + Qt.callLater(function () { + if (root.activeTarget) + InputKeys.focus(root.activeTarget) + }) + } + + Component.onDestruction: { + if (root.networkConsentPending) + Providers.resolveNetworkConsent(String(root.networkConsent.id), false) + } function showToastAction(message, actionText, callback) { toast.showAction(message, actionText, callback) } function defaultRoute() { - return Session.authenticated ? "home" : "login" + if (!root.signedIn) + return "addProvider" + return "home" } function restoreRecoveredRoute() { - if (!Router.recoveryPending || !Session.authenticated) + if (!Router.recoveryPending || !root.signedIn) return false const args = root.routeArgs if (root.route === "libraryGrid") { @@ -404,33 +451,17 @@ KeyRouter { } function applyInitializedRoute() { + if (!Providers.restored) + return if (Router.recoveryPending) { if (root.restoreRecoveredRoute()) return - if (Session.authenticated) + if (root.signedIn) return } Router.reset(root.defaultRoute()) } - Connections { - target: Session - function onAuthenticatedStateChanged() { - if (Session.authenticated && root.switchUserReturnPending) { - root.completeSwitchUserReturn() - return - } - if (Session.authenticated) - root.clearSwitchUserReturn() - if (Session.authenticated && root.restoreRecoveredRoute()) - return - if (Session.authenticated) - Router.replace(root.defaultRoute()) - else - Router.reset(root.defaultRoute()) - } - } - // Every platform states its own text rendering rather than inheriting a // default, so that tuning one cannot quietly move another. // @@ -442,9 +473,8 @@ KeyRouter { // // Linux native rendering goes through the platform FreeType/fontconfig // path, including the user's antialiasing and subpixel policy. Light - // hinting keeps baselines aligned without snapping stems to whole pixels, - // which is what small labels were being coarsened by. Other desktops - // retain Qt's scalable distance-field rendering. + // hinting keeps baselines aligned without snapping stems to whole pixels. + // The durable render-mode setting owns the text rasterizer. Component.onCompleted: { // A phone is a finger until something says otherwise. The pointer // handlers above only fire once the app has been touched, so without @@ -458,20 +488,14 @@ KeyRouter { // the television's text look chiselled at a viewing distance. // Hinting the vertical metrics alone keeps the weight and the // baseline crisp while curves antialias smoothly. - Theme.normalTextRenderType = Text.NativeRendering Typography.sansHinting = Font.PreferVerticalHinting } else if (Qt.platform.os === "linux") { - Theme.normalTextRenderType = Text.NativeRendering Typography.sansHinting = Font.PreferVerticalHinting - } else { - Theme.normalTextRenderType = Text.QtRendering } // The TV keeps its two-step text entry: there the field taking focus // is what raises the on-screen keyboard, so the row stays the D-pad // target until Select is pressed. Theme.textEntryFollowsFocus = !Platform.isTV - root.remoteConnectionKnown = RemoteControl.targetSelected - root.lastRemoteTargetName = RemoteControl.targetSelected ? RemoteControl.selectedTargetName : "" if (App.initialized) root.applyInitializedRoute() } @@ -641,58 +665,23 @@ KeyRouter { } } - function switchUser() { - switchUserReturnProfileId = Session.activeProfileId - switchUserReturnRoute = route - switchUserReturnArgs = Object.assign({}, routeArgs) - switchUserReturnPending = false - Router.reset("login") - App.switchUser() - navigationTarget = routeStack - InputKeys.focus(routeStack) - } - - // The way out of a start that is not arriving: back to the server list, - // with discovery running again, without waiting for whatever is not - // answering. - function chooseServer() { - switchUser() - Qt.callLater(function () { - const page = routeStack.activeItem - if (page && page.openAddAccount) - page.openAddAccount() - }) + function openProviderScreen(context) { + if (context) + pushRoute("providerScreen", { + "context": context + }) } - function cancelSwitchUser() { - if (!canCancelSwitchUser) + function openCollectionEditor(containerId, title) { + if (!containerId || !Sources.collectionEditingAvailable(containerId)) return false - if (switchUserReturnPending) - return true - switchUserReturnPending = true - App.useProfile(switchUserReturnProfileId) - return true - } - - function completeSwitchUserReturn() { - if (!switchUserReturnPending || !Session.authenticated) - return false - const returnRoute = switchUserReturnRoute - const returnArgs = switchUserReturnArgs - clearSwitchUserReturn() - Router.reset(returnRoute, returnArgs) - navigationTarget = routeStack - InputKeys.focus(routeStack) + pushRoute("collectionEditor", { + containerId: containerId, + title: title + }) return true } - function clearSwitchUserReturn() { - switchUserReturnProfileId = "" - switchUserReturnRoute = "" - switchUserReturnArgs = ({}) - switchUserReturnPending = false - } - function releaseTextInput() { let item = root.Window.window ? root.Window.window.activeFocusItem : null while (item) { @@ -709,30 +698,42 @@ KeyRouter { return updateDialog.back() if (tlsTrustPending) return tlsTrustDialog.back() - if (textInputActive) { - releaseTextInput() + if (networkConsentPending) { + resolveNetworkConsent(false) + return true + } + if (providerInstallDialog.visible) + return true + if (remoteGroupConfirmationPending) { + RemoteTargets.confirmLeaveGroup(false) return true } - if (navBar.visible && navBar.remoteControlMenuOpen) { - navBar.closeRemoteMenu() + if (providerOverlay) { + providerOverlay.close() return true } - if (navBar.visible && navBar.syncPlayMenuOpen) { - navBar.closeSyncPlayMenu() + if (Downloads.opened) { + if (downloadsDialogLoader.item) + return downloadsDialogLoader.item.back() + Downloads.close() + return true + } + if (textInputActive) { + releaseTextInput() + return true + } + if (navBar.visible && navBar.menuOpen) { + navBar.back() return true } if (diagnosticsVisible) { - diagnosticsVisible = false + Settings.setValue("shell/diagnostics", false) return true } if (itemMenuOpen && itemContextMenuLoader.item) { itemContextMenuLoader.item.closeMenu() return true } - if (managementOverlayVisible) { - closeManagementOverlay() - return true - } if (mediaInfoVisible) { closeMediaInfo() return true @@ -756,7 +757,7 @@ KeyRouter { goHome() return true } - if (route === "home" || route === "login") + if (route === "home" || (route === "addProvider" && !root.signedIn)) return backAtRoot() if (Router.canPop) { Router.pop(route === "personDetails" ? "itemDetails" : "home") @@ -780,9 +781,8 @@ KeyRouter { } function forward() { - if (tlsTrustPending || textInputActive || (navBar.visible && (navBar.syncPlayMenuOpen - || navBar.remoteControlMenuOpen)) - || diagnosticsVisible || itemMenuOpen || managementOverlayVisible || mediaInfoVisible + if (networkConsentPending || tlsTrustPending || textInputActive || (navBar.visible && navBar.menuOpen) + || diagnosticsVisible || itemMenuOpen || providerOverlay || Downloads.opened || mediaInfoVisible || playerSessionActive) return true if (!Router.canForward) @@ -801,19 +801,49 @@ KeyRouter { } function openItemMenu(item, anchorItem, context) { - Management.loadCurrentUserPolicy() itemMenuLoaded = true return itemContextMenuLoader.item ? itemContextMenuLoader.item.openForItem(item || ({}), anchorItem || null, context || ({})) : false } + function openDownloads(itemId, returnFocus, destination) { + downloadsReturnFocus = returnFocus || navigationTarget + downloadsDestinationRequested = Boolean(destination) + Downloads.open(itemId || "") + } + + Connections { + target: Downloads + function onOpenedChanged() { + if (Downloads.opened) { + if (!root.downloadsReturnFocus) + root.downloadsReturnFocus = root.navigationTarget + } else { + const target = root.downloadsReturnFocus + root.downloadsReturnFocus = null + root.downloadsDestinationRequested = false + Qt.callLater(() => InputKeys.focus(root.providerOverlay || root.itemMenuOpen ? root.activeTarget : + target && target.visible + ? target : + root.activeTarget)) + } + } + } + + function openLibraryMenu(library, anchorItem, context) { + const options = Object.assign({}, context || ({}), { + "library": true + }) + return openItemMenu(library || ({}), anchorItem, options) + } + function finishItemMenuOpeningGesture() { if (itemContextMenuLoader.item) itemContextMenuLoader.item.finishOpeningGesture() } function restoreFocusAfterItemMenu() { - if (managementOverlayVisible || mediaInfoVisible || diagnosticsVisible || player.visible) + if (providerOverlay || Downloads.opened || mediaInfoVisible || diagnosticsVisible || player.visible) return navigationTarget = routeStack InputKeys.focus(routeStack) @@ -840,20 +870,6 @@ KeyRouter { InputKeys.focus(routeStack) } - function openManagement(mode, item) { - managementMode = mode - managementItem = item || ({}) - managementOverlayVisible = true - } - - function closeManagementOverlay() { - managementOverlayVisible = false - managementMode = "" - managementItem = ({}) - Qt.inputMethod.hide() - InputKeys.focus(routeStack) - } - function openPerson(person) { personItem = person || ({}) const personId = String(personItem.id || "") @@ -881,7 +897,7 @@ KeyRouter { } function focusNavBar() { - if (route === "login") + if (setupRoute) return const page = routeStack.activeItem if (page && page.revealHeader) @@ -905,6 +921,8 @@ KeyRouter { function globalShortcut(key, phase, repeat, modifiers) { if (phase === "press" && key !== 0) Metrics.keyboardFocusActive = true + if (networkConsentPending) + return false if (phase === "release" && key === uiScaleShortcutKey) { uiScaleShortcutKey = 0 return true @@ -960,7 +978,7 @@ KeyRouter { if (phase !== "release") return false if (control && key === Qt.Key_D) { - diagnosticsVisible = !diagnosticsVisible + Settings.setValue("shell/diagnostics", !diagnosticsVisible) return true } if (key === Qt.Key_Slash) { @@ -994,13 +1012,12 @@ KeyRouter { PointHandler { acceptedButtons: Qt.LeftButton onActiveChanged: if (active) { - if (navBar.remoteControlMenuOpen && !navBar.containsRemoteControlPoint(root, - point.position.x, - point.position.y)) + if (navBar.remoteMenuOpen && !navBar.containsRemotePoint(root, point.position.x, + point.position.y)) navBar.closeRemoteMenu(false) - if (navBar.syncPlayMenuOpen && !navBar.containsSyncPlayPoint(root, point.position.x, - point.position.y)) - navBar.closeSyncPlayMenu(false) + if (navBar.groupMenuOpen && !navBar.containsGroupPoint(root, point.position.x, + point.position.y)) + navBar.closeGroupMenu(false) } } } @@ -1039,21 +1056,20 @@ KeyRouter { // window lane; top and bottom can briefly coexist and leave the // bar stretched across the viewport. y: root.navBarAtBottom ? Math.max(0, parent.height - height) : 0 - height: root.chromeRoute === "login" ? 0 : Metrics.topBarHeightPx + height: root.setupRoute ? 0 : Metrics.topBarHeightPx edge: root.navBarAtBottom ? "bottom" : "top" - visible: root.chromeRoute !== "login" + visible: !root.setupRoute z: 1 // Same reason as the height above: the rail marks where you are, // not where you are going, so it does not blink its selection off // for the frames a page takes to arrive. + shell: root currentRoute: root.chromeRoute onActiveFocusChanged: if (activeFocus) root.navigationTarget = navBar onNavigate: r => { if (r === "home") root.goHome() - else if (r === "switchUser") - root.switchUser() else if (r === "settings") root.pushRoute("settings") else @@ -1062,17 +1078,13 @@ KeyRouter { onContentRequested: root.focusContent() } - // Space the now-playing bar takes out of the page, so content ends - // above it rather than under it. - readonly property real nowPlayingReserve: nowPlayingBar.visible ? nowPlayingBar.height : 0 - RouteStack { id: routeStack objectName: "shellRouteStack" anchors.left: parent.left anchors.right: parent.right y: root.navBarAtBottom ? 0 : navBar.height - height: Math.max(0, parent.height - navBar.height - contentLayer.nowPlayingReserve) + height: Math.max(0, parent.height - navBar.height - remoteMini.height) route: root.route shell: root startupReady: App.initialized @@ -1081,20 +1093,19 @@ KeyRouter { root.navigationTarget = routeStack } - RemoteNowPlayingBar { - id: nowPlayingBar - objectName: "shellRemoteNowPlayingBar" + RemoteNowPlaying { + id: remoteMini + objectName: "shellRemoteNowPlaying" anchors.left: parent.left anchors.right: parent.right - // Above the navigation when it sits at the bottom, against the - // viewport edge when it does not. - y: root.navBarAtBottom ? Math.max(0, navBar.y - height) : Math.max(0, parent.height - height) - z: 2 - // The remote control page is this bar in full, so it would only - // duplicate itself there. - visible: shown && root.chromeRoute !== "remoteControl" && root.chromeRoute !== "login" - enabled: visible - onOpenRequested: root.pushRoute("remoteControl") + y: routeStack.y + routeStack.height + height: visible ? implicitHeight : 0 + visible: (RemoteTargets.selectedTargetId.length > 0 || RemoteTargets.problem.length > 0) + && root.chromeRoute !== "remoteControl" && !root.setupRoute + shell: root + onOpenControls: root.pushRoute("remoteControl") + onActiveFocusChanged: if (activeFocus) + root.navigationTarget = remoteMini } } } @@ -1201,9 +1212,12 @@ KeyRouter { ActionButton { id: switchServerButton Layout.alignment: Qt.AlignHCenter - text: "Switch server" + text: "Accounts" kind: "secondary" - onClicked: root.chooseServer() + onClicked: { + startupSplash.dismissed = true + Router.reset("accounts") + } } } } @@ -1227,18 +1241,50 @@ KeyRouter { } } + // A provider's own screen over whatever is showing, such as the release + // picker a provider raises while resolving playback. Loader { - id: managementOverlayLoader + id: providerOverlayLoader anchors.fill: parent - z: 57 - active: root.managementOverlayVisible - asynchronous: true - sourceComponent: ManagementDialog { - mode: root.managementMode - item: root.managementItem - onDismissed: root.closeManagementOverlay() + z: 59 + active: root.providerOverlay !== null + sourceComponent: ProviderSurface { + context: root.providerOverlay + overlay: true + onFinished: { + root.providerOverlay = null + InputKeys.focus(root.activeTarget) + } + } + } + + Loader { + id: remoteGroupConfirmationLoader + anchors.fill: parent + z: 60 + active: root.remoteGroupConfirmationPending + sourceComponent: ConfirmationDialog { + title: "Leave watch together?" + message: "Controlling another device leaves your current watch-together group. Selecting the device does not transfer or start playback." + confirmText: "Leave and select device" + onAccepted: { + RemoteTargets.confirmLeaveGroup(true) + root.focusContent() + } + onDismissed: { + RemoteTargets.confirmLeaveGroup(false) + root.focusContent() + } } - onLoaded: item.prepare() + } + + ProviderUpdatePrompt { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: root.safeBottomPx + Metrics.scaled(24) + shown: root.chromeRoute === "home" && !root.playerHoldsScreen && App.initialized + z: 65 } Loader { @@ -1252,6 +1298,17 @@ KeyRouter { } } + Loader { + id: downloadsDialogLoader + anchors.fill: parent + z: 58.5 + active: Downloads.opened + sourceComponent: DownloadsDialog { + destinationExpanded: root.downloadsDestinationRequested + inputActive: root.activeTarget === downloadsDialogLoader.item + } + } + Loader { id: mediaInfoOverlayLoader anchors.fill: parent @@ -1339,6 +1396,12 @@ KeyRouter { onClicked: App.clearError() } } + ProviderInstallDialog { + id: providerInstallDialog + transfers: Store.transfers + z: 100 + } + UpdateDialog { id: updateDialog updater: Platform.updateController @@ -1351,6 +1414,31 @@ KeyRouter { inputKeys: InputKeys z: 250 } + Loader { + id: networkConsentLoader + anchors.fill: parent + z: 240 + active: root.networkConsentPending + sourceComponent: ConfirmationDialog { + title: root.networkConsent.kind === "lan" ? "Search local network?" : "Allow provider connection?" + message: { + const consent = root.networkConsent + const owner = String(consent.provider || "") + (consent.account ? " — " + consent.account : "") + if (consent.kind === "lan") + return owner + " wants to search nearby private networks for servers.\n\n" + + "This sends bounded, unauthenticated HTTP requests. It does not sign in " + + "or allow authenticated connections to discovered servers." + return owner + " wants permission to connect to this exact origin:\n\n" + String(consent.origin + || "") + "\n\n" + ( + consent.unencrypted + ? "This connection is unencrypted. Other people on the network may be able to read or change its traffic.\n\n" : + "") + "Allow only if you trust this destination." + } + confirmText: root.networkConsent.kind === "lan" ? "Search" : "Allow" + onAccepted: root.resolveNetworkConsent(true) + onDismissed: root.resolveNetworkConsent(false) + } + } InputLatencyWarning { anchors.fill: parent z: 90 diff --git a/qml/shell/DiagnosticsOverlay.qml b/qml/shell/DiagnosticsOverlay.qml index adc77c78..dc1e568c 100644 --- a/qml/shell/DiagnosticsOverlay.qml +++ b/qml/shell/DiagnosticsOverlay.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" @@ -8,6 +8,10 @@ Item { id: root property string route: "" property string focusedItemId: "" + // Only read behind the capability: a source without SyncPlay has no such + // singleton to report on. + readonly property var syncPlay: ProviderCapabilities.groupPlayback ? Group : null + readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false function formatBytes(bytes) { const value = Math.max(0, Number(bytes || 0)) @@ -111,27 +115,31 @@ Item { + " samples " + InputLatency.sampleCount } SecondaryText { - visible: SyncPlay.enabled + visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: "SyncPlay " + (SyncPlay.groupState || "Unknown") + (SyncPlay.groupStateReason ? " / " - + SyncPlay.groupStateReason : - "") + (SyncPlay.waitingForPlayback - ? " · waiting to play" : - "") + text: !root.syncPlayActive ? "" : "Group " + (root.syncPlay.groupState || "Unknown") + ( + root.syncPlay.groupStateReason ? " / " + + root.syncPlay.groupStateReason : + "") + (root.syncPlay.waitingForPlayback + ? " · waiting to play" : "") elide: Text.ElideRight } SecondaryText { - visible: SyncPlay.enabled + visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: "Time sync " + SyncPlay.timeSyncDevice + " offset " + root.signedMs(SyncPlay.clockOffsetMs) - + " ping " + Number(SyncPlay.pingMs || 0).toFixed(2) + " ms" + text: !root.syncPlayActive ? "" : "Time sync " + "server offset " + root.signedMs( + root.syncPlay.clockOffsetMs) + " ping " + Number(root.syncPlay.pingMs + || 0).toFixed(2) + + " ms" elide: Text.ElideRight } SecondaryText { - visible: SyncPlay.enabled + visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: "Playback drift " + (SyncPlay.playbackDiffValid ? root.signedMs(SyncPlay.playbackDiffMs) : "—") + " method " - + SyncPlay.syncMethod + " (ignore <100 ms; seek ≥400 ms)" + text: !root.syncPlayActive ? "" : "Playback drift " + (root.syncPlay.playbackDiffValid ? root.signedMs( + root.syncPlay.playbackDiffMs) : + "—") + " method " + + root.syncPlay.syncMethod + " (ignore <100 ms; seek ≥400 ms)" elide: Text.ElideRight } SecondaryText { diff --git a/qml/shell/DownloadsDialog.qml b/qml/shell/DownloadsDialog.qml new file mode 100644 index 00000000..c6365833 --- /dev/null +++ b/qml/shell/DownloadsDialog.qml @@ -0,0 +1,592 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +OverlayDialog { + id: root + objectName: "downloadsDialog" + preferredWidth: 680 + preferredHeight: 680 + padding: 20 + + property bool destinationExpanded: false + property bool inputActive: true + property bool choosingAgain: false + property string selectedJobId: "" + property var deletionJob: null + property bool cancelling: false + readonly property string itemId: Downloads.selectionItemId + readonly property var itemStatus: { + const jobs = Downloads.jobs + return itemId ? Downloads.statusFor(itemId) : ({}) + } + readonly property bool selecting: !!itemId && (!itemStatus.id || choosingAgain) + readonly property var options: selecting ? Sources.downloadOptions(itemId) : [] + readonly property var visibleJobs: itemId ? Downloads.jobs.filter(job => job.itemId === itemId) : Downloads.jobs + readonly property var selectedJob: visibleJobs.find(job => job.id === selectedJobId) || visibleJobs[Math.max(0, + list.currentIndex)] + || ({}) + readonly property bool activeJob: selectedJob.state === "preparing" || selectedJob.state === "downloading" + readonly property string selectedState: String(selectedJob.state || "") + readonly property bool folderControls: Downloads.canChooseFolder && (!Downloads.mobile || destinationExpanded) + readonly property bool editingDestination: destinationExpanded && folderControls + readonly property bool folderBrowsing: editingDestination && Platform.isWebOS + readonly property var rows: folderBrowsing ? Downloads.folderEntries(folderPath.text) : selecting ? options : + visibleJobs + + property bool reconcilingRows: false + + ListModel { + id: downloadRows + } + + // Keep delegates and selection alive while byte counts change. + function syncRows() { + const keepId = selectedJobId + const previousIndex = list.currentIndex + reconcilingRows = true + for (let index = 0; index < rows.length; ++index) { + const rowKey = selecting ? "option:" + index : String(rows[index].id) + let existing = index + while (existing < downloadRows.count && downloadRows.get(existing).entryKey !== rowKey) + ++existing + if (existing === downloadRows.count) + downloadRows.insert(index, { + entryKey: rowKey, + record: rows[index] + }) + else { + if (existing !== index) + downloadRows.move(existing, index, 1) + downloadRows.setProperty(index, "record", rows[index]) + } + } + if (downloadRows.count > rows.length) + downloadRows.remove(rows.length, downloadRows.count - rows.length) + const selectedIndex = selecting ? -1 : rows.findIndex(job => job.id === keepId) + list.currentIndex = rows.length > 0 ? selectedIndex >= 0 ? selectedIndex : Math.max(0, Math.min(previousIndex, + rows.length + - 1)) : -1 + selectedJobId = !selecting && list.currentIndex >= 0 ? rows[list.currentIndex].id : "" + reconcilingRows = false + } + + function conciseError(error) { + return String(error || "").replace(/\s+/g, " ").trim().slice(0, 160) + } + + function bytes(value) { + let amount = Math.max(0, Number(value) || 0) + const units = ["B", "KiB", "MiB", "GiB", "TiB"] + let unit = 0 + while (amount >= 1024 && unit < units.length - 1) { + amount /= 1024; + ++unit + } + return (unit === 0 ? Math.round(amount) : amount.toFixed(1)) + " " + units[unit] + } + + function statusText(job) { + const received = bytes(job.received) + switch (job.state) { + case "preparing": + return "Preparing download…" + case "downloading": + return Number(job.total) > 0 ? Math.min(100, Math.floor(Number(job.received) / Number(job.total) * 100)) + + "% · " + received + " of " + bytes(job.total) : received + + " received · Downloading…" + case "complete": + return "Downloaded · " + received + case "failed": + return "Failed" + case "cancelled": + return "Cancelled" + default: + return "" + } + } + + function focusEntry() { + if (!inputActive) + return + if (destinationExpanded && folderControls) { + InputKeys.focus(chooseFolderButton) + } else if (selecting && options.length === 1) { + InputKeys.focus(downloadButton) + } else if (list.count > 0) { + list.currentIndex = Math.max(0, Math.min(list.currentIndex, list.count - 1)) + if (!selecting && visibleJobs[list.currentIndex]) + selectedJobId = visibleJobs[list.currentIndex].id + InputKeys.focus(list) + } else { + InputKeys.focus(closeButton) + } + } + + function focusJobAction() { + if (!inputActive) + return + InputKeys.focus(activeJob ? cancelButton : selectedJob.state === "complete" ? playButton : selectedJob.state + === "failed" || selectedJob.state + === "cancelled" ? retryButton : + closeButton) + } + + function chooseDestination() { + if (!Downloads.canChooseFolder) + return + if (Downloads.mobile) { + Downloads.chooseFolder() + } else if (Platform.isWebOS) { + destinationExpanded = true + Qt.callLater(() => folderPath.focusRow()) + } else if (folderDialog.item) { + folderDialog.item.open() + } else { + folderDialog.active = true + } + } + + function savePath() { + const path = folderPath.text.trim() + Downloads.setDestination(path.startsWith("/") ? "file://" + path.split("/").map(encodeURIComponent).join("/") : + path) + } + + function routeKey(key, phase, repeat) { + if (deletionJob) + return deletionDialog.item ? deletionDialog.item.routeKey(key, phase, repeat) : true + const current = root.Window.window ? root.Window.window.activeFocusItem : null + if (InputKeys.isBack(key, false, false)) { + if (phase === "release") + back() + return true + } + if (InputKeys.isAccept(key)) + return !InputKeys.isTextInputItem(current) + if (!InputKeys.isDirection(key)) + return false + if (InputKeys.isTextInputItem(current)) + return false + if (phase !== "press") + return true + if (list.activeFocus && (key === Qt.Key_Up || key === Qt.Key_Down)) { + const nextIndex = list.currentIndex + (key === Qt.Key_Down ? 1 : -1) + if (nextIndex >= 0 && nextIndex < list.count) { + list.currentIndex = nextIndex + list.positionViewAtIndex(nextIndex, ListView.Contain) + return true + } + } + if (current) { + const forward = key === Qt.Key_Down || key === Qt.Key_Right + const next = current.nextItemInFocusChain(forward) + if (next && root.contains(root.mapFromItem(next, 0, 0))) + InputKeys.focus(next) + } + return true + } + + function activate() { + if (deletionJob) { + if (deletionDialog.item) + deletionDialog.item.activate() + return + } + const current = root.Window.window ? root.Window.window.activeFocusItem : null + if (list.activeFocus && list.currentItem) + list.currentItem.activated() + else if (current && typeof current.activate === "function") + current.activate() + else if (current && typeof current.clicked === "function") + current.clicked() + } + + function back() { + if (deletionJob) { + deletionJob = null + Qt.callLater(() => InputKeys.focus(cancelling ? cancelButton : removeButton)) + } else if (folderPath.editing) { + Qt.inputMethod.hide() + InputKeys.focus(chooseFolderButton) + return true + } else if (choosingAgain) { + choosingAgain = false + Qt.callLater(focusEntry) + } else if (editingDestination) { + destinationExpanded = false + Qt.callLater(focusEntry) + } else { + Downloads.close() + } + return true + } + + onDismissed: back() + onInputActiveChanged: if (inputActive) + Qt.callLater(focusEntry) + onRowsChanged: Qt.callLater(syncRows) + onSelectingChanged: Qt.callLater(function () { + syncRows() + focusEntry() + }) + onSelectedStateChanged: { + const current = root.Window.window ? root.Window.window.activeFocusItem : null + if (current === cancelButton || current === retryButton || current === playButton) + Qt.callLater(focusJobAction) + } + Component.onCompleted: { + syncRows() + Qt.callLater(focusEntry) + } + + AppText { + Layout.fillWidth: true + text: root.editingDestination ? "Download folder" : root.selecting ? "Download" : "Downloads" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + } + + SecondaryText { + Layout.fillWidth: true + text: root.editingDestination + ? "Folder changes apply only to new downloads. Existing files stay where they are." : root.selecting + ? "Choose the version to save. Converted versions are prepared by your server." : + "Keep Spool open to finish downloads. Downloaded files are available offline." + wrapMode: Text.WordWrap + } + + SecondaryText { + Layout.fillWidth: true + visible: !!Downloads.problem + text: root.conciseError(Downloads.problem) + color: Theme.errorText + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + Accessible.name: text + } + + SecondaryText { + Layout.fillWidth: true + visible: !Downloads.mobile || root.destinationExpanded + text: "Save new downloads to: " + Downloads.destination + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideMiddle + Accessible.name: "Save new downloads to " + Downloads.destination + } + + SecondaryText { + Layout.fillWidth: true + visible: !root.editingDestination && root.selecting && root.options.length === 1 + text: visible ? String(root.options[0].label) : "" + wrapMode: Text.WordWrap + } + + ActionButton { + id: downloadButton + visible: !root.editingDestination && root.selecting && root.options.length === 1 + text: "Download" + kind: "primary" + onClicked: { + Downloads.start(root.itemId, root.options[0]) + root.choosingAgain = false + } + } + + SecondaryText { + Layout.fillWidth: true + visible: Downloads.mobile && !Downloads.canChooseFolder + text: "Stored in Spool on this device." + wrapMode: Text.WordWrap + } + + Flow { + Layout.fillWidth: true + visible: root.folderControls + spacing: Metrics.scaled(8) + ActionButton { + id: chooseFolderButton + text: "Choose folder…" + iconName: "folder" + onClicked: root.chooseDestination() + } + ActionButton { + text: "Use default folder" + kind: "flat" + onClicked: Downloads.resetDestination() + } + } + + TextFieldRow { + id: folderPath + Layout.fillWidth: true + visible: root.folderControls && root.destinationExpanded && Platform.isWebOS + label: "Download folder path" + placeholderText: "/media/usb/Spool" + text: Downloads.destination + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoPredictiveText + onAccepted: root.savePath() + } + + ActionButton { + visible: folderPath.visible + text: "Use this folder" + enabled: !!folderPath.text.trim() + onClicked: root.savePath() + } + + Repeater { + model: root.folderControls && root.destinationExpanded && Downloads.mobile ? Downloads.destinationChoices : [] + delegate: ActionButton { + required property var modelData + Layout.fillWidth: true + text: String(modelData.label) + onClicked: Downloads.setDestination(modelData.url) + } + } + + SecondaryText { + Layout.fillWidth: true + visible: root.destinationExpanded && Downloads.mobile + text: "Folder changes apply only to new downloads. App storage is removed when you uninstall Spool." + wrapMode: Text.WordWrap + } + + ListView { + id: list + objectName: "downloadsList" + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.folderBrowsing || (!root.editingDestination && (!root.selecting || root.options.length > 1)) + activeFocusOnTab: visible && count > 0 + clip: true + focus: true + keyNavigationEnabled: false + model: downloadRows + spacing: Metrics.scaled(8) + currentIndex: 0 + onCurrentIndexChanged: { + if (!root.reconcilingRows && !root.selecting && root.visibleJobs[currentIndex]) + root.selectedJobId = root.visibleJobs[currentIndex].id + } + delegate: MenuRow { + id: downloadRow + required property int index + required property var record + readonly property var modelData: record + readonly property bool active: modelData.state === "preparing" || modelData.state === "downloading" + readonly property bool unknown: active && (modelData.state === "preparing" || Number(modelData.total) <= 0) + width: list.width + rowHeight: Metrics.scaled(root.folderBrowsing ? 56 : root.selecting ? 76 : active ? 100 : 80) + label: root.folderBrowsing || root.selecting ? String(modelData.label) : String(modelData.title) + detail: root.folderBrowsing ? "" : root.selecting ? modelData.mode === "original" ? "Original file" : + "Converted by server" : + [root.statusText( + modelData), String( + modelData.quality + || "")].filter( + Boolean).join(" · ") + iconName: root.folderBrowsing ? "folder" : root.selecting ? "download" : modelData.state === "complete" + ? "download_done" : "download" + highlighted: list.activeFocus && list.currentIndex === index + Accessible.description: detail + onHovered: list.currentIndex = index + onActivated: { + list.currentIndex = index + InputKeys.focus(list) + if (root.folderBrowsing) { + folderPath.text = modelData.path + list.currentIndex = 0 + Qt.callLater(() => InputKeys.focus(list)) + } else if (root.selecting) { + Downloads.start(root.itemId, modelData) + root.choosingAgain = false + } else { + root.selectedJobId = modelData.id + Qt.callLater(root.focusJobAction) + } + } + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Metrics.scaled(12) + height: Metrics.scaled(4) + radius: height / 2 + color: Theme.border + visible: downloadRow.active && !downloadRow.unknown + Accessible.role: Accessible.ProgressBar + Accessible.name: root.statusText(downloadRow.modelData) + Accessible.description: "Download progress" + Rectangle { + width: parent.width * Math.max(0, Math.min(1, Number(downloadRow.modelData.received) / Math.max(1, Number( + downloadRow.modelData.total)))) + height: parent.height + radius: parent.radius + color: Theme.accent + } + } + BusySpinner { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Metrics.scaled(10) + width: Metrics.scaled(18) + height: width + visible: downloadRow.unknown + Accessible.role: Accessible.ProgressBar + Accessible.name: root.statusText(downloadRow.modelData) + running: visible && !Theme.reducedMotion + color: Theme.textMuted + } + } + ListScrollBar { + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + flickable: list + } + } + + SecondaryText { + Layout.fillWidth: true + visible: !root.editingDestination && list.count === 0 + text: root.selecting ? "No download versions are available for this item." : + "Nothing downloaded yet. Use Download… in an item's menu." + wrapMode: Text.WordWrap + } + + SecondaryText { + Layout.fillWidth: true + visible: !root.editingDestination && !root.selecting && !!root.selectedJob.error + text: root.conciseError(root.selectedJob.error) + color: Theme.errorText + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + Accessible.name: text + } + + Flow { + Layout.fillWidth: true + spacing: Metrics.scaled(8) + ActionButton { + id: cancelButton + visible: !root.editingDestination && !root.selecting && root.activeJob + text: "Cancel download" + onClicked: { + if (Number(root.selectedJob.received) > 0) { + root.cancelling = true + root.deletionJob = root.selectedJob + } else { + Downloads.cancel(root.selectedJob.id) + } + } + } + ActionButton { + id: retryButton + visible: !root.editingDestination && !root.selecting && (root.selectedJob.state === "failed" + || root.selectedJob.state === "cancelled") + text: "Retry" + onClicked: Downloads.retry(root.selectedJob.id) + } + ActionButton { + visible: !root.editingDestination && !root.selecting && !!root.itemId && root.selectedJob.state === "failed" + text: "Choose another version" + onClicked: root.choosingAgain = true + } + ActionButton { + id: playButton + visible: !root.editingDestination && !root.selecting && root.selectedJob.state === "complete" + text: "Play offline" + iconName: "play_arrow" + kind: "primary" + onClicked: { + const offlineId = Downloads.offlineItemId(root.selectedJob.id) + if (offlineId) { + Downloads.close() + App.playItemId(offlineId) + } + } + } + ActionButton { + id: removeButton + visible: !root.editingDestination && !root.selecting && !!root.selectedJob.id && !root.activeJob + text: root.selectedJob.state === "complete" ? "Delete downloaded file" : "Remove" + kind: root.selectedJob.state === "complete" ? "danger" : "flat" + onClicked: { + if (root.selectedJob.state === "complete") { + root.cancelling = false + root.deletionJob = root.selectedJob + } else { + Downloads.remove(root.selectedJob.id) + Qt.callLater(root.focusEntry) + } + } + } + ActionButton { + id: closeButton + text: root.editingDestination ? "Done" : "Close" + kind: "flat" + onClicked: { + if (root.editingDestination) { + root.destinationExpanded = false + Qt.callLater(root.focusEntry) + } else { + Downloads.close() + } + } + } + } + + Loader { + id: folderDialog + active: false + source: active ? Qt.resolvedUrl("../pages/DesktopFolderDialog.qml") : "" + onLoaded: { + item.title = "Choose download folder" + item.open() + } + } + + Connections { + target: folderDialog.item + function onFolderSelected(folder) { + Downloads.setDestination(folder) + root.focusEntry() + } + function onDismissed() { + root.focusEntry() + } + } + + Loader { + id: deletionDialog + parent: root + anchors.fill: parent + z: 10 + active: root.deletionJob !== null + sourceComponent: ConfirmationDialog { + title: root.cancelling ? "Cancel download?" : "Delete downloaded file?" + message: root.cancelling ? root.bytes(root.deletionJob ? root.deletionJob.received : 0) + + " downloaded so far will be discarded." : "Delete “" + String(root.deletionJob + ? root.deletionJob.title : + "") + "” from this device? The server copy is not changed." + confirmText: root.cancelling ? "Cancel download" : "Delete file" + destructive: true + onAccepted: { + if (root.cancelling) + Downloads.cancel(root.deletionJob.id) + else + Downloads.remove(root.deletionJob.id) + root.deletionJob = null + Qt.callLater(root.focusEntry) + } + onDismissed: root.back() + } + } +} diff --git a/qml/shell/SyncPlayMenu.qml b/qml/shell/GroupMenu.qml similarity index 60% rename from qml/shell/SyncPlayMenu.qml rename to qml/shell/GroupMenu.qml index 3c9487e9..a9cc2239 100644 --- a/qml/shell/SyncPlayMenu.qml +++ b/qml/shell/GroupMenu.qml @@ -5,14 +5,12 @@ import QtQuick.Layouts import "../theme" import "../primitives" -// Dropdown menu for the top-bar SyncPlay button. Mirrors jellyfin-web's -// SyncPlayMenu: when in a group it shows the active group and a Leave action; -// otherwise it lists joinable groups and an option to create a new one. +// The top bar's watch-together menu. In a group it shows who is in it and a +// way out; otherwise the groups every account can see, and a way to start one. FocusScope { id: menu property bool menuOpen: false - readonly property var syncPlay: SyncPlay property var entries: [] property int currentIndex: 0 signal requestClose @@ -21,11 +19,6 @@ FocusScope { implicitHeight: panel.implicitHeight height: implicitHeight - function defaultGroupName() { - const name = Session.username ? String(Session.username) : "" - return (name.length > 0 ? name : "My") + "'s group" - } - function isActionable(entry) { return entry && (entry.kind === "join" || entry.kind === "create" || entry.kind === "leave") } @@ -37,54 +30,67 @@ FocusScope { return 0 } + function people(count) { + return count + (count === 1 ? " person" : " people") + } + function buildEntries() { const out = [] - if (!syncPlay) - return out - if (syncPlay.enabled) { - // The server reports distinct usernames, not sessions, so several - // devices signed in as one person count once. - const memberCount = syncPlay.participantCount || 0 - const statusParts = [memberCount + " user" + (memberCount === 1 ? "" : "s")] - if ((syncPlay.groupState || "").length > 0) - statusParts.push(syncPlay.groupState) - statusParts.push(syncPlay.socketConnected ? Math.round(syncPlay.pingMs) + " ms" : "Reconnecting") + if (Group.enabled) { + const parts = [people(Group.participantCount)] + if (Group.groupState.length > 0) + parts.push(Group.groupState) + if (Group.pingMs > 0) + parts.push(Math.round(Group.pingMs) + " ms") out.push({ kind: "current", - label: syncPlay.currentGroupName || "SyncPlay group", - sub: statusParts.join(" / "), + label: Group.currentGroupName, + sub: parts.join(" · "), icon: "groups" }) + const names = Group.participants.join(", ") + if (names.length > 0) + out.push({ + kind: "info", + label: names, + sub: "", + icon: "person" + }) out.push({ kind: "leave", - label: "Leave group", + label: "Leave", sub: "", icon: "logout" }) - } else { - const groups = syncPlay.groups || [] - for (let i = 0; i < groups.length; ++i) { - const group = groups[i] || ({}) - const count = group.Participants ? group.Participants.length : 0 - out.push({ - kind: "join", - groupId: group.GroupId || "", - label: group.GroupName || "Group", - sub: count + " user" + (count === 1 ? "" : "s"), - icon: "login" - }) - } - if (groups.length === 0) - out.push({ - kind: "empty", - label: "No groups available", - sub: "", - icon: "block" - }) + return out + } + const groups = Group.groups || [] + const accounts = {} + for (const group of groups) { + accounts[group.accountId] = group.accountLabel out.push({ - kind: "create", - label: "Create a new group", + kind: "join", + groupId: group.id, + label: group.name || "Group", + sub: people((group.participants || []).length) + " · " + group.accountLabel, + icon: "login" + }) + } + if (groups.length === 0) + out.push({ + kind: "info", + label: Group.loadingGroups ? "Looking for groups…" : "No groups yet", sub: "", + icon: Group.loadingGroups ? "hourglass_empty" : "block" + }) + for (const source of Providers.accounts) { + if (!source.running || !source.enabled) + continue + out.push({ + kind: "create", + accountId: source.id, + label: "New group", + sub: source.label + " · " + source.providerName, icon: "add" }) } @@ -92,6 +98,7 @@ FocusScope { } function openMenu() { + Group.refreshGroups() entries = buildEntries() currentIndex = firstActionable(entries) menuOpen = true @@ -100,7 +107,6 @@ FocusScope { return list.currentIndex = currentIndex InputKeys.focus(list) - list.forceActiveFocus(Qt.PopupFocusReason) }) } @@ -110,19 +116,14 @@ FocusScope { function activateEntry(index) { const entry = entries[index] - if (!entry || !syncPlay) + if (!entry) return - if (entry.kind === "join") { - syncPlay.joinGroup(entry.groupId) - // The device that is playing is the one the group has to contain, - // so the target joins on its own behalf rather than being driven - // from here. - if (RemoteControl.targetSelected) - RemoteControl.requestSyncPlayJoin(entry.groupId) - } else if (entry.kind === "create") - syncPlay.createGroup(defaultGroupName()) + if (entry.kind === "join") + Group.joinGroup(entry.groupId) + else if (entry.kind === "create") + Group.createGroup(entry.accountId, "Watch together") else if (entry.kind === "leave") - syncPlay.leaveGroup() + Group.leaveGroup() else return requestClose() @@ -142,7 +143,7 @@ FocusScope { } Connections { - target: menu.syncPlay + target: Group enabled: menu.menuOpen function onGroupsChanged() { menu.entries = menu.buildEntries() @@ -152,9 +153,6 @@ FocusScope { menu.entries = menu.buildEntries() menu.currentIndex = menu.firstActionable(menu.entries) } - function onConnectionChanged() { - menu.entries = menu.buildEntries() - } } Surface { @@ -181,7 +179,7 @@ FocusScope { AppText { Layout.fillWidth: true Layout.leftMargin: 4 - text: "SyncPlay" + text: "Watch together" color: Theme.textMuted font.pixelSize: 12 font.weight: Font.DemiBold @@ -204,12 +202,11 @@ FocusScope { delegate: MenuRow { required property int index required property var modelData - readonly property bool rowActionable: menu.isActionable(modelData) width: list.width label: modelData.label || "" detail: modelData.sub || "" iconName: modelData.icon || "" - actionable: rowActionable + actionable: menu.isActionable(modelData) highlighted: ListView.isCurrentItem && list.activeFocus checkIconName: "" onHovered: list.currentIndex = index diff --git a/qml/shell/ItemContextMenu.qml b/qml/shell/ItemContextMenu.qml index 4a21831a..438aeb48 100644 --- a/qml/shell/ItemContextMenu.qml +++ b/qml/shell/ItemContextMenu.qml @@ -6,6 +6,7 @@ import "../primitives" FocusScope { id: root + objectName: libraryContext ? "libraryContextMenu" : "itemContextMenu" property var item: ({}) property var anchorItem: null @@ -17,10 +18,22 @@ FocusScope { property bool playedState: Boolean(item && item.played) property bool opened: false property bool backdropDismissArmed: false + property int actionsRequest: -1 + property var providerActions: [] + property bool actionsLoading: false + property string actionsProblem: "" + readonly property string containerId: String(context && context.containerId || "") + readonly property bool libraryContext: Boolean(context.library) + property bool showingHiddenLibraries: false + readonly property string entryId: String(context && context.entryId || item && (item.playlistItemId || item.entryId) + || "") + readonly property string editorContainerId: itemType === "Playlist" || itemType === "BoxSet" ? itemId : containerId signal closed readonly property int windowWidth: root.Window.window ? root.Window.window.width : 1920 readonly property int menuEdgeMargin: Math.max(12, Metrics.gapPx) - readonly property int menuRowHeight: Math.max(Metrics.touchTargetPx, Metrics.scaled(36)) + readonly property int menuRowHeight: Math.max(Metrics.touchTargetPx, Metrics.scaled(menuOptions.some(option => Boolean( + option.reason)) + ? 54 : 36)) readonly property int menuPanelWidth: Math.min(windowWidth - menuEdgeMargin * 2, Math.max(280, Math.min(320, Math.round( windowWidth @@ -42,10 +55,6 @@ FocusScope { readonly property bool inCollection: currentViewKind === "boxset" || currentViewKind === "collection" readonly property bool collectionEligible: actionable && (itemType === "Movie" || itemType === "Series" || itemType === "Episode") - readonly property bool canManagePlaylists: Management.currentUserCanManagePlaylists - readonly property bool canManageCollections: Management.currentUserCanManageCollections - readonly property bool canRenameItem: Management.currentUserCanRenameItems - readonly property bool canDeleteItem: Management.currentUserCanDeleteItems visible: opened focus: opened @@ -63,6 +72,48 @@ FocusScope { } } + Connections { + target: Sources + function onItemActionsReady(requestId, actions, problem) { + if (!root.opened || requestId !== root.actionsRequest) + return + const selected = root.menuOptions[root.menuIndex] + root.providerActions = actions + root.actionsLoading = false + root.actionsProblem = problem + root.rebuildMenu() + const index = selected ? root.menuOptions.findIndex(option => option.action === selected.action) : -1 + root.menuIndex = Math.max(0, index) + Qt.callLater(root.positionMenu) + } + function onExtensionSupportChanged(accountId) { + if (root.opened && !root.libraryContext) + root.loadProviderActions() + } + } + + Connections { + target: Libraries + enabled: root.opened && root.libraryContext + function onHiddenLibrariesChanged() { + root.rebuildMenu() + root.menuIndex = Math.max(0, Math.min(root.menuIndex, root.menuOptions.length - 1)) + Qt.callLater(root.positionMenu) + } + function onModelReset() { + if (!root.showingHiddenLibraries) + root.closeMenu() + } + } + + function loadProviderActions() { + providerActions = [] + actionsProblem = "" + actionsLoading = true + actionsRequest = Sources.requestItemActions(itemId, itemType, containerId, entryId) + rebuildMenu() + } + function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)) } @@ -78,6 +129,53 @@ FocusScope { function rebuildMenu() { const options = [] + if (libraryContext) { + if (showingHiddenLibraries) { + for (const library of Libraries.hiddenLibraries) { + const origin = Sources.originOf(String(library.libraryId)) + options.push({ + action: "unhide", + libraryId: String(library.libraryId), + icon: "visibility", + label: "Show " + String(library.name || "library"), + reason: Sources.multipleSources && origin ? String(origin.serverName + || origin.providerName || "") : + "" + }) + } + if (options.length === 0) + options.push({ + action: "empty", + icon: "check", + label: "No hidden libraries", + enabled: false + }) + options.push({ + action: "done", + icon: "check", + label: "Done" + }) + } else { + if (context.row) + options.push({ + action: "moveLibrary", + icon: "swap_horiz", + label: "Move" + }) + options.push({ + action: "hideLibrary", + icon: "visibility_off", + label: "Hide library" + }) + options.push({ + action: "hiddenLibraries", + icon: "visibility", + label: "Show hidden libraries" + }) + } + menuOptions = options + return true + } if (continueWatchingContext && actionable) options.push({ action: "details", @@ -143,60 +241,36 @@ FocusScope { label: favoriteState ? "Remove favourite" : "Add favourite", checked: favoriteState }) - if (canManagePlaylists && queueable) - options.push({ - action: "playlist", - icon: "playlist_add", - label: "Add to playlist", - checked: false - }) - if (canManageCollections && collectionEligible) + if (Downloads.supported && queueable && (Downloads.statusFor(itemId).id || Sources.downloadOptions( + itemId).length > 0)) options.push({ - action: "collection", - icon: "library_add", - label: "Add to collection", + action: "download", + icon: "download", + label: "Download…", checked: false }) - if (inPlaylist && item.playlistItemId) { + if (editorContainerId && Sources.collectionEditingAvailable(editorContainerId)) options.push({ - action: "moveUp", - icon: "keyboard_arrow_up", - label: "Move up", + action: "collectionEditor", + icon: "edit", + label: "Manage entries", checked: false }) + for (const action of providerActions) options.push({ - action: "moveDown", - icon: "keyboard_arrow_down", - label: "Move down", + action: "provider:" + action.id, + icon: action.icon || "more_horiz", + label: action.label, + enabled: action.enabled !== false, + reason: action.reason || "", checked: false }) + if (actionsLoading || actionsProblem) options.push({ - action: "removeParent", - icon: "remove_circle", - label: "Remove from playlist", - checked: false - }) - } else if (inCollection && canManageCollections) { - options.push({ - action: "removeParent", - icon: "remove_circle", - label: "Remove from collection", - checked: false - }) - } - if ((itemType === "Playlist" && canManagePlaylists) || canRenameItem) - options.push({ - action: "rename", - icon: "drive_file_rename_outline", - label: "Rename", - checked: false - }) - if (canDeleteItem) - options.push({ - action: "delete", - icon: "delete", - label: "Delete", - checked: false + action: "providerStatus", + icon: "more_horiz", + label: actionsLoading ? "Loading provider actions…" : actionsProblem, + enabled: false }) } if (itemType !== "Series" && itemType !== "Season" && item && (item.movieId || item.id || item.title || item.displayTitle @@ -229,6 +303,10 @@ FocusScope { item = nextItem || ({}) anchorItem = anchor || null context = nextContext || ({}) + showingHiddenLibraries = Boolean(context.showHidden) + providerActions = [] + actionsProblem = "" + actionsLoading = true syncItemState() if (!rebuildMenu()) return false @@ -240,6 +318,8 @@ FocusScope { backdropArmTimer.restart() } opened = true + if (!libraryContext) + loadProviderActions() InputKeys.focus(menuList) Qt.callLater(positionMenu) return true @@ -258,6 +338,10 @@ FocusScope { backdropArmTimer.stop() backdropDismissArmed = false opened = false + if (!libraryContext) + Sources.cancelItemActions() + actionsRequest = -1 + providerActions = [] item = ({}) anchorItem = null context = ({}) @@ -268,7 +352,36 @@ FocusScope { function activateMenuIndex(index) { if (index < 0 || index >= menuOptions.length) return + if (menuOptions[index].enabled === false) + return const action = menuOptions[index].action + if (libraryContext) { + const libraryId = String(item.libraryId || "") + if (action === "hiddenLibraries") { + showingHiddenLibraries = true + rebuildMenu() + menuIndex = 0 + Qt.callLater(positionMenu) + return + } + if (action === "unhide") { + Libraries.showLibrary(String(menuOptions[index].libraryId)) + return + } + if (action === "moveLibrary") { + const row = context.row + closeMenu() + Qt.callLater(() => { + if (row) + row.beginMoveById(libraryId) + }) + return + } + if (action === "hideLibrary") + Libraries.hideLibrary(libraryId) + closeMenu() + return + } if (action === "details") { shell.openDetailsAt(context.model, Number(context.index || 0), "resume", String(context.returnRoute || "home")) @@ -300,14 +413,17 @@ FocusScope { } else if (action === "favorite") { favoriteState = !favoriteState ItemState.setFavorite(itemId, favoriteState) - } else if (action === "playlist" || action === "collection") { - shell.openManagement(action, item) - } else if (action === "removeParent") { - shell.openManagement("remove", item) - } else if (action === "moveUp" || action === "moveDown") { - Management.movePlaylistItemInCurrent(item, action === "moveUp" ? -1 : 1) - } else if (action === "rename" || action === "delete") { - shell.openManagement(action, item) + } else if (action === "download") { + shell.openDownloads(itemId, anchorItem) + } else if (action === "collectionEditor") { + shell.openCollectionEditor(editorContainerId, itemType === "Playlist" || itemType === "BoxSet" ? String( + item.title + || "") : String( + context.containerTitle + || Browse.title + || "")) + } else if (action.startsWith("provider:")) { + Sources.runItemAction(action.slice(9), itemId, itemType, containerId, entryId) } else if (action === "info") { shell.openMediaInfo(item) } @@ -345,6 +461,7 @@ FocusScope { PopupMenuPanel { id: menuPanel + objectName: root.libraryContext ? "libraryContextMenuPanel" : "itemContextMenuPanel" width: root.menuPanelWidth open: root.opened openHeight: root.menuPanelHeight @@ -352,6 +469,7 @@ FocusScope { MenuListView { id: menuList + objectName: "contextMenuList" anchors.fill: parent anchors.margins: 6 model: root.menuOptions @@ -367,6 +485,8 @@ FocusScope { label: modelData.label || "" iconName: modelData.icon || "more_horiz" checked: Boolean(modelData.checked) + actionable: modelData.enabled !== false + detail: modelData.reason || "" highlighted: ListView.isCurrentItem rowHeight: root.menuRowHeight compact: true diff --git a/qml/shell/KeyRouter.qml b/qml/shell/KeyRouter.qml index c4a717d9..f2c533fd 100644 --- a/qml/shell/KeyRouter.qml +++ b/qml/shell/KeyRouter.qml @@ -9,6 +9,7 @@ FocusScope { property bool textInputActive: false property bool backspaceNavigatesInTextInput: false property bool webOsScanCodes: false + property bool tvOsRemote: false property var backHandler: null property var globalHandler: null property int longPressInterval: 520 @@ -186,6 +187,11 @@ FocusScope { } function normalizedKey(event) { + // Qt's UIKit plugin reports Siri Remote Back/Menu as Menu. Normalize + // both phases so a handled Back cannot leak its release to UIKit. + // An unhandled root Back remains unaccepted for the system launcher. + if (tvOsRemote && event.key === Qt.Key_Menu) + return Qt.Key_Back if (webOsScanCodes && event.key === 0) { // LG's Wayland stack can lose the Qt key on physical remote // releases while retaining the XKB scan code. Recover directions diff --git a/qml/shell/ManagementDialog.qml b/qml/shell/ManagementDialog.qml deleted file mode 100644 index 9f6d8294..00000000 --- a/qml/shell/ManagementDialog.qml +++ /dev/null @@ -1,198 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" - -OverlayDialog { - id: root - - property string mode: "" - property var item: ({}) - property string nameDraft: "" - property int targetIndex: 0 - readonly property var targets: mode === "collection" ? Management.collectionTargets : Management.playlistTargets - readonly property string heading: mode === "playlist" ? "Add to playlist" : mode === "collection" - ? "Add to collection" : mode === "rename" ? "Rename" : mode - === "delete" - ? "Delete item" : - Browse.viewKind - === "playlist" - ? "Remove from playlist" : - "Remove from collection" - - function itemTitle(value) { - return String(value && (value.displayTitle || value.title || value.seriesName || value.name) || "Selected item") - } - - function focusNameField() { - InputKeys.focus(managementNameField) - managementNameField.focusField() - } - - function prepare() { - targetIndex = 0 - nameDraft = mode === "rename" ? itemTitle(item) : "" - if (mode === "playlist" || mode === "collection") - Management.refreshTargets(mode) - if (mode === "rename") - Qt.callLater(focusNameField) - else - Qt.callLater(function () { - InputKeys.focus(root) - }) - } - - function submitName(name) { - const value = String(name || "").trim() - if (value.length === 0) - return - if (mode === "playlist") - Management.createPlaylistForItem(value, item) - else if (mode === "collection") - Management.createCollectionForItem(value, item) - else - Management.renameItem(item, value) - dismissed() - } - - function selectTarget(index) { - const target = targets[index] || ({}) - const targetId = String(target.movieId || target.id || "") - if (targetId.length === 0) - return - if (mode === "playlist") - Management.addItemToPlaylist(targetId, item) - else - Management.addItemToCollection(targetId, item) - dismissed() - } - - function confirm() { - if (mode === "delete") - Management.deleteItem(item) - else - Management.removeItemFromCurrentParent(item) - dismissed() - } - - function routeKey(key, phase, repeat) { - if (mode !== "playlist" && mode !== "collection") - return false - if (key === Qt.Key_Up && targetIndex === 0) { - focusNameField() - return true - } - targetIndex = Math.max(0, Math.min(targets.length - 1, targetIndex + (key === Qt.Key_Down ? 1 : key - === Qt.Key_Up ? -1 : - 0))) - return InputKeys.isHorizontal(key) || key === Qt.Key_Up || key === Qt.Key_Down - } - - function activate() { - if (mode === "playlist" || mode === "collection") - selectTarget(targetIndex) - else if (mode === "rename") - submitName(nameDraft) - else - confirm() - } - - function back() { - dismissed() - return true - } - - AppText { - Layout.fillWidth: true - text: root.heading - color: Theme.textPrimary - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - } - - AppText { - Layout.fillWidth: true - text: root.itemTitle(root.item) - color: Theme.textMuted - font.pixelSize: Metrics.bodySizePx - elide: Text.ElideRight - visible: root.mode.length > 0 - } - - TextFieldRow { - id: managementNameField - Layout.fillWidth: true - visible: root.mode === "playlist" || root.mode === "collection" || root.mode === "rename" - label: root.mode === "rename" ? "Name" : (root.mode === "collection" ? "New collection name" : - "New playlist name") - text: root.nameDraft - onTextEdited: value => root.nameDraft = value - onAccepted: root.submitName(root.nameDraft) - } - - AppText { - Layout.fillWidth: true - visible: root.mode === "delete" || root.mode === "remove" - text: root.mode === "delete" ? "This permanently deletes the item from the server." : - "This removes the item from the current playlist or collection." - color: Theme.textMuted - font.pixelSize: Metrics.bodySizePx - wrapMode: Text.Wrap - } - - Repeater { - model: root.mode === "playlist" || root.mode === "collection" ? root.targets.length : 0 - delegate: Rectangle { - required property int index - Layout.fillWidth: true - height: Metrics.controlHeightPx - radius: Theme.radiusMedium - color: root.targetIndex === index ? Theme.focusedFill : "transparent" - border.width: root.targetIndex === index ? Theme.focusBorderWidth : Theme.hoverBorderWidth - border.color: root.targetIndex === index ? Theme.accent : Theme.border - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Metrics.scaled(14) - anchors.rightMargin: Metrics.scaled(14) - spacing: Metrics.scaled(12) - MaterialIcon { - name: root.mode === "collection" ? "collections_bookmark" : "playlist_play" - iconColor: Theme.textPrimary - iconSize: Metrics.iconSizePx - } - AppText { - Layout.fillWidth: true - text: root.itemTitle(root.targets[index] || ({})) - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - elide: Text.ElideRight - } - } - - MouseArea { - anchors.fill: parent - onClicked: root.selectTarget(index) - } - } - } - - RowLayout { - Layout.fillWidth: true - visible: root.mode === "delete" || root.mode === "remove" - Item { - Layout.fillWidth: true - } - ActionButton { - text: "Cancel" - onClicked: root.dismissed() - } - ActionButton { - text: root.mode === "delete" ? "Delete" : "Remove" - kind: "primary" - onClicked: root.confirm() - } - } -} diff --git a/qml/shell/ProviderActionPicker.qml b/qml/shell/ProviderActionPicker.qml new file mode 100644 index 00000000..311e4005 --- /dev/null +++ b/qml/shell/ProviderActionPicker.qml @@ -0,0 +1,265 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Spool + +FocusScope { + id: root + property var provider + property Component remoteControls: null + property string pinLabel: "Account PIN" + property string pinExplanation: "Cancelling keeps your current account active." + property var sortChoices: [] + readonly property string kind: provider ? String(provider.arguments.kind || "") : "" + readonly property bool choosing: kind === "playlist" || kind === "collection" + readonly property bool naming: choosing || kind === "rename" || kind === "renameCollection" + readonly property var downloadVariants: kind === "download" && provider ? provider.arguments.variants || [] : [] + property bool busy: false + property bool exhausted: true + property string cursor: "" + property string error: "" + function loadTargets(append) { + if (busy) + return + busy = true + provider.requestList("targets", { + kind: kind, + itemId: provider.arguments.itemId, + playlistType: provider.arguments.playlistType || "video", + cursor: append ? cursor : undefined + }, append).then(result => { + cursor = String(result.cursor || "") + exhausted = result.exhausted !== false + busy = false + }, () => { + busy = false + error = "Couldn't load destinations. Check your connection and permissions." + }) + } + function submitPin() { + const value = pin.text + pin.text = "" + provider.complete({ + pin: value + }) + } + function activate() { + const item = Window.activeFocusItem + if (item && typeof item.activate === "function") + item.activate() + else if (item && typeof item.clicked === "function") + item.clicked() + } + function routeKey(key, phase, repeat) { + if (kind !== "download") + return false + if (InputKeys.isBack(key, false, false)) { + if (phase === "release") + provider.close() + return true + } + if (InputKeys.isAccept(key)) + return true + if (!InputKeys.isDirection(key)) + return false + if (phase === "press" && (key === Qt.Key_Up || key === Qt.Key_Down)) { + if (downloadList.activeFocus) { + const next = downloadList.currentIndex + (key === Qt.Key_Down ? 1 : -1) + if (next >= 0 && next < downloadList.count) { + downloadList.currentIndex = next + downloadList.positionViewAtIndex(next, ListView.Contain) + } else { + InputKeys.focus(cancelButton) + } + } else if (downloadList.count > 0) { + InputKeys.focus(downloadList) + } + } + return true + } + Component.onCompleted: { + if (choosing) + loadTargets(false) + if (kind === "rename" || kind === "renameCollection") + name.text = String(provider.arguments.title || "") + Qt.callLater(() => { + if (kind === "homePin") + pin.focusRow() + else if (kind === "download") + InputKeys.focus(downloadList.count > 0 ? downloadList : cancelButton) + else if (choosing) + InputKeys.focus(list) + else if (naming) + name.focusRow() + else if (kind === "collectionSort" && sortButtons.count) + InputKeys.focus(sortButtons.itemAt(0)) + else if (kind === "remoteControls" && remoteLoader.item) + InputKeys.focus(remoteLoader.item) + else + InputKeys.focus(cancelButton) + }) + } + ColumnLayout { + anchors.fill: root.kind === "homePin" ? undefined : parent + anchors.centerIn: root.kind === "homePin" ? parent : undefined + anchors.margins: root.kind === "homePin" ? 0 : Metrics.pageMarginPx + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(520)) + height: root.kind === "homePin" ? implicitHeight : parent.height - Metrics.pageMarginPx * 2 + spacing: Metrics.scaled(12) + AppText { + Layout.fillWidth: true + text: ({ + download: "Choose version", + playlist: "Add to playlist", + collection: "Add to collection", + rename: "Rename", + renameCollection: "Rename collection", + collectionSort: "Collection order", + confirm: "Delete from the server?", + remoteControls: "Device controls", + homePin: "Unlock " + String(root.provider.arguments.title || "account") + })[root.kind] || "" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + SecondaryText { + Layout.fillWidth: true + visible: !!root.error + text: root.error + color: Theme.errorText + wrapMode: Text.WordWrap + } + Loader { + id: remoteLoader + Layout.fillWidth: true + Layout.fillHeight: true + visible: active + active: root.kind === "remoteControls" + sourceComponent: root.remoteControls + } + TextFieldRow { + id: pin + Layout.fillWidth: true + visible: root.kind === "homePin" + label: root.pinLabel + echoMode: TextInput.Password + inputMethodHints: Qt.ImhDigitsOnly | Qt.ImhNoPredictiveText + onAccepted: root.submitPin() + } + SecondaryText { + Layout.fillWidth: true + visible: root.kind === "homePin" + text: root.pinExplanation + wrapMode: Text.WordWrap + } + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.choosing + clip: true + model: root.provider ? root.provider.rows : null + keyNavigationEnabled: true + delegate: MenuRow { + required property var record + required property int index + width: ListView.view.width + label: record.title + iconName: root.kind === "collection" ? "video_library" : "playlist_play" + highlighted: ListView.isCurrentItem && list.activeFocus + onHovered: list.currentIndex = index + onActivated: root.provider.complete({ + targetId: record.id, + targetName: record.title + }) + } + function activate() { + if (currentItem) + currentItem.activated() + } + } + ListView { + id: downloadList + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.kind === "download" + clip: true + model: root.downloadVariants + keyNavigationEnabled: true + delegate: MenuRow { + required property var modelData + required property int index + width: downloadList.width + label: String(modelData.label || "") + detail: String(modelData.detail || "") + iconName: "download" + highlighted: ListView.isCurrentItem && downloadList.activeFocus + onHovered: downloadList.currentIndex = index + onActivated: root.provider.complete({ + variantId: modelData.id + }) + } + function activate() { + if (currentItem) + currentItem.activated() + } + } + ActionButton { + visible: root.choosing && !root.exhausted + enabled: !root.busy + text: root.busy ? "Loading…" : "Load more" + onClicked: root.loadTargets(true) + } + Repeater { + id: sortButtons + model: root.kind === "collectionSort" ? root.sortChoices : [] + delegate: ActionButton { + required property var modelData + text: modelData.label + onClicked: root.provider.complete({ + sort: modelData.value + }) + } + } + TextFieldRow { + id: name + Layout.fillWidth: true + visible: root.naming + label: root.choosing ? "New " + root.kind : "Name" + onAccepted: { + if (text.trim()) + root.provider.complete({ + newName: text.trim() + }) + } + } + RowLayout { + Layout.alignment: Qt.AlignRight + ActionButton { + id: cancelButton + text: root.kind === "remoteControls" ? "Close" : "Cancel" + kind: "flat" + onClicked: root.provider.close() + } + ActionButton { + visible: root.kind === "homePin" + text: "Unlock" + kind: "primary" + onClicked: root.submitPin() + } + ActionButton { + id: confirm + visible: root.kind === "confirm" || root.naming && !!name.text.trim() + enabled: !root.busy + text: root.kind === "confirm" ? "Delete" : root.choosing ? "Create" : "Save" + kind: root.kind === "confirm" ? "danger" : "primary" + onClicked: root.provider.complete(root.kind === "confirm" ? { + confirmed: true + } : { + newName: name.text.trim() + }) + } + } + } +} diff --git a/qml/shell/ProviderCodePanel.qml b/qml/shell/ProviderCodePanel.qml new file mode 100644 index 00000000..d5e54bd2 --- /dev/null +++ b/qml/shell/ProviderCodePanel.qml @@ -0,0 +1,94 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Spool + +// A sign-in code to type on another device. Instructions sit below its box; +// the address can be followed wherever there is a browser, and the code can +// be copied wherever there is a clipboard. +ColumnLayout { + id: root + property string code: "" + property string instructions: "" + property string linkUrl: "" + property bool copied: false + readonly property string linkText: linkUrl.replace(/^https?:\/\//i, "").replace(/\/+$/, "") + // A television has neither a browser worth sending someone to nor a + // clipboard; it only needs the address spelled out. + readonly property bool interactive: !Platform.isTV + + onCodeChanged: copied = false + spacing: Metrics.scaled(14) + + AppText { + Layout.fillWidth: true + visible: !!root.linkUrl + text: root.linkText + elide: Text.ElideRight + color: Theme.accent + font.pixelSize: Metrics.scaled(30) + font.weight: Font.DemiBold + font.underline: root.interactive && linkHover.hovered + HoverHandler { + id: linkHover + enabled: root.interactive + cursorShape: Qt.PointingHandCursor + } + TapHandler { + enabled: root.interactive + onTapped: Qt.openUrlExternally(root.linkUrl) + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Metrics.scaled(6) + spacing: Metrics.scaled(12) + + Surface { + Layout.fillWidth: true + implicitHeight: codeText.implicitHeight + Metrics.scaled(36) + baseColor: Theme.accentPanel + AppText { + id: codeText + anchors.centerIn: parent + text: root.code + font.pixelSize: Metrics.scaled(56) + font.weight: Font.Bold + font.letterSpacing: Metrics.scaled(14) + } + } + + ActionButton { + Layout.alignment: Qt.AlignVCenter + visible: root.interactive + iconName: root.copied ? "check" : "content_copy" + text: root.copied ? "Copied" : "Copy" + onClicked: { + clipboard.copyText(root.code) + root.copied = true + } + } + } + AppText { + Layout.fillWidth: true + visible: !!root.instructions + text: root.instructions + wrapMode: Text.WordWrap + color: Theme.textSecondary + font.pixelSize: Metrics.scaled(22) + lineHeight: 1.15 + } + + // Qt Quick has no clipboard of its own; a text editor's does. + TextEdit { + id: clipboard + visible: false + function copyText(value) { + text = value + selectAll() + copy() + text = "" + } + } +} diff --git a/qml/shell/ProviderCompatibilityNotice.qml b/qml/shell/ProviderCompatibilityNotice.qml new file mode 100644 index 00000000..c2d25f95 --- /dev/null +++ b/qml/shell/ProviderCompatibilityNotice.qml @@ -0,0 +1,9 @@ +import QtQuick +import Spool + +AppText { + property var provider + visible: !!provider && !!provider.missingHostExtensions && provider.missingHostExtensions.length > 0 + text: "Update Spool to use all features of this provider." + wrapMode: Text.WordWrap +} diff --git a/qml/shell/ProviderInstallDialog.qml b/qml/shell/ProviderInstallDialog.qml new file mode 100644 index 00000000..a736e5ab --- /dev/null +++ b/qml/shell/ProviderInstallDialog.qml @@ -0,0 +1,87 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +OverlayDialog { + id: root + required property var transfers + visible: transfers.length > 0 + focus: visible + preferredWidth: 620 + + function routeKey(key, phase, repeat) { + return true + } + function activate() { + } + function back() { + return true + } + + AppText { + Layout.fillWidth: true + text: "Installing providers" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + } + Repeater { + model: root.transfers + delegate: ColumnLayout { + id: transfer + required property var modelData + Layout.fillWidth: true + spacing: Metrics.scaled(10) + readonly property bool downloading: modelData.state === "downloading" + readonly property bool determinate: downloading && modelData.total > 0 + readonly property real fraction: determinate ? Math.min(1, Math.max(0, modelData.received + / modelData.total)) : 0 + + AppText { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: (transfer.downloading ? "Downloading " : "Verifying and installing ") + transfer.modelData.name + + "…" + Accessible.role: Accessible.StaticText + Accessible.name: text + } + RowLayout { + Layout.fillWidth: true + BusySpinner { + visible: !transfer.determinate + Layout.preferredWidth: Metrics.scaled(28) + Layout.preferredHeight: Metrics.scaled(28) + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Metrics.scaled(10) + radius: height / 2 + color: Theme.bgRaised + clip: true + visible: transfer.determinate + Accessible.role: Accessible.ProgressBar + Accessible.name: transfer.modelData.name + Accessible.description: Math.round(transfer.fraction * 100) + "%" + Rectangle { + width: parent.width * transfer.fraction + height: parent.height + radius: parent.radius + color: Theme.accent + } + } + AppText { + visible: transfer.determinate + text: Math.round(transfer.fraction * 100) + "%" + } + } + } + } + AppText { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: "This closes automatically when installation finishes." + color: Theme.textSecondary + } +} diff --git a/qml/shell/ProviderLinkScreen.qml b/qml/shell/ProviderLinkScreen.qml new file mode 100644 index 00000000..717509aa --- /dev/null +++ b/qml/shell/ProviderLinkScreen.qml @@ -0,0 +1,191 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Spool + +FocusScope { + id: root + property var provider + property string title: "" + property string instructions: "" + property string code: "" + property string error: "" + property bool busy: false + property bool pinRequired: false + property string pinLabel: "PIN" + property alias pinText: pin.text + property var choices: [] + property bool retryVisible: true + property string backText: "Back" + // Where the code is entered, such as https://plex.tv/link. A desktop + // opens it once a code is ready; a phone offers it as a link. + property string linkUrl: "" + // Small print, such as trademark notices, kept apart from the steps. + property string footnote: "" + property bool linkOpened: false + readonly property bool desktop: !Platform.isTV && !Platform.isAndroid + onCodeChanged: { + if (code && linkUrl && desktop && !linkOpened) { + linkOpened = true + Qt.openUrlExternally(linkUrl) + } + } + signal retryRequested + signal backRequested + signal choiceSelected(int index) + signal pinSubmitted(string value) + function focusChoices() { + InputKeys.focus(choicesList) + } + function focusPin() { + pin.focusRow() + } + function submitPin() { + const value = pin.text + pin.text = "" + pinSubmitted(value) + } + function activate() { + const item = Window.activeFocusItem + if (item && typeof item.activate === "function") + item.activate() + else if (item && typeof item.clicked === "function") + item.clicked() + else if (pin.activeFocus) + submitPin() + } + ColumnLayout { + anchors.centerIn: parent + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(720)) + height: Math.min(implicitHeight, parent.height - Metrics.pageMarginPx * 2) + spacing: Metrics.scaled(16) + AppText { + Layout.fillWidth: true + Layout.bottomMargin: Metrics.scaled(4) + text: root.title + font.pixelSize: Metrics.scaled(40) + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + AppText { + Layout.fillWidth: true + visible: !root.code && !!root.instructions + text: root.instructions + color: Theme.textSecondary + font.pixelSize: Metrics.scaled(20) + wrapMode: Text.WordWrap + } + ProviderCodePanel { + Layout.fillWidth: true + visible: !!root.code + code: root.code + instructions: root.linkUrl ? (root.instructions || "On your phone or computer, go to") : root.instructions + linkUrl: root.linkUrl + } + RowLayout { + Layout.fillWidth: true + visible: !!root.code && !root.error + spacing: Metrics.scaled(10) + BusySpinner { + Layout.preferredWidth: Metrics.scaled(20) + Layout.preferredHeight: Metrics.scaled(20) + running: parent.visible + } + SecondaryText { + Layout.fillWidth: true + text: "Waiting for you to enter the code…" + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.WordWrap + } + } + ListView { + id: choicesList + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: Math.min(contentHeight, root.height * 0.45) + visible: root.choices.length > 0 + clip: true + model: root.choices + keyNavigationEnabled: true + spacing: Metrics.scaled(8) + delegate: ServerCard { + required property var modelData + required property int index + width: ListView.view.width + title: modelData.title || modelData.name || "" + serverAddress: modelData.address || "" + enabled: !root.busy + onAccepted: root.choiceSelected(index) + } + function activate() { + if (currentItem) + currentItem.accepted() + } + } + TextFieldRow { + id: pin + Layout.fillWidth: true + visible: root.pinRequired + label: root.pinLabel + echoMode: TextInput.Password + inputMethodHints: Qt.ImhDigitsOnly | Qt.ImhNoPredictiveText + onAccepted: root.submitPin() + } + ActionButton { + Layout.fillWidth: true + visible: root.pinRequired + text: "Unlock" + kind: "primary" + enabled: !root.busy + onClicked: root.submitPin() + } + BusySpinner { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: Metrics.scaled(24) + Layout.preferredHeight: Metrics.scaled(24) + running: root.busy + visible: running + } + SecondaryText { + Layout.fillWidth: true + visible: !!root.error + text: root.error + color: Theme.errorText + wrapMode: Text.WordWrap + } + Flow { + Layout.fillWidth: true + Layout.topMargin: Metrics.scaled(8) + spacing: Metrics.scaled(12) + ActionButton { + visible: !!root.code && !!root.linkUrl && root.desktop + kind: "primary" + iconName: "open_in_new" + text: "Open " + root.linkUrl.replace(/^https?:\/\//i, "").replace(/\/+$/, "") + onClicked: Qt.openUrlExternally(root.linkUrl) + } + ActionButton { + visible: root.retryVisible + iconName: "refresh" + text: "Get a new code" + enabled: !root.busy + onClicked: root.retryRequested() + } + ActionButton { + visible: !!root.backText + text: root.backText + kind: "flat" + onClicked: root.backRequested() + } + } + SecondaryText { + Layout.fillWidth: true + Layout.topMargin: Metrics.scaled(12) + visible: !!root.footnote + text: root.footnote + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + wrapMode: Text.WordWrap + } + } +} diff --git a/qml/shell/ProviderRemoteControls.qml b/qml/shell/ProviderRemoteControls.qml new file mode 100644 index 00000000..a4fd04cb --- /dev/null +++ b/qml/shell/ProviderRemoteControls.qml @@ -0,0 +1,155 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Spool + +FocusScope { + id: root + property var provider + property string commandKey: "name" + property string valueKey: "value" + property string textCapability: "text" + property string textCommand: "" + property string messageCommand: "" + property string mirrorCommand: "" + property string instructions: + "These commands act on the selected device, not on this screen. Only commands advertised by that device are shown." + property var errorMessages: ({}) + property var controls: [] + property var capabilities: ({}) + property bool busy: false + property string problem: "" + property int generation: 0 + readonly property string targetId: provider ? String(provider.arguments.targetId || "") : "" + readonly property bool textAvailable: capabilities[textCapability] === true + readonly property bool messageAvailable: capabilities.message === true + function message(reason) { + const code = String(reason && (reason.code || reason.message) || reason) + return errorMessages[code] || "Couldn't control this device. Check the connection and refresh." + } + function refresh() { + if (busy) + return + const stamp = ++generation + busy = true + problem = "" + provider.request("remoteControls", { + targetId: targetId + }).then(result => { + if (stamp !== generation || provider.closed) + return + controls = result.controls || [] + capabilities = result + busy = false + Qt.callLater(() => controls.length ? InputKeys.focus(list) : InputKeys.focus( + refreshButton)) + }, reason => { + if (stamp === generation) { + busy = false + problem = message(reason) + } + }) + } + function send(command, value) { + if (busy) + return + const stamp = ++generation + const args = { + targetId: targetId + } + args[commandKey] = command + if (value !== undefined) + args[valueKey] = value + busy = true + problem = "" + provider.request("remoteControl", args).then(() => { + if (stamp !== generation || provider.closed) + return + busy = false + if (command === textCommand) + field.text = "" + }, reason => { + if (stamp === generation) { + busy = false + problem = message(reason) + } + }) + } + Component.onCompleted: refresh() + Component.onDestruction: ++generation + ColumnLayout { + anchors.fill: parent + spacing: Metrics.scaled(12) + SecondaryText { + Layout.fillWidth: true + text: root.instructions + wrapMode: Text.WordWrap + } + SecondaryText { + Layout.fillWidth: true + visible: !!root.problem + text: root.problem + color: Theme.errorText + wrapMode: Text.WordWrap + } + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.controls + keyNavigationEnabled: true + KeyNavigation.down: field.visible ? field : refreshButton + delegate: MenuRow { + required property var modelData + required property int index + width: ListView.view.width + label: modelData.label + enabled: !root.busy + highlighted: ListView.isCurrentItem && list.activeFocus + onHovered: list.currentIndex = index + onActivated: root.send(modelData.id) + } + function activate() { + if (currentItem && !root.busy) + currentItem.activated() + } + } + TextFieldRow { + id: field + Layout.fillWidth: true + visible: root.textAvailable || root.messageAvailable + enabled: !root.busy + label: "Text for the device" + KeyNavigation.up: list + onAccepted: { + if (text.length <= 4096) + root.send(root.textAvailable ? root.textCommand : root.messageCommand, text) + } + } + ActionButton { + visible: root.textAvailable + text: "Send text" + enabled: !root.busy && field.text.length <= 4096 + onClicked: root.send(root.textCommand, field.text) + } + ActionButton { + visible: root.messageAvailable + text: "Show message" + enabled: !root.busy && field.text.length > 0 && field.text.length <= 4096 + onClicked: root.send(root.messageCommand, field.text) + } + ActionButton { + visible: root.capabilities.mirror === true + text: "Show current item details on player" + enabled: !root.busy + onClicked: root.send(root.mirrorCommand) + } + ActionButton { + id: refreshButton + text: root.busy ? "Working…" : "Refresh device controls" + enabled: !root.busy + onClicked: root.refresh() + } + } +} diff --git a/qml/shell/ProviderSurface.qml b/qml/shell/ProviderSurface.qml new file mode 100644 index 00000000..9d323f43 --- /dev/null +++ b/qml/shell/ProviderSurface.qml @@ -0,0 +1,181 @@ +import QtQuick +import "../theme" +import "../primitives" + +// Hosts one of a provider's own screens: sign-in, account settings or a +// picker. The component gets the context as `provider` and nothing else, and +// the surface closes when the context settles either way. +FocusScope { + id: root + + property var context: null + // Over the current page with a scrim, rather than as a page of its own. + property bool overlay: false + readonly property var provider: context ? Providers.modules.find(m => m.id === context.moduleId) : null + readonly property Item screen: loader.item + property var loadedContext: null + property bool embedded: false + signal finished(var result, bool cancelled) + + focus: true + + // The route page is cached and its context arrives after construction, + // so a screen is (re)loaded whenever the context changes. + onContextChanged: load() + function load() { + if (context === loadedContext) + return + loadedContext = context || null + if (loadedContext) + loader.setSource(loadedContext.component, { + "provider": loadedContext + }) + else + loader.source = "" + } + + // Provider screens are ordinary QML forms. Where they route keys + // themselves they get first say; otherwise Up and Down walk the focus + // chain, which is enough for a D-pad to reach every field and button. + function routeKey(key, phase, repeat) { + if (screen && screen.routeKey && screen.routeKey(key, phase, repeat)) + return true + if (phase !== "press") + return false + const current = Window.activeFocusItem + if (!current || InputKeys.isTextInputItem(current)) + return false + if (key === Qt.Key_Down || key === Qt.Key_Up) { + const next = current.nextItemInFocusChain(key === Qt.Key_Down) + if (next && root.contains(root.mapFromItem(next, 0, 0))) + InputKeys.focus(next) + return true + } + return false + } + + function activate() { + if (screen && screen.activate) + return screen.activate() + const item = Window.activeFocusItem + if (item && typeof item.activate === "function") + item.activate() + else if (item && typeof item.clicked === "function") + item.clicked() + } + + function back() { + if (screen && screen.back && screen.back()) + return true + if (context) + context.close() + return true + } + + Connections { + target: root.context || null + function onFinished(result, cancelled) { + root.finished(result, cancelled) + } + } + + Rectangle { + anchors.fill: parent + color: root.embedded ? "transparent" : root.overlay ? Theme.overlayScrimStrong : Theme.bg + MouseArea { + anchors.fill: parent + enabled: root.overlay + acceptedButtons: Qt.AllButtons + onWheel: wheel => wheel.accepted = true + } + } + + Item { + id: frame + anchors.centerIn: parent + width: root.overlay ? Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(760)) : parent.width + height: root.overlay ? Math.min(parent.height - Metrics.pageMarginPx * 2, Metrics.scaled(820)) : parent.height + + Rectangle { + anchors.fill: parent + visible: root.overlay + radius: Theme.radiusPanel + color: Theme.bgRaised + border.width: Theme.hoverBorderWidth + border.color: Theme.border + } + + Row { + id: header + x: Metrics.pageMarginPx + y: root.embedded ? 0 : Metrics.pageMarginPx + spacing: Metrics.scaled(14) + visible: !root.embedded + height: visible ? Metrics.touchTargetPx : 0 + + IconButton { + anchors.verticalCenter: parent.verticalCenter + iconName: root.overlay ? "close" : "arrow_back" + accessibleName: root.overlay ? "Close" : "Back" + onClicked: root.back() + } + + ProviderIcon { + anchors.verticalCenter: parent.verticalCenter + width: Metrics.scaled(34) + height: width + source: root.provider ? root.provider.iconUrl : "" + name: root.provider ? root.provider.name : "" + seed: root.context ? root.context.moduleId : "" + } + + AppText { + anchors.verticalCenter: parent.verticalCenter + text: root.provider ? root.provider.name : "" + font.pixelSize: Metrics.bodySizePx + Metrics.scaled(2) + font.weight: Font.DemiBold + } + } + + AppText { + id: compatibilityNotice + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Metrics.pageMarginPx + anchors.rightMargin: Metrics.pageMarginPx + visible: Boolean(root.context && root.context.missingHostExtensions + && root.context.missingHostExtensions.length > 0) + height: visible ? implicitHeight + Metrics.scaled(12) : 0 + text: "Update Spool to use all features of this provider." + font.pixelSize: Metrics.bodySizePx + color: Theme.textSecondary + wrapMode: Text.WordWrap + } + + Loader { + id: loader + anchors.top: compatibilityNotice.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: root.embedded ? 0 : Metrics.scaled(12) + focus: true + asynchronous: true + Component.onCompleted: root.load() + onLoaded: InputKeys.focus(item) + onStatusChanged: if (status === Loader.Error && root.context) { + App.toastMessage("This provider's screen could not be opened") + root.context.close() + } + } + + BusySpinner { + anchors.centerIn: loader + width: Metrics.scaled(34) + height: width + running: loader.status === Loader.Loading + visible: running + } + } +} diff --git a/qml/shell/ProviderUpdatePrompt.qml b/qml/shell/ProviderUpdatePrompt.qml new file mode 100644 index 00000000..02a9a6cd --- /dev/null +++ b/qml/shell/ProviderUpdatePrompt.qml @@ -0,0 +1,76 @@ +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +// Offered on home when providers have updates and the preference is "Ask +// first". "Always" switches the preference to automatic and updates now. +Item { + id: root + + property bool shown: false + property bool dismissed: false + readonly property var updates: Store.updates + readonly property bool visibleNow: shown && !dismissed && updates.length > 0 && Settings.value("providers/updates") + === "ask" + + height: visibleNow ? card.height : 0 + visible: visibleNow + + function names() { + const first = updates[0] ? updates[0].name : "" + return updates.length > 1 ? first + " and " + (updates.length - 1) + " more" : first + } + + Surface { + id: card + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(680)) + height: content.implicitHeight + Metrics.scaled(28) + baseColor: Theme.floatingPanel + elevated: true + + RowLayout { + id: content + anchors.fill: parent + anchors.leftMargin: Metrics.scaled(18) + anchors.rightMargin: Metrics.scaled(14) + spacing: Metrics.scaled(12) + + MaterialIcon { + name: "system_update" + iconSize: Metrics.scaled(24) + iconColor: Theme.accent + } + + AppText { + Layout.fillWidth: true + text: "Updates for " + root.names() + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap + } + + ActionButton { + text: "Not now" + kind: "flat" + onClicked: root.dismissed = true + } + ActionButton { + text: "Always" + kind: "secondary" + onClicked: { + Settings.setValue("providers/updates", "auto") + Store.updateAll() + } + } + ActionButton { + text: "Update" + kind: "primary" + onClicked: { + Store.updateAll() + root.dismissed = true + } + } + } + } +} diff --git a/qml/shell/RemoteControlMenu.qml b/qml/shell/RemoteControlMenu.qml deleted file mode 100644 index e206af5f..00000000 --- a/qml/shell/RemoteControlMenu.qml +++ /dev/null @@ -1,196 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" - -FocusScope { - id: menu - - property bool menuOpen: false - property var entries: [] - property int currentIndex: 0 - signal requestClose - - visible: menuOpen - implicitHeight: panel.implicitHeight - height: implicitHeight - - function actionable(entry) { - return entry && entry.kind !== "empty" && entry.kind !== "status" - } - - function buildEntries() { - const out = [] - if (RemoteControl.targetSelected) { - out.push({ - kind: "status", - label: "Controlling " + (RemoteControl.selectedTargetName || "selected device"), - detail: RemoteControl.selectedTargetDetail || "Connected", - icon: "cast_connected" - }) - out.push({ - kind: "disconnect", - label: "Stop controlling", - detail: "Playback on the target continues", - icon: "cast" - }) - } - const targets = RemoteControl.targets || [] - for (let index = 0; index < targets.length; ++index) { - const target = targets[index] || ({}) - if (target.sessionId === RemoteControl.selectedSessionId) - continue - const detail = [] - if (target.client) - detail.push(target.client) - if (target.userName) - detail.push(target.userName) - if (target.nowPlayingTitle) - detail.push("Playing " + target.nowPlayingTitle) - out.push({ - kind: "target", - sessionId: target.sessionId || "", - label: target.deviceName || target.client || "Jellyfin client", - detail: detail.join(" · "), - icon: target.deviceType === "TV" ? "tv" : "devices" - }) - } - if (out.length === 0) - out.push({ - kind: "empty", - label: RemoteControl.busy ? "Looking for clients…" : "No remote clients available", - detail: "Enable remote control on the target device", - icon: RemoteControl.busy ? "sync" : "cast" - }) - out.push({ - kind: "refresh", - label: "Refresh clients", - detail: "", - icon: "refresh" - }) - return out - } - - function firstActionable() { - for (let index = 0; index < entries.length; ++index) - if (actionable(entries[index])) - return index - return 0 - } - - function openMenu() { - RemoteControl.refreshTargets() - entries = buildEntries() - currentIndex = firstActionable() - menuOpen = true - Qt.callLater(function () { - list.currentIndex = currentIndex - InputKeys.focus(list) - }) - } - - function closeMenu() { - menuOpen = false - } - - function activateEntry(index) { - const entry = entries[index] - if (!actionable(entry)) - return - if (entry.kind === "target") { - RemoteControl.selectTarget(entry.sessionId) - } else if (entry.kind === "disconnect") { - RemoteControl.clearTarget() - } else if (entry.kind === "refresh") { - RemoteControl.refreshTargets() - return - } - requestClose() - } - - function routeKey(key, phase, repeat) { - return list.routeKey(key, phase, repeat) - } - - function activate() { - list.activate() - } - - function back() { - requestClose() - return true - } - - Connections { - target: RemoteControl - enabled: menu.menuOpen - function onTargetsChanged() { - menu.entries = menu.buildEntries() - menu.currentIndex = Math.min(menu.currentIndex, Math.max(0, menu.entries.length - 1)) - } - function onTargetChanged() { - menu.entries = menu.buildEntries() - menu.currentIndex = menu.firstActionable() - } - function onBusyChanged() { - menu.entries = menu.buildEntries() - } - } - - Surface { - id: panel - anchors.fill: parent - implicitHeight: Math.min(Metrics.scaled(520), contentColumn.implicitHeight + Metrics.scaled(20)) - elevated: true - baseColor: Theme.bgRaised - clip: true - - PopupShield {} - - ColumnLayout { - id: contentColumn - anchors.fill: parent - anchors.margins: Metrics.scaled(10) - spacing: Metrics.scaled(6) - - AppText { - Layout.fillWidth: true - Layout.leftMargin: Metrics.scaled(4) - text: "Play On" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - font.weight: Font.DemiBold - } - - MenuListView { - id: list - Layout.fillWidth: true - Layout.preferredHeight: Math.min(contentHeight, Metrics.scaled(470)) - model: menu.entries - currentIndex: menu.currentIndex - rowEnabled: function (entry, index) { - return menu.actionable(menu.entries[index]) - } - onCurrentIndexChanged: menu.currentIndex = currentIndex - onDismissed: menu.requestClose() - onAccepted: index => menu.activateEntry(index) - - delegate: MenuRow { - required property int index - required property var modelData - width: list.width - label: modelData.label || "" - detail: modelData.detail || "" - iconName: modelData.icon || "" - actionable: menu.actionable(modelData) - highlighted: ListView.isCurrentItem && list.activeFocus - checkIconName: "" - onHovered: list.currentIndex = index - onActivated: menu.activateEntry(index) - } - } - } - } -} diff --git a/qml/shell/RemoteNowPlaying.qml b/qml/shell/RemoteNowPlaying.qml new file mode 100644 index 00000000..03d7b7ab --- /dev/null +++ b/qml/shell/RemoteNowPlaying.qml @@ -0,0 +1,87 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +FocusScope { + id: root + property var remote: RemoteTargets + property var shell + signal openControls + readonly property var snapshot: remote.state || ({}) + readonly property var commands: snapshot.commands || [] + readonly property bool failed: remote.problem.length > 0 + readonly property string toggleAction: snapshot.state === "paused" ? "unpause" : "pause" + readonly property bool toggleAvailable: remote.selectedTargetId.length > 0 && commands.indexOf(toggleAction) >= 0 + property int actionIndex: 0 + implicitHeight: Math.max(Metrics.touchTargetPx, Metrics.scaled(64)) + function activate() { + if (actionIndex === 1 && toggleAvailable) + remote.send({ + action: toggleAction + }) + else + openControls() + } + function back() { + if (shell) + shell.focusContent() + return true + } + function routeKey(key, phase, repeat) { + if (!InputKeys.isDirection(key)) + return false + if (phase === "release") + return true + if (key === Qt.Key_Left) + actionIndex = 0 + else if (key === Qt.Key_Right) + actionIndex = toggleAvailable ? 1 : 0 + else if (shell) + shell.focusContent() + return true + } + Rectangle { + anchors.fill: parent + color: Theme.bgRaised + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.pageMarginPx + anchors.rightMargin: Metrics.pageMarginPx + spacing: Metrics.gapPx + Image { + source: root.snapshot.artwork || "" + visible: source.toString().length > 0 + Layout.preferredWidth: root.height - Metrics.scaled(12) + Layout.preferredHeight: Layout.preferredWidth + fillMode: Image.PreserveAspectFit + asynchronous: true + } + MenuRow { + Layout.fillWidth: true + label: root.failed ? root.remote.problem : root.snapshot.title || root.remote.selectedTarget.name + || "Remote playback" + detail: root.failed ? "Choose a device or explicitly return to local playback." : ( + root.remote.selectedTarget.name || "") + " · " + (root.snapshot.state + || "Connecting") + iconName: root.failed ? "warning" : "cast" + highlighted: root.activeFocus && root.actionIndex === 0 + compact: true + onActivated: root.openControls() + Accessible.name: "Open remote playback controls: " + label + } + IconButton { + visible: root.toggleAvailable + iconName: root.toggleAction === "pause" ? "pause" : "play_arrow" + accessibleName: root.toggleAction === "pause" ? "Pause remote playback" : "Resume remote playback" + selected: root.activeFocus && root.actionIndex === 1 + enabled: !root.remote.busy + onClicked: root.remote.send({ + action: root.toggleAction + }) + } + } +} diff --git a/qml/shell/RemoteNowPlayingBar.qml b/qml/shell/RemoteNowPlayingBar.qml deleted file mode 100644 index 1a13fabf..00000000 --- a/qml/shell/RemoteNowPlayingBar.qml +++ /dev/null @@ -1,472 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" - -// A compact transport for the client this device is driving, kept in reach -// while the user browses everything else. Dragging its bar previews on the -// other screen exactly as the remote control page does, so the two are the -// same control in two places rather than two behaviours. -Item { - id: root - - signal openRequested - - readonly property var item: RemoteControl.nowPlayingItem || ({}) - readonly property bool hasMedia: Boolean(item.movieId || item.title) - readonly property bool shown: RemoteControl.targetSelected && (hasMedia || RemoteControl.playbackPending) - property real shownPositionTicks: RemoteControl.positionTicks - property string openMenu: "" - - // The frame at the position being shown, which during a drag is the one - // under the finger. Falls back to the item's own artwork when the server - // has no trickplay for it. - readonly property var previewData: RemoteControl.trickplayAvailable ? RemoteControl.trickplayForTicks( - shownPositionTicks) : ({}) - readonly property bool previewReady: previewData && previewData.available === true - readonly property var audioTracks: RemoteControl.audioTracks || [] - readonly property var subtitleTracks: RemoteControl.subtitleTracks || [] - - function formatTicks(ticks) { - const total = Math.max(0, Math.floor(Number(ticks || 0) / 10000000)) - const hours = Math.floor(total / 3600) - const minutes = Math.floor((total % 3600) / 60) - const seconds = total % 60 - const minuteText = hours > 0 && minutes < 10 ? "0" + minutes : String(minutes) - const secondText = seconds < 10 ? "0" + seconds : String(seconds) - return hours > 0 ? hours + ":" + minuteText + ":" + secondText : minuteText + ":" + secondText - } - - function selectedTrackLabel(tracks, fallback) { - for (let index = 0; index < tracks.length; ++index) - if (tracks[index].selected) - return tracks[index].label - return fallback - } - - function toggleMenu(name) { - openMenu = openMenu === name ? "" : name - } - - implicitHeight: Metrics.scaled(87) - visible: shown - enabled: shown - - onShownChanged: if (!shown) - openMenu = "" - - Connections { - target: RemoteControl - function onStateChanged() { - if (!seekBar.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() - } - } - - Timer { - interval: 1000 - repeat: true - running: root.shown && root.visible && !RemoteControl.paused - onTriggered: if (!seekBar.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() - } - - // An enlarged frame while scrubbing, clear of the thumb doing the - // scrubbing. The inline thumbnail tracks the same position, but at this - // size the picture is actually readable. - Item { - width: Math.min(root.width - Metrics.scaled(24), Metrics.scaled(300)) - height: 0 - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - visible: root.previewReady && seekBar.dragging - z: 20 - - Column { - anchors.bottom: parent.top - anchors.bottomMargin: Metrics.scaled(10) - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - spacing: Metrics.scaled(4) - - Rectangle { - id: previewFrame - readonly property real imageScale: root.previewReady && root.previewData.width > 0 ? width - / root.previewData.width : - 1 - width: parent.width - height: root.previewReady && root.previewData.width > 0 ? width * root.previewData.height - / root.previewData.width : 0 - radius: Theme.radiusLarge - color: "black" - border.width: 1 - border.color: Theme.borderStrong - clip: true - - Image { - source: root.previewReady ? "image://artwork/" + encodeURIComponent(root.previewData.url) : "" - x: root.previewReady ? root.previewData.offsetX * previewFrame.imageScale : 0 - y: root.previewReady ? root.previewData.offsetY * previewFrame.imageScale : 0 - width: root.previewReady ? root.previewData.sheetWidth * previewFrame.imageScale : 0 - height: root.previewReady ? root.previewData.sheetHeight * previewFrame.imageScale : 0 - fillMode: Image.Stretch - cache: true - asynchronous: true - } - } - - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - width: previewClock.width + Metrics.scaled(14) - height: previewClock.height + Metrics.scaled(6) - radius: height / 2 - color: Theme.bgRaised - - AppText { - id: previewClock - anchors.centerIn: parent - text: root.formatTicks(root.shownPositionTicks) - color: Theme.textPrimary - font.pixelSize: Metrics.metaSizePx - font.weight: Font.DemiBold - } - } - } - } - - Rectangle { - anchors.fill: parent - color: Theme.bgRaised - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - height: 1 - color: Theme.borderStrong - } - - // Opens the full remote. Sits behind the controls so their taps win. - MouseArea { - anchors.fill: parent - onClicked: { - if (root.openMenu.length > 0) - root.openMenu = "" - else - root.openRequested() - } - } - - // Left: what is playing, and where. - RowLayout { - id: nowPlaying - anchors.left: parent.left - anchors.leftMargin: Metrics.scaled(12) - anchors.right: transport.left - anchors.rightMargin: Metrics.scaled(12) - anchors.top: parent.top - anchors.topMargin: Metrics.scaled(11) - anchors.bottom: parent.bottom - anchors.bottomMargin: Metrics.scaled(6) - spacing: Metrics.scaled(11) - // On a narrow screen the six transport buttons leave no room to - // say what is playing. Losing the caption beats crushing it. - visible: width > Metrics.scaled(96) - - Rectangle { - id: thumb - readonly property real imageScale: root.previewReady && root.previewData.width > 0 ? width - / root.previewData.width : - 1 - Layout.preferredWidth: Metrics.scaled(78) - Layout.preferredHeight: Metrics.scaled(44) - Layout.alignment: Qt.AlignVCenter - radius: Theme.radiusSmall - color: "black" - clip: true - - ImageCard { - anchors.fill: parent - visible: !root.previewReady - imageUrl: root.hasMedia ? Art.url(root.item, "landscape") : "" - fallbackText: "" - fallbackIcon: "cast_connected" - } - - Image { - visible: root.previewReady - source: root.previewReady ? "image://artwork/" + encodeURIComponent(root.previewData.url) : "" - x: root.previewReady ? root.previewData.offsetX * thumb.imageScale : 0 - y: root.previewReady ? root.previewData.offsetY * thumb.imageScale : 0 - width: root.previewReady ? root.previewData.sheetWidth * thumb.imageScale : 0 - height: root.previewReady ? root.previewData.sheetHeight * thumb.imageScale : 0 - fillMode: Image.Stretch - cache: true - asynchronous: true - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(2) - - AppText { - Layout.fillWidth: true - text: RemoteControl.playbackPending ? "Starting " + (RemoteControl.pendingTitle || "playback") : - String(root.item.title || "Nothing playing") - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - font.weight: Font.DemiBold - maximumLineCount: 1 - elide: Text.ElideRight - } - - // The cast mark stands in for the word "on", so the row reads - // as one phrase naming where this is playing. - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(5) - - MaterialIcon { - name: "cast_connected" - iconSize: Metrics.scaled(17) - iconColor: Theme.accent - Layout.preferredWidth: Metrics.scaled(17) - Layout.preferredHeight: Metrics.scaled(17) - } - - SecondaryText { - Layout.fillWidth: true - text: RemoteControl.selectedTargetName - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - maximumLineCount: 1 - elide: Text.ElideRight - } - - // Mirrors the group state so the controller and the client - // it drives both show they are in a SyncPlay group. - MaterialIcon { - visible: SyncPlay && SyncPlay.enabled - name: "groups" - iconSize: Metrics.scaled(17) - iconColor: Theme.accent - Layout.preferredWidth: Metrics.scaled(17) - Layout.preferredHeight: Metrics.scaled(17) - } - } - } - } - - // Centre: the transport the player overlay offers, in the same order, - // so the two read as one control in two places. - RowLayout { - id: transport - readonly property real secondarySize: Math.max(Metrics.touchTargetPx * 0.82, Metrics.scaled(40)) - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: Metrics.scaled(3) - spacing: Metrics.scaled(2) - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "skip_previous" - accessibleName: "Previous" - onClicked: RemoteControl.previousTrack() - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "replay_10" - accessibleName: "Back 10 seconds" - onClicked: RemoteControl.seekRelative(-100000000) - } - - IconButton { - width: Math.max(Metrics.touchTargetPx, Metrics.scaled(52)) - height: width - iconRatio: 0.7 - iconName: RemoteControl.paused ? "play_arrow" : "pause" - accessibleName: RemoteControl.paused ? "Play" : "Pause" - selected: true - onClicked: RemoteControl.togglePause() - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "forward_10" - accessibleName: "Forward 10 seconds" - onClicked: RemoteControl.seekRelative(100000000) - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "skip_next" - accessibleName: "Next" - onClicked: RemoteControl.nextTrack() - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "stop" - accessibleName: "Stop" - onClicked: RemoteControl.stopPlayback() - } - } - - // Right: track selection, only for what this item actually offers. - RowLayout { - id: trackButtons - anchors.right: parent.right - anchors.rightMargin: Metrics.scaled(8) - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: Metrics.scaled(3) - spacing: Metrics.scaled(2) - - IconButton { - id: audioButton - visible: root.audioTracks.length > 1 - iconRatio: 0.62 - iconName: "audiotrack" - accessibleName: "Audio track" - selected: root.openMenu === "audio" - onClicked: root.toggleMenu("audio") - } - - IconButton { - id: subtitleButton - visible: root.subtitleTracks.length > 0 - iconRatio: 0.62 - iconName: "subtitles" - accessibleName: "Subtitles" - selected: root.openMenu === "subtitles" - onClicked: root.toggleMenu("subtitles") - } - } - } - - // Both pickers open upward from their own button, so the button stays - // under the thumb that opened it. - Component { - id: trackMenu - - Surface { - id: panel - property var tracks: [] - property string heading: "" - property var chooser: null - property Item anchorItem: null - - readonly property real panelWidth: Math.min(root.width - Metrics.scaled(16), Metrics.scaled(300)) - - width: panelWidth - height: Math.min(Metrics.scaled(300), column.implicitHeight + Metrics.scaled(12)) - x: anchorItem ? Math.max(Metrics.scaled(8), Math.min(root.width - width - Metrics.scaled(8), - anchorItem.mapToItem(root, 0, 0).x + anchorItem.width - / 2 - width / 2)) : 0 - y: -height - Metrics.scaled(8) - z: 30 - elevated: true - baseColor: Theme.bgRaised - - Flickable { - anchors.fill: parent - anchors.margins: Metrics.scaled(6) - contentWidth: width - contentHeight: column.implicitHeight - boundsBehavior: Flickable.StopAtBounds - clip: true - - Column { - id: column - width: parent.width - - Repeater { - model: panel.tracks - - delegate: MenuRow { - required property var modelData - width: column.width - compact: true - label: String(modelData.label || "Track") - checked: modelData.selected === true - onActivated: { - if (panel.chooser) - panel.chooser(Number(modelData.index)) - root.openMenu = "" - } - } - } - } - } - } - } - - Loader { - active: root.openMenu === "audio" - sourceComponent: trackMenu - onLoaded: { - item.tracks = Qt.binding(() => root.audioTracks) - item.anchorItem = audioButton - item.chooser = index => RemoteControl.selectAudioTrack(index) - } - } - - Loader { - active: root.openMenu === "subtitles" - sourceComponent: trackMenu - onLoaded: { - item.tracks = Qt.binding(() => root.subtitleTracks) - item.anchorItem = subtitleButton - item.chooser = index => RemoteControl.selectSubtitleTrack(index) - } - } - - // The seek bar rides the top edge, where it reads as the progress of the - // bar itself rather than one more control competing with the row. - InlineSlider { - id: seekBar - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: -implicitHeight / 2 + Metrics.scaled(2) - from: 0 - to: Math.max(1, RemoteControl.runtimeTicks) - value: root.shownPositionTicks - barHeight: Metrics.scaled(4) - handleSize: dragging ? Metrics.scaled(18) : Metrics.scaled(11) - interactionMargin: Metrics.scaled(18) - accented: true - enabled: root.hasMedia && RemoteControl.runtimeTicks > 0 - opacity: enabled ? 1 : 0 - z: 25 - - onMoved: newValue => { - root.shownPositionTicks = newValue - if (dragging) - RemoteControl.previewSeek(Math.round(newValue), true) - } - onCommitted: newValue => { - RemoteControl.cancelSeekPreview() - RemoteControl.seek(Math.round(newValue)) - } - - Behavior on handleSize { - NumberAnimation { - duration: 90 - } - } - } -} diff --git a/qml/shell/RouteStack.qml b/qml/shell/RouteStack.qml index 4b0b9886..e66a978d 100644 --- a/qml/shell/RouteStack.qml +++ b/qml/shell/RouteStack.qml @@ -5,7 +5,7 @@ import "PageReadiness.js" as PageReadiness FocusScope { id: root - property string route: "login" + property string route: "home" property var shell property bool startupReady: true focus: true @@ -13,7 +13,7 @@ FocusScope { property var uiTransitionToken: 0 // Resident-page host: pages are created once and route changes switch - // visibility + focus only. Login is destroyed on leave; + // visibility + focus only. Setup pages are destroyed on leave; // itemDetails/personDetails/search are evicted under memory pressure. property var pages: ({}) property var activeLoader: null @@ -28,22 +28,25 @@ FocusScope { function pageKey(nextRoute) { switch (nextRoute) { - case "login": - return "login" + case "accounts": + case "addProvider": + case "providerScreen": case "libraryGrid": - return "libraryGrid" + case "collectionEditor": + case "remoteControl": + return nextRoute case "itemDetails": return "itemDetails" case "personDetails": return "personDetails" case "search": return "search" - case "remoteControl": - return "remoteControl" case "openSourceNotices": return "openSourceNotices" case "settings": return "settings" + case "settingsSync": + return "settingsSync" case "subtitleSettings": return "subtitleSettings" default: @@ -53,22 +56,30 @@ FocusScope { function pageSource(key) { switch (key) { - case "login": - return Qt.resolvedUrl("../pages/LoginPage.qml") + case "accounts": + return Qt.resolvedUrl("../pages/AccountsPage.qml") + case "addProvider": + return Qt.resolvedUrl("../pages/AddProviderPage.qml") + case "providerScreen": + return Qt.resolvedUrl("../pages/ProviderScreenPage.qml") case "libraryGrid": return Qt.resolvedUrl("../pages/LibraryGridPage.qml") + case "collectionEditor": + return Qt.resolvedUrl("../pages/CollectionEditorPage.qml") + case "remoteControl": + return Qt.resolvedUrl("../pages/RemoteControlPage.qml") case "itemDetails": return Qt.resolvedUrl("../pages/ItemDetailsPage.qml") case "personDetails": return Qt.resolvedUrl("../pages/PersonDetailsPage.qml") case "search": return Qt.resolvedUrl("../pages/SearchPage.qml") - case "remoteControl": - return Qt.resolvedUrl("../pages/RemoteControlPage.qml") case "openSourceNotices": return Qt.resolvedUrl("../pages/OpenSourceNoticesPage.qml") case "settings": return Qt.resolvedUrl("../pages/SettingsPage.qml") + case "settingsSync": + return Qt.resolvedUrl("../pages/SettingsSyncPage.qml") case "subtitleSettings": return Qt.resolvedUrl("../pages/SubtitleSettingsPanel.qml") default: @@ -107,24 +118,24 @@ FocusScope { function showRoute() { const key = pageKey(route) const existing = pages[key] - const promoted = existing && existing.status === Loader.Loading - // Somebody is looking at the screen waiting for this page. The - // incubator's whole purpose is to protect frames it does not know are - // being wasted: it spreads a cold build over eighteen of them and the - // gui thread sits idle for half that window. Build it in one go. - const loader = loaderFor(key, true) - pendingLoader = loader - // Finish in-flight incubation synchronously when someone is actively - // waiting on this exact page: a promoted prewarm the user beat to the - // punch, or the startup route while nothing else is on screen yet. - if (promoted || activeRoute === "") - loader.asynchronous = false - const warm = loader.status === Loader.Ready && Boolean(loader.item) + // Classify the cache BEFORE loaderFor() can synchronously create a page. + // Start timing there too: otherwise cold construction disappears from + // the sample and the newly constructed page is misreported as a hit. + const promoted = Boolean(existing && existing.status === Loader.Loading) + const warm = Boolean(existing && existing.status === Loader.Ready && existing.item) const cacheHit = warm ? "hit" : promoted ? "promoted" : "miss" + pendingLoader = null uiTransitionToken = InputLatency.beginUiTransition("route:" + route + (warm ? ":warm" : ":cold"), activeRoute, route, cacheHit) settleWatchdog.restart() - if (warm) { + + const loader = loaderFor(key, true) + pendingLoader = loader + // Finishing a promoted loader can emit onLoaded synchronously and + // activate it here. Do not activate the same page a second time below. + if (promoted || activeRoute === "") + loader.asynchronous = false + if (pendingLoader === loader && loader.status === Loader.Ready && Boolean(loader.item)) { InputLatency.mark(uiTransitionToken, "instance") activatePending() } @@ -172,7 +183,7 @@ FocusScope { // while it builds. What made a television slow was never the // prewarming; it was dropping every page the moment it left the // screen and paying to build it again on the way back. - if (Session.authenticated && !prewarmScheduled) { + if (Providers.hasAccounts && !prewarmScheduled) { prewarmScheduled = true // Most-wanted first. The budget stops this queue partway through on // a small television, so whatever stands at the front is what @@ -186,7 +197,7 @@ FocusScope { } function dropTransientPages() { - for (const key of ["login"]) { + for (const key of ["accounts", "addProvider", "providerScreen", "collectionEditor"]) { const loader = pages[key] if (loader && loader !== activeLoader) { delete pages[key] @@ -234,7 +245,7 @@ FocusScope { } function evictBeyondBudget() { - const shown = useOrder.filter(key => Boolean(pages[key])); + const shown = useOrder.filter(key => Boolean(pages[key])) // Prewarmed pages never pass through noteUse(), so an order taken from // useOrder alone could not see them: the budget said three while the // process was holding five. Count every resident page, and spend the @@ -248,7 +259,7 @@ FocusScope { if (!loader || loader === activeLoader || key === pinnedPageKey) continue delete pages[key] - loader.destroy() + loader.destroy(); --excess console.info("route host: released", key) } diff --git a/qml/shell/ServerIdentityRow.qml b/qml/shell/ServerIdentityRow.qml new file mode 100644 index 00000000..b3f03fac --- /dev/null +++ b/qml/shell/ServerIdentityRow.qml @@ -0,0 +1,39 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Spool + +Surface { + id: root + property string serverName: "" + property string serverAddress: "" + signal changeRequested + implicitHeight: Math.max(Metrics.controlHeightPx, content.implicitHeight + Metrics.scaled(16)) + RowLayout { + id: content + anchors.fill: parent + anchors.margins: Metrics.scaled(8) + spacing: Metrics.scaled(10) + MaterialIcon { + name: "dns" + iconColor: Theme.textMuted + iconSize: Metrics.iconSizePx + } + AppText { + Layout.maximumWidth: root.width * 0.38 + text: root.serverName + font.weight: Font.DemiBold + elide: Text.ElideRight + } + SecondaryText { + Layout.fillWidth: true + text: root.serverAddress + elide: Text.ElideMiddle + } + ActionButton { + text: "Change" + kind: "blue" + onClicked: root.changeRequested() + } + } +} diff --git a/qml/shell/ServerLogin.qml b/qml/shell/ServerLogin.qml new file mode 100644 index 00000000..fbbb2ca5 --- /dev/null +++ b/qml/shell/ServerLogin.qml @@ -0,0 +1,545 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Spool + +// Provider operations supply data; layout, focus, cancellation and forms are +// compiled with Spool. Service-specific linking can occupy the alternate slot. +FocusScope { + id: root + property var provider + property string serviceName: "" + property var errorMessages: ({}) + property string codeLabel: "" + property string codeStartOperation: "" + property string codePollOperation: "" + property string codeInstructions: "" + property string codeEnabledField: "" + property string codeAvailableField: "" + property string alternateLabel: "" + property Component alternateScreen: null + property bool alternateActive: false + property string step: "server" + property var server: ({}) + property var servers: [] + property bool busy: false + property bool discovering: false + property bool validAddress: false + property bool lanAvailable: false + property bool lanSearching: false + property string error: "" + property string code: "" + property string secret: "" + property int generation: 0 + property int validationGeneration: 0 + property int discoveryGeneration: 0 + property int codeGeneration: 0 + readonly property bool closed: !provider || provider.closed + + function validateAddress(input) { + const stamp = ++validationGeneration + validAddress = false + if (!input.trim() || closed) + return + provider.request("serverCandidates", { + server: input + }).then(result => { + if (stamp === validationGeneration && !closed) + validAddress = !!result.servers.length + }, () => {}) + } + function mergeServers(found) { + const merged = servers.slice() + for (const entry of found) { + const index = merged.findIndex(row => row.id === entry.id) + if (index < 0) + merged.push(entry) + else + merged[index] = entry + } + servers = merged + } + function discoverServers() { + if (discovering || closed || alternateActive || step !== "server") + return + discovering = true + const stamp = discoveryGeneration + provider.request("discover").then(result => { + discovering = false + if (stamp === discoveryGeneration && !closed) + mergeServers(result.servers || []) + }, () => { + discovering = false + }) + } + function cancelLocalSearch() { + ++discoveryGeneration + if (lanSearching && !closed) + provider.cancelLanDiscovery() + lanSearching = false + } + function searchLocalNetwork() { + if (lanSearching) { + cancelLocalSearch() + return + } + if (busy || closed) + return + const stamp = ++discoveryGeneration + lanSearching = true + const cursors = new Set() + function next(cursor) { + if (stamp !== discoveryGeneration || closed) + return + return provider.request("discoverMore", cursor ? { + cursor: cursor + } : {}).then(result => { + if (stamp !== discoveryGeneration || closed) + return + mergeServers(result.servers || []) + if (result.exhausted) { + lanSearching = false + return + } + if (!result.cursor || cursors.has(result.cursor) + || cursors.size >= 512) + throw "invalid_pagination" + cursors.add(result.cursor) + return next(result.cursor) + }) + } + provider.allowLanDiscovery().then(() => next(null)).catch(() => { + if (stamp === discoveryGeneration) { + cancelLocalSearch() + error = "Local search did not finish. Enter a server address or try again." + } + }) + } + function fail(value) { + busy = false + error = errorMessages[value] || ({ + http_401: "Wrong username or password", + invalid_credentials: "Wrong username or password", + invalid_server: "Enter a valid server address", + origin_denied: "This server address was not allowed" + })[value] || "Couldn't reach the server. Try again." + } + function connect(input) { + if (busy || closed || !String(input).trim()) + return + cancelLocalSearch() + const stamp = ++generation + busy = true + error = "" + function current() { + return stamp === generation && !closed + } + function attempt(candidates, index) { + if (!current()) + return null + if (!candidates || index >= candidates.length) + throw "invalid_server" + const candidate = candidates[index] + return provider.allowOrigin(candidate).then(() => { + if (!current()) + return null + return provider.request("probe", { + server: candidate + }).catch(reason => { + if (!current()) + return null + if (reason !== "cancelled" && reason !== "origin_denied" && index + 1 + < candidates.length) + return attempt(candidates, index + 1) + throw reason + }) + }) + } + provider.request("serverCandidates", { + server: input + }).then(result => attempt(result.servers, 0)).then(result => { + if (!current() || !result) + return + server = result + busy = false + step = "account" + usernameField.text = "" + password.text = "" + Qt.callLater(() => usernameField.focusRow()) + }, reason => { + if (current()) + fail(reason) + }) + } + function signIn(name, value) { + if (busy || closed) + return + const stamp = generation + busy = true + error = "" + password.text = "" + provider.request("authenticate", { + server: server.server, + username: name, + password: value + }).then(account => { + if (stamp === generation && !closed) + provider.complete(account) + }, reason => { + if (stamp === generation && !closed) + fail(reason) + }) + } + function cancelCode() { + ++codeGeneration + codePoll.stop() + code = "" + secret = "" + } + function startCode() { + if (busy || closed) + return + cancelCode() + const stamp = codeGeneration + busy = true + error = "" + provider.request(codeStartOperation, { + server: server.server + }).then(result => { + if (stamp !== codeGeneration || closed) + return + code = result.code + secret = result.secret + busy = false + codePoll.start() + }, reason => { + if (stamp === codeGeneration && !closed) + fail(reason) + }) + } + function pollCode() { + const stamp = codeGeneration + provider.request(codePollOperation, { + server: server.server, + secret: secret + }).then(result => { + if (stamp !== codeGeneration || closed) + return + if (result.authenticated) { + cancelCode() + provider.complete(result.account) + } else + codePoll.start() + }, () => { + if (stamp === codeGeneration && !closed) + codePoll.start() + }) + } + function retryAvailability() { + const stamp = generation + busy = true + provider.request("probe", { + server: server.server + }).then(result => { + if (stamp === generation && !closed) { + server = result + busy = false + } + }, reason => { + if (stamp === generation && !closed) + fail(reason) + }) + } + function back() { + if (alternateActive) { + alternateActive = false + return true + } + if (lanSearching) { + cancelLocalSearch() + return true + } + if (step === "server" && !busy) + return false + ++generation + cancelCode() + busy = false + error = "" + step = "server" + password.text = "" + Qt.callLater(() => address.focusRow()) + return true + } + function activate() { + const item = Window.activeFocusItem + if (item && typeof item.activate === "function") + item.activate() + else if (item && typeof item.clicked === "function") + item.clicked() + else if (item && typeof item.accepted === "function") + item.accepted() + } + Component.onCompleted: { + discoverServers() + provider.request("extensionStatus").then(result => { + if (!closed) + lanAvailable = !!result.enabled["spool.lan-probe"] + }, () => {}) + Qt.callLater(() => address.focusRow()) + } + Component.onDestruction: { + ++generation + cancelCode() + cancelLocalSearch() + } + Timer { + interval: 4000 + repeat: true + running: !root.closed && root.step === "server" && !root.alternateActive + onTriggered: root.discoverServers() + } + Timer { + id: validation + interval: 150 + onTriggered: root.validateAddress(address.text) + } + Timer { + id: codePoll + interval: 2000 + onTriggered: root.pollCode() + } + + Flickable { + anchors.fill: parent + visible: !root.alternateActive + contentHeight: Math.max(height, column.implicitHeight + Metrics.pageMarginPx * 2) + boundsBehavior: Flickable.StopAtBounds + clip: true + ColumnLayout { + id: column + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(680)) + x: (parent.width - width) / 2 + y: Math.max(Metrics.pageMarginPx, (parent.height - implicitHeight) / 2) + spacing: Metrics.scaled(14) + // The page header already names the provider; this says what to do. + AppText { + Layout.fillWidth: true + text: root.step === "server" ? "Choose a server" : "Sign in" + font.pixelSize: Metrics.scaled(40) + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + AppText { + Layout.fillWidth: true + Layout.bottomMargin: Metrics.scaled(10) + text: root.step === "server" ? "Pick a " + (root.serviceName || "media") + + " server found on your network, or enter its address." : + "Sign in with your " + (root.serviceName || "server") + " account." + color: Theme.textSecondary + font.pixelSize: Metrics.scaled(20) + wrapMode: Text.WordWrap + } + Repeater { + model: root.step === "server" ? root.servers : [] + delegate: ServerCard { + required property var modelData + Layout.fillWidth: true + title: modelData.name + serverAddress: modelData.address + onAccepted: root.connect(modelData.address) + } + } + SecondaryText { + Layout.fillWidth: true + visible: root.step === "server" && !root.servers.length + text: root.discovering ? "Looking for servers on your network…" : + "No servers found. Enter your server address below." + wrapMode: Text.WordWrap + } + TextFieldRow { + id: address + Layout.fillWidth: true + visible: root.step === "server" + label: "Server address" + placeholderText: "192.168.1.20" + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + onTextChanged: { + root.validAddress = false + validation.restart() + } + onAccepted: root.connect(text) + } + ActionButton { + Layout.fillWidth: true + Layout.preferredHeight: Metrics.scaled(56) + visible: root.step === "server" + text: root.busy ? "Connecting…" : "Connect" + iconName: "arrow_forward" + kind: "primary" + enabled: root.validAddress && !root.busy + onClicked: root.connect(address.text) + } + SecondaryText { + Layout.fillWidth: true + font.pixelSize: Metrics.bodySizePx + visible: root.step === "server" && address.text.length > 0 && !root.validAddress && !root.busy + text: "Enter a server address, such as https://media.example.com or 192.168.1.20." + wrapMode: Text.WordWrap + } + SecondaryText { + Layout.fillWidth: true + font.pixelSize: Metrics.bodySizePx + visible: root.step === "server" && root.error.length > 0 + text: root.error + color: Theme.errorText + wrapMode: Text.WordWrap + } + ActionButton { + kind: "secondary" + Layout.fillWidth: true + visible: root.step === "server" && root.lanAvailable + iconName: root.lanSearching ? "close" : "search" + text: root.lanSearching ? "Cancel local search" : "Search local network" + onClicked: root.searchLocalNetwork() + } + ActionButton { + kind: "secondary" + Layout.fillWidth: true + visible: root.step === "server" && !!root.alternateScreen + text: root.alternateLabel + enabled: !root.busy + onClicked: { + root.cancelLocalSearch() + root.alternateActive = true + } + } + ServerIdentityRow { + Layout.fillWidth: true + visible: root.step === "account" + serverName: root.server.name || root.serviceName + serverAddress: root.server.server || "" + onChangeRequested: root.back() + } + Flow { + Layout.fillWidth: true + visible: root.step === "account" && (root.server.users || []).length > 0 + spacing: Metrics.scaled(12) + Repeater { + model: root.step === "account" ? root.server.users || [] : [] + delegate: ProfileTile { + required property var modelData + tileSize: Metrics.scaled(80) + username: modelData.name + onAccepted: { + usernameField.text = modelData.name + if (modelData.hasPassword) + password.focusRow() + else + root.signIn(modelData.name, "") + } + } + } + } + AppText { + visible: root.step === "account" + text: "Username" + font.weight: Font.Medium + } + TextFieldRow { + id: usernameField + Layout.fillWidth: true + visible: root.step === "account" + accessibleName: "Username" + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + enterKeyType: Qt.EnterKeyNext + onAccepted: password.focusRow() + } + AppText { + visible: root.step === "account" + text: "Password" + font.weight: Font.Medium + } + TextFieldRow { + id: password + Layout.fillWidth: true + visible: root.step === "account" + accessibleName: "Password" + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText + enterKeyType: Qt.EnterKeyGo + onAccepted: root.signIn(usernameField.text, text) + } + ActionButton { + Layout.fillWidth: true + Layout.preferredHeight: Metrics.scaled(56) + visible: root.step === "account" + text: root.busy ? "Signing in…" : "Sign in" + iconName: "login" + kind: "primary" + enabled: !root.busy && !!usernameField.text.trim() + onClicked: root.signIn(usernameField.text, password.text) + } + SecondaryText { + Layout.fillWidth: true + font.pixelSize: Metrics.bodySizePx + visible: root.step === "account" && root.error.length > 0 + text: root.error + color: Theme.errorText + wrapMode: Text.WordWrap + } + ActionButton { + kind: "secondary" + Layout.fillWidth: true + visible: root.step === "account" && !!root.codeLabel && root.server[root.codeAvailableField] === false + text: "Retry " + root.codeLabel + " availability" + enabled: !root.busy + onClicked: root.retryAvailability() + } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Metrics.scaled(6) + visible: root.step === "account" && !!root.codeLabel && root.server[root.codeEnabledField] === true + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.border + } + SecondaryText { + text: "or" + } + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.border + } + } + ActionButton { + kind: "secondary" + Layout.fillWidth: true + visible: root.step === "account" && !!root.codeLabel && root.server[root.codeEnabledField] === true + text: root.code ? "Cancel " + root.codeLabel : "Use " + root.codeLabel + iconName: root.code ? "close" : "bolt" + enabled: !root.busy + onClicked: root.code ? root.cancelCode() : root.startCode() + } + ProviderCodePanel { + Layout.fillWidth: true + visible: !!root.code + code: root.code + instructions: root.codeInstructions + } + BusySpinner { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: Metrics.scaled(24) + Layout.preferredHeight: Metrics.scaled(24) + running: root.busy + visible: running + } + } + } + Loader { + anchors.fill: parent + active: root.alternateActive + sourceComponent: root.alternateScreen + } +} diff --git a/qml/shell/ToastLayer.qml b/qml/shell/ToastLayer.qml index cc9f96cc..8a890add 100644 --- a/qml/shell/ToastLayer.qml +++ b/qml/shell/ToastLayer.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Layouts import "../theme" import "../primitives" @@ -87,26 +88,32 @@ Item { Surface { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: 34 - width: Math.min(parent.width - 96, toastContent.implicitWidth + 42) - height: 48 + anchors.bottomMargin: Metrics.scaled(34) + width: Math.min(parent.width - Metrics.pageMarginPx * 2, toastContent.implicitWidth + Metrics.scaled(42)) + height: Math.max(Metrics.controlHeightPx, toastContent.implicitHeight + Metrics.scaled(24)) visible: root.message.length > 0 elevated: true - Row { + RowLayout { id: toastContent - anchors.centerIn: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Metrics.scaled(21) + anchors.rightMargin: Metrics.scaled(21) spacing: Metrics.scaled(16) AppText { - anchors.verticalCenter: parent.verticalCenter + Layout.fillWidth: true text: root.message color: Theme.textPrimary + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap } ActionButton { visible: root.actionText.length > 0 - anchors.verticalCenter: parent.verticalCenter + Layout.alignment: Qt.AlignVCenter text: root.actionText kind: "flat" onClicked: root.triggerAction() diff --git a/qml/shell/TopBar.qml b/qml/shell/TopBar.qml index 3c727566..600005f6 100644 --- a/qml/shell/TopBar.qml +++ b/qml/shell/TopBar.qml @@ -4,9 +4,10 @@ import QtQuick import QtQuick.Layouts import "../theme" import "../primitives" +import "../pages" -// Horizontal top navigation bar. Hosts primary routes on the left and playback -// destination, remote-control, and SyncPlay actions on the right. D-pad: +// Horizontal navigation bar: primary routes on the left, watching together on +// the right when an account supports it. D-pad: // Left/Right move between bar items, Down enters the content area, and pages // return focus here by pressing Up at their top edge. FocusScope { @@ -17,31 +18,24 @@ FocusScope { // of a thumb, and its vertical keys follow it. property string edge: "top" signal navigate(string route) + property var shell + property bool remoteMenuOpen: false + readonly property bool menuOpen: groupMenuOpen || remoteMenuOpen + readonly property bool remoteVisible: RemoteTargets.available signal contentRequested - readonly property bool syncPlayMenuOpen: syncMenuLoader.item ? syncMenuLoader.item.menuOpen : false - property bool syncPlayMenuLoaded: false - readonly property bool remoteControlMenuOpen: remoteMenuLoader.item ? remoteMenuLoader.item.menuOpen : false - property bool remoteControlMenuLoaded: false - readonly property bool castVisible: Settings.castButtonEnabled - readonly property bool remoteVisible: RemoteControl.targetSelected - readonly property var syncPlay: SyncPlay - readonly property bool syncActive: syncPlay ? syncPlay.enabled : false - readonly property var syncGroups: syncPlay ? syncPlay.groups : [] - readonly property bool syncAvailable: syncGroups && syncGroups.length > 0 + readonly property bool groupMenuOpen: groupMenuLoader.item ? groupMenuLoader.item.menuOpen : false + property bool groupMenuLoaded: false + readonly property bool groupVisible: ProviderCapabilities.groupPlayback readonly property string selectedRoute: currentRoute === "libraryGrid" ? "home" : currentRoute - // A rail cell is never narrower than the button inside it. It used to be - // a flat scaled(50) while the button took the touch-target floor, so on a - // phone a 72px button sat in a 41px cell: the buttons overlapped by more - // than half their width and a tap near a boundary went to whichever - // happened to be stacked last. With a pointer the reverse held, and the - // few pixels either side of each button were dead. + // A rail cell is never narrower than the button inside it, so hit areas + // tile with nothing dead between them on any pointer. readonly property int railCellWidth: Math.max(Metrics.scaled(50), Metrics.touchTargetPx) - // Index space: navigation buttons, optional Remote and Cast buttons, then SyncPlay. + // Index space: the rail's buttons, then the group button when shown. function lastIndex() { - return railRepeater.count + (castVisible ? 1 : 0) + (remoteVisible ? 1 : 0) + return railRepeater.count + (remoteVisible ? 1 : 0) + (groupVisible ? 1 : 0) - 1 } function focusedIndex() { @@ -50,32 +44,22 @@ FocusScope { if (item && item.hasButtonFocus()) return i } - if (remoteVisible && remoteButton.activeFocus) - return railRepeater.count - if (castVisible && castButton.activeFocus) - return railRepeater.count + (remoteVisible ? 1 : 0) - if (syncButton.activeFocus) - return lastIndex() - return 0 + return remoteButton.activeFocus ? railRepeater.count : groupButton.activeFocus ? lastIndex() : 0 } function focusIndex(index) { const clamped = Math.max(0, Math.min(lastIndex(), index)) - if (clamped < railRepeater.count) { - const item = railRepeater.itemAt(clamped) - if (item) - item.forceButtonFocus() - return - } if (remoteVisible && clamped === railRepeater.count) { InputKeys.focus(remoteButton) return } - if (castVisible && clamped === railRepeater.count + (remoteVisible ? 1 : 0)) { - InputKeys.focus(castButton) + if (groupVisible && clamped === lastIndex()) { + InputKeys.focus(groupButton) return } - InputKeys.focus(syncButton) + const item = railRepeater.itemAt(clamped) + if (item) + item.forceButtonFocus() } function focusCurrent() { @@ -86,135 +70,80 @@ FocusScope { return } } - const first = railRepeater.itemAt(0) - if (first) - first.forceButtonFocus() + focusIndex(0) } - function remoteMenu() { - remoteControlMenuLoaded = true - return remoteMenuLoader.item - } - - function openRemoteMenu() { - closeSyncPlayMenu(false) - const menu = remoteMenu() - if (menu) - menu.openMenu() - } - - function closeRemoteMenu(restoreFocus) { - const menu = remoteMenuLoader.item - if (menu) - menu.closeMenu() - if (restoreFocus !== false && castVisible) - InputKeys.focus(castButton) - } - - function syncMenu() { - syncPlayMenuLoaded = true - return syncMenuLoader.item - } - - function openSyncMenu() { - closeRemoteMenu(false) - if (syncPlay) - syncPlay.refreshGroups() - const menu = syncMenu() - if (menu) - menu.openMenu() - } - - function closeSyncPlayMenu(restoreFocus) { - const menu = syncMenuLoader.item - if (menu) - menu.closeMenu() - if (restoreFocus !== false) - InputKeys.focus(syncButton) - } - - function closeMenus(restoreFocus) { - if (remoteControlMenuOpen) - closeRemoteMenu(restoreFocus) - if (syncPlayMenuOpen) - closeSyncPlayMenu(restoreFocus) + function openGroupMenu() { + if (!groupVisible) + return + remoteMenuOpen = false + groupMenuLoaded = true + if (groupMenuLoader.item) + groupMenuLoader.item.openMenu() } - function containsSyncPlayPoint(item, x, y) { - const buttonPoint = syncButton.mapFromItem(item, x, y) - if (syncButton.contains(buttonPoint)) - return true - const menu = syncMenuLoader.item - if (!menu || !menu.menuOpen) - return false - const menuPoint = menu.mapFromItem(item, x, y) - return menu.contains(menuPoint) + function closeGroupMenu(restoreFocus) { + if (groupMenuLoader.item) + groupMenuLoader.item.closeMenu() + if (restoreFocus !== false && groupVisible) + InputKeys.focus(groupButton) } - function containsRemoteControlPoint(item, x, y) { - const buttonPoint = castButton.mapFromItem(item, x, y) - if (castButton.contains(buttonPoint)) + function containsGroupPoint(item, x, y) { + if (groupButton.contains(groupButton.mapFromItem(item, x, y))) return true - if (remoteVisible) { - const remoteButtonPoint = remoteButton.mapFromItem(item, x, y) - if (remoteButton.contains(remoteButtonPoint)) - return true - } - const menu = remoteMenuLoader.item - if (!menu || !menu.menuOpen) - return false - const menuPoint = menu.mapFromItem(item, x, y) - return menu.contains(menuPoint) + const menu = groupMenuLoader.item + return Boolean(menu && menu.menuOpen && menu.contains(menu.mapFromItem(item, x, y))) } function activate() { - const remote = remoteMenuLoader.item - if (remote && remote.menuOpen) { - remote.activate() + const menu = groupMenuLoader.item + if (menu && menu.menuOpen) { + menu.activate() return } - const sync = syncMenuLoader.item - if (sync && sync.menuOpen) { - sync.activate() + const index = focusedIndex() + if (remoteMenuOpen && remoteMenuLoader.item) { + remoteMenuLoader.item.activate() return } - const index = focusedIndex() if (remoteVisible && index === railRepeater.count) { - closeRemoteMenu(false) - navigate("remoteControl") - } else if (castVisible && index === railRepeater.count + (remoteVisible ? 1 : 0)) { openRemoteMenu() - } else if (index >= lastIndex()) { - openSyncMenu() - } else { - const item = railRepeater.itemAt(index) - if (item) - navigate(item.route) + return } + if (groupVisible && index === lastIndex()) { + openGroupMenu() + return + } + const item = railRepeater.itemAt(index) + if (item) + navigate(item.route) } function back() { - if (remoteControlMenuOpen) { - closeRemoteMenu() - return true - } - if (syncPlayMenuOpen) { - closeSyncPlayMenu() + if (remoteMenuOpen) { + if (remoteMenuLoader.item && remoteMenuLoader.item.back()) + return true + closeRemoteMenu(true) return true } - return false + if (!groupMenuOpen) + return false + closeGroupMenu() + return true } - onActiveFocusChanged: if (!activeFocus) - closeMenus(false) + onActiveFocusChanged: if (!activeFocus) { + closeGroupMenu(false) + closeRemoteMenu(false) + } function routeKey(key, phase, repeat) { - const remote = remoteMenuLoader.item - if (remote && remote.menuOpen) - return remote.routeKey(key, phase, repeat) - const sync = syncMenuLoader.item - if (sync && sync.menuOpen) - return sync.routeKey(key, phase, repeat) + if (remoteMenuOpen && remoteMenuLoader.item) + return remoteMenuLoader.item.routeKey(key, phase, repeat) + const menu = groupMenuLoader.item + if (menu && menu.menuOpen) + return menu.routeKey(key, phase, repeat) // The rail leaves towards wherever the content is, which is below it // on a page and above it once it has moved down within thumb reach. const towardsContent = root.edge === "bottom" ? Qt.Key_Up : Qt.Key_Down @@ -236,12 +165,8 @@ FocusScope { TapHandler { acceptedButtons: Qt.LeftButton onTapped: eventPoint => { - const remotePoint = castButton.mapFromItem(root, eventPoint.position.x, eventPoint.position.y) - if (!castButton.contains(remotePoint)) - root.closeRemoteMenu(false) - const syncPoint = syncButton.mapFromItem(root, eventPoint.position.x, eventPoint.position.y) - if (!syncButton.contains(syncPoint)) - root.closeSyncPlayMenu(false) + if (!root.containsGroupPoint(root, eventPoint.position.x, eventPoint.position.y)) + root.closeGroupMenu(false) } } @@ -279,9 +204,9 @@ FocusScope { icon: "search" }, { - label: "Switch user", - route: "switchUser", - icon: "person" + label: "Profiles & servers", + route: "accounts", + icon: "switch_account" }, { label: "Settings", @@ -307,40 +232,16 @@ FocusScope { id: button anchors.centerIn: parent iconName: modelData.icon - accessibleName: modelData.route === "switchUser" && Session.activeProfileLabel.length > 0 - ? "Switch user — " + Session.activeProfileLabel : modelData.label + accessibleName: modelData.label railStyle: true // The rail changes pages; the selection goes with them. focusOnClick: false selected: root.selectedRoute === modelData.route onClicked: { - root.closeSyncPlayMenu(false) + root.closeGroupMenu(false) root.navigate(modelData.route) } } - - Rectangle { - anchors.top: button.bottom - anchors.topMargin: Metrics.scaled(5) - anchors.horizontalCenter: button.horizontalCenter - width: switchTooltip.implicitWidth + Metrics.scaled(16) - height: switchTooltip.implicitHeight + Metrics.scaled(10) - radius: Theme.radiusSmall - color: Theme.floatingPanel - border.width: Theme.hoverBorderWidth - border.color: Theme.borderStrong - visible: Platform.hasDesktopPointer && modelData.route === "switchUser" && button.pointerHovered - z: 100 - - AppText { - id: switchTooltip - anchors.centerIn: parent - text: "Switch user — " + Session.activeProfileLabel - color: Theme.textPrimary - font.pixelSize: Metrics.metaSizePx - maximumLineCount: 1 - } - } } } @@ -348,100 +249,38 @@ FocusScope { Layout.fillWidth: true } - Item { - visible: root.castVisible - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: visible ? root.railCellWidth : 0 - Layout.fillHeight: true - - IconButton { - id: castButton - anchors.centerIn: parent - iconName: RemoteControl.targetSelected ? "cast_connected" : "cast" - accessibleName: RemoteControl.targetSelected ? "Play on — " + RemoteControl.selectedTargetName : - "Play on" - railStyle: true - selected: root.remoteControlMenuOpen - onClicked: { - if (root.remoteControlMenuOpen) - root.closeRemoteMenu(false) - else - root.openRemoteMenu() - } - - Rectangle { - visible: RemoteControl.targetSelected - width: Metrics.scaled(9) - height: width - radius: width / 2 - anchors.right: parent.right - anchors.top: parent.top - anchors.rightMargin: Metrics.scaled(7) - anchors.topMargin: Metrics.scaled(7) - color: Theme.success - border.width: Theme.focusBorderWidth - border.color: Theme.bgRaised - } - } - } Item { visible: root.remoteVisible - Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: visible ? root.railCellWidth : 0 Layout.fillHeight: true - IconButton { id: remoteButton anchors.centerIn: parent - iconName: "settings_remote" - accessibleName: "Remote control — " + RemoteControl.selectedTargetName + iconName: "cast" + accessibleName: "Playback devices" railStyle: true - focusOnClick: false - selected: root.currentRoute === "remoteControl" - onClicked: { - root.closeRemoteMenu(false) - root.navigate("remoteControl") - } - - Rectangle { - visible: RemoteControl.playbackPending - width: Metrics.scaled(9) - height: width - radius: width / 2 - anchors.right: parent.right - anchors.top: parent.top - anchors.rightMargin: Metrics.scaled(7) - anchors.topMargin: Metrics.scaled(7) - color: Theme.accent - border.width: Theme.focusBorderWidth - border.color: Theme.bgRaised - } + selected: root.remoteMenuOpen || RemoteTargets.selectedTargetId.length > 0 + onClicked: root.remoteMenuOpen ? root.closeRemoteMenu(true) : root.openRemoteMenu() } } Item { + visible: root.groupVisible Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: root.railCellWidth + Layout.preferredWidth: visible ? root.railCellWidth : 0 Layout.fillHeight: true IconButton { - id: syncButton + id: groupButton anchors.centerIn: parent iconName: "groups" - accessibleName: "SyncPlay" + accessibleName: "Watch together" railStyle: true - selected: root.syncPlayMenuOpen - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: { - if (root.syncPlayMenuOpen) - root.closeSyncPlayMenu(false) - else - root.openSyncMenu() - } + selected: root.groupMenuOpen + onClicked: root.groupMenuOpen ? root.closeGroupMenu(false) : root.openGroupMenu() - // Status dot: accent when in a group, green when groups exist to join. Rectangle { - visible: root.syncActive || root.syncAvailable + visible: Group.enabled width: Metrics.scaled(9) height: width radius: width / 2 @@ -449,7 +288,7 @@ FocusScope { anchors.top: parent.top anchors.rightMargin: Metrics.scaled(7) anchors.topMargin: Metrics.scaled(7) - color: root.syncActive ? Theme.accent : Theme.success + color: Theme.accent border.width: Theme.focusBorderWidth border.color: Theme.bgRaised } @@ -458,30 +297,61 @@ FocusScope { } Loader { - id: remoteMenuLoader - width: Metrics.scaled(360) - anchors.top: parent.bottom + id: groupMenuLoader + width: Metrics.scaled(340) + anchors.top: root.edge === "bottom" ? undefined : parent.bottom + anchors.bottom: root.edge === "bottom" ? parent.top : undefined anchors.right: parent.right anchors.topMargin: Metrics.scaled(6) - anchors.rightMargin: Metrics.scaled(64) + anchors.bottomMargin: Metrics.scaled(6) + anchors.rightMargin: Metrics.scaled(14) z: 50 - active: root.remoteControlMenuLoaded - sourceComponent: RemoteControlMenu { - onRequestClose: root.closeRemoteMenu() + active: root.groupVisible && root.groupMenuLoaded + sourceComponent: GroupMenu { + onRequestClose: root.closeGroupMenu() } + onLoaded: item.openMenu() + } + function openRemoteMenu() { + closeGroupMenu(false) + remoteMenuOpen = true + RemoteTargets.setControlsVisible(true) + Qt.callLater(() => { + if (remoteMenuLoader.item) + InputKeys.focus(remoteMenuLoader.item) + }) + } + function closeRemoteMenu(restoreFocus) { + if (!remoteMenuOpen) + return + remoteMenuOpen = false + RemoteTargets.setControlsVisible(RemoteTargets.selectedTargetId.length > 0) + if (restoreFocus) + InputKeys.focus(remoteButton) + } + function containsRemotePoint(item, x, y) { + if (remoteButton.contains(remoteButton.mapFromItem(item, x, y))) + return true + const menu = remoteMenuLoader.item + return Boolean(menu && menu.contains(menu.mapFromItem(item, x, y))) } - Loader { - id: syncMenuLoader - width: Metrics.scaled(320) - anchors.top: parent.bottom - anchors.right: parent.right - anchors.topMargin: Metrics.scaled(6) - anchors.rightMargin: Metrics.scaled(14) + id: remoteMenuLoader + active: root.remoteMenuOpen + width: Math.min(root.width - Metrics.scaled(28), Metrics.scaled(560)) + height: Math.min(item ? item.preferredMenuHeight : Metrics.scaled(360), Math.max(Metrics.scaled(200), + root.parent.height + - root.height - Metrics.scaled( + 20))) + // Avoid mutually exclusive anchors: cached QML can retain both during + // edge changes and calculate a negative menu height. + x: root.width - width - Metrics.scaled(10) + y: root.edge === "bottom" ? -height - Metrics.scaled(10) : root.height + Metrics.scaled(10) z: 50 - active: root.syncPlayMenuLoaded - sourceComponent: SyncPlayMenu { - onRequestClose: root.closeSyncPlayMenu() + sourceComponent: RemoteControlPage { + compact: true + shell: root.shell } + onLoaded: InputKeys.focus(item) } } diff --git a/qml/shell/VideoSurface.qml b/qml/shell/VideoSurface.qml index 828162c4..4491f375 100644 --- a/qml/shell/VideoSurface.qml +++ b/qml/shell/VideoSurface.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../primitives" FocusScope { @@ -84,6 +84,7 @@ FocusScope { // Registered dynamically by main.cpp, so no static .qmltypes entry exists. MpvVideoItem { anchors.fill: parent + hdrOutput: NativeWindow.hdrOutput visible: root.active && Player.embeddedVideoOutput opacity: 1.0 z: 0 diff --git a/qml/theme/Metrics.qml b/qml/theme/Metrics.qml index 57619bb6..4c46c037 100644 --- a/qml/theme/Metrics.qml +++ b/qml/theme/Metrics.qml @@ -114,9 +114,6 @@ QtObject { function cardScaled(value) { return Math.max(1, Math.round(value * cardScale)) } - function chromeScaled(value) { - return Math.max(1, Math.round(value * chromeScale * uiScale)) - } // Pixels back into yardstick units. This is the only honest way to ask // how much room a container has, because it is free of both the panel's // density and the user's zoom. @@ -151,9 +148,6 @@ QtObject { function detailRowPosterWidth() { return cardScaled(176) } - function detailHeroHeight(height) { - return Math.max(scaled(420), Math.min(scaled(660), Math.round(height * 0.64))) - } // One card width serves the library grid and every row that quotes it, so a // home row and the grid behind it land on the same size at any window size // rather than each rounding a table of their own. diff --git a/qml/theme/Theme.qml b/qml/theme/Theme.qml index 544fbce8..55be63d9 100644 --- a/qml/theme/Theme.qml +++ b/qml/theme/Theme.qml @@ -1,6 +1,6 @@ pragma Singleton import QtQuick -import JellyfinWebOS +import Spool QtObject { readonly property color bg: "#0E0E0E" diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt new file mode 100644 index 00000000..42eb2371 --- /dev/null +++ b/sdk/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.22) +project(SpoolProviderContract LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 REQUIRED COMPONENTS Core Qml) +add_executable(provider-contract-runner provider-contract-runner.cpp) +target_link_libraries(provider-contract-runner PRIVATE Qt6::Core Qt6::Qml) +install(TARGETS provider-contract-runner RUNTIME DESTINATION bin) diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 00000000..613b7c0e --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,441 @@ +# Spool provider SDK (API 0.2) + +A provider teaches Spool a media source: a server, a service, a folder. It is a +small package of JavaScript (the logic) and optional QML (its own sign-in, +settings and picker screens). Spool runs every provider the same way, whether +bundled with the app, installed from the store or added from a link. + +| File | What it is | +| --- | --- | +| `provider.d.ts` | The contract: `createSource`, every operation, events and the screen context | +| `spool-provider.py` | Builds, validates and describes packages (`build`, `validate`, `feed`) | +| `provider-contract-runner.cpp` | Runs a provider's `tests/contract.mjs` in Qt's JS engine, as Spool does | + +`spool-player/spool-provider-example` is a complete provider to start from. + +## Packages + +``` +manifest.json format 2 (below) +LICENSE, NOTICE +logic/*.mjs entry module exporting createSource(configuration, host) +ui/*.qml optional screens named in manifest.ui +assets/ icon and anything else the screens show +``` + +```json +{ + "format": 2, "api": "0.2", + "id": "publisher.name", "name": "Shown name", "version": "1.2.3", + "summary": "One line, up to 120 characters", "publisher": "You", "homepage": "https://…", + "icon": "assets/icon.svg", "entry": "logic/provider.mjs", + "capabilities": ["search", "userState", "reporting", "segments", "streamQuality", "trickplay", + "discovery", "groupPlayback", "remoteControl", "speedTest"], + "origins": ["https://api.example.org"], + "ui": { "login": "ui/Login.qml", "settings": "ui/Settings.qml", "picker": "ui/Picker.qml" }, + "actions": [{ "id": "playlist", "label": "Add to playlist", "icon": "playlist_add", "types": ["Movie"] }] +} +``` + +- A provider with a `login` screen needs an account; one without is added straight away. +- `origins` are reachable by every account; `*` allows any HTTP(S) origin. Anything else an account + reaches is what its login screen allowed with `provider.allowOrigin(url)`. +- `actions` appear in the item menu for the listed types and run through `runItemAction`. +- Packages are `.tar.zst` (ustar, zstd), at most 16 MiB, 512 files, 32 MiB expanded. Paths are + relative, without hidden parts, of the listed types; links and native binaries are refused. + +``` +python3 sdk/spool-provider.py build path/to/provider # dist/-.tar.zst +python3 sdk/spool-provider.py validate dist/-.tar.zst +python3 sdk/spool-provider.py feed dist/-.tar.zst --url https://…/-.tar.zst +``` + +Building is reproducible. It needs Python 3.14, or the `zstd` command on older Pythons. + +## Running + +Each provider module gets one worker thread and QJSEngine; `createSource` is called once per account +with that account's configuration and a host that lives as long as the account. Keep account state in +that closure. Operations are called as `operation(args, host)` and return a plain value or a Promise. +Throw `new Error('snake_case_code')` to fail: the code reaches Spool (`http_401` asks the viewer to sign +in again), anything else becomes `provider_error`. ES2020 modules and Promises only: no `async`/`await`, +no Node or browser globals, and Qt's engine lacks some newer built-ins such as `Array.prototype.flatMap`. + +| Limit | | +| --- | --- | +| Uninterrupted script | 500 ms (worker CPU time on Windows, excluding loader I/O and descheduling); exceeding it turns the module off until restarted | +| Operation | settles within 15 s; eight in flight per account | +| HTTP | four at once per operation, 1 MiB bodies, 8 MiB responses, redirects returned not followed, no cookies | +| Sockets | `host.socket` on the source host, four per account | +| Timers | `host.delay`: 0–60 s on the source host, 0–10 s in an operation, 16 pending | +| Results | 50,000 values, depth 20, arrays of 10,000, 4 MiB of text; ticks as decimal strings | + +This is a reviewed, in-process profile, not a sandbox: install providers you trust. + +### Provider logging + +Both hosts expose `isLogEnabled(level)` and `log(level, message, fields?)` for +`trace`, `debug`, `info`, `warn`, and `error`. Messages may be strings or lazy +zero-argument functions returning strings. The native guard runs before the +function, field access, conversion, redaction, or JSON encoding. There is no +`console` shim and providers must not build a separate logger or log sink. + +```js +host.log('debug', function() { return 'Catalogue request completed'; }); +if (host.isLogEnabled('trace')) { + host.log('trace', 'Catalogue page', { count: items.length, status: response.status }); +} +``` + +Use the guard before expensive formatting or constructing `fields`; JavaScript +evaluates ordinary arguments before calling `log`. Fields are flat JSON scalars: +at most 16 identifier-like keys (48 characters), string values up to 256 +characters, and a 1,536-character aggregate budget. Non-scalar fields are ignored. +Messages exceeding 2,048 UTF-16 units are replaced with a limit marker; final +native lines are limited to 4,096 units and control characters are flattened. + +Logs use the same Qt filtering and application log sink as native diagnostics: +`spool.provider` maps debug/info/warn/error to Qt debug/info/warning/critical. +Info and above are enabled by default. Trace uses Qt debug severity on the +separate, default-off `spool.provider.trace` category, with a `trace:` label. +For example, `QT_LOGGING_RULES='spool.provider.debug=true;spool.provider.trace.debug=true'` +enables both diagnostic levels; `spool.provider.info=false` disables info. +Filters are checked for every call, so no per-provider cached enablement flags. + +Spool adds the trusted provider ID and a short opaque account fingerprint, not +account labels, usernames, server addresses or configuration. URLs, recognizable +credentials and personal fields are always redacted, even with +`--unredacted-urls`. Textual Cookie, Set-Cookie and Authorization values are +redacted in both header lines and quoted JSON, including credentials acquired +after source creation. Known credentials in source configuration are also removed +when present as bare message/field text. This is defense in depth, not permission +to log secrets: never log credentials, cookies, authentication/request/response +bodies, signed stream URLs, titles or torrent hashes. Use stable event descriptions, +counts, timing and HTTP status codes instead. + +## Connection speed + +Declare `speedTest` when the service offers a bounded download endpoint, then +implement the operation using the native operation host: + +```js +speedTest(args, host) { + return host.speedTest({ + url: server + "/download-test?bytes={bytes}&nonce={nonce}", + headers: { Authorization: authorization() } + }); +} +``` + +Spool substitutes `{bytes}` and a unique `{nonce}` for each request. Return +exactly that many uncompressed bytes with HTTP 200. The URL must stay on an +allowed HTTP(S) origin; redirects, cookies, truncated and oversized samples +are rejected. HTTP errors remain `http_NNN`, including `http_401`. + +For servers without a generated test endpoint, pass +`{url: mediaUrl, headers: authorizationHeaders, range: true}` for an accessible +static media file at least 4 MiB long. Spool supplies bounded `Range` headers, +requires HTTP 206 and an exact `Content-Range` with a valid total size, and +rejects servers that ignore ranges. URL placeholders are optional in this mode. +Each parallel round divides the first 4 MiB into disjoint ranges; cache-control +requests bypass HTTP caches. The measurement includes media-server storage +and transport overhead, without starting playback or a transcoding session. +Providers cannot supply their own `Range` or compression headers. + +The worker warms 512 KiB, measures one/two connections with 4 MiB totals, and +tries four if warmup time-to-first-byte is at least 20 ms or two improve the +rate by at least 10%. It chooses the fewest lanes within 85% of the fastest, +returns that lane count's rate with 25% headroom, and clamps to 1–1000 Mbps. +The result is `{bitrate, parallelRequests}`. Bodies never reach JS; the probe +reserves the operation's HTTP slots and shares its 15-second deadline and +cancellation. `speedTest` exists only on the operation host, not the source host. + +Spool schedules probes while idle and passes each account's result back in +`PlaybackContext.measuredBitrate` (zero before measurement) and +`parallelRequests` (two before measurement). Use the measured ceiling only +when the viewer has not chosen a session or settings limit. Spool shows the +result under Quality → Auto and in Streaming settings. +An in-flight bounded probe finishes even if playback starts. New automatic +probes wait for idle; an explicit refresh may measure during playback. + + +## Quality policy + +Quality limits are backend-neutral ceilings in bits/second and pixels, not +transcoder presets. Providers translate them into their service's negotiation +or source selection. Keep the same precedence across providers: + +1. A nonzero player `maxBitrate` overrides automatic bitrate selection. +2. `unlimitedLocalNetwork` applies only when the media server positively + identifies this connection as local; a failed lookup never implies local. +3. Otherwise use `preferredMaxBitrate`, then `measuredBitrate`, then a + provider-documented fallback. An explicit choice may exceed the measurement. +4. Independently use `maxHeight`, then `preferredMaxHeight`; zero means no + height ceiling. A local-network bitrate exemption does not remove it. + +For Jellyfin and Emby, send these limits in PlaybackInfo and DeviceProfile; +for Plex, translate bits/second to the server's kbit/second bandwidth setting +and negotiate whether the selected media can direct play, remux or transcode. +Never treat a remux preference as permission to exceed a quality ceiling. +Preserve an explicitly selected edition rather than silently substituting one. +`Resolved.source` can supply fresh selected-edition bitrate and pixel dimensions +from playback negotiation. Quality menus use this original analysis rather than +a stale catalogue summary, a measured automatic limit, or transcoded output. + + +`Resolved.timelineOriginTicks` identifies the source position represented by +normalized media time zero (zero when omitted). A server-started HLS stream +that already begins at the resume point must advertise that origin, so Spool +seeks only the remaining fraction instead of seeking the full resume offset +again. UI positions, reports, chapters and segment boundaries remain absolute +source positions. Seeking before the stream origin resolves a fresh stream. +Direct files and full-timeline streams keep origin zero. + +A source-only service need not expose a transcoder. A future Stremio-style +provider can use the same context to select among known stream variants and +return `pick` for its provider-owned QML picker when a choice is needed. +[Stremio's stream contract](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/api/responses/stream.md) +offers descriptive names and optional `videoSize`, not a standardized numeric +bitrate, height or transcoding API. Do not infer reliable constraints from a +quality label alone; size and duration give average bitrate, not peak demand. +Unknown variants should remain visibly unknown in the picker rather than +being presented as satisfying a limit. + +Only measure an endpoint on the actual media route. A generic Internet speed +test, an add-on catalogue host, or a local torrent gateway does not establish +throughput from the stream's CDN or peers. If the service has no suitable +bounded endpoint, omit `speedTest`; retain explicit quality/source choices +instead of inventing a measurement. Per-account measurements are appropriate +for a fixed media server, not interchangeable across arbitrary stream origins. + +## Offline downloads + +Declare `downloads` for original media and additionally `downloadTranscode` only +when the server can produce a finite, complete encoded media file. Implement +`download({itemId, mode, maxBitrate?, maxHeight?, variantId?}, host)` and return +`{url, container, headers?, size?, cleanup?}`. `mode` is `original` or `transcoded`; +encoding is performed by the server, never on the viewer's device. `container` +is a local-playable media suffix, such as `mp4`, `mkv`, or `flac`. + +The endpoint must finish at EOF and contain the whole item from its beginning. +An HLS/DASH manifest, live resource, or saved online playlist is not a download. +Progressive finite server endpoints are supported, including responses without +a known length. The host streams bounded chunks to a temporary file and publishes +the copy only after success. URLs must be approved account origins; native requests +reuse account TLS trust, reject redirects, omit cookies, and use only the returned +headers. Do not put credentials in filenames or metadata. + +A provider-owned release/file picker can return `PickRequest`; the host repeats +`download` with the submitted choice merged in, preserving the selected item, +mode and quality ceilings. A `cleanup` object is opaque and lives only in memory. +Implement `downloadRelease({cleanup}, host)` when server-session cleanup is needed; +it is called on completion, cancellation and failure. Interrupted downloads are +shown as retryable failures after restart, not silently resumed with stale URLs. + +## Artwork ownership + +An image tag belongs to an item, not necessarily the row that displays it. +When a thumbnail or backdrop is inherited, return `thumbItemId` or +`backdropItemId` alongside its tag. Omit the owner for the row's own image. +Spool scopes these opaque IDs to the account and preserves them in cached +media rows; home cards and details request the image from that owner. +Do not attach a parent's tag to a child without its owner ID. Series posters +and album covers retain their existing `seriesId`/`albumId` ownership. + +## Seek previews + +Return `resolve().trickplay` for the selected media variant, not a global URL +template in `describe()`. Sprite sheets use +`{width, height, columns, rows, count, intervalMs, urlTemplate}`; the absolute +HTTP(S) template has one `{index}` substitution. BIF sequences use +`{format: "bif", url}` with optional `width`/`height`. Pass the whole sequence +URL, not individual JPEGs: C++ parses its timestamp/offset index and decodes +the selected frame. Missing server-generated previews mean omit `trickplay`. + +`PlaybackContext.videoPreviews` is the global seek-preview preference (default +true). When false, omit `trickplay` and do not request/prefetch preview-only +metadata. `details`, `remoteConnect` and `remoteState` receive the same +`videoPreviews` boolean: skip preview-only metadata and omit `preview` when +disabled. Metadata required for playback, tracks, or the ordinary catalogue remains independent. +The native loader immediately cancels active preview work and clears decoded +frames when the viewer turns previews off. + +Both formats use the account's `resolve().headers`; keep tokens out of URLs. +The native loader prefetches the resume preview, caches bounded preview data +separately from posters, and uploads only the requested frame. While loading +or on failure, the player shows no preview frame or black placeholder. + +## Screens + +A screen is mounted with a `provider` property (`ScreenContext` in `provider.d.ts`) and may +`import QtQuick`, `QtQuick.Layouts`, `QtQuick.Controls`, `QtQml`, `QtQml.Models` and `Spool` (the +app's theme, metrics, input keys and primitives). `request()` calls an operation of this account; +`requestList()` streams `items` into the native `rows` model; `complete()` or `close()` settles the +screen once. Map error codes to your own words. + +## Testing + +``` +cmake -S sdk -B build/sdk && cmake --build build/sdk +build/sdk/provider-contract-runner tests/contract.mjs +QV4_FORCE_INTERPRETER=1 build/sdk/provider-contract-runner tests/contract.mjs +``` + +`tests/contract.mjs` exports `run()`, which returns a Promise or throws; the runner prints why a +contract failed and gives up after 10 seconds. + +## Publishing + +Attach the package and its `spool-provider.json` (the `feed` output) to each release. Spool can then +install it from a link to the repository: GitHub resolves to +`releases/latest/download/spool-provider.json`, GitLab to +`-/releases/permalink/latest/downloads/spool-provider.json`, and any other site to +`/spool-provider.json` at the address given. Installed providers are updated from the same place. + +To be listed in the store, open a pull request on `spool-player/spool-providers` adding +`providers/.json` with that feed entry. CI downloads the package, checks its digest and validates +it; once merged, the store site is rebuilt and the provider appears in Spool. + +## Pagination and collection occurrences + +Pages carry opaque continuations: omit `cursor` for the first request and pass +the returned token unchanged thereafter. Only `exhausted: true` ends a listing; +short or empty nonterminal pages are valid when their cursor advances. Missing, +empty or repeated nonterminal cursors fail with `invalid_pagination`. +Collectors stop after 256 pages; collect-all also has a 10,000-row bound. +Exceeding either bound without completion reports `response_limit`. + +`Item.entryId` identifies an occurrence within its container. It is not a media +ID and is never account-prefixed. Duplicate media IDs retain separate entry IDs +and queue occurrences. ID lookups fetch at most 50 unique IDs per request and +reconstruct the original requested order, including duplicates and omitting +missing rows. + +## Optional extensions + +Keep manifest `format: 2`, `api: "0.2"` and the existing baseline capability +names. Declare optional features in a top-level `extensions` object, for example +`{"spool.speed-test": 1}`. Values are exact positive wire-major integers +(1 through 2147483647), not minimum versions. Declarations allow at most 32 +namespaced IDs of at most 128 characters. Unknown valid IDs or majors do not +prevent baseline loading; malformed declarations are rejected. + +Both source and operation hosts expose the same frozen `host.extensions` map +of supported declared versions. Negotiate exact feature versions from this map, +never infer support from the application version. +Return account/server offers in `describe().extensions`; effective support is +the exact intersection of declarations, host support and account offers. +`host.emit('extensionsChanged', {extensions})` replaces only that account's +offers; support loss cancels affected calls and updates controls. + +Optional operations are checked before provider execution and fail with +`unsupported_extension` when unavailable. Providers must also check support. +Speed-test implementations declare `spool.speed-test`; inherited artwork owners +require `spool.artwork-owners`. When that feature is unavailable, retain own artwork +and ordinary series/album fallback without inherited child tags. + +Provider and host builds are released together against the current prerelease +contract. Older host compatibility is not supported; do not add aliases or logging +fallbacks to make current providers run on frozen host glue. Missing declared host +features and server permission/endpoint failures remain distinct conditions. + +### Optional network facilities + +`spool.http-metadata` allows `host.http` to request up to 16 response-header +names. Only those lowercase names are returned, with a 64 KiB aggregate bound; +cookie-setting headers are forbidden. Redirect and cookie policy is unchanged. + +`spool.origin-grants` lets account settings/pickers request an exact HTTP(S) +origin. A host-owned confirmation names the provider, account and origin, +including an unencrypted-HTTP warning. Approval updates the worker allowlist +and persists without restarting the source; denial or stale consent grants +nothing. Certificate trust remains separate. + +`spool.lan-probe` is available only to a login draft after explicit +`provider.allowLanDiscovery()` consent. `host.probeLocalHttp` probes at most +32 targets per page with four concurrent requests, a 600 ms wall deadline and +4 KiB response bodies. It scans at most two ranked private/link-local IPv4 +networks (254 targets each), never public or IPv6 subnets. Opaque single-use +cursors belong to that draft's snapshot. Requests carry no credentials or +cookies and follow no redirects. Discovery does not grant authenticated access: +normal server selection still calls `allowOrigin`. Cancel/Back uses +`cancelLanDiscovery()` without disabling password or UDP login. + +### Catalogue and queue contracts + +`spool.suggestions` provides a bounded recommendation set. No extension means +no suggestions section; Continue Watching is not substituted. Search remains +a bounded top-N query with progressive account delivery. + +`spool.item-actions` fetches permission-aware actions when a menu opens. +`spool.collection-editing` edits container-local occurrence IDs, preserving +duplicates. The host supplies both the post-removal index and preceding entry +ID for moves; the provider chooses its native move style. Unordered/read-only +containers do not offer movement. Mutations are serialized and uncertain +results trigger a refresh, not an assumed rollback. + +`spool.playback-queue-reporting` adds an immutable queue snapshot only on start, +restart or membership/order revision. Ordinary progress can carry the current +index without recopying the queue. Each account receives only its own entries. +Unknown exact occurrence indexes are omitted rather than guessed. +PMS queue preparation runs on the source host and emits sanitized +`playbackQueueStatus` events; failure is nonfatal to ordinary playback reports. +Mixed audio/video queues cannot be represented by one PMS queue. Bulk append +is verified against count/order and occurrence IDs, with read-back before +reconciliation after uncertain mutations; fixture success is not a live-server +compatibility guarantee. + +Protected `spool.remote-targets` state uses the same sheet/BIF descriptor in +`preview`, with an optional `headers` map. Use the same account/device +authorization as media requests; never put tokens in preview query strings. +The host keeps headers out of QML, validates the approved HTTP(S) origin and +numeric `{index}` substitution, and decodes previews through the dedicated +native loader. Authenticated previews bypass URL-only disk caching and cookies, +reject foreign-origin redirects, and keep cached data isolated by session. + +### Native preferences and application data + +`spool.playback-preferences` exposes the service's own audio/subtitle defaults. +Language values use ISO-639-2 (empty means no preference); the host normalizes +two-letter codes through Qt. A writable mode must round-trip its full normalized +vocabulary. Writes merge only the four mapped fields into a freshly fetched +configuration and preserve unrelated fields and user policy. + +`spool.settings-storage` stores application-owned JSON documents by canonical +UUID, not filesystem or service paths. Values include JSON null; `found:false` +alone means absence. Documents are bounded to 64 KiB of compact UTF-8 JSON and +depth 16, or the provider's smaller advertised limit. Malformed, oversized or +inaccessible documents are errors, never empty documents to overwrite. + +`dataInfo.conditionalWrites` describes actual server guarantees. With CAS, +`expectedRevision:null` means create-if-absent and a stale revision is +`conflict`. Replacement-only implementations reject any expected revision with +`unsupported_condition`. Jellyfin/Emby DisplayPreferences preserve the whole +DTO and unrelated CustomPrefs while changing only `spool.data.v1` in the +signed-in user's Spool partition; they advertise no CAS. Plex advertises +neither a preference writer nor application-data storage. + +### Discovery and contextual screens + +`host.discover({port, message, timeout})` sends the provider's UDP discovery message +on active IPv4 broadcast interfaces and returns an ordinary JavaScript array of +`{address, text}` replies. Jellyfin/Emby discovery uses broadcast, not multicast. +The host owns sockets, reply limits and cancellation; providers parse their own +protocol. Retry broadcast discovery when the viewer chooses local search, before +the optional consented `spool.lan-probe` HTTP fallback. HTTP continuation pages can +be short or empty when their wall deadline expires: follow each new cursor until +`exhausted`, rather than assuming every page scanned the requested target limit. + +Provider QML is contextual. A playback `pick` result opens `ui.picker` over the +current details page with a dimmed background and returns its completed arguments +to resolution. Closing cancels the pending choice; it does not replace the details +route. Providers can compose sections within their screens (for example, an inline +Quick Connect code underneath password sign-in). + +`spool.remote-targets` supplies the shared device dropdown's target, state, command +and queue data. Set a target's `customControls` when it also needs provider-owned +QML. The host mounts `ui.picker` with `{kind: "remoteControls", targetId}` as a +section inside the dropdown; `provider.complete`/`close` returns to shared device +controls. Use layouts that adapt to the available width and height. Full-page +remote controls can also open the same component as a modal overlay. Providers +never need to navigate a shell route to add these controls. diff --git a/sdk/provider-contract-runner.cpp b/sdk/provider-contract-runner.cpp new file mode 100644 index 00000000..5f1e95c2 --- /dev/null +++ b/sdk/provider-contract-runner.cpp @@ -0,0 +1,58 @@ +#include +#include +#include +#include +#include +#include + +class ContractResult final : public QObject { + Q_OBJECT +public: + using QObject::QObject; + Q_INVOKABLE void complete(bool success, const QString& reason = {}) + { + if (settled) + return; + settled = true; + std::fprintf(success ? stdout : stderr, "Qt %s provider contract %s%s%s\n", qVersion(), + success ? "passed" : "failed", reason.isEmpty() ? "" : ": ", qPrintable(reason)); + QCoreApplication::exit(success ? 0 : 1); + } + +private: + bool settled = false; +}; + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + if (app.arguments().size() != 2) { + std::fprintf(stderr, "usage: provider-contract-runner tests.mjs\n"); + return 2; + } + QJSEngine engine; + ContractResult result; + QJSEngine::setObjectOwnership(&result, QJSEngine::CppOwnership); + QTimer::singleShot(0, &app, [&] { + const QJSValue module = engine.importModule(QFileInfo(app.arguments()[1]).absoluteFilePath()); + if (module.isError() || !module.property(QStringLiteral("run")).isCallable()) { + std::fprintf(stderr, "Contract module did not load or export run(): %s\n", + qPrintable(module.isError() ? module.toString() : QStringLiteral("missing run"))); + result.complete(false); + return; + } + QJSValue invoke = engine.evaluate(QStringLiteral(R"JS( + (function(module, result) { + try { + Promise.resolve(module.run()).then(function() { result.complete(true); }, + function(error) { result.complete(false, String(error) + "\n" + (error && error.stack || "")); }); + } catch (error) { result.complete(false, String(error) + "\n" + (error && error.stack || "")); } + }) + )JS")); + invoke.call({ module, engine.newQObject(&result) }); + }); + QTimer::singleShot(10000, &app, [&] { result.complete(false, QStringLiteral("timed out")); }); + return app.exec(); +} + +#include "provider-contract-runner.moc" diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts new file mode 100644 index 00000000..b504f161 --- /dev/null +++ b/sdk/provider.d.ts @@ -0,0 +1,496 @@ +/** + * Spool provider API 0.2. + * + * A provider is an ES module exporting `createSource(configuration, host)`. + * Spool calls it once per account; the returned object's methods are the + * operations below, each called as `operation(args, host)` and returning a + * plain object or a Promise of one. Every operation is optional except + * `describe`; implement what the service supports and declare it in + * manifest.json `capabilities`. + * + * Runs on a worker thread in Qt's JS engine: ES2020 modules and Promises, no + * Node or browser globals, no async/await. 500 ms of uninterrupted script + * disables the module, and each operation must settle within 15 seconds. + */ + +export type Value = null | boolean | number | string | Value[] | { [key: string]: Value }; + +/** Exact wire-major negotiation for optional declared features on the current host. */ +export type Extensions = Readonly>; + +export interface HttpOptions { + method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record; + body?: string; + /** spool.http-metadata v1: at most 16 response header names; never Set-Cookie. */ + responseHeaders?: string[]; +} +/** Redirects are not followed: `status` is 3xx and `location` is set. */ +export interface HttpResponse { + status: number; body: string; location?: string; + /** Requested lowercase names only, bounded to 64 KiB; absent unless requested. */ + headers?: Record; +} + +/** Authenticated generated download endpoint, or a range-capable media resource. */ +export interface SpeedTestEndpoint { + url: string; // HTTP(S); generated endpoints require {bytes} and {nonce}. + /** Probe a media file of at least 4 MiB with native Range requests and strict HTTP 206 validation. */ + range?: boolean; + headers?: Record; +} +/** Conservative playback ceiling (bits/s), not raw link capacity. */ +export interface SpeedTestResult { bitrate: number; parallelRequests: 1 | 2 | 4 } + +export interface Socket { + onopen: (() => void) | null; + onmessage: ((text: string) => void) | null; + onclose: ((code: number) => void) | null; + send(text: string): void; + close(): void; +} + +export interface Device { + /** Stable per install and per running instance; present it to servers. */ + id: string; + name: string; + app: 'Spool'; + version: string; + platform: string; + /** BCP 47. */ + locale: string; +} + +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; +/** Flat diagnostic metadata, never authentication, request bodies or media URLs. */ +export type LogFields = Readonly>; + +/** + * Given to createSource and lives as long as the account: use it for + * connections that outlast an operation. Everything stops when the account + * is removed, disabled or the provider is updated. + */ +export interface SourceHost { + device: Device; + /** Frozen host-supported exact versions requested in manifest.extensions. */ + readonly extensions?: Extensions; + /** Native category guard; check before constructing expensive diagnostic fields. */ + isLogEnabled(level: LogLevel): boolean; + /** Lazy message runs only when enabled. Native redaction/bounds always apply. */ + log(level: LogLevel, message: string | (() => string), fields?: LogFields): void; + /** Only origins the account was set up with (or manifest `origins`). */ + http(url: string, options?: HttpOptions): Promise; + /** 0–60000 ms. */ + delay(milliseconds: number): Promise; + /** ws:// or wss:// on an allowed origin; at most four open. */ + socket(url: string, options?: { headers?: Record }): Socket; + /** Push to Spool: see Events. */ + emit(type: K, payload: Events[K]): void; +} + +/** Given to each operation; its requests are cancelled with it. */ +export interface OperationHost extends SourceHost { + /** 0–10000 ms. */ + delay(milliseconds: number): Promise; + /** UDP broadcast on the local network; replies within `timeout` ms (100–5000). */ + discover(options: { port: number; message: string; timeout?: number }): Promise<{ address: string; text: string }[]>; + /** spool.lan-probe v1, login draft after allowLanDiscovery consent only. + * No authentication, cookies, redirects or origin grants. Up to 32 targets, + * four concurrent requests and 600 ms per page; bodies at most 4 KiB. + */ + probeLocalHttp?(options: { port: number; path: string; cursor?: string; limit?: number }): Promise<{ + responses: { origin: string; status: number; body: string }[]; + cursor: string | null; exhausted: boolean; + }>; + /** + * Measures on the native provider worker, discarding response bodies. + * Same origin/TLS policy as http; no redirects or cookies. Cancelled with + * this operation. Generated endpoints return exactly the requested bytes; + * range endpoints must honor the native Range header and Content-Range. + * Warms 512 KiB, then compares 4 MiB totals over one, two and optionally + * four connections. Result reserves 25% headroom and is bounded to 1–1000 Mbps. + */ + speedTest(endpoint: SpeedTestEndpoint): Promise; +} + +/** + * `configuration` is what the login screen completed with (empty while that + * screen is being shown). Keep account state in the closure, not in module + * globals: one module serves every account of this provider. + */ +export type CreateSource = (configuration: Record, host: SourceHost) => Source; + +/** Throw `new Error('code')` with a short snake_case code; Spool shows no provider text. `http_401` marks the account as needing sign-in. */ +export type Operation = (args: A, host: OperationHost) => R | Promise; + +export interface Page { items: Item[]; cursor: string | null; total?: number | null; exhausted: boolean } +export interface PageArgs { cursor?: string; limit: number } + +export type SortBy = 'SortName' | 'Random' | 'CommunityRating' | 'CriticRating' | 'DateCreated' | 'DateLastContentAdded' + | 'OfficialRating' | 'PremiereDate' | 'PlayCount' | 'Runtime' | 'DatePlayed' | string; + +/** The viewer's library filters; each is present only while set. */ +export interface BrowseFilters { + filters?: ('IsPlayed' | 'IsUnplayed' | 'IsFavorite' | 'IsResumable')[]; + genres?: string[]; years?: string[]; officialRatings?: string[]; tags?: string[]; studioIds?: string[]; + seriesStatus?: string[]; videoTypes?: string[]; includeItemTypes?: string[]; + isHd?: boolean; is4K?: boolean; is3D?: boolean; isHdr?: boolean; hasSubtitles?: boolean; hasTrailer?: boolean; + hasSpecialFeature?: boolean; hasThemeSong?: boolean; hasThemeVideo?: boolean; specialEpisode?: boolean; + isMissing?: boolean; isUnaired?: boolean; + /** Titles starting with this letter, or '#' for anything before A. */ + alphabet?: string; +} + +export interface Item { + id: string; + /** Opaque occurrence identity within a collection; not a media ID. */ + entryId?: string; + title: string; + type: 'Movie' | 'Series' | 'Season' | 'Episode' | 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist' | 'BoxSet' + | 'Folder' | 'Video' | 'MusicVideo' | 'Book' | 'Photo' | 'PhotoAlbum' | 'TvChannel' | string; + sortName?: string; overview?: string; year?: number; + /** 100 ns ticks; send as a decimal string when beyond 2^53. */ + runtimeTicks?: number | string; resumeTicks?: number | string; + favorite?: boolean; played?: boolean; playCount?: number; childCount?: number; virtual?: boolean; + datePlayed?: string; dateCreated?: string; dateUpdated?: string; premiereDate?: string; endDate?: string; status?: string; + seriesId?: string; seriesName?: string; seasonId?: string; season?: number; episode?: number; + album?: string; albumId?: string; albumArtist?: string; + /** Passed back as `{tag}` in the artwork template, or used as-is when it is an https URL and there is no template. */ + posterTag?: string; backdropTag?: string; logoTag?: string; bannerTag?: string; thumbTag?: string; + /** Owner of an inherited image; omitted for this item's own image. Scoped by the host like other item IDs. */ + backdropItemId?: string; thumbItemId?: string; + seriesPosterTag?: string; albumPosterTag?: string; + genres?: string[]; tags?: string[]; studios?: string[]; + officialRating?: string; communityRating?: number; criticRating?: number; + externalIds?: Record; + links?: { name: string; url: string }[]; + people?: { id: string; name: string; type?: string; role?: string; imageTag?: string }[]; + variants?: Variant[]; +} + +export interface Variant { + id: string; label?: string; container?: string; filename?: string; + sizeBytes?: number | string; bitrate?: number; runtimeTicks?: number | string; streams?: Stream[]; +} + +export interface Stream { + index: number; type: 'Video' | 'Audio' | 'Subtitle'; codec?: string; profile?: string; language?: string; + title?: string; width?: number; height?: number; frameRate?: number; bitrate?: number; bitDepth?: number; + channels?: number; sampleRate?: number; range?: string; rangeType?: string; + default?: boolean; forced?: boolean; external?: boolean; interlaced?: boolean; + /** An external subtitle file on the stream's own origin, fetched with its headers. + * External subtitles without one cannot be shown and are left out of the player. */ + url?: string; +} + +/** Merged into every resolve call by Spool. */ +export interface PlaybackContext { + /** The viewer's pick in the player; 0 is automatic. */ + maxBitrate: number; maxHeight: number; + /** The standing preference from settings. */ + preferredMaxBitrate: number; preferredMaxHeight: number; preferRemux: boolean; unlimitedLocalNetwork: boolean; + /** What this device decodes when `restrictVideoCodecs`; otherwise anything. */ + videoCodecs: string[]; restrictVideoCodecs: boolean; + /** This account's measured conservative ceiling; zero until a successful idle probe. */ + measuredBitrate: number; + /** Native playback range-request budget selected by the probe; two before measurement. */ + parallelRequests: 1 | 2 | 4; + /** Global seek-preview preference, default true. When false, omit preview descriptors and skip preview-only metadata requests/prefetch. */ + videoPreviews: boolean; +} + +export interface Resolved { + url: string; headers?: Record; variantId: string; playSessionId?: string; + playMethod?: 'DirectPlay' | 'DirectStream' | 'Transcode'; container?: string; + /** Source ticks represented by the normalized media stream's time-pos zero; omitted means zero. + * Playback positions, reporting and segments remain in source coordinates. + */ + timelineOriginTicks?: string; + /** Fresh original-edition analysis, never the negotiated output or an automatic ceiling. */ + source?: { bitrate: number; width: number; height: number }; + streams?: Stream[]; segments?: Segment[]; + /** Native preview loader fetches/caches sheets or one BIF sequence using this account's headers. */ + trickplay?: { width: number; height: number; columns: number; rows: number; count: number; intervalMs: number; + urlTemplate: string; format?: 'sprites'; headers?: Record } | + { format: 'bif'; url: string; width?: number; height?: number; headers?: Record }; +} +/** Answer resolve with this to show the provider's `picker` screen first; Spool calls resolve again with what it completes with merged in. */ +export interface PickRequest { pick: Record } + +/** Finite complete media file, original or encoded by the provider's server. + * HLS/DASH manifests and live streams are not download endpoints. + * All URLs must be approved account origins. Headers belong only to this file. + */ +export interface DownloadPlan { + url: string; + container: string; + headers?: Record; + size?: number; + /** Opaque server session cleanup; kept in memory, never persisted. */ + cleanup?: Record; +} +export interface DownloadArgs { + itemId: string; + mode: 'original' | 'transcoded'; + maxBitrate?: number; + maxHeight?: number; + variantId?: string; +} + +export interface Segment { type: 'Intro' | 'Outro' | 'Recap' | 'Preview' | 'Commercial'; startTicks: number | string; endTicks: number | string } + +// Optional catalogue extensions, wire major 1. +export interface ItemAction { + id: string; label: string; icon?: string; enabled?: boolean; reason?: string; +} +export interface CollectionInfo { + ordered: boolean; removable: boolean; moveMode: 'none' | 'index' | 'after'; +} +export interface PlaybackQueueSnapshot { + revision: string; + items: { itemId: string; entryId?: string; mediaType: 'audio' | 'video' }[]; +} +export interface CatalogueExtensions { + /** spool.suggestions: bounded recommendations, not Continue Watching. */ + suggestions?: Operation; + /** spool.item-actions: load on menu opening, not per rendered row. */ + itemActions?: Operation<{ itemId: string; itemType: string; containerId?: string; entryId?: string }, + { actions: ItemAction[] }>; + /** spool.collection-editing: every returned row carries its container-local entryId. */ + collectionInfo?: Operation<{ containerId: string }, CollectionInfo>; + collectionEntries?: Operation; + collectionRemove?: Operation<{ containerId: string; entryId: string }, {}>; + /** index and afterEntryId describe the same destination after removing the moving entry. */ + collectionMove?: Operation<{ containerId: string; entryId: string; index: number; afterEntryId: string | null }, {}>; +} + +// Optional preference and application-data extensions, wire major 1. +export interface PreferenceValues { + /** ISO-639-2, or empty for no preference. */ + audioLanguage?: string; + audioMode?: 'Default' | 'Smart'; + subtitleLanguage?: string; + subtitleMode?: 'Default' | 'Smart' | 'OnlyForced' | 'Always' | 'None'; +} +export interface PreferenceExtensions { + preferencesRead?: Operation<{}, { values: PreferenceValues; writable: (keyof PreferenceValues)[] }>; + preferencesWrite?: Operation<{ values: Partial }, {}>; +} +export interface ApplicationDataExtensions { + /** spool.settings-storage: maxBytes cannot exceed the host's 64 KiB/depth-16 bound. */ + dataInfo?: Operation<{}, { maxBytes: number; conditionalWrites: boolean }>; + /** key is a canonical application-owned UUID, not a path; found distinguishes absent from null. */ + dataRead?: Operation<{ key: string }, { found: boolean; value?: Value; revision?: string }>; + /** null expectedRevision is create-if-absent. Unsupported conditions must reject, not be ignored. */ + dataWrite?: Operation<{ key: string; value: Value; expectedRevision?: string | null }, { revision?: string }>; + dataDelete?: Operation<{ key: string; expectedRevision?: string | null }, {}>; +} + +// Optional outbound remote control, spool.remote-targets wire major 1. +// This is independent of inbound Events.remote and remoteControl capability. +export interface RemoteTarget { + id: string; name: string; detail?: string; origins?: string[]; commands: string[]; + queueEditing: 'none' | 'replace' | 'in-place'; customControls?: boolean; +} +export interface RemoteState { + state: 'stopped' | 'playing' | 'paused' | 'buffering' | 'error'; + commands: string[]; + item?: Item; + /** Decimal ticks; absence means unknown, not zero. */ + positionTicks?: string; runtimeTicks?: string; + volume?: number; muted?: boolean; rate?: number; + repeatMode?: 'RepeatNone' | 'RepeatAll' | 'RepeatOne'; shuffled?: boolean; + audioTracks?: { id: string; label: string; selected: boolean }[]; + subtitleTracks?: { id: string; label: string; selected: boolean }[]; + queueRevision?: string; currentEntryId?: string; + /** Genuine backend acknowledgment only; never a fabricated host sequence. */ + commandSequence?: number; + /** Approved source origin; native decoding supports sprite sheets and whole BIF sequences. */ + preview?: Resolved['trickplay']; +} +export type RemoteTargetCommand = + | { action: 'play'; itemIds: string[]; index: number; positionTicks: string; + mode: 'now' | 'next' | 'last' | 'shuffle'; variantId?: string } + | { action: 'pause' | 'unpause' | 'stop' | 'next' | 'previous' } + | { action: 'seek'; positionTicks: string } + | { action: 'volume'; value: number } + | { action: 'mute' | 'shuffle'; value: boolean } + /** null subtitles mean Off; null audio is invalid. IDs are provider track IDs, not indexes. */ + | { action: 'audioTrack' | 'subtitleTrack'; trackId: string | null } + | { action: 'repeat'; mode: 'RepeatNone' | 'RepeatAll' | 'RepeatOne' } + | { action: 'queuePlay' | 'queueRemove'; entryId: string } + | { action: 'queueMove'; entryId: string; index: number; afterEntryId: string | null }; +export interface RemoteTargetExtensions { + remoteTargets?: Operation<{}, { targets: RemoteTarget[] }>; + /** Global preview preference is also supplied when inspecting another player. */ + remoteConnect?: Operation<{ targetId: string; videoPreviews: boolean }, RemoteState>; + remoteState?: Operation<{ targetId: string; videoPreviews: boolean }, RemoteState>; + remoteQueue?: Operation; + remoteCommand?: Operation<{ targetId: string; command: RemoteTargetCommand }, { commandSequence?: number }>; +} + +// Optional private account activation transaction, spool.account-activation v1. +export interface AccountActivationExtensions { + /** Native-only: provider QML cannot request this operation directly. */ + activate?: Operation<{ + reason: 'linked' | 'startup' | 'switch' | 'family'; + lastUsed: boolean; + /** Opaque in-memory proof, at most 16 KiB; never persisted or exposed to QML. */ + grant?: Value; + /** Picker submissions are nested and cannot replace the native-controlled fields above. */ + answers?: Record; + }, { grant?: Value } | PickRequest>; +} + +export interface Source extends CatalogueExtensions, PreferenceExtensions, ApplicationDataExtensions, + RemoteTargetExtensions, AccountActivationExtensions { + /** Required. Artwork templates take {itemId} {type} {tag} {width} {height} {quality} {format}. */ + describe(): { artwork?: string; extensions?: Extensions; + activation?: { familyId: string; identityId: string } }; + /** Baseline-callable compatibility information; no network update check. */ + extensionStatus?: Operation<{}, { enabled: Extensions; missingHost: string[] }>; + + libraries?: Operation<{}, { items: { id: string; title: string; collectionType?: string; posterTag?: string }[] }>; + browse?: Operation; + items?: Operation; + search?: Operation; + /** Omit preview-only metadata fields/prefetch when videoPreviews is false. */ + details?: Operation<{ itemId: string; videoPreviews: boolean }, { item: Item }>; + seasons?: Operation; + episodes?: Operation; + resume?: Operation; + nextUp?: Operation; + latest?: Operation; + similar?: Operation; + personItems?: Operation; + /** `supported` names the BrowseFilters this library honours, as a key or `key:value` + * (`filters:IsPlayed`). When present Spool offers only those; when absent it offers + * every filter except `isHdr`, which is offered only where declared. */ + filterOptions?: Operation<{ parentId: string; collectionType?: string }, + { genres?: string[]; years?: number[]; officialRatings?: string[]; tags?: string[]; supported?: string[] }>; + + resolve?: Operation; + /** Capability downloads; downloadTranscode additionally allows mode=transcoded. + * Negotiate only; native code streams the finite file without buffering it. + * A PickRequest repeats this call with picker answers merged into the arguments. + */ + download?: Operation; + /** Called on completion, cancellation or failure when a plan supplied cleanup. */ + downloadRelease?: Operation<{ cleanup: Record }, {}>; + segments?: Operation<{ itemId: string }, { segments: Segment[] }>; + /** New packages declare spool.speed-test v1; legacy speedTest capability remains supported. */ + speedTest?: Operation<{}, SpeedTestResult>; + report?: Operation<{ event: 'start' | 'progress' | 'stop'; itemId: string; variantId: string; playSessionId: string; + playMethod: string; positionTicks: string; paused?: boolean; rate: number; volume?: number; muted?: boolean; + failed?: boolean; audioStreamIndex: number; subtitleStreamIndex: number; + /** spool.playback-queue-reporting: immutable membership/order revision, omitted on unchanged progress. */ + queue?: PlaybackQueueSnapshot; queueIndex?: number }, {}>; + + favorite?: Operation<{ itemId: string; value: boolean }, {}>; + played?: Operation<{ itemId: string; value: boolean }, {}>; + progress?: Operation<{ itemId: string; positionTicks: string }, {}>; + + /** Manifest `actions` are run here; `pick` shows the picker, then runs again with its result merged in. */ + runItemAction?: Operation<{ action: string; itemId: string; itemType: string; [choice: string]: Value }, + { changed?: boolean; itemId?: string; message?: string } | PickRequest>; + + /** Watching together (capability `groupPlayback`); state arrives as `group` events. */ + groups?: Operation<{}, { items: { id: string; name: string; participants: string[] }[] }>; + groupCreate?: Operation<{ name: string }, {}>; + groupJoin?: Operation<{ groupId: string }, {}>; + groupLeave?: Operation<{}, {}>; + groupSend?: Operation; + /** Server clock for sync: when it received and when it answered, in ms since the epoch. */ + clock?: Operation<{}, { received: number; sent: number }>; + + /** Called before an account is removed. */ + signOut?: Operation<{}, {}>; + + /** Anything else is callable from the provider's own QML through `provider.request()`. */ + [operation: string]: unknown; +} + +export type GroupAction = + | { action: 'pause' | 'unpause' } + | { action: 'seek'; positionTicks: string } + | { action: 'next' | 'previous' | 'play'; entryId: string } + | { action: 'setQueue'; itemIds: string[]; index: number; positionTicks: string } + | { action: 'queue'; itemIds: string[]; next: boolean } + | { action: 'move'; entryId: string; index: number } + | { action: 'remove'; entryIds: string[] } + | { action: 'buffering'; buffering: boolean; playing: boolean; positionTicks: string; entryId: string; at: number } + | { action: 'ping'; ms: number }; + +/** `host.emit(type, payload)`. Times are server ms since the epoch. */ +export interface Events { + /** Something on the server changed; `itemId` narrows it. */ + changed: { itemId?: string }; + /** Merged into the stored configuration, e.g. a refreshed token. */ + configuration: Record; + /** Replaces this account's offers; cannot grant undeclared or unsupported versions. */ + extensionsChanged: { extensions: Extensions }; + /** Nonfatal spool.playback-queue-reporting status; never credentials or raw server errors. */ + playbackQueueStatus: { revision: string; state: 'preparing' | 'ready' | 'unavailable' }; + /** Invalidates only this source/target; never an inbound remote command. */ + remoteChanged: { targetId: string }; + /** Account-activation-only device-local family options; never settings-sync input. + * Providers must enforce who may change them. Values are booleans, not credentials or grants. + */ + activationConfiguration: { configuration: Record }; + group: + | { type: 'connected' } + | { type: 'joined' | 'update'; groupId: string; name: string; state: string; reason?: string; participants: string[]; at?: number } + | { type: 'participants'; participants: string[] } + | { type: 'participantJoined' | 'participantLeft'; name: string } + | { type: 'state'; state: 'Idle' | 'Waiting' | 'Paused' | 'Playing'; reason?: string } + | { type: 'queue'; items: { itemId: string; entryId: string }[]; index: number; positionTicks: string; at?: number; reason?: string } + | { type: 'command'; command: 'pause' | 'unpause' | 'seek' | 'stop'; at: number; positionTicks: string; entryId?: string; emittedAt?: number } + | { type: 'left' } + | { type: 'error'; code: 'group_missing' | 'access_denied' | 'create_denied' | 'join_denied' | 'disabled' | string }; + remote: RemoteCommand; +} + +/** Another client asking this one to do something. */ +export type RemoteCommand = + | { command: 'play'; itemIds: string[]; index?: number; positionTicks?: string; mode?: 'now' | 'next' | 'last' | 'shuffle' } + | { command: 'pause' | 'unpause' | 'playPause' | 'stop' | 'next' | 'previous' | 'rewind' | 'fastForward' | 'toggleMute' | 'stats' } + | { command: 'seek'; positionTicks: string } + | { command: 'volume'; value: number } | { command: 'volumeStep'; delta: number } | { command: 'mute'; value: boolean } + | { command: 'audioTrack' | 'subtitleTrack'; index: number } + | { command: 'repeat'; mode: 'RepeatNone' | 'RepeatAll' | 'RepeatOne' } | { command: 'shuffle'; value: boolean } + | { command: 'quality'; bitrate: number; height?: number } + | { command: 'navigate'; to: 'home' | 'search' | 'settings' | 'toggle-osd' | 'context-menu' | 'fullscreen' } + | { command: 'show'; itemId: string; itemType?: string; title?: string } + | { command: 'message'; text: string } | { command: 'text'; value: string } + | { command: 'key'; name: 'up' | 'down' | 'left' | 'right' | 'pageUp' | 'pageDown' | 'select' | 'back' | 'home' | 'end' | 'space' }; + +/** + * The object a provider screen (manifest `ui`) receives as its `provider` + * property. Screens `import Spool` for Theme, Metrics, InputKeys and the + * app's primitives (ActionButton, TextFieldRow, MenuRow, BusySpinner, + * AppText, SecondaryText, MaterialIcon, IconButton, ProviderIcon, ...). + */ +export interface ScreenContext { + role: 'login' | 'settings' | 'picker'; + /** For a picker: the `pick` object resolve or runItemAction returned. */ + arguments: Record; + /** New-host metadata: utility support for login, effective account offers otherwise. */ + readonly extensions?: Extensions; + readonly missingHostExtensions?: readonly string[]; + /** Notifying, device-local boolean options for this activation family; no credentials/grants. */ + readonly activationConfiguration?: Readonly>; + request(operation: string, args?: Record): Promise>; + /** Moves `items` into `rows` (a list model with `record` and `title` roles, up to 10,000 rows). */ + requestList(operation: string, args?: Record, append?: boolean): Promise>; + rows: unknown; + /** Login server selection; existing accounts require spool.origin-grants and host consent. */ + allowOrigin(url: string): Promise; + /** spool.lan-probe v1: explicit login-draft consent; does not authorize an origin. */ + allowLanDiscovery?(): Promise; + /** Cancel this draft's pending discovery/consent without closing password login. */ + cancelLanDiscovery?(): void; + /** login: { account, label, detail?, group?, configuration }; settings: { configuration? }; picker: the choice. */ + complete(result: Record): void; + close(): void; +} diff --git a/sdk/spool-provider.py b/sdk/spool-provider.py new file mode 100755 index 00000000..0c3e6582 --- /dev/null +++ b/sdk/spool-provider.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Build, check and describe Spool provider packages (.tar.zst, manifest format 2). + + spool-provider.py build DIR [--output FILE] deterministic package from a provider checkout + spool-provider.py validate FILE the same checks Spool runs before installing + spool-provider.py feed FILE --url URL the store/feed entry for a published package + +A package holds manifest.json, LICENSE/NOTICE, the icon, and the logic/, ui/ and +assets/ directories. Nothing in it is executed by these checks. +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import pathlib +import re +import shutil +import subprocess +import sys +import tarfile +from typing import NoReturn + +API = "0.2" +MAX_ARCHIVE = 16 * 1024 * 1024 +MAX_EXPANDED = 32 * 1024 * 1024 +MAX_FILE = 8 * 1024 * 1024 +MAX_FILES = 512 +EXTENSIONS = {".mjs", ".js", ".qml", ".json", ".png", ".jpg", ".svg", ".webp", ".ttf", ".otf", ".txt", ".md", ".map"} +QML_IMPORTS = {"QtQuick", "QtQuick.Layouts", "QtQuick.Controls", "QtQml", "QtQml.Models", "Spool"} +CAPABILITIES = {"search", "userState", "reporting", "segments", "streamQuality", "trickplay", "discovery", + "groupPlayback", "remoteControl", "speedTest", "downloads", "downloadTranscode"} +UI_ROLES = {"login", "settings", "picker"} +ROOTS = ("manifest.json", "LICENSE", "NOTICE", "logic", "ui", "assets") +NATIVE_MAGIC = (b"\x7fELF", b"MZ", b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe", b"\xca\xfe\xba\xbe", b"\0asm") +ID = re.compile(r"[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+") +VERSION = re.compile(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?") + + +def fail(message: str) -> NoReturn: + raise ValueError(message) + + +def valid_path(name: str) -> bool: + parts = name.split("/") + suffix = pathlib.PurePosixPath(name).suffix.lower() + return (0 < len(name) <= 200 and "\\" not in name and ":" not in name + and all(part and not part.startswith(".") and not part.endswith(" ") for part in parts) + and (suffix in EXTENSIONS or parts[-1] in {"LICENSE", "NOTICE"})) + + +def validate(files: dict[str, bytes]) -> dict: + if not files or len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_EXPANDED: + fail("package is empty or too large") + folded = set() + for name, data in files.items(): + if not valid_path(name): + fail(f"forbidden path: {name}") + if name.casefold() in folded: + fail(f"case-colliding path: {name}") + folded.add(name.casefold()) + if len(data) > MAX_FILE or data.startswith(NATIVE_MAGIC): + fail(f"oversized or native file: {name}") + try: + manifest = json.loads(files["manifest.json"]) + except (KeyError, ValueError): + fail("manifest.json is missing or not JSON") + if not isinstance(manifest, dict) or manifest.get("format") != 2 or manifest.get("api") != API: + fail(f"manifest must declare format 2 and api {API}") + for key in ("id", "name", "version", "entry"): + if not isinstance(manifest.get(key), str) or not manifest[key].strip(): + fail(f"manifest.{key} is required") + if not ID.fullmatch(manifest["id"]) or len(manifest["id"]) > 128: + fail("manifest.id must look like publisher.name") + if not VERSION.fullmatch(manifest["version"]): + fail("manifest.version must be semantic (1.2.3 or 1.2.3-beta.1)") + if len(manifest["name"]) > 64 or len(manifest.get("summary", "")) > 120: + fail("name is limited to 64 characters and summary to 120") + if not manifest["entry"].endswith(".mjs") or manifest["entry"] not in files: + fail("manifest.entry must name a packaged .mjs module") + if manifest.get("icon") and manifest["icon"] not in files: + fail("manifest.icon names a missing file") + ui = manifest.get("ui", {}) + if not isinstance(ui, dict) or not set(ui) <= UI_ROLES: + fail(f"manifest.ui roles are {sorted(UI_ROLES)}") + for role, path in ui.items(): + if path not in files or not path.endswith(".qml"): + fail(f"manifest.ui.{role} names a missing QML file") + unknown = set(manifest.get("capabilities", [])) - CAPABILITIES + if unknown: + fail(f"unknown capabilities: {sorted(unknown)}") + extensions = manifest.get("extensions", {}) + if not isinstance(extensions, dict) or len(extensions) > 32: + fail("manifest.extensions must be an object with at most 32 declarations") + for extension, major in extensions.items(): + if len(extension) > 128 or not ID.fullmatch(extension): + fail("manifest.extensions ids must be namespaced and at most 128 characters") + if (type(major) not in (int, float) or not 1 <= major <= 2147483647 + or int(major) != major): + fail("manifest.extensions versions must be exact positive wire-major integers") + for origin in manifest.get("origins", []): + if origin != "*" and not re.fullmatch(r"https?://[^/\s]+", origin): + fail(f"origins are scheme://host[:port] or *: {origin}") + for name, data in files.items(): + if name.endswith(".qml"): + imports = set(re.findall(r"^\s*import\s+([A-Za-z][\w.]*)", data.decode("utf-8"), re.MULTILINE)) + if not imports <= QML_IMPORTS: + fail(f"{name} imports modules Spool does not provide: {sorted(imports - QML_IMPORTS)}") + return manifest + + +def compress(data: bytes) -> bytes: + try: + from compression import zstd # Python 3.14+ + return zstd.compress(data, level=19) + except ImportError: + pass + if not shutil.which("zstd"): + fail("needs Python 3.14 or the zstd command") + return subprocess.run(["zstd", "-19", "-q", "-c", f"--stream-size={len(data)}"], input=data, + capture_output=True, check=True).stdout + + +def decompress(data: bytes) -> bytes: + try: + from compression import zstd + return zstd.decompress(data) + except ImportError: + pass + if not shutil.which("zstd"): + fail("needs Python 3.14 or the zstd command") + return subprocess.run(["zstd", "-d", "-q", "-c"], input=data, capture_output=True, check=True).stdout + + +def read(path: pathlib.Path) -> tuple[dict, dict[str, bytes]]: + data = path.read_bytes() + if len(data) > MAX_ARCHIVE: + fail("archive is larger than 16 MiB") + files = {} + with tarfile.open(fileobj=io.BytesIO(decompress(data)), mode="r:") as archive: + for member in archive.getmembers(): + if member.isdir(): + continue + if not member.isfile(): + fail(f"links and special files are not allowed: {member.name}") + files[member.name.removeprefix("./")] = archive.extractfile(member).read() + return validate(files), files + + +def collect(source: pathlib.Path) -> dict[str, bytes]: + files = {} + for root in ROOTS: + path = source / root + for entry in [path] if path.is_file() else sorted(path.rglob("*")) if path.is_dir() else []: + if entry.is_symlink(): + fail(f"symlinks are not allowed: {entry}") + if entry.is_file(): + files[entry.relative_to(source).as_posix()] = entry.read_bytes() + return files + + +def build(source: pathlib.Path, output: pathlib.Path | None) -> pathlib.Path: + files = collect(source) + manifest = validate(files) + tar = io.BytesIO() + with tarfile.open(fileobj=tar, mode="w:", format=tarfile.USTAR_FORMAT) as archive: + for name, data in sorted(files.items()): + info = tarfile.TarInfo(name) + info.size, info.mode, info.mtime = len(data), 0o644, 0 + archive.addfile(info, io.BytesIO(data)) + output = output or source / "dist" / f"{manifest['id']}-{manifest['version']}.tar.zst" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(compress(tar.getvalue())) + read(output) + return output + + +def feed(path: pathlib.Path, url: str) -> dict: + manifest, _ = read(path) + data = path.read_bytes() + entry = {key: manifest[key] for key in ("id", "name", "version", "api", "summary", "publisher", "homepage") + if manifest.get(key)} + entry.update(url=url, size=len(data), sha256=hashlib.sha256(data).hexdigest()) + return entry + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("command", choices=("build", "validate", "feed")) + parser.add_argument("path", type=pathlib.Path) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--url") + args = parser.parse_args() + if args.command == "build": + print(build(args.path, args.output)) + elif args.command == "validate": + manifest, _ = read(args.path) + print(f"{manifest['id']} {manifest['version']} ok") + else: + if not args.url: + parser.error("feed needs --url, the address the package is downloaded from") + print(json.dumps(feed(args.path, args.url), indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ValueError, OSError, tarfile.TarError, subprocess.CalledProcessError) as error: + print(f"spool-provider: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/src/api/HttpRequestPolicy.h b/src/api/HttpRequestPolicy.h deleted file mode 100644 index e8ba3edf..00000000 --- a/src/api/HttpRequestPolicy.h +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include "../common/NetworkAddress.h" -#include -#include - -#include - -namespace JellyfinNative { - -enum class HttpOperation { - Read, - Mutation, - PlaybackReport, -}; - -class HttpRequestPolicy final { -public: - static bool allowsCredentialTransport(const QUrl& url) - { - if (!url.isValid() || url.host().isEmpty() || !url.userInfo().isEmpty()) - return false; - if (url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0) - return true; - if (url.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) != 0) - return false; - QHostAddress address; - return address.setAddress(url.host()) && isPrivateNetworkAddress(address); - } - - static bool sameOrigin(const QUrl& left, const QUrl& right) - { - const auto effectivePort = [](const QUrl& url) { - return url.port(url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0 ? 443 : 80); - }; - return left.scheme().compare(right.scheme(), Qt::CaseInsensitive) == 0 - && left.host().compare(right.host(), Qt::CaseInsensitive) == 0 - && effectivePort(left) == effectivePort(right); - } - - static constexpr int transferTimeoutMs() - { - return 30000; - } - - static constexpr int maximumAttempts(HttpOperation operation) - { - return operation == HttpOperation::Mutation ? 1 : 3; - } - - static bool shouldRetry( - HttpOperation operation, int completedAttempts, int statusCode, QNetworkReply::NetworkError networkError) - { - if (completedAttempts >= maximumAttempts(operation) || statusCode == 401) - return false; - - if (statusCode == 408 || statusCode == 425 || statusCode == 429 || statusCode >= 500) { - return true; - } - - if (networkError != QNetworkReply::NoError) { - switch (networkError) { - case QNetworkReply::ConnectionRefusedError: - case QNetworkReply::RemoteHostClosedError: - case QNetworkReply::HostNotFoundError: - case QNetworkReply::TimeoutError: - case QNetworkReply::TemporaryNetworkFailureError: - case QNetworkReply::NetworkSessionFailedError: - case QNetworkReply::ProxyConnectionRefusedError: - case QNetworkReply::ProxyConnectionClosedError: - case QNetworkReply::ProxyNotFoundError: - case QNetworkReply::ProxyTimeoutError: - case QNetworkReply::UnknownNetworkError: - case QNetworkReply::UnknownProxyError: - case QNetworkReply::UnknownServerError: - return true; - default: - return false; - } - } - - return false; - } - - static constexpr int retryDelayMs(int completedAttempts) - { - return std::min(2000, 250 << std::clamp(completedAttempts - 1, 0, 3)); - } -}; - -} // namespace JellyfinNative diff --git a/src/api/ItemsQuery.cpp b/src/api/ItemsQuery.cpp deleted file mode 100644 index fc480785..00000000 --- a/src/api/ItemsQuery.cpp +++ /dev/null @@ -1,87 +0,0 @@ -#include "ItemsQuery.h" - -#include - -namespace JellyfinNative { - -ItemsQuery& ItemsQuery::add(const QString& key, const QString& value) -{ - m_query.addQueryItem(key, value); - return *this; -} - -ItemsQuery& ItemsQuery::addIfNotEmpty(const QString& key, const QString& value) -{ - if (!value.isEmpty()) - m_query.addQueryItem(key, value); - return *this; -} - -ItemsQuery& ItemsQuery::userId(const QString& userId) -{ - return add(QStringLiteral("userId"), userId); -} - -ItemsQuery& ItemsQuery::parentId(const QString& parentId) -{ - return addIfNotEmpty(QStringLiteral("parentId"), parentId); -} - -ItemsQuery& ItemsQuery::recursive(bool enabled) -{ - return add(QStringLiteral("recursive"), enabled ? QStringLiteral("true") : QStringLiteral("false")); -} - -ItemsQuery& ItemsQuery::includeItemTypes(const QString& types) -{ - return addIfNotEmpty(QStringLiteral("includeItemTypes"), types); -} - -ItemsQuery& ItemsQuery::mediaTypes(const QString& types) -{ - return addIfNotEmpty(QStringLiteral("mediaTypes"), types); -} - -ItemsQuery& ItemsQuery::fields(const QString& fields) -{ - return addIfNotEmpty(QStringLiteral("fields"), fields); -} - -ItemsQuery& ItemsQuery::images(const QString& types, int imageTypeLimit) -{ - addIfNotEmpty(QStringLiteral("enableImageTypes"), types); - add(QStringLiteral("imageTypeLimit"), QString::number(std::max(1, imageTypeLimit))); - return *this; -} - -ItemsQuery& ItemsQuery::sort(const QString& sortBy, const QString& sortOrder) -{ - addIfNotEmpty(QStringLiteral("sortBy"), sortBy); - addIfNotEmpty(QStringLiteral("sortOrder"), sortOrder); - return *this; -} - -ItemsQuery& ItemsQuery::limit(int requested, int maximum) -{ - const int boundedMaximum = std::max(1, maximum); - add(QStringLiteral("limit"), QString::number(std::clamp(requested, 1, boundedMaximum))); - return *this; -} - -ItemsQuery& ItemsQuery::startIndex(int startIndex) -{ - add(QStringLiteral("startIndex"), QString::number(std::max(0, startIndex))); - return *this; -} - -ItemsQuery& ItemsQuery::enableTotalRecordCount(bool enabled) -{ - return add(QStringLiteral("enableTotalRecordCount"), enabled ? QStringLiteral("true") : QStringLiteral("false")); -} - -QUrlQuery ItemsQuery::toUrlQuery() const -{ - return m_query; -} - -} // namespace JellyfinNative diff --git a/src/api/ItemsQuery.h b/src/api/ItemsQuery.h deleted file mode 100644 index 20e910ac..00000000 --- a/src/api/ItemsQuery.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include -#include - -namespace JellyfinNative { - -class ItemsQuery final { -public: - ItemsQuery() = default; - - ItemsQuery& add(const QString& key, const QString& value); - ItemsQuery& addIfNotEmpty(const QString& key, const QString& value); - ItemsQuery& userId(const QString& userId); - ItemsQuery& parentId(const QString& parentId); - ItemsQuery& recursive(bool enabled = true); - ItemsQuery& includeItemTypes(const QString& types); - ItemsQuery& mediaTypes(const QString& types); - ItemsQuery& fields(const QString& fields); - ItemsQuery& images( - const QString& types = QStringLiteral("Primary,Backdrop,Logo,Banner,Thumb"), int imageTypeLimit = 3); - ItemsQuery& sort(const QString& sortBy, const QString& sortOrder = QStringLiteral("Ascending")); - ItemsQuery& limit(int requested, int maximum); - ItemsQuery& startIndex(int startIndex); - ItemsQuery& enableTotalRecordCount(bool enabled); - - QUrlQuery toUrlQuery() const; - -private: - QUrlQuery m_query; -}; - -} // namespace JellyfinNative diff --git a/src/api/JellyfinApiFacade.cpp b/src/api/JellyfinApiFacade.cpp deleted file mode 100644 index 436fa384..00000000 --- a/src/api/JellyfinApiFacade.cpp +++ /dev/null @@ -1,2234 +0,0 @@ -#include "JellyfinApiFacade.h" - -#include "../common/AsyncTask.h" -#include "../common/MetaJson.h" -#include "../common/NetworkAddress.h" -#include "../common/TlsTrust.h" -#include "../common/VariantUtils.h" -#include "../diagnostics/Diagnostics.h" -#include "ItemsQuery.h" -#include "PlaybackBandwidthPolicy.h" -#include "PlaybackNegotiation.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#if QT_CONFIG(ssl) -#include -#endif -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace JellyfinNative { - -namespace { - constexpr int kConnectionCacheExpirySeconds = 15 * 60; - - QString requireString(const QJsonObject& object, const QString& key) - { - const QString value = object.value(key).toString(); - if (value.isEmpty()) - throw std::runtime_error(QStringLiteral("Missing field: %1").arg(key).toStdString()); - return value; - } - - QString cleanContainerName(const QString& container) - { - if (container.contains(QLatin1Char(','))) - return container.section(QLatin1Char(','), 0, 0).trimmed(); - return container.trimmed(); - } - - bool isQuickConnectPath(const QString& path) - { - return path.startsWith(QStringLiteral("/QuickConnect/")) - || path == QStringLiteral("/Users/AuthenticateWithQuickConnect"); - } - - QJsonArray itemsArrayFromDocument(const QJsonDocument& document) - { - if (document.isArray()) - return document.array(); - return document.object().value(QStringLiteral("Items")).toArray(); - } - - QString detailItemFields() - { - return QStringLiteral( - "Overview,ProductionYear,PremiereDate,EndDate,Status,DateCreated,DateLastContentAdded,ImageTags," - "BackdropImageTags," - "UserData,Path,RunTimeTicks,SeriesInfo,LocationType,IsVirtualItem,Genres,Tags,Studios,ProviderIds," - "ExternalUrls,OfficialRating,CommunityRating,CriticRating,People,PrimaryImageAspectRatio,MediaSources"); - } - - QString libraryItemFields() - { - return QStringLiteral( - "SortName,Overview,ProductionYear,PremiereDate,EndDate,Status,DateCreated,DateLastContentAdded,ImageTags," - "BackdropImageTags,UserData,RunTimeTicks,SeriesInfo,LocationType,IsVirtualItem,Genres,Tags,Studios," - "OfficialRating,CommunityRating,CriticRating,PrimaryImageAspectRatio"); - } - - QString firstString(const QJsonArray& array) - { - for (const QJsonValue& value : array) { - const QString stringValue = value.toString(); - if (!stringValue.isEmpty()) - return stringValue; - } - return {}; - } - - QString includeItemTypesForCollection(QString collectionType) - { - if (collectionType == QStringLiteral("movies")) - return QStringLiteral("Movie"); - if (collectionType == QStringLiteral("tvshows")) - return QStringLiteral("Series"); - if (collectionType == QStringLiteral("playlists")) - return QStringLiteral("Playlist"); - if (collectionType == QStringLiteral("boxsets")) - return QStringLiteral("BoxSet"); - if (collectionType == QStringLiteral("music")) - return QStringLiteral("MusicAlbum"); - if (collectionType == QStringLiteral("books")) - return QStringLiteral("Book,AudioBook"); - if (collectionType == QStringLiteral("photos")) - return QStringLiteral("PhotoAlbum,Photo"); - if (collectionType == QStringLiteral("musicvideos")) - return QStringLiteral("MusicVideo"); - if (collectionType == QStringLiteral("homevideos")) - return QStringLiteral("Folder,Video,PhotoAlbum,Photo"); - return QStringLiteral("Folder,Movie,Series,Season,Episode,MusicVideo,Video,Audio,MusicAlbum," - "MusicArtist,Book,AudioBook,Photo,PhotoAlbum,Playlist,BoxSet"); - } - - QStringList itemTypesList(const QString& types) - { - return types.split(QLatin1Char(','), Qt::SkipEmptyParts); - } - - bool libraryBrowseUsesDirectChildren(const QString& collectionType) - { - return collectionType == QStringLiteral("playlists") || collectionType == QStringLiteral("boxsets"); - } - - void addJoinedQueryItem(QUrlQuery& query, const QVariantMap& options, const QString& key, const QString& queryKey, - QLatin1Char delimiter) - { - const QStringList values = stringListFromVariantMap(options, key); - if (!values.isEmpty()) - query.addQueryItem(queryKey, values.join(delimiter)); - } - - void addOptionalBoolQueryItem( - QUrlQuery& query, const QVariantMap& options, const QString& key, const QString& queryKey) - { - if (!options.contains(key)) - return; - const QVariant value = options.value(key); - if (!value.isValid() || value.isNull()) - return; - query.addQueryItem(queryKey, value.toBool() ? QStringLiteral("true") : QStringLiteral("false")); - } - - void addLibraryQueryOptions(QUrlQuery& query, const QVariantMap& options) - { - const QString sortBy = options.value(QStringLiteral("sortBy"), QStringLiteral("SortName")).toString(); - const QString sortOrder = options.value(QStringLiteral("sortOrder"), QStringLiteral("Ascending")).toString(); - query.addQueryItem(QStringLiteral("sortBy"), sortBy.isEmpty() ? QStringLiteral("SortName") : sortBy); - query.addQueryItem(QStringLiteral("sortOrder"), sortOrder.isEmpty() ? QStringLiteral("Ascending") : sortOrder); - - addJoinedQueryItem(query, options, QStringLiteral("filters"), QStringLiteral("filters"), QLatin1Char(',')); - addJoinedQueryItem(query, options, QStringLiteral("genres"), QStringLiteral("genres"), QLatin1Char('|')); - addJoinedQueryItem( - query, options, QStringLiteral("officialRatings"), QStringLiteral("officialRatings"), QLatin1Char('|')); - addJoinedQueryItem(query, options, QStringLiteral("tags"), QStringLiteral("tags"), QLatin1Char('|')); - addJoinedQueryItem(query, options, QStringLiteral("years"), QStringLiteral("years"), QLatin1Char(',')); - addJoinedQueryItem(query, options, QStringLiteral("studioIds"), QStringLiteral("studioIds"), QLatin1Char('|')); - addJoinedQueryItem( - query, options, QStringLiteral("seriesStatus"), QStringLiteral("seriesStatus"), QLatin1Char(',')); - addJoinedQueryItem( - query, options, QStringLiteral("videoTypes"), QStringLiteral("videoTypes"), QLatin1Char(',')); - - addOptionalBoolQueryItem(query, options, QStringLiteral("isHd"), QStringLiteral("isHd")); - addOptionalBoolQueryItem(query, options, QStringLiteral("is4K"), QStringLiteral("is4K")); - addOptionalBoolQueryItem(query, options, QStringLiteral("is3D"), QStringLiteral("is3D")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasSubtitles"), QStringLiteral("hasSubtitles")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasTrailer"), QStringLiteral("hasTrailer")); - addOptionalBoolQueryItem( - query, options, QStringLiteral("hasSpecialFeature"), QStringLiteral("hasSpecialFeature")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasThemeSong"), QStringLiteral("hasThemeSong")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasThemeVideo"), QStringLiteral("hasThemeVideo")); - addOptionalBoolQueryItem(query, options, QStringLiteral("isMissing"), QStringLiteral("isMissing")); - addOptionalBoolQueryItem(query, options, QStringLiteral("isUnaired"), QStringLiteral("isUnaired")); - - const QVariant specialEpisode = options.value(QStringLiteral("specialEpisode")); - if (specialEpisode.isValid() && !specialEpisode.isNull() && specialEpisode.toBool()) - query.addQueryItem(QStringLiteral("parentIndexNumber"), QStringLiteral("0")); - - const QString alphabet = options.value(QStringLiteral("alphabet")).toString(); - if (!alphabet.isEmpty()) { - if (alphabet == QStringLiteral("#")) - query.addQueryItem(QStringLiteral("nameLessThan"), QStringLiteral("A")); - else - query.addQueryItem(QStringLiteral("nameStartsWith"), alphabet); - } - } - - QList mediaStreamsFromApiJson(const QJsonArray& array) - { - QList streams; - streams.reserve(array.size()); - for (const QJsonValue& value : array) { - const QJsonObject object = value.toObject(); - MediaStreamInfo stream = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - stream.frameRate = object.value(QStringLiteral("AverageFrameRate")).toDouble(); - if (stream.frameRate <= 0.0) - stream.frameRate = object.value(QStringLiteral("RealFrameRate")).toDouble(); - if (!stream.type.isEmpty() || !stream.codec.isEmpty()) - streams.push_back(stream); - } - return streams; - } - - MediaSourceInfo mediaSourceFromApiJson(const QJsonObject& object) - { - MediaSourceInfo source = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - source.container = cleanContainerName(object.value(QStringLiteral("Container")).toString()); - source.bitRate = object.value(QStringLiteral("Bitrate")).toInt(); - if (source.bitRate <= 0) - source.bitRate = object.value(QStringLiteral("BitRate")).toInt(); - source.runtimeTicks = object.value(QStringLiteral("RunTimeTicks")).toVariant().toLongLong(); - source.streams = mediaStreamsFromApiJson(object.value(QStringLiteral("MediaStreams")).toArray()); - return source; - } - - MovieItem mediaItemFromJson(const QJsonObject& object) - { - MovieItem item = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - item.posterTag - = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Primary")).toString(); - item.logoTag = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Logo")).toString(); - item.bannerTag - = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Banner")).toString(); - item.thumbTag = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Thumb")).toString(); - item.backdropTag = firstString(object.value(QStringLiteral("BackdropImageTags")).toArray()); - const int indexNumber = object.value(QStringLiteral("IndexNumber")).toInt(); - item.seasonNumber = item.itemType == QStringLiteral("Episode") - ? object.value(QStringLiteral("ParentIndexNumber")).toInt() - : indexNumber; - item.episodeNumber = item.itemType == QStringLiteral("Episode") ? indexNumber : 0; - const QJsonObject userData = object.value(QStringLiteral("UserData")).toObject(); - item.resumeTicks = normalizedResumeTicks( - userData.value(QStringLiteral("PlaybackPositionTicks")).toVariant().toLongLong(), item.runtimeTicks); - item.favorite = userData.value(QStringLiteral("IsFavorite")).toBool(false); - item.played = userData.value(QStringLiteral("Played")).toBool(false); - item.datePlayed = userData.value(QStringLiteral("LastPlayedDate")).toString(); - item.playCount = userData.value(QStringLiteral("PlayCount")).toInt(); - item.studios = metaStringListFromJson(object, { QStringLiteral("Studios"), QStringLiteral("Name") }); - item.mediaSources.clear(); - const QJsonArray sourceArray = object.value(QStringLiteral("MediaSources")).toArray(); - item.mediaSources.reserve(sourceArray.size()); - for (const QJsonValue& sourceValue : sourceArray) { - MediaSourceInfo source = mediaSourceFromApiJson(sourceValue.toObject()); - if (!source.id.isEmpty() || !source.container.isEmpty() || !source.streams.isEmpty()) - item.mediaSources.push_back(source); - } - return item; - } - - std::vector mediaItemsFromJson(const QJsonArray& items, const QStringList& allowedTypes) - { - std::vector result; - result.reserve(items.size()); - for (const QJsonValue& value : items) { - const QJsonObject object = value.toObject(); - if (allowedTypes.contains(object.value(QStringLiteral("Type")).toString())) - result.push_back(mediaItemFromJson(object)); - } - return result; - } - -} - -JellyfinApiFacade::JellyfinApiFacade( - QNetworkAccessManager *networkAccessManager, TlsTrustController *tlsTrust, QObject *parent) - : QObject(parent) - , m_networkAccessManager(networkAccessManager) - , m_rest(networkAccessManager, this) -{ - if (tlsTrust) - tlsTrust->attachNetworkAccessManager(m_networkAccessManager, QStringLiteral("Jellyfin API")); - m_requestFactory.setTransferTimeout(std::chrono::milliseconds(HttpRequestPolicy::transferTimeoutMs())); - m_requestFactory.setAttribute( - QNetworkRequest::ConnectionCacheExpiryTimeoutSecondsAttribute, kConnectionCacheExpirySeconds); - - // Moving between Wi-Fi, ethernet and cellular changes what the link can - // carry. Backends are optional: where none loads the ceiling simply keeps - // whatever the last measurement found. - if (QNetworkInformation::loadDefaultBackend()) { - if (auto *information = QNetworkInformation::instance()) { - connect(information, &QNetworkInformation::transportMediumChanged, this, - &JellyfinApiFacade::handleNetworkRouteChanged); - connect(information, &QNetworkInformation::reachabilityChanged, this, - [this](QNetworkInformation::Reachability reachability) { - if (reachability == QNetworkInformation::Reachability::Online) - handleNetworkRouteChanged(); - }); - } - } - - applyCommonHeaders(); -} - -JellyfinApiFacade::~JellyfinApiFacade() -{ - cancelRequests(); -} - -void JellyfinApiFacade::setServerUrl(const QString& serverUrl) -{ - QString normalized = serverUrl; - while (normalized.endsWith(QLatin1Char('/'))) - normalized.chop(1); - const bool serverChanged = normalized != m_serverUrl; - m_serverUrl = normalized; - m_requestFactory.setBaseUrl(QUrl(m_serverUrl)); - if (serverChanged) { - ++m_playbackNetworkGeneration; - m_inLocalNetwork = isLanHost(QUrl(m_serverUrl).host()); - m_playbackEndpointKnown = m_inLocalNetwork; - m_measuredStreamingBitrate = 0; - setPlaybackParallelRequests(1); - updateEffectiveStreamingBitrate(); - } - preconnectToServer(); -} - -QString JellyfinApiFacade::serverUrl() const -{ - return m_serverUrl; -} - -void JellyfinApiFacade::setAcceptLanguage(const QString& bcp47Tag) -{ - if (bcp47Tag.isEmpty()) - return; - m_acceptLanguage = bcp47Tag; - applyCommonHeaders(); -} - -void JellyfinApiFacade::setDeviceIdentity( - const QString& deviceId, const QString& deviceName, const QString& clientVersion) -{ - m_deviceId = deviceId; - m_deviceName = deviceName; - m_clientVersion = clientVersion; -} - -void JellyfinApiFacade::setDeviceId(const QString& deviceId) -{ - m_deviceId = deviceId; -} - -void JellyfinApiFacade::setDeviceName(const QString& deviceName) -{ - const QString trimmed = deviceName.trimmed(); - if (trimmed.isEmpty() || trimmed == m_deviceName) - return; - m_deviceName = trimmed; - applyCommonHeaders(); -} - -QString JellyfinApiFacade::deviceId() const -{ - return m_deviceId; -} - -void JellyfinApiFacade::setSession(const AuthSession& session) -{ - const bool tokenChanged = m_session.accessToken != session.accessToken; - if (tokenChanged) { - ++m_playbackNetworkGeneration; - setPlaybackParallelRequests(1); - } - m_session = session; - m_authExpirationReported = false; - if (m_session.accessToken.isEmpty()) - m_preconnectedAuthority.clear(); - if (tokenChanged) { - // The bitrate probe needs a token, so nothing can be measured before - // sign-in. Reusing what this route measured last time gives playback a - // real number at once instead of the conservative estimate, and the - // probe that follows refreshes it. - if (!m_session.accessToken.isEmpty()) - restoreRememberedMeasurement(); - emit sessionTokenChanged(); - } - preconnectToServer(); -} - -void JellyfinApiFacade::preconnectToServer() -{ - if (!m_networkAccessManager || m_serverUrl.isEmpty() || m_session.accessToken.isEmpty()) - return; - - const QUrl url(m_serverUrl); - if (!url.isValid() || url.host().isEmpty() || url.scheme() != QStringLiteral("https")) - return; - - const int port = url.port(443); - const QString authority = url.host() + QLatin1Char(':') + QString::number(port); - if (m_preconnectedAuthority == authority) - return; - - m_preconnectedAuthority = authority; -#if QT_CONFIG(ssl) - m_networkAccessManager->connectToHostEncrypted( - url.host(), static_cast(port), QSslConfiguration::defaultConfiguration()); -#else - // The webOS Qt build has OpenSSL disabled; still warm DNS/TCP so the first - // authenticated HTTPS request does not pay every socket setup cost. - m_networkAccessManager->connectToHost(url.host(), static_cast(port)); -#endif -} - -void JellyfinApiFacade::setPlaybackPreferences( - qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight) -{ - m_manualMaxStreamingBitrate = manualMaxStreamingBitrate > 0 - ? std::clamp(manualMaxStreamingBitrate, 1'000'000, PlaybackBandwidthPolicy::MaximumBitrate) - : 0; - m_unlimitedLocalNetwork = unlimitedLocalNetwork; - m_preferRemux = preferRemux; - const int normalizedHeight = maxStreamingHeight > 0 ? maxStreamingHeight : 0; - if (m_maxStreamingHeight != normalizedHeight) { - m_maxStreamingHeight = normalizedHeight; - // The profile is what carries the ceiling, so a changed ceiling has to - // reach anything holding one. - emit deviceProfileChanged(); - } - updateEffectiveStreamingBitrate(); -} - -void JellyfinApiFacade::setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs) -{ - for (QString& codec : videoCodecs) - codec = codec.trimmed().toLower(); - videoCodecs.removeAll(QString()); - videoCodecs.removeDuplicates(); - if (m_videoCodecs == videoCodecs && m_restrictVideoCodecs == restrictVideoCodecs) - return; - - m_videoCodecs = std::move(videoCodecs); - m_restrictVideoCodecs = restrictVideoCodecs; - qInfo() << "playback capabilities: video codecs=" << m_videoCodecs << "restricted=" << m_restrictVideoCodecs; - emit deviceProfileChanged(); -} -void JellyfinApiFacade::setRemoteControlTargetEnabled(bool enabled) -{ - if (m_remoteControlTargetEnabled == enabled) - return; - m_remoteControlTargetEnabled = enabled; - emit deviceProfileChanged(); -} - -int JellyfinApiFacade::playbackParallelRequests() const -{ - return m_playbackParallelRequests; -} - -void JellyfinApiFacade::setPlaybackParallelRequests(int parallelRequests) -{ - const int normalized = std::clamp(parallelRequests, 1, 4); - if (normalized == m_playbackParallelRequests) - return; - m_playbackParallelRequests = normalized; - emit playbackNetworkProfileChanged(); -} - -qint64 JellyfinApiFacade::maxStreamingBitrate() const -{ - return m_maxStreamingBitrate; -} - -qint64 JellyfinApiFacade::measuredStreamingBitrate() const -{ - return m_measuredStreamingBitrate; -} - -void JellyfinApiFacade::setSessionBitrateOverride(qint64 bitrate) -{ - const qint64 normalized = bitrate > 0 - ? std::clamp(bitrate, PlaybackBandwidthPolicy::MinimumBitrate, PlaybackBandwidthPolicy::MaximumBitrate) - : 0; - if (normalized == m_sessionBitrateOverride) - return; - m_sessionBitrateOverride = normalized; - updateEffectiveStreamingBitrate(); -} - -qint64 JellyfinApiFacade::sessionBitrateOverride() const -{ - return m_sessionBitrateOverride; -} - -void JellyfinApiFacade::setSessionHeightOverride(int height) -{ - const int normalized = height > 0 ? height : 0; - if (normalized == m_sessionHeightOverride) - return; - m_sessionHeightOverride = normalized; - emit deviceProfileChanged(); -} - -int JellyfinApiFacade::sessionHeightOverride() const -{ - return m_sessionHeightOverride; -} - -int JellyfinApiFacade::maxStreamingHeight() const -{ - // The session override wins outright rather than taking the lower of the - // two: a viewer who asks the overlay for 1080p on a device configured to - // 480p is asking for 1080p now. - return m_sessionHeightOverride > 0 ? m_sessionHeightOverride : m_maxStreamingHeight; -} - -PlaybackBandwidthPolicy::Source JellyfinApiFacade::streamingBitrateSource() const -{ - if (m_sessionBitrateOverride > 0) - return PlaybackBandwidthPolicy::Source::Manual; - const PlaybackBandwidthPolicy::Source source - = PlaybackBandwidthPolicy::effectiveBitrateSource(m_manualMaxStreamingBitrate, m_unlimitedLocalNetwork, - m_playbackEndpointKnown, m_inLocalNetwork, m_measuredStreamingBitrate); - // The policy cannot tell a measurement taken now from one restored for - // this route, so name the difference here. - if (source == PlaybackBandwidthPolicy::Source::Measured && m_measurementRemembered) - return PlaybackBandwidthPolicy::Source::Remembered; - return source; -} - -void JellyfinApiFacade::setPlaybackActive(bool active) -{ - if (m_playbackActive == active) - return; - m_playbackActive = active; - if (active || !m_measurementDeferred) - return; - m_measurementDeferred = false; - Async::runScoped( - this, refreshPlaybackNetworkState(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "playback bandwidth: deferred measurement failed" << exceptionMessage(error); - }); -} - -QString JellyfinApiFacade::currentNetworkSignature() const -{ - QStringList addresses; - const QList local = QNetworkInterface::allAddresses(); - addresses.reserve(local.size()); - for (const QHostAddress& address : local) - addresses.push_back(address.toString()); - - QString transport; - if (auto *information = QNetworkInformation::instance()) - transport = QVariant::fromValue(information->transportMedium()).toString(); - - const QUrl url(m_serverUrl); - const QString authority = url.host().toLower() + QLatin1Char(':') + QString::number(url.port(443)); - return PlaybackBandwidthPolicy::networkSignature(authority, addresses, transport); -} - -void JellyfinApiFacade::restoreRememberedMeasurement() -{ - if (m_serverUrl.isEmpty()) - return; - m_measuredNetworkSignature = currentNetworkSignature(); - m_measuredStreamingBitrate = 0; - m_measurementRemembered = false; - - QSettings settings; - settings.beginGroup(QStringLiteral("playback/bandwidth/") + m_measuredNetworkSignature); - const qint64 bitrate = settings.value(QStringLiteral("bitrate")).toLongLong(); - const qint64 recordedAt = settings.value(QStringLiteral("recordedAt")).toLongLong(); - const int parallelRequests = settings.value(QStringLiteral("parallelRequests"), 1).toInt(); - settings.endGroup(); - - if (bitrate <= 0 - || !PlaybackBandwidthPolicy::isRememberedMeasurementUsable(recordedAt, QDateTime::currentMSecsSinceEpoch())) { - updateEffectiveStreamingBitrate(); - return; - } - - m_measuredStreamingBitrate = bitrate; - m_measurementRemembered = true; - setPlaybackParallelRequests(parallelRequests); - qInfo() << "playback bandwidth: restored" << bitrate << "for route" << m_measuredNetworkSignature; - updateEffectiveStreamingBitrate(); -} - -void JellyfinApiFacade::rememberMeasurement() -{ - if (m_measuredStreamingBitrate <= 0 || m_measuredNetworkSignature.isEmpty()) - return; - QSettings settings; - settings.beginGroup(QStringLiteral("playback/bandwidth/") + m_measuredNetworkSignature); - settings.setValue(QStringLiteral("bitrate"), m_measuredStreamingBitrate); - settings.setValue(QStringLiteral("parallelRequests"), m_playbackParallelRequests); - settings.setValue(QStringLiteral("recordedAt"), QDateTime::currentMSecsSinceEpoch()); - settings.endGroup(); -} - -void JellyfinApiFacade::handleNetworkRouteChanged() -{ - if (m_serverUrl.isEmpty() || m_session.accessToken.isEmpty()) - return; - if (currentNetworkSignature() == m_measuredNetworkSignature) - return; - - // A different route invalidates the measurement outright rather than - // carrying a home Wi-Fi ceiling onto a phone hotspot. Reload whatever is - // remembered for the new route so playback has a number immediately, then - // re-measure unless a stream is already running. - ++m_playbackNetworkGeneration; - m_playbackEndpointKnown = false; - m_inLocalNetwork = false; - restoreRememberedMeasurement(); - Async::runScoped( - this, refreshPlaybackNetworkState(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "playback bandwidth: re-measurement failed" << exceptionMessage(error); - }); -} - -void JellyfinApiFacade::updateEffectiveStreamingBitrate() -{ - // A ceiling picked in the player is a direct instruction, so it bypasses - // every automatic input rather than competing with them. - const qint64 effective = m_sessionBitrateOverride > 0 - ? m_sessionBitrateOverride - : PlaybackBandwidthPolicy::effectiveBitrate(m_manualMaxStreamingBitrate, m_unlimitedLocalNetwork, - m_playbackEndpointKnown, m_inLocalNetwork, m_measuredStreamingBitrate); - const PlaybackBandwidthPolicy::Source source = streamingBitrateSource(); - // The description can change while the number does not: a remembered - // ceiling confirmed by a fresh probe is the same ceiling, differently - // earned, and the player says so. - const bool descriptionChanged = source != m_streamingBitrateSource; - m_streamingBitrateSource = source; - if (effective == m_maxStreamingBitrate) { - if (descriptionChanged) - emit streamingBitrateChanged(); - return; - } - m_maxStreamingBitrate = effective; - qInfo() << "playback bandwidth: effective=" << m_maxStreamingBitrate << "manual=" << m_manualMaxStreamingBitrate - << "measured=" << m_measuredStreamingBitrate << "remembered=" << m_measurementRemembered - << "endpointKnown=" << m_playbackEndpointKnown << "local=" << m_inLocalNetwork - << "unlimitedLocal=" << m_unlimitedLocalNetwork; - emit deviceProfileChanged(); - emit streamingBitrateChanged(); -} - -QCoro::Task JellyfinApiFacade::measurePlaybackBitrate(int totalSampleBytes, int parallelRequests) -{ - parallelRequests = std::clamp(parallelRequests, 1, 4); - const int bytesPerRequest = std::max(1, totalSampleBytes / parallelRequests); - const qint64 cacheBuster = QDateTime::currentMSecsSinceEpoch(); - QElapsedTimer timer; - timer.start(); - - // QCoro::Task starts eagerly. Construct all requests before awaiting any - // result so this measures aggregate throughput at the requested level of - // concurrency, while keeping the total transferred bytes constant. - std::vector> tasks; - tasks.reserve(static_cast(parallelRequests)); - for (int lane = 0; lane < parallelRequests; ++lane) { - QUrlQuery query; - query.addQueryItem(QStringLiteral("size"), QString::number(bytesPerRequest)); - query.addQueryItem(QStringLiteral("_"), QStringLiteral("%1-%2").arg(cacheBuster).arg(lane)); - tasks.push_back(requestBytes(HttpMethod::Get, QStringLiteral("/Playback/BitrateTest"), query)); - } - - qint64 transferredBytes = 0; - for (auto& task : tasks) - transferredBytes += (co_await task).size(); - co_return PlaybackBandwidthPolicy::conservativeEstimate(transferredBytes, std::max(1, timer.elapsed())); -} - -QCoro::Task JellyfinApiFacade::measurePlaybackRoundTripTime() -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("_"), QString::number(QDateTime::currentMSecsSinceEpoch())); - QElapsedTimer timer; - timer.start(); - co_await requestBytes(HttpMethod::Get, QStringLiteral("/System/Endpoint"), query); - co_return std::max(1, timer.elapsed()); -} - -QCoro::Task JellyfinApiFacade::refreshPlaybackNetworkState() -{ - if (m_serverUrl.isEmpty() || m_session.accessToken.isEmpty()) - co_return; - if (m_playbackActive) { - // Several megabytes of probe traffic would compete with the stream it - // is meant to size. Whatever ceiling is already known carries this - // playback; measure once the stream ends. - m_measurementDeferred = true; - co_return; - } - - const quint64 generation = ++m_playbackNetworkGeneration; - const QJsonObject endpoint = (co_await requestJson(HttpMethod::Get, QStringLiteral("/System/Endpoint"))).object(); - if (generation != m_playbackNetworkGeneration) - co_return; - - m_playbackEndpointKnown = true; - m_inLocalNetwork - = endpoint.value(QStringLiteral("IsLocal")).toBool() || endpoint.value(QStringLiteral("IsInNetwork")).toBool(); - updateEffectiveStreamingBitrate(); - - // Warm the authenticated server route and exclude DNS/TLS/request setup - // from the samples used to select a streaming ceiling. - co_await measurePlaybackBitrate(512 * 1024, 1); - if (generation != m_playbackNetworkGeneration) - co_return; - const qint64 roundTripMilliseconds = co_await measurePlaybackRoundTripTime(); - if (generation != m_playbackNetworkGeneration) - co_return; - constexpr int benchmarkBytes = 4 * 1024 * 1024; - const qint64 singleRequest = co_await measurePlaybackBitrate(benchmarkBytes, 1); - if (generation != m_playbackNetworkGeneration) - co_return; - const qint64 dualRequest = co_await measurePlaybackBitrate(benchmarkBytes, 2); - if (generation != m_playbackNetworkGeneration) - co_return; - - qint64 fourRequest = 0; - if (PlaybackBandwidthPolicy::shouldBenchmarkFourRequests(roundTripMilliseconds, singleRequest, dualRequest)) { - fourRequest = co_await measurePlaybackBitrate(benchmarkBytes, 4); - if (generation != m_playbackNetworkGeneration) - co_return; - } - - m_measuredStreamingBitrate = std::max({ singleRequest, dualRequest, fourRequest }); - m_measurementRemembered = false; - m_measuredNetworkSignature = currentNetworkSignature(); - setPlaybackParallelRequests( - PlaybackBandwidthPolicy::selectParallelRequests(singleRequest, dualRequest, fourRequest)); - qInfo() << "playback bandwidth: benchmark rtt_ms=" << roundTripMilliseconds << "one=" << singleRequest - << "two=" << dualRequest << "four=" << fourRequest << "selectedRequests=" << m_playbackParallelRequests - << "selectedBitrate=" << m_measuredStreamingBitrate; - rememberMeasurement(); - updateEffectiveStreamingBitrate(); -} - -AuthSession JellyfinApiFacade::session() const -{ - return m_session; -} - -void JellyfinApiFacade::cancelRequests() -{ - if (m_shuttingDown) - return; - m_shuttingDown = true; - const auto replies = m_activeReplies; - for (QNetworkReply *reply : replies) { - if (reply) - reply->abort(); - } -} - -QCoro::Task JellyfinApiFacade::authenticateByName(QString username, QString password) -{ - const QJsonDocument response - = co_await requestJson(HttpMethod::Post, QStringLiteral("/Users/AuthenticateByName"), {}, - QJsonDocument(QJsonObject { - { QStringLiteral("Username"), username }, - { QStringLiteral("Pw"), password }, - })); - - const QJsonObject object = response.object(); - const QJsonObject user = object.value(QStringLiteral("User")).toObject(); - const AuthSession session { - requireString(user, QStringLiteral("Id")), - requireString(user, QStringLiteral("Name")), - requireString(object, QStringLiteral("AccessToken")), - object.value(QStringLiteral("ServerId")).toString(), - }; - setSession(session); - co_return session; -} - -QCoro::Task JellyfinApiFacade::quickConnectEnabled() -{ - const QByteArray response = co_await requestBytes(HttpMethod::Get, QStringLiteral("/QuickConnect/Enabled")); - co_return response.trimmed() == QByteArrayLiteral("true"); -} - -QCoro::Task JellyfinApiFacade::initiateQuickConnect() -{ - const QJsonDocument response = co_await requestJson(HttpMethod::Post, QStringLiteral("/QuickConnect/Initiate")); - co_return response.object(); -} - -QCoro::Task JellyfinApiFacade::pollQuickConnect(QString secret) -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("secret"), secret); - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/QuickConnect/Connect"), query); - co_return response.object(); -} - -QCoro::Task JellyfinApiFacade::authenticateWithQuickConnect(QString secret) -{ - const QJsonDocument response - = co_await requestJson(HttpMethod::Post, QStringLiteral("/Users/AuthenticateWithQuickConnect"), {}, - QJsonDocument(QJsonObject { - { QStringLiteral("Secret"), secret }, - })); - - const QJsonObject object = response.object(); - const QJsonObject user = object.value(QStringLiteral("User")).toObject(); - const AuthSession session { - requireString(user, QStringLiteral("Id")), - requireString(user, QStringLiteral("Name")), - requireString(object, QStringLiteral("AccessToken")), - object.value(QStringLiteral("ServerId")).toString(), - }; - setSession(session); - co_return session; -} - -QCoro::Task JellyfinApiFacade::fetchCurrentUserName() -{ - if (m_session.userId.isEmpty()) - co_return QString(); - - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1").arg(m_session.userId)); - co_return response.object().value(QStringLiteral("Name")).toString(); -} - -QCoro::Task JellyfinApiFacade::fetchUserConfiguration() -{ - if (m_session.userId.isEmpty()) - co_return QJsonObject(); - - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1").arg(m_session.userId)); - co_return response.object().value(QStringLiteral("Configuration")).toObject(); -} - -QCoro::Task JellyfinApiFacade::updateUserConfiguration(QJsonObject configuration) -{ - if (m_session.userId.isEmpty()) - co_return; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Users/%1/Configuration").arg(m_session.userId), - QJsonDocument(configuration)); -} - -QCoro::Task JellyfinApiFacade::fetchCurrentUserPolicy() -{ - if (m_session.userId.isEmpty()) - co_return QJsonObject(); - - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1").arg(m_session.userId)); - co_return response.object().value(QStringLiteral("Policy")).toObject(); -} - -QCoro::Task JellyfinApiFacade::fetchCultures() -{ - const QJsonDocument response = co_await requestJson(HttpMethod::Get, QStringLiteral("/Localization/Options")); - co_return response.isArray() ? response.array() : response.object().value(QStringLiteral("Items")).toArray(); -} - -QCoro::Task> JellyfinApiFacade::fetchLibraries() -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - query.addQueryItem(QStringLiteral("includeHidden"), QStringLiteral("false")); - query.addQueryItem(QStringLiteral("presetViews"), QStringLiteral("true")); - query.addQueryItem(QStringLiteral("enableImageTypes"), QStringLiteral("Primary")); - query.addQueryItem(QStringLiteral("imageTypeLimit"), QStringLiteral("1")); - - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/UserViews"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - - std::vector libraries; - libraries.reserve(items.size()); - for (const QJsonValue& value : items) { - const QJsonObject object = value.toObject(); - LibraryItem library = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - library.imageTag - = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Primary")).toString(); - libraries.push_back(std::move(library)); - } - - co_return libraries; -} - -QCoro::Task JellyfinApiFacade::fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions) -{ - if (!descriptor.isValid()) - co_return PagedMovieItems { {}, 0, std::max(0, startIndex), std::clamp(limit, 1, 200) }; - - QString path = QStringLiteral("/Items"); - QStringList allowedTypes; - const int maximumLimit = descriptor.kind == BrowseKind::Library ? 100 : 200; - // Stream metadata feeds the browse info line; person credits page through - // entire filmographies, so keep that path on the lean field set. - const QString fields = descriptor.kind == BrowseKind::Person - ? libraryItemFields() - : libraryItemFields() + QStringLiteral(",MediaSources"); - ItemsQuery builder = ItemsQuery() - .userId(m_session.userId) - .fields(fields) - .images() - .startIndex(startIndex) - .limit(limit, maximumLimit); - - switch (descriptor.kind) { - case BrowseKind::Library: { - QString includeItemTypes = includeItemTypesForCollection(descriptor.collectionType); - const QString requestedTypes - = stringListFromVariantMap(queryOptions, QStringLiteral("includeItemTypes")).join(QLatin1Char(',')); - const bool collectionFilter - = descriptor.collectionType == QStringLiteral("movies") && requestedTypes == QStringLiteral("BoxSet"); - if (collectionFilter) - includeItemTypes = requestedTypes; - builder.parentId(descriptor.id) - .recursive(!collectionFilter && !libraryBrowseUsesDirectChildren(descriptor.collectionType)) - .includeItemTypes(includeItemTypes); - allowedTypes = itemTypesList(includeItemTypes); - break; - } - case BrowseKind::FolderChildren: - builder.parentId(descriptor.id) - .recursive(false) - .includeItemTypes( - QStringLiteral("Folder,Movie,Series,Season,Episode,MusicVideo,Video,Audio,AudioBook,Book,Photo," - "PhotoAlbum,Playlist,BoxSet,MusicAlbum,MusicArtist")); - allowedTypes = itemTypesList( - QStringLiteral("Folder,Movie,Series,Season,Episode,MusicVideo,Video,Audio,AudioBook,Book,Photo," - "PhotoAlbum,Playlist,BoxSet,MusicAlbum,MusicArtist")); - break; - case BrowseKind::Person: - builder.recursive() - .add(QStringLiteral("personIds"), descriptor.id) - .includeItemTypes(QStringLiteral("Movie,Series,Episode")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("Movie,Series,Episode")); - break; - case BrowseKind::Genre: { - const bool music = descriptor.collectionType == QStringLiteral("music"); - const QString types = music ? QStringLiteral("MusicAlbum") : QStringLiteral("Movie,Series"); - builder.recursive() - .add(QStringLiteral("genres"), descriptor.name) - .includeItemTypes(types) - .sort(QStringLiteral("SortName")); - if (!music) - builder.mediaTypes(QStringLiteral("Video")); - allowedTypes = itemTypesList(types); - break; - } - case BrowseKind::Studio: - builder.recursive() - .add(QStringLiteral("studios"), descriptor.name) - .includeItemTypes(QStringLiteral("Movie,Series")) - .mediaTypes(QStringLiteral("Video")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("Movie,Series")); - break; - case BrowseKind::SeriesSeasons: - path = QStringLiteral("/Shows/%1/Seasons") - .arg(descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId); - allowedTypes = itemTypesList(QStringLiteral("Season")); - break; - case BrowseKind::SeasonEpisodes: - path = QStringLiteral("/Shows/%1/Episodes").arg(descriptor.seriesId); - builder.addIfNotEmpty(QStringLiteral("seasonId"), descriptor.seasonId); - allowedTypes = itemTypesList(QStringLiteral("Episode")); - break; - case BrowseKind::Playlist: - path = QStringLiteral("/Playlists/%1/Items").arg(descriptor.id); - allowedTypes = itemTypesList(QStringLiteral("Movie,Episode,MusicVideo,Video,Audio")); - break; - case BrowseKind::BoxSet: - builder.parentId(descriptor.id) - .recursive(false) - .includeItemTypes(QStringLiteral("Movie,Series,Episode")) - .mediaTypes(QStringLiteral("Video")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("Movie,Series,Episode")); - break; - case BrowseKind::ArtistAlbums: - builder.recursive() - .add(QStringLiteral("artistIds"), descriptor.id) - .includeItemTypes(QStringLiteral("MusicAlbum")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("MusicAlbum")); - break; - case BrowseKind::None: - break; - } - - QUrlQuery query = builder.toUrlQuery(); - if (descriptor.kind == BrowseKind::Library) - addLibraryQueryOptions(query, queryOptions); - - const QString pageName = descriptor.name.isEmpty() ? descriptor.id : descriptor.name; - QElapsedTimer requestTimer; - requestTimer.start(); - QJsonObject response; - try { - response = (co_await requestJson(HttpMethod::Get, path, query)).object(); - } catch (...) { - qWarning() << "browse page: request failed" << pageName << "start=" << std::max(0, startIndex) - << "limit=" << std::clamp(limit, 1, maximumLimit) << "ms=" << requestTimer.elapsed(); - throw; - } - - std::vector items = mediaItemsFromJson(response.value(QStringLiteral("Items")).toArray(), allowedTypes); - if (descriptor.kind == BrowseKind::SeriesSeasons) { - const QString seriesId = descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId; - for (MovieItem& item : items) { - if (item.seriesId.isEmpty()) - item.seriesId = seriesId; - } - } - - const int effectiveStart = std::max(0, startIndex); - const int effectiveLimit = std::clamp(limit, 1, maximumLimit); - const int totalCount = response.value(QStringLiteral("TotalRecordCount")).toInt(0); - qInfo() << "browse page: request complete" << pageName << "start=" << effectiveStart << "limit=" << effectiveLimit - << "items=" << items.size() << "total=" << totalCount << "ms=" << requestTimer.elapsed(); - co_return PagedMovieItems { std::move(items), totalCount, effectiveStart, effectiveLimit }; -} - -QCoro::Task JellyfinApiFacade::fetchLibraryFilterOptions(QString libraryId, QString collectionType) -{ - QVariantMap options; - if (libraryId.isEmpty()) - co_return options; - - const QString includeItemTypes = includeItemTypesForCollection(collectionType); - - QUrlQuery filterQuery - = ItemsQuery().userId(m_session.userId).parentId(libraryId).includeItemTypes(includeItemTypes).toUrlQuery(); - - const QJsonObject filters - = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items/Filters"), filterQuery)).object(); - options.insert(QStringLiteral("genres"), metaStringListFromJson(filters, { QStringLiteral("Genres") })); - options.insert( - QStringLiteral("officialRatings"), metaStringListFromJson(filters, { QStringLiteral("OfficialRatings") })); - options.insert(QStringLiteral("tags"), metaStringListFromJson(filters, { QStringLiteral("Tags") })); - - QVariantList years; - const QJsonArray yearsArray = filters.value(QStringLiteral("Years")).toArray(); - years.reserve(yearsArray.size()); - for (const QJsonValue& value : yearsArray) { - const int year = value.toInt(); - if (year > 0) - years.push_back(year); - } - options.insert(QStringLiteral("years"), years); - - QUrlQuery studiosQuery = ItemsQuery() - .userId(m_session.userId) - .parentId(libraryId) - .includeItemTypes(includeItemTypes) - .sort(QStringLiteral("SortName")) - .toUrlQuery(); - - const QJsonArray studioItems = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Studios"), studiosQuery)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - QVariantList studios; - studios.reserve(studioItems.size()); - for (const QJsonValue& value : studioItems) { - const QJsonObject studio = value.toObject(); - const QString id = studio.value(QStringLiteral("Id")).toString(); - const QString name = studio.value(QStringLiteral("Name")).toString(); - if (!id.isEmpty() && !name.isEmpty()) - studios.push_back(QVariantMap { { QStringLiteral("id"), id }, { QStringLiteral("name"), name } }); - } - options.insert(QStringLiteral("studios"), studios); - - co_return options; -} - -QCoro::Task JellyfinApiFacade::fetchItemDetails(QString itemId) -{ - if (itemId.isEmpty() || m_session.userId.isEmpty()) - co_return MovieItem {}; - - QUrlQuery query = ItemsQuery().fields(detailItemFields()).images().toUrlQuery(); - - const QJsonObject object = (co_await requestJson(HttpMethod::Get, - QStringLiteral("/Users/%1/Items/%2").arg(m_session.userId, itemId), query)) - .object(); - co_return mediaItemFromJson(object); -} - -QCoro::Task> JellyfinApiFacade::fetchItemsByIds(QStringList itemIds) -{ - itemIds.removeAll(QString()); - itemIds.removeDuplicates(); - if (itemIds.isEmpty() || m_session.userId.isEmpty()) - co_return std::vector {}; - - std::vector result; - result.reserve(static_cast(itemIds.size())); - - // SyncPlay queues commonly contain an entire library. Putting every UUID - // into one query string exceeds Jellyfin/reverse-proxy request-line limits - // (the TV observed HTTP 414 with a 262-item queue). - constexpr qsizetype kItemsPerRequest = 50; - for (qsizetype offset = 0; offset < itemIds.size(); offset += kItemsPerRequest) { - const QStringList batch = itemIds.sliced(offset, std::min(kItemsPerRequest, itemIds.size() - offset)); - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .fields(detailItemFields()) - .images() - .add(QStringLiteral("ids"), batch.join(QLatin1Char(','))) - .toUrlQuery(); - const QJsonArray items = itemsArrayFromDocument( - co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1/Items").arg(m_session.userId), query)); - for (const QJsonValue& value : items) - result.push_back(mediaItemFromJson(value.toObject())); - } - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchSeasons(QString seriesId) -{ - const PagedMovieItems page = co_await fetchBrowsePage(BrowseDescriptor::seriesSeasons(seriesId), 0, 200); - co_return page.items; -} - -QCoro::Task> JellyfinApiFacade::fetchEpisodes(QString seriesId, QString seasonId) -{ - constexpr int pageSize = 200; - const BrowseDescriptor descriptor = BrowseDescriptor::seasonEpisodes(seriesId, seasonId); - std::vector episodes; - int startIndex = 0; - while (true) { - PagedMovieItems page = co_await fetchBrowsePage(descriptor, startIndex, pageSize); - if (page.items.empty()) - break; - startIndex += static_cast(page.items.size()); - episodes.insert( - episodes.end(), std::make_move_iterator(page.items.begin()), std::make_move_iterator(page.items.end())); - if (startIndex >= page.totalRecordCount || static_cast(page.items.size()) < pageSize) - break; - } - co_return episodes; -} - -QCoro::Task> JellyfinApiFacade::fetchResumeItems(int limit) -{ - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .limit(limit, 60) - .recursive() - .fields(libraryItemFields()) - .includeItemTypes(QStringLiteral("Movie,Episode")) - .images() - .mediaTypes(QStringLiteral("Video")) - .toUrlQuery(); - - const QJsonArray items = itemsArrayFromDocument( - co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1/Items/Resume").arg(m_session.userId), query)); - - std::vector result; - result.reserve(items.size()); - for (const auto& value : items) { - auto item = mediaItemFromJson(value.toObject()); - if (isPlayableItem(item) && item.resumeTicks > 0) - result.push_back(item); - } - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchNextUpEpisodes(int limit) -{ - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .limit(limit, 60) - .fields(libraryItemFields()) - .images() - .add(QStringLiteral("enableResumable"), QStringLiteral("false")) - .toUrlQuery(); - - const QJsonArray items - = itemsArrayFromDocument(co_await requestJson(HttpMethod::Get, QStringLiteral("/Shows/NextUp"), query)); - - std::vector result; - result.reserve(items.size()); - for (const auto& value : items) { - auto item = mediaItemFromJson(value.toObject()); - if (item.itemType == QStringLiteral("Episode") && isPlayableItem(item)) - result.push_back(item); - } - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchLatestItems(QString parentId, int limit) -{ - QUrlQuery query = ItemsQuery() - .limit(limit, 200) - .fields(libraryItemFields()) - .images() - .add(QStringLiteral("groupItems"), QStringLiteral("false")) - .parentId(parentId) - .toUrlQuery(); - - const QJsonDocument doc - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1/Items/Latest").arg(m_session.userId), query); - - const QJsonArray items = itemsArrayFromDocument(doc); - - std::vector result; - result.reserve(items.size()); - for (const auto& value : items) - result.push_back(mediaItemFromJson(value.toObject())); - co_return result; -} - -QCoro::Task> JellyfinApiFacade::searchItems(QString searchTerm, int limit) -{ - searchTerm = searchTerm.trimmed(); - if (searchTerm.isEmpty()) - co_return std::vector {}; - - const QStringList searchableTypes { - QStringLiteral("Movie"), - QStringLiteral("Series"), - QStringLiteral("Season"), - QStringLiteral("Episode"), - QStringLiteral("MusicVideo"), - QStringLiteral("Video"), - QStringLiteral("Audio"), - QStringLiteral("MusicAlbum"), - QStringLiteral("MusicArtist"), - QStringLiteral("Book"), - QStringLiteral("AudioBook"), - QStringLiteral("Photo"), - QStringLiteral("PhotoAlbum"), - QStringLiteral("Playlist"), - QStringLiteral("BoxSet"), - QStringLiteral("Folder"), - QStringLiteral("Person"), - QStringLiteral("Trailer"), - }; - const auto fetchTypes = [this, &searchTerm](const QString& types, int requestLimit, - const QStringList& allowedTypes) -> QCoro::Task> { - const QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .recursive() - .add(QStringLiteral("searchTerm"), searchTerm) - .includeItemTypes(types) - .fields(libraryItemFields()) - .sort(QStringLiteral("SortName")) - .images() - .limit(requestLimit, 200) - .toUrlQuery(); - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - co_return mediaItemsFromJson(items, allowedTypes); - }; - - std::vector result = co_await fetchTypes(searchableTypes.join(QLatin1Char(',')), limit, searchableTypes); - - // Large episode libraries can consume a mixed result limit before any - // matching series containers arrive. A small dedicated series query keeps - // show search reliable without knowing anything about the server's library - // names or layout. - const std::vector series - = co_await fetchTypes(QStringLiteral("Series"), std::min(limit, 40), { QStringLiteral("Series") }); - QSet seen; - for (const MovieItem& item : result) - seen.insert(item.id); - for (const MovieItem& item : series) { - if (!seen.contains(item.id)) { - seen.insert(item.id); - result.push_back(item); - } - } - - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchSearchSuggestions(int limit) -{ - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .recursive() - .includeItemTypes(QStringLiteral("Movie,Series")) - .mediaTypes(QStringLiteral("Video")) - .fields(libraryItemFields()) - .sort(QStringLiteral("IsFavoriteOrLiked,Random"), {}) - .images() - .enableTotalRecordCount(false) - .limit(limit, 60) - .toUrlQuery(); - - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - - const std::vector result - = mediaItemsFromJson(items, { QStringLiteral("Movie"), QStringLiteral("Series") }); - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchSimilarItems(QString itemId, int limit) -{ - if (itemId.isEmpty()) - co_return std::vector {}; - - QUrlQuery query - = ItemsQuery().userId(m_session.userId).limit(limit, 60).fields(libraryItemFields()).images().toUrlQuery(); - - const QJsonArray items = itemsArrayFromDocument( - co_await requestJson(HttpMethod::Get, QStringLiteral("/Items/%1/Similar").arg(itemId), query)); - - const std::vector result - = mediaItemsFromJson(items, { QStringLiteral("Movie"), QStringLiteral("Series"), QStringLiteral("Episode") }); - co_return result; -} - -QCoro::Task JellyfinApiFacade::fetchItemsByPerson(QString personId, int maximumItems) -{ - PersonCredits credits; - if (personId.isEmpty()) - co_return credits; - - const int boundedMaximum = std::clamp(maximumItems, 1, 10000); - int startIndex = 0; - while (startIndex < boundedMaximum) { - const int pageLimit = std::min(200, boundedMaximum - startIndex); - PagedMovieItems page = co_await fetchBrowsePage(BrowseDescriptor::person(personId), startIndex, pageLimit); - if (page.items.empty()) - break; - startIndex += static_cast(page.items.size()); - credits.items.insert(credits.items.end(), std::make_move_iterator(page.items.begin()), - std::make_move_iterator(page.items.end())); - if ((page.totalRecordCount > 0 && startIndex >= page.totalRecordCount) - || static_cast(page.items.size()) < pageLimit) - break; - } - - QSet seriesIds; - for (const MovieItem& item : credits.items) { - if (item.itemType == QStringLiteral("Episode") && !item.seriesId.isEmpty()) - seriesIds.insert(item.seriesId); - } - - const QStringList ids = seriesIds.values(); - for (qsizetype offset = 0; offset < ids.size(); offset += 200) { - const QStringList batch = ids.sliced(offset, std::min(200, ids.size() - offset)); - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .includeItemTypes(QStringLiteral("Series")) - .fields(libraryItemFields() + QStringLiteral(",RecursiveItemCount")) - .images() - .limit(batch.size(), 200) - .add(QStringLiteral("ids"), batch.join(QLatin1Char(','))) - .toUrlQuery(); - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - std::vector series = mediaItemsFromJson(items, { QStringLiteral("Series") }); - credits.relatedSeries.insert(credits.relatedSeries.end(), std::make_move_iterator(series.begin()), - std::make_move_iterator(series.end())); - } - - co_return credits; -} - -QCoro::Task> JellyfinApiFacade::fetchManagementTargets(QString itemType) -{ - if (m_session.userId.isEmpty()) - co_return std::vector {}; - - itemType = itemType.trimmed(); - if (itemType != QStringLiteral("Playlist") && itemType != QStringLiteral("BoxSet")) - co_return std::vector {}; - - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .recursive() - .includeItemTypes(itemType) - .fields(libraryItemFields()) - .sort(QStringLiteral("SortName")) - .images() - .limit(200, 200) - .toUrlQuery(); - - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - co_return mediaItemsFromJson(items, { itemType }); -} - -QCoro::Task JellyfinApiFacade::createPlaylist(QString name, QStringList itemIds) -{ - name = name.trimmed(); - if (name.isEmpty()) - throw std::runtime_error("Playlist name is required"); - - QJsonArray ids; - for (const QString& itemId : itemIds) { - if (!itemId.isEmpty()) - ids.push_back(itemId); - } - QJsonObject body { - { QStringLiteral("Name"), name }, - { QStringLiteral("UserId"), m_session.userId }, - { QStringLiteral("Ids"), ids }, - { QStringLiteral("IsPublic"), false }, - }; - const QJsonObject response - = (co_await requestJson(HttpMethod::Post, QStringLiteral("/Playlists"), {}, QJsonDocument(body))).object(); - co_return response.value(QStringLiteral("Id")).toString(); -} - -QCoro::Task JellyfinApiFacade::addPlaylistItems(QString playlistId, QStringList itemIds, int position) -{ - if (playlistId.isEmpty() || itemIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - if (position >= 0) - query.addQueryItem(QStringLiteral("position"), QString::number(position)); - co_await requestJson(HttpMethod::Post, QStringLiteral("/Playlists/%1/Items").arg(playlistId), query); -} - -QCoro::Task JellyfinApiFacade::removePlaylistItems(QString playlistId, QStringList entryIds) -{ - if (playlistId.isEmpty() || entryIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("entryIds"), entryIds.join(QLatin1Char(','))); - co_await requestJson(HttpMethod::Delete, QStringLiteral("/Playlists/%1/Items").arg(playlistId), query); -} - -QCoro::Task JellyfinApiFacade::movePlaylistItem(QString playlistId, QString playlistItemId, int newIndex) -{ - if (playlistId.isEmpty() || playlistItemId.isEmpty() || newIndex < 0) - co_return; - - co_await requestNoContent(HttpMethod::Post, - QStringLiteral("/Playlists/%1/Items/%2/Move/%3").arg(playlistId, playlistItemId, QString::number(newIndex)), - QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::updatePlaylistName(QString playlistId, QString name) -{ - name = name.trimmed(); - if (playlistId.isEmpty() || name.isEmpty()) - co_return; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Playlists/%1").arg(playlistId), - QJsonDocument(QJsonObject { { QStringLiteral("Name"), name } })); -} - -QCoro::Task JellyfinApiFacade::createCollection(QString name, QStringList itemIds) -{ - name = name.trimmed(); - if (name.isEmpty()) - throw std::runtime_error("Collection name is required"); - - QUrlQuery query; - query.addQueryItem(QStringLiteral("name"), name); - if (!itemIds.isEmpty()) - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - query.addQueryItem(QStringLiteral("isLocked"), QStringLiteral("false")); - const QJsonObject response - = (co_await requestJson(HttpMethod::Post, QStringLiteral("/Collections"), query)).object(); - co_return response.value(QStringLiteral("Id")).toString(); -} - -QCoro::Task JellyfinApiFacade::addCollectionItems(QString collectionId, QStringList itemIds) -{ - if (collectionId.isEmpty() || itemIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - co_await requestJson(HttpMethod::Post, QStringLiteral("/Collections/%1/Items").arg(collectionId), query); -} - -QCoro::Task JellyfinApiFacade::removeCollectionItems(QString collectionId, QStringList itemIds) -{ - if (collectionId.isEmpty() || itemIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - co_await requestJson(HttpMethod::Delete, QStringLiteral("/Collections/%1/Items").arg(collectionId), query); -} - -QCoro::Task JellyfinApiFacade::renameItem(QString itemId, QString name) -{ - name = name.trimmed(); - if (itemId.isEmpty() || name.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - QJsonObject item = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items/%1").arg(itemId), query)).object(); - item.insert(QStringLiteral("Name"), name); - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Items/%1").arg(itemId), QJsonDocument(item)); -} - -QCoro::Task JellyfinApiFacade::deleteItem(QString itemId) -{ - if (itemId.isEmpty()) - co_return; - - co_await requestNoContent(HttpMethod::Delete, QStringLiteral("/Items/%1").arg(itemId), QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::setItemFavorite(QString itemId, bool favorite) -{ - if (itemId.isEmpty()) - co_return; - - const QString path = QStringLiteral("/Users/%1/FavoriteItems/%2").arg(m_session.userId, itemId); - co_await requestNoContent(favorite ? HttpMethod::Post : HttpMethod::Delete, path, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::setItemPlayed(QString itemId, bool played) -{ - if (itemId.isEmpty()) - co_return; - - const QString path = QStringLiteral("/Users/%1/PlayedItems/%2").arg(m_session.userId, itemId); - co_await requestNoContent(played ? HttpMethod::Post : HttpMethod::Delete, path, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::setItemPlaybackPosition(QString itemId, qint64 positionTicks) -{ - if (itemId.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - const QJsonObject body { - { QStringLiteral("ItemId"), itemId }, - { QStringLiteral("PlaybackPositionTicks"), std::max(0, positionTicks) }, - { QStringLiteral("Played"), false }, - }; - co_await requestJson( - HttpMethod::Post, QStringLiteral("/UserItems/%1/UserData").arg(itemId), query, QJsonDocument(body)); -} - -QCoro::Task> JellyfinApiFacade::fetchMediaSegments(QString itemId) -{ - Diagnostics::Task task(QStringLiteral("api_fetch_media_segments"), { { QStringLiteral("itemId"), itemId } }); - std::vector result; - try { - const QJsonDocument doc - = co_await requestJson(HttpMethod::Get, QStringLiteral("/MediaSegments/%1").arg(itemId)); - const QJsonArray items = itemsArrayFromDocument(doc); - result.reserve(items.size()); - for (const auto& value : items) { - const QJsonObject object = value.toObject(); - MediaSegment segment; - segment.id = object.value(QStringLiteral("Id")).toString(); - segment.type = object.value(QStringLiteral("Type")).toString(); - // Jellyfin returns ticks as a JSON number; toVariant().toLongLong() handles ints and doubles. - segment.startTicks = object.value(QStringLiteral("StartTicks")).toVariant().toLongLong(); - segment.endTicks = object.value(QStringLiteral("EndTicks")).toVariant().toLongLong(); - if (segment.endTicks > segment.startTicks) - result.push_back(segment); - } - } catch (const std::exception& e) { - // Servers without the media-segments endpoint return 404. Treat any - // failure as "no segments" — playback should keep working. - qInfo() << "api: media segments unavailable for" << itemId << ":" << e.what(); - } - co_return result; -} - -QCoro::Task JellyfinApiFacade::fetchTrickplayInfo(QString itemId, QString mediaSourceId) -{ - if (itemId.isEmpty() || m_session.userId.isEmpty()) - co_return TrickplayInfo {}; - - const QUrlQuery query = ItemsQuery().fields(QStringLiteral("Trickplay")).toUrlQuery(); - const QJsonObject item = (co_await requestJson(HttpMethod::Get, - QStringLiteral("/Users/%1/Items/%2").arg(m_session.userId, itemId), query)) - .object(); - co_return PlaybackNegotiation::selectTrickplay( - item.value(QStringLiteral("Trickplay")).toObject(), mediaSourceId, 320); -} - -QString JellyfinApiFacade::trickplayTileUrl(const QString& itemId, int width, int tileIndex) const -{ - if (m_serverUrl.isEmpty() || itemId.isEmpty() || width <= 0 || tileIndex < 0) - return {}; - - QUrl url = serverUrlWithPath(m_serverUrl, - { QStringLiteral("Videos"), itemId, QStringLiteral("Trickplay"), QString::number(width), - QStringLiteral("%1.jpg").arg(tileIndex) }); - - return url.toString(QUrl::FullyEncoded); -} -QCoro::Task JellyfinApiFacade::fetchControllableSessions() -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("controllableByUserId"), m_session.userId); - const QJsonDocument document = co_await requestJson(HttpMethod::Get, QStringLiteral("/Sessions"), query); - co_return document.isArray() ? document.array() : document.object().value(QStringLiteral("Items")).toArray(); -} - -QCoro::Task JellyfinApiFacade::sendRemotePlay(QString sessionId, QStringList itemIds, QString playCommand, - qint64 startPositionTicks, int startIndex, QString mediaSourceId, int audioStreamIndex, int subtitleStreamIndex) -{ - sessionId = sessionId.trimmed(); - itemIds.removeAll(QString()); - if (sessionId.isEmpty() || itemIds.isEmpty()) - throw std::runtime_error("Remote play needs a target and at least one item"); - - QUrlQuery query; - query.addQueryItem(QStringLiteral("playCommand"), playCommand.isEmpty() ? QStringLiteral("PlayNow") : playCommand); - query.addQueryItem(QStringLiteral("itemIds"), itemIds.join(QLatin1Char(','))); - if (startPositionTicks >= 0) - query.addQueryItem(QStringLiteral("startPositionTicks"), QString::number(startPositionTicks)); - if (startIndex >= 0) - query.addQueryItem(QStringLiteral("startIndex"), QString::number(startIndex)); - if (!mediaSourceId.isEmpty()) - query.addQueryItem(QStringLiteral("mediaSourceId"), mediaSourceId); - if (audioStreamIndex >= -1) - query.addQueryItem(QStringLiteral("audioStreamIndex"), QString::number(audioStreamIndex)); - if (subtitleStreamIndex >= -1) - query.addQueryItem(QStringLiteral("subtitleStreamIndex"), QString::number(subtitleStreamIndex)); - - const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - co_await requestBytes( - HttpMethod::Post, QStringLiteral("/Sessions/%1/Playing").arg(encodedSession), query, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::sendRemotePlaystate(QString sessionId, QString command, qint64 seekPositionTicks) -{ - sessionId = sessionId.trimmed(); - command = command.trimmed(); - if (sessionId.isEmpty() || command.isEmpty()) - throw std::runtime_error("Remote playstate command needs a target and command"); - - QUrlQuery query; - if (seekPositionTicks >= 0) - query.addQueryItem(QStringLiteral("seekPositionTicks"), QString::number(seekPositionTicks)); - if (!m_session.userId.isEmpty()) - query.addQueryItem(QStringLiteral("controllingUserId"), m_session.userId); - const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - const QString encodedCommand = QString::fromLatin1(QUrl::toPercentEncoding(command)); - co_await requestBytes(HttpMethod::Post, - QStringLiteral("/Sessions/%1/Playing/%2").arg(encodedSession, encodedCommand), query, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::sendRemoteGeneralCommand(QString sessionId, QString command, QJsonObject arguments) -{ - sessionId = sessionId.trimmed(); - command = command.trimmed(); - if (sessionId.isEmpty() || command.isEmpty()) - throw std::runtime_error("Remote general command needs a target and command"); - - QJsonObject body { { QStringLiteral("Name"), command } }; - if (!arguments.isEmpty()) - body.insert(QStringLiteral("Arguments"), arguments); - const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - co_await requestNoContent( - HttpMethod::Post, QStringLiteral("/Sessions/%1/Command").arg(encodedSession), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::fetchSyncPlayGroups() -{ - Diagnostics::Task task(QStringLiteral("api_syncplay_list")); - try { - const QJsonDocument doc = co_await requestJson(HttpMethod::Get, QStringLiteral("/SyncPlay/List")); - if (doc.isArray()) - co_return doc.array(); - co_return doc.object().value(QStringLiteral("Items")).toArray(); - } catch (const std::exception& e) { - qInfo() << "api: syncplay list failed:" << e.what(); - co_return QJsonArray(); - } -} - -QCoro::Task JellyfinApiFacade::createSyncPlayGroup(QString name) -{ - const QJsonObject body = { { QStringLiteral("GroupName"), name } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/New"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::joinSyncPlayGroup(QString groupId) -{ - const QJsonObject body = { { QStringLiteral("GroupId"), groupId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Join"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::leaveSyncPlayGroup() -{ - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Leave"), QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::fetchUtcTime() -{ - co_return (co_await requestJson(HttpMethod::Get, QStringLiteral("/GetUtcTime"))).object(); -} - -QCoro::Task JellyfinApiFacade::syncPlayReportPing(qint64 pingMs) -{ - const QJsonObject body = { - { QStringLiteral("Ping"), std::max(0, pingMs) }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Ping"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayReportBuffering( - bool buffering, qint64 positionTicks, bool playing, QString playlistItemId, QDateTime serverTime) -{ - if (playlistItemId.isEmpty()) { - playlistItemId = QStringLiteral("00000000-0000-0000-0000-000000000000"); - } - const QJsonObject body = { - { QStringLiteral("When"), serverTime.toUTC().toString(Qt::ISODateWithMs) }, - { QStringLiteral("PositionTicks"), positionTicks }, - { QStringLiteral("IsPlaying"), playing }, - { QStringLiteral("PlaylistItemId"), playlistItemId }, - }; - const QString path = buffering ? QStringLiteral("/SyncPlay/Buffering") : QStringLiteral("/SyncPlay/Ready"); - co_await requestNoContent(HttpMethod::Post, path, QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlaySetNewQueue( - QStringList itemIds, int playingItemPosition, qint64 startPositionTicks) -{ - QJsonArray playingQueue; - for (const QString& itemId : itemIds) { - if (!itemId.isEmpty()) - playingQueue.append(itemId); - } - if (playingQueue.isEmpty() || playingItemPosition < 0 || playingItemPosition >= playingQueue.size()) - throw std::runtime_error("SyncPlay queue has no playable item"); - - const QJsonObject body = { - { QStringLiteral("PlayingQueue"), playingQueue }, - { QStringLiteral("PlayingItemPosition"), playingItemPosition }, - { QStringLiteral("StartPositionTicks"), startPositionTicks }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/SetNewQueue"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayUnpause() -{ - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Unpause"), QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::syncPlayPause() -{ - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Pause"), QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::syncPlaySeek(qint64 positionTicks) -{ - const QJsonObject body = { - { QStringLiteral("PositionTicks"), std::max(0, positionTicks) }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Seek"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayNextItem(QString playlistItemId) -{ - const QJsonObject body = { { QStringLiteral("PlaylistItemId"), playlistItemId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/NextItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayPreviousItem(QString playlistItemId) -{ - const QJsonObject body = { { QStringLiteral("PlaylistItemId"), playlistItemId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/PreviousItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayQueue(QStringList itemIds, bool queueNext) -{ - QJsonArray ids; - for (const QString& itemId : itemIds) { - if (!itemId.isEmpty()) - ids.append(itemId); - } - if (ids.isEmpty()) - throw std::runtime_error("SyncPlay queue request has no items"); - - const QJsonObject body = { - { QStringLiteral("ItemIds"), ids }, - { QStringLiteral("Mode"), queueNext ? QStringLiteral("QueueNext") : QStringLiteral("Queue") }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Queue"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayMovePlaylistItem(QString playlistItemId, int newIndex) -{ - if (playlistItemId.isEmpty()) - throw std::runtime_error("SyncPlay move needs a playlist item id"); - - const QJsonObject body = { - { QStringLiteral("PlaylistItemId"), playlistItemId }, - { QStringLiteral("NewIndex"), std::max(0, newIndex) }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/MovePlaylistItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayRemoveFromPlaylist(QStringList playlistItemIds) -{ - QJsonArray ids; - for (const QString& playlistItemId : playlistItemIds) { - if (!playlistItemId.isEmpty()) - ids.append(playlistItemId); - } - if (ids.isEmpty()) - throw std::runtime_error("SyncPlay removal has no playlist items"); - - const QJsonObject body = { - { QStringLiteral("PlaylistItemIds"), ids }, - // Removing rows is not the same as ending the session, and the server - // will happily do both if asked. - { QStringLiteral("ClearPlaylist"), false }, - { QStringLiteral("ClearPlayingItem"), false }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/RemoveFromPlaylist"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlaySetPlaylistItem(QString playlistItemId) -{ - if (playlistItemId.isEmpty()) - throw std::runtime_error("SyncPlay jump needs a playlist item id"); - - const QJsonObject body = { { QStringLiteral("PlaylistItemId"), playlistItemId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/SetPlaylistItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::negotiatePlayback(MovieItem movie, bool forceTranscode) -{ - Diagnostics::Task task(QStringLiteral("api_negotiate_playback"), - { { QStringLiteral("itemId"), movie.id }, { QStringLiteral("title"), movie.title } }); - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - query.addQueryItem(QStringLiteral("fields"), QStringLiteral("Trickplay")); - - const QJsonObject body = { - { QStringLiteral("UserId"), m_session.userId }, - { QStringLiteral("MaxStreamingBitrate"), m_maxStreamingBitrate }, - { QStringLiteral("StartTimeTicks"), movie.resumeTicks }, - { QStringLiteral("AutoOpenLiveStream"), true }, - { QStringLiteral("EnableDirectPlay"), !forceTranscode }, - { QStringLiteral("EnableDirectStream"), !forceTranscode }, - { QStringLiteral("EnableTranscoding"), true }, - { QStringLiteral("AllowVideoStreamCopy"), !forceTranscode }, - { QStringLiteral("AllowAudioStreamCopy"), true }, - { QStringLiteral("DeviceProfile"), buildDeviceProfile() }, - }; - - const QJsonObject playbackResponse - = (co_await requestJson( - HttpMethod::Post, QStringLiteral("/Items/%1/PlaybackInfo").arg(movie.id), query, QJsonDocument(body))) - .object(); - - PlaybackSession session = buildPlaybackSession(movie, playbackResponse); - if (session.trickplay.width <= 0) { - const QUrlQuery itemQuery = ItemsQuery().fields(QStringLiteral("Trickplay")).toUrlQuery(); - const QJsonObject item = (co_await requestJson(HttpMethod::Get, - QStringLiteral("/Users/%1/Items/%2").arg(m_session.userId, movie.id), itemQuery)) - .object(); - session.trickplay = PlaybackNegotiation::selectTrickplay( - item.value(QStringLiteral("Trickplay")).toObject(), session.mediaSourceId, 320); - } - co_return session; -} - -QCoro::Task JellyfinApiFacade::postCapabilities() -{ - QJsonArray supportedCommands; - if (m_remoteControlTargetEnabled) { - supportedCommands = { - QStringLiteral("MoveUp"), - QStringLiteral("MoveDown"), - QStringLiteral("MoveLeft"), - QStringLiteral("MoveRight"), - QStringLiteral("PageUp"), - QStringLiteral("PageDown"), - QStringLiteral("PreviousLetter"), - QStringLiteral("NextLetter"), - QStringLiteral("Select"), - QStringLiteral("Back"), - QStringLiteral("SendKey"), - QStringLiteral("SendString"), - QStringLiteral("VolumeUp"), - QStringLiteral("VolumeDown"), - QStringLiteral("Mute"), - QStringLiteral("Unmute"), - QStringLiteral("ToggleMute"), - QStringLiteral("SetVolume"), - QStringLiteral("SetAudioStreamIndex"), - QStringLiteral("SetSubtitleStreamIndex"), - QStringLiteral("ToggleOsd"), - QStringLiteral("ToggleOsdMenu"), - QStringLiteral("ToggleContextMenu"), - QStringLiteral("ToggleStats"), - QStringLiteral("ToggleFullscreen"), - QStringLiteral("GoHome"), - QStringLiteral("GoToSettings"), - QStringLiteral("GoToSearch"), - QStringLiteral("DisplayContent"), - QStringLiteral("DisplayMessage"), - QStringLiteral("SetRepeatMode"), - QStringLiteral("SetShuffleQueue"), - QStringLiteral("SetPlaybackOrder"), - QStringLiteral("SetMaxStreamingBitrate"), - QStringLiteral("Play"), - }; - } - const QJsonObject body = { - { QStringLiteral("PlayableMediaTypes"), - m_remoteControlTargetEnabled ? QJsonArray { QStringLiteral("Video"), QStringLiteral("Audio") } - : QJsonArray {} }, - { QStringLiteral("SupportedCommands"), supportedCommands }, - { QStringLiteral("SupportsMediaControl"), m_remoteControlTargetEnabled }, - { QStringLiteral("SupportsPersistentIdentifier"), true }, - { QStringLiteral("DeviceProfile"), buildDeviceProfile() }, - }; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Capabilities/Full"), QJsonDocument(body)); -} - -QJsonArray nowPlayingQueueJson(const std::vector& queue) -{ - QJsonArray array; - for (const PlaybackQueueItem& item : queue) { - QJsonObject object { { QStringLiteral("Id"), item.itemId } }; - if (!item.playlistItemId.isEmpty()) - object.insert(QStringLiteral("PlaylistItemId"), item.playlistItemId); - array.push_back(object); - } - return array; -} - -QCoro::Task JellyfinApiFacade::reportPlaybackStart( - PlaybackSession session, double playbackRate, int volume, bool muted) -{ - QJsonObject body = { - { QStringLiteral("CanSeek"), true }, - { QStringLiteral("ItemId"), session.itemId }, - { QStringLiteral("MediaSourceId"), session.mediaSourceId }, - { QStringLiteral("PlayMethod"), session.playMethod }, - { QStringLiteral("PlaySessionId"), session.playSessionId }, - { QStringLiteral("PositionTicks"), session.startTimeTicks }, - { QStringLiteral("AudioStreamIndex"), session.audioStreamIndex }, - { QStringLiteral("SubtitleStreamIndex"), session.subtitleStreamIndex }, - { QStringLiteral("PlaybackRate"), playbackRate }, - { QStringLiteral("VolumeLevel"), std::clamp(volume, 0, 100) }, - { QStringLiteral("IsMuted"), muted }, - }; - if (!session.nowPlayingQueue.empty()) - body.insert(QStringLiteral("NowPlayingQueue"), nowPlayingQueueJson(session.nowPlayingQueue)); - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Playing"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::reportPlaybackProgress( - PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted) -{ - QJsonObject body = { - { QStringLiteral("CanSeek"), true }, - { QStringLiteral("ItemId"), session.itemId }, - { QStringLiteral("MediaSourceId"), session.mediaSourceId }, - { QStringLiteral("PlayMethod"), session.playMethod }, - { QStringLiteral("PlaySessionId"), session.playSessionId }, - { QStringLiteral("PositionTicks"), positionTicks }, - { QStringLiteral("IsPaused"), paused }, - { QStringLiteral("AudioStreamIndex"), session.audioStreamIndex }, - { QStringLiteral("SubtitleStreamIndex"), session.subtitleStreamIndex }, - { QStringLiteral("PlaybackRate"), playbackRate }, - { QStringLiteral("VolumeLevel"), std::clamp(volume, 0, 100) }, - { QStringLiteral("IsMuted"), muted }, - }; - if (!session.nowPlayingQueue.empty()) - body.insert(QStringLiteral("NowPlayingQueue"), nowPlayingQueueJson(session.nowPlayingQueue)); - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Playing/Progress"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate) -{ - const QJsonObject body = { - { QStringLiteral("ItemId"), session.itemId }, - { QStringLiteral("MediaSourceId"), session.mediaSourceId }, - { QStringLiteral("PlaySessionId"), session.playSessionId }, - { QStringLiteral("PositionTicks"), positionTicks }, - { QStringLiteral("Failed"), failed }, - { QStringLiteral("PlaybackRate"), playbackRate }, - }; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Playing/Stopped"), QJsonDocument(body)); -} - -QNetworkRequest JellyfinApiFacade::createRequest(const QString& path, const QUrlQuery& query) const -{ - QNetworkRequest request - = query.isEmpty() ? m_requestFactory.createRequest(path) : m_requestFactory.createRequest(path, query); - if (!HttpRequestPolicy::allowsCredentialTransport(request.url())) - throw std::runtime_error("Credentials require HTTPS or a numeric private/loopback HTTP address"); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - request.setRawHeader("Authorization", authorizationHeader().toUtf8()); - if (path == QStringLiteral("/Playback/BitrateTest") || path == QStringLiteral("/System/Endpoint")) { - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); - request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); - if (path == QStringLiteral("/Playback/BitrateTest")) - request.setRawHeader("Accept", "application/octet-stream"); - } - return request; -} - -QString JellyfinApiFacade::authorizationHeader(const QString& tokenOverride) const -{ - QStringList parts { - QStringLiteral("Client=\"Spool for Jellyfin\""), - QStringLiteral("Device=\"%1\"").arg(m_deviceName), - QStringLiteral("DeviceId=\"%1\"").arg(m_deviceId), - QStringLiteral("Version=\"%1\"").arg(m_clientVersion), - }; - - const QString token = tokenOverride.isEmpty() ? m_session.accessToken : tokenOverride; - if (!token.isEmpty()) - parts.push_back(QStringLiteral("Token=\"%1\"").arg(token)); - return QStringLiteral("MediaBrowser %1").arg(parts.join(QStringLiteral(", "))); -} - -void JellyfinApiFacade::applyCommonHeaders() -{ - QHttpHeaders headers; - headers.append(QHttpHeaders::WellKnownHeader::Accept, QStringLiteral("application/json")); - if (!m_acceptLanguage.isEmpty()) - headers.append(QHttpHeaders::WellKnownHeader::AcceptLanguage, m_acceptLanguage); - m_requestFactory.setCommonHeaders(headers); -} - -QCoro::Task JellyfinApiFacade::requestJson( - HttpMethod method, QString path, QUrlQuery query, QJsonDocument body) -{ - const QByteArray payload = co_await requestBytes(method, path, query, body); - if (payload.isEmpty()) - co_return QJsonDocument(); - - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); - if (parseError.error != QJsonParseError::NoError) - throw std::runtime_error(parseError.errorString().toStdString()); - co_return document; -} - -QCoro::Task JellyfinApiFacade::requestNoContent(HttpMethod method, QString path, QJsonDocument body) -{ - co_await requestBytes(method, path, {}, body); -} - -QCoro::Task JellyfinApiFacade::requestBytes( - HttpMethod method, QString path, QUrlQuery query, QJsonDocument body) -{ - const QString methodName = method == HttpMethod::Get ? QStringLiteral("GET") - : method == HttpMethod::Post ? QStringLiteral("POST") - : QStringLiteral("DELETE"); - const HttpOperation operation = operationFor(method, path); - const int maximumAttempts = HttpRequestPolicy::maximumAttempts(operation); - - for (int attempt = 1; attempt <= maximumAttempts; ++attempt) { - if (m_shuttingDown) - throw std::runtime_error("Request canceled during shutdown"); - - QNetworkRequest request = createRequest(path, query); - if (method == HttpMethod::Post && body.isNull()) - request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); - Diagnostics::NetworkRequest diagnosticsRequest(methodName, request.url().toString(QUrl::FullyEncoded)); - QNetworkReply *reply = nullptr; - - if (isQuickConnectPath(path)) - qInfo() << "api: quick connect request" << methodName; - - switch (method) { - case HttpMethod::Get: - reply = m_rest.get(request); - break; - case HttpMethod::Post: - reply = body.isNull() ? m_rest.post(request, QByteArray {}) : m_rest.post(request, body); - break; - case HttpMethod::Delete: - reply = m_rest.deleteResource(request); - break; - } - - m_activeReplies.insert(reply); - reply = co_await reply; - m_activeReplies.remove(reply); - const QByteArray payload = reply && reply->isReadable() ? reply->readAll() : QByteArray {}; - const QString errorText = reply ? reply->errorString() : QStringLiteral("Network reply disappeared"); - const int statusCode = reply ? reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() : 500; - const auto networkError = reply ? reply->error() : QNetworkReply::UnknownNetworkError; - if (reply) - reply->deleteLater(); - diagnosticsRequest.finish(statusCode, networkError == QNetworkReply::NoError ? QString() : errorText); - - if (networkError == QNetworkReply::NoError && statusCode < 300) - co_return payload; - - QString details = payload.isEmpty() ? errorText : QString::fromUtf8(payload).trimmed(); - if (path == QStringLiteral("/Users/AuthenticateByName")) { - const QJsonDocument errorDocument = QJsonDocument::fromJson(payload); - if (errorDocument.isObject()) { - const QJsonObject object = errorDocument.object(); - details = object.value(QStringLiteral("detail")).toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("Message")).toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("message")).toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("ResponseStatus")) - .toObject() - .value(QStringLiteral("Message")) - .toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("title")).toString(); - } - const bool generic = details.isEmpty() || details == errorText - || details.startsWith(QStringLiteral("Error processing request"), Qt::CaseInsensitive) - || details.compare(QStringLiteral("Unauthorized"), Qt::CaseInsensitive) == 0 - || details.startsWith(QLatin1Char('<')); - if (generic) { - if (statusCode == 401) - details = QStringLiteral("Incorrect username or password."); - else if (statusCode == 403) - details - = QStringLiteral("Sign-in is not allowed for this account. Contact your server administrator."); - else - details = errorText; - } - } - if (statusCode == 401 && shouldExpireSession(path) && !m_authExpirationReported) { - m_authExpirationReported = true; - emit authenticationExpired(QStringLiteral("Your Jellyfin session has expired. Sign in again.")); - } - - if (!HttpRequestPolicy::shouldRetry(operation, attempt, statusCode, networkError)) { - if (isQuickConnectPath(path)) - qWarning() << "api: quick connect request failed" << statusCode; - throw std::runtime_error(QStringLiteral("%1 (%2)").arg(details).arg(statusCode).toStdString()); - } - - const int delayMs = HttpRequestPolicy::retryDelayMs(attempt); - qWarning() << "api: request attempt" << attempt << "failed with" << statusCode << "- retrying in" << delayMs - << "ms"; - co_await QCoro::sleepFor(std::chrono::milliseconds(delayMs)); - } - - throw std::runtime_error("HTTP retry policy exhausted"); -} - -HttpOperation JellyfinApiFacade::operationFor(HttpMethod method, const QString& path) const -{ - if (path.startsWith(QStringLiteral("/Sessions/Playing"))) - return HttpOperation::PlaybackReport; - return method == HttpMethod::Get ? HttpOperation::Read : HttpOperation::Mutation; -} - -bool JellyfinApiFacade::shouldExpireSession(const QString& path) const -{ - return !m_session.accessToken.isEmpty() && !isQuickConnectPath(path) - && !path.startsWith(QStringLiteral("/Users/Authenticate")); -} - -QJsonObject JellyfinApiFacade::buildDeviceProfile() const -{ - return PlaybackNegotiation::buildDeviceProfile( - m_maxStreamingBitrate, maxStreamingHeight(), m_videoCodecs, m_restrictVideoCodecs); -} - -PlaybackSession JellyfinApiFacade::buildPlaybackSession( - const MovieItem& movie, const QJsonObject& playbackResponse) const -{ - const QJsonArray mediaSources = playbackResponse.value(QStringLiteral("MediaSources")).toArray(); - if (mediaSources.isEmpty()) - throw std::runtime_error("No media sources returned by Jellyfin"); - - const PlaybackSelection selection = PlaybackNegotiation::selectSource(mediaSources, m_preferRemux); - const QJsonObject selectedSource = selection.source; - - const QString mediaSourceId = requireString(selectedSource, QStringLiteral("Id")); - const QString container = cleanContainerName(selectedSource.value(QStringLiteral("Container")).toString()); - - TrickplayInfo trickplay = PlaybackNegotiation::selectTrickplay( - playbackResponse.value(QStringLiteral("Trickplay")).toObject(), mediaSourceId, 320); - if (trickplay.width <= 0) { - trickplay = PlaybackNegotiation::selectTrickplay( - selectedSource.value(QStringLiteral("Trickplay")).toObject(), mediaSourceId, 320); - } - - return { - movie.id, - movie.title, - movie.itemType, - PlaybackNegotiation::buildUrl(m_serverUrl, movie.id, selection), - mediaSourceId, - playbackResponse.value(QStringLiteral("PlaySessionId")).toString(), - selection.playMethod, - container, - movie.resumeTicks, - movie.runtimeTicks, - mediaStreamsFromApiJson(selectedSource.value(QStringLiteral("MediaStreams")).toArray()), - {}, - trickplay, - {}, - }; -} - -} // namespace JellyfinNative diff --git a/src/api/JellyfinApiFacade.h b/src/api/JellyfinApiFacade.h deleted file mode 100644 index 004b8c0f..00000000 --- a/src/api/JellyfinApiFacade.h +++ /dev/null @@ -1,242 +0,0 @@ -#pragma once - -#include "../common/JellyfinTypes.h" -#include "HttpRequestPolicy.h" -#include "PlaybackBandwidthPolicy.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace JellyfinNative { - -class TlsTrustController; -class JellyfinApiFacade final : public QObject { - Q_OBJECT - -public: - explicit JellyfinApiFacade( - QNetworkAccessManager *networkAccessManager, TlsTrustController *tlsTrust, QObject *parent = nullptr); - ~JellyfinApiFacade() override; - - void setServerUrl(const QString& serverUrl); - QString serverUrl() const; - - void setDeviceIdentity(const QString& deviceId, const QString& deviceName, const QString& clientVersion); - void setDeviceId(const QString& deviceId); - // Some platforms only learn their user-visible name asynchronously. The - // name rides the authorization header of every request, so the server - // picks a later one up on its own. - void setDeviceName(const QString& deviceName); - QString deviceId() const; - - // Forwarded into the QNetworkRequestFactory common headers so every API - // call hints the server about our locale. Jellyfin uses this to localise - // server-returned strings (Continue Watching titles, etc.). - void setAcceptLanguage(const QString& bcp47Tag); - - void setSession(const AuthSession& session); - AuthSession session() const; - void setPlaybackPreferences( - qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight = 0); - void setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs); - void setRemoteControlTargetEnabled(bool enabled); - bool remoteControlTargetEnabled() const - { - return m_remoteControlTargetEnabled; - } - int playbackParallelRequests() const; - QCoro::Task refreshPlaybackNetworkState(); - - // Measuring pulls several megabytes from the server, so it must never run - // beside a stream. Playback that starts before a measurement lands keeps - // whatever ceiling is known, which is a remembered one where the route has - // been seen before and a conservative estimate otherwise. - void setPlaybackActive(bool active); - qint64 maxStreamingBitrate() const; - qint64 measuredStreamingBitrate() const; - PlaybackBandwidthPolicy::Source streamingBitrateSource() const; - - // A ceiling chosen from the player overlay. It outranks every automatic - // input and the Settings limit, and it lasts only as long as this session: - // a quality picked because the train Wi-Fi is bad should not still be - // capping playback at home tomorrow. Zero restores automatic selection. - void setSessionBitrateOverride(qint64 bitrate); - qint64 sessionBitrateOverride() const; - - // Resolution is settled separately from bitrate: a viewer sparing a slow - // decoder wants a smaller picture at the bitrate they already have, and a - // viewer on a metered connection wants fewer bits at the size they already - // have. The session override outranks the Settings ceiling and lasts only - // as long as this session; zero means no ceiling from that source. - void setSessionHeightOverride(int height); - int sessionHeightOverride() const; - int maxStreamingHeight() const; - QString authorizationHeader(const QString& tokenOverride = {}) const; - void cancelRequests(); - - QCoro::Task authenticateByName(QString username, QString password); - QCoro::Task quickConnectEnabled(); - QCoro::Task initiateQuickConnect(); - QCoro::Task pollQuickConnect(QString secret); - QCoro::Task authenticateWithQuickConnect(QString secret); - QCoro::Task fetchCurrentUserName(); - QCoro::Task fetchUserConfiguration(); - QCoro::Task updateUserConfiguration(QJsonObject configuration); - QCoro::Task fetchCurrentUserPolicy(); - QCoro::Task fetchCultures(); - QCoro::Task> fetchLibraries(); - QCoro::Task fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}); - QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}); - QCoro::Task fetchItemDetails(QString itemId); - QCoro::Task> fetchItemsByIds(QStringList itemIds); - QCoro::Task> fetchSeasons(QString seriesId); - QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}); - QCoro::Task> fetchResumeItems(int limit = 24); - QCoro::Task> fetchNextUpEpisodes(int limit = 24); - QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24); - QCoro::Task> searchItems(QString searchTerm, int limit = 80); - QCoro::Task> fetchSearchSuggestions(int limit = 20); - QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24); - QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000); - QCoro::Task> fetchManagementTargets(QString itemType); - QCoro::Task createPlaylist(QString name, QStringList itemIds = {}); - QCoro::Task addPlaylistItems(QString playlistId, QStringList itemIds, int position = -1); - QCoro::Task removePlaylistItems(QString playlistId, QStringList entryIds); - QCoro::Task movePlaylistItem(QString playlistId, QString playlistItemId, int newIndex); - QCoro::Task updatePlaylistName(QString playlistId, QString name); - QCoro::Task createCollection(QString name, QStringList itemIds = {}); - QCoro::Task addCollectionItems(QString collectionId, QStringList itemIds); - QCoro::Task removeCollectionItems(QString collectionId, QStringList itemIds); - QCoro::Task renameItem(QString itemId, QString name); - QCoro::Task deleteItem(QString itemId); - QCoro::Task setItemFavorite(QString itemId, bool favorite); - QCoro::Task setItemPlayed(QString itemId, bool played); - QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks); - QCoro::Task> fetchMediaSegments(QString itemId); - QCoro::Task fetchTrickplayInfo(QString itemId, QString mediaSourceId = {}); - QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const; - QCoro::Task negotiatePlayback(MovieItem movie, bool forceTranscode = false); - - QCoro::Task fetchControllableSessions(); - QCoro::Task sendRemotePlay(QString sessionId, QStringList itemIds, QString playCommand, - qint64 startPositionTicks = -1, int startIndex = -1, QString mediaSourceId = {}, int audioStreamIndex = -2, - int subtitleStreamIndex = -2); - QCoro::Task sendRemotePlaystate(QString sessionId, QString command, qint64 seekPositionTicks = -1); - QCoro::Task sendRemoteGeneralCommand(QString sessionId, QString command, QJsonObject arguments = {}); - - // SyncPlay REST endpoints used alongside SyncPlayController's WebSocket. - QCoro::Task fetchSyncPlayGroups(); - QCoro::Task createSyncPlayGroup(QString name); - QCoro::Task joinSyncPlayGroup(QString groupId); - QCoro::Task leaveSyncPlayGroup(); - QCoro::Task fetchUtcTime(); - QCoro::Task syncPlayReportPing(qint64 pingMs); - QCoro::Task syncPlayReportBuffering( - bool buffering, qint64 positionTicks, bool playing, QString playlistItemId, QDateTime serverTime); - QCoro::Task syncPlaySetNewQueue(QStringList itemIds, int playingItemPosition, qint64 startPositionTicks); - QCoro::Task syncPlayPause(); - QCoro::Task syncPlayUnpause(); - QCoro::Task syncPlaySeek(qint64 positionTicks); - QCoro::Task syncPlayNextItem(QString playlistItemId); - QCoro::Task syncPlayPreviousItem(QString playlistItemId); - // Group queue authoring. The queue is server state inside a group, so these - // replace the local edits rather than accompanying them. - QCoro::Task syncPlayQueue(QStringList itemIds, bool queueNext); - QCoro::Task syncPlayMovePlaylistItem(QString playlistItemId, int newIndex); - QCoro::Task syncPlayRemoveFromPlaylist(QStringList playlistItemIds); - QCoro::Task syncPlaySetPlaylistItem(QString playlistItemId); - - QCoro::Task postCapabilities(); - QCoro::Task reportPlaybackStart(PlaybackSession session, double playbackRate, int volume, bool muted); - QCoro::Task reportPlaybackProgress( - PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted); - QCoro::Task reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate = 1.0); - -signals: - void authenticationExpired(const QString& message); - void deviceProfileChanged(); - void playbackNetworkProfileChanged(); - void streamingBitrateChanged(); - void sessionTokenChanged(); - -private: - enum class HttpMethod { - Get, - Post, - Delete, - }; - - QNetworkRequest createRequest(const QString& path, const QUrlQuery& query = {}) const; - QCoro::Task measurePlaybackBitrate(int totalSampleBytes, int parallelRequests); - QCoro::Task measurePlaybackRoundTripTime(); - QCoro::Task requestJson( - HttpMethod method, QString path, QUrlQuery query = {}, QJsonDocument body = {}); - QCoro::Task requestNoContent(HttpMethod method, QString path, QJsonDocument body); - QCoro::Task requestBytes( - HttpMethod method, QString path, QUrlQuery query = {}, QJsonDocument body = {}); - - QJsonObject buildDeviceProfile() const; - PlaybackSession buildPlaybackSession(const MovieItem& movie, const QJsonObject& playbackResponse) const; - HttpOperation operationFor(HttpMethod method, const QString& path) const; - bool shouldExpireSession(const QString& path) const; - void preconnectToServer(); - void applyCommonHeaders(); - void updateEffectiveStreamingBitrate(); - void setPlaybackParallelRequests(int parallelRequests); - QString currentNetworkSignature() const; - void restoreRememberedMeasurement(); - void rememberMeasurement(); - void handleNetworkRouteChanged(); - - QNetworkAccessManager *m_networkAccessManager = nullptr; - QRestAccessManager m_rest; - QNetworkRequestFactory m_requestFactory; - QString m_serverUrl; - QString m_deviceId; - QString m_deviceName = QStringLiteral("LG webOS TV"); - QString m_clientVersion = QStringLiteral("0.1.0"); - AuthSession m_session; - QSet m_activeReplies; - QString m_preconnectedAuthority; - QString m_acceptLanguage; - qint64 m_maxStreamingBitrate = 20'000'000; - qint64 m_manualMaxStreamingBitrate = 0; - qint64 m_sessionBitrateOverride = 0; - int m_maxStreamingHeight = 0; - int m_sessionHeightOverride = 0; - qint64 m_measuredStreamingBitrate = 0; - QString m_measuredNetworkSignature; - int m_playbackParallelRequests = 1; - quint64 m_playbackNetworkGeneration = 0; - bool m_playbackEndpointKnown = false; - bool m_inLocalNetwork = false; - PlaybackBandwidthPolicy::Source m_streamingBitrateSource = PlaybackBandwidthPolicy::Source::Estimate; - bool m_measurementRemembered = false; - bool m_playbackActive = false; - bool m_measurementDeferred = false; - bool m_unlimitedLocalNetwork = false; - QStringList m_videoCodecs; - bool m_restrictVideoCodecs = false; - bool m_remoteControlTargetEnabled = true; - bool m_preferRemux = true; - bool m_authExpirationReported = false; - bool m_shuttingDown = false; -}; - -} // namespace JellyfinNative diff --git a/src/api/PlaybackBandwidthPolicy.cpp b/src/api/PlaybackBandwidthPolicy.cpp deleted file mode 100644 index 1b5b65df..00000000 --- a/src/api/PlaybackBandwidthPolicy.cpp +++ /dev/null @@ -1,212 +0,0 @@ -#include "PlaybackBandwidthPolicy.h" - -#include "common/NetworkAddress.h" - -#include -#include - -#include -#include - -namespace JellyfinNative { - -PlaybackBandwidthPolicy::Source PlaybackBandwidthPolicy::effectiveBitrateSource( - qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, bool inLocalNetwork, qint64 measuredBitrate) -{ - if (unlimitedLocalNetwork && endpointKnown && inLocalNetwork) - return Source::UnlimitedLocal; - if (manualBitrate > 0) - return Source::Manual; - if (measuredBitrate > 0) - return Source::Measured; - if (endpointKnown && inLocalNetwork) - return Source::LocalEstimate; - return Source::Estimate; -} - -bool PlaybackBandwidthPolicy::isEstimate(Source source) -{ - return source == Source::LocalEstimate || source == Source::Estimate; -} - -qint64 PlaybackBandwidthPolicy::effectiveBitrate( - qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, bool inLocalNetwork, qint64 measuredBitrate) -{ - if (unlimitedLocalNetwork && endpointKnown && inLocalNetwork) - return MaximumBitrate; - if (manualBitrate > 0) - return std::clamp(manualBitrate, MinimumBitrate, MaximumBitrate); - if (measuredBitrate > 0) - return std::clamp(measuredBitrate, MinimumBitrate, MaximumBitrate); - // A local route is not evidence of a fast one. Until something is - // measured, assume a plausible wireless link rather than gigabit ethernet. - if (endpointKnown && inLocalNetwork) - return LocalNetworkFallbackBitrate; - return AutoFallbackBitrate; -} - -qint64 PlaybackBandwidthPolicy::conservativeEstimate(qint64 transferredBytes, qint64 elapsedMilliseconds) -{ - if (transferredBytes <= 0 || elapsedMilliseconds <= 0) - return 0; - const long double measuredBitsPerSecond - = static_cast(transferredBytes) * 8'000.0L / static_cast(elapsedMilliseconds); - const long double conservative = measuredBitsPerSecond * 0.75L; - return std::clamp(static_cast(std::llround(conservative)), MinimumBitrate, MaximumBitrate); -} - -bool PlaybackBandwidthPolicy::shouldBenchmarkFourRequests( - qint64 roundTripMilliseconds, qint64 singleRequestBitrate, qint64 dualRequestBitrate) -{ - if (singleRequestBitrate <= 0 || dualRequestBitrate <= 0) - return false; - - constexpr qint64 highLatencyMilliseconds = 20; - constexpr long double worthwhileDualGain = 1.10L; - return roundTripMilliseconds >= highLatencyMilliseconds - || static_cast(dualRequestBitrate) - >= static_cast(singleRequestBitrate) * worthwhileDualGain; -} - -int PlaybackBandwidthPolicy::selectParallelRequests( - qint64 singleRequestBitrate, qint64 dualRequestBitrate, qint64 fourRequestBitrate) -{ - const qint64 best = std::max({ singleRequestBitrate, dualRequestBitrate, fourRequestBitrate }); - if (best <= 0) - return 1; - - constexpr long double closeEnoughToBest = 0.85L; - const auto isCloseEnough = [best](qint64 bitrate) { - return bitrate > 0 && static_cast(bitrate) >= static_cast(best) * closeEnoughToBest; - }; - if (isCloseEnough(singleRequestBitrate)) - return 1; - if (isCloseEnough(dualRequestBitrate)) - return 2; - return fourRequestBitrate > 0 ? 4 : 2; -} - -QString PlaybackBandwidthPolicy::networkSignature( - const QString& serverAuthority, const QStringList& localAddresses, const QString& transportMedium) -{ - // Only the network part of each private address takes part: a DHCP lease - // that hands out a different host on the same network is the same route, - // and a public address would put the user's own address in the key. - QStringList networks; - for (const QString& candidate : localAddresses) { - QHostAddress address; - if (!address.setAddress(candidate.trimmed()) || address.isLoopback()) - continue; - if (!isPrivateNetworkAddress(address)) - continue; - if (address.protocol() == QAbstractSocket::IPv4Protocol) { - const quint32 value = address.toIPv4Address() & 0xffffff00u; - networks.push_back(QHostAddress(value).toString()); - } else if (address.protocol() == QAbstractSocket::IPv6Protocol) { - networks.push_back(address.toString().section(QLatin1Char(':'), 0, 3)); - } - } - networks.removeDuplicates(); - networks.sort(); - - const QString material = serverAuthority.trimmed().toLower() + QLatin1Char('|') - + transportMedium.trimmed().toLower() + QLatin1Char('|') + networks.join(QLatin1Char(',')); - return QString::fromLatin1( - QCryptographicHash::hash(material.toUtf8(), QCryptographicHash::Sha256).toHex().left(32)); -} - -QString PlaybackBandwidthPolicy::formatBitrate(qint64 bitsPerSecond) -{ - if (bitsPerSecond <= 0) - return QStringLiteral("unknown"); - const double mbps = static_cast(bitsPerSecond) / 1'000'000.0; - if (mbps >= 10.0) - return QString::number(std::llround(mbps)) + QStringLiteral(" Mbps"); - if (mbps >= 1.0) - return QString::number(mbps, 'f', 1) + QStringLiteral(" Mbps"); - return QString::number(std::llround(mbps * 1000.0)) + QStringLiteral(" kbps"); -} - -QList PlaybackBandwidthPolicy::resolutionLadder() -{ - return { 2160, 1080, 720, 480, 360 }; -} - -QString PlaybackBandwidthPolicy::describeResolution(int height) -{ - if (height <= 0) - return QStringLiteral("Source"); - // 2160p is universally sold as 4K, and nothing else in the ladder is. - if (height >= 2160) - return QStringLiteral("4K"); - return QString::number(height) + QLatin1Char('p'); -} - -QList PlaybackBandwidthPolicy::qualityLadder(qint64 sourceBitrate) -{ - struct Rung { - qint64 bitrate; - int height; - }; - // The rungs jellyfin-web offers, so a viewer who knows one client is not - // surprised by the other. The height is carried rather than spelled, so - // the rung the viewer picked is the rung the server is asked for. - static constexpr Rung rungs[] = { - { 120'000'000, 2160 }, - { 80'000'000, 2160 }, - { 60'000'000, 1080 }, - { 40'000'000, 1080 }, - { 20'000'000, 1080 }, - { 15'000'000, 1080 }, - { 10'000'000, 720 }, - { 8'000'000, 720 }, - { 6'000'000, 720 }, - { 4'000'000, 480 }, - { 3'000'000, 480 }, - { 2'000'000, 480 }, - { 1'000'000, 360 }, - }; - - QList options; - options.reserve(std::size(rungs)); - for (const Rung& rung : rungs) { - if (sourceBitrate > 0 && rung.bitrate >= sourceBitrate) - continue; - options.push_back(QualityOption { rung.bitrate, rung.height, - describeResolution(rung.height) + QStringLiteral(" · ") + formatBitrate(rung.bitrate) }); - } - return options; -} - -QString PlaybackBandwidthPolicy::describeAuto(Source source, qint64 effectiveBitrate, int parallelRequests) -{ - const QString ceiling = formatBitrate(effectiveBitrate); - const QString lanes = parallelRequests > 1 ? QStringLiteral(" · %1 connections").arg(parallelRequests) : QString(); - switch (source) { - case Source::UnlimitedLocal: - return QStringLiteral("No limit on this network"); - case Source::Manual: - return QStringLiteral("Limited to %1 in Settings").arg(ceiling); - case Source::Measured: - return QStringLiteral("Measured %1").arg(ceiling) + lanes; - case Source::Remembered: - return QStringLiteral("%1 remembered for this network").arg(ceiling) + lanes; - case Source::LocalEstimate: - return QStringLiteral("Estimated %1 · local network, not measured yet").arg(ceiling); - case Source::Estimate: - break; - } - return QStringLiteral("Estimated %1 · not measured yet").arg(ceiling); -} - -bool PlaybackBandwidthPolicy::isRememberedMeasurementUsable(qint64 recordedAtMsSinceEpoch, qint64 nowMsSinceEpoch) -{ - if (recordedAtMsSinceEpoch <= 0) - return false; - // A record from the future means the clock moved; treat it as unusable - // rather than trusting it forever. - const qint64 age = nowMsSinceEpoch - recordedAtMsSinceEpoch; - return age >= 0 && age <= RememberedLifetimeMs; -} - -} // namespace JellyfinNative diff --git a/src/api/PlaybackBandwidthPolicy.h b/src/api/PlaybackBandwidthPolicy.h deleted file mode 100644 index ce1a47f9..00000000 --- a/src/api/PlaybackBandwidthPolicy.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace JellyfinNative { - -class PlaybackBandwidthPolicy final { -public: - // Where the ceiling handed to the server came from. The player surfaces - // this so "Auto" can say whether it is a measurement or a guess. - // effectiveBitrateSource() cannot tell a fresh measurement from a - // remembered one; the caller that supplied the value substitutes - // Remembered when it came from the cache. - enum class Source { - UnlimitedLocal, - Manual, - Measured, - Remembered, - LocalEstimate, - Estimate, - }; - - static constexpr qint64 AutoFallbackBitrate = 20'000'000; - // A LAN is not automatically gigabit ethernet. This is the ceiling used on - // a local network before a measurement lands: high enough that ordinary - // 1080p and most 4K HEVC still direct play, low enough that a 2.4 GHz - // link is not immediately asked for a 80 Mbps remux. - static constexpr qint64 LocalNetworkFallbackBitrate = 40'000'000; - static constexpr qint64 MaximumBitrate = 1'000'000'000; - static constexpr qint64 MinimumBitrate = 1'000'000; - // A remembered measurement describes a network, not a moment. Keep it long - // enough to cover a normal viewing routine and short enough that a - // rearranged home network re-measures on its own. - static constexpr qint64 RememberedLifetimeMs = 7LL * 24 * 60 * 60 * 1000; - - static qint64 effectiveBitrate(qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, - bool inLocalNetwork, qint64 measuredBitrate); - static Source effectiveBitrateSource(qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, - bool inLocalNetwork, qint64 measuredBitrate); - static bool isEstimate(Source source); - - static qint64 conservativeEstimate(qint64 transferredBytes, qint64 elapsedMilliseconds); - static bool shouldBenchmarkFourRequests( - qint64 roundTripMilliseconds, qint64 singleRequestBitrate, qint64 dualRequestBitrate); - static int selectParallelRequests( - qint64 singleRequestBitrate, qint64 dualRequestBitrate, qint64 fourRequestBitrate = 0); - - // Identifies the route to one server well enough to reuse a measurement - // across launches, and to notice that the route changed. Local interface - // subnets move with the network; the transport tells Wi-Fi from cellular - // when two networks happen to hand out the same private range. - static QString networkSignature( - const QString& serverAuthority, const QStringList& localAddresses, const QString& transportMedium); - static bool isRememberedMeasurementUsable(qint64 recordedAtMsSinceEpoch, qint64 nowMsSinceEpoch); - - struct QualityOption { - qint64 bitrate = 0; - // The vertical resolution the rung promises. A bitrate ceiling alone - // makes the server re-encode at the source resolution, so the label - // said 480p while the picture stayed 1080p; this is what makes the - // promise reach the device profile. - int height = 0; - QString label; - }; - - // Vertical resolutions offered as a standalone ceiling, coarsest first. - // Nothing here is a bitrate: a viewer capping resolution to spare a panel - // or a decoder should not have to reason about megabits. - static QList resolutionLadder(); - static QString describeResolution(int height); - - static QString formatBitrate(qint64 bitsPerSecond); - // The ladder a viewer can pick from, coarsest first. Rungs at or above the - // source bitrate are dropped: they would transcode the stream without - // lowering anything, which is the worst of both. - static QList qualityLadder(qint64 sourceBitrate); - // The line under "Auto". jellyfin-web says only "Auto"; we know whether - // the ceiling was measured, remembered for this network, or guessed. - static QString describeAuto(Source source, qint64 effectiveBitrate, int parallelRequests); -}; - -} // namespace JellyfinNative diff --git a/src/api/PlaybackNegotiation.cpp b/src/api/PlaybackNegotiation.cpp deleted file mode 100644 index 50b4d862..00000000 --- a/src/api/PlaybackNegotiation.cpp +++ /dev/null @@ -1,285 +0,0 @@ -#include "PlaybackNegotiation.h" - -#include -#include - -#include -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr auto kDirectPlay = "DirectPlay"; - constexpr auto kDirectStream = "DirectStream"; - constexpr auto kTranscode = "Transcode"; - const QStringList kHlsVideoCodecPreference { QStringLiteral("hevc"), QStringLiteral("h264"), QStringLiteral("av1"), - QStringLiteral("vp9") }; - - qint64 sourceBitrate(const QJsonObject& source) - { - qint64 bitrate = source.value(QStringLiteral("Bitrate")).toInteger(); - if (bitrate <= 0) - bitrate = source.value(QStringLiteral("BitRate")).toInteger(); - return bitrate; - } - - int methodRank(const QJsonObject& source, bool preferRemux, QString *method) - { - if (source.value(QStringLiteral("SupportsDirectPlay")).toBool()) { - *method = QString::fromLatin1(kDirectPlay); - return 3; - } - - const bool directStream = source.value(QStringLiteral("SupportsDirectStream")).toBool(); - const bool transcode = !source.value(QStringLiteral("TranscodingUrl")).toString().isEmpty(); - if (preferRemux && directStream) { - *method = QString::fromLatin1(kDirectStream); - return 2; - } - if (transcode) { - *method = QString::fromLatin1(kTranscode); - return 2; - } - if (directStream) { - *method = QString::fromLatin1(kDirectStream); - return 1; - } - return -1; - } - - QUrl serverRelativeUrl(const QString& serverUrl, const QString& path) - { - const QUrl candidate(path); - if (!candidate.scheme().isEmpty() || !candidate.host().isEmpty()) - return candidate; - - QUrl result(serverUrl); - QString basePath = result.path(); - while (basePath.endsWith(QLatin1Char('/'))) - basePath.chop(1); - - QString relativePath = candidate.path(); - if (!relativePath.startsWith(QLatin1Char('/'))) - relativePath.prepend(QLatin1Char('/')); - if (!basePath.isEmpty() && basePath != QStringLiteral("/") - && !relativePath.startsWith(basePath + QLatin1Char('/'))) { - relativePath.prepend(basePath); - } - result.setPath(relativePath); - result.setQuery(candidate.query()); - result.setFragment(candidate.fragment()); - return result; - } - QUrl videoStreamUrl(const QString& serverUrl, const QString& itemId, const QString& mediaSourceId, bool isStatic) - { - QUrl url(serverUrl); - QString path = url.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - url.setPath(QStringLiteral("%1/Videos/%2/stream").arg(path, itemId)); - QUrlQuery query; - query.addQueryItem(QStringLiteral("static"), isStatic ? QStringLiteral("true") : QStringLiteral("false")); - query.addQueryItem(QStringLiteral("MediaSourceId"), mediaSourceId); - url.setQuery(query); - return url; - } - - QStringList normalizedCodecs(const QStringList& codecs) - { - QStringList result; - result.reserve(codecs.size()); - for (const QString& codec : codecs) { - const QString normalized = codec.trimmed().toLower(); - if (!normalized.isEmpty() && !result.contains(normalized)) - result.push_back(normalized); - } - return result; - } - - QStringList transcodeVideoCodecs(const QStringList& directVideoCodecs, bool restrictVideoCodecs) - { - if (!restrictVideoCodecs) - return kHlsVideoCodecPreference; - - const QStringList supported = normalizedCodecs(directVideoCodecs); - QStringList result; - for (const QString& preferred : kHlsVideoCodecPreference) { - if (supported.contains(preferred)) - result.push_back(preferred); - } - return result; - } - - QJsonArray localSubtitleProfiles() - { - const QStringList formats { QStringLiteral("srt"), QStringLiteral("ass"), QStringLiteral("ssa"), - QStringLiteral("vtt"), QStringLiteral("pgssub"), QStringLiteral("dvdsub") }; - QJsonArray profiles; - for (const QString& format : formats) { - profiles.push_back(QJsonObject { - { QStringLiteral("Format"), format }, - { QStringLiteral("Method"), QStringLiteral("Embed") }, - }); - profiles.push_back(QJsonObject { - { QStringLiteral("Format"), format }, - { QStringLiteral("Method"), QStringLiteral("External") }, - }); - } - return profiles; - } - -} // namespace - -PlaybackSelection PlaybackNegotiation::selectSource(const QJsonArray& mediaSources, bool preferRemux) -{ - PlaybackSelection selected; - int bestRank = -1; - qint64 bestBitrate = -1; - - for (const QJsonValue& value : mediaSources) { - const QJsonObject source = value.toObject(); - QString method; - const int rank = methodRank(source, preferRemux, &method); - const qint64 bitrate = sourceBitrate(source); - if (rank > bestRank || (rank == bestRank && bitrate > bestBitrate)) { - selected = { source, method }; - bestRank = rank; - bestBitrate = bitrate; - } - } - - if (bestRank < 0 || selected.source.isEmpty()) - throw std::runtime_error("No playable media source available"); - return selected; -} - -TrickplayInfo PlaybackNegotiation::selectTrickplay( - const QJsonObject& trickplay, const QString& mediaSourceId, int preferredWidth) -{ - QJsonObject widths; - const QJsonObject first - = trickplay.constBegin() == trickplay.constEnd() ? QJsonObject {} : trickplay.constBegin().value().toObject(); - const bool widthMap = first.contains(QStringLiteral("Width")) || first.contains(QStringLiteral("TileWidth")) - || first.contains(QStringLiteral("Interval")); - if (widthMap) { - widths = trickplay; - } else if (!mediaSourceId.isEmpty() && trickplay.contains(mediaSourceId)) { - widths = trickplay.value(mediaSourceId).toObject(); - } else if (!trickplay.isEmpty()) { - widths = first; - } - - TrickplayInfo best; - int bestDiff = std::numeric_limits::max(); - for (auto it = widths.constBegin(); it != widths.constEnd(); ++it) { - const QJsonObject info = it.value().toObject(); - const int width = info.value(QStringLiteral("Width")).toInt(); - if (width <= 0) - continue; - const int diff = std::abs(width - preferredWidth); - if (diff >= bestDiff) - continue; - bestDiff = diff; - best.width = width; - best.height = info.value(QStringLiteral("Height")).toInt(); - best.tileWidth = info.value(QStringLiteral("TileWidth")).toInt(); - best.tileHeight = info.value(QStringLiteral("TileHeight")).toInt(); - best.thumbnailCount = info.value(QStringLiteral("ThumbnailCount")).toInt(); - best.intervalMs = info.value(QStringLiteral("Interval")).toInt(); - best.bandwidth = info.value(QStringLiteral("Bandwidth")).toInt(); - } - return best; -} - -QString PlaybackNegotiation::buildUrl( - const QString& serverUrl, const QString& itemId, const PlaybackSelection& selection) -{ - const QString mediaSourceId = selection.source.value(QStringLiteral("Id")).toString(); - if (mediaSourceId.isEmpty()) - throw std::runtime_error("Selected media source has no id"); - - QUrl url; - if (selection.playMethod == QString::fromLatin1(kDirectPlay)) { - url = videoStreamUrl(serverUrl, itemId, mediaSourceId, true); - } else { - const QString responseUrl = selection.playMethod == QString::fromLatin1(kTranscode) - ? selection.source.value(QStringLiteral("TranscodingUrl")).toString() - : selection.source.value(QStringLiteral("DirectStreamUrl")).toString(); - if (!responseUrl.isEmpty()) { - url = serverRelativeUrl(serverUrl, responseUrl); - } else { - url = videoStreamUrl(serverUrl, itemId, mediaSourceId, false); - } - } - - return url.toString(QUrl::FullyEncoded); -} - -QJsonObject PlaybackNegotiation::buildDeviceProfile( - qint64 maxStreamingBitrate, int maxHeight, const QStringList& videoCodecs, bool restrictVideoCodecs) -{ - const qint64 bitrate = std::clamp(maxStreamingBitrate, 1'000'000, 1'000'000'000); - QJsonArray codecProfiles; - if (maxHeight > 0) { - codecProfiles.push_back(QJsonObject { - { QStringLiteral("Type"), QStringLiteral("Video") }, - { QStringLiteral("Conditions"), - QJsonArray { QJsonObject { - { QStringLiteral("Condition"), QStringLiteral("LessThanEqual") }, - { QStringLiteral("Property"), QStringLiteral("Height") }, - { QStringLiteral("Value"), QString::number(maxHeight) }, - // Not required: a source already below the ceiling should - // direct play rather than be transcoded up to it. - { QStringLiteral("IsRequired"), false }, - } } }, - }); - } - QJsonArray directPlayProfiles; - const QStringList normalizedVideoCodecs = normalizedCodecs(videoCodecs); - if (!restrictVideoCodecs) { - directPlayProfiles.push_back(QJsonObject { { QStringLiteral("Type"), QStringLiteral("Video") } }); - } else if (!normalizedVideoCodecs.isEmpty()) { - directPlayProfiles.push_back(QJsonObject { - { QStringLiteral("Type"), QStringLiteral("Video") }, - { QStringLiteral("VideoCodec"), normalizedVideoCodecs.join(QLatin1Char(',')) }, - }); - } - // Audio is always decoded to PCM by mpv. Keep this unrestricted even on - // webOS so an unsupported compressed-output format never forces a video - // transcode merely because Jellyfin could not copy the audio stream. - directPlayProfiles.push_back(QJsonObject { { QStringLiteral("Type"), QStringLiteral("Audio") } }); - - QStringList outputVideoCodecs = transcodeVideoCodecs(normalizedVideoCodecs, restrictVideoCodecs); - if (outputVideoCodecs.isEmpty()) - outputVideoCodecs.push_back(QStringLiteral("h264")); - - return { - { QStringLiteral("Name"), QStringLiteral("JellyfinNative") }, - { QStringLiteral("MaxStreamingBitrate"), bitrate }, - { QStringLiteral("MaxStaticBitrate"), bitrate }, - { QStringLiteral("MusicStreamingTranscodingBitrate"), 1'280'000 }, - { QStringLiteral("DirectPlayProfiles"), directPlayProfiles }, - { QStringLiteral("TranscodingProfiles"), - QJsonArray { - QJsonObject { - { QStringLiteral("Type"), QStringLiteral("Video") }, - { QStringLiteral("Container"), QStringLiteral("mp4") }, - { QStringLiteral("Protocol"), QStringLiteral("hls") }, - { QStringLiteral("AudioCodec"), QStringLiteral("aac,ac3,eac3,mp3,flac,opus,dts,truehd") }, - { QStringLiteral("VideoCodec"), outputVideoCodecs.join(QLatin1Char(',')) }, - { QStringLiteral("Context"), QStringLiteral("Streaming") }, - { QStringLiteral("MaxAudioChannels"), QStringLiteral("6") }, - { QStringLiteral("MinSegments"), 2 }, - { QStringLiteral("BreakOnNonKeyFrames"), false }, - }, - } }, - { QStringLiteral("ContainerProfiles"), QJsonArray {} }, - { QStringLiteral("CodecProfiles"), codecProfiles }, - { QStringLiteral("SubtitleProfiles"), localSubtitleProfiles() }, - { QStringLiteral("ResponseProfiles"), QJsonArray {} }, - }; -} - -} // namespace JellyfinNative diff --git a/src/api/PlaybackNegotiation.h b/src/api/PlaybackNegotiation.h deleted file mode 100644 index c37a1c5f..00000000 --- a/src/api/PlaybackNegotiation.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include "../common/JellyfinTypes.h" - -#include -#include -#include -#include - -namespace JellyfinNative { - -struct PlaybackSelection { - QJsonObject source; - QString playMethod; -}; - -class PlaybackNegotiation final { -public: - static PlaybackSelection selectSource(const QJsonArray& mediaSources, bool preferRemux); - static QString buildUrl(const QString& serverUrl, const QString& itemId, const PlaybackSelection& selection); - static TrickplayInfo selectTrickplay( - const QJsonObject& trickplay, const QString& mediaSourceId, int preferredWidth); - // maxHeight caps the vertical resolution the server may send. It reaches - // Jellyfin as a codec-profile condition rather than a URL parameter, - // because the transcode URL is the server's to write: given the condition - // it scales the stream down and puts MaxHeight in the URL itself. A - // bitrate ceiling alone leaves the source resolution untouched, which is - // how picking 480p used to lower the bitrate and nothing else. Zero means - // no ceiling. - static QJsonObject buildDeviceProfile(qint64 maxStreamingBitrate, int maxHeight = 0, - const QStringList& videoCodecs = {}, bool restrictVideoCodecs = false); -}; - -} // namespace JellyfinNative diff --git a/src/app/AccountProfile.cpp b/src/app/AccountProfile.cpp deleted file mode 100644 index 5480d973..00000000 --- a/src/app/AccountProfile.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include "AccountProfile.h" - -#include -#include - -namespace JellyfinNative { - -QString AccountProfile::displayLabel() const -{ - if (userName.isEmpty()) - return serverName; - if (serverName.isEmpty()) - return userName; - return userName + QStringLiteral(" · ") + serverName; -} - -QString canonicalServerUrl(const QString& input) -{ - QUrl url = QUrl::fromUserInput(input.trimmed()); - if (!url.isValid() || url.host().isEmpty()) - return {}; - - url.setScheme(url.scheme().toLower()); - url.setHost(url.host().toLower()); - if ((url.scheme() == QStringLiteral("http") && url.port() == 80) - || (url.scheme() == QStringLiteral("https") && url.port() == 443)) { - url.setPort(-1); - } - QString path = url.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - url.setPath(path); - url.setQuery({}); - url.setFragment({}); - return url.adjusted(QUrl::NormalizePathSegments).toString(QUrl::FullyEncoded); -} - -QString accountProfileId(const QString& serverId, const QString& serverUrl, const QString& userId) -{ - const QString serverIdentity - = serverId.trimmed().isEmpty() ? canonicalServerUrl(serverUrl) : serverId.trimmed().toLower(); - const QByteArray identity = serverIdentity.toUtf8() + '\0' + userId.trimmed().toUtf8(); - return QString::fromLatin1(QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex().left(24)); -} - -} // namespace JellyfinNative diff --git a/src/app/AccountProfile.h b/src/app/AccountProfile.h deleted file mode 100644 index 94c81e45..00000000 --- a/src/app/AccountProfile.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include - -namespace JellyfinNative { - -struct AccountProfile { - QString profileId; - QString serverId; - QString serverName; - QString serverUrl; - QString userId; - QString userName; - QString accessToken; - QString avatarTag; - qint64 lastUsedAt = 0; - qint64 createdAt = 0; - bool needsAuthentication = false; - - QString displayLabel() const; -}; - -QString canonicalServerUrl(const QString& input); -QString accountProfileId(const QString& serverId, const QString& serverUrl, const QString& userId); - -} // namespace JellyfinNative diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 61ecd2df..88c7973c 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -2,34 +2,32 @@ #include "ArtworkService.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" -#include "../common/MetaJson.h" #include "../common/SeriesAudioSelection.h" #include "../diagnostics/Diagnostics.h" #include "../platform/PlatformPaths.h" #include "../player/PlayQueueController.h" #include "../player/PlaybackFailurePolicy.h" #include "../player/PlayerController.h" +#include "../provider/Catalog.h" +#include "../provider/GroupPlayback.h" +#include "../provider/PlaybackSource.h" +#include "../provider/SourceHub.h" +#include "../provider/StreamQualityControl.h" #include "BrowseSessionController.h" #include "ContentModelController.h" +#include "GroupPlaybackController.h" #include "HomeModelController.h" #include "LibraryPrefetchController.h" #include "LibraryQuery.h" -#include "QuickConnectController.h" #include "SearchController.h" -#include "SessionController.h" #include "SettingsController.h" -#include "SpoolLink.h" -#include "SpoolRemoteProtocol.h" +#include "SettingsSyncController.h" #include "UserItemStateController.h" #include -#include -#include -#include -#include #include +#include #include #include #include @@ -42,7 +40,7 @@ #include #endif -namespace JellyfinNative { +namespace Spool { namespace { @@ -53,12 +51,13 @@ namespace { // Reopening a library within this window shows the cached page as-is; // a server refresh so soon after the last one only causes delegate churn. constexpr qint64 kFreshLibraryCacheMs = 30000; - constexpr auto kSeriesTrackSelectionNamespace = "series-track-selection-v2"; + // v3: keyed by the catalog's scope rather than a Jellyfin server and user. + constexpr auto kSeriesTrackSelectionNamespace = "series-track-selection-v3"; constexpr auto kRememberSeriesAudioTrackKey = "playback/rememberSeriesAudioTrack"; - QString seriesTrackSelectionKey(const AuthSession& session, const QString& seriesId) + QString seriesTrackSelectionKey(const QString& libraryScopeKey, const QString& seriesId) { - return session.serverId + QLatin1Char('/') + session.userId + QLatin1Char('/') + seriesId; + return libraryScopeKey + QLatin1Char('/') + seriesId; } QByteArray encodeTrackSelection(const SeriesAudioPreference& audioPreference, int subtitleStreamIndex) @@ -92,126 +91,207 @@ namespace { } -AppController::AppController(DatabaseManager *database, DiscoveryController *discovery, JellyfinApiFacade *api, - ArtworkService *artwork, PlayerController *player, TlsTrustController *tlsTrust, QObject *parent) +AppController::AppController( + DatabaseManager *database, SourceHub *provider, ArtworkService *artwork, PlayerController *player, QObject *parent) : QObject(parent) , m_database(database) - , m_discovery(discovery) - , m_api(api) + , m_provider(provider) + , m_catalog(provider->catalog()) + , m_playback(provider->playback()) + , m_quality(provider->streamQuality()) , m_artwork(artwork) , m_player(player) { - m_playQueue = new PlayQueueController(api, this); - m_syncPlay = new SyncPlayController(api, player, m_playQueue, tlsTrust, this); - m_remoteControl = new RemoteControlController(api, this); - m_quickConnect = new QuickConnectController(api, this); - m_settings = new SettingsController(database, api, player, artwork, this); - m_session = new SessionController(database, api, this); - m_prefetch = new LibraryPrefetchController(api, artwork, this); + m_playQueue = new PlayQueueController(m_playback, this); + const auto updateReportingQueue = [this] { + QHash audioItems; + audioItems.reserve(m_playQueue->count()); + for (int row = 0; row < m_playQueue->count(); ++row) { + const QModelIndex index = m_playQueue->index(row); + const QString id = m_playQueue->data(index, PlayQueueController::ItemIdRole).toString(); + const QString type = m_playQueue->data(index, PlayQueueController::ItemTypeRole).toString(); + // The queue has already applied isPlayableItem; its audio classes + // are Audio and AudioBook, with the remaining playable types video. + audioItems.insert(id, type == QStringLiteral("Audio") || type == QStringLiteral("AudioBook")); + } + const auto ordered = m_playQueue->nowPlayingQueue(); + std::vector entries; + entries.reserve(ordered.size()); + int current = m_playQueue->currentIndex(); + const auto currentRow = m_playQueue->index(current); + const QString currentId = m_playQueue->data(currentRow, PlayQueueController::ItemIdRole).toString(); + const QString currentEntry = m_playQueue->data(currentRow, PlayQueueController::PlaylistItemIdRole).toString(); + int matchedIndex = -1; + int matches = 0; + for (const auto& item : ordered) { + if (item.itemId == currentId && item.playlistItemId == currentEntry) { + matchedIndex = static_cast(entries.size()); + ++matches; + } + entries.push_back({ item.itemId, item.playlistItemId, audioItems.value(item.itemId) }); + } + // The public queue model does not expose the shuffled occurrence index. + // Never guess which anonymous duplicate is current. + if (m_playQueue->shuffled()) + current = matches == 1 ? matchedIndex : -1; + m_provider->setPlaybackQueue(std::move(entries), current); + }; + connect(m_playQueue, &PlayQueueController::queueChanged, this, updateReportingQueue); + m_settings = new SettingsController(database, player, artwork, this); + m_prefetch = new LibraryPrefetchController(m_catalog, artwork, this); m_browse = new BrowseSessionController(m_prefetch, this); - m_management = new LibraryManagementController(api, m_browse, this); - m_home = new HomeModelController(database, api, m_prefetch, this); - m_content = new ContentModelController(api, m_prefetch, this); - m_search = new SearchController(api, m_prefetch, this); - m_itemState = new UserItemStateController(api, m_browse, m_home, m_content, m_search, this); - if (m_artwork) { - m_artwork->setServerUrl(m_session->serverUrl()); - connect(m_session, &SessionController::serverUrlChanged, m_artwork, - [this]() { m_artwork->setServerUrl(m_session->serverUrl()); }); - } - connect(m_playQueue, &PlayQueueController::successorPlaybackReady, this, [this]() { playQueueCurrent(false); }); + m_home = new HomeModelController(database, m_catalog, m_prefetch, this); + m_content = new ContentModelController(m_catalog, m_prefetch, this); + m_search = new SearchController(provider->search(), m_prefetch, this); + m_itemState = new UserItemStateController(provider->itemState(), m_browse, m_home, m_content, m_search, this); + m_group = new GroupPlaybackController(provider, player, m_playQueue, this); + connect(m_group, &GroupPlaybackController::errorText, this, &AppController::showToast); + connect(provider, &SourceHub::accountEvent, this, + [this](const QString& accountId, const QString& type, const QVariantMap& payload) { + if (type == QStringLiteral("remote")) + handleRemoteCommand(accountId, payload); + else if (type == QStringLiteral("playbackQueueStatus") + && payload.value(QStringLiteral("state")).toString() == QStringLiteral("unavailable")) + showToast( + QStringLiteral("This provider could not synchronize the playback queue. Playback continues.")); + }); + connect(provider, &SourceHub::streamingQualityChanged, this, &AppController::streamingQualityChanged); + // Probes must not compete with playback or foreground catalogue requests. + const auto updateProbeActivity = [this] { + m_provider->setPlaybackActive(m_busy || m_playbackTransition || m_player->sessionActive() + || m_browse->loadingMore() || m_search->busy() || m_search->suggestionsBusy() || m_home->loading() + || m_content->detailRowsBusy() || m_content->personItemsBusy()); + }; + connect(this, &AppController::busyChanged, this, updateProbeActivity); + connect(this, &AppController::playbackTransitionChanged, this, updateProbeActivity); + connect(m_player, &PlayerController::sessionActiveChanged, this, updateProbeActivity); + connect(m_player, &PlayerController::sessionActiveChanged, this, [this] { + if (!m_player->sessionActive()) + m_playingAccountId.clear(); + }); + connect(m_browse, &BrowseSessionController::pagingChanged, this, updateProbeActivity); + connect(m_search, &SearchController::busyChanged, this, updateProbeActivity); + connect(m_search, &SearchController::suggestionsChanged, this, updateProbeActivity); + connect(m_home, &HomeModelController::loadingChanged, this, updateProbeActivity); + connect(m_content, &ContentModelController::detailRowsChanged, this, updateProbeActivity); + connect(m_content, &ContentModelController::personItemsChanged, this, updateProbeActivity); + connect(m_playQueue, &PlayQueueController::successorLookupFinished, this, [this](bool ready) { + if (!ready || inGroup()) { + setPlaybackTransition(false); + return; + } + playQueueCurrent(false); + }); connect(m_browse, &BrowseSessionController::reloadRequested, this, [this]() { beginBrowse(); }); connect(m_browse, &BrowseSessionController::moreItemsRequested, this, &AppController::loadMoreCurrentItems); - connect(m_api, &JellyfinApiFacade::authenticationExpired, m_session, &SessionController::expireSession); - connect(m_syncPlay, &SyncPlayController::errorText, this, &AppController::showToast); - connect(m_remoteControl, &RemoteControlController::errorText, this, &AppController::showToast); - connect(m_remoteControl, &RemoteControlController::feedbackText, this, &AppController::showToast); - if (SpoolLink *link = m_remoteControl->link()) - connect(link, &SpoolLink::messageReceived, this, &AppController::handleSpoolMessage); - connect(m_syncPlay, &SyncPlayController::sessionsUpdated, m_remoteControl, &RemoteControlController::applySessions); connect(m_database, &DatabaseManager::recoveryNotice, this, &AppController::showToast); - connect(m_syncPlay, &SyncPlayController::remotePlayCommand, this, &AppController::handleRemotePlay); - connect(m_syncPlay, &SyncPlayController::remotePlaystateCommand, this, &AppController::handleRemotePlaystate); - connect(m_syncPlay, &SyncPlayController::remoteGeneralCommand, this, &AppController::handleRemoteGeneralCommand); - connect(m_syncPlay, &SyncPlayController::queuePlaybackRequested, this, [this](qint64 positionTicks) { - MovieItem item = m_playQueue->currentItem(); - if (item.id.isEmpty()) - return; - item.resumeTicks = std::max(0, positionTicks); - if (m_player->sessionActive() && m_activePlaybackItem.id != item.id) - m_player->stopWithReason(QStringLiteral("syncplay-group-switch")); - m_activePlaybackItem = item; - setBusy(true, QStringLiteral("Joining SyncPlay playback…")); - Async::runScoped( - this, startPlayback(item, true), []() {}, - [this](const std::exception_ptr& error) { - setBusy(false); - showToast(exceptionMessage(error)); - }, - "SyncPlay playback startup"); - }); + { + connect(m_group, &GroupPlayback::queuePlaybackRequested, this, [this](qint64 positionTicks) { + MovieItem item = m_playQueue->currentItem(); + if (item.id.isEmpty()) + return; + item.resumeTicks = std::max(0, positionTicks); + if (m_player->sessionActive() && m_activePlaybackItem.id != item.id) + m_player->stopWithReason(QStringLiteral("syncplay-group-switch")); + m_activePlaybackItem = item; + setBusy(true, QStringLiteral("Joining the group…")); + Async::runScoped( + this, startPlayback(item, true), []() {}, + [this](const std::exception_ptr& error) { + setBusy(false); + showToast(exceptionMessage(error)); + }, + "group playback startup"); + }); + } connect(m_content, &ContentModelController::errorOccurred, this, &AppController::showToast); connect(m_search, &SearchController::errorOccurred, this, &AppController::showToast); connect(m_itemState, &UserItemStateController::errorOccurred, this, &AppController::setErrorText); - connect(m_management, &LibraryManagementController::errorOccurred, this, &AppController::showToast); - connect(m_management, &LibraryManagementController::operationSucceeded, this, &AppController::showToast); - connect(m_management, &LibraryManagementController::refreshRequested, this, [this](const QString& changedItemId) { - if (!changedItemId.isEmpty() && m_browse->descriptor().id == changedItemId) - goHome(); - else - beginBrowse(); - }); - connect(m_quickConnect, &QuickConnectController::busyChanged, this, &AppController::setBusy); - connect(m_quickConnect, &QuickConnectController::errorOccurred, this, &AppController::setErrorText); connect(m_settings, &SettingsController::errorOccurred, this, &AppController::showToast); - connect(m_session, &SessionController::busyChanged, this, &AppController::setBusy); - connect(m_session, &SessionController::errorOccurred, this, &AppController::setErrorText); - connect(m_session, &SessionController::accountProfilesChanged, this, [this]() { - const bool hasProfiles = !m_session->accountProfiles().isEmpty(); - if (m_hasDefaultProfile == hasProfiles) - return; - m_hasDefaultProfile = hasProfiles; - emit defaultProfileChanged(); + connect(m_provider, &Provider::toastRequested, this, &AppController::showToast); + connect(m_provider, &Provider::errorOccurred, this, &AppController::setErrorText); + connect(m_provider, &SourceHub::browseSourcesChanged, this, [this] { + m_libraryListGeneration.invalidate(); + QSet accounts; + for (Provider *source : m_provider->sources()) + accounts.insert(source->id()); + const auto isAvailable + = [this, &accounts](const QString& id) { return accounts.contains(m_provider->accountOf(id)); }; + m_home->invalidate(isAvailable); + std::vector libraries; + for (const LibraryItem& library : m_libraries.libraries()) { + if (isAvailable(library.id)) + libraries.push_back(library); + } + m_libraries.setLibraries(libraries); }); - connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession&) { - m_syncPlay->connectSocket(); - m_remoteControl->start(); - m_discovery->stop(); - if (m_artwork) - m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); - if (!m_hasDefaultProfile) { - m_hasDefaultProfile = true; - emit defaultProfileChanged(); + connect(m_provider, &Provider::contentChanged, this, [this](const QString& changedItemId) { + if (!changedItemId.isEmpty() && m_browse->descriptor().id == changedItemId) { + goHome(); + return; + } + // No item: an account came or went, or a whole library changed, so + // the library list and home rows are rebuilt too. + if (changedItemId.isEmpty()) { + m_home->invalidate(); + loadLibraries(); } + beginBrowse(); + }); + connect(m_provider, &Provider::sessionStarted, this, [this]() { m_home->loadCachedPayload(); loadLibraries(); + }); + connect(m_provider, &Provider::sessionEnded, this, &AppController::resetApplicationState); - // The home route is the only launch-critical server work. Subtitle - // metadata and bandwidth probing are useful, but starting them beside - // the initial home requests competes for the TV's limited network and - // JSON-processing budget. Load them after the first interaction window; - // opening Settings sooner triggers the same idempotent load directly. - const QString sessionToken = m_api->session().accessToken; - QTimer::singleShot(5000, this, [this, sessionToken]() { - if (!m_session->authenticated() || m_api->session().accessToken != sessionToken) + connect(m_player, &PlayerController::stopRequested, this, [this] { + m_playbackLoadGeneration.invalidate(); + m_playQueue->cancelEpisodeSuccessors(); + cancelEpisodicPlaybackSelection(); + setPlaybackTransition(false); + setBusy(false); + }); + connect(m_player, &PlayerController::playbackStopped, this, &AppController::handlePlaybackStopped); + connect(m_player, &PlayerController::playbackSeekOutsideStream, this, + [this](const QString& itemId, qint64 positionTicks) { + if (itemId != m_activePlaybackItem.id || !m_player->sessionActive()) return; - m_settings->loadRemote(); + const MovieItem seekItem = PlaybackFailurePolicy::retryItem(m_activePlaybackItem, positionTicks); + setBusy(true, QStringLiteral("Seeking…")); Async::runScoped( - this, m_api->refreshPlaybackNetworkState(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "playback bandwidth: route measurement failed" << exceptionMessage(error); - }); + this, + startPlayback(seekItem, false, false, m_activeAudioStreamIndex, m_activeSubtitleStreamIndex, true), + []() {}, + [this](const std::exception_ptr& error) { + setBusy(false); + showToast(exceptionMessage(error)); + }, + "seek outside stream"); + }); + // A dropped connection used to look like the end of the episode: it was + // marked played and the next one started. Pick up where the stream broke + // instead, holding the surface as a queue advance would. + connect(m_player, &PlayerController::playbackInterrupted, this, + [this](const QString& itemId, qint64 positionTicks, bool resumable) { + if (itemId.isEmpty() || itemId != m_activePlaybackItem.id) + return; + if (!resumable || inGroup()) { + showToast(QStringLiteral("Playback was interrupted.")); + return; + } + const MovieItem resumeItem = PlaybackFailurePolicy::retryItem(m_activePlaybackItem, positionTicks); + setPlaybackTransition(true); + setBusy(true, QStringLiteral("Reconnecting…")); + Async::runScoped( + this, startPlayback(resumeItem, false, false, m_activeAudioStreamIndex, m_activeSubtitleStreamIndex), + []() {}, + [this](const std::exception_ptr& error) { + setPlaybackTransition(false); + setBusy(false); + showToast(exceptionMessage(error)); + }, + "interrupted playback resume"); }); - }); - connect(m_session, &SessionController::loggedOut, this, &AppController::resetApplicationState); - connect(m_quickConnect, &QuickConnectController::authenticated, this, - [this](const AuthSession& session) { m_session->acceptSession(session); }); - connect(m_discovery, &DiscoveryController::serverDiscovered, this, [this](const DiscoveredServer& server) { - m_discoveredServers.upsertServer(server); - cacheDiscoveredServers(); - }); - - connect(m_player, &PlayerController::playbackStopped, this, &AppController::handlePlaybackStopped); // The device has just shown it cannot sustain the picture it was asked // for. Move down one rung and say so plainly; the setting persists, so // the next thing that plays starts where this one ended up rather than @@ -242,14 +322,10 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis if (m_player->fileLoaded()) m_qualityFallbackBitrate = -1; }); - // Keep the bandwidth probe off the wire while a stream is running; it - // resumes on its own once the session ends. connect(m_player, &PlayerController::visibleChanged, this, [this]() { if (m_player->visible()) setPlaybackTransition(false); }); - connect(m_player, &PlayerController::sessionActiveChanged, this, - [this]() { m_api->setPlaybackActive(m_player->sessionActive()); }); connect(m_player, &PlayerController::streamSelectionChanged, this, [this](int audioStreamIndex, int subtitleStreamIndex) { // Kept so a quality change can renegotiate onto the same tracks. @@ -262,13 +338,13 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis ? seriesAudioPreferenceForSelection(m_activePlaybackStreams, audioStreamIndex) : SeriesAudioPreference {}; m_database->saveCacheEntry(QString::fromLatin1(kSeriesTrackSelectionNamespace), - seriesTrackSelectionKey(m_api->session(), m_activePlaybackItem.seriesId), + seriesTrackSelectionKey(m_catalog->libraryScopeKey(), m_activePlaybackItem.seriesId), encodeTrackSelection(audioPreference, subtitleStreamIndex)); }); connect(m_player, &PlayerController::playbackLoadFailed, this, [this](const QString& itemId, qint64 positionTicks, const QString&, bool retryableCodecFailure, int audioStreamIndex, int subtitleStreamIndex) { - const bool syncPlayActive = m_syncPlay && m_syncPlay->enabled(); + const bool syncPlayActive = inGroup(); if (itemId.isEmpty() || itemId != m_activePlaybackItem.id) return; // A quality the server cannot deliver should cost the viewer the @@ -278,8 +354,12 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis const int restoredHeight = m_qualityFallbackHeight; m_qualityFallbackBitrate = -1; m_qualityFallbackHeight = 0; - m_api->setSessionBitrateOverride(restoredBitrate); - m_api->setSessionHeightOverride(restoredHeight); + if (m_quality) + m_quality->setOverride(restoredBitrate, restoredHeight); + else { + m_genericBitrateOverride = restoredBitrate; + m_genericHeightOverride = restoredHeight; + } emit streamingQualityChanged(); const MovieItem resumeItem = PlaybackFailurePolicy::retryItem(m_activePlaybackItem, positionTicks); setBusy(true, QStringLiteral("Restoring the previous quality…")); @@ -294,7 +374,7 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis return; } if (retryableCodecFailure && syncPlayActive) { - m_syncPlay->leaveGroup(); + m_group->leaveGroup(); showToast(QStringLiteral("This stream is not directly compatible. Leaving SyncPlay; start it again to " "use server transcoding.")); return; @@ -346,185 +426,99 @@ void AppController::initialize() QCoro::Task AppController::initializeAsync() { Diagnostics::Task task(QStringLiteral("app_initialize")); - QStringList startupKeys = SettingsController::localSettingKeys(); - startupKeys.append(SessionController::localStorageKeys()); - StartupState startupState = co_await m_database->loadStartupStateAsync(startupKeys); + StartupState startupState = co_await m_database->loadStartupStateAsync(SettingsController::localSettingKeys()); QString deviceId = std::move(startupState.deviceId); if (deviceId.isEmpty()) { deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces); m_database->saveDeviceId(deviceId); } - // Jellyfin keys a session on the device id, and delivers each websocket - // message to the one socket of that session that was last active. Two - // instances sharing the stored id therefore collapse into a single - // session: SyncPlay counts them once and their group updates land on - // whichever process spoke last. Give every extra instance its own - // identity, derived from the stored one so the first is unchanged. - // The device name is deliberately left alone: the server stores it on the - // row it looks up by access token, which both instances share, so a - // per-instance name would be rewritten on every request by whichever - // instance spoke last. + // Servers key a session on the device id, so two instances sharing one + // collapse into a single session and receive each other's socket + // messages. Every extra instance gets its own, derived from the stored + // one so the first is unchanged. const int instanceSlot = claimInstanceSlot(); if (instanceSlot > 0) { deviceId += QStringLiteral("-%1").arg(instanceSlot + 1); qInfo() << "app: another instance holds the stored device identity; running as instance" << instanceSlot + 1; } - m_api->setDeviceId(deviceId); - m_settings->applyLocalValues(startupState.values); - const bool hasDefaultProfile - = m_session->initializeFromStorage(std::move(startupState.values), std::move(startupState.profiles)); - if (m_hasDefaultProfile != hasDefaultProfile) { - m_hasDefaultProfile = hasDefaultProfile; - emit defaultProfileChanged(); - } + if (m_settingsSync) + co_await m_settingsSync->loadLocalAsync(); + emit deviceIdentityReady(deviceId); m_initialized = true; emit initializedChanged(); - if (!m_session->authenticated()) { - Async::runScoped( - this, applyDiscoveredServersCacheAsync(), - [this]() { - if (!m_session->authenticated()) - m_discovery->start(); - }, - [this](const std::exception_ptr& error) { - qWarning() << "discovery: cached server load failed" << exceptionMessage(error); - if (!m_session->authenticated()) - m_discovery->start(); - }, - "startup discovery cache"); - } -} - -void AppController::chooseDiscoveredServer(int index) -{ - const auto server = m_discoveredServers.serverAt(index); - if (server.address.isEmpty()) - return; - m_session->setServerName(server.name); - m_session->setServerUrl(server.address); -} - -void AppController::rememberServer(const QString& name, const QString& address) -{ - const QString normalizedAddress = address.trimmed(); - if (normalizedAddress.isEmpty()) - return; - - m_discoveredServers.upsertServer({ normalizedAddress, - name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed(), normalizedAddress }); - cacheDiscoveredServers(); - m_session->setServerName(name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed()); - m_session->setServerUrl(normalizedAddress); -} - -void AppController::cacheDiscoveredServers() -{ - QJsonArray cache; - for (const auto& entry : m_discoveredServers.servers()) - cache.push_back(metaToJson(entry)); - m_database->saveDiscoveredServers(cache); -} - -void AppController::useProfile(const QString& profileId) -{ - setErrorText({}); - m_quickConnect->cancel(); - m_syncPlay->disconnectSocket(); - m_remoteControl->stop(); - m_session->activateProfile(profileId); -} - -void AppController::switchUser() -{ - qInfo() << "app: switch user requested"; - m_quickConnect->cancel(); - setBusy(false); - setErrorText({}); - m_discovery->start(); } -void AppController::logout() +void AppController::resetApplicationState() { - qInfo() << "app: logout requested"; - m_quickConnect->cancel(); - if (m_player->visible()) - m_player->stopWithReason(QStringLiteral("logout")); - m_syncPlay->disconnectSocket(); - m_remoteControl->stop(); - m_session->logout(); + m_remotePlaybackRequestGeneration.invalidate(); + resetVisibleModels(); + m_activePlaybackItem = {}; } -void AppController::resetApplicationState() +void AppController::resetVisibleModels() { - m_syncPlay->disconnectSocket(); - m_remoteControl->stop(); - m_remotePlaybackRequestGeneration.invalidate(); - m_remotePlaybackFingerprint.clear(); - m_settings->clearRemote(); m_prefetch->stop(); - if (m_artwork) { - m_artwork->setAuthorizationHeader({}); + if (m_artwork) m_artwork->cancelPrefetches(); - } - if (m_database) - m_database->invalidateHomePayloads(); m_libraries.clear(); m_browse->clear(); m_home->reset(); m_content->reset(); m_search->reset(); - m_activePlaybackItem = {}; - m_management->clear(); m_libraryLoadGeneration.invalidate(); + m_libraryListGeneration.invalidate(); m_browse->reset(); setBusy(false); setErrorText({}); - Async::runScoped( - this, applyDiscoveredServersCacheAsync(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "discovery: cached server load failed" << exceptionMessage(error); - }, - "discovery cache"); - m_discovery->start(); +} + +void AppController::revokeAccountIdentity(const QString& accountId) +{ + // The registry emits this before removing the old source, while opaque IDs + // still resolve through the hub. Unrelated providers retain their playback. + const bool ownsPending = m_provider->accountOf(m_activePlaybackItem.id) == accountId; + if (ownsPending) + m_playbackLoadGeneration.invalidate(); + if (m_playingAccountId == accountId) + m_player->stopWithReason(QStringLiteral("account-identity-changed")); + if (ownsPending) + m_activePlaybackItem = {}; + for (int row = m_playQueue->count() - 1; row >= 0; --row) { + if (m_provider->accountOf(m_playQueue->itemAt(row).id) == accountId) + m_playQueue->removeItem(row); + } + resetVisibleModels(); } void AppController::goHome() { - if (!m_session->authenticated()) + if (!m_provider->ready()) return; qInfo() << "app: go home viewKind=" << m_browse->viewKind(); m_libraryLoadGeneration.invalidate(); setBusy(false); - m_discovery->stop(); refreshHomeRows(); } void AppController::openLibrary(int index) { - const LibraryItem library = m_libraries.libraryAt(index); - if (library.id.isEmpty()) - return; - const QVariantMap defaultQuery = defaultLibraryQuery(library); - m_browse->enterLibrary(library, libraryContentLabel(library), defaultQuery); - m_home->recordLibraryUse(library); - loadLibraryFilterOptions(beginBrowse(m_browse->query() == defaultQuery), library); + openLibraryById(m_libraries.libraryAt(index).id); } bool AppController::openLibraryById(const QString& libraryId) { - if (libraryId.isEmpty()) + const LibraryItem library = m_libraries.libraryById(libraryId); + if (library.id.isEmpty()) return false; - for (int index = 0; index < m_libraries.count(); ++index) { - if (m_libraries.libraryAt(index).id == libraryId) { - openLibrary(index); - return true; - } - } - return false; + const QVariantMap defaultQuery = defaultLibraryQuery(library); + m_browse->enterLibrary(library, defaultQuery); + m_home->recordLibraryUse(library); + loadLibraryFilterOptions(beginBrowse(m_browse->query() == defaultQuery), library); + return true; } void AppController::playOrOpen(const MovieItem& item, bool fromStart) @@ -534,25 +528,39 @@ void AppController::playOrOpen(const MovieItem& item, bool fromStart) if (m_browse->enterItem(item)) { beginBrowse(); } else { - playQueuedItem(item, fromStart); + playQueuedItem(item, fromStart, userPlayDestination()); } } void AppController::playItemId(const QString& itemId, bool fromStart) { - if (itemId.isEmpty() || !m_api) + fetchPlayItem(itemId, fromStart, userPlayDestination()); +} + +void AppController::playLocalItemId(const QString& itemId, bool fromStart) +{ + fetchPlayItem(itemId, fromStart, std::nullopt); +} + +void AppController::fetchPlayItem(const QString& itemId, bool fromStart, PlayDestination destination) +{ + if (itemId.isEmpty() || !m_catalog) return; setBusy(true, QStringLiteral("Loading item for playback…")); Async::runScoped( - this, m_api->fetchItemDetails(itemId), - [this, fromStart](const MovieItem& item) { + this, m_catalog->fetchItemDetails(itemId), + [this, fromStart, destination](const MovieItem& item) { setBusy(false); + if (!destinationIsCurrent(destination)) + return; if (!item.id.isEmpty()) - playQueuedItem(item, fromStart); + playQueuedItem(item, fromStart, destination); else showToast(QStringLiteral("Item not found.")); }, - [this](const std::exception_ptr& error) { + [this, destination](const std::exception_ptr& error) { setBusy(false); + if (!destinationIsCurrent(destination)) + return; showToast(exceptionMessage(error)); }, "play item by id"); @@ -562,20 +570,20 @@ void AppController::playFromModel(QObject *model, int index, bool fromStart) { if (!model) return; + const auto destination = userPlayDestination(); if (auto *queue = qobject_cast(model)) { if (queue != m_playQueue) { showToast(QStringLiteral("This item is no longer in the play queue.")); return; } - // Opening an item from the queue is still a play request, and it must - // land wherever every other one does. - if (m_remoteControl->targetSelected()) { - const MovieItem item = m_playQueue->itemAt(index); - if (!item.id.isEmpty()) { - playQueuedItem(item, fromStart); - return; - } + if (destination && !destination->targetId.isEmpty()) { + std::vector items; + items.reserve(size_t(queue->count())); + for (int row = 0; row < queue->count(); ++row) + items.push_back(queue->itemAt(row)); + dispatchRemotePlay(destination, items, index, fromStart); + return; } if (!queue->playAt(index)) { showToast(QStringLiteral("This item is no longer in the play queue.")); @@ -594,13 +602,13 @@ void AppController::playFromModel(QObject *model, int index, bool fromStart) if (isBrowseContainer(item)) playOrOpen(item, fromStart); else if (item.itemType == QStringLiteral("Episode") && !item.seriesId.isEmpty()) - playQueuedItem(item, fromStart); + playQueuedItem(item, fromStart, destination); else if (item.itemType == QStringLiteral("Audio") && !item.albumId.isEmpty() && !modelIsOrderedList(movieModel)) - playAlbumFrom(item, fromStart); + playAlbumFrom(item, fromStart, destination); else if (modelIsOrderedList(movieModel)) - playQueuedItems(movieModel->movies(), index, fromStart); + playQueuedItems(movieModel->movies(), index, fromStart, destination); else - playQueuedItem(item, fromStart); + playQueuedItem(item, fromStart, destination); } // Whether the list a track was picked out of is one the user assembled or @@ -628,10 +636,10 @@ bool AppController::modelIsOrderedList(MovieGridModel *model) const } } -void AppController::playAlbumFrom(const MovieItem& track, bool fromStart) +void AppController::playAlbumFrom(const MovieItem& track, bool fromStart, PlayDestination destination) { - if (!m_api || track.albumId.isEmpty()) { - playQueuedItem(track, fromStart); + if (track.albumId.isEmpty()) { + playQueuedItem(track, fromStart, destination); return; } @@ -640,47 +648,56 @@ void AppController::playAlbumFrom(const MovieItem& track, bool fromStart) // The same descriptor ContentModelController uses for album children, so // there is one definition of what an album contains. Async::runScoped( - this, m_api->fetchBrowsePage(BrowseDescriptor::folderChildren(track.albumId), 0, 200, {}), - [this, generation, track, fromStart](const PagedMovieItems& page) { + this, m_catalog->fetchBrowsePage(BrowseDescriptor::folderChildren(track.albumId), 0, 200, {}, std::nullopt), + [this, generation, track, fromStart, destination](const PagedMovieItems& page) { if (generation != m_albumQueueGeneration) return; setBusy(false); + if (!destinationIsCurrent(destination)) + return; const auto found = std::find_if(page.items.cbegin(), page.items.cend(), [&track](const MovieItem& candidate) { return candidate.id == track.id; }); if (found == page.items.cend()) { - playQueuedItem(track, fromStart); + playQueuedItem(track, fromStart, destination); return; } - playQueuedItems(page.items, static_cast(std::distance(page.items.cbegin(), found)), fromStart); + playQueuedItems( + page.items, static_cast(std::distance(page.items.cbegin(), found)), fromStart, destination); }, - [this, generation, track, fromStart](const std::exception_ptr&) { + [this, generation, track, fromStart, destination](const std::exception_ptr&) { if (generation != m_albumQueueGeneration) return; setBusy(false); + if (!destinationIsCurrent(destination)) + return; // The track is still what the user asked for; losing the rest of // the album is a worse outcome than not playing at all. - playQueuedItem(track, fromStart); + playQueuedItem(track, fromStart, destination); }); } void AppController::stopPlayback() { - // A system stop can arrive between tracks, while no mpv session exists. - // Invalidate negotiation as well so its reply cannot restart stopped music. - m_playbackLoadGeneration.invalidate(); - setPlaybackTransition(false); - setBusy(false); m_player->stop(); } void AppController::playQueueNext() +{ + if (m_remoteTargets && !m_remoteTargets->selection().targetId.isEmpty()) { + m_remoteTargets->send({ { QStringLiteral("action"), QStringLiteral("next") } }); + return; + } + playLocalQueueNext(userPlayDestination()); +} + +void AppController::playLocalQueueNext(PlayDestination destination) { if (!m_playQueue->canGoNext()) { - playEpisodeWithContext(m_playQueue->currentItem(), 1, true); + playEpisodeWithContext(m_playQueue->currentItem(), 1, true, destination); return; } - if (m_syncPlay && m_syncPlay->enabled()) { - m_syncPlay->requestNextItem(); + if (inGroup()) { + m_group->requestNextItem(); return; } if (!m_playQueue->next()) @@ -689,13 +706,22 @@ void AppController::playQueueNext() } void AppController::playQueuePrevious() +{ + if (m_remoteTargets && !m_remoteTargets->selection().targetId.isEmpty()) { + m_remoteTargets->send({ { QStringLiteral("action"), QStringLiteral("previous") } }); + return; + } + playLocalQueuePrevious(userPlayDestination()); +} + +void AppController::playLocalQueuePrevious(PlayDestination destination) { if (!m_playQueue->canGoPrevious()) { - playEpisodeWithContext(m_playQueue->currentItem(), -1, true); + playEpisodeWithContext(m_playQueue->currentItem(), -1, true, destination); return; } - if (m_syncPlay && m_syncPlay->enabled()) { - m_syncPlay->requestPreviousItem(); + if (inGroup()) { + m_group->requestPreviousItem(); return; } if (!m_playQueue->previous()) @@ -705,60 +731,28 @@ void AppController::playQueuePrevious() void AppController::playQueueItem(int index) { + if (m_remoteTargets && !m_remoteTargets->selection().targetId.isEmpty()) { + playFromModel(m_playQueue, index, false); + return; + } // A second click on the row already playing used to tear mpv down and // restart the same track from the top, which is never what the click meant. if (index == m_playQueue->currentIndex() && m_player->sessionActive()) return; - if (inSyncPlayGroup()) { + if (inGroup()) { // Jumping the group, not just this client. - m_syncPlay->requestPlayItem(queuePlaylistItemId(index)); + m_group->requestPlayItem(queueEntryId(index)); return; } - if (m_remoteControl->targetSelected()) { - const MovieItem item = m_playQueue->itemAt(index); - if (!item.id.isEmpty()) { - playQueuedItem(item, false); - return; - } - } if (!m_playQueue->playAt(index)) return; playQueueCurrent(false); } -bool AppController::queueEditable() const -{ - return true; -} - -bool AppController::inSyncPlayGroup() const -{ - return m_syncPlay && m_syncPlay->enabled(); -} - -QString AppController::queuePlaylistItemId(int index) const -{ - const MovieItem item = m_playQueue->itemAt(index); - return item.playlistItemId; -} - -bool AppController::previewQueueMove(int from, int to) -{ - // Previewed locally even in a group. Waiting on a round trip per step would - // make a held D-pad key and a pointer drag both unusable; the group's own - // PlayQueue broadcast is what settles the order a moment later. - return m_playQueue->moveItem(from, to); -} - -void AppController::commitQueueMove(int from, int to) -{ - if (from == to || !inSyncPlayGroup()) - return; - // The preview already left the row at `to`, so that is the entry to publish. - m_syncPlay->requestMoveItem(queuePlaylistItemId(to), to); -} - +// Previewed locally even in a group. Waiting on a round trip per step would +// make a held D-pad key and a pointer drag both unusable; the group's own +// PlayQueue broadcast is what settles the order a moment later. bool AppController::previewQueueMoveRange(int from, int count, int to) { return m_playQueue->moveRange(from, count, to); @@ -766,20 +760,20 @@ bool AppController::previewQueueMoveRange(int from, int count, int to) void AppController::commitQueueMoveRange(int from, int count, int to) { - if (from == to || count <= 0 || !inSyncPlayGroup()) + if (from == to || count <= 0 || !inGroup()) return; // The preview has already laid the block down at `to`, so publish the rows // where they now sit, top down, which is the order the group will apply. for (int offset = 0; offset < count; ++offset) - m_syncPlay->requestMoveItem(queuePlaylistItemId(to + offset), to + offset); + m_group->requestMoveItem(queueEntryId(to + offset), to + offset); } void AppController::removeQueueItem(int index) { - if (inSyncPlayGroup()) { + if (inGroup()) { // No local edit: a removal is a single action with nothing to animate, // so let the group's broadcast be the one thing that changes the queue. - m_syncPlay->requestRemoveItems({ queuePlaylistItemId(index) }); + m_group->requestRemoveItems({ queueEntryId(index) }); return; } @@ -801,7 +795,7 @@ void AppController::removeQueueItem(int index) void AppController::playNextFromItem(const MovieItem& item) { - if (m_remoteControl->playItems({ item }, 0, QStringLiteral("PlayNext"), false)) + if (dispatchRemotePlay(userPlayDestination(), std::span(&item, 1), 0, true, QStringLiteral("next"))) return; if (enqueueForGroup(item, true)) return; @@ -811,7 +805,7 @@ void AppController::playNextFromItem(const MovieItem& item) void AppController::addToQueueFromItem(const MovieItem& item) { - if (m_remoteControl->playItems({ item }, 0, QStringLiteral("PlayLast"), false)) + if (dispatchRemotePlay(userPlayDestination(), std::span(&item, 1), 0, true, QStringLiteral("last"))) return; if (enqueueForGroup(item, false)) return; @@ -819,23 +813,11 @@ void AppController::addToQueueFromItem(const MovieItem& item) setErrorText(QStringLiteral("This item cannot be queued.")); } -bool AppController::enqueueForGroup(const MovieItem& item, bool queueNext) -{ - if (!inSyncPlayGroup()) - return false; - if (item.id.isEmpty() || !isPlayableItem(item)) { - setErrorText(QStringLiteral("This item cannot be queued.")); - return true; - } - m_syncPlay->requestQueueItems({ item.id }, queueNext); - return true; -} - void AppController::loadMoreCurrentItems() { if (m_browse->loadingMore() || !m_browse->hasMore()) return; - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_catalog->signedIn()) return; const BrowseDescriptor descriptor = m_browse->descriptor(); if (!descriptor.isValid()) @@ -855,13 +837,15 @@ void AppController::loadMoreCurrentItems() showToast(exceptionMessage(error)); }; - Async::runLatest(this, m_api->fetchBrowsePage(descriptor, startIndex, kLibraryPageSize, query), + Async::runLatest(this, + m_catalog->fetchBrowsePage(descriptor, startIndex, kLibraryPageSize, query, m_browse->nextCursor()), m_libraryLoadGeneration, loadGeneration, onDone, onError); } -void AppController::playQueuedItems(const std::vector& items, int startIndex, bool fromStart) +void AppController::playQueuedItems( + const std::vector& items, int startIndex, bool fromStart, PlayDestination destination) { - if (m_remoteControl->playItems(items, startIndex, QStringLiteral("PlayNow"), fromStart)) + if (dispatchRemotePlay(destination, items, startIndex, fromStart)) return; if (!m_playQueue->playNow(items, startIndex)) { showToast(QStringLiteral("This item cannot be queued.")); @@ -874,10 +858,6 @@ void AppController::playModel(MovieGridModel *model, bool shuffled) { if (!model) return; - if (shuffled && m_remoteControl->targetSelected()) { - m_remoteControl->playItems(model->movies(), 0, QStringLiteral("PlayShuffle"), false); - return; - } const std::vector& items = model->movies(); const auto firstPlayable = std::find_if( items.begin(), items.end(), [](const MovieItem& item) { return !item.id.isEmpty() && isPlayableItem(item); }); @@ -885,29 +865,47 @@ void AppController::playModel(MovieGridModel *model, bool shuffled) showToast(QStringLiteral("This list has no playable items.")); return; } - playQueuedItems(items, static_cast(std::distance(items.begin(), firstPlayable)), false); + const auto destination = userPlayDestination(); + const int startIndex = static_cast(std::distance(items.begin(), firstPlayable)); + if (dispatchRemotePlay( + destination, items, startIndex, false, shuffled ? QStringLiteral("shuffle") : QStringLiteral("now"))) + return; + playQueuedItems(items, startIndex, false, destination); if (shuffled) m_playQueue->setShuffled(true); } void AppController::queueEpisodicContainer(const QString& seriesId, const QString& seasonId, bool next) { - if (!m_api || seriesId.isEmpty()) + if (seriesId.isEmpty()) return; + const auto destination = userPlayDestination(); Async::runScoped( - this, m_api->fetchEpisodes(seriesId, seasonId), - [this, next](const std::vector& episodes) { - if (m_remoteControl->playItems(episodes, next ? 0 : static_cast(episodes.size()) - 1, - next ? QStringLiteral("PlayNext") : QStringLiteral("PlayLast"), false)) + this, m_catalog->fetchEpisodes(seriesId, seasonId), + [this, next, destination](const std::vector& episodes) { + if (!destinationIsCurrent(destination)) return; if (episodes.empty()) { setErrorText(QStringLiteral("There is nothing here to queue.")); return; } + if (dispatchRemotePlay( + destination, episodes, 0, true, next ? QStringLiteral("next") : QStringLiteral("last"))) + return; + if (inGroup()) { + QStringList ids; + for (const auto& episode : episodes) + if (!episode.id.isEmpty() && isPlayableItem(episode)) + ids.append(episode.id); + m_group->requestQueueItems(ids, next); + return; + } if (!m_playQueue->addToQueue(episodes, next)) setErrorText(QStringLiteral("This item cannot be queued.")); }, - [this](const std::exception_ptr& error) { + [this, destination](const std::exception_ptr& error) { + if (!destinationIsCurrent(destination)) + return; qWarning() << "queue: episode lookup failed" << exceptionMessage(error); setErrorText(QStringLiteral("Could not reach the server to queue that.")); }); @@ -915,22 +913,26 @@ void AppController::queueEpisodicContainer(const QString& seriesId, const QStrin void AppController::playEpisodicContainer(const QString& seriesId, const QString& seasonId) { - if (!m_api || seriesId.isEmpty()) + if (seriesId.isEmpty()) return; if (m_episodeQueuePending) return; m_episodeQueuePending = true; + const auto destination = userPlayDestination(); const quint64 generation = ++m_episodeQueueGeneration; setBusy(true, seasonId.isEmpty() ? QStringLiteral("Finding the next episode…") : QStringLiteral("Finding the next episode in this season…")); Async::runScoped( - this, m_api->fetchEpisodes(seriesId, seasonId), - [this, generation](const std::vector& episodes) { + this, m_catalog->fetchEpisodes(seriesId, seasonId), + [this, generation, destination](const std::vector& episodes) { if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + setBusy(false); + if (!destinationIsCurrent(destination)) + return; const int startIndex = episodicPlaybackStartIndex(episodes); if (startIndex < 0) { setBusy(false); @@ -941,13 +943,15 @@ void AppController::playEpisodicContainer(const QString& seriesId, const QString : QStringLiteral("No playable episodes are available.")); return; } - playQueuedItems(episodes, startIndex, false); + playQueuedItems(episodes, startIndex, false, destination); }, - [this, generation](const std::exception_ptr& error) { + [this, generation, destination](const std::exception_ptr& error) { if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; setBusy(false); + if (!destinationIsCurrent(destination)) + return; showToast(exceptionMessage(error)); }, "episodic container playback"); @@ -962,14 +966,14 @@ void AppController::cancelEpisodicPlaybackSelection() setBusy(false); } -void AppController::playQueuedItem(const MovieItem& item, bool fromStart) +void AppController::playQueuedItem(const MovieItem& item, bool fromStart, PlayDestination destination) { - if (m_remoteControl->playItems({ item }, 0, QStringLiteral("PlayNow"), fromStart)) - return; if (item.itemType == QStringLiteral("Episode") && !item.seriesId.isEmpty()) { - playEpisodeWithContext(item, 0, fromStart); + playEpisodeWithContext(item, 0, fromStart, destination); return; } + if (dispatchRemotePlay(destination, std::span(&item, 1), 0, fromStart)) + return; if (!m_playQueue->playNow(item)) { showToast(QStringLiteral("This item cannot be queued.")); return; @@ -977,11 +981,12 @@ void AppController::playQueuedItem(const MovieItem& item, bool fromStart) startQueuedPlayback(fromStart); } -void AppController::playEpisodeWithContext(const MovieItem& episode, int direction, bool fromStart) +void AppController::playEpisodeWithContext( + const MovieItem& episode, int direction, bool fromStart, PlayDestination destination) { - if (!m_api || episode.itemType != QStringLiteral("Episode") || episode.seriesId.isEmpty()) { - if (direction == 0 && m_playQueue->playNow(episode)) - startQueuedPlayback(fromStart); + if (episode.itemType != QStringLiteral("Episode") || episode.seriesId.isEmpty()) { + if (direction == 0) + playQueuedItems({ episode }, 0, fromStart, destination); return; } @@ -989,17 +994,20 @@ void AppController::playEpisodeWithContext(const MovieItem& episode, int directi m_episodeQueuePending = true; setBusy(true, direction == 0 ? QStringLiteral("Loading episode queue…") : QStringLiteral("Finding episode…")); Async::runScoped( - this, m_api->fetchEpisodes(episode.seriesId), - [this, generation, episode, direction, fromStart](const std::vector& episodes) { + this, m_catalog->fetchEpisodes(episode.seriesId), + [this, generation, episode, direction, fromStart, destination](const std::vector& episodes) { if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + setBusy(false); + if (!destinationIsCurrent(destination)) + return; const auto current = std::find_if(episodes.begin(), episodes.end(), [&episode](const MovieItem& candidate) { return candidate.id == episode.id; }); if (current == episodes.end()) { setBusy(false); - if (direction == 0 && m_playQueue->playNow(episode)) - startQueuedPlayback(fromStart); + if (direction == 0) + playQueuedItems({ episode }, 0, fromStart, destination); else showToast(QStringLiteral("This episode was not found in its series.")); return; @@ -1020,6 +1028,8 @@ void AppController::playEpisodeWithContext(const MovieItem& episode, int directi } targetIndex = candidate; } + if (dispatchRemotePlay(destination, episodes, targetIndex, direction == 0 ? fromStart : true)) + return; if (!m_playQueue->playNow(episodes, targetIndex)) { setBusy(false); @@ -1029,13 +1039,15 @@ void AppController::playEpisodeWithContext(const MovieItem& episode, int directi qInfo() << "play queue: loaded episode context" << episodes.size() << "items, target" << targetIndex; startQueuedPlayback(direction == 0 ? fromStart : true); }, - [this, generation, episode, direction, fromStart](const std::exception_ptr& error) { + [this, generation, episode, direction, fromStart, destination](const std::exception_ptr& error) { if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; setBusy(false); - if (direction == 0 && m_playQueue->playNow(episode)) { - startQueuedPlayback(fromStart); + if (!destinationIsCurrent(destination)) + return; + if (direction == 0) { + playQueuedItems({ episode }, 0, fromStart, destination); return; } showToast(exceptionMessage(error)); @@ -1045,7 +1057,7 @@ void AppController::playEpisodeWithContext(const MovieItem& episode, int directi void AppController::startQueuedPlayback(bool fromStart) { - if (!m_syncPlay || !m_syncPlay->enabled()) { + if (!inGroup()) { playQueueCurrent(fromStart); return; } @@ -1064,13 +1076,13 @@ void AppController::startQueuedPlayback(bool fromStart) // Arm this before SetNewQueue: the websocket PlayQueue update can arrive // before or after the HTTP response. Waiting for the response allowed an // old loaded session to consume the pending unpause request. - m_syncPlay->requestUnpauseWhenReady(); + m_group->requestUnpauseWhenReady(); Async::runScoped( - this, m_api->syncPlaySetNewQueue(itemIds, m_playQueue->currentIndex(), startPositionTicks), []() {}, + this, m_group->publishQueue(itemIds, m_playQueue->currentIndex(), startPositionTicks), []() {}, [this, generation](const std::exception_ptr& error) { if (!m_syncPlayQueueRequestGeneration.isCurrent(generation)) return; - m_syncPlay->cancelPendingUnpause(); + m_group->cancelPendingUnpause(); setBusy(false); showToast(exceptionMessage(error)); }, @@ -1085,10 +1097,10 @@ void AppController::playQueueCurrent(bool fromStart) if (fromStart || !isMeaningfulResumePosition(item.resumeTicks, item.runtimeTicks)) item.resumeTicks = 0; m_activePlaybackItem = item; - if (m_api && item.itemType == QStringLiteral("Movie") && item.people.isEmpty()) { + if (item.itemType == QStringLiteral("Movie") && item.people.isEmpty()) { const QString itemId = item.id; Async::runScoped( - this, m_api->fetchItemDetails(itemId), + this, m_catalog->fetchItemDetails(itemId), [this, itemId](const MovieItem& details) { m_playQueue->updatePeople(itemId, details.people); }, [itemId](const std::exception_ptr& error) { qInfo() << "app: movie credits unavailable for" << itemId << ":" << exceptionMessage(error); @@ -1105,265 +1117,6 @@ void AppController::playQueueCurrent(bool fromStart) "playback startup"); } -void AppController::handleRemotePlay(const QJsonObject& data) -{ - if (!m_api->remoteControlTargetEnabled()) - return; - QStringList itemIds; - for (const QJsonValue& value : data.value(QStringLiteral("ItemIds")).toArray()) { - const QString id = value.toString(); - if (!id.isEmpty()) - itemIds.push_back(id); - } - if (itemIds.isEmpty()) - return; - - const QString command = data.value(QStringLiteral("PlayCommand")).toString(QStringLiteral("PlayNow")); - const int requestedIndex = data.value(QStringLiteral("StartIndex")).toInt(0); - const qint64 startTicks = data.value(QStringLiteral("StartPositionTicks")).toVariant().toLongLong(); - const QString fingerprint - = QStringLiteral("%1\n%2\n%3\n%4") - .arg(command, QString::number(requestedIndex), QString::number(startTicks), itemIds.join(QChar(0x1f))); - if (fingerprint == m_remotePlaybackFingerprint) { - qInfo() << "remote: ignored duplicate play request" << command << itemIds.size() << "items"; - return; - } - - m_remotePlaybackFingerprint = fingerprint; - const RequestGeneration::Token generation = m_remotePlaybackRequestGeneration.next(); - setBusy(true, QStringLiteral("Starting remote playback…")); - qInfo() << "remote: play" << command << itemIds.size() << "items, index" << requestedIndex; - Async::runScoped( - this, m_api->fetchItemsByIds(itemIds), - [this, generation, fingerprint, itemIds, command, requestedIndex, startTicks]( - const std::vector& fetched) { - if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) - return; - std::vector ordered; - ordered.reserve(static_cast(itemIds.size())); - for (const QString& id : itemIds) { - const auto found = std::find_if( - fetched.begin(), fetched.end(), [&id](const MovieItem& item) { return item.id == id; }); - if (found != fetched.end()) - ordered.push_back(*found); - } - if (ordered.empty()) { - m_remotePlaybackFingerprint.clear(); - setBusy(false); - showToast(QStringLiteral("The remote playback item is unavailable.")); - return; - } - - if (command == QStringLiteral("PlayNext")) { - for (auto item = ordered.rbegin(); item != ordered.rend(); ++item) - m_playQueue->playNext(*item); - m_remotePlaybackFingerprint.clear(); - setBusy(false); - return; - } - if (command == QStringLiteral("PlayLast")) { - for (const MovieItem& item : ordered) - m_playQueue->addToQueue(item); - m_remotePlaybackFingerprint.clear(); - setBusy(false); - return; - } - - const int index = std::clamp(requestedIndex, 0, static_cast(ordered.size()) - 1); - ordered[static_cast(index)].resumeTicks = std::max(0, startTicks); - const bool queued = m_playQueue->playNow(ordered, index); - if (queued && command == QStringLiteral("PlayShuffle")) - m_playQueue->setShuffled(true); - if (!queued) { - m_remotePlaybackFingerprint.clear(); - setBusy(false); - showToast(QStringLiteral("The remote playback item could not be queued.")); - return; - } - startQueuedPlayback(startTicks <= 0); - QTimer::singleShot(10'000, this, [this, fingerprint]() { - if (m_remotePlaybackFingerprint == fingerprint) - m_remotePlaybackFingerprint.clear(); - }); - }, - [this, generation](const std::exception_ptr& error) { - if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) - return; - m_remotePlaybackFingerprint.clear(); - setBusy(false); - showToast(exceptionMessage(error)); - }, - "remote playback request"); -} - -void AppController::handleSpoolMessage(const SpoolRemoteProtocol::Message& message) -{ - if (const auto preview = SpoolRemoteProtocol::seekPreview(message)) { - // A preview for something this client is not playing would scrub an - // overlay over the wrong picture; a teardown is always safe. - if (!preview->active || (m_player->sessionActive() && preview->itemId == m_activePlaybackItem.id)) - emit remoteSeekPreviewRequested(preview->positionTicks, preview->active); - return; - } - if (const auto join = SpoolRemoteProtocol::syncPlayJoin(message)) { - qInfo() << "remote: joining SyncPlay group" << join->groupId << "on request"; - m_syncPlay->joinGroup(join->groupId); - return; - } - qInfo() << "remote: ignoring unknown Spool command" << message.command; -} - -void AppController::handleRemotePlaystate(const QJsonObject& data) -{ - if (!m_api->remoteControlTargetEnabled()) - return; - const QString command = data.value(QStringLiteral("Command")).toString(); - qInfo() << "remote: playstate" << command; - if (command == QStringLiteral("Stop")) { - m_player->stopWithReason(QStringLiteral("remote-stop")); - } else if (command == QStringLiteral("Pause")) { - if (!m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); - } else if (command == QStringLiteral("Unpause")) { - if (m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); - } else if (command == QStringLiteral("PlayPause")) { - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); - } else if (command == QStringLiteral("Seek")) { - const double seconds - = static_cast(data.value(QStringLiteral("SeekPositionTicks")).toVariant().toLongLong()) - / 10'000'000.0; - m_syncPlay->enabled() ? m_syncPlay->requestSeek(seconds) : m_player->seek(seconds); - } else if (command == QStringLiteral("Rewind")) { - m_syncPlay->enabled() ? m_syncPlay->requestRelativeSeek(-10.0) : m_player->seekBack(); - } else if (command == QStringLiteral("FastForward")) { - m_syncPlay->enabled() ? m_syncPlay->requestRelativeSeek(10.0) : m_player->seekForward(); - } else if (command == QStringLiteral("NextTrack")) { - playQueueNext(); - } else if (command == QStringLiteral("PreviousTrack")) { - playQueuePrevious(); - } -} - -void AppController::handleRemoteGeneralCommand(const QJsonObject& data) -{ - if (!m_api->remoteControlTargetEnabled()) - return; - const QString command = data.value(QStringLiteral("Name")).toString(); - const QJsonObject arguments = data.value(QStringLiteral("Arguments")).toObject(); - const auto argumentInt = [&arguments](const QString& key, int fallback = 0) { - bool ok = false; - const int value = arguments.value(key).toVariant().toInt(&ok); - return ok ? value : fallback; - }; - qInfo() << "remote: general command" << command; - - if (const auto message = SpoolRemoteProtocol::decode(command, arguments)) { - // Link signalling never reaches the application; everything else is - // handled the same whether it arrived here or over the direct path. - SpoolLink *link = m_remoteControl->link(); - if (!link || !link->handleServerMessage(*message)) - handleSpoolMessage(*message); - } else if (command == QStringLiteral("SetVolume")) { - m_player->setVolume(argumentInt(QStringLiteral("Volume"), m_player->volume())); - } else if (command == QStringLiteral("VolumeUp")) { - m_player->adjustVolume(5); - } else if (command == QStringLiteral("VolumeDown")) { - m_player->adjustVolume(-5); - } else if (command == QStringLiteral("Mute")) { - m_player->setMuted(true); - } else if (command == QStringLiteral("Unmute")) { - m_player->setMuted(false); - } else if (command == QStringLiteral("ToggleMute")) { - m_player->toggleMuted(); - } else if (command == QStringLiteral("SetAudioStreamIndex")) { - m_player->selectAudioStreamIndex(argumentInt(QStringLiteral("Index"), -1)); - } else if (command == QStringLiteral("SetSubtitleStreamIndex")) { - m_player->selectSubtitleStreamIndex(argumentInt(QStringLiteral("Index"), -1)); - } else if (command == QStringLiteral("SetRepeatMode")) { - const QString mode = arguments.value(QStringLiteral("RepeatMode")).toString(); - if (mode == QStringLiteral("RepeatNone") || mode == QStringLiteral("RepeatAll") - || mode == QStringLiteral("RepeatOne")) - m_remoteRepeatMode = mode; - } else if (command == QStringLiteral("SetShuffleQueue")) { - m_playQueue->setShuffled( - arguments.value(QStringLiteral("ShuffleMode")).toString() == QStringLiteral("Shuffle")); - } else if (command == QStringLiteral("SetPlaybackOrder")) { - m_playQueue->setShuffled( - arguments.value(QStringLiteral("PlaybackOrder")).toString() == QStringLiteral("Shuffle")); - } else if (command == QStringLiteral("SetMaxStreamingBitrate")) { - selectStreamingQuality(arguments.value(QStringLiteral("Bitrate")).toVariant().toLongLong(), - arguments.value(QStringLiteral("Height")).toVariant().toInt()); - } else if (command == QStringLiteral("ToggleStats")) { - m_player->toggleDebugOsd(); - } else if (command == QStringLiteral("ToggleOsd")) { - emit remoteUiActionRequested(QStringLiteral("toggle-osd")); - } else if (command == QStringLiteral("ToggleOsdMenu") || command == QStringLiteral("ToggleContextMenu")) { - emit remoteUiActionRequested(QStringLiteral("context-menu")); - } else if (command == QStringLiteral("ToggleFullscreen")) { - emit remoteUiActionRequested(QStringLiteral("fullscreen")); - } else if (command == QStringLiteral("GoHome")) { - emit remoteUiActionRequested(QStringLiteral("home")); - } else if (command == QStringLiteral("GoToSettings")) { - emit remoteUiActionRequested(QStringLiteral("settings")); - } else if (command == QStringLiteral("GoToSearch")) { - emit remoteUiActionRequested(QStringLiteral("search")); - } else if (command == QStringLiteral("DisplayContent")) { - const QString itemId = arguments.value(QStringLiteral("ItemId")).toString(); - if (!itemId.isEmpty()) { - emit remoteContentRequested(itemId, arguments.value(QStringLiteral("ItemType")).toString(), - arguments.value(QStringLiteral("ItemName")).toString()); - } - } else if (command == QStringLiteral("Play") || command == QStringLiteral("Unpause")) { - if (m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); - } else if (command == QStringLiteral("Pause")) { - if (!m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); - } else if (command == QStringLiteral("Stop")) { - m_player->stopWithReason(QStringLiteral("remote-stop")); - } else if (command == QStringLiteral("PlayNext")) { - playQueueNext(); - } else if (command == QStringLiteral("DisplayMessage")) { - const QString message = arguments.value(QStringLiteral("Text")).toString().trimmed(); - if (!message.isEmpty()) - emit remoteMessageRequested(message); - } else if (command == QStringLiteral("SendString")) { - const QString text = arguments.value(QStringLiteral("String")).toString(); - QObject *focusObject = QGuiApplication::focusObject(); - if (!text.isEmpty() && focusObject) { - QInputMethodEvent input; - input.setCommitString(text); - QCoreApplication::sendEvent(focusObject, &input); - } - } else { - const QHash keys = { - { QStringLiteral("MoveUp"), Qt::Key_Up }, - { QStringLiteral("MoveDown"), Qt::Key_Down }, - { QStringLiteral("MoveLeft"), Qt::Key_Left }, - { QStringLiteral("MoveRight"), Qt::Key_Right }, - { QStringLiteral("PageUp"), Qt::Key_PageUp }, - { QStringLiteral("PageDown"), Qt::Key_PageDown }, - { QStringLiteral("PreviousLetter"), Qt::Key_PageUp }, - { QStringLiteral("NextLetter"), Qt::Key_PageDown }, - { QStringLiteral("Select"), Qt::Key_Return }, - { QStringLiteral("Back"), Qt::Key_Back }, - { QStringLiteral("Home"), Qt::Key_Home }, - { QStringLiteral("End"), Qt::Key_End }, - { QStringLiteral("Space"), Qt::Key_Space }, - }; - const QString keyName - = command == QStringLiteral("SendKey") ? arguments.value(QStringLiteral("Key")).toString() : command; - const auto found = keys.constFind(keyName); - if (found != keys.cend() && QGuiApplication::focusWindow()) { - QKeyEvent press(QEvent::KeyPress, *found, Qt::NoModifier); - QKeyEvent release(QEvent::KeyRelease, *found, Qt::NoModifier); - QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &press); - QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &release); - } - } -} - QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPaused, bool forceTranscode, int audioStreamIndex, int subtitleStreamIndex, bool restartActive) { @@ -1374,13 +1127,13 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau if (!forceTranscode) m_codecFallbackAttempted = false; - PlaybackSession session = co_await m_api->negotiatePlayback(playItem, forceTranscode); + PlaybackSession session = co_await m_playback->resolvePlayback(playItem, forceTranscode); if (!m_playbackLoadGeneration.isCurrent(generation)) co_return; if (!forceTranscode && playItem.itemType == QStringLiteral("Episode") && !playItem.seriesId.isEmpty()) { const QByteArray storedSelection = co_await m_database->loadCacheEntryAsync(QString::fromLatin1(kSeriesTrackSelectionNamespace), - seriesTrackSelectionKey(m_api->session(), playItem.seriesId)); + seriesTrackSelectionKey(m_catalog->libraryScopeKey(), playItem.seriesId)); if (!m_playbackLoadGeneration.isCurrent(generation)) co_return; SeriesAudioPreference storedAudioPreference; @@ -1424,11 +1177,22 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau session.restoreStreamSelection = true; } } + if (!restartActive && !forceTranscode) { + co_await m_settings->applyDeferredTrackDefaults(); + if (!m_playbackLoadGeneration.isCurrent(generation)) + co_return; + } m_activePlaybackStreams = session.mediaStreams; + m_activeMediaSourceId = session.mediaSourceId; + m_activeSourceBitrate = session.sourceBitrate; + m_activeSourceHeight = session.sourceHeight; + m_activeSourceWidth = session.sourceWidth; + m_activePlayMethod = session.playMethod; const int fileAudioDelayMs = restartActive ? m_player->fileAudioDelayMs() : 0; const int subtitleDelayMs = restartActive ? m_player->subtitleDelayMs() : 0; setBusy(false); m_player->play(session, startPaused); + m_playingAccountId = m_provider->accountOf(session.itemId); if (restartActive) { m_player->setFileAudioDelayMs(fileAudioDelayMs); m_player->setSubtitleDelayMs(subtitleDelayMs); @@ -1436,7 +1200,7 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau const QString itemId = playItem.id; Async::runScoped( - this, m_api->fetchMediaSegments(itemId), + this, m_playback->fetchMediaSegments(itemId), [this, itemId](const std::vector& segments) { if (m_player && !segments.empty()) m_player->setMediaSegments(itemId, segments); @@ -1473,10 +1237,8 @@ void AppController::shutdown() m_shuttingDown = true; qInfo() << "app: shutdown requested"; m_player->prepareForShutdown(); - m_quickConnect->cancel(); m_prefetch->stop(); - m_api->cancelRequests(); - m_discovery->stop(); + m_provider->shutdown(); m_player->teardownMpv(); } @@ -1541,41 +1303,31 @@ void AppController::showToast(const QString& message) emit toastMessage(message); } -QCoro::Task AppController::applyDiscoveredServersCacheAsync() -{ - const auto servers = co_await m_database->loadDiscoveredServersAsync(); - std::vector parsed; - parsed.reserve(servers.size()); - for (const auto& value : servers) - parsed.push_back(metaFromJson(value.toObject())); - m_discoveredServers.setServers(parsed, false); -} - void AppController::loadLibraries() { m_prefetch->stop(); - Async::runScoped( - this, m_api->fetchLibraries(), + const RequestGeneration::Token generation = m_libraryListGeneration.next(); + Async::runLatest( + this, m_catalog->fetchLibraries(), m_libraryListGeneration, generation, [this](const std::vector& libraries) { m_libraries.setLibraries(libraries); setBusy(false); - m_discovery->stop(); refreshHomeRows(); }, [this](const std::exception_ptr& error) { setBusy(false); - if (!m_session->handleUnauthorized(error)) - setErrorText(exceptionMessage(error)); + setErrorText(exceptionMessage(error)); }); } void AppController::loadLibraryFilterOptions(RequestGeneration::Token generation, const LibraryItem& library) { - if (!m_api || m_api->session().accessToken.isEmpty() || library.id.isEmpty()) + if (!m_catalog->signedIn() || library.id.isEmpty()) return; Async::runLatest( - this, m_api->fetchLibraryFilterOptions(library.id, library.collectionType), m_libraryLoadGeneration, generation, + this, m_catalog->fetchLibraryFilterOptions(library.id, library.collectionType), m_libraryLoadGeneration, + generation, [this, library](const QVariantMap& options) { if (library.id != m_browse->libraryId()) return; @@ -1591,7 +1343,13 @@ void AppController::loadLibraryFilterOptions(RequestGeneration::Token generation void AppController::showCurrentItemsPage(const PagedMovieItems& page, const QString& cacheKey, bool append) { - m_browse->setPage(page, cacheKey, append); + try { + m_browse->setPage(page, cacheKey, append); + } catch (const std::exception& error) { + setBusy(false); + showToast(QString::fromUtf8(error.what())); + return; + } // Keep the warm cache in sync with what the user just saw so the next // open of this library can skip the refresh while the data is fresh. if (!append && page.startIndex == 0 && m_prefetch) @@ -1602,7 +1360,7 @@ void AppController::showCurrentItemsPage(const PagedMovieItems& page, const QStr RequestGeneration::Token AppController::beginBrowse(bool useWarmCache) { const BrowseDescriptor descriptor = m_browse->descriptor(); - if (!descriptor.isValid() || !m_api || m_api->session().accessToken.isEmpty()) + if (!descriptor.isValid() || !m_catalog->signedIn()) return 0; const RequestGeneration::Token generation = m_libraryLoadGeneration.next(); @@ -1621,7 +1379,7 @@ RequestGeneration::Token AppController::beginBrowse(bool useWarmCache) m_artwork->cancelPrefetches(); if (useWarmCache) { const int cachedCount = m_browse->applyCachedPage(cacheKey); - m_browse->setWarmCachePaging(cachedCount, kLibraryPageSize); + m_browse->setLoadingMore(true); if (cachedCount > 0) { const qint64 ageMs = m_prefetch ? m_prefetch->pageAgeMs(cacheKey) : -1; if (ageMs >= 0 && ageMs < kFreshLibraryCacheMs) { @@ -1638,8 +1396,8 @@ RequestGeneration::Token AppController::beginBrowse(bool useWarmCache) } Async::runLatest( - this, m_api->fetchBrowsePage(descriptor, 0, kLibraryPageSize, query), m_libraryLoadGeneration, generation, - [this, cacheKey](const PagedMovieItems& page) { showCurrentItemsPage(page, cacheKey, false); }, + this, m_catalog->fetchBrowsePage(descriptor, 0, kLibraryPageSize, query, std::nullopt), m_libraryLoadGeneration, + generation, [this, cacheKey](const PagedMovieItems& page) { showCurrentItemsPage(page, cacheKey, false); }, [this](const std::exception_ptr& error) { m_browse->setLoadingMore(false); showToast(exceptionMessage(error)); @@ -1656,30 +1414,103 @@ void AppController::openNamedCollection(const QString& kind, const QString& valu beginBrowse(); } +QString AppController::connectionSpeedDescription() const +{ + return m_provider->speedTestDescription(); +} + +void AppController::refreshConnectionSpeed() +{ + m_provider->refreshSpeedTests(); +} + +AppController::SourceAnalysis AppController::typedSourceAnalysis() const +{ + SourceAnalysis analysis { m_activeSourceBitrate, m_activeSourceWidth, m_activeSourceHeight, + std::max(m_activeSourceHeight, m_activeSourceWidth * 9 / 16) }; + const bool needsResolution = analysis.width <= 0 && analysis.height <= 0; + if (analysis.bitrate > 0 && !needsResolution) + return analysis; + + // Resolved original-source dimensions take precedence over cached item + // metadata. Never infer the original from the player's transcoded output. + for (const MediaSourceInfo& source : m_activePlaybackItem.mediaSources) { + if (!m_activeMediaSourceId.isEmpty() && source.id != m_activeMediaSourceId) + continue; + if (analysis.bitrate <= 0) + analysis.bitrate = source.bitRate; + if (needsResolution) { + for (const MediaStreamInfo& stream : source.streams) { + if (stream.type != QStringLiteral("Video")) + continue; + const int qualityHeight = std::max(stream.height, stream.width * 9 / 16); + if (qualityHeight > analysis.qualityHeight) { + analysis.width = stream.width; + analysis.height = stream.height; + analysis.qualityHeight = qualityHeight; + } + } + } + break; + } + return analysis; +} + +QVariantMap AppController::streamingQualitySource() const +{ + const SourceAnalysis source = typedSourceAnalysis(); + return { + { QStringLiteral("active"), m_player->sessionActive() }, + { QStringLiteral("sourceId"), m_activeMediaSourceId }, + { QStringLiteral("bitrate"), source.bitrate }, + { QStringLiteral("width"), source.width }, + { QStringLiteral("height"), source.height }, + { QStringLiteral("qualityHeight"), source.qualityHeight }, + { QStringLiteral("resolution"), StreamQualityControl::describeResolution(source.qualityHeight) }, + { QStringLiteral("playMethod"), m_activePlayMethod }, + }; +} + QVariantList AppController::streamingQualityOptions() const { - const qint64 override = m_api->sessionBitrateOverride(); + const qint64 override = m_quality ? m_quality->bitrateOverride() : m_genericBitrateOverride; + const int heightOverride = m_quality ? m_quality->heightOverride() : m_genericHeightOverride; QVariantList options; options.push_back(QVariantMap { { QStringLiteral("label"), QStringLiteral("Auto") }, - { QStringLiteral("detail"), - PlaybackBandwidthPolicy::describeAuto( - m_api->streamingBitrateSource(), m_api->maxStreamingBitrate(), m_api->playbackParallelRequests()) }, + { QStringLiteral("detail"), m_quality ? m_quality->autoDescription() : QStringLiteral("Direct Play") }, { QStringLiteral("bitrate"), 0 }, - { QStringLiteral("selected"), override <= 0 }, + { QStringLiteral("height"), 0 }, + { QStringLiteral("selected"), override <= 0 && heightOverride <= 0 }, }); - qint64 sourceBitrate = 0; - for (const MediaSourceInfo& source : m_activePlaybackItem.mediaSources) - sourceBitrate = std::max(sourceBitrate, source.bitRate); + const SourceAnalysis source = typedSourceAnalysis(); + const qint64 sourceBitrate = source.bitrate; + const int sourceHeight = source.qualityHeight; - for (const PlaybackBandwidthPolicy::QualityOption& rung : PlaybackBandwidthPolicy::qualityLadder(sourceBitrate)) { + options.push_back(QVariantMap { + { QStringLiteral("label"), QStringLiteral("Original") }, + { QStringLiteral("detail"), + sourceBitrate > 0 ? StreamQualityControl::formatBitrate(sourceBitrate) : QString() }, + { QStringLiteral("bitrate"), 1'000'000'000LL }, + { QStringLiteral("height"), std::max(4320, sourceHeight) }, + { QStringLiteral("selected"), override == 1'000'000'000LL && heightOverride == std::max(4320, sourceHeight) }, + }); + + std::vector rungs; + if (m_quality) + rungs = m_quality->ladder(sourceBitrate, sourceHeight); + if (rungs.empty()) + rungs = StreamQualityControl::defaultLadder(sourceBitrate, sourceHeight); + + for (const StreamQualityControl::Rung& rung : rungs) { options.push_back(QVariantMap { { QStringLiteral("label"), rung.label }, { QStringLiteral("detail"), QString() }, { QStringLiteral("bitrate"), rung.bitrate }, { QStringLiteral("height"), rung.height }, - { QStringLiteral("selected"), override == rung.bitrate }, + { QStringLiteral("selected"), + override == rung.bitrate && (heightOverride == 0 || heightOverride == rung.height) }, }); } return options; @@ -1687,20 +1518,21 @@ QVariantList AppController::streamingQualityOptions() const void AppController::selectStreamingQuality(qint64 bitrate, int height) { - const qint64 previousBitrate = m_api->sessionBitrateOverride(); - const int previousHeight = m_api->sessionHeightOverride(); + const qint64 previousBitrate = m_quality ? m_quality->bitrateOverride() : m_genericBitrateOverride; + const int previousHeight = m_quality ? m_quality->heightOverride() : m_genericHeightOverride; if (previousBitrate == bitrate && previousHeight == height) return; - m_api->setSessionBitrateOverride(bitrate); - // The rung's resolution travels with its bitrate. Without this the server - // was told a ceiling in megabits and nothing about size, so it re-encoded - // at the source resolution and "480p" only ever meant "fewer bits". - m_api->setSessionHeightOverride(height); + if (m_quality) + m_quality->setOverride(bitrate, height); + else { + m_genericBitrateOverride = bitrate; + m_genericHeightOverride = height; + } emit streamingQualityChanged(); - // The server chose direct play or transcoding against the ceiling it was - // given when the stream was negotiated, so a new ceiling only takes effect - // through a fresh negotiation. Resume where the viewer was, keeping the + // The source chose direct play or transcoding against the ceiling it was + // given when the stream was resolved, so a new ceiling only takes effect + // through a fresh resolution. Resume where the viewer was, keeping the // audio and subtitle tracks they had selected. if (!m_player->sessionActive() || m_activePlaybackItem.id.isEmpty()) return; @@ -1710,7 +1542,7 @@ void AppController::selectStreamingQuality(qint64 bitrate, int height) const int subtitleStreamIndex = m_activeSubtitleStreamIndex; m_qualityFallbackBitrate = previousBitrate; m_qualityFallbackHeight = previousHeight; - // Renegotiating is a network round trip. Leaving the old core up for it + // Resolving again is a network round trip. Leaving the old core up for it // keeps the picture on screen until the replacement is ready to start; // play() still tears it down synchronously before the new one begins. setBusy(true, QStringLiteral("Changing quality…")); @@ -1726,27 +1558,30 @@ void AppController::selectStreamingQuality(qint64 bitrate, int height) void AppController::handlePlaybackStopped(const QString& itemId, qint64 positionTicks, bool completed) { qInfo() << "app: playback stopped" << itemId << positionTicks << completed; - m_itemState->recordPlaybackStopped(m_activePlaybackItem, itemId, positionTicks, completed); + const MovieItem successor = completed && itemId == m_activePlaybackItem.id + ? m_playQueue->nextUnplayedEpisode(m_activePlaybackItem) + : MovieItem {}; + m_itemState->recordPlaybackStopped(m_activePlaybackItem, itemId, positionTicks, completed, successor); m_playQueue->updateResumeTicks(itemId, completed ? 0 : positionTicks); - if (!completed || m_activePlaybackItem.id != itemId || (m_syncPlay && m_syncPlay->enabled())) { + if (!completed || m_activePlaybackItem.id != itemId || inGroup()) { setPlaybackTransition(false); return; } bool continueQueue = false; - if (m_remoteRepeatMode == QStringLiteral("RepeatOne")) { + if (m_repeatMode == QStringLiteral("RepeatOne")) { continueQueue = m_playQueue->currentIndex() >= 0; } else { continueQueue = m_playQueue->next(); - if (!continueQueue && m_remoteRepeatMode == QStringLiteral("RepeatAll") && m_playQueue->rowCount() > 0) + if (!continueQueue && m_repeatMode == QStringLiteral("RepeatAll") && m_playQueue->rowCount() > 0) continueQueue = m_playQueue->playAt(0); } if (continueQueue) { // Hold the surface across the gap while the next (or repeated) item is // negotiated and decoded. setPlaybackTransition(true); - playQueueCurrent(m_remoteRepeatMode == QStringLiteral("RepeatOne")); + playQueueCurrent(m_repeatMode == QStringLiteral("RepeatOne")); } else { - setPlaybackTransition(false); + setPlaybackTransition(true); m_playQueue->enqueueEpisodeSuccessors(m_activePlaybackItem); } } @@ -1770,4 +1605,4 @@ void AppController::setPlaybackTransition(bool transition) }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/AppController.h b/src/app/AppController.h index 8d699e47..b79d7b49 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -1,10 +1,8 @@ #pragma once #include "../cache/DatabaseManager.h" -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" -#include "../discovery/DiscoveryController.h" -#include "../models/DiscoveredServerModel.h" +#include "../media/MediaTypes.h" #include "../models/LibraryListModel.h" #include "../models/MovieGridModel.h" #include "../player/PlayQueueController.h" @@ -12,32 +10,38 @@ #include "BrowseSessionController.h" #include "ContentModelController.h" #include "HomeModelController.h" -#include "LibraryManagementController.h" -#include "QuickConnectController.h" -#include "RemoteControlController.h" +#include "RemoteTargetsController.h" #include "SearchController.h" -#include "SessionController.h" #include "SettingsController.h" -#include "SpoolRemoteProtocol.h" -#include "SyncPlayController.h" #include +#include #include #include #include #include #include +#include +#include #include -namespace JellyfinNative { +namespace Spool { class ArtworkService; -class JellyfinApiFacade; +class Catalog; +class GroupPlaybackController; class LibraryPrefetchController; +class PlaybackSource; +class SourceHub; +class SettingsSyncController; +class StreamQualityControl; class UserItemStateController; class TlsTrustController; +// The composition root: it owns the source-agnostic controllers, wires them +// to the player and the queue, and reaches media only through the SourceHub, +// which stands for every enabled account at once. class AppController final : public QObject { Q_OBJECT Q_PROPERTY(bool busy MEMBER m_busy NOTIFY busyChanged) @@ -47,17 +51,22 @@ class AppController final : public QObject { Q_PROPERTY(bool playbackTransition MEMBER m_playbackTransition NOTIFY playbackTransitionChanged) Q_PROPERTY(QString busyText MEMBER m_busyText NOTIFY busyChanged) Q_PROPERTY(QString errorText MEMBER m_errorText NOTIFY errorTextChanged) - Q_PROPERTY(bool hasDefaultProfile MEMBER m_hasDefaultProfile NOTIFY defaultProfileChanged) Q_PROPERTY(bool initialized READ initialized NOTIFY initializedChanged) + Q_PROPERTY(QString connectionSpeedDescription READ connectionSpeedDescription NOTIFY streamingQualityChanged) public: - AppController(DatabaseManager *database, DiscoveryController *discovery, JellyfinApiFacade *api, - ArtworkService *artwork, PlayerController *player, TlsTrustController *tlsTrust, QObject *parent = nullptr); - - DiscoveredServerModel *discoveredServers() + AppController(DatabaseManager *database, SourceHub *provider, ArtworkService *artwork, PlayerController *player, + QObject *parent = nullptr); + void attachSettingsSync(SettingsSyncController *sync) + { + m_settingsSync = sync; + } + void attachRemoteTargets(RemoteTargetsController *targets) { - return &m_discoveredServers; + m_remoteTargets = targets; } + void revokeAccountIdentity(const QString& accountId); + ArtworkService *artwork() const { return m_artwork; @@ -94,47 +103,31 @@ class AppController final : public QObject { { return m_playQueue; } - SyncPlayController *syncPlay() - { - return m_syncPlay; - } - RemoteControlController *remoteControl() - { - return m_remoteControl; - } SettingsController *settings() { return m_settings; } - bool initialized() const + GroupPlaybackController *group() { - return m_initialized; - } - SessionController *session() - { - return m_session; - } - QuickConnectController *quickConnect() - { - return m_quickConnect; + return m_group; } - LibraryManagementController *management() + bool initialized() const { - return m_management; + return m_initialized; } Q_INVOKABLE void initialize(); void shutdown(); - Q_INVOKABLE void chooseDiscoveredServer(int index); - Q_INVOKABLE void rememberServer(const QString& name, const QString& address); - Q_INVOKABLE void useProfile(const QString& profileId); - Q_INVOKABLE void switchUser(); - Q_INVOKABLE void logout(); Q_INVOKABLE void goHome(); Q_INVOKABLE void openLibrary(int index); Q_INVOKABLE bool openLibraryById(const QString& libraryId); Q_INVOKABLE void playFromModel(QObject *model, int index, bool fromStart = false); Q_INVOKABLE void playItemId(const QString& itemId, bool fromStart = false); + // Explicit local launch path: inbound/CLI playback must never be relayed. + void playLocalItemId(const QString& itemId, bool fromStart = false); + // Validated same-user automation; shares group/local semantics with inbound controls. + bool handleLocalControl(const QVariantMap& command); + Q_INVOKABLE void transferPlaybackToRemote(); void stopPlayback(); Q_INVOKABLE void playQueueNext(); Q_INVOKABLE void playQueuePrevious(); @@ -144,10 +137,6 @@ class AppController final : public QObject { // drag that never passed a guard. A reorder gesture previews locally on // every step — waiting on a round trip per step would make a held D-pad key // unusable — and publishes once, on drop. - Q_INVOKABLE bool queueEditable() const; - Q_INVOKABLE bool previewQueueMove(int from, int to); - Q_INVOKABLE void commitQueueMove(int from, int to); - // The same pair for a folded run dragged as one block. Q_INVOKABLE bool previewQueueMoveRange(int from, int count, int to); Q_INVOKABLE void commitQueueMoveRange(int from, int count, int to); Q_INVOKABLE void removeQueueItem(int index); @@ -171,21 +160,24 @@ class AppController final : public QObject { // the current position because the server decides direct play against the // ceiling it was handed at negotiation time. Q_INVOKABLE QVariantList streamingQualityOptions() const; + QVariantMap streamingQualitySource() const; Q_INVOKABLE void selectStreamingQuality(qint64 bitrate, int height = 0); + QString connectionSpeedDescription() const; + Q_INVOKABLE void refreshConnectionSpeed(); signals: void busyChanged(); void playbackTransitionChanged(); void streamingQualityChanged(); void errorTextChanged(); - void defaultProfileChanged(); void initializedChanged(); void aggressiveMemoryPressure(); void toastMessage(const QString& message); void remoteUiActionRequested(const QString& action); void remoteMessageRequested(const QString& message); void remoteContentRequested(const QString& itemId, const QString& itemType, const QString& title); - void remoteSeekPreviewRequested(qint64 positionTicks, bool active); + // The stored or per-instance device identity is known; providers start. + void deviceIdentityReady(const QString& deviceId); void clearLogsRequested(); void diagnosticsReportSaved(const QString& path); @@ -196,8 +188,7 @@ class AppController final : public QObject { void showToast(const QString& message); QCoro::Task initializeAsync(); void resetApplicationState(); - QCoro::Task applyDiscoveredServersCacheAsync(); - void cacheDiscoveredServers(); + void resetVisibleModels(); void loadLibraries(); void loadMoreCurrentItems(); void refreshHomeRows() @@ -209,65 +200,90 @@ class AppController final : public QObject { RequestGeneration::Token beginBrowse(bool useWarmCache = false); QCoro::Task startPlayback(MovieItem playItem, bool startPaused = false, bool forceTranscode = false, int audioStreamIndex = -2, int subtitleStreamIndex = -2, bool restartActive = false); - void playQueuedItems(const std::vector& items, int startIndex, bool fromStart = false); + // nullopt is an explicitly local internal operation. A user operation captures + // even the local selection, so a later target change invalidates its lookup. + using PlayDestination = std::optional; + PlayDestination userPlayDestination() const; + bool destinationIsCurrent(const PlayDestination& destination) const; + bool dispatchRemotePlay(const PlayDestination& destination, std::span items, int startIndex, + bool fromStart, const QString& mode = QStringLiteral("now"), + std::optional positionTicks = std::nullopt); + void fetchPlayItem(const QString& itemId, bool fromStart, PlayDestination destination); + void playQueuedItems( + const std::vector& items, int startIndex, bool fromStart, PlayDestination destination); bool modelIsOrderedList(MovieGridModel *model) const; - void playAlbumFrom(const MovieItem& track, bool fromStart); - bool inSyncPlayGroup() const; + void playAlbumFrom(const MovieItem& track, bool fromStart, PlayDestination destination); + bool inGroup() const; + void groupOrLocalTogglePause(); void setPlaybackTransition(bool transition); - QString queuePlaylistItemId(int index) const; + QString queueEntryId(int index) const; bool enqueueForGroup(const MovieItem& item, bool queueNext); - void playQueuedItem(const MovieItem& item, bool fromStart = false); - void playEpisodeWithContext(const MovieItem& episode, int direction, bool fromStart); + void playQueuedItem(const MovieItem& item, bool fromStart, PlayDestination destination); + void playEpisodeWithContext(const MovieItem& episode, int direction, bool fromStart, PlayDestination destination); + void playLocalQueueNext(PlayDestination destination); + void playLocalQueuePrevious(PlayDestination destination); void playQueueCurrent(bool fromStart = false); void startQueuedPlayback(bool fromStart = false); - void handleRemotePlay(const QJsonObject& data); - void handleSpoolMessage(const SpoolRemoteProtocol::Message& message); - void handleRemotePlaystate(const QJsonObject& data); - void handleRemoteGeneralCommand(const QJsonObject& data); + // AppControllerRemote.cpp: commands another client sent through a source. + void handleRemoteCommand(const QString& accountId, const QVariantMap& command); + void playRemoteItems(const QString& accountId, const QVariantMap& command); // Folder-like containers open their child listing; everything else plays directly. void playOrOpen(const MovieItem& item, bool fromStart = false); void handlePlaybackStopped(const QString& itemId, qint64 positionTicks, bool completed); + struct SourceAnalysis { + qint64 bitrate = 0; + int width = 0; + int height = 0; + int qualityHeight = 0; + }; + SourceAnalysis typedSourceAnalysis() const; + DatabaseManager *m_database = nullptr; - DiscoveryController *m_discovery = nullptr; - JellyfinApiFacade *m_api = nullptr; + SourceHub *m_provider = nullptr; + Catalog *m_catalog = nullptr; + PlaybackSource *m_playback = nullptr; + // Null when the provider lacks the matching capability. + StreamQualityControl *m_quality = nullptr; + GroupPlaybackController *m_group = nullptr; + RemoteTargetsController *m_remoteTargets = nullptr; ArtworkService *m_artwork = nullptr; PlayerController *m_player = nullptr; - SyncPlayController *m_syncPlay = nullptr; - RemoteControlController *m_remoteControl = nullptr; PlayQueueController *m_playQueue = nullptr; ContentModelController *m_content = nullptr; BrowseSessionController *m_browse = nullptr; HomeModelController *m_home = nullptr; - QuickConnectController *m_quickConnect = nullptr; SettingsController *m_settings = nullptr; - SessionController *m_session = nullptr; + SettingsSyncController *m_settingsSync = nullptr; LibraryPrefetchController *m_prefetch = nullptr; UserItemStateController *m_itemState = nullptr; SearchController *m_search = nullptr; quint64 m_episodeQueueGeneration = 0; quint64 m_albumQueueGeneration = 0; bool m_episodeQueuePending = false; - LibraryManagementController *m_management = nullptr; - DiscoveredServerModel m_discoveredServers; LibraryListModel m_libraries; MovieItem m_activePlaybackItem; + QString m_activeMediaSourceId; + qint64 m_activeSourceBitrate = 0; + int m_activeSourceHeight = 0; + int m_activeSourceWidth = 0; + QString m_activePlayMethod; + QString m_playingAccountId; QList m_activePlaybackStreams; int m_activeAudioStreamIndex = -1; int m_activeSubtitleStreamIndex = -1; bool m_busy = false; bool m_playbackTransition = false; quint64 m_playbackTransitionGeneration = 0; - bool m_hasDefaultProfile = false; bool m_initialized = false; QString m_busyText; QString m_errorText; - QString m_remoteRepeatMode = QStringLiteral("RepeatNone"); + QString m_repeatMode = QStringLiteral("RepeatNone"); RequestGeneration m_libraryLoadGeneration; + RequestGeneration m_libraryListGeneration; RequestGeneration m_playbackLoadGeneration; RequestGeneration m_syncPlayQueueRequestGeneration; RequestGeneration m_remotePlaybackRequestGeneration; - QString m_remotePlaybackFingerprint; bool m_shuttingDown = false; bool m_codecFallbackAttempted = false; // The ceiling a quality change moved away from, kept only until that @@ -275,9 +291,11 @@ class AppController final : public QObject { // one that was playing instead of ending playback. qint64 m_qualityFallbackBitrate = -1; int m_qualityFallbackHeight = 0; + qint64 m_genericBitrateOverride = 0; + int m_genericHeightOverride = 0; // Held for the process lifetime so a second local instance picks the next // slot and reports a device identity of its own. std::unique_ptr m_instanceLock; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/AppControllerRemote.cpp b/src/app/AppControllerRemote.cpp new file mode 100644 index 00000000..041c2d94 --- /dev/null +++ b/src/app/AppControllerRemote.cpp @@ -0,0 +1,274 @@ +// Remote commands and group playback glue for AppController, kept apart from +// the browsing and queue logic in AppController.cpp. +#include "AppController.h" + +#include "../common/AsyncTask.h" +#include "../player/PlayQueueController.h" +#include "../player/PlayerController.h" +#include "../provider/SourceHub.h" +#include "GroupPlaybackController.h" + +#include +#include +#include +#include +#include +#include + +namespace Spool { + +AppController::PlayDestination AppController::userPlayDestination() const +{ + return m_remoteTargets ? PlayDestination(m_remoteTargets->selection()) : std::nullopt; +} + +bool AppController::destinationIsCurrent(const PlayDestination& destination) const +{ + return !destination || (m_remoteTargets && m_remoteTargets->isCurrent(*destination)); +} + +// Returns true when the operation belongs to a remote (including failure), or +// its captured destination expired. Neither case may fall through to local play. +bool AppController::dispatchRemotePlay(const PlayDestination& destination, std::span items, + int startIndex, bool fromStart, const QString& mode, std::optional positionTicks) +{ + if (!destinationIsCurrent(destination)) + return true; + if (!destination || destination->targetId.isEmpty()) + return false; + if (startIndex < 0 || startIndex >= static_cast(items.size()) || items[size_t(startIndex)].id.isEmpty() + || !isPlayableItem(items[size_t(startIndex)])) { + setBusy(false); + showToast(QStringLiteral("This item cannot be queued.")); + return true; + } + QStringList ids; + ids.reserve(static_cast(items.size())); + int index = -1; + for (size_t row = 0; row < items.size(); ++row) { + const auto& item = items[row]; + if (item.id.isEmpty() || !isPlayableItem(item)) + continue; + if (row == size_t(startIndex)) + index = ids.size(); + ids.append(item.id); + } + const MovieItem& item = items[size_t(startIndex)]; + const qint64 ticks = positionTicks.value_or( + fromStart || !isMeaningfulResumePosition(item.resumeTicks, item.runtimeTicks) ? 0 : item.resumeTicks); + const auto selection = *destination; + const bool startsPlayback = mode == QStringLiteral("now") || mode == QStringLiteral("shuffle"); + // The remote controller owns command busy/error state. Do not leave the + // local loading indicator behind when a selected target disappears. + setBusy(false); + Async::runScoped( + this, m_remoteTargets->play(selection, ids, index, QString::number(ticks), mode), + [this, selection, startsPlayback](bool accepted) { + if (!m_remoteTargets || !m_remoteTargets->isCurrent(selection)) + return; + if (accepted && startsPlayback) + stopPlayback(); + }, + [this, selection](const std::exception_ptr& error) { + if (!m_remoteTargets || !m_remoteTargets->isCurrent(selection)) + return; + showToast(exceptionMessage(error)); + }, + "outbound remote playback"); + return true; +} + +void AppController::transferPlaybackToRemote() +{ + const auto destination = userPlayDestination(); + if (!destination || destination->targetId.isEmpty() || !m_player->sessionActive()) + return; + std::vector items; + items.reserve(size_t(m_playQueue->count())); + for (int row = 0; row < m_playQueue->count(); ++row) + items.push_back(m_playQueue->itemAt(row)); + dispatchRemotePlay(destination, items, m_playQueue->currentIndex(), false, QStringLiteral("now"), + std::max(0, qint64(m_player->positionSeconds() * 10'000'000.0))); +} + +bool AppController::inGroup() const +{ + return m_group && m_group->enabled(); +} + +void AppController::groupOrLocalTogglePause() +{ + if (inGroup()) + m_group->requestTogglePause(); + else + m_player->togglePause(); +} + +QString AppController::queueEntryId(int index) const +{ + return m_playQueue->itemAt(index).playlistItemId; +} + +bool AppController::enqueueForGroup(const MovieItem& item, bool queueNext) +{ + if (!inGroup()) + return false; + if (item.id.isEmpty() || !item.isPlayable()) { + setErrorText(QStringLiteral("This item cannot be queued.")); + return true; + } + m_group->requestQueueItems({ item.id }, queueNext); + return true; +} + +// Commands arrive already translated by the provider into one vocabulary +// (see sdk/provider.d.ts, RemoteCommand), so any server's "play this on that +// device" lands here the same way. +void AppController::handleRemoteCommand(const QString& accountId, const QVariantMap& command) +{ + const QString name = command.value(QStringLiteral("command")).toString(); + const auto seconds + = [&command] { return command.value(QStringLiteral("positionTicks")).toLongLong() / 10'000'000.0; }; + qInfo() << "remote:" << name; + if (name == QStringLiteral("play")) { + playRemoteItems(accountId, command); + } else if (name == QStringLiteral("pause") || name == QStringLiteral("unpause")) { + if (m_player->paused() == (name == QStringLiteral("unpause"))) + groupOrLocalTogglePause(); + } else if (name == QStringLiteral("playPause")) { + groupOrLocalTogglePause(); + } else if (name == QStringLiteral("stop")) { + m_player->stopWithReason(QStringLiteral("remote-stop")); + } else if (name == QStringLiteral("seek")) { + inGroup() ? m_group->requestSeek(seconds()) : m_player->seek(seconds()); + } else if (name == QStringLiteral("rewind")) { + inGroup() ? m_group->requestRelativeSeek(-10.0) : m_player->seekBack(); + } else if (name == QStringLiteral("fastForward")) { + inGroup() ? m_group->requestRelativeSeek(10.0) : m_player->seekForward(); + } else if (name == QStringLiteral("next")) { + playLocalQueueNext(std::nullopt); + } else if (name == QStringLiteral("previous")) { + playLocalQueuePrevious(std::nullopt); + } else if (name == QStringLiteral("volume")) { + m_player->setVolume(command.value(QStringLiteral("value"), m_player->volume()).toInt()); + } else if (name == QStringLiteral("volumeStep")) { + m_player->adjustVolume(command.value(QStringLiteral("delta")).toInt()); + } else if (name == QStringLiteral("mute")) { + m_player->setMuted(command.value(QStringLiteral("value"), true).toBool()); + } else if (name == QStringLiteral("toggleMute")) { + m_player->toggleMuted(); + } else if (name == QStringLiteral("audioTrack")) { + m_player->selectAudioStreamIndex(command.value(QStringLiteral("index"), -1).toInt()); + } else if (name == QStringLiteral("subtitleTrack")) { + m_player->selectSubtitleStreamIndex(command.value(QStringLiteral("index"), -1).toInt()); + } else if (name == QStringLiteral("repeat")) { + const QString mode = command.value(QStringLiteral("mode")).toString(); + if (mode == QStringLiteral("RepeatNone") || mode == QStringLiteral("RepeatAll") + || mode == QStringLiteral("RepeatOne")) + m_repeatMode = mode; + } else if (name == QStringLiteral("shuffle")) { + m_playQueue->setShuffled(command.value(QStringLiteral("value")).toBool()); + } else if (name == QStringLiteral("quality")) { + selectStreamingQuality( + command.value(QStringLiteral("bitrate")).toLongLong(), command.value(QStringLiteral("height")).toInt()); + } else if (name == QStringLiteral("stats")) { + m_player->toggleDebugOsd(); + } else if (name == QStringLiteral("navigate")) { + // home, search, settings, osd, menu or fullscreen: the shell's to do. + emit remoteUiActionRequested(command.value(QStringLiteral("to")).toString()); + } else if (name == QStringLiteral("show")) { + const QString itemId = command.value(QStringLiteral("itemId")).toString(); + if (!itemId.isEmpty()) + emit remoteContentRequested(m_provider->scoped(accountId, itemId), + command.value(QStringLiteral("itemType")).toString(), + command.value(QStringLiteral("title")).toString()); + } else if (name == QStringLiteral("message")) { + const QString text = command.value(QStringLiteral("text")).toString().trimmed(); + if (!text.isEmpty()) + emit remoteMessageRequested(text); + } else if (name == QStringLiteral("text")) { + const QString text = command.value(QStringLiteral("value")).toString(); + if (QObject *focus = QGuiApplication::focusObject(); focus && !text.isEmpty()) { + QInputMethodEvent input; + input.setCommitString(text); + QCoreApplication::sendEvent(focus, &input); + } + } else if (name == QStringLiteral("key")) { + static const QHash keys { { QStringLiteral("up"), Qt::Key_Up }, + { QStringLiteral("down"), Qt::Key_Down }, { QStringLiteral("left"), Qt::Key_Left }, + { QStringLiteral("right"), Qt::Key_Right }, { QStringLiteral("pageUp"), Qt::Key_PageUp }, + { QStringLiteral("pageDown"), Qt::Key_PageDown }, { QStringLiteral("select"), Qt::Key_Return }, + { QStringLiteral("back"), Qt::Key_Back }, { QStringLiteral("home"), Qt::Key_Home }, + { QStringLiteral("end"), Qt::Key_End }, { QStringLiteral("space"), Qt::Key_Space } }; + const auto key = keys.constFind(command.value(QStringLiteral("name")).toString()); + if (key != keys.cend() && QGuiApplication::focusWindow()) { + QKeyEvent press(QEvent::KeyPress, *key, Qt::NoModifier); + QKeyEvent release(QEvent::KeyRelease, *key, Qt::NoModifier); + QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &press); + QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &release); + } + } +} + +bool AppController::handleLocalControl(const QVariantMap& command) +{ + const QString name = command.value(QStringLiteral("command")).toString(); + if (name == QStringLiteral("play")) { + const QString id = command.value(QStringLiteral("itemId")).toString(); + if (m_provider->accountOf(id).isEmpty() || SourceHub::rawId(id).isEmpty()) + return false; + playLocalItemId(id, command.value(QStringLiteral("fromStart")).toBool()); + return true; + } + if (name != QStringLiteral("pause") && name != QStringLiteral("unpause") && name != QStringLiteral("stop") + && name != QStringLiteral("seek") && name != QStringLiteral("navigate") && name != QStringLiteral("key")) + return false; + handleRemoteCommand({}, command); + return true; +} + +void AppController::playRemoteItems(const QString& accountId, const QVariantMap& command) +{ + QStringList itemIds; + for (const QVariant& id : command.value(QStringLiteral("itemIds")).toList()) + itemIds.append(m_provider->scoped(accountId, id.toString())); + itemIds.removeAll(QString()); + if (itemIds.isEmpty()) + return; + const QString mode = command.value(QStringLiteral("mode"), QStringLiteral("now")).toString(); + const int requestedIndex = command.value(QStringLiteral("index")).toInt(); + const qint64 startTicks = command.value(QStringLiteral("positionTicks")).toLongLong(); + const RequestGeneration::Token generation = m_remotePlaybackRequestGeneration.next(); + setBusy(true, QStringLiteral("Starting playback…")); + Async::runScoped( + this, m_catalog->fetchItemsByIds(itemIds), + [this, generation, mode, requestedIndex, startTicks](std::vector items) { + if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) + return; + setBusy(false); + if (items.empty()) { + showToast(QStringLiteral("That item isn't available here.")); + return; + } + if (mode == QStringLiteral("next") || mode == QStringLiteral("last")) { + m_playQueue->addToQueue(items, mode == QStringLiteral("next")); + return; + } + const int index = std::clamp(requestedIndex, 0, static_cast(items.size()) - 1); + items[size_t(index)].resumeTicks = std::max(0, startTicks); + if (!m_playQueue->playNow(items, index)) + return; + if (mode == QStringLiteral("shuffle")) + m_playQueue->setShuffled(true); + startQueuedPlayback(startTicks <= 0); + }, + [this, generation](const std::exception_ptr& error) { + if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) + return; + setBusy(false); + showToast(exceptionMessage(error)); + }, + "remote playback"); +} + +} // namespace Spool diff --git a/src/app/ArtworkImageProvider.h b/src/app/ArtworkImageProvider.h index 541bb542..403bc133 100644 --- a/src/app/ArtworkImageProvider.h +++ b/src/app/ArtworkImageProvider.h @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { class ArtworkImageProvider final : public QQuickAsyncImageProvider { public: @@ -16,4 +16,4 @@ class ArtworkImageProvider final : public QQuickAsyncImageProvider { ArtworkService *m_service = nullptr; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ArtworkPrefetcher.h b/src/app/ArtworkPrefetcher.h index 085bd370..32f487e1 100644 --- a/src/app/ArtworkPrefetcher.h +++ b/src/app/ArtworkPrefetcher.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct MovieItem; class ArtworkPrefetcher { @@ -14,4 +14,4 @@ class ArtworkPrefetcher { virtual QString itemUrl(const MovieItem& item, bool landscape, int width = 0) const = 0; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ArtworkService.cpp b/src/app/ArtworkService.cpp index b414cbc6..8c8b15c1 100644 --- a/src/app/ArtworkService.cpp +++ b/src/app/ArtworkService.cpp @@ -2,11 +2,15 @@ #include "../common/TlsTrust.h" #include "../platform/PlatformSettingsPolicy.h" #include "ArtworkImageProvider.h" +#include "LocalThumbnail.h" #include +#include +#include #include #include #include +#include #include #include #include @@ -25,7 +29,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -49,7 +53,7 @@ namespace { return sourceSize.scaled(requestedSize, Qt::KeepAspectRatioByExpanding); } -#if defined(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS) +#if defined(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS) bool shouldLogAspectDiagnostic(const QSize& source, const QSize& requested, const QSize& decoded) { if (!source.isValid() || !requested.isValid() || !decoded.isValid() || source.width() <= 0 @@ -83,7 +87,7 @@ namespace { reader.setAutoTransform(false); const QSize scaledSize = decodeSizeForRequest(reader.size(), requestedSize); if (scaledSize.isValid()) { -#if defined(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS) +#if defined(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS) if (shouldLogAspectDiagnostic(reader.size(), requestedSize, scaledSize)) { qWarning() << "artwork: aspect-preserving decode" << "source=" << reader.size() << "requested=" << requestedSize << "decode=" << scaledSize; @@ -115,15 +119,14 @@ class ArtworkFetchWorker final : public QObject { { } - void fetchRender(int requestId, QUrl url) + void fetchRender(int requestId, QUrl url, QString key, QByteArray headers) { ensureNetwork(); if (!m_network || !url.isValid() || url.scheme().isEmpty()) { - deliverRender(requestId, cacheKeyForUrl(url), {}, QStringLiteral("Invalid artwork URL"), false); + deliverRender(requestId, key, {}, QStringLiteral("Invalid artwork URL"), false); return; } - - m_renderQueue.enqueue({ requestId, std::move(url), monotonicNs() }); + m_renderQueue.enqueue({ requestId, std::move(url), monotonicNs(), std::move(key), std::move(headers) }); drainRender(); } @@ -167,11 +170,6 @@ class ArtworkFetchWorker final : public QObject { } } - void setAuthorizationHeader(QString header) - { - m_authorizationHeader = header.toUtf8(); - } - void cancelAll() { m_renderQueue.clear(); @@ -188,6 +186,8 @@ class ArtworkFetchWorker final : public QObject { int requestId = 0; QUrl url; qint64 queuedNs = 0; + QString key; + QByteArray headers; }; void ensureNetwork() @@ -206,12 +206,26 @@ class ArtworkFetchWorker final : public QObject { } } - QNetworkRequest cachedRequest(const QUrl& url) const + QNetworkRequest cachedRequest(const QUrl& url, const QByteArray& headers = {}) const { QNetworkRequest request(url); - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache); - if (!m_authorizationHeader.isEmpty()) - request.setRawHeader("Authorization", m_authorizationHeader); + if (headers.isEmpty()) { + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache); + return request; + } + // Qt's disk cache is keyed only by URL, not account/Authorization. + // Protected previews use the credential-partitioned memory cache and + // must never forward credentials to a redirected foreign origin. + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::SameOriginRedirectPolicy); + request.setAttribute(QNetworkRequest::CookieLoadControlAttribute, QNetworkRequest::Manual); + request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); + for (const QByteArray& line : headers.split('\n')) { + const auto colon = line.indexOf(':'); + if (colon > 0) + request.setRawHeader(line.left(colon), line.mid(colon + 1).trimmed()); + } return request; } @@ -221,9 +235,9 @@ class ArtworkFetchWorker final : public QObject { while (m_network && m_renderReplies.size() < kRenderConcurrency && !m_renderQueue.isEmpty()) { const RenderRequest request = m_renderQueue.dequeue(); const qint64 fetchStartedNs = monotonicNs(); - QNetworkReply *reply = m_network->get(cachedRequest(request.url)); + QNetworkReply *reply = m_network->get(cachedRequest(request.url, request.headers)); m_renderReplies.insert(request.requestId, reply); - const QString key = cacheKeyForUrl(request.url); + const QString key = request.key; connect(reply, &QNetworkReply::finished, this, [this, reply, requestId = request.requestId, key, queuedNs = request.queuedNs, fetchStartedNs]() { m_renderReplies.remove(requestId); @@ -312,7 +326,6 @@ class ArtworkFetchWorker final : public QObject { qint64 m_networkCacheBytes = 0; QPointer m_service; TlsTrustController *m_tlsTrust = nullptr; - QByteArray m_authorizationHeader; QNetworkAccessManager *m_network = nullptr; QQueue m_renderQueue; QHash m_renderReplies; @@ -328,11 +341,24 @@ class ArtworkImageResponse final : public QQuickImageResponse { : m_service(service) , m_requestedSize(std::move(requestedSize)) { - if (!m_service) { - finish({}, QStringLiteral("Artwork service unavailable")); - return; - } - m_requestId = m_service->requestImage(std::move(url), m_requestedSize, this); + const QPointer self(this); + QObject *context = service ? static_cast(service) : QCoreApplication::instance(); + if (context && thread() != context->thread()) + moveToThread(context->thread()); + // Source/account resolution and request bookkeeping belong to the GUI + // thread. Deferring also lets Qt connect finished() before any error. + QMetaObject::invokeMethod( + context, + [self, url = std::move(url)]() mutable { + if (!self) + return; + if (!self->m_service || self->cancelled()) { + self->finish({}, QStringLiteral("Artwork request unavailable")); + return; + } + self->m_requestId = self->m_service->requestImage(std::move(url), self->m_requestedSize, self); + }, + Qt::QueuedConnection); } ~ArtworkImageResponse() override = default; @@ -350,8 +376,18 @@ class ArtworkImageResponse final : public QQuickImageResponse { void cancel() override { m_cancelled.store(true); - if (m_service && m_requestId > 0) - m_service->cancelRequest(m_requestId); + const QPointer self(this); + QObject *context = m_service ? static_cast(m_service.data()) : QCoreApplication::instance(); + QMetaObject::invokeMethod( + context, + [self] { + if (!self) + return; + if (self->m_service && self->m_requestId > 0) + self->m_service->cancelRequest(self->m_requestId); + self->finish({}, QStringLiteral("Cancelled")); + }, + Qt::QueuedConnection); } bool cancelled() const @@ -368,15 +404,10 @@ class ArtworkImageResponse final : public QQuickImageResponse { { if (m_finished.exchange(true)) return; - if (m_cancelled.load() && error.isEmpty()) { - m_error = QStringLiteral("Cancelled"); - } else if (image.isNull() && !error.isEmpty()) { - m_image = QImage(1, 1, QImage::Format_ARGB32_Premultiplied); - m_image.fill(Qt::transparent); - } else { - m_image = std::move(image); - m_error = std::move(error); - } + if (m_cancelled.load() && error.isEmpty()) + error = QStringLiteral("Cancelled"); + m_image = std::move(image); + m_error = std::move(error); emit finished(); } @@ -427,6 +458,8 @@ ArtworkService::ArtworkService(QString cacheDirectory, qint64 networkCacheBytes, { m_decodePool.setMaxThreadCount(std::max(1, decodeThreads)); m_decodePool.setExpiryTimeout(30000); + m_thumbnailPool.setMaxThreadCount(1); + m_thumbnailPool.setExpiryTimeout(1000); m_timingBatchTimer.setSingleShot(true); m_timingBatchTimer.setInterval(120); connect(&m_timingBatchTimer, &QTimer::timeout, this, &ArtworkService::flushTimingBatch); @@ -452,10 +485,13 @@ ArtworkService::~ArtworkService() m_workerThread.quit(); m_workerThread.wait(); m_decodePool.waitForDone(); + m_thumbnailPool.waitForDone(); } QString ArtworkService::url(const QVariant& value, const QString& kind, int width) const { + if (value.metaType() == QMetaType::fromType()) + return url(value.value().toVariant(), kind, width); if (value.canConvert()) return movieUrl(value.value(), kind, width); if (value.metaType().id() == QMetaType::QVariantMap) { @@ -465,6 +501,14 @@ QString ArtworkService::url(const QVariant& value, const QString& kind, int widt item.posterTag = map.value(QStringLiteral("posterTag")).toString(); item.albumId = map.value(QStringLiteral("albumId")).toString(); item.albumPrimaryImageTag = map.value(QStringLiteral("albumPrimaryImageTag")).toString(); + item.seriesId = map.value(QStringLiteral("seriesId")).toString(); + item.seriesPrimaryImageTag = map.value(QStringLiteral("seriesPrimaryImageTag")).toString(); + item.thumbTag = map.value(QStringLiteral("thumbTag")).toString(); + item.thumbItemId = map.value(QStringLiteral("thumbItemId")).toString(); + item.backdropTag = map.value(QStringLiteral("backdropTag")).toString(); + item.backdropItemId = map.value(QStringLiteral("backdropItemId")).toString(); + item.logoTag = map.value(QStringLiteral("logoTag")).toString(); + item.bannerTag = map.value(QStringLiteral("bannerTag")).toString(); return movieUrl(item, kind, width); } if (value.canConvert()) { @@ -483,11 +527,9 @@ QString ArtworkService::itemUrl(const MovieItem& item, bool landscape, int width return movieUrl(item, landscape ? QStringLiteral("landscape") : QStringLiteral("poster"), width); } -void ArtworkService::setServerUrl(QString serverUrl) +void ArtworkService::setSource(const ArtworkSource *source) { - while (serverUrl.endsWith(QLatin1Char('/'))) - serverUrl.chop(1); - m_serverUrl = std::move(serverUrl); + m_source = source; } void ArtworkService::setUiWidth(int width) @@ -547,9 +589,11 @@ QString ArtworkService::movieUrl(const MovieItem& item, const QString& kind, int } else if (kind == QStringLiteral("landscape")) { if (!item.thumbTag.isEmpty()) { tag = item.thumbTag; + itemId = item.thumbItemId.isEmpty() ? item.id : item.thumbItemId; imageType = QStringLiteral("Thumb"); } else if (!item.backdropTag.isEmpty()) { tag = item.backdropTag; + itemId = item.backdropItemId.isEmpty() ? item.id : item.backdropItemId; imageType = QStringLiteral("Backdrop"); } else if (tag.isEmpty() && !item.seriesId.isEmpty()) { itemId = item.seriesId; @@ -564,9 +608,11 @@ QString ArtworkService::movieUrl(const MovieItem& item, const QString& kind, int } else if (kind == QStringLiteral("backdrop")) { if (!item.backdropTag.isEmpty()) { tag = item.backdropTag; + itemId = item.backdropItemId.isEmpty() ? item.id : item.backdropItemId; imageType = QStringLiteral("Backdrop"); } else if (!item.thumbTag.isEmpty()) { tag = item.thumbTag; + itemId = item.thumbItemId.isEmpty() ? item.id : item.thumbItemId; imageType = QStringLiteral("Thumb"); } maxWidth = width > 0 ? width : 1920; @@ -586,6 +632,7 @@ QString ArtworkService::movieUrl(const MovieItem& item, const QString& kind, int qualityOffset = 11; } else if (kind == QStringLiteral("thumb")) { tag = item.thumbTag; + itemId = item.thumbItemId.isEmpty() ? item.id : item.thumbItemId; imageType = QStringLiteral("Thumb"); maxWidth = width > 0 ? width : 720; qualityOffset = 7; @@ -596,36 +643,33 @@ QString ArtworkService::movieUrl(const MovieItem& item, const QString& kind, int QString ArtworkService::buildLosslessUrl( const QString& itemId, const QString& tag, const QString& imageType, int maxWidth) const { - if (m_serverUrl.isEmpty() || itemId.isEmpty() || tag.isEmpty() || imageType.isEmpty()) + if (!m_source) return {}; - QUrl url = serverUrlWithPath(m_serverUrl, { QStringLiteral("Items"), itemId, QStringLiteral("Images"), imageType }); - QUrlQuery query; - query.addQueryItem(QStringLiteral("maxWidth"), QString::number(maxWidth)); - query.addQueryItem(QStringLiteral("quality"), QStringLiteral("90")); - query.addQueryItem(QStringLiteral("format"), QStringLiteral("png")); - query.addQueryItem(QStringLiteral("tag"), tag); - url.setQuery(query); - return url.toString(QUrl::FullyEncoded); + ArtworkSource::ImageRequest request; + request.itemId = itemId; + request.tag = tag; + request.imageType = imageType; + request.maxWidth = maxWidth; + request.format = QStringLiteral("png"); + request.quality = 90; + return m_source->imageUrl(request); } QString ArtworkService::buildUrl(const QString& itemId, const QString& tag, const QString& imageType, int maxWidth, int qualityOffset, int fillWidth, int fillHeight) const { - if (m_serverUrl.isEmpty() || itemId.isEmpty() || tag.isEmpty() || imageType.isEmpty()) + if (!m_source) return {}; - QUrl url = serverUrlWithPath(m_serverUrl, { QStringLiteral("Items"), itemId, QStringLiteral("Images"), imageType }); - QUrlQuery query; - if (fillWidth > 0 && fillHeight > 0) { - query.addQueryItem(QStringLiteral("fillWidth"), QString::number(fillWidth)); - query.addQueryItem(QStringLiteral("fillHeight"), QString::number(fillHeight)); - } else { - query.addQueryItem(QStringLiteral("maxWidth"), QString::number(maxWidth)); - } - query.addQueryItem(QStringLiteral("quality"), QString::number(qualityForOffset(qualityOffset))); - query.addQueryItem(QStringLiteral("format"), artworkFormat()); - query.addQueryItem(QStringLiteral("tag"), tag); - url.setQuery(query); - return url.toString(QUrl::FullyEncoded); + ArtworkSource::ImageRequest request; + request.itemId = itemId; + request.tag = tag; + request.imageType = imageType; + request.maxWidth = maxWidth; + request.fillWidth = fillWidth; + request.fillHeight = fillHeight; + request.format = artworkFormat(); + request.quality = qualityForOffset(qualityOffset); + return m_source->imageUrl(request); } QQuickImageResponse *ArtworkService::requestImageResponse(const QString& id, const QSize& requestedSize) @@ -639,6 +683,11 @@ void ArtworkService::prefetch(const QStringList& urls) QStringList uncached; uncached.reserve(urls.size()); for (const QString& url : urls) { + // Generated thumbnails and authenticated playback previews load on + // demand, without speculative decoding or unscoped HTTP requests. + const QString scheme = QUrl(url).scheme(); + if (scheme == QLatin1String("spool-thumbnail") || scheme == QLatin1String("spool-artwork")) + continue; const QString key = cacheKeyForUrl(QUrl(url)); if (key.isEmpty() || !m_byteCache || !m_byteCache->get(key).isEmpty()) continue; @@ -661,12 +710,6 @@ int ArtworkService::outstandingRequests() const return static_cast(m_responses.size()) + static_cast(m_pendingDeliveries.size()); } -void ArtworkService::setAuthorizationHeader(QString header) -{ - invokeWorker([header = std::move(header)]( - ArtworkFetchWorker *worker) mutable { worker->setAuthorizationHeader(std::move(header)); }); -} - void ArtworkService::cancelPrefetches() { invokeWorker([](ArtworkFetchWorker *worker) { worker->cancelPrefetches(); }); @@ -683,13 +726,24 @@ void ArtworkService::releaseMemory(bool aggressive) int ArtworkService::requestImage(QUrl url, QSize requestedSize, ArtworkImageResponse *response) { + Q_ASSERT(thread() == QThread::currentThread()); + QString key = cacheKeyForUrl(url); + QByteArray headers; + if (url.scheme() == QLatin1String("spool-artwork")) { + const auto resource = m_source ? m_source->resolveImage(url) : ArtworkSource::ImageResource {}; + url = resource.url; + headers = resource.headers; + key += QLatin1Char(':') + cacheKeyForUrl(url); + if (!headers.isEmpty()) + key += QLatin1Char(':') + + QString::fromLatin1(QCryptographicHash::hash(headers, QCryptographicHash::Sha256).toHex()); + } if (!url.isValid() || url.scheme().isEmpty()) { if (response) response->finish({}, QStringLiteral("Invalid artwork URL")); return 0; } - const QString key = cacheKeyForUrl(url); const QByteArray cached = m_byteCache ? m_byteCache->get(key) : QByteArray(); if (!cached.isEmpty()) { Timing timing; @@ -703,8 +757,31 @@ int ArtworkService::requestImage(QUrl url, QSize requestedSize, ArtworkImageResp const int requestId = m_nextRequestId++; m_responses.insert(requestId, response); m_requestStarts.insert(requestId, monotonicNs()); - invokeWorker([requestId, url = std::move(url)]( - ArtworkFetchWorker *worker) mutable { worker->fetchRender(requestId, std::move(url)); }); + if (url.scheme() == QLatin1String("spool-thumbnail")) { + const QPointer target(response); + const qint64 queued = monotonicNs(); + m_thumbnailPool.start(QRunnable::create([this, target, requestId, key, url, queued] { + if (!target || target->cancelled()) + return; + const qint64 started = monotonicNs(); + QString error; + QByteArray bytes = localThumbnail(url, m_cacheDirectory, error); + const qint64 fetched = monotonicNs() - started; + QMetaObject::invokeMethod( + this, + [this, requestId, key, bytes = std::move(bytes), error = std::move(error), queued, started, + fetched]() mutable { + handleRenderFetched( + requestId, key, std::move(bytes), std::move(error), true, started - queued, fetched); + }, + Qt::QueuedConnection); + })); + return requestId; + } + invokeWorker( + [requestId, url = std::move(url), key, headers = std::move(headers)](ArtworkFetchWorker *worker) mutable { + worker->fetchRender(requestId, std::move(url), key, std::move(headers)); + }); return requestId; } @@ -906,4 +983,4 @@ QQuickImageResponse *ArtworkImageProvider::requestImageResponse(const QString& i : new ArtworkImageResponse(nullptr, {}, requestedSize); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ArtworkService.h b/src/app/ArtworkService.h index 0f8f4e6d..86ea2971 100644 --- a/src/app/ArtworkService.h +++ b/src/app/ArtworkService.h @@ -1,6 +1,7 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" +#include "../provider/ArtworkSource.h" #include "ArtworkPrefetcher.h" #include @@ -24,7 +25,7 @@ class QQuickImageResponse; -namespace JellyfinNative { +namespace Spool { class ArtworkByteCache final { public: @@ -52,7 +53,8 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { Q_INVOKABLE QString url(const QVariant& item, const QString& kind, int width = 0) const; QString itemUrl(const MovieItem& item, bool landscape, int width = 0) const override; - void setServerUrl(QString serverUrl); + // Where image URLs come from. Unset, every request resolves to no URL. + void setSource(const ArtworkSource *source); void setUiWidth(int width); // Which codec to ask the server for, and how hard it should compress. // Format is "webp" or "jpeg"; anything else falls back to the platform @@ -66,7 +68,6 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { void prefetch(const QStringList& urls) override; void cancelPrefetches() override; void releaseMemory(bool aggressive); - void setAuthorizationHeader(QString header); // How much artwork is still on its way: requests that have not produced an // image yet, plus images decoded but not yet handed to the scene graph. // Zero means everything asked for has arrived, which is what "wait until @@ -122,7 +123,7 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { QString artworkFormat() const; QString m_cacheDirectory; - QString m_serverUrl; + const ArtworkSource *m_source = nullptr; QString m_artworkFormat; int m_webpQuality = 75; int m_jpegQuality = 82; @@ -132,6 +133,7 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { mutable QMutex m_decodeTotalsMutex; DecodeTotals m_decodeTotals; QThreadPool m_decodePool; + QThreadPool m_thumbnailPool; QThread m_workerThread; ArtworkFetchWorker *m_worker = nullptr; QHash> m_responses; @@ -146,4 +148,4 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { int m_nextRequestId = 1; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/BrowseSessionController.cpp b/src/app/BrowseSessionController.cpp index fc16b79b..61caa853 100644 --- a/src/app/BrowseSessionController.cpp +++ b/src/app/BrowseSessionController.cpp @@ -7,8 +7,9 @@ #include #include #include +#include -namespace JellyfinNative { +namespace Spool { BrowseSessionController::BrowseSessionController(LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) @@ -35,8 +36,7 @@ int BrowseSessionController::applyCachedPage(const QString& cacheKey) const auto prefetchedPage = m_prefetch->cachedPage(cacheKey); if (prefetchedPage) { - m_items.setMovies(prefetchedPage->items); - m_prefetch->prefetchPosters(prefetchedPage->items); + setPage(*prefetchedPage, cacheKey, false); return m_items.rowCount(); } m_items.clear(); @@ -67,6 +67,8 @@ void BrowseSessionController::resetPaging(const QString& cacheKey) m_totalCount = 0; m_nextStartIndex = 0; m_pageSize = 0; + m_nextCursor.reset(); + m_seenCursors.clear(); emit pagingChanged(); } @@ -74,6 +76,18 @@ void BrowseSessionController::setPage(const PagedMovieItems& page, const QString { QElapsedTimer applyTimer; applyTimer.start(); + if (!page.exhausted + && (!page.nextCursor || page.nextCursor->isEmpty() || (append && m_seenCursors.contains(*page.nextCursor)))) { + m_loadingMore = false; + m_hasMore = false; + emit pagingChanged(); + throw std::runtime_error("invalid_pagination"); + } + if (!append) + m_seenCursors.clear(); + if (!page.exhausted) + m_seenCursors.insert(*page.nextCursor); + m_nextCursor = page.exhausted ? std::nullopt : page.nextCursor; if (append) m_items.appendMovies(page.items); else @@ -90,10 +104,7 @@ void BrowseSessionController::setPage(const PagedMovieItems& page, const QString m_totalCount = hasServerTotal ? std::max(page.totalRecordCount, m_nextStartIndex) : m_nextStartIndex; if (page.limit > 0) m_pageSize = page.limit; - m_hasMore = hasServerTotal ? m_nextStartIndex < m_totalCount - : page.items.size() >= static_cast(std::max(1, page.limit)); - if (page.items.empty()) - m_hasMore = false; + m_hasMore = !page.exhausted; m_loadingMore = false; if (m_prefetch) @@ -109,16 +120,6 @@ void BrowseSessionController::setLoadingMore(bool loading) emit pagingChanged(); } -void BrowseSessionController::setWarmCachePaging(int cachedCount, int pageSize) -{ - m_nextStartIndex = cachedCount; - m_pageSize = std::max(1, pageSize); - m_totalCount = cachedCount; - m_hasMore = cachedCount >= pageSize; - m_loadingMore = true; - emit pagingChanged(); -} - void BrowseSessionController::prefetchVisibleRange(int firstIndex, int lastIndex) { if (firstIndex < 0 || lastIndex < firstIndex || m_items.rowCount() <= 0) @@ -161,13 +162,12 @@ void BrowseSessionController::updatePlayed(const QString& itemId, bool played) m_items.updatePlayed(itemId, played); } -void BrowseSessionController::enterLibrary( - const LibraryItem& library, const QString& contentLabel, const QVariantMap& defaultQuery) +void BrowseSessionController::enterLibrary(const LibraryItem& library, const QVariantMap& defaultQuery) { + resetPaging(); m_libraryId = library.id; m_libraryCollectionType = library.collectionType; m_title = library.name; - m_contentLabel = contentLabel; m_viewKind = QStringLiteral("library"); m_seriesId.clear(); m_seasonId.clear(); @@ -182,24 +182,19 @@ bool BrowseSessionController::enterItem(const MovieItem& item) { BrowseDescriptor descriptor; QString viewKind; - QString contentLabel; if (item.itemType == QStringLiteral("Playlist")) { descriptor = BrowseDescriptor::playlist(item.id, item.title); viewKind = QStringLiteral("playlist"); - contentLabel = QStringLiteral("Items"); } else if (item.itemType == QStringLiteral("BoxSet")) { descriptor = BrowseDescriptor::boxSet(item.id, item.title); viewKind = QStringLiteral("boxset"); - contentLabel = QStringLiteral("Titles"); } else if (item.itemType == QStringLiteral("Folder") || item.itemType == QStringLiteral("PhotoAlbum") || item.itemType == QStringLiteral("MusicAlbum")) { descriptor = BrowseDescriptor::folderChildren(item.id, item.title); viewKind = QStringLiteral("folder"); - contentLabel = QStringLiteral("Items"); } else if (item.itemType == QStringLiteral("MusicArtist")) { descriptor = BrowseDescriptor::artistAlbums(item.id, item.title); viewKind = QStringLiteral("artist"); - contentLabel = QStringLiteral("Albums"); } else { return false; } @@ -207,7 +202,6 @@ bool BrowseSessionController::enterItem(const MovieItem& item) m_descriptor = std::move(descriptor); m_viewKind = std::move(viewKind); m_title = item.title; - m_contentLabel = std::move(contentLabel); m_query.clear(); m_filterOptions.clear(); emit changed(); @@ -227,7 +221,6 @@ void BrowseSessionController::enterNamedCollection( m_libraryCollectionType = collectionType; m_viewKind = viewKind; m_title = name; - m_contentLabel = collectionType == QStringLiteral("music") ? QStringLiteral("Music") : QStringLiteral("Titles"); m_query.clear(); m_filterOptions.clear(); emit changed(); @@ -288,6 +281,7 @@ bool BrowseSessionController::setQuery(QVariantMap query) if (m_query == query) return false; m_query = std::move(query); + resetPaging(); if (!m_libraryId.isEmpty()) { m_libraryQueries.insert(m_libraryId, m_query); QSettings().setValue(QStringLiteral("browse/libraryQueries/") + m_libraryId, m_query); @@ -316,6 +310,7 @@ void BrowseSessionController::setFilterOptions(const QVariantMap& options) void BrowseSessionController::clearBrowseIdentity() { + resetPaging(); m_libraryId.clear(); m_libraryCollectionType.clear(); m_seriesId.clear(); @@ -323,4 +318,4 @@ void BrowseSessionController::clearBrowseIdentity() m_descriptor = {}; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/BrowseSessionController.h b/src/app/BrowseSessionController.h index 19df4560..b49e6baf 100644 --- a/src/app/BrowseSessionController.h +++ b/src/app/BrowseSessionController.h @@ -1,29 +1,30 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "../models/MovieGridModel.h" #include #include +#include #include #include #include -namespace JellyfinNative { +namespace Spool { class LibraryPrefetchController; class BrowseSessionController final : public QObject { Q_OBJECT - Q_PROPERTY(JellyfinNative::MovieGridModel *items READ items CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *items READ items CONSTANT) Q_PROPERTY(bool loadingMore READ loadingMore NOTIFY pagingChanged) Q_PROPERTY(bool hasMore READ hasMore NOTIFY pagingChanged) Q_PROPERTY(int totalCount READ totalCount NOTIFY pagingChanged) Q_PROPERTY(QString libraryId READ libraryId NOTIFY changed) + Q_PROPERTY(QString containerId READ containerId NOTIFY changed) Q_PROPERTY(QString libraryCollectionType READ libraryCollectionType NOTIFY changed) Q_PROPERTY(QString title READ title NOTIFY changed) - Q_PROPERTY(QString contentLabel READ contentLabel NOTIFY changed) Q_PROPERTY(QString viewKind READ viewKind NOTIFY changed) Q_PROPERTY(QVariantMap query READ query NOTIFY changed) Q_PROPERTY(QVariantMap filterOptions READ filterOptions NOTIFY changed) @@ -56,6 +57,10 @@ class BrowseSessionController final : public QObject { { return m_nextStartIndex; } + std::optional nextCursor() const + { + return m_nextCursor; + } int rowCount() const { return m_items.rowCount(); @@ -64,6 +69,11 @@ class BrowseSessionController final : public QObject { { return m_libraryId; } + QString containerId() const + { + return m_descriptor.kind == BrowseKind::Playlist || m_descriptor.kind == BrowseKind::BoxSet ? m_descriptor.id + : QString(); + } QString libraryCollectionType() const { return m_libraryCollectionType; @@ -72,10 +82,6 @@ class BrowseSessionController final : public QObject { { return m_title; } - QString contentLabel() const - { - return m_contentLabel; - } QString viewKind() const { return m_viewKind; @@ -111,7 +117,6 @@ class BrowseSessionController final : public QObject { void resetPaging(const QString& cacheKey = {}); void setPage(const PagedMovieItems& page, const QString& cacheKey, bool append); void setLoadingMore(bool loading); - void setWarmCachePaging(int cachedCount, int pageSize); Q_INVOKABLE void prefetchVisibleRange(int firstIndex, int lastIndex); Q_INVOKABLE void prefetchPageForIndex(int lastIndex); Q_INVOKABLE void prefetchNextPage(); @@ -120,7 +125,7 @@ class BrowseSessionController final : public QObject { void updateFavorite(const QString& itemId, bool favorite); void updatePlayed(const QString& itemId, bool played); - void enterLibrary(const LibraryItem& library, const QString& contentLabel, const QVariantMap& defaultQuery); + void enterLibrary(const LibraryItem& library, const QVariantMap& defaultQuery); bool enterItem(const MovieItem& item); void enterNamedCollection(const QString& viewKind, const QString& name, const QString& collectionType = {}); Q_INVOKABLE void setSort(const QString& sortBy, const QString& sortOrder); @@ -149,11 +154,12 @@ class BrowseSessionController final : public QObject { int m_totalCount = 0; int m_nextStartIndex = 0; int m_pageSize = 0; + std::optional m_nextCursor; + QSet m_seenCursors; QString m_libraryId; QString m_libraryCollectionType; QString m_title; - QString m_contentLabel = QStringLiteral("Movies"); QString m_viewKind; QString m_seriesId; QString m_seasonId; @@ -163,4 +169,4 @@ class BrowseSessionController final : public QObject { QVariantMap m_filterOptions; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/CollectionEditingController.cpp b/src/app/CollectionEditingController.cpp new file mode 100644 index 00000000..5c6bdc50 --- /dev/null +++ b/src/app/CollectionEditingController.cpp @@ -0,0 +1,302 @@ +#include "CollectionEditingController.h" + +#include "../common/AsyncTask.h" +#include "../provider/SourceHub.h" +#include +#include +#include +#include + +namespace Spool { +namespace { + constexpr int MaximumPages = 256; + constexpr int MaximumEntries = 10000; +} + +CollectionEditingController::CollectionEditingController(SourceHub *sources, QObject *parent) + : QObject(parent) + , m_sources(sources) +{ + connect(sources, &SourceHub::extensionSupportChanged, this, [this](const QString& account) { + if (!m_containerId.isEmpty() && account == m_accountId) { + // A permission/offer or source generation change invalidates every + // loaded occurrence and in-flight result, not just the buttons. + const auto container = m_containerId; + const auto title = m_title; + close(); + if (m_sources->collectionEditingAvailable(container)) + open(container, title); + else { + m_problem = tr("Collection editing is unavailable for this account."); + emit changed(); + } + } + }); +} + +CollectionEditingController::~CollectionEditingController() +{ + if (!m_containerId.isEmpty()) + m_sources->cancelCollection(m_containerId); +} + +int CollectionEditingController::indexOf(const QString& entryId) const +{ + for (size_t i = 0; i < m_entries.size(); ++i) + if (m_entries[i].playlistItemId == entryId) + return int(i); + return -1; +} + +int CollectionEditingController::selectedIndex() const +{ + return indexOf(m_selected); +} + +void CollectionEditingController::clearEntries() +{ + m_entries.clear(); + m_rows.clear(); + m_selected.clear(); + m_seenCursors.clear(); + m_cursor.reset(); + m_pages = 0; + m_exhausted = true; + m_removable = false; + m_movable = false; +} + +void CollectionEditingController::close() +{ + ++m_generation; + if (!m_containerId.isEmpty()) + m_sources->cancelCollection(m_containerId); + m_containerId.clear(); + m_accountId.clear(); + m_title.clear(); + m_problem.clear(); + m_busy = false; + clearEntries(); + emit changed(); +} + +void CollectionEditingController::open(const QString& containerId, const QString& title) +{ + close(); + m_containerId = containerId; + m_accountId = m_sources->accountOf(containerId); + m_title = title; + if (!m_sources->collectionEditingAvailable(containerId)) { + m_problem = tr("Collection editing is unavailable for this account."); + emit changed(); + return; + } + beginLoad(true); +} + +void CollectionEditingController::selectEntry(const QString& entryId) +{ + if (m_selected != entryId && indexOf(entryId) >= 0) { + m_selected = entryId; + emit changed(); + } +} + +void CollectionEditingController::refresh() +{ + beginLoad(true); +} +void CollectionEditingController::requestNextPage() +{ + if (hasMore()) + beginLoad(false); +} + +void CollectionEditingController::beginLoad(bool reset) +{ + if (m_busy || m_containerId.isEmpty()) + return; + m_busy = true; + m_problem.clear(); + const auto generation = ++m_generation; + const int desired = reset ? std::max(1, int(m_entries.size())) : int(m_entries.size()) + 1; + emit changed(); + Async::runScoped( + this, load(generation, reset, desired), [this, generation] { finish(generation); }, + [this, generation](const std::exception_ptr&) { fail(generation); }, "collection entries"); +} + +void CollectionEditingController::finish(quint64 generation) +{ + if (generation != m_generation) + return; + m_busy = false; + emit changed(); +} + +void CollectionEditingController::fail(quint64 generation) +{ + if (generation != m_generation) + return; + // Never leave stale editable entries after an uncertain read or mutation. + clearEntries(); + m_busy = false; + m_problem = tr("Could not refresh the collection. Retry before making another change."); + emit changed(); +} + +QCoro::Task CollectionEditingController::load(quint64 generation, bool reset, int desiredCount) +{ + QPointer guard(this); + const QString container = m_containerId; + bool removable = m_removable; + bool movable = m_movable; + if (reset) { + const auto info = co_await m_sources->collectionCall(container, QStringLiteral("collectionInfo")); + if (!guard || generation != m_generation) + co_return; + const QString mode = info.value(QStringLiteral("moveMode")).toString(); + if (mode != QStringLiteral("none") && mode != QStringLiteral("index") && mode != QStringLiteral("after")) + throw std::runtime_error("invalid_collection_info"); + if (info.value(QStringLiteral("ordered")).metaType().id() != QMetaType::Bool + || info.value(QStringLiteral("removable")).metaType().id() != QMetaType::Bool) + throw std::runtime_error("invalid_collection_info"); + removable = info.value(QStringLiteral("removable")).toBool(); + movable = info.value(QStringLiteral("ordered")).toBool() && mode != QStringLiteral("none"); + } + auto cursor = reset ? std::optional() : m_cursor; + auto cursors = reset ? QSet() : m_seenCursors; + const size_t baseCount = reset ? 0 : m_entries.size(); + std::vector entries; + int pages = reset ? 0 : m_pages; + bool exhausted = reset ? false : m_exhausted; + QSet entryIds; + if (!reset) + for (const auto& item : m_entries) + entryIds.insert(item.playlistItemId); + while (!exhausted && int(baseCount + entries.size()) < desiredCount) { + if (pages >= MaximumPages || baseCount + entries.size() >= MaximumEntries) + throw std::runtime_error("response_limit"); + auto page = co_await m_sources->collectionEntries(container, cursor); + if (!guard || generation != m_generation) + co_return; + ++pages; + if (!page.exhausted && (!page.nextCursor || page.nextCursor->isEmpty() || cursors.contains(*page.nextCursor))) + throw std::runtime_error("invalid_pagination"); + if (!page.exhausted) + cursors.insert(*page.nextCursor); + if (baseCount + entries.size() + page.items.size() > MaximumEntries) + throw std::runtime_error("response_limit"); + for (auto& item : page.items) { + if (item.playlistItemId.isEmpty() || entryIds.contains(item.playlistItemId)) + throw std::runtime_error("invalid_collection_entry"); + entryIds.insert(item.playlistItemId); + entries.push_back(std::move(item)); + } + cursor = page.exhausted ? std::nullopt : page.nextCursor; + exhausted = page.exhausted; + } + const QString selected = m_selected; + const int nearest = std::max(0, selectedIndex()); + if (reset) { + m_entries = std::move(entries); + m_rows.clear(); + } else { + m_entries.insert( + m_entries.end(), std::make_move_iterator(entries.begin()), std::make_move_iterator(entries.end())); + } + m_cursor = std::move(cursor); + m_seenCursors = std::move(cursors); + m_pages = pages; + m_exhausted = exhausted; + m_removable = removable; + m_movable = movable; + m_selected = indexOf(selected) >= 0 ? selected + : m_entries.empty() ? QString() + : m_entries[std::min(nearest, int(m_entries.size()) - 1)].playlistItemId; + m_rows.reserve(qsizetype(m_entries.size())); + for (size_t index = baseCount; index < m_entries.size(); ++index) { + const auto& item = m_entries[index]; + m_rows.append( + QVariantMap { { QStringLiteral("entryId"), item.playlistItemId }, { QStringLiteral("itemId"), item.id }, + { QStringLiteral("title"), item.title }, { QStringLiteral("itemType"), item.itemType } }); + } + emit changed(); +} + +void CollectionEditingController::removeEntry(const QString& entryId) +{ + mutate(entryId, 0); +} +void CollectionEditingController::moveEntry(const QString& entryId, int direction) +{ + if (direction == -1 || direction == 1) + mutate(entryId, direction); +} + +void CollectionEditingController::mutate(const QString& entryId, int direction) +{ + if (m_busy || indexOf(entryId) < 0 || (direction == 0 ? !m_removable : !m_movable)) + return; + m_selected = entryId; + m_busy = true; + m_problem.clear(); + const auto generation = ++m_generation; + emit changed(); + Async::runScoped( + this, mutation(generation, entryId, direction), [this, generation] { finish(generation); }, + [this, generation](const std::exception_ptr&) { fail(generation); }, "collection mutation"); +} + +QCoro::Task CollectionEditingController::mutation(quint64 generation, QString entryId, int direction) +{ + QPointer guard(this); + if (direction == 1 && indexOf(entryId) == int(m_entries.size()) - 1 && !m_exhausted) { + // An anchor on the next page must be known before sending a move. No + // full-container lookup and no index-to-anchor translation request. + co_await load(generation, false, int(m_entries.size()) + 1); + if (!guard || generation != m_generation) + co_return; + } + const int from = indexOf(entryId); + const int destination = from + direction; + if (from < 0 || (direction != 0 && (destination < 0 || destination >= int(m_entries.size())))) + co_return; + const QString container = m_containerId; + const int desired = std::max(1, int(m_entries.size())); + QVariantMap args { { QStringLiteral("entryId"), entryId } }; + if (direction != 0) { + args.insert(QStringLiteral("index"), destination); + // Both forms describe the destination AFTER removing the moving row. + const int predecessor = direction < 0 ? destination - 1 : destination; + // An invalid QVariant becomes JS undefined; the wire contract requires null. + args.insert(QStringLiteral("afterEntryId"), + predecessor < 0 ? QVariant::fromValue(nullptr) : QVariant(m_entries[size_t(predecessor)].playlistItemId)); + } + bool uncertain = false; + bool permissionDenied = false; + try { + co_await m_sources->collectionCall(container, + direction == 0 ? QStringLiteral("collectionRemove") : QStringLiteral("collectionMove"), std::move(args)); + } catch (const std::exception& error) { + uncertain = true; + const auto code = QString::fromUtf8(error.what()); + permissionDenied + = code.contains(QStringLiteral("http_403")) || code.contains(QStringLiteral("permission_denied")); + } + if (!guard || generation != m_generation) + co_return; + m_sources->collectionChanged(container); + // Re-read permissions as well as rows after success, 403, disappearance or + // an uncertain transport error. Never retry a mutation automatically. + co_await load(generation, true, desired); + if (!guard || generation != m_generation) + co_return; + if (uncertain) + m_problem = permissionDenied + ? tr("This account does not have permission to make that change. Entries and permissions have been " + "refreshed.") + : tr("The change could not be confirmed. Entries and permissions have been refreshed."); +} + +} // namespace Spool diff --git a/src/app/CollectionEditingController.h b/src/app/CollectionEditingController.h new file mode 100644 index 00000000..968b1aeb --- /dev/null +++ b/src/app/CollectionEditingController.h @@ -0,0 +1,104 @@ +#pragma once + +#include "../media/MediaTypes.h" +#include +#include +#include +#include + +namespace Spool { + +class SourceHub; + +// An editor holds a consecutive native-order prefix. Occurrences, never media +// IDs, identify selection and mutations; duplicate media remain distinct rows. +class CollectionEditingController final : public QObject { + Q_OBJECT + Q_PROPERTY(QString containerId READ containerId NOTIFY changed) + Q_PROPERTY(QString title READ title NOTIFY changed) + Q_PROPERTY(QVariantList entries READ entries NOTIFY changed) + Q_PROPERTY(QString selectedEntryId READ selectedEntryId NOTIFY changed) + Q_PROPERTY(int selectedIndex READ selectedIndex NOTIFY changed) + Q_PROPERTY(bool busy READ busy NOTIFY changed) + Q_PROPERTY(bool hasMore READ hasMore NOTIFY changed) + Q_PROPERTY(bool removable READ removable NOTIFY changed) + Q_PROPERTY(bool movable READ movable NOTIFY changed) + Q_PROPERTY(QString problem READ problem NOTIFY changed) +public: + explicit CollectionEditingController(SourceHub *sources, QObject *parent = nullptr); + ~CollectionEditingController() override; + QString containerId() const + { + return m_containerId; + } + QString title() const + { + return m_title; + } + QVariantList entries() const + { + return m_rows; + } + QString selectedEntryId() const + { + return m_selected; + } + int selectedIndex() const; + bool busy() const + { + return m_busy; + } + bool hasMore() const + { + return !m_exhausted && !m_containerId.isEmpty(); + } + bool removable() const + { + return m_removable; + } + bool movable() const + { + return m_movable; + } + QString problem() const + { + return m_problem; + } + Q_INVOKABLE void open(const QString& containerId, const QString& title); + Q_INVOKABLE void close(); + Q_INVOKABLE void refresh(); + Q_INVOKABLE void requestNextPage(); + Q_INVOKABLE void selectEntry(const QString& entryId); + Q_INVOKABLE void removeEntry(const QString& entryId); + Q_INVOKABLE void moveEntry(const QString& entryId, int direction); +signals: + void changed(); + +private: + int indexOf(const QString& entryId) const; + void beginLoad(bool reset); + void mutate(const QString& entryId, int direction); + QCoro::Task load(quint64 generation, bool reset, int desiredCount); + QCoro::Task mutation(quint64 generation, QString entryId, int direction); + void finish(quint64 generation); + void fail(quint64 generation); + void clearEntries(); + SourceHub *m_sources; + QString m_containerId; + QString m_accountId; + QString m_title; + QString m_selected; + QString m_problem; + std::vector m_entries; + QVariantList m_rows; + std::optional m_cursor; + QSet m_seenCursors; + quint64 m_generation = 0; + int m_pages = 0; + bool m_busy = false; + bool m_exhausted = true; + bool m_removable = false; + bool m_movable = false; +}; + +} // namespace Spool diff --git a/src/app/ContentModelController.cpp b/src/app/ContentModelController.cpp index a12813ae..44635bf4 100644 --- a/src/app/ContentModelController.cpp +++ b/src/app/ContentModelController.cpp @@ -1,6 +1,5 @@ #include "ContentModelController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "LibraryPrefetchController.h" @@ -11,12 +10,11 @@ #include #include -namespace JellyfinNative { +namespace Spool { -ContentModelController::ContentModelController( - JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent) +ContentModelController::ContentModelController(Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(catalog) , m_prefetch(prefetch) { } @@ -48,11 +46,12 @@ void ContentModelController::loadDetailRows( const RequestGeneration::Token generation = m_detailRowsGeneration.next(); m_detailRowsPending = 0; m_detailRowsBusy = false; + m_detailContextInitialIndex = 0; m_detailSeasons.clear(); m_detailSeasonOptions.clear(); m_detailSimilarItems.clear(); - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) { + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) { emit detailRowsChanged(); return; } @@ -92,8 +91,23 @@ void ContentModelController::loadDetailRows( } else if (loadEpisodes) { Async::runLatest( this, m_api->fetchEpisodes(seriesId, seasonId), m_detailRowsGeneration, generation, - [this, generation, seriesId](const std::vector& episodes) { + [this, generation, seriesId, itemId, itemType](const std::vector& episodes) { qInfo() << "detail rows: episodes loaded" << seriesId << episodes.size(); + int initialIndex = episodicPlaybackStartIndex(episodes); + if (initialIndex < 0) { + initialIndex = 0; + for (int index = 0; index < static_cast(episodes.size()); ++index) { + if (episodes[static_cast(index)].played) + initialIndex = index; + } + } + if (itemType == QStringLiteral("Episode")) { + const auto current = std::find_if(episodes.cbegin(), episodes.cend(), + [&itemId](const MovieItem& episode) { return episode.id == itemId; }); + if (current != episodes.cend()) + initialIndex = static_cast(std::distance(episodes.cbegin(), current)); + } + m_detailContextInitialIndex = initialIndex; m_detailSeasons.setMovies(episodes); m_prefetch->prefetchPosters(episodes); emit detailRowsChanged(); @@ -105,7 +119,8 @@ void ContentModelController::loadDetailRows( }); } else if (loadBoxSet) { Async::runLatest( - this, m_api->fetchBrowsePage(BrowseDescriptor::boxSet(itemId), 0, 200), m_detailRowsGeneration, generation, + this, m_api->fetchBrowsePage(BrowseDescriptor::boxSet(itemId), 0, 200, {}, std::nullopt), + m_detailRowsGeneration, generation, [this, generation, itemId](const PagedMovieItems& page) { qInfo() << "detail rows: box set children loaded" << itemId << page.items.size(); m_detailSeasons.setMovies(page.items); @@ -119,8 +134,8 @@ void ContentModelController::loadDetailRows( }); } else if (loadAlbum) { Async::runLatest( - this, m_api->fetchBrowsePage(BrowseDescriptor::folderChildren(itemId), 0, 200), m_detailRowsGeneration, - generation, + this, m_api->fetchBrowsePage(BrowseDescriptor::folderChildren(itemId), 0, 200, {}, std::nullopt), + m_detailRowsGeneration, generation, [this, generation, itemId](const PagedMovieItems& page) { qInfo() << "detail rows: album tracks loaded" << itemId << page.items.size(); m_detailSeasons.setMovies(page.items); @@ -169,7 +184,7 @@ void ContentModelController::loadItemDetail(const QString& itemId) const RequestGeneration::Token generation = m_detailItemGeneration.next(); m_detailItem = {}; - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) { + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) { emit detailItemChanged(); return; } @@ -193,7 +208,7 @@ void ContentModelController::loadPersonItems(const QString& personId) { const RequestGeneration::Token generation = m_personItemsGeneration.next(); clearPersonItems(); - if (personId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) { + if (personId.isEmpty() || !m_api || !m_api->signedIn()) { m_personItemsBusy = false; emit personItemsChanged(); return; @@ -399,6 +414,7 @@ void ContentModelController::reset() m_linkedItems.clear(); m_detailItem = {}; m_detailRowsBusy = false; + m_detailContextInitialIndex = 0; m_detailRowsPending = 0; m_personItemsBusy = false; @@ -420,4 +436,4 @@ void ContentModelController::finishDetailRowLoad(RequestGeneration::Token genera } } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ContentModelController.h b/src/app/ContentModelController.h index 34c145c8..f987bb2d 100644 --- a/src/app/ContentModelController.h +++ b/src/app/ContentModelController.h @@ -2,6 +2,7 @@ #include "../common/RequestGeneration.h" #include "../models/MovieGridModel.h" +#include "../provider/Catalog.h" #include #include @@ -11,24 +12,24 @@ #include #include -namespace JellyfinNative { +namespace Spool { -class JellyfinApiFacade; class LibraryPrefetchController; class ContentModelController final : public QObject { Q_OBJECT - Q_PROPERTY(JellyfinNative::MovieItem detailItem READ detailItem NOTIFY detailItemChanged) - Q_PROPERTY(JellyfinNative::MovieGridModel *detailSeasons READ detailSeasons CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *detailSeasonOptions READ detailSeasonOptions CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *detailSimilarItems READ detailSimilarItems CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *linkedItems READ linkedItems CONSTANT) + Q_PROPERTY(Spool::MovieItem detailItem READ detailItem NOTIFY detailItemChanged) + Q_PROPERTY(Spool::MovieGridModel *detailSeasons READ detailSeasons CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *detailSeasonOptions READ detailSeasonOptions CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *detailSimilarItems READ detailSimilarItems CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *linkedItems READ linkedItems CONSTANT) Q_PROPERTY(QVariantList personItemRows READ personItemRows NOTIFY personItemsChanged) Q_PROPERTY(bool detailRowsBusy READ detailRowsBusy NOTIFY detailRowsChanged) + Q_PROPERTY(int detailContextInitialIndex READ detailContextInitialIndex NOTIFY detailRowsChanged) Q_PROPERTY(bool personItemsBusy READ personItemsBusy NOTIFY personItemsChanged) public: - ContentModelController(JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent = nullptr); + ContentModelController(Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent = nullptr); MovieGridModel *detailSeasons() { @@ -51,6 +52,10 @@ class ContentModelController final : public QObject { { return m_detailRowsBusy; } + int detailContextInitialIndex() const + { + return m_detailContextInitialIndex; + } bool personItemsBusy() const { return m_personItemsBusy; @@ -93,7 +98,7 @@ class ContentModelController final : public QObject { void setPersonCredits(PersonCredits credits); void clearPersonItems(); - JellyfinApiFacade *m_api = nullptr; + Catalog *m_api = nullptr; LibraryPrefetchController *m_prefetch = nullptr; MovieGridModel m_detailSeasons; MovieGridModel m_detailSeasonOptions; @@ -102,6 +107,7 @@ class ContentModelController final : public QObject { MovieGridModel m_linkedItems; MovieItem m_detailItem; bool m_detailRowsBusy = false; + int m_detailContextInitialIndex = 0; RequestGeneration m_detailRowsGeneration; RequestGeneration m_detailItemGeneration; int m_detailRowsPending = 0; @@ -109,4 +115,4 @@ class ContentModelController final : public QObject { RequestGeneration m_personItemsGeneration; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/CpuTopology.cpp b/src/app/CpuTopology.cpp index 11076b3f..8c0f545f 100644 --- a/src/app/CpuTopology.cpp +++ b/src/app/CpuTopology.cpp @@ -4,14 +4,14 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { int environmentDecodeThreads() { bool ok = false; - const int value = QString::fromLocal8Bit(qgetenv("JELLYFIN_WEBP_DECODE_THREADS")).toInt(&ok); + const int value = QString::fromLocal8Bit(qgetenv("SPOOL_WEBP_DECODE_THREADS")).toInt(&ok); return ok ? value : 0; } @@ -35,4 +35,4 @@ CpuTopology detectCpuTopology() return { logical, physical, smt, decodeThreads, source }; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/CpuTopology.h b/src/app/CpuTopology.h index 9a661c34..e06b4853 100644 --- a/src/app/CpuTopology.h +++ b/src/app/CpuTopology.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct CpuTopology { int logicalCpus = 1; @@ -14,4 +14,4 @@ struct CpuTopology { CpuTopology detectCpuTopology(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/DownloadManager.cpp b/src/app/DownloadManager.cpp new file mode 100644 index 00000000..911d580d --- /dev/null +++ b/src/app/DownloadManager.cpp @@ -0,0 +1,673 @@ +#include "DownloadManager.h" +#include "../common/TlsTrust.h" +#include "../platform/AndroidDownloadStorage.h" +#include "../provider/SourceHub.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(Q_OS_ANDROID) +#include +#include +#include +#endif + +namespace Spool { +struct DownloadManager::Job { + QString id; + QString itemId; + QString title; + QString destination; + QVariantMap option; + QVariantMap metadata; + QString path; + QString temporary; + QString state = QStringLiteral("preparing"); + QString error; + qint64 received = 0; + qint64 total = -1; + bool stopped = false; + bool checked = false; + QByteArray prefix; + DownloadPlan plan; + QFile file; + QPointer reply; +}; + +bool DownloadManager::supported() +{ + return true; +} +bool DownloadManager::mobile() +{ +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) || defined(Q_OS_TVOS) + return true; +#else + return false; +#endif +} +bool DownloadManager::canChooseFolder() +{ +#if defined(Q_OS_IOS) || defined(Q_OS_TVOS) + return false; +#else + return true; +#endif +} +QString DownloadManager::defaultDestination() +{ + // Mobile application storage requires neither a shared-storage permission + // nor an expiring document-provider grant. iOS AppData is backup-excluded. +#if defined(SPOOL_WEBOS) || defined(Q_OS_TVOS) + const auto location = QStandardPaths::AppDataLocation; +#else + const auto location = mobile() ? QStandardPaths::AppDataLocation : QStandardPaths::MoviesLocation; +#endif + QString root = QStandardPaths::writableLocation(location); + if (root.isEmpty()) + root = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return QDir(root).filePath(mobile() ? QStringLiteral("downloads") : QStringLiteral("Spool")); +} +QVariantList DownloadManager::destinationChoices() +{ + QVariantList choices; +#if defined(Q_OS_ANDROID) + choices.push_back(QVariantMap { { QStringLiteral("label"), QStringLiteral("Internal app storage") }, + { QStringLiteral("url"), QUrl::fromLocalFile(defaultDestination()) } }); + const QJniObject context = QNativeInterface::QAndroidApplication::context(); + const auto external + = context.callObjectMethod("getExternalFilesDir", "(Ljava/lang/String;)Ljava/io/File;", nullptr); + if (external.isValid()) { + const QString path = external.callObjectMethod("getAbsolutePath", "()Ljava/lang/String;").toString(); + if (!path.isEmpty()) + choices.push_back(QVariantMap { { QStringLiteral("label"), QStringLiteral("External app storage") }, + { QStringLiteral("url"), QUrl::fromLocalFile(QDir(path).filePath(QStringLiteral("downloads"))) } }); + } +#endif + return choices; +} +DownloadManager::DownloadManager(SourceHub *sources, QString dataRoot, TlsTrustController *trust, QObject *parent) + : QObject(parent) + , m_sources(sources) + , m_dataRoot(std::move(dataRoot)) + , m_network(new QNetworkAccessManager(this)) + , m_androidStorage(new AndroidDownloadStorage(this)) +{ + if (trust) + trust->attachNetworkAccessManager(m_network, QStringLiteral("Download")); + m_destination = defaultDestination(); + restore(); + connect(m_androidStorage, &AndroidDownloadStorage::folderSelected, this, &DownloadManager::setDestination); + connect(m_androidStorage, &AndroidDownloadStorage::problem, this, [this](const QString& problem) { + m_problem = problem; + emit changed(); + }); + connect(sources, &SourceHub::browseSourcesChanged, this, [this] { + for (const auto& id : m_order) { + const auto job = m_jobs.value(id); + if (!job->stopped + && (!m_sources->source(m_sources->accountOf(job->itemId)) + || m_sources->downloadOptions(job->itemId).isEmpty())) + finish(job, tr("Account unavailable. Sign in and retry.")); + } + }); +} +DownloadManager::~DownloadManager() +{ + disconnect(m_sources, nullptr, this, nullptr); + for (const auto& job : m_jobs) { + if (job->stopped) + continue; + job->stopped = true; + m_sources->cancelDownloadNegotiation(job->itemId, job->id); + if (job->reply) { + disconnect(job->reply, nullptr, this, nullptr); + job->reply->abort(); + } + job->file.close(); + if (!job->temporary.isEmpty()) { + if (job->temporary.startsWith(QStringLiteral("content://"))) + AndroidDownloadStorage::removeFile(QUrl(job->temporary)); + else + QFile::remove(job->temporary); + } + job->state = QStringLiteral("failed"); + job->error = tr("Interrupted. Retry to download again."); + } + persist(); +} +QVariantList DownloadManager::jobs() const +{ + QVariantList rows; + rows.reserve(m_order.size()); + for (const auto& id : m_order) { + const auto job = m_jobs.value(id); + rows.push_back(QVariantMap { { QStringLiteral("id"), id }, { QStringLiteral("itemId"), job->itemId }, + { QStringLiteral("title"), job->title }, { QStringLiteral("state"), job->state }, + { QStringLiteral("error"), job->error }, { QStringLiteral("received"), job->received }, + { QStringLiteral("total"), job->total }, + { QStringLiteral("quality"), job->option.value(QStringLiteral("label")) } }); + } + return rows; +} + +QVariantList DownloadManager::libraryFiles() const +{ + QVariantList files; + for (const auto& id : m_order) { + const auto job = m_jobs.value(id); + if (job->state == QStringLiteral("complete")) + files.push_back(QVariantMap { { QStringLiteral("path"), job->path }, + { QStringLiteral("metadata"), job->metadata }, { QStringLiteral("size"), job->received } }); + } + return files; +} +void DownloadManager::open(const QString& itemId) +{ + m_selectionItemId = itemId; + m_opened = true; + emit openedChanged(); +} +void DownloadManager::close() +{ + m_opened = false; + m_selectionItemId.clear(); + emit openedChanged(); +} +QVariantMap DownloadManager::statusFor(const QString& itemId) const +{ + const auto rows = jobs(); + for (auto it = rows.crbegin(); it != rows.crend(); ++it) + if (it->toMap().value(QStringLiteral("itemId")).toString() == itemId) + return it->toMap(); + return {}; +} +void DownloadManager::start(const QString& itemId, const QVariantMap& option) +{ + if (!supported() || itemId.isEmpty()) + return; + const auto status = statusFor(itemId); + const QString state = status.value(QStringLiteral("state")).toString(); + if (state == QStringLiteral("preparing") || state == QStringLiteral("downloading") + || state == QStringLiteral("complete")) + return; + const auto options = m_sources->downloadOptions(itemId); + const bool valid = std::any_of( + options.cbegin(), options.cend(), [&option](const QVariant& value) { return value.toMap() == option; }); + if (!valid) { + m_problem = tr("Downloads are unavailable for this account."); + emit changed(); + return; + } + auto job = std::make_shared(); + job->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + job->itemId = itemId; + job->title = tr("Preparing download…"); + job->destination = m_destination; + job->option = option; + m_jobs.insert(job->id, job); + m_order.push_back(job->id); + m_selectionItemId.clear(); + emit openedChanged(); + if (!persist()) { + finish(job, m_problem); + return; + } + emit changed(); + prepare(job).then([] {}, + [guard = QPointer(this), job](const std::exception& error) { + if (!guard || job->stopped) + return; + if (QByteArrayView(error.what()) == "download_cancelled") + guard->cancel(job->id); + else + guard->finish(job, guard->tr("Could not prepare download. Check the account and server, then retry.")); + }); +} +QCoro::Task DownloadManager::prepare(std::shared_ptr job) +{ + QPointer guard(this); + const MovieItem item = co_await m_sources->fetchItemDetails(job->itemId); + if (!guard || job->stopped) + co_return; + job->title = item.title; + job->metadata = { { QStringLiteral("title"), item.title }, { QStringLiteral("type"), item.itemType }, + { QStringLiteral("overview"), item.overview }, { QStringLiteral("year"), item.year }, + { QStringLiteral("runtimeTicks"), QString::number(item.runtimeTicks) }, + { QStringLiteral("seriesName"), item.seriesName }, { QStringLiteral("seasonNumber"), item.seasonNumber }, + { QStringLiteral("episodeNumber"), item.episodeNumber }, + { QStringLiteral("resumeTicks"), QString::number(item.resumeTicks) } }; + emit changed(); + const QString destination = job->destination; + DownloadRequest request { job->itemId, + job->option.value(QStringLiteral("mode")).toString() == QStringLiteral("transcoded"), + job->option.value(QStringLiteral("maxBitrate")).toLongLong(), + job->option.value(QStringLiteral("maxHeight")).toInt(), {} }; + if (item.mediaSources.size() == 1) + request.variantId = item.mediaSources.front().id; + DownloadPlan plan = co_await m_sources->negotiateDownload(request, job->id); + if (!guard) + co_return; + if (job->stopped) { + co_await release(std::move(plan)); + co_return; + } + // Only known local-playable file containers can be committed to the library. + static const QSet containers { QStringLiteral("mkv"), QStringLiteral("mp4"), QStringLiteral("webm"), + QStringLiteral("mov"), QStringLiteral("avi"), QStringLiteral("m4v"), QStringLiteral("ts"), + QStringLiteral("mpg"), QStringLiteral("mpeg"), QStringLiteral("wmv"), QStringLiteral("mp3"), + QStringLiteral("flac"), QStringLiteral("ogg"), QStringLiteral("opus"), QStringLiteral("m4a"), + QStringLiteral("wav"), QStringLiteral("aac") }; + const QString suffix = QFileInfo(plan.url.path()).suffix().toLower(); + if (!containers.contains(plan.container) || suffix == QStringLiteral("m3u8") || suffix == QStringLiteral("mpd") + || !m_sources->downloadOriginAllowed(job->itemId, plan.url) || !plan.url.userInfo().isEmpty() + || !plan.url.fragment().isEmpty()) { + job->plan = std::move(plan); + finish(job, tr("The provider did not offer an approved complete media file.")); + co_return; + } + QString name = item.title; + name.replace(QRegularExpression(QStringLiteral("[^\\p{L}\\p{N} ._-]")), QStringLiteral("_")); + name = name.left(100).trimmed(); + if (name.isEmpty()) + name = QStringLiteral("Media"); + const QString filename = name + QLatin1Char('-') + job->id + QLatin1Char('.') + plan.container; + job->metadata.insert(QStringLiteral("container"), plan.container); + if (AndroidDownloadStorage::isTree(QUrl(destination))) { + const QUrl document = AndroidDownloadStorage::createFile(QUrl(destination), filename + QStringLiteral(".part")); + if (document.isEmpty()) { + job->plan = std::move(plan); + finish(job, tr("Could not create a file in the selected folder. Choose it again to restore access.")); + co_return; + } + job->temporary = document.toString(QUrl::FullyEncoded); + job->path = filename; + } else { + job->path = QDir(destination).filePath(filename); + job->temporary = job->path + QStringLiteral(".part"); + } + transfer(job, std::move(plan)); +} +QCoro::Task DownloadManager::release(DownloadPlan plan) +{ + try { + if (!plan.cleanup.isEmpty()) + co_await m_sources->releaseDownload(std::move(plan.cleanup)); + } catch (const std::exception&) { + // Cleanup cannot change an already committed file or expose provider errors. + } +} +void DownloadManager::transfer(const std::shared_ptr& job, DownloadPlan plan) +{ + job->plan = std::move(plan); + job->total = job->plan.size; + if (!job->temporary.startsWith(QStringLiteral("content://")) + && !QDir().mkpath(QFileInfo(job->path).absolutePath())) { + finish(job, tr("Could not create the download folder.")); + return; + } + job->file.setFileName(job->temporary); + if (!job->file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + finish(job, tr("Could not write to the download folder.")); + return; + } + QNetworkRequest request(job->plan.url); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + request.setAttribute(QNetworkRequest::CookieLoadControlAttribute, QNetworkRequest::Manual); + request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); + request.setAttribute(QNetworkRequest::AuthenticationReuseAttribute, QNetworkRequest::Manual); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setTransferTimeout(60000); + static const QRegularExpression headerName(QStringLiteral("^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")); + for (auto it = job->plan.headers.cbegin(); it != job->plan.headers.cend(); ++it) { + const auto value = it.value().toString().toUtf8(); + if (!headerName.match(it.key()).hasMatch() || value.contains('\r') || value.contains('\n')) { + finish(job, tr("The provider returned invalid request headers.")); + return; + } + request.setRawHeader(it.key().toLatin1(), value); + } + if (!persist()) { + finish(job, m_problem); + return; + } + job->reply = m_network->get(request); + job->reply->setReadBufferSize(64 * 1024); + job->state = QStringLiteral("downloading"); + persist(); + emit changed(); + connect(job->reply, &QNetworkReply::readyRead, this, [this, job] { consume(job); }); + connect(job->reply, &QNetworkReply::downloadProgress, this, [this, job](qint64, qint64 total) { + if (!job->stopped && total >= 0) { + job->total = total; + emit changed(); + } + }); + connect(job->reply, &QNetworkReply::finished, this, [this, job] { + if (job->stopped) + return; + consume(job); + if (job->stopped) + return; + if (job->reply->error() != QNetworkReply::NoError) + finish(job, tr("Transfer failed. Check the network and server, then retry.")); + else if (job->received <= 0 || (job->total >= 0 && job->received != job->total) + || (job->plan.size > 0 && job->received != job->plan.size)) + finish(job, tr("The server returned an incomplete file.")); + else + finish(job); + }); +} +void DownloadManager::consume(const std::shared_ptr& job) +{ + if (job->stopped || !job->reply) + return; + const int status = job->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const auto mime = job->reply->header(QNetworkRequest::ContentTypeHeader).toString().toLower(); + if (status != 200 || mime.contains(QStringLiteral("mpegurl")) || mime.contains(QStringLiteral("dash+xml")) + || mime.startsWith(QStringLiteral("text/")) || mime.contains(QStringLiteral("json"))) { + finish(job, + status == 401 || status == 403 ? tr("Sign in again to download this item.") + : tr("The server did not return a complete media file.")); + return; + } + char buffer[64 * 1024]; + while (job->reply->bytesAvailable() > 0) { + const qint64 count = job->reply->read(buffer, sizeof(buffer)); + if (count <= 0) + break; + // Detect disguised playlists before publishing any file as offline media. + if (!job->checked) { + job->prefix.append(buffer, static_cast(std::min(count, 256 - job->prefix.size()))); + const auto prefix = job->prefix.trimmed(); + if (prefix.startsWith("#EXTM3U") || prefix.startsWith("checked = job->prefix.size() >= 256; + } + if (job->file.write(buffer, count) != count) { + finish(job, tr("Could not save the file. Check free space and folder access.")); + return; + } + job->received += count; + } + emit changed(); +} +void DownloadManager::finish(const std::shared_ptr& job, const QString& error) +{ + if (job->stopped) + return; + job->stopped = true; + job->error = error; + if (job->reply) { + disconnect(job->reply, nullptr, this, nullptr); + if (!job->reply->isFinished()) + job->reply->abort(); + job->reply->deleteLater(); + job->reply = nullptr; + } + if (error.isEmpty()) { + if (!job->file.flush()) + job->error = tr("Could not finish writing the media file."); + job->file.close(); + if (job->file.error() != QFileDevice::NoError && job->error.isEmpty()) + job->error = tr("Could not close the downloaded media file safely."); + if (job->error.isEmpty()) { + if (job->temporary.startsWith(QStringLiteral("content://"))) { + const QUrl renamed = AndroidDownloadStorage::renameFile(QUrl(job->temporary), job->path); + if (renamed.isEmpty()) + job->error = tr("Could not publish the downloaded file in the selected folder."); + else { + job->path = renamed.toString(QUrl::FullyEncoded); + job->temporary.clear(); + } + } else if (!QFile::rename(job->temporary, job->path)) { + job->error = tr("Could not publish the downloaded file."); + } else { + job->temporary.clear(); + } + } + } else { + job->file.close(); + } + if (!job->temporary.isEmpty()) { + if (job->temporary.startsWith(QStringLiteral("content://"))) + AndroidDownloadStorage::removeFile(QUrl(job->temporary)); + else + QFile::remove(job->temporary); + job->temporary.clear(); + } + job->state = job->error.isEmpty() ? QStringLiteral("complete") : QStringLiteral("failed"); + release(std::move(job->plan)).then([] { }); + if (!persist() && job->state == QStringLiteral("complete")) { + if (job->path.startsWith(QStringLiteral("content://"))) + AndroidDownloadStorage::removeFile(QUrl(job->path)); + else + QFile::remove(job->path); + job->state = QStringLiteral("failed"); + job->error = m_problem; + } + emit changed(); + if (job->state == QStringLiteral("complete")) { + emit libraryChanged(); + emit toastRequested(tr("%1 downloaded. Available in Downloads offline.").arg(job->title)); + } else if (job->error != tr("Cancelled")) { + emit toastRequested(tr("Download failed: %1").arg(job->title)); + } +} +void DownloadManager::cancel(const QString& id) +{ + const auto job = m_jobs.value(id); + if (!job || job->stopped) + return; + finish(job, tr("Cancelled")); + job->state = QStringLiteral("cancelled"); + m_sources->cancelDownloadNegotiation(job->itemId, job->id); + persist(); + emit changed(); +} +void DownloadManager::retry(const QString& id) +{ + const auto job = m_jobs.value(id); + if (!job || !job->stopped || job->state == QStringLiteral("complete")) + return; + const QString itemId = job->itemId; + const QVariantMap option = job->option; + start(itemId, option); + if (statusFor(itemId).value(QStringLiteral("id")).toString() != id) + remove(id); +} +void DownloadManager::remove(const QString& id) +{ + const auto job = m_jobs.value(id); + if (!job || !job->stopped) + return; + const bool content = job->path.startsWith(QStringLiteral("content://")); + if (job->state == QStringLiteral("complete") + && !(content ? AndroidDownloadStorage::removeFile(QUrl(job->path)) : QFile::remove(job->path)) + && (content || QFileInfo::exists(job->path))) { + m_problem = tr("Could not remove the downloaded file."); + emit changed(); + return; + } + if (!job->path.isEmpty() && !content) + QFile::remove(job->path + QStringLiteral(".spool.json")); + m_jobs.remove(id); + m_order.removeAll(id); + persist(); + emit changed(); + emit libraryChanged(); +} +void DownloadManager::cancelAll() +{ + const auto ids = m_order; + for (const auto& id : ids) + cancel(id); +} +void DownloadManager::clearFinished() +{ + const auto ids = m_order; + for (const auto& id : ids) + if (m_jobs.value(id)->stopped && m_jobs.value(id)->state != QStringLiteral("complete")) + remove(id); +} +QString DownloadManager::offlineItemId(const QString& id) const +{ + const auto job = m_jobs.value(id); + if (!job || job->state != QStringLiteral("complete")) + return {}; + const QString canonical + = job->path.startsWith(QStringLiteral("content://")) ? job->path : QFileInfo(job->path).canonicalFilePath(); + if (canonical.isEmpty()) + return {}; + return m_sources->scoped(QStringLiteral("spool-downloads"), + QString::fromLatin1(QCryptographicHash::hash(canonical.toUtf8(), QCryptographicHash::Sha256).toHex())); +} +void DownloadManager::setDestination(const QUrl& folder) +{ + if (!supported() || (!folder.isLocalFile() && !AndroidDownloadStorage::isTree(folder))) + return; + if (AndroidDownloadStorage::isTree(folder)) { + const auto previous = m_destination; + m_destination = folder.toString(QUrl::FullyEncoded); + if (!persist()) + m_destination = previous; + emit changed(); + return; + } + const QString path = QDir::cleanPath(folder.toLocalFile()); + if (mobile()) { + const auto choices = destinationChoices(); + if (!std::any_of(choices.cbegin(), choices.cend(), + [&folder](const QVariant& row) { return row.toMap().value(QStringLiteral("url")).toUrl() == folder; })) + return; + } + if (!QFileInfo(path).isAbsolute() || !QDir().mkpath(path)) { + m_problem = tr("The download folder is unavailable."); + } else { + const QString previous = m_destination; + m_destination = path; + if (!persist()) + m_destination = previous; + } + emit changed(); +} +void DownloadManager::resetDestination() +{ + const auto previous = m_destination; + m_destination = defaultDestination(); + if (!persist()) + m_destination = previous; + emit changed(); +} +void DownloadManager::chooseFolder() +{ + m_androidStorage->chooseFolder(); +} +bool DownloadManager::persist() +{ + QDir().mkpath(m_dataRoot); + QJsonArray rows; + for (const auto& id : m_order) { + const auto job = m_jobs.value(id); + rows.push_back( + QJsonObject::fromVariantMap({ { QStringLiteral("id"), id }, { QStringLiteral("itemId"), job->itemId }, + { QStringLiteral("title"), job->title }, { QStringLiteral("option"), job->option }, + { QStringLiteral("path"), job->path }, { QStringLiteral("temporary"), job->temporary }, + { QStringLiteral("metadata"), job->metadata }, { QStringLiteral("state"), job->state }, + { QStringLiteral("error"), job->error }, { QStringLiteral("received"), QString::number(job->received) }, + { QStringLiteral("total"), QString::number(job->total) } })); + } + const QByteArray data = QJsonDocument( + QJsonObject { { QStringLiteral("version"), 1 }, { QStringLiteral("destination"), m_destination }, + { QStringLiteral("jobs"), + rows } }).toJson(QJsonDocument::Compact); + QSaveFile file(QDir(m_dataRoot).filePath(QStringLiteral("downloads.json"))); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) { + m_problem = tr("Could not save the download list."); + return false; + } + m_problem.clear(); + return true; +} +void DownloadManager::restore() +{ + QFile file(QDir(m_dataRoot).filePath(QStringLiteral("downloads.json"))); + if (!file.open(QIODevice::ReadOnly) || file.size() > 16 * 1024 * 1024) + return; + const auto root = QJsonDocument::fromJson(file.readAll()).object(); + if (root.value(QStringLiteral("version")).toInt() != 1) + return; + const QString savedDestination = root.value(QStringLiteral("destination")).toString(); + if (!savedDestination.isEmpty() + && (QFileInfo(savedDestination).isAbsolute() || AndroidDownloadStorage::isTree(QUrl(savedDestination)))) + m_destination = savedDestination; + for (const auto& value : root.value(QStringLiteral("jobs")).toArray()) { + const auto row = value.toObject(); + auto job = std::make_shared(); + job->id = row.value(QStringLiteral("id")).toString(); + job->itemId = row.value(QStringLiteral("itemId")).toString(); + job->title = row.value(QStringLiteral("title")).toString(); + job->option = row.value(QStringLiteral("option")).toObject().toVariantMap(); + job->path = row.value(QStringLiteral("path")).toString(); + job->temporary = row.value(QStringLiteral("temporary")).toString(); + job->metadata = row.value(QStringLiteral("metadata")).toObject().toVariantMap(); + job->state = row.value(QStringLiteral("state")).toString(); + job->error = row.value(QStringLiteral("error")).toString(); + job->received = row.value(QStringLiteral("received")).toString().toLongLong(); + job->total = row.value(QStringLiteral("total")).toString().toLongLong(); + job->stopped = true; + if (QUuid(job->id).isNull() || job->itemId.isEmpty() || m_jobs.contains(job->id)) + continue; + if (!job->temporary.isEmpty()) { + if (job->temporary.startsWith(QStringLiteral("content://"))) + AndroidDownloadStorage::removeFile(QUrl(job->temporary)); + else + QFile::remove(job->temporary); + job->temporary.clear(); + } + QFile existing(job->path); + if ((job->state == QStringLiteral("complete") && !existing.open(QIODevice::ReadOnly)) + || job->state == QStringLiteral("preparing") || job->state == QStringLiteral("downloading")) { + job->state = QStringLiteral("failed"); + job->error = tr("Interrupted or file unavailable. Retry to download again."); + } + m_jobs.insert(job->id, job); + m_order.push_back(job->id); + } +} +} // namespace Spool + +QVariantList Spool::DownloadManager::folderEntries(const QString& path) const +{ + QVariantList rows; + if (!canChooseFolder() || mobile() || !QFileInfo(path).isAbsolute() || !QFileInfo(path).isDir()) + return rows; + QDir directory(path); + if (directory.cdUp()) + rows.push_back(QVariantMap { { QStringLiteral("id"), directory.absolutePath() }, + { QStringLiteral("label"), tr("Up a folder") }, { QStringLiteral("path"), directory.absolutePath() } }); + const auto entries = QDir(path).entryInfoList(QDir::Dirs | QDir::Readable | QDir::NoDotAndDotDot, QDir::Name); + rows.reserve(rows.size() + entries.size()); + for (const auto& entry : entries) + rows.push_back(QVariantMap { { QStringLiteral("id"), entry.absoluteFilePath() }, + { QStringLiteral("label"), entry.fileName() }, { QStringLiteral("path"), entry.absoluteFilePath() } }); + return rows; +} diff --git a/src/app/DownloadManager.h b/src/app/DownloadManager.h new file mode 100644 index 00000000..0ff98cc2 --- /dev/null +++ b/src/app/DownloadManager.h @@ -0,0 +1,94 @@ +#pragma once + +#include "../provider/DownloadSource.h" +#include +#include +#include +#include +#include + +class QNetworkAccessManager; +namespace Spool { +class SourceHub; +class AndroidDownloadStorage; +class TlsTrustController; + +class DownloadManager final : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantList jobs READ jobs NOTIFY changed) + Q_PROPERTY(QString destination READ destination NOTIFY changed) + Q_PROPERTY(bool supported READ supported CONSTANT) + Q_PROPERTY(bool mobile READ mobile CONSTANT) + Q_PROPERTY(bool canChooseFolder READ canChooseFolder CONSTANT) + Q_PROPERTY(QVariantList destinationChoices READ destinationChoices CONSTANT) + Q_PROPERTY(bool opened READ opened NOTIFY openedChanged) + Q_PROPERTY(QString selectionItemId READ selectionItemId NOTIFY openedChanged) + Q_PROPERTY(QString problem READ problem NOTIFY changed) +public: + DownloadManager( + SourceHub *sources, QString dataRoot, TlsTrustController *trust = nullptr, QObject *parent = nullptr); + ~DownloadManager() override; + QVariantList jobs() const; + QString destination() const + { + return m_destination; + } + QVariantList libraryFiles() const; + static bool supported(); + static bool mobile(); + static bool canChooseFolder(); + static QVariantList destinationChoices(); + bool opened() const + { + return m_opened; + } + QString selectionItemId() const + { + return m_selectionItemId; + } + QString problem() const + { + return m_problem; + } + Q_INVOKABLE void open(const QString& itemId = {}); + Q_INVOKABLE QVariantList folderEntries(const QString& path) const; + Q_INVOKABLE void close(); + Q_INVOKABLE void start(const QString& itemId, const QVariantMap& option); + Q_INVOKABLE void cancel(const QString& id); + Q_INVOKABLE void retry(const QString& id); + Q_INVOKABLE void remove(const QString& id); + Q_INVOKABLE void setDestination(const QUrl& folder); + Q_INVOKABLE void resetDestination(); + Q_INVOKABLE void chooseFolder(); + Q_INVOKABLE QString offlineItemId(const QString& id) const; + Q_INVOKABLE QVariantMap statusFor(const QString& itemId) const; + Q_INVOKABLE void cancelAll(); + Q_INVOKABLE void clearFinished(); +signals: + void changed(); + void openedChanged(); + void libraryChanged(); + void toastRequested(const QString& message); + +private: + struct Job; + QCoro::Task prepare(std::shared_ptr job); + QCoro::Task release(DownloadPlan plan); + void transfer(const std::shared_ptr& job, DownloadPlan plan); + void consume(const std::shared_ptr& job); + void finish(const std::shared_ptr& job, const QString& error = {}); + bool persist(); + void restore(); + static QString defaultDestination(); + SourceHub *m_sources; + QString m_dataRoot; + QString m_destination; + QString m_problem; + QNetworkAccessManager *m_network; + AndroidDownloadStorage *m_androidStorage; + QHash> m_jobs; + QStringList m_order; + bool m_opened = false; + QString m_selectionItemId; +}; +} // namespace Spool diff --git a/src/app/GraphicsStartup.cpp b/src/app/GraphicsStartup.cpp index fe9c1675..854c8f1a 100644 --- a/src/app/GraphicsStartup.cpp +++ b/src/app/GraphicsStartup.cpp @@ -4,7 +4,7 @@ #include "platform/PlatformDisplayOutput.h" #include "platform/PlatformStartup.h" #include "player/RenderTargetProfile.h" -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) #include "platform/linux/WaylandColorInfo.h" #endif @@ -14,7 +14,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { QSGRendererInterface::GraphicsApi GraphicsStartup::configureBeforeApplication(bool launchTest) { @@ -22,7 +22,7 @@ QSGRendererInterface::GraphicsApi GraphicsStartup::configureBeforeApplication(bo // compatibility path; Linux and macOS use Vulkan for HDR-capable rendering. #if defined(Q_OS_WIN) auto graphicsApi = QSGRendererInterface::Direct3D11; -#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) && QT_CONFIG(vulkan) +#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) && QT_CONFIG(vulkan) auto graphicsApi = QSGRendererInterface::Vulkan; #elif defined(Q_OS_MACOS) && QT_CONFIG(vulkan) auto graphicsApi = QSGRendererInterface::Vulkan; @@ -42,7 +42,15 @@ QSGRendererInterface::GraphicsApi GraphicsStartup::configureBeforeApplication(bo qInfo("startup: graphics backend setting asks for %s", requestedApi.constData()); } } - if (launchTest) { +#if defined(SPOOL_APPLE_MOBILE) + // The UIKit simulator/device is a real native surface. Its smoke must + // exercise the GLES scene graph used by the embedded mpv framebuffer. + const bool softwareLaunchTest = false; + Q_UNUSED(launchTest); +#else + const bool softwareLaunchTest = launchTest; +#endif + if (softwareLaunchTest) { // Headless launch tests cannot assume any graphics adapter exists. graphicsApi = QSGRendererInterface::Software; } else if (requestedApi == "opengl") { @@ -65,6 +73,9 @@ QSGRendererInterface::GraphicsApi GraphicsStartup::configureBeforeApplication(bo // QTBUG-149443: MoltenVK must present through a plain CAMetalLayer. qputenv("QT_MTL_NO_TRANSACTION", QByteArrayLiteral("1")); } +#endif +#if defined(SPOOL_APPLE_MOBILE) + graphicsApi = QSGRendererInterface::OpenGL; #endif QQuickWindow::setGraphicsApi(graphicsApi); qInfo("startup: scene graph on %s", @@ -81,18 +92,19 @@ QByteArray GraphicsStartup::prepareBeforeWindow(QSGRendererInterface::GraphicsAp bool automaticHdr = false; bool allowHdrRequest = !launchTest && (graphicsApi == QSGRendererInterface::Vulkan || graphicsApi == QSGRendererInterface::Direct3D11); -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) allowHdrRequest = allowHdrRequest && QGuiApplication::platformName().startsWith(QLatin1String("wayland")); automaticHdr = allowHdrRequest; #elif defined(Q_OS_WIN) - // Qt checks the window's output and Windows' Use HDR state before choosing - // FP16. The live probe verifies the actual buffer and native signaling. - // Vulkan requests the same scRGB encoding through swapchain colorspace. + // Check Windows' current desktop HDR mode before asking Qt for FP16. + // Backend support alone also exists on SDR desktops. automaticHdr = allowHdrRequest; #endif + const bool desktopHdr + = allowHdrRequest && PlatformDisplayOutput::desktopHdrEnabled(QGuiApplication::primaryScreen()); const QByteArray hdrRequest - = allowHdrRequest ? RenderTargetPolicy::startupSwapChainRequest(automaticHdr) : QByteArray(); -#if !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) + = allowHdrRequest ? RenderTargetPolicy::startupSwapChainRequest(desktopHdr, automaticHdr) : QByteArray(); +#if !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) if (!hdrRequest.isEmpty()) { qputenv("QSG_RHI_HDR", hdrRequest); qInfo("startup: set QSG_RHI_HDR=%s in environment", hdrRequest.constData()); @@ -112,13 +124,13 @@ GraphicsStartup::GraphicsStartup(NativeAppWindow& window, const QByteArray& hdrR : QObject(&window) , m_window(window) { -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) m_waylandHdrSurface = std::make_unique(&window); #endif window.setProperty("_qt_sg_hdr_format", hdrRequest); qInfo("startup: output request=%s qpa=%s", hdrRequest.isEmpty() ? "SDR" : hdrRequest.constData(), qPrintable(QGuiApplication::platformName())); -#if !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) // QQuickWindow stops its scene graph before destroying its QObject children. // Direct callbacks therefore retain a live owner through render shutdown; // queued GUI updates are cancelled automatically when the owner dies. @@ -133,7 +145,7 @@ GraphicsStartup::~GraphicsStartup() = default; void GraphicsStartup::probeSwapchain() { -#if !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) // QRhi access stays on the render thread. Retry until the swapchain exists, // then snapshot once per scene graph, not on every frame. Output-change // subscriptions are deliberately separate from this startup observation. @@ -149,9 +161,9 @@ void GraphicsStartup::probeSwapchain() // Native display queries, Wayland protocol ownership and QML // notifications belong to the GUI thread, never the render thread. PlatformDisplayOutput::updateDisplayLuminance(display, &m_window); - bool scrgb - = display.hdrAvailable && display.preferredFormat == RenderTargetProfile::Format::ExtendedSrgbLinear; -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) + bool scrgb = display.desktopHdrEnabled && display.hdrAvailable + && display.preferredFormat == RenderTargetProfile::Format::ExtendedSrgbLinear; +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) if (!m_waylandHdrSurface->setEnabled(scrgb && display.needsWaylandDescription)) scrgb = false; #endif @@ -165,4 +177,4 @@ void GraphicsStartup::probeSwapchain() #endif } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GraphicsStartup.h b/src/app/GraphicsStartup.h index 94a54bb1..33d872f1 100644 --- a/src/app/GraphicsStartup.h +++ b/src/app/GraphicsStartup.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative { +namespace Spool { class NativeAppWindow; class WaylandHdrSurface; @@ -34,9 +34,9 @@ class GraphicsStartup final : public QObject { NativeAppWindow& m_window; // Accessed only by direct scene-graph callbacks on the render thread. bool m_outputSnapshotPending = true; -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) std::unique_ptr m_waylandHdrSurface; #endif }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SyncPlayClock.cpp b/src/app/GroupClock.cpp similarity index 68% rename from src/app/SyncPlayClock.cpp rename to src/app/GroupClock.cpp index cff94568..5bd36f06 100644 --- a/src/app/SyncPlayClock.cpp +++ b/src/app/GroupClock.cpp @@ -1,9 +1,9 @@ -#include "SyncPlayClock.h" +#include "GroupClock.h" #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -12,29 +12,29 @@ namespace { } // namespace -double SyncPlayTimeMeasurement::offsetMs() const +double GroupClockSample::offsetMs() const { return ((serverRequestReceivedMs - localRequestSentMs) + (serverResponseSentMs - localResponseReceivedMs)) / 2.0; } -double SyncPlayTimeMeasurement::roundTripDelayMs() const +double GroupClockSample::roundTripDelayMs() const { return (localResponseReceivedMs - localRequestSentMs) - (serverResponseSentMs - serverRequestReceivedMs); } -double SyncPlayTimeMeasurement::pingMs() const +double GroupClockSample::pingMs() const { return std::max(0.0, roundTripDelayMs() / 2.0); } -void SyncPlayClock::reset() +void GroupClock::reset() { m_measurements.clear(); m_best = {}; m_ready = false; } -void SyncPlayClock::addMeasurement(const SyncPlayTimeMeasurement& measurement) +void GroupClock::addMeasurement(const GroupClockSample& measurement) { m_measurements.push_back(measurement); while (m_measurements.size() > kMaxMeasurements) @@ -42,28 +42,28 @@ void SyncPlayClock::addMeasurement(const SyncPlayTimeMeasurement& measurement) selectBestMeasurement(); } -bool SyncPlayClock::ready() const +bool GroupClock::ready() const { return m_ready; } -double SyncPlayClock::offsetMs() const +double GroupClock::offsetMs() const { return m_ready ? m_best.offsetMs() : 0.0; } -double SyncPlayClock::pingMs() const +double GroupClock::pingMs() const { return m_ready ? m_best.pingMs() : 0.0; } -std::int64_t SyncPlayClock::localDelayUntil(std::int64_t serverTimeMs, std::int64_t localNowMs) const +std::int64_t GroupClock::localDelayUntil(std::int64_t serverTimeMs, std::int64_t localNowMs) const { const std::int64_t localTargetMs = serverTimeMs - static_cast(std::llround(offsetMs())); return std::max(0, localTargetMs - localNowMs); } -std::int64_t SyncPlayClock::estimatePositionTicks( +std::int64_t GroupClock::estimatePositionTicks( std::int64_t positionTicks, std::int64_t serverTimeMs, std::int64_t localNowMs) const { const std::int64_t serverNowMs = localNowMs + static_cast(std::llround(offsetMs())); @@ -71,7 +71,7 @@ std::int64_t SyncPlayClock::estimatePositionTicks( return positionTicks + elapsedMs * kTicksPerMillisecond; } -void SyncPlayClock::selectBestMeasurement() +void GroupClock::selectBestMeasurement() { if (m_measurements.empty()) { m_ready = false; @@ -79,10 +79,10 @@ void SyncPlayClock::selectBestMeasurement() } m_best = *std::min_element(m_measurements.cbegin(), m_measurements.cend(), - [](const SyncPlayTimeMeasurement& left, const SyncPlayTimeMeasurement& right) { + [](const GroupClockSample& left, const GroupClockSample& right) { return left.roundTripDelayMs() < right.roundTripDelayMs(); }); m_ready = true; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SyncPlayClock.h b/src/app/GroupClock.h similarity index 72% rename from src/app/SyncPlayClock.h rename to src/app/GroupClock.h index 81d19263..8071d022 100644 --- a/src/app/SyncPlayClock.h +++ b/src/app/GroupClock.h @@ -3,9 +3,9 @@ #include #include -namespace JellyfinNative { +namespace Spool { -struct SyncPlayTimeMeasurement { +struct GroupClockSample { std::int64_t localRequestSentMs = 0; std::int64_t serverRequestReceivedMs = 0; std::int64_t serverResponseSentMs = 0; @@ -16,10 +16,10 @@ struct SyncPlayTimeMeasurement { double pingMs() const; }; -class SyncPlayClock final { +class GroupClock final { public: void reset(); - void addMeasurement(const SyncPlayTimeMeasurement& measurement); + void addMeasurement(const GroupClockSample& measurement); bool ready() const; double offsetMs() const; @@ -31,9 +31,9 @@ class SyncPlayClock final { private: void selectBestMeasurement(); - std::vector m_measurements; - SyncPlayTimeMeasurement m_best; + std::vector m_measurements; + GroupClockSample m_best; bool m_ready = false; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp new file mode 100644 index 00000000..f990a14f --- /dev/null +++ b/src/app/GroupPlaybackController.cpp @@ -0,0 +1,800 @@ +#include "GroupPlaybackController.h" + +#include "../common/AsyncTask.h" +#include "../player/PlayQueueController.h" +#include "../player/PlayerController.h" +#include "../provider/SourceHub.h" + +#include +#include + +#include +#include +#include + +namespace Spool { + +namespace { + constexpr int kGreedyTimeSyncIntervalMs = 1'000; + constexpr int kSteadyTimeSyncIntervalMs = 60'000; + // mpv retimes real audio output, so small clock errors stay inaudible and + // a seek replaces rate correction once that would take too long. + constexpr int kMinDelaySpeedToSyncMs = 100; + constexpr int kMinDelaySkipToSyncMs = 400; + constexpr int kSpeedToSyncDurationMs = 1'000; + constexpr double kSpeedToSyncMinSpeed = 0.2; + constexpr int kSyncMethodThresholdMs = 3'000; + constexpr int kSyncCooldownMs = kSyncMethodThresholdMs / 2; + constexpr double kSpeedToSyncMinRate = 0.97; + constexpr double kSpeedToSyncMaxRate = 1.03; + constexpr int kSpeedToSyncMaxDurationMs = 10'000; + constexpr int kInternalSeekBufferingSuppressionMs = 3'000; + constexpr qint64 kTicksPerSecond = 10'000'000; + constexpr qint64 kTicksPerMillisecond = 10'000; + + qint64 ms(const QVariantMap& event, const char *key) + { + return event.value(QLatin1String(key)).toLongLong(); + } + + QString errorMessage(const QString& code) + { + static const QHash messages { + { QStringLiteral("group_missing"), QStringLiteral("That group no longer exists.") }, + { QStringLiteral("access_denied"), QStringLiteral("This account can't play what the group is watching.") }, + { QStringLiteral("create_denied"), QStringLiteral("The server didn't allow creating a group.") }, + { QStringLiteral("join_denied"), QStringLiteral("The server didn't allow joining that group.") }, + { QStringLiteral("disabled"), QStringLiteral("Watching together is turned off on this server.") }, + }; + return messages.value(code, QStringLiteral("Watch together: %1").arg(code)); + } +} // namespace + +GroupCorrection GroupDriftPolicy::evaluate(double diffMs) +{ + const double absDiffMs = std::abs(diffMs); + if (absDiffMs < kMinDelaySpeedToSyncMs) + return {}; + if (absDiffMs < kMinDelaySkipToSyncMs) { + // Keep the speed positive when the client is ahead by more than the + // correction window allows. + double speedToSyncTimeMs = kSpeedToSyncDurationMs; + if (diffMs <= -speedToSyncTimeMs * kSpeedToSyncMinSpeed) + speedToSyncTimeMs = absDiffMs / (1.0 - kSpeedToSyncMinSpeed); + const double requestedSpeed = 1.0 + diffMs / speedToSyncTimeMs; + const double speed = std::clamp(requestedSpeed, kSpeedToSyncMinRate, kSpeedToSyncMaxRate); + if (speed != requestedSpeed) { + // Recover the same distance at the bounded rate; hitting the cap + // leaves a remainder for the next correction. + speedToSyncTimeMs + = std::min(absDiffMs / std::abs(speed - 1.0), static_cast(kSpeedToSyncMaxDurationMs)); + } + return { GroupCorrection::Method::Speed, speed, static_cast(std::llround(speedToSyncTimeMs)) }; + } + return { GroupCorrection::Method::Skip, 1.0, 0 }; +} + +GroupPlaybackController::GroupPlaybackController( + SourceHub *hub, PlayerController *player, PlayQueueController *playQueue, QObject *parent) + : GroupPlayback(parent) + , m_hub(hub) + , m_player(player) + , m_playQueue(playQueue) +{ + m_timeSyncTimer.setSingleShot(true); + m_commandTimer.setSingleShot(true); + // mpv's seeking flag is often a short transition, not starvation; wait + // a second before telling the group this client is buffering. + m_bufferingDebounceTimer.setSingleShot(true); + m_bufferingDebounceTimer.setInterval(1'000); + m_speedCorrectionTimer.setSingleShot(true); + m_correctionTimer.setInterval(1'000); + connect(&m_timeSyncTimer, &QTimer::timeout, this, &GroupPlaybackController::requestTimeSync); + connect(&m_commandTimer, &QTimer::timeout, this, &GroupPlaybackController::executeScheduledCommand); + connect(&m_correctionTimer, &QTimer::timeout, this, &GroupPlaybackController::correctPlaybackDrift); + connect(&m_speedCorrectionTimer, &QTimer::timeout, this, &GroupPlaybackController::finishSpeedCorrection); + connect(&m_bufferingDebounceTimer, &QTimer::timeout, this, [this] { sendPlayerBufferingState(true); }); + connect( + m_player, &PlayerController::playbackStateChanged, this, &GroupPlaybackController::handlePlayerStateChanged); + connect(m_hub, &Provider::capabilitiesChanged, this, &GroupPlaybackController::availableChanged); + connect(m_hub, &SourceHub::accountEvent, this, + [this](const QString& account, const QString& type, const QVariantMap& payload) { + if (type == QStringLiteral("group")) + handleEvent(account, payload); + }); +} + +bool GroupPlaybackController::available() const +{ + return m_hub->capabilities().testFlag(Provider::GroupPlayback); +} + +qint64 GroupPlaybackController::serverNowMs() const +{ + return QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); +} + +void GroupPlaybackController::send(const QString& action, QVariantMap arguments, const char *failure) +{ + if (m_account.isEmpty()) + return; + arguments.insert(QStringLiteral("action"), action); + Async::runScoped( + this, m_hub->call(m_account, QStringLiteral("groupSend"), arguments), [](QVariantMap) {}, + [this, failure](const std::exception_ptr& error) { + if (failure) + emit errorText(QStringLiteral("%1: %2").arg(QLatin1String(failure), exceptionMessage(error))); + }, + "group request"); +} + +void GroupPlaybackController::refreshGroups() +{ + m_groups.clear(); + for (Provider *source : m_hub->sources()) { + if (!source->capabilities().testFlag(Provider::GroupPlayback)) + continue; + const QString account = source->id(); + const QString label = source->displayName(); + ++m_loadingGroups; + Async::runScoped( + this, m_hub->call(account, QStringLiteral("groups")), + [this, account, label](QVariantMap result) { + --m_loadingGroups; + for (const QVariant& value : result.value(QStringLiteral("items")).toList()) { + QVariantMap group = value.toMap(); + group.insert( + QStringLiteral("id"), m_hub->scoped(account, group.value(QStringLiteral("id")).toString())); + group.insert(QStringLiteral("accountId"), account); + group.insert(QStringLiteral("accountLabel"), label); + m_groups.append(group); + } + emit groupsChanged(); + }, + [this](const std::exception_ptr&) { + --m_loadingGroups; + emit groupsChanged(); + }, + "group list"); + } + emit groupsChanged(); +} + +void GroupPlaybackController::createGroup(const QString& accountId, const QString& name) +{ + // The server announces the group over the account's socket before the + // request returns, so events from this account count from now. + m_account = accountId; + Async::runScoped( + this, m_hub->call(accountId, QStringLiteral("groupCreate"), { { QStringLiteral("name"), name } }), + [this](QVariantMap) { beginTimeSync(); }, + [this](const std::exception_ptr& error) { + if (m_groupId.isEmpty()) + m_account.clear(); + emit errorText(exceptionMessage(error)); + }, + "group create"); +} + +void GroupPlaybackController::joinGroup(const QString& groupId) +{ + const QString account = m_hub->accountOf(groupId); + if (account.isEmpty()) + return; + // As with creating: the joined event can arrive before the reply. + m_account = account; + Async::runScoped( + this, + m_hub->call(account, QStringLiteral("groupJoin"), { { QStringLiteral("groupId"), SourceHub::rawId(groupId) } }), + [this, groupId](QVariantMap) { + m_player->setSyncPlaybackSpeed(1.0); + beginTimeSync(); + if (!m_groupId.isEmpty()) + return; + m_groupId = SourceHub::rawId(groupId); + for (const QVariant& group : std::as_const(m_groups)) { + if (group.toMap().value(QStringLiteral("id")) == groupId) + applyInfo(group.toMap()); + } + m_joinedAtServerMs = serverNowMs(); + emit groupChanged(); + sendPlayerBufferingState(true); + }, + [this](const std::exception_ptr& error) { + if (m_groupId.isEmpty()) + m_account.clear(); + emit errorText(exceptionMessage(error)); + }, + "group join"); +} + +void GroupPlaybackController::leaveGroup() +{ + if (m_groupId.isEmpty()) + return; + const QString account = m_account; + clearGroup(); + Async::runScoped( + this, m_hub->call(account, QStringLiteral("groupLeave")), [](QVariantMap) { }, + [](const std::exception_ptr&) { }, "group leave"); +} + +void GroupPlaybackController::requestUnpauseWhenReady() +{ + if (!enabled()) + return; + m_queueHandoff.arm(); + setWaitingForGroupPlayback(true); +} + +void GroupPlaybackController::cancelPendingUnpause() +{ + m_queueHandoff.cancel(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); +} + +void GroupPlaybackController::requestTogglePause() +{ + if (!enabled() || !m_player->sessionActive()) + return; + if (m_player->paused()) { + requestGroupUnpause(); + } else { + // Pause here at once; the group's command then settles the position. + m_player->setPaused(true); + send(QStringLiteral("pause"), {}, "Pause"); + } +} + +void GroupPlaybackController::requestSeek(double positionSeconds) +{ + if (!enabled() || !std::isfinite(positionSeconds)) + return; + m_seekResume.arm(!m_player->paused()); + send(QStringLiteral("seek"), + { { QStringLiteral("positionTicks"), + QString::number(qint64(std::max(0.0, positionSeconds) * kTicksPerSecond)) } }, + "Seek"); +} + +void GroupPlaybackController::requestRelativeSeek(double deltaSeconds) +{ + requestSeek(m_player->positionSeconds() + deltaSeconds); +} + +void GroupPlaybackController::requestNextItem() +{ + if (enabled() && !m_entryId.isEmpty()) + send(QStringLiteral("next"), { { QStringLiteral("entryId"), m_entryId } }); +} + +void GroupPlaybackController::requestPreviousItem() +{ + if (enabled() && !m_entryId.isEmpty()) + send(QStringLiteral("previous"), { { QStringLiteral("entryId"), m_entryId } }); +} + +void GroupPlaybackController::requestMoveItem(const QString& entryId, int newIndex) +{ + if (enabled() && !entryId.isEmpty()) + send(QStringLiteral("move"), { { QStringLiteral("entryId"), entryId }, { QStringLiteral("index"), newIndex } }); +} + +void GroupPlaybackController::requestRemoveItems(const QStringList& entryIds) +{ + if (enabled() && !entryIds.isEmpty()) + send(QStringLiteral("remove"), { { QStringLiteral("entryIds"), entryIds } }); +} + +void GroupPlaybackController::requestQueueItems(const QStringList& itemIds, bool queueNext) +{ + if (enabled() && !itemIds.isEmpty()) + send( + QStringLiteral("queue"), { { QStringLiteral("itemIds"), itemIds }, { QStringLiteral("next"), queueNext } }); +} + +void GroupPlaybackController::requestPlayItem(const QString& entryId) +{ + if (enabled() && !entryId.isEmpty()) + send(QStringLiteral("play"), { { QStringLiteral("entryId"), entryId } }); +} + +QCoro::Task GroupPlaybackController::publishQueue( + QStringList itemIds, int playingIndex, qint64 startPositionTicks) +{ + co_await m_hub->call(m_account, QStringLiteral("groupSend"), + { { QStringLiteral("action"), QStringLiteral("setQueue") }, { QStringLiteral("itemIds"), itemIds }, + { QStringLiteral("index"), playingIndex }, + { QStringLiteral("positionTicks"), QString::number(startPositionTicks) } }); +} + +void GroupPlaybackController::handleEvent(const QString& accountId, const QVariantMap& event) +{ + const QString type = event.value(QStringLiteral("type")).toString(); + if (type == QStringLiteral("connected")) { + if (accountId == m_account && enabled()) + beginTimeSync(); + return; + } + if (accountId != m_account || (m_groupId.isEmpty() && type != QStringLiteral("joined"))) + return; + if (type == QStringLiteral("joined") || type == QStringLiteral("update")) { + applyInfo(event); + // The server stamps this and then, for a group already playing, sends + // the command that brings us in line. Compare against the stamp, not a + // local estimate taken a round trip later that would discard it. + m_joinedAtServerMs = ms(event, "at") > 0 ? ms(event, "at") : serverNowMs(); + sendPlayerBufferingState(true); + } else if (type == QStringLiteral("participants")) { + m_participants = event.value(QStringLiteral("participants")).toStringList(); + emit groupChanged(); + } else if (type == QStringLiteral("participantJoined") || type == QStringLiteral("participantLeft")) { + const QString name = event.value(QStringLiteral("name")).toString(); + m_participants.removeAll(name); + if (type == QStringLiteral("participantJoined") && !name.isEmpty()) + m_participants.append(name); + emit groupChanged(); + } else if (type == QStringLiteral("state")) { + m_groupState = event.value(QStringLiteral("state")).toString(); + m_groupStateReason = event.value(QStringLiteral("reason")).toString(); + if (m_groupState == QStringLiteral("Waiting") && m_groupStateReason == QStringLiteral("Unpause")) + setWaitingForGroupPlayback(true); + else if (m_groupState == QStringLiteral("Paused") + && (m_groupStateReason == QStringLiteral("Ready") || m_groupStateReason == QStringLiteral("Pause")) + && !m_unpauseRequestPending) + setWaitingForGroupPlayback(false); + if (m_seekResume.takeWhenReady(m_groupState, m_groupStateReason)) + requestGroupUnpause(); + emit groupChanged(); + } else if (type == QStringLiteral("queue")) { + applyQueue(event); + } else if (type == QStringLiteral("command")) { + handleCommand(event); + } else if (type == QStringLiteral("left")) { + clearGroup(); + } else if (type == QStringLiteral("error")) { + emit errorText(errorMessage(event.value(QStringLiteral("code")).toString())); + } +} + +void GroupPlaybackController::applyInfo(const QVariantMap& info) +{ + const QString id = info.value(QStringLiteral("groupId")).toString(); + if (!id.isEmpty()) + m_groupId = id; + m_groupName = info.value(QStringLiteral("name"), QStringLiteral("Watch together")).toString(); + m_groupState = info.value(QStringLiteral("state")).toString(); + m_groupStateReason = info.value(QStringLiteral("reason")).toString(); + m_participants = info.value(QStringLiteral("participants")).toStringList(); + if (!m_speedCorrectionActive) + m_player->setSyncPlaybackSpeed(1.0); + emit groupChanged(); +} + +void GroupPlaybackController::handleCommand(const QVariantMap& event) +{ + const qint64 emittedAt = ms(event, "emittedAt"); + if (emittedAt > 0 && m_joinedAtServerMs > 0 && emittedAt < m_joinedAtServerMs) + return; + const QString command = event.value(QStringLiteral("command")).toString(); + const qint64 serverTimeMs = ms(event, "at"); + const qint64 positionTicks = event.value(QStringLiteral("positionTicks")).toLongLong(); + const QString entryId = event.value(QStringLiteral("entryId")).toString(); + if (!entryId.isEmpty() && !m_entryId.isEmpty() && entryId != m_entryId) + return; + m_scheduledEntryId = entryId; + const QString key = QStringLiteral("%1|%2|%3|%4").arg(command).arg(serverTimeMs).arg(positionTicks).arg(m_entryId); + if (key == m_lastCommandKey && m_commandTimer.isActive()) + return; + m_lastCommandKey = key; + m_scheduledCommand = command; + m_scheduledPositionTicks = positionTicks; + m_scheduledServerTimeMs = serverTimeMs; + m_commandDue = false; + m_commandTimer.stop(); + m_bufferingDebounceTimer.stop(); + if (command == QStringLiteral("unpause")) + setWaitingForGroupPlayback(true); + const qint64 localNowMs = QDateTime::currentMSecsSinceEpoch(); + const qint64 delayMs = serverTimeMs <= 0 ? 0 + : m_clock.ready() ? m_clock.localDelayUntil(serverTimeMs, localNowMs) + : std::max(0, serverTimeMs - localNowMs); + m_commandTimer.start(static_cast(std::min(delayMs, std::numeric_limits::max()))); +} + +void GroupPlaybackController::applyQueue(const QVariantMap& event) +{ + const qint64 updatedAt = ms(event, "at"); + if (updatedAt > 0 && updatedAt < m_lastQueueUpdateMs) + return; + m_lastQueueUpdateMs = std::max(m_lastQueueUpdateMs, updatedAt); + const QVariantList entries = event.value(QStringLiteral("items")).toList(); + const int playingIndex = event.value(QStringLiteral("index"), -1).toInt(); + if (entries.isEmpty() || playingIndex < 0 || playingIndex >= entries.size()) + return; + QStringList itemIds; + QStringList entryIds; + for (const QVariant& value : entries) { + const QVariantMap entry = value.toMap(); + itemIds.append(m_hub->scoped(m_account, entry.value(QStringLiteral("itemId")).toString())); + entryIds.append(entry.value(QStringLiteral("entryId")).toString()); + } + if (itemIds.contains(QString())) + return; + + const quint64 generation = ++m_queueGeneration; + m_queueHandoff.observeQueueUpdate(); + m_entryId = entryIds.at(playingIndex); + const QString selectedItemId = itemIds.at(playingIndex); + const qint64 requestedTicks = event.value(QStringLiteral("positionTicks")).toLongLong(); + + // A long queue can take seconds to fill in; start the selected item + // first, since reporting ready does not wait for the rest. + const auto hydrate = [this, generation, itemIds, entryIds, playingIndex] { + Async::runScoped( + this, m_hub->fetchItemsByIds(itemIds), + [this, generation, itemIds, entryIds, playingIndex](const std::vector& fetched) { + if (generation != m_queueGeneration) + return; + std::vector ordered; + int resolvedIndex = -1; + for (int i = 0; i < itemIds.size(); ++i) { + const auto found = std::find_if(fetched.begin(), fetched.end(), + [&](const MovieItem& item) { return item.id == itemIds.at(i); }); + if (found == fetched.end()) + continue; + MovieItem item = *found; + item.playlistItemId = entryIds.at(i); + if (i == playingIndex) + resolvedIndex = static_cast(ordered.size()); + ordered.push_back(std::move(item)); + } + // Usually the echo of an edit made here: rebuilding would tear + // down every row the queue panel is showing. + if (m_playQueue->matchesQueue(ordered, resolvedIndex)) + return; + m_playQueue->setShuffled(false); + if (resolvedIndex >= 0) + m_playQueue->playNow(ordered, resolvedIndex); + }, + [](const std::exception_ptr&) {}, "group queue"); + }; + + if (m_player->sessionActive() && m_playQueue->currentItem().id == selectedItemId) { + m_queueLoading = false; + hydrate(); + sendPlayerBufferingState(true); + sendPendingUnpause(); + return; + } + m_queueLoading = true; + setWaitingForGroupPlayback(true); + Async::runScoped( + this, m_hub->fetchItemsByIds({ selectedItemId }), + [this, generation, selectedItemId, entry = m_entryId, requestedTicks, hydrate]( + const std::vector& fetched) { + if (generation != m_queueGeneration) + return; + m_queueLoading = false; + const auto selected = std::find_if( + fetched.cbegin(), fetched.cend(), [&](const MovieItem& item) { return item.id == selectedItemId; }); + MovieItem item = selected == fetched.cend() ? MovieItem {} : *selected; + item.playlistItemId = entry; + m_playQueue->setShuffled(false); + if (item.id.isEmpty() || !m_playQueue->playNow(item)) { + setWaitingForGroupPlayback(false); + emit errorText(QStringLiteral("The group's current item isn't available here.")); + return; + } + m_waitingForPlaybackStart = true; + m_playerStateKnown = false; + send(QStringLiteral("buffering"), + { { QStringLiteral("buffering"), true }, { QStringLiteral("playing"), false }, + { QStringLiteral("positionTicks"), QString::number(std::max(0, requestedTicks)) }, + { QStringLiteral("entryId"), m_entryId }, { QStringLiteral("at"), serverNowMs() } }); + emit queuePlaybackRequested(requestedTicks); + hydrate(); + }, + [this, generation](const std::exception_ptr& error) { + if (generation != m_queueGeneration) + return; + m_queueLoading = false; + setWaitingForGroupPlayback(false); + emit errorText(exceptionMessage(error)); + }, + "group item"); +} + +void GroupPlaybackController::clearGroup() +{ + m_commandTimer.stop(); + m_speedCorrectionTimer.stop(); + m_bufferingDebounceTimer.stop(); + m_timeSyncTimer.stop(); + m_correctionTimer.stop(); + m_speedCorrectionActive = false; + m_player->clearSyncPlaybackSpeed(); + m_groupId.clear(); + m_groupName.clear(); + m_groupState.clear(); + m_groupStateReason.clear(); + m_participants.clear(); + m_entryId.clear(); + m_lastCommandKey.clear(); + m_scheduledCommand.clear(); + m_scheduledEntryId.clear(); + m_scheduledPositionTicks = 0; + m_scheduledServerTimeMs = 0; + m_joinedAtServerMs = 0; + m_queueHandoff.cancel(); + m_seekResume.cancel(); + ++m_queueGeneration; + m_queueLoading = false; + m_waitingForPlaybackStart = false; + m_commandDue = false; + m_unpauseRequestPending = false; + m_syncCorrectionAttempts = 0; + m_lastCorrectionAtMs = 0; + m_suppressSeekBufferingUntilMs = 0; + m_playerStateKnown = false; + setWaitingForGroupPlayback(false); + setPlaybackDiff(0, false); + setSyncMethod(QStringLiteral("None")); + emit groupChanged(); +} + +void GroupPlaybackController::executeScheduledCommand() +{ + if (m_scheduledCommand.isEmpty()) + return; + if (m_scheduledCommand != QStringLiteral("stop") + && (m_queueLoading || m_waitingForPlaybackStart || !m_player->sessionActive())) { + m_commandDue = true; + return; + } + if (!m_scheduledEntryId.isEmpty() && !m_entryId.isEmpty() && m_scheduledEntryId != m_entryId) { + m_commandDue = false; + return; + } + m_commandDue = false; + const QString command = m_scheduledCommand; + qint64 positionTicks = m_scheduledPositionTicks; + if (command == QStringLiteral("unpause") && m_scheduledServerTimeMs > 0) + positionTicks = m_clock.estimatePositionTicks( + positionTicks, m_scheduledServerTimeMs, QDateTime::currentMSecsSinceEpoch()); + const double targetSeconds = static_cast(positionTicks) / kTicksPerSecond; + const double positionDelta = std::abs(m_player->positionSeconds() - targetSeconds); + + if (command == QStringLiteral("pause")) { + m_seekResume.cancel(); + finishSpeedCorrection(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + m_player->setPaused(true); + if (positionDelta > 0.1) + m_player->seek(targetSeconds); + } else if (command == QStringLiteral("unpause")) { + finishSpeedCorrection(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + m_syncCorrectionAttempts = 0; + // Re-enable correction half a threshold after unpausing, so drift + // accumulated while the player spins up is not "corrected". + m_lastCorrectionAtMs = QDateTime::currentMSecsSinceEpoch(); + setPlaybackDiff(0, false); + setSyncMethod(QStringLiteral("None")); + if (positionDelta * 1'000.0 > kMinDelaySkipToSyncMs && !m_player->seeking()) { + m_suppressSeekBufferingUntilMs = QDateTime::currentMSecsSinceEpoch() + kInternalSeekBufferingSuppressionMs; + m_player->seek(targetSeconds); + } + m_player->setPaused(false); + } else if (command == QStringLiteral("seek")) { + finishSpeedCorrection(); + m_player->setPaused(true); + m_player->seek(targetSeconds); + QTimer::singleShot(250, this, [this] { sendPlayerBufferingState(true); }); + } else if (command == QStringLiteral("stop")) { + m_seekResume.cancel(); + finishSpeedCorrection(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + setPlaybackDiff(0, false); + setSyncMethod(QStringLiteral("None")); + m_player->stopWithReason(QStringLiteral("group-stop")); + } +} + +void GroupPlaybackController::correctPlaybackDrift() +{ + if (!enabled() || !m_player->sessionActive() || m_scheduledCommand != QStringLiteral("unpause") + || m_scheduledServerTimeMs <= 0 || m_player->paused() || m_player->buffering() || m_player->seeking()) { + setPlaybackDiff(0, false); + if (m_speedCorrectionActive) + finishSpeedCorrection(); + return; + } + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + const qint64 expectedTicks + = m_clock.estimatePositionTicks(m_scheduledPositionTicks, m_scheduledServerTimeMs, nowMs); + const qint64 actualTicks = static_cast(m_player->estimatedPositionSeconds() * kTicksPerSecond); + const qint64 signedDiffTicks = expectedTicks - actualTicks; + setPlaybackDiff(signedDiffTicks, true); + const GroupCorrection correction + = GroupDriftPolicy::evaluate(static_cast(signedDiffTicks) / kTicksPerMillisecond); + if (correction.method == GroupCorrection::Method::None) { + m_syncCorrectionAttempts = 0; + if (!m_speedCorrectionActive) + setSyncMethod(QStringLiteral("None")); + return; + } + if (m_speedCorrectionActive) { + // A large jump should not wait for gentle rate correction to finish. + if (correction.method == GroupCorrection::Method::Speed) + return; + finishSpeedCorrection(); + } + if (nowMs - m_lastCorrectionAtMs < kSyncCooldownMs) + return; + m_lastCorrectionAtMs = nowMs; + ++m_syncCorrectionAttempts; + if (correction.method == GroupCorrection::Method::Speed) { + m_speedCorrectionActive = true; + m_player->setSyncPlaybackSpeed(correction.speed); + setSyncMethod(QStringLiteral("Speed ×%1").arg(correction.speed, 0, 'f', 2)); + m_speedCorrectionTimer.start(correction.durationMs); + return; + } + setSyncMethod(QStringLiteral("Skip (%1)").arg(m_syncCorrectionAttempts)); + m_suppressSeekBufferingUntilMs = nowMs + kInternalSeekBufferingSuppressionMs; + m_player->seek(static_cast(expectedTicks) / kTicksPerSecond); +} + +void GroupPlaybackController::finishSpeedCorrection() +{ + m_speedCorrectionTimer.stop(); + if (enabled()) + m_player->setSyncPlaybackSpeed(1.0); + if (!m_speedCorrectionActive) + return; + m_speedCorrectionActive = false; + setSyncMethod(QStringLiteral("None")); +} + +void GroupPlaybackController::handlePlayerStateChanged() +{ + if (m_waitingForPlaybackStart) { + // Buffering was already reported; say ready only once mpv has the file. + if (m_player->sessionActive() && m_player->fileLoaded()) { + m_waitingForPlaybackStart = false; + if (m_commandDue) + executeScheduledCommand(); + sendPlayerBufferingState(true); + sendPendingUnpause(); + } + return; + } + sendPlayerBufferingState(false); + sendPendingUnpause(); +} + +void GroupPlaybackController::sendPlayerBufferingState(bool force) +{ + if (!enabled() || !m_player->sessionActive()) + return; + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + const bool internalSeek = m_player->seeking() && nowMs < m_suppressSeekBufferingUntilMs; + const bool buffering = !internalSeek && (m_player->buffering() || m_player->seeking()); + if (!force && buffering && (!m_playerStateKnown || !m_lastPlayerBuffering)) { + if (!m_bufferingDebounceTimer.isActive()) + m_bufferingDebounceTimer.start(); + return; + } + if (!buffering) + m_bufferingDebounceTimer.stop(); + if (!force && m_playerStateKnown && buffering == m_lastPlayerBuffering) + return; + m_playerStateKnown = true; + m_lastPlayerBuffering = buffering; + send(QStringLiteral("buffering"), + { { QStringLiteral("buffering"), buffering }, { QStringLiteral("playing"), !m_player->paused() }, + { QStringLiteral("positionTicks"), QString::number(qint64(m_player->positionSeconds() * kTicksPerSecond)) }, + { QStringLiteral("entryId"), m_entryId }, { QStringLiteral("at"), serverNowMs() } }); +} + +void GroupPlaybackController::sendPendingUnpause() +{ + if (!enabled() + || !m_queueHandoff.canSend( + m_queueLoading, m_waitingForPlaybackStart, m_player->sessionActive(), m_player->fileLoaded())) + return; + m_queueHandoff.cancel(); + requestGroupUnpause(); +} + +void GroupPlaybackController::requestGroupUnpause() +{ + if (!enabled() || m_unpauseRequestPending) + return; + m_unpauseRequestPending = true; + setWaitingForGroupPlayback(true); + Async::runScoped( + this, + m_hub->call( + m_account, QStringLiteral("groupSend"), { { QStringLiteral("action"), QStringLiteral("unpause") } }), + [](QVariantMap) {}, + [this](const std::exception_ptr& error) { + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + emit errorText(exceptionMessage(error)); + }, + "group unpause"); +} + +void GroupPlaybackController::setWaitingForGroupPlayback(bool waiting) +{ + if (m_waitingForGroupPlayback != waiting) { + m_waitingForGroupPlayback = waiting; + emit syncStatusChanged(); + } +} + +void GroupPlaybackController::setPlaybackDiff(qint64 diffTicks, bool valid) +{ + const double diffMs = valid ? static_cast(diffTicks) / kTicksPerMillisecond : 0.0; + if (m_playbackDiffValid == valid && (!valid || qFuzzyCompare(m_playbackDiffMs, diffMs))) + return; + m_playbackDiffValid = valid; + m_playbackDiffMs = diffMs; + emit syncStatusChanged(); +} + +void GroupPlaybackController::setSyncMethod(const QString& method) +{ + if (m_syncMethod != method) { + m_syncMethod = method; + emit syncStatusChanged(); + } +} + +void GroupPlaybackController::beginTimeSync() +{ + m_timeSyncTimer.stop(); + m_clock.reset(); + m_greedyTimeSyncRemaining = 3; + m_correctionTimer.start(); + requestTimeSync(); +} + +void GroupPlaybackController::requestTimeSync() +{ + if (m_account.isEmpty() || m_timeSyncInFlight) + return; + m_timeSyncInFlight = true; + const qint64 sentMs = QDateTime::currentMSecsSinceEpoch(); + Async::runScoped( + this, m_hub->call(m_account, QStringLiteral("clock")), + [this, sentMs](QVariantMap response) { + m_timeSyncInFlight = false; + const qint64 receivedMs = QDateTime::currentMSecsSinceEpoch(); + const qint64 serverReceived = ms(response, "received"); + const qint64 serverSent = ms(response, "sent"); + if (serverReceived > 0 && serverSent > 0) { + m_clock.addMeasurement({ sentMs, serverReceived, serverSent, receivedMs }); + emit clockChanged(); + send(QStringLiteral("ping"), { { QStringLiteral("ms"), qint64(std::llround(m_clock.pingMs())) } }); + } + const int interval = m_greedyTimeSyncRemaining > 0 ? kGreedyTimeSyncIntervalMs : kSteadyTimeSyncIntervalMs; + m_greedyTimeSyncRemaining = std::max(0, m_greedyTimeSyncRemaining - 1); + if (enabled()) + m_timeSyncTimer.start(interval); + }, + [this](const std::exception_ptr&) { + m_timeSyncInFlight = false; + if (enabled()) + m_timeSyncTimer.start(kSteadyTimeSyncIntervalMs); + }, + "group clock"); +} + +} // namespace Spool diff --git a/src/app/SyncPlayController.h b/src/app/GroupPlaybackController.h similarity index 56% rename from src/app/SyncPlayController.h rename to src/app/GroupPlaybackController.h index 7340baad..f7c1c516 100644 --- a/src/app/SyncPlayController.h +++ b/src/app/GroupPlaybackController.h @@ -1,23 +1,25 @@ #pragma once -#include "SyncPlayClock.h" +#include "../provider/GroupPlayback.h" +#include "GroupClock.h" -#include -#include +#include #include #include #include #include -#include +#include +#include -namespace JellyfinNative { +namespace Spool { -class JellyfinApiFacade; -class PlayerController; class PlayQueueController; -class TlsTrustController; +class PlayerController; +class SourceHub; -class SyncPlayQueueHandoff final { +// Queue handoff: after publishing a queue, unpause only once the group's own +// queue update has arrived and the file is loaded here. +class GroupQueueHandoff final { public: void arm() { @@ -44,7 +46,8 @@ class SyncPlayQueueHandoff final { bool m_queueUpdateObserved = false; }; -class SyncPlaySeekResume final { +// A seek pauses the group; resume once everyone reports ready again. +class GroupSeekResume final { public: void arm(bool groupWasPlaying) { @@ -66,7 +69,7 @@ class SyncPlaySeekResume final { bool m_pending = false; }; -struct SyncCorrection { +struct GroupCorrection { enum class Method { None, Speed, Skip }; Method method = Method::None; @@ -74,39 +77,54 @@ struct SyncCorrection { int durationMs = 0; }; -// Mirrors the strategy selection in jellyfin-web -// src/plugins/syncPlay/core/PlaybackCore.js so both clients tolerate and -// recover the same amount of drift. -class SyncPlayDriftPolicy final { +// Mirrors jellyfin-web's PlaybackCore strategy selection so mixed clients +// tolerate and recover the same drift. +class GroupDriftPolicy final { public: - // diffMs is the estimated server position minus the local position, so a - // positive value means this client is behind the group. - static SyncCorrection evaluate(double diffMs); + // Positive diffMs: this client is behind the group. + static GroupCorrection evaluate(double diffMs); }; -class SyncPlayController final : public QObject { +// Watching together, independent of whose server hosts the group. +// +// The native side keeps everything timing-critical: the clock offset from +// round-trip samples, commands scheduled against server time, drift +// correction by rate or seek, and the ready/buffering handshake. The provider +// keeps the protocol: it owns the socket and turns server messages into a +// small set of `group` events (joined, state, participants, queue, command, +// left, error) and turns the actions below into its server's requests. +class GroupPlaybackController final : public GroupPlayback { Q_OBJECT + Q_PROPERTY(QVariantList groups READ groups NOTIFY groupsChanged) + Q_PROPERTY(bool loadingGroups READ loadingGroups NOTIFY groupsChanged) + Q_PROPERTY(bool available READ available NOTIFY availableChanged) Q_PROPERTY(QString currentGroupId READ currentGroupId NOTIFY groupChanged) Q_PROPERTY(QString currentGroupName READ currentGroupName NOTIFY groupChanged) - Q_PROPERTY(QJsonArray groups READ groups NOTIFY groupsChanged) Q_PROPERTY(bool enabled READ enabled NOTIFY groupChanged) Q_PROPERTY(QStringList participants READ participants NOTIFY groupChanged) Q_PROPERTY(int participantCount READ participantCount NOTIFY groupChanged) Q_PROPERTY(QString groupState READ groupState NOTIFY groupChanged) Q_PROPERTY(QString groupStateReason READ groupStateReason NOTIFY groupChanged) - Q_PROPERTY(bool socketConnected READ socketConnected NOTIFY connectionChanged) - Q_PROPERTY(double clockOffsetMs READ clockOffsetMs NOTIFY connectionChanged) - Q_PROPERTY(double pingMs READ pingMs NOTIFY connectionChanged) - Q_PROPERTY(QString timeSyncDevice READ timeSyncDevice CONSTANT) + Q_PROPERTY(double clockOffsetMs READ clockOffsetMs NOTIFY clockChanged) + Q_PROPERTY(double pingMs READ pingMs NOTIFY clockChanged) Q_PROPERTY(bool waitingForPlayback READ waitingForPlayback NOTIFY syncStatusChanged) Q_PROPERTY(double playbackDiffMs READ playbackDiffMs NOTIFY syncStatusChanged) Q_PROPERTY(bool playbackDiffValid READ playbackDiffValid NOTIFY syncStatusChanged) Q_PROPERTY(QString syncMethod READ syncMethod NOTIFY syncStatusChanged) public: - SyncPlayController(JellyfinApiFacade *api, PlayerController *player, PlayQueueController *playQueue, - TlsTrustController *tlsTrust, QObject *parent = nullptr); + GroupPlaybackController( + SourceHub *hub, PlayerController *player, PlayQueueController *playQueue, QObject *parent = nullptr); + QVariantList groups() const + { + return m_groups; + } + bool loadingGroups() const + { + return m_loadingGroups > 0; + } + bool available() const; QString currentGroupId() const { return m_groupId; @@ -115,11 +133,7 @@ class SyncPlayController final : public QObject { { return m_groupName; } - QJsonArray groups() const - { - return m_groups; - } - bool enabled() const + bool enabled() const override { return !m_groupId.isEmpty(); } @@ -129,7 +143,7 @@ class SyncPlayController final : public QObject { } int participantCount() const { - return m_participants.size(); + return int(m_participants.size()); } QString groupState() const { @@ -139,7 +153,6 @@ class SyncPlayController final : public QObject { { return m_groupStateReason; } - bool socketConnected() const; double clockOffsetMs() const { return m_clock.offsetMs(); @@ -148,10 +161,6 @@ class SyncPlayController final : public QObject { { return m_clock.pingMs(); } - QString timeSyncDevice() const - { - return QStringLiteral("Jellyfin server"); - } bool waitingForPlayback() const { return m_waitingForGroupPlayback; @@ -169,82 +178,75 @@ class SyncPlayController final : public QObject { return m_syncMethod; } + // Lists the groups of every account that can host one. Q_INVOKABLE void refreshGroups(); - Q_INVOKABLE void connectSocket(); - Q_INVOKABLE void disconnectSocket(); - Q_INVOKABLE void createGroup(const QString& name); - Q_INVOKABLE void joinGroup(const QString& groupId); - Q_INVOKABLE void leaveGroup(); - Q_INVOKABLE void requestTogglePause(); - Q_INVOKABLE void requestSeek(double positionSeconds); - Q_INVOKABLE void requestRelativeSeek(double deltaSeconds); - Q_INVOKABLE void requestNextItem(); - Q_INVOKABLE void requestPreviousItem(); - // Inside a group the queue belongs to the server. These publish an intent - // and the resulting PlayQueue broadcast is what actually changes the queue. - void requestMoveItem(const QString& playlistItemId, int newIndex); - void requestRemoveItems(const QStringList& playlistItemIds); - void requestQueueItems(const QStringList& itemIds, bool queueNext); - void requestPlayItem(const QString& playlistItemId); - void requestUnpauseWhenReady(); - void cancelPendingUnpause(); + Q_INVOKABLE void createGroup(const QString& accountId, const QString& name); + // `groupId` is scoped to its account, as refreshGroups() lists it. + Q_INVOKABLE void joinGroup(const QString& groupId) override; + Q_INVOKABLE void leaveGroup() override; + Q_INVOKABLE void requestTogglePause() override; + Q_INVOKABLE void requestSeek(double positionSeconds) override; + Q_INVOKABLE void requestRelativeSeek(double deltaSeconds) override; + Q_INVOKABLE void requestNextItem() override; + Q_INVOKABLE void requestPreviousItem() override; + void requestMoveItem(const QString& entryId, int newIndex) override; + void requestRemoveItems(const QStringList& entryIds) override; + void requestQueueItems(const QStringList& itemIds, bool queueNext) override; + void requestPlayItem(const QString& entryId) override; + void requestUnpauseWhenReady() override; + void cancelPendingUnpause() override; + QCoro::Task publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) override; + + // A `group` event from the account hosting the group. + void handleEvent(const QString& accountId, const QVariantMap& event); signals: void groupsChanged(); + void availableChanged(); void groupChanged(); - void connectionChanged(); + void clockChanged(); void syncStatusChanged(); void errorText(const QString& text); - void remotePlayCommand(const QJsonObject& data); - void remotePlaystateCommand(const QJsonObject& data); - void remoteGeneralCommand(const QJsonObject& data); - void sessionsUpdated(const QJsonArray& sessions); - void queuePlaybackRequested(qint64 positionTicks); private: - void handleSocketTextMessage(const QString& message); - void handleSyncPlayCommand(const QJsonObject& data); - void handleSyncPlayGroupUpdate(const QJsonObject& data); - void applyPlayQueueUpdate(const QJsonObject& queue); - void prepareQueuePlayback(qint64 positionTicks); - void sendPendingUnpause(); - void requestGroupUnpause(); - void setWaitingForGroupPlayback(bool waiting); - void setPlaybackDiff(qint64 diffTicks, bool valid); - void setSyncMethod(const QString& method); - void applyGroupInfo(const QString& groupId, const QJsonObject& info); + void send(const QString& action, QVariantMap arguments = {}, const char *failure = nullptr); + void handleCommand(const QVariantMap& event); + void applyQueue(const QVariantMap& event); + void applyInfo(const QVariantMap& info); void clearGroup(); void executeScheduledCommand(); void correctPlaybackDrift(); void finishSpeedCorrection(); void handlePlayerStateChanged(); void sendPlayerBufferingState(bool force = false); + void sendPendingUnpause(); + void requestGroupUnpause(); + void setWaitingForGroupPlayback(bool waiting); + void setPlaybackDiff(qint64 diffTicks, bool valid); + void setSyncMethod(const QString& method); void beginTimeSync(); void requestTimeSync(); - void scheduleTimeSync(); - void reportRequestError(const QString& action, const std::exception_ptr& error); - void sendKeepAlive(); - QUrl socketUrl() const; + qint64 serverNowMs() const; - JellyfinApiFacade *m_api = nullptr; - PlayerController *m_player = nullptr; - PlayQueueController *m_playQueue = nullptr; - QWebSocket m_socket; + SourceHub *m_hub; + PlayerController *m_player; + PlayQueueController *m_playQueue; + QString m_account; QString m_groupId; QString m_groupName; QStringList m_participants; QString m_groupState; QString m_groupStateReason; - QJsonArray m_groups; - SyncPlayClock m_clock; + QVariantList m_groups; + int m_loadingGroups = 0; + GroupClock m_clock; QTimer m_timeSyncTimer; QTimer m_commandTimer; QTimer m_correctionTimer; QTimer m_speedCorrectionTimer; - QTimer m_reconnectTimer; QTimer m_bufferingDebounceTimer; - QString m_playlistItemId; - QString m_scheduledPlaylistItemId; + QString m_entryId; + QString m_scheduledEntryId; QString m_lastCommandKey; QString m_scheduledCommand; qint64 m_scheduledPositionTicks = 0; @@ -252,22 +254,17 @@ class SyncPlayController final : public QObject { qint64 m_lastCorrectionAtMs = 0; qint64 m_suppressSeekBufferingUntilMs = 0; qint64 m_joinedAtServerMs = 0; - qint64 m_lastPlayQueueUpdateMs = 0; + qint64 m_lastQueueUpdateMs = 0; double m_playbackDiffMs = 0.0; - quint64 m_playQueueGeneration = 0; + quint64 m_queueGeneration = 0; int m_greedyTimeSyncRemaining = 0; - SyncPlayQueueHandoff m_queueHandoff; - SyncPlaySeekResume m_seekResume; + GroupQueueHandoff m_queueHandoff; + GroupSeekResume m_seekResume; int m_syncCorrectionAttempts = 0; - bool m_socketDesired = false; - // Consecutive socket failures with a reconnect still wanted; reset by a - // successful connect and by giving up on the socket deliberately. - int m_socketFailures = 0; bool m_timeSyncInFlight = false; - bool m_timeSyncErrorReported = false; bool m_playerStateKnown = false; bool m_lastPlayerBuffering = false; - bool m_playQueueLoading = false; + bool m_queueLoading = false; bool m_waitingForPlaybackStart = false; bool m_commandDue = false; bool m_waitingForGroupPlayback = false; @@ -277,4 +274,4 @@ class SyncPlayController final : public QObject { QString m_syncMethod = QStringLiteral("None"); }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/HomeModelController.cpp b/src/app/HomeModelController.cpp index f3cfddbd..7d993f06 100644 --- a/src/app/HomeModelController.cpp +++ b/src/app/HomeModelController.cpp @@ -1,6 +1,5 @@ #include "HomeModelController.h" -#include "../api/JellyfinApiFacade.h" #include "../cache/DatabaseManager.h" #include "../common/AsyncTask.h" #include "../common/MetaJson.h" @@ -18,10 +17,10 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { - constexpr int kHomePayloadSchemaVersion = 12; + constexpr int kHomePayloadSchemaVersion = 14; // Cover art is square; cropping it to a poster or a thumbnail throws away // the edges of the artwork the way the album was meant to be seen. @@ -163,10 +162,10 @@ namespace { } // namespace HomeModelController::HomeModelController( - DatabaseManager *database, JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent) + DatabaseManager *database, Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) , m_database(database) - , m_api(api) + , m_api(catalog) , m_prefetch(prefetch) { } @@ -211,8 +210,11 @@ bool HomeModelController::applyCachedPayload(const QJsonObject& payload) sections.push_back(std::move(section)); } - m_resumeItems.setMovies(movieArrayFromJson(payload.value(QStringLiteral("resumeItems")).toArray())); - m_nextUpItems.setMovies(movieArrayFromJson(payload.value(QStringLiteral("nextUpItems")).toArray())); + auto resume = movieArrayFromJson(payload.value(QStringLiteral("resumeItems")).toArray()); + auto nextUp = movieArrayFromJson(payload.value(QStringLiteral("nextUpItems")).toArray()); + reconcilePlaybackRows(resume, nextUp); + m_resumeItems.setMovies(std::move(resume)); + m_nextUpItems.setMovies(std::move(nextUp)); if (updateLatestLibraryRows(std::move(sections))) emit latestLibraryRowsChanged(); return m_resumeItems.rowCount() > 0 || m_nextUpItems.rowCount() > 0 || !m_latestLibrarySections.empty(); @@ -235,19 +237,19 @@ QCoro::Task HomeModelController::loadCachedPayloadAsync() const QString key = payloadCacheKey(); if (key.isEmpty()) co_return; + const RequestGeneration::Token generation = m_generation.current(); + const auto playbackGeneration = m_playbackRowsGeneration.current(); const QJsonObject payload = co_await m_database->loadHomePayloadAsync(key, kHomePayloadSchemaVersion); + if (!m_generation.isCurrent(generation) || !m_playbackRowsGeneration.isCurrent(playbackGeneration) + || key != payloadCacheKey() || m_loaded) + co_return; if (applyCachedPayload(payload)) qInfo() << "home: warm payload cache applied" << key; } QString HomeModelController::payloadCacheKey() const { - if (!m_api) - return {}; - const AuthSession session = m_api->session(); - const QString userKey = session.userId.isEmpty() ? session.userName : session.userId; - const QString serverKey = session.serverId.isEmpty() ? m_api->serverUrl() : session.serverId; - return userKey.isEmpty() || serverKey.isEmpty() ? QString() : QStringLiteral("%1/%2").arg(serverKey, userKey); + return m_api ? m_api->libraryScopeKey() : QString(); } void HomeModelController::saveCachedPayload(const QJsonObject& payload) @@ -259,7 +261,7 @@ void HomeModelController::saveCachedPayload(const QJsonObject& payload) void HomeModelController::refresh(const std::vector& libraries) { - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_api || !m_api->signedIn()) return; if (libraries.empty()) return; @@ -295,6 +297,7 @@ void HomeModelController::refresh(const std::vector& libraries) QCoro::Task HomeModelController::refreshAsync( std::vector libraries, RequestGeneration::Token generation) { + const auto playbackGeneration = m_playbackRowsGeneration.current(); std::vector latestLibraries; latestLibraries.reserve(libraries.size()); for (const LibraryItem& library : libraries) { @@ -329,8 +332,11 @@ QCoro::Task HomeModelController::refreshAsync( if (!m_generation.isCurrent(generation)) co_return; - m_resumeItems.setMovies(resumeItems); - m_nextUpItems.setMovies(nextUpItems); + if (m_playbackRowsGeneration.isCurrent(playbackGeneration)) { + reconcilePlaybackRows(resumeItems, nextUpItems); + m_resumeItems.setMovies(resumeItems); + m_nextUpItems.setMovies(nextUpItems); + } std::vector latestSections; latestSections.reserve(latestLibraries.size()); @@ -350,12 +356,12 @@ QCoro::Task HomeModelController::refreshAsync( m_refreshInFlight = false; m_loaded = true; - saveCachedPayload(payloadFromSections(resumeItems, nextUpItems, latestSections)); + saveCachedPayload(payloadFromSections(m_resumeItems.movies(), m_nextUpItems.movies(), latestSections)); const bool latestRowsChanged = updateLatestLibraryRows(std::move(latestSections)); emit loadingChanged(); - m_prefetch->prefetchPosters(resumeItems, 0, 12, LibraryPrefetchController::ImageKind::Landscape); - m_prefetch->prefetchPosters(nextUpItems, 0, 12, LibraryPrefetchController::ImageKind::Landscape); + m_prefetch->prefetchPosters(m_resumeItems.movies(), 0, 12, LibraryPrefetchController::ImageKind::Landscape); + m_prefetch->prefetchPosters(m_nextUpItems.movies(), 0, 12, LibraryPrefetchController::ImageKind::Landscape); for (const LatestLibrarySection& section : m_latestLibrarySections) { if (!section.model) continue; @@ -408,14 +414,16 @@ void HomeModelController::upsertResumeItem(MovieItem item, qint64 positionTicks) if (item.id.isEmpty()) return; + m_playbackRowsGeneration.invalidate(); item.resumeTicks = normalizedResumeTicks(positionTicks, item.runtimeTicks); item.played = false; if (!isMeaningfulResumePosition(item.resumeTicks, item.runtimeTicks)) { updateResumeTicks(item.id, item.resumeTicks); return; } + m_locallyPlayed.remove(item.id); - const auto current = m_resumeItems.movies(); + const auto& current = m_resumeItems.movies(); if (!current.empty() && current.front().id == item.id) { m_resumeItems.updateResumeTicks(item.id, item.resumeTicks); return; @@ -436,6 +444,7 @@ void HomeModelController::upsertResumeItem(MovieItem item, qint64 positionTicks) void HomeModelController::updateResumeTicks(const QString& itemId, qint64 positionTicks) { + m_playbackRowsGeneration.invalidate(); m_resumeItems.updateResumeTicks(itemId, positionTicks); m_resumeItems.removeUnresumable(); m_nextUpItems.updateResumeTicks(itemId, positionTicks); @@ -457,6 +466,11 @@ void HomeModelController::updateFavorite(const QString& itemId, bool favorite) void HomeModelController::updatePlayed(const QString& itemId, bool played) { + m_playbackRowsGeneration.invalidate(); + if (played) + m_locallyPlayed.insert(itemId); + else + m_locallyPlayed.remove(itemId); m_resumeItems.updatePlayed(itemId, played); m_resumeItems.removeUnresumable(); if (played) @@ -469,9 +483,124 @@ void HomeModelController::updatePlayed(const QString& itemId, bool played) } } +void HomeModelController::advanceNextUp(const MovieItem& completed, const MovieItem& successor) +{ + if (completed.seriesId.isEmpty() || successor.id.isEmpty() || successor.id == completed.id + || successor.seriesId != completed.seriesId || successor.played) + return; + m_optimisticNextUp.insert(completed.seriesId, successor); + auto items = m_nextUpItems.movies(); + auto current = std::find_if(items.begin(), items.end(), + [&completed](const MovieItem& item) { return item.seriesId == completed.seriesId; }); + if (current != items.end()) + *current = successor; + else + items.insert(items.begin(), successor); + m_nextUpItems.setMovies(std::move(items)); +} + +void HomeModelController::reconcilePlaybackRows(std::vector& resume, std::vector& nextUp) +{ + const auto playedLocally = [this](const MovieItem& item) { return m_locallyPlayed.contains(item.id); }; + std::erase_if(resume, playedLocally); + // A home request started before completion, or an eventually consistent + // provider response, must never put the completed episode back on screen. + for (auto it = m_optimisticNextUp.begin(); it != m_optimisticNextUp.end();) { + auto current = std::find_if( + nextUp.begin(), nextUp.end(), [&it](const MovieItem& item) { return item.seriesId == it.key(); }); + if (current != nextUp.end() && !playedLocally(*current)) { + it = m_optimisticNextUp.erase(it); + } else { + if (current != nextUp.end()) + *current = it.value(); + else + nextUp.insert(nextUp.begin(), it.value()); + ++it; + } + } + std::erase_if(nextUp, playedLocally); +} + +void HomeModelController::refreshPlaybackRows() +{ + if (!m_api || !m_api->signedIn()) + return; + const auto generation = m_playbackRowsGeneration.next(); + Async::runScoped( + this, refreshPlaybackRowsAsync(generation), []() {}, + [](const std::exception_ptr& error) { + qWarning() << "home: playback rows refresh failed" << exceptionMessage(error); + }, + "home playback rows"); +} + +QCoro::Task HomeModelController::refreshPlaybackRowsAsync(RequestGeneration::Token generation) +{ + auto resumeTask = m_api->fetchResumeItems(); + auto nextUpTask = m_api->fetchNextUpEpisodes(); + auto resume = co_await resumeTask; + auto nextUp = co_await nextUpTask; + if (!m_playbackRowsGeneration.isCurrent(generation)) + co_return; + reconcilePlaybackRows(resume, nextUp); + m_resumeItems.setMovies(std::move(resume)); + m_nextUpItems.setMovies(std::move(nextUp)); + std::vector sections; + sections.reserve(m_latestLibrarySections.size()); + for (const auto& section : m_latestLibrarySections) + sections.push_back({ section.order, section.library, section.model->movies() }); + saveCachedPayload(payloadFromSections(m_resumeItems.movies(), m_nextUpItems.movies(), sections)); + m_prefetch->prefetchPosters(m_nextUpItems.movies(), 0, 12, LibraryPrefetchController::ImageKind::Landscape); +} + +void HomeModelController::invalidate(const std::function& isAvailable) +{ + m_generation.invalidate(); + m_playbackRowsGeneration.invalidate(); + m_refreshInFlight = false; + m_loaded = false; + m_prefetch->stop(); + if (isAvailable) { + m_locallyPlayed.removeIf([&isAvailable](const QString& id) { return !isAvailable(id); }); + for (auto it = m_optimisticNextUp.begin(); it != m_optimisticNextUp.end();) { + if (!isAvailable(it.value().id)) + it = m_optimisticNextUp.erase(it); + else + ++it; + } + const auto retainItems = [&isAvailable](MovieGridModel& model) { + const auto& current = model.movies(); + if (std::all_of(current.begin(), current.end(), + [&isAvailable](const MovieItem& item) { return isAvailable(item.id); })) + return; + std::vector retained; + retained.reserve(current.size()); + for (const MovieItem& item : current) { + if (isAvailable(item.id)) + retained.push_back(item); + } + model.setMovies(std::move(retained)); + }; + retainItems(m_resumeItems); + retainItems(m_nextUpItems); + std::erase_if(m_latestLibrarySections, + [&isAvailable](const LatestLibrarySection& section) { return !isAvailable(section.library.id); }); + for (LatestLibrarySection& section : m_latestLibrarySections) { + if (section.model) + retainItems(*section.model); + } + m_recentLibraryIds.removeIf([&isAvailable](const QString& id) { return !isAvailable(id); }); + } + emit latestLibraryRowsChanged(); + emit loadingChanged(); +} + void HomeModelController::reset() { m_generation.invalidate(); + m_playbackRowsGeneration.invalidate(); + m_locallyPlayed.clear(); + m_optimisticNextUp.clear(); m_refreshInFlight = false; m_loaded = false; m_prefetch->stop(); @@ -542,4 +671,4 @@ QJsonObject HomeModelController::payloadFromSections(const std::vector +#include #include +#include #include #include #include +#include #include #include -namespace JellyfinNative { +namespace Spool { class DatabaseManager; -class JellyfinApiFacade; class LibraryPrefetchController; class HomeModelController final : public QObject { Q_OBJECT - Q_PROPERTY(JellyfinNative::MovieGridModel *resumeItems READ resumeItems CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *nextUpItems READ nextUpItems CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *resumeItems READ resumeItems CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *nextUpItems READ nextUpItems CONSTANT) Q_PROPERTY(QVariantList latestLibraryRows READ latestLibraryRows NOTIFY latestLibraryRowsChanged) Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) public: - HomeModelController(DatabaseManager *database, JellyfinApiFacade *api, LibraryPrefetchController *prefetch, - QObject *parent = nullptr); + HomeModelController( + DatabaseManager *database, Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent = nullptr); MovieGridModel *resumeItems() { @@ -52,6 +55,9 @@ class HomeModelController final : public QObject { void updateResumeTicks(const QString& itemId, qint64 positionTicks); void updateFavorite(const QString& itemId, bool favorite); void updatePlayed(const QString& itemId, bool played); + void advanceNextUp(const MovieItem& completed, const MovieItem& successor); + void refreshPlaybackRows(); + void invalidate(const std::function& isAvailable = {}); void reset(); signals: @@ -77,17 +83,22 @@ class HomeModelController final : public QObject { QCoro::Task loadCachedPayloadAsync(); QString payloadCacheKey() const; void saveCachedPayload(const QJsonObject& payload); + QCoro::Task refreshPlaybackRowsAsync(RequestGeneration::Token generation); + void reconcilePlaybackRows(std::vector& resume, std::vector& nextUp); DatabaseManager *m_database = nullptr; - JellyfinApiFacade *m_api = nullptr; + Catalog *m_api = nullptr; LibraryPrefetchController *m_prefetch = nullptr; MovieGridModel m_resumeItems; MovieGridModel m_nextUpItems; std::vector m_latestLibrarySections; RequestGeneration m_generation; + RequestGeneration m_playbackRowsGeneration; + QSet m_locallyPlayed; + QHash m_optimisticNextUp; bool m_refreshInFlight = false; bool m_loaded = false; QStringList m_recentLibraryIds; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryManagementController.cpp b/src/app/LibraryManagementController.cpp deleted file mode 100644 index 96dcf72a..00000000 --- a/src/app/LibraryManagementController.cpp +++ /dev/null @@ -1,351 +0,0 @@ -#include "LibraryManagementController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" -#include "../common/MetaJson.h" -#include "BrowseSessionController.h" - -#include -#include -#include - -#include - -namespace JellyfinNative { - -LibraryManagementController::LibraryManagementController( - JellyfinApiFacade *api, BrowseSessionController *browse, QObject *parent) - : QObject(parent) - , m_api(api) - , m_browse(browse) -{ -} - -void LibraryManagementController::clear() -{ - m_playlistTargets.clear(); - m_collectionTargets.clear(); - m_policyLoadStarted = false; - m_currentUserCanManagePlaylists = false; - m_currentUserCanManageCollections = false; - m_currentUserCanRenameItems = false; - m_currentUserCanDeleteItems = false; - emit policyChanged(); - emit targetsChanged(); -} - -void LibraryManagementController::loadCurrentUserPolicy() -{ - if (m_policyLoadStarted) - return; - if (!m_api || m_api->session().accessToken.isEmpty()) { - clear(); - return; - } - m_policyLoadStarted = true; - - m_currentUserCanManagePlaylists = true; - emit policyChanged(); - Async::runScoped( - this, m_api->fetchCurrentUserPolicy(), - [this](const QJsonObject& policy) { - const bool administrator = policy.value(QStringLiteral("IsAdministrator")).toBool(false); - m_currentUserCanManagePlaylists = !m_api->session().accessToken.isEmpty(); - m_currentUserCanManageCollections - = administrator || policy.value(QStringLiteral("EnableCollectionManagement")).toBool(false); - m_currentUserCanRenameItems = administrator; - m_currentUserCanDeleteItems - = administrator || policy.value(QStringLiteral("EnableContentDeletion")).toBool(false); - emit policyChanged(); - }, - [this](const std::exception_ptr& error) { - qWarning() << "management policy fetch failed" << exceptionMessage(error); - m_currentUserCanManagePlaylists = false; - m_currentUserCanManageCollections = false; - m_currentUserCanRenameItems = false; - m_currentUserCanDeleteItems = false; - emit policyChanged(); - }); -} - -void LibraryManagementController::setTargets(const QString& kind, const std::vector& items) -{ - QVariantList targets; - targets.reserve(static_cast(items.size())); - for (const MovieItem& item : items) - targets.push_back(metaToJson(item).toVariantMap()); - - if (kind == QStringLiteral("playlist")) - m_playlistTargets = targets; - else if (kind == QStringLiteral("collection")) - m_collectionTargets = targets; - emit targetsChanged(); -} - -QStringList LibraryManagementController::itemIdsFor(const MovieItem& item) const -{ - return item.id.isEmpty() ? QStringList {} : QStringList { item.id }; -} - -void LibraryManagementController::refreshAfterMutation(const QString& changedItemId) -{ - emit refreshRequested(changedItemId); - refreshTargets(QStringLiteral("playlist")); - refreshTargets(QStringLiteral("collection")); -} - -bool LibraryManagementController::authenticated() -{ - if (!m_api || m_api->session().accessToken.isEmpty()) { - emit errorOccurred(QStringLiteral("Sign in before managing library items.")); - return false; - } - return true; -} - -bool LibraryManagementController::playlistAllowed() -{ - if (!authenticated()) - return false; - if (!m_currentUserCanManagePlaylists) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot manage playlists.")); - return false; - } - return true; -} - -bool LibraryManagementController::collectionAllowed() -{ - if (!authenticated()) - return false; - if (!m_currentUserCanManageCollections) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot manage collections.")); - return false; - } - return true; -} - -bool LibraryManagementController::renameAllowed(const QString& itemType) -{ - if (itemType == QStringLiteral("Playlist")) - return playlistAllowed(); - if (!authenticated()) - return false; - if (!m_currentUserCanRenameItems) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot rename this item.")); - return false; - } - return true; -} - -bool LibraryManagementController::deleteAllowed() -{ - if (!authenticated()) - return false; - if (!m_currentUserCanDeleteItems) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot delete items.")); - return false; - } - return true; -} - -void LibraryManagementController::refreshTargets(const QString& kind) -{ - if (!authenticated()) - return; - - const QString normalized - = kind == QStringLiteral("collection") ? QStringLiteral("collection") : QStringLiteral("playlist"); - const QString itemType - = normalized == QStringLiteral("collection") ? QStringLiteral("BoxSet") : QStringLiteral("Playlist"); - Async::runScoped( - this, m_api->fetchManagementTargets(itemType), - [this, normalized](const std::vector& items) { setTargets(normalized, items); }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::createPlaylistForItem(const QString& name, const MovieItem& item) -{ - if (!playlistAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - Async::runScoped( - this, m_api->createPlaylist(name, itemIds), - [this](const QString&) { - emit operationSucceeded(QStringLiteral("Playlist created")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::addItemToPlaylist(const QString& playlistId, const MovieItem& item) -{ - if (!playlistAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - if (playlistId.isEmpty() || itemIds.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item and playlist first.")); - return; - } - Async::runScoped( - this, m_api->addPlaylistItems(playlistId, itemIds), - [this]() { - emit operationSucceeded(QStringLiteral("Added to playlist")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::createCollectionForItem(const QString& name, const MovieItem& item) -{ - if (!collectionAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - Async::runScoped( - this, m_api->createCollection(name, itemIds), - [this](const QString&) { - emit operationSucceeded(QStringLiteral("Collection created")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::addItemToCollection(const QString& collectionId, const MovieItem& item) -{ - if (!collectionAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - if (collectionId.isEmpty() || itemIds.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item and collection first.")); - return; - } - Async::runScoped( - this, m_api->addCollectionItems(collectionId, itemIds), - [this]() { - emit operationSucceeded(QStringLiteral("Added to collection")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::removeItemFromCurrentParent(const MovieItem& movie) -{ - if (!m_browse) { - emit errorOccurred(QStringLiteral("Open a playlist or collection before removing items.")); - return; - } - - const BrowseDescriptor descriptor = m_browse->descriptor(); - if (descriptor.kind == BrowseKind::Playlist) { - if (!playlistAllowed()) - return; - if (movie.playlistItemId.isEmpty()) { - emit errorOccurred(QStringLiteral("This playlist entry cannot be removed.")); - return; - } - Async::runScoped( - this, m_api->removePlaylistItems(descriptor.id, { movie.playlistItemId }), - [this]() { - emit operationSucceeded(QStringLiteral("Removed from playlist")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); - return; - } - if (descriptor.kind == BrowseKind::BoxSet) { - if (!collectionAllowed()) - return; - if (movie.id.isEmpty()) { - emit errorOccurred(QStringLiteral("This collection item cannot be removed.")); - return; - } - Async::runScoped( - this, m_api->removeCollectionItems(descriptor.id, { movie.id }), - [this]() { - emit operationSucceeded(QStringLiteral("Removed from collection")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); - return; - } - emit errorOccurred(QStringLiteral("Open a playlist or collection before removing items.")); -} - -void LibraryManagementController::movePlaylistItemInCurrent(const MovieItem& movie, int delta) -{ - if (!playlistAllowed()) - return; - if (!m_browse) { - emit errorOccurred(QStringLiteral("This playlist entry cannot be moved.")); - return; - } - const BrowseDescriptor descriptor = m_browse->descriptor(); - if (descriptor.kind != BrowseKind::Playlist || movie.playlistItemId.isEmpty()) { - emit errorOccurred(QStringLiteral("This playlist entry cannot be moved.")); - return; - } - - int currentIndex = -1; - const std::vector& items = m_browse->items()->movies(); - for (int i = 0; i < static_cast(items.size()); ++i) { - if (items[static_cast(i)].playlistItemId == movie.playlistItemId) { - currentIndex = i; - break; - } - } - if (currentIndex < 0) - return; - const int newIndex = std::clamp(currentIndex + delta, 0, std::max(0, static_cast(items.size()) - 1)); - if (newIndex == currentIndex) - return; - - Async::runScoped( - this, m_api->movePlaylistItem(descriptor.id, movie.playlistItemId, newIndex), - [this]() { - emit operationSucceeded(QStringLiteral("Playlist item moved")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::renameItem(const MovieItem& movie, const QString& name) -{ - const QString trimmed = name.trimmed(); - if (movie.id.isEmpty() || trimmed.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item and name first.")); - return; - } - if (!renameAllowed(movie.itemType)) - return; - - auto onRenamed = [this, itemId = movie.id]() { - emit operationSucceeded(QStringLiteral("Item renamed")); - refreshAfterMutation(itemId); - }; - auto onError = [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }; - if (movie.itemType == QStringLiteral("Playlist")) { - Async::runScoped(this, m_api->updatePlaylistName(movie.id, trimmed), onRenamed, onError); - return; - } - Async::runScoped(this, m_api->renameItem(movie.id, trimmed), onRenamed, onError); -} - -void LibraryManagementController::deleteItem(const MovieItem& movie) -{ - if (movie.id.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item before deleting.")); - return; - } - if (!deleteAllowed()) - return; - - Async::runScoped( - this, m_api->deleteItem(movie.id), - [this, itemId = movie.id]() { - emit operationSucceeded(QStringLiteral("Item deleted")); - refreshAfterMutation(itemId); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -} // namespace JellyfinNative diff --git a/src/app/LibraryManagementController.h b/src/app/LibraryManagementController.h deleted file mode 100644 index 6ffbe4a3..00000000 --- a/src/app/LibraryManagementController.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "../common/JellyfinTypes.h" - -#include -#include -#include -#include - -#include - -namespace JellyfinNative { - -class BrowseSessionController; -class JellyfinApiFacade; - -class LibraryManagementController final : public QObject { - Q_OBJECT - Q_PROPERTY(bool currentUserCanManagePlaylists MEMBER m_currentUserCanManagePlaylists NOTIFY policyChanged) - Q_PROPERTY(bool currentUserCanManageCollections MEMBER m_currentUserCanManageCollections NOTIFY policyChanged) - Q_PROPERTY(bool currentUserCanRenameItems MEMBER m_currentUserCanRenameItems NOTIFY policyChanged) - Q_PROPERTY(bool currentUserCanDeleteItems MEMBER m_currentUserCanDeleteItems NOTIFY policyChanged) - Q_PROPERTY(QVariantList playlistTargets MEMBER m_playlistTargets NOTIFY targetsChanged) - Q_PROPERTY(QVariantList collectionTargets MEMBER m_collectionTargets NOTIFY targetsChanged) - -public: - LibraryManagementController(JellyfinApiFacade *api, BrowseSessionController *browse, QObject *parent = nullptr); - - Q_INVOKABLE void clear(); - Q_INVOKABLE void loadCurrentUserPolicy(); - Q_INVOKABLE void refreshTargets(const QString& kind); - Q_INVOKABLE void createPlaylistForItem(const QString& name, const MovieItem& item); - Q_INVOKABLE void addItemToPlaylist(const QString& playlistId, const MovieItem& item); - Q_INVOKABLE void createCollectionForItem(const QString& name, const MovieItem& item); - Q_INVOKABLE void addItemToCollection(const QString& collectionId, const MovieItem& item); - Q_INVOKABLE void removeItemFromCurrentParent(const MovieItem& item); - Q_INVOKABLE void movePlaylistItemInCurrent(const MovieItem& item, int delta); - Q_INVOKABLE void renameItem(const MovieItem& item, const QString& name); - Q_INVOKABLE void deleteItem(const MovieItem& item); - -signals: - void policyChanged(); - void targetsChanged(); - void errorOccurred(const QString& errorText); - void operationSucceeded(const QString& action); - void refreshRequested(const QString& changedItemId); - -private: - void setTargets(const QString& kind, const std::vector& items); - void refreshAfterMutation(const QString& changedItemId = {}); - QStringList itemIdsFor(const MovieItem& item) const; - bool authenticated(); - bool playlistAllowed(); - bool collectionAllowed(); - bool renameAllowed(const QString& itemType); - bool deleteAllowed(); - - JellyfinApiFacade *m_api = nullptr; - BrowseSessionController *m_browse = nullptr; - QVariantList m_playlistTargets; - QVariantList m_collectionTargets; - bool m_policyLoadStarted = false; - bool m_currentUserCanManagePlaylists = false; - bool m_currentUserCanManageCollections = false; - bool m_currentUserCanRenameItems = false; - bool m_currentUserCanDeleteItems = false; -}; - -} // namespace JellyfinNative diff --git a/src/app/LibraryPrefetchController.cpp b/src/app/LibraryPrefetchController.cpp index a941d72e..b7aebb5a 100644 --- a/src/app/LibraryPrefetchController.cpp +++ b/src/app/LibraryPrefetchController.cpp @@ -1,6 +1,5 @@ #include "LibraryPrefetchController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "ArtworkPrefetcher.h" #include "LibraryQuery.h" @@ -11,7 +10,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -20,10 +19,9 @@ namespace { } // namespace -LibraryPrefetchController::LibraryPrefetchController( - JellyfinApiFacade *api, ArtworkPrefetcher *artwork, QObject *parent) +LibraryPrefetchController::LibraryPrefetchController(Catalog *catalog, ArtworkPrefetcher *artwork, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(catalog) , m_artwork(artwork) { m_timer.setSingleShot(true); @@ -42,7 +40,7 @@ void LibraryPrefetchController::stop() void LibraryPrefetchController::schedule(const std::vector& libraries, const QStringList& recentLibraryIds) { stop(); - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_api || !m_api->signedIn()) return; std::vector selected; @@ -147,7 +145,7 @@ void LibraryPrefetchController::prefetchPosters( void LibraryPrefetchController::startNext() { - if (m_active || !m_api || m_api->session().accessToken.isEmpty()) + if (m_active || !m_api || !m_api->signedIn()) return; if (m_index < 0 || m_index >= static_cast(m_queue.size())) return; @@ -158,7 +156,8 @@ void LibraryPrefetchController::startNext() qInfo() << "library prefetch: fetching" << request.title << request.cacheKey; Async::runLatest( - this, m_api->fetchBrowsePage(request.descriptor, 0, kLibraryPageSize), m_generation, generation, + this, m_api->fetchBrowsePage(request.descriptor, 0, kLibraryPageSize, {}, std::nullopt), m_generation, + generation, [this, request](const PagedMovieItems& page) { qInfo() << "library prefetch: cached" << request.title << page.items.size(); storePage(request.cacheKey, page); @@ -172,4 +171,4 @@ void LibraryPrefetchController::startNext() }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryPrefetchController.h b/src/app/LibraryPrefetchController.h index a0f706d6..4968cbd6 100644 --- a/src/app/LibraryPrefetchController.h +++ b/src/app/LibraryPrefetchController.h @@ -1,7 +1,8 @@ #pragma once -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" +#include "../media/MediaTypes.h" +#include "../provider/Catalog.h" #include #include @@ -12,10 +13,9 @@ #include #include -namespace JellyfinNative { +namespace Spool { class ArtworkPrefetcher; -class JellyfinApiFacade; class LibraryPrefetchController final : public QObject { Q_OBJECT @@ -23,7 +23,7 @@ class LibraryPrefetchController final : public QObject { public: enum class ImageKind { Poster, Landscape }; - LibraryPrefetchController(JellyfinApiFacade *api, ArtworkPrefetcher *artwork = nullptr, QObject *parent = nullptr); + LibraryPrefetchController(Catalog *catalog, ArtworkPrefetcher *artwork = nullptr, QObject *parent = nullptr); void stop(); void schedule(const std::vector& libraries, const QStringList& recentLibraryIds); @@ -43,7 +43,7 @@ class LibraryPrefetchController final : public QObject { void startNext(); - JellyfinApiFacade *m_api = nullptr; + Catalog *m_api = nullptr; ArtworkPrefetcher *m_artwork = nullptr; QTimer m_timer; RequestGeneration m_generation; @@ -56,4 +56,4 @@ class LibraryPrefetchController final : public QObject { int m_imagePrefetchAheadItems = 16; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryQuery.cpp b/src/app/LibraryQuery.cpp index 3a460b47..62b8ef5e 100644 --- a/src/app/LibraryQuery.cpp +++ b/src/app/LibraryQuery.cpp @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -60,19 +60,6 @@ namespace { } // namespace -QString libraryContentLabel(const LibraryItem& library) -{ - if (library.collectionType == QStringLiteral("tvshows")) - return QStringLiteral("TV Shows"); - if (library.collectionType == QStringLiteral("movies")) - return QStringLiteral("Movies"); - if (library.collectionType == QStringLiteral("musicvideos")) - return QStringLiteral("Music Videos"); - if (library.collectionType == QStringLiteral("homevideos")) - return QStringLiteral("Home Videos"); - return library.name.isEmpty() ? QStringLiteral("Library") : library.name; -} - QVariantMap defaultLibraryQuery(const LibraryItem& library) { Q_UNUSED(library); @@ -116,6 +103,7 @@ int activeLibraryFilterCount(const QVariantMap& query) QStringLiteral("isHd"), QStringLiteral("is4K"), QStringLiteral("is3D"), + QStringLiteral("isHdr"), QStringLiteral("hasSubtitles"), QStringLiteral("hasTrailer"), QStringLiteral("hasSpecialFeature"), @@ -153,4 +141,4 @@ QString libraryCacheKey(const LibraryItem& library, const QVariantMap& query) return signature.isEmpty() ? baseKey : QStringLiteral("%1?%2").arg(baseKey, signature); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryQuery.h b/src/app/LibraryQuery.h index ca4e3d60..593ec066 100644 --- a/src/app/LibraryQuery.h +++ b/src/app/LibraryQuery.h @@ -1,13 +1,12 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include -namespace JellyfinNative { +namespace Spool { -QString libraryContentLabel(const LibraryItem& library); bool supportsLatestLibraryRow(const LibraryItem& library); QVariantMap defaultLibraryQuery(const LibraryItem& library); QStringList libraryQueryStringList(const QVariantMap& query, const QString& key); @@ -15,4 +14,4 @@ int activeLibraryFilterCount(const QVariantMap& query); QString libraryCacheKey(const LibraryItem& library); QString libraryCacheKey(const LibraryItem& library, const QVariantMap& query); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LocalThumbnail.cpp b/src/app/LocalThumbnail.cpp new file mode 100644 index 00000000..6838133e --- /dev/null +++ b/src/app/LocalThumbnail.cpp @@ -0,0 +1,117 @@ +#include "LocalThumbnail.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace Spool { +QByteArray localThumbnail(const QUrl& url, const QString& cacheDirectory, QString& error) +{ + QUrl fileUrl(url); + fileUrl.setScheme(QStringLiteral("file")); + fileUrl.setQuery({}); + const QFileInfo source(fileUrl.toLocalFile()); + if (!source.isFile() || !source.isReadable()) { + error = QStringLiteral("The local media file is unavailable"); + return {}; + } + const QByteArray identity = source.canonicalFilePath().toUtf8() + '\n' + QByteArray::number(source.size()) + '\n' + + QByteArray::number(source.lastModified().toMSecsSinceEpoch()); + const QString directory = QDir(cacheDirectory).filePath(QStringLiteral("local-thumbnails-v1")); + QDir().mkpath(directory); + const QString destination = QDir(directory).filePath( + QString::fromLatin1(QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex()) + + QStringLiteral(".jpg")); + QFile cached(destination); + if (cached.open(QIODevice::ReadOnly)) + return cached.readAll(); + + std::unique_ptr decoder(mpv_create(), &mpv_terminate_destroy); + if (!decoder) { + error = QStringLiteral("Could not create the thumbnail decoder"); + return {}; + } + const auto option + = [&](const char *name, const char *value) { return mpv_set_option_string(decoder.get(), name, value) >= 0; }; + if (!option("config", "no") || !option("terminal", "no") || !option("load-scripts", "no") || !option("audio", "no") + || !option("sub", "no") || !option("hwdec", "no") || !option("vd-lavc-threads", "1") || !option("vo", "libmpv") + || !option("pause", "yes") || !option("start", "10%") || mpv_initialize(decoder.get()) < 0) { + error = QStringLiteral("Could not initialize the thumbnail decoder"); + return {}; + } + // The shipped FFmpeg deliberately contains no image encoders or scale + // filter. libmpv's software renderer uses swscale directly; Qt encodes the + // bounded result. No GPU, external ffmpeg executable or visible window. + mpv_render_param creation[] = { { MPV_RENDER_PARAM_API_TYPE, const_cast(MPV_RENDER_API_TYPE_SW) }, + { MPV_RENDER_PARAM_INVALID, nullptr } }; + mpv_render_context *rawContext = nullptr; + if (mpv_render_context_create(&rawContext, decoder.get(), creation) < 0) { + error = QStringLiteral("Could not create the software thumbnail renderer"); + return {}; + } + std::unique_ptr renderer( + rawContext, &mpv_render_context_free); + const QByteArray path = source.absoluteFilePath().toUtf8(); + const char *command[] = { "loadfile", path.constData(), nullptr }; + if (mpv_command(decoder.get(), command) < 0) { + error = QStringLiteral("Could not open media for a thumbnail"); + return {}; + } + QImage image; + QElapsedTimer deadline; + deadline.start(); + while (deadline.elapsed() < 10000) { + const mpv_event *event = mpv_wait_event(decoder.get(), 0.01); + if (event->event_id == MPV_EVENT_END_FILE || event->event_id == MPV_EVENT_SHUTDOWN) + break; + if (!(mpv_render_context_update(renderer.get()) & MPV_RENDER_UPDATE_FRAME)) + continue; + mpv_render_frame_info frame {}; + mpv_render_context_get_info(renderer.get(), { MPV_RENDER_PARAM_NEXT_FRAME_INFO, &frame }); + int64_t width = 0, height = 0; + if (mpv_get_property(decoder.get(), "dwidth", MPV_FORMAT_INT64, &width) < 0 + || mpv_get_property(decoder.get(), "dheight", MPV_FORMAT_INT64, &height) < 0 || width <= 0 || height <= 0 + || width > 32768 || height > 32768) + continue; + const QSize size + = QSize(static_cast(width), static_cast(height)).scaled(640, 360, Qt::KeepAspectRatio); + image = QImage(size, QImage::Format_RGBX8888); + int dimensions[] = { image.width(), image.height() }; + size_t stride = image.bytesPerLine(); + mpv_render_param parameters[] = { { MPV_RENDER_PARAM_SW_SIZE, dimensions }, + { MPV_RENDER_PARAM_SW_FORMAT, const_cast("rgb0") }, { MPV_RENDER_PARAM_SW_STRIDE, &stride }, + { MPV_RENDER_PARAM_SW_POINTER, image.bits() }, { MPV_RENDER_PARAM_INVALID, nullptr } }; + if (mpv_render_context_render(renderer.get(), parameters) < 0) { + image = {}; + break; + } + if ((frame.flags & MPV_RENDER_FRAME_INFO_PRESENT) && !(frame.flags & MPV_RENDER_FRAME_INFO_REDRAW)) + break; + image = {}; + } + if (image.isNull()) { + error = QStringLiteral("No video frame could be decoded for this file"); + return {}; + } + QByteArray bytes; + QBuffer encoded(&bytes); + if (!encoded.open(QIODevice::WriteOnly) || !image.save(&encoded, "JPEG", 82)) { + error = QStringLiteral("Could not encode the local thumbnail"); + return {}; + } + QSaveFile saved(destination); + if (saved.open(QIODevice::WriteOnly) && saved.write(bytes) == bytes.size()) + saved.commit(); + return bytes; +} +} diff --git a/src/app/LocalThumbnail.h b/src/app/LocalThumbnail.h new file mode 100644 index 00000000..f1485f2c --- /dev/null +++ b/src/app/LocalThumbnail.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include + +namespace Spool { +// Called on the dedicated thumbnail worker, never the GUI or network thread. +QByteArray localThumbnail(const QUrl& url, const QString& cacheDirectory, QString& error); +} diff --git a/src/app/LocalizationManager.cpp b/src/app/LocalizationManager.cpp index 2e2d3025..4775bbe7 100644 --- a/src/app/LocalizationManager.cpp +++ b/src/app/LocalizationManager.cpp @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { constexpr auto kSettingsKey = "i18n/locale"; @@ -101,4 +101,4 @@ void LocalizationManager::applyLocale(const QString& localeTag) emit localeChanged(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LocalizationManager.h b/src/app/LocalizationManager.h index 1dbaaa52..ac9de8ec 100644 --- a/src/app/LocalizationManager.h +++ b/src/app/LocalizationManager.h @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { // Owns the active Qt translator and exposes locale switching to QML. The // strategy follows the Qt-native approach: .qm files compiled from .ts files @@ -52,4 +52,4 @@ class LocalizationManager final : public QObject { bool m_useSystem = true; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/MemoryBudget.cpp b/src/app/MemoryBudget.cpp index aa9ea175..8f8e4ecf 100644 --- a/src/app/MemoryBudget.cpp +++ b/src/app/MemoryBudget.cpp @@ -3,7 +3,7 @@ #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -30,4 +30,4 @@ MemoryBudget MemoryBudget::detect() return budget; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/MemoryBudget.h b/src/app/MemoryBudget.h index 82664c94..81056474 100644 --- a/src/app/MemoryBudget.h +++ b/src/app/MemoryBudget.h @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct MemoryBudget { qint64 memTotalBytes = 0; @@ -16,4 +16,4 @@ struct MemoryBudget { static MemoryBudget detect(); }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/QuickConnectController.cpp b/src/app/QuickConnectController.cpp deleted file mode 100644 index a5930c1b..00000000 --- a/src/app/QuickConnectController.cpp +++ /dev/null @@ -1,180 +0,0 @@ -#include "QuickConnectController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" - -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr int kPollIntervalMs = 5000; - constexpr int kMaxPollAttempts = 36; - constexpr int kMaxPollErrors = 6; - -} // namespace - -QuickConnectController::QuickConnectController(JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_api(api) -{ - m_pollTimer.setInterval(kPollIntervalMs); - connect(&m_pollTimer, &QTimer::timeout, this, &QuickConnectController::poll); -} - -QString QuickConnectController::code() const -{ - return m_code; -} - -QString QuickConnectController::phase() const -{ - return m_phase; -} - -bool QuickConnectController::active() const -{ - return !m_secret.isEmpty(); -} - -void QuickConnectController::start(const QString& serverUrl) -{ - if (serverUrl.isEmpty()) { - emit errorOccurred(QStringLiteral("Enter a Jellyfin server URL first.")); - return; - } - - cancel(); - const quint64 generation = m_generation; - emit busyChanged(true, QStringLiteral("Starting Quick Connect…")); - m_api->setServerUrl(serverUrl); - - Async::runScoped( - this, m_api->quickConnectEnabled(), - [this, generation](bool enabled) { - if (generation != m_generation) - return; - if (!enabled) { - emit busyChanged(false, {}); - emit errorOccurred(QStringLiteral("Quick Connect is disabled on this server.")); - return; - } - - Async::runScoped( - this, m_api->initiateQuickConnect(), - [this, generation](const QJsonObject& result) { - if (generation != m_generation) - return; - emit busyChanged(false, {}); - m_code = result.value(QStringLiteral("Code")).toString(); - m_secret = result.value(QStringLiteral("Secret")).toString(); - m_phase = QStringLiteral("user"); - m_pollAttempts = 0; - m_pollErrors = 0; - emit changed(); - poll(); - m_pollTimer.start(); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - emit busyChanged(false, {}); - const QString message = exceptionMessage(error); - qWarning() << "quick connect: initiate failed"; - emit errorOccurred(message); - }); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - emit busyChanged(false, {}); - const QString message = exceptionMessage(error); - qWarning() << "quick connect: enabled check failed"; - emit errorOccurred(message); - }); -} - -void QuickConnectController::cancel() -{ - ++m_generation; - m_pollTimer.stop(); - m_code.fill(QLatin1Char('\0')); - m_code.clear(); - m_phase.clear(); - m_secret.fill(QLatin1Char('\0')); - m_secret.clear(); - m_pollAttempts = 0; - m_pollErrors = 0; - emit changed(); -} - -void QuickConnectController::poll() -{ - if (m_secret.isEmpty()) - return; - - ++m_pollAttempts; - if (m_pollAttempts > kMaxPollAttempts) { - qWarning() << "quick connect: timed out after polls" << m_pollAttempts; - cancel(); - emit errorOccurred(QStringLiteral("Quick Connect timed out.")); - return; - } - - const quint64 generation = m_generation; - Async::runScoped( - this, m_api->pollQuickConnect(m_secret), - [this, generation](const QJsonObject& result) { - if (generation != m_generation) - return; - - m_pollErrors = 0; - const bool isAuthenticated = result.value(QStringLiteral("Authenticated")).toBool(); - qInfo() << "quick connect: poll" << m_pollAttempts << "authenticated" << isAuthenticated; - if (!isAuthenticated) - return; - - m_pollTimer.stop(); - m_phase = QStringLiteral("server"); - emit changed(); - - Async::runScoped( - this, m_api->authenticateWithQuickConnect(m_secret), - [this, generation](const AuthSession& session) { - if (generation != m_generation) - return; - qInfo() << "quick connect: authenticated successfully"; - cancel(); - emit authenticated(session); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - const QString message = exceptionMessage(error); - qWarning() << "quick connect: token exchange failed"; - cancel(); - emit errorOccurred(message); - }); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - - const QString message = exceptionMessage(error); - ++m_pollErrors; - qWarning() << "quick connect: poll failed" << m_pollErrors; - - if (message.contains(QStringLiteral("(401)")) || message.contains(QStringLiteral("(404)")) - || m_pollErrors >= kMaxPollErrors) { - cancel(); - emit errorOccurred(message); - return; - } - // A transient poll failure changes nothing the screen shows: the - // flow is still waiting on the user to approve the code. - }); -} - -} // namespace JellyfinNative diff --git a/src/app/QuickConnectController.h b/src/app/QuickConnectController.h deleted file mode 100644 index 7cd425c1..00000000 --- a/src/app/QuickConnectController.h +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once - -#include "../common/JellyfinTypes.h" - -#include -#include -#include - -namespace JellyfinNative { - -class JellyfinApiFacade; - -class QuickConnectController final : public QObject { - Q_OBJECT - Q_PROPERTY(QString code READ code NOTIFY changed) - // Who the flow is waiting on: "user" until the code is approved elsewhere, - // "server" while the token is exchanged, empty when nothing is running. - Q_PROPERTY(QString phase READ phase NOTIFY changed) - Q_PROPERTY(bool active READ active NOTIFY changed) - -public: - explicit QuickConnectController(JellyfinApiFacade *api, QObject *parent = nullptr); - - QString code() const; - QString phase() const; - bool active() const; - - Q_INVOKABLE void start(const QString& serverUrl); - Q_INVOKABLE void cancel(); - -signals: - void changed(); - void busyChanged(bool busy, const QString& text); - void errorOccurred(const QString& message); - void authenticated(const JellyfinNative::AuthSession& session); - -private: - void poll(); - - JellyfinApiFacade *m_api = nullptr; - QTimer m_pollTimer; - QString m_code; - QString m_phase; - QString m_secret; - int m_pollAttempts = 0; - int m_pollErrors = 0; - quint64 m_generation = 0; -}; - -} // namespace JellyfinNative diff --git a/src/app/RemoteControlController.cpp b/src/app/RemoteControlController.cpp deleted file mode 100644 index 683378ef..00000000 --- a/src/app/RemoteControlController.cpp +++ /dev/null @@ -1,962 +0,0 @@ -#include "RemoteControlController.h" -#include "SpoolRemoteProtocol.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" -#include "../common/MetaJson.h" -#include "../platform/PlatformRemoteMediaSession.h" - -#include -#include -#include - -#include -#include - -namespace JellyfinNative { -namespace { - - // How far the target's own report may sit from where a seek put it before - // the reading is treated as confirmation. One poll interval of playback - // plus room for the report itself being a moment stale. - constexpr qint64 kSeekConfirmToleranceTicks = 25'000'000; - // How long to keep holding an unconfirmed seek. Past this the target is - // assumed to have ignored it and its own report wins again. - constexpr qint64 kSeekConfirmTimeoutMs = 8'000; - - qint64 jsonInteger(const QJsonValue& value) - { - return value.toVariant().toLongLong(); - } - - QString streamLabel(const QJsonObject& stream) - { - QString label = stream.value(QStringLiteral("DisplayTitle")).toString().trimmed(); - if (!label.isEmpty()) - return label; - QStringList parts; - const QString language = stream.value(QStringLiteral("Language")).toString().trimmed(); - const QString codec = stream.value(QStringLiteral("Codec")).toString().trimmed().toUpper(); - if (!language.isEmpty()) - parts.push_back(language); - if (!codec.isEmpty()) - parts.push_back(codec); - return parts.isEmpty() ? QStringLiteral("Track %1").arg(stream.value(QStringLiteral("Index")).toInt() + 1) - : parts.join(QStringLiteral(" · ")); - } - - QVariantMap normalizedNowPlayingItem(const QJsonObject& object) - { - if (object.isEmpty()) - return {}; - return metaToJson(metaFromJson(object, MetaJsonKeyPolicy::PascalCase)).toVariantMap(); - } - -} // namespace - -RemoteControlController::RemoteControlController(JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_api(api) - , m_mediaSession(createPlatformRemoteMediaSession(this)) -{ - m_link = new SpoolLink(api, this); - m_refreshTimer.setInterval(5'000); - connect(&m_refreshTimer, &QTimer::timeout, this, &RemoteControlController::refreshTargets); - m_pendingRefreshTimer.setInterval(1'000); - connect(&m_pendingRefreshTimer, &QTimer::timeout, this, &RemoteControlController::refreshTargets); - m_pendingTimeoutTimer.setSingleShot(true); - m_pendingTimeoutTimer.setInterval(30'000); - connect(&m_pendingTimeoutTimer, &QTimer::timeout, this, [this]() { - const QString targetName = m_selectedTargetName; - m_playGeneration.invalidate(); - setPlaybackPending(false); - refreshTargets(); - emit feedbackText(QStringLiteral("Playback did not start on %1").arg(targetName)); - }); - - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::playRequested, this, [this]() { requestPaused(false); }); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::pauseRequested, this, [this]() { requestPaused(true); }); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::playPauseRequested, this, - &RemoteControlController::togglePause); - connect( - m_mediaSession.get(), &PlatformRemoteMediaSession::stopRequested, this, &RemoteControlController::stopPlayback); - connect( - m_mediaSession.get(), &PlatformRemoteMediaSession::nextRequested, this, &RemoteControlController::nextTrack); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::previousRequested, this, - &RemoteControlController::previousTrack); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::seekRequested, this, - [this](qint64 positionMs) { seek(positionMs * 10'000); }); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::seekRelativeRequested, this, - [this](qint64 deltaMs) { seekRelative(deltaMs * 10'000); }); - connect( - m_mediaSession.get(), &PlatformRemoteMediaSession::volumeRequested, this, &RemoteControlController::setVolume); -} - -RemoteControlController::~RemoteControlController() = default; - -void RemoteControlController::start() -{ - if (!m_refreshTimer.isActive()) - m_refreshTimer.start(); - refreshTargets(); -} - -void RemoteControlController::stop() -{ - m_refreshTimer.stop(); - m_sessionsGeneration.invalidate(); - m_sessions.clear(); - m_targets.clear(); - clearTarget(); - emit targetsChanged(); -} - -void RemoteControlController::setBusy(bool busy) -{ - if (m_busy == busy) - return; - m_busy = busy; - emit busyChanged(); -} -void RemoteControlController::setPlaybackPending(bool pending) -{ - if (m_playbackPending == pending) - return; - m_playbackPending = pending; - if (pending) { - m_pendingTimeoutTimer.start(); - } else { - m_pendingRefreshTimer.stop(); - m_pendingTimeoutTimer.stop(); - m_pendingTargetSessionId.clear(); - m_pendingItemId.clear(); - m_pendingPreviousItemId.clear(); - m_pendingTitle.clear(); - } - emit playbackPendingChanged(); -} - -void RemoteControlController::refreshTargets() -{ - if (!m_api || m_api->session().accessToken.isEmpty() || m_busy) - return; - const RequestGeneration::Token generation = m_sessionsGeneration.current(); - setBusy(true); - Async::runScoped( - this, m_api->fetchControllableSessions(), - [this, generation](const QJsonArray& sessions) { - setBusy(false); - if (m_sessionsGeneration.isCurrent(generation)) - applySessions(sessions); - }, - [this](const std::exception_ptr& error) { - setBusy(false); - reportCommandError(QStringLiteral("refresh remote targets"), error); - }, - "remote target refresh"); -} - -void RemoteControlController::applySessions(const QJsonArray& sessions) -{ - // A pushed session snapshot supersedes an HTTP refresh already in flight. - m_sessionsGeneration.invalidate(); - QHash nextSessions; - QVariantList nextTargets; - const QString localDeviceId = m_api ? m_api->deviceId() : QString(); - QString localSessionId; - - for (const QJsonValue& value : sessions) { - const QJsonObject session = value.toObject(); - const QString id = session.value(QStringLiteral("Id")).toString(); - if (!localDeviceId.isEmpty() && session.value(QStringLiteral("DeviceId")).toString() == localDeviceId) - localSessionId = id; - if (id.isEmpty() || session.value(QStringLiteral("DeviceId")).toString() == localDeviceId) - continue; - const QJsonObject capabilities = session.value(QStringLiteral("Capabilities")).toObject(); - if (!capabilities.value(QStringLiteral("SupportsMediaControl")).toBool(false)) - continue; - - nextSessions.insert(id, session); - const QJsonObject nowPlaying = session.value(QStringLiteral("NowPlayingItem")).toObject(); - nextTargets.push_back(QVariantMap { - { QStringLiteral("sessionId"), id }, - { QStringLiteral("deviceName"), session.value(QStringLiteral("DeviceName")).toString() }, - { QStringLiteral("deviceType"), session.value(QStringLiteral("DeviceType")).toString() }, - { QStringLiteral("client"), session.value(QStringLiteral("Client")).toString() }, - { QStringLiteral("userName"), session.value(QStringLiteral("UserName")).toString() }, - { QStringLiteral("nowPlayingTitle"), nowPlaying.value(QStringLiteral("Name")).toString() }, - { QStringLiteral("appVersion"), session.value(QStringLiteral("ApplicationVersion")).toString() }, - }); - } - - std::sort(nextTargets.begin(), nextTargets.end(), [](const QVariant& left, const QVariant& right) { - return QString::localeAwareCompare(left.toMap().value(QStringLiteral("deviceName")).toString(), - right.toMap().value(QStringLiteral("deviceName")).toString()) - < 0; - }); - - if (!localSessionId.isEmpty() && localSessionId != m_localSessionId) { - m_localSessionId = localSessionId; - m_link->setLocalSessionId(localSessionId); - // A peer can only answer once it has somewhere to answer to. - if (!m_selectedSessionId.isEmpty()) - m_link->connectToPeer(m_selectedSessionId); - } - const bool targetListChanged = m_targets != nextTargets; - m_sessions = std::move(nextSessions); - m_targets = std::move(nextTargets); - if (targetListChanged) { - qInfo() << "remote control: discovered" << m_targets.size() << "controllable client(s)"; - emit targetsChanged(); - } - - if (!m_selectedSessionId.isEmpty() && !m_sessions.contains(m_selectedSessionId)) { - clearTarget(); - return; - } - if (!m_selectedSessionId.isEmpty()) - applySelectedSession(); -} - -void RemoteControlController::selectTarget(const QString& sessionId) -{ - const QString normalized = sessionId.trimmed(); - if (!m_sessions.contains(normalized)) { - emit errorText(QStringLiteral("That remote client is no longer available.")); - refreshTargets(); - return; - } - if (m_selectedSessionId == normalized) { - applySelectedSession(); - return; - } - clearSelectedState(false); - m_selectedSessionId = normalized; - m_link->connectToPeer(normalized); - applySelectedSession(true); -} - -void RemoteControlController::clearTarget() -{ - if (m_selectedSessionId.isEmpty() && m_selectedTargetName.isEmpty()) { - clearSelectedState(); - return; - } - m_link->reset(); - m_selectedSessionId.clear(); - m_selectedTargetName.clear(); - m_selectedTargetDetail.clear(); - clearSelectedState(); - emit targetChanged(); -} - -void RemoteControlController::clearPendingCommands() -{ - m_pauseGeneration.invalidate(); - m_pendingPaused.reset(); - m_pendingPauseDeadlineMs = 0; - m_pendingSeekTicks.reset(); - m_pendingSeekAtMs = 0; - m_pendingSeekDeadlineMs = 0; -} - -void RemoteControlController::clearSelectedState(bool notify) -{ - m_queueGeneration.invalidate(); - m_playGeneration.invalidate(); - clearPendingCommands(); - setPlaybackPending(false); - m_trickplayGeneration.invalidate(); - m_trickplayTimeline.clear(); - m_trickplayItemId.clear(); - m_trickplayMediaSourceId.clear(); - m_previewSentTicks = -1; - m_previewSentActive = false; - if (notify) - emit trickplayChanged(); - m_nowPlayingItem.clear(); - m_queue.clear(); - m_audioTracks.clear(); - m_subtitleTracks.clear(); - m_supportedCommands.clear(); - m_rawQueue = {}; - m_positionTicks = 0; - m_runtimeTicks = 0; - m_stateReceivedAtMs = 0; - m_playbackRate = 1.0; - m_volume = 100; - m_paused = true; - m_muted = false; - m_shuffled = false; - m_repeatMode = QStringLiteral("RepeatNone"); - if (notify) { - if (m_mediaSession) - m_mediaSession->clear(); - emit stateChanged(); - } -} - -void RemoteControlController::applySelectedSession(bool selectionChanged) -{ - const QJsonObject session = m_sessions.value(m_selectedSessionId); - if (session.isEmpty()) - return; - - const QString previousTargetName = m_selectedTargetName; - const QString previousTargetDetail = m_selectedTargetDetail; - m_selectedTargetName = session.value(QStringLiteral("DeviceName")).toString().trimmed(); - if (m_selectedTargetName.isEmpty()) - m_selectedTargetName = session.value(QStringLiteral("Client")).toString().trimmed(); - const QString client = session.value(QStringLiteral("Client")).toString().trimmed(); - const QString user = session.value(QStringLiteral("UserName")).toString().trimmed(); - QStringList detailParts; - if (!client.isEmpty()) - detailParts.push_back(client); - if (!user.isEmpty()) - detailParts.push_back(user); - m_selectedTargetDetail = detailParts.join(QStringLiteral(" · ")); - const bool targetInfoChanged - = previousTargetName != m_selectedTargetName || previousTargetDetail != m_selectedTargetDetail; - - const QJsonObject nowPlaying = session.value(QStringLiteral("NowPlayingItem")).toObject(); - const QJsonObject playState = session.value(QStringLiteral("PlayState")).toObject(); - if (m_playbackPending && m_selectedSessionId == m_pendingTargetSessionId) { - const QString nowPlayingId = nowPlaying.value(QStringLiteral("Id")).toString(); - // Empty while the target is between items, which is most of what a - // start looks like from here. - const bool unchanged = nowPlayingId.isEmpty() || nowPlayingId == m_pendingPreviousItemId; - if (nowPlayingId == m_pendingItemId) { - setPlaybackPending(false); - } else if (unchanged) { - // The command has not landed yet. Hold the item the user chose - // rather than flicking back to the one being replaced. - if (targetInfoChanged) - emit targetChanged(); - emit stateChanged(); - return; - } else { - // Playing something nobody here asked for: the target was driven - // directly, so follow it now instead of showing a stale item for - // the rest of the timeout. - setPlaybackPending(false); - } - } - if (nowPlaying.value(QStringLiteral("Id")).toString() - != m_nowPlayingItem.value(QStringLiteral("movieId")).toString()) - clearPendingCommands(); - m_nowPlayingItem = normalizedNowPlayingItem(nowPlaying); - loadTrickplay(nowPlaying.value(QStringLiteral("Id")).toString(), - nowPlaying.value(QStringLiteral("MediaSourceId")).toString()); - const qint64 reportedTicks = jsonInteger(playState.value(QStringLiteral("PositionTicks"))); - m_runtimeTicks = jsonInteger(nowPlaying.value(QStringLiteral("RunTimeTicks"))); - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - m_playbackRate = playState.value(QStringLiteral("PlaybackRate")).toDouble(1.0); - if (m_pendingSeekTicks && nowMs <= m_pendingSeekDeadlineMs) { - // Hold the position this device asked for until the target's own - // report reaches it, so the bar stays where the user put it. - const qint64 elapsedTicks - = m_paused ? 0 : static_cast((nowMs - m_pendingSeekAtMs) * 10'000.0 * m_playbackRate); - const qint64 expected = *m_pendingSeekTicks + elapsedTicks; - if (std::llabs(reportedTicks - expected) <= kSeekConfirmToleranceTicks) { - m_pendingSeekTicks.reset(); - m_pendingSeekDeadlineMs = 0; - m_positionTicks = reportedTicks; - m_stateReceivedAtMs = nowMs; - } else { - m_positionTicks = *m_pendingSeekTicks; - m_stateReceivedAtMs = m_pendingSeekAtMs; - } - } else { - m_pendingSeekTicks.reset(); - m_pendingSeekDeadlineMs = 0; - m_positionTicks = reportedTicks; - m_stateReceivedAtMs = nowMs; - } - const bool reportedPaused = playState.value(QStringLiteral("IsPaused")).toBool(true); - if (m_pendingPaused && nowMs <= m_pendingPauseDeadlineMs) { - if (reportedPaused == *m_pendingPaused) { - m_pendingPaused.reset(); - m_pendingPauseDeadlineMs = 0; - } else - m_paused = *m_pendingPaused; - } else { - m_pendingPauseDeadlineMs = 0; - m_pendingPaused.reset(); - m_paused = reportedPaused; - } - m_muted = playState.value(QStringLiteral("IsMuted")).toBool(false); - m_volume = std::clamp(playState.value(QStringLiteral("VolumeLevel")).toInt(100), 0, 100); - m_repeatMode = playState.value(QStringLiteral("RepeatMode")).toString(QStringLiteral("RepeatNone")); - m_shuffled = playState.value(QStringLiteral("PlaybackOrder")).toString() == QStringLiteral("Shuffle"); - - const QJsonArray commands - = session.value(QStringLiteral("Capabilities")).toObject().value(QStringLiteral("SupportedCommands")).toArray(); - m_supportedCommands.clear(); - m_supportedCommands.reserve(commands.size()); - for (const QJsonValue& command : commands) { - const QString name = command.toString(); - if (!name.isEmpty()) - m_supportedCommands.push_back(name); - } - - const int selectedAudio = playState.value(QStringLiteral("AudioStreamIndex")).toInt(-1); - const int selectedSubtitle = playState.value(QStringLiteral("SubtitleStreamIndex")).toInt(-1); - m_audioTracks.clear(); - m_subtitleTracks.clear(); - for (const QJsonValue& value : nowPlaying.value(QStringLiteral("MediaStreams")).toArray()) { - const QJsonObject stream = value.toObject(); - const int index = stream.value(QStringLiteral("Index")).toInt(-1); - QVariantMap row { - { QStringLiteral("index"), index }, - { QStringLiteral("label"), streamLabel(stream) }, - { QStringLiteral("selected"), false }, - }; - if (stream.value(QStringLiteral("Type")).toString() == QStringLiteral("Audio")) { - row.insert(QStringLiteral("selected"), index == selectedAudio); - m_audioTracks.push_back(row); - } else if (stream.value(QStringLiteral("Type")).toString() == QStringLiteral("Subtitle")) { - row.insert(QStringLiteral("selected"), index == selectedSubtitle); - m_subtitleTracks.push_back(row); - } - } - if (!m_subtitleTracks.isEmpty()) { - m_subtitleTracks.prepend(QVariantMap { - { QStringLiteral("index"), -1 }, - { QStringLiteral("label"), QStringLiteral("Off") }, - { QStringLiteral("selected"), selectedSubtitle < 0 }, - }); - } - - m_rawQueue = session.value(QStringLiteral("NowPlayingQueue")).toArray(); - refreshQueueDetails(m_rawQueue); - updateMediaSession(); - if (selectionChanged) - emit trickplayChanged(); - if (selectionChanged || targetInfoChanged) - emit targetChanged(); - emit stateChanged(); -} - -void RemoteControlController::refreshQueueDetails(const QJsonArray& rawQueue) -{ - const RequestGeneration::Token generation = m_queueGeneration.next(); - if (rawQueue.isEmpty()) { - m_queue.clear(); - emit stateChanged(); - return; - } - - QStringList ids; - ids.reserve(rawQueue.size()); - for (const QJsonValue& value : rawQueue) { - const QString id = value.toObject().value(QStringLiteral("Id")).toString(); - if (!id.isEmpty()) - ids.push_back(id); - } - if (ids.isEmpty() || !m_api) { - m_queue.clear(); - emit stateChanged(); - return; - } - - const QJsonObject selectedSession = m_sessions.value(m_selectedSessionId); - const QString currentPlaylistItemId = selectedSession.value(QStringLiteral("PlaylistItemId")).toString(); - const QString currentItemId - = selectedSession.value(QStringLiteral("NowPlayingItem")).toObject().value(QStringLiteral("Id")).toString(); - Async::runScoped( - this, m_api->fetchItemsByIds(ids), - [this, generation, rawQueue, currentPlaylistItemId, currentItemId](const std::vector& items) { - if (generation != m_queueGeneration.current()) - return; - QVariantList queue; - queue.reserve(rawQueue.size()); - for (const QJsonValue& value : rawQueue) { - const QJsonObject raw = value.toObject(); - const QString id = raw.value(QStringLiteral("Id")).toString(); - const auto item = std::find_if( - items.cbegin(), items.cend(), [&id](const MovieItem& candidate) { return candidate.id == id; }); - QVariantMap row = item == items.cend() ? QVariantMap {} : metaToJson(*item).toVariantMap(); - row.insert(QStringLiteral("movieId"), id); - row.insert(QStringLiteral("playlistItemId"), raw.value(QStringLiteral("PlaylistItemId")).toString()); - const QString playlistItemId = raw.value(QStringLiteral("PlaylistItemId")).toString(); - row.insert(QStringLiteral("current"), - !currentPlaylistItemId.isEmpty() ? playlistItemId == currentPlaylistItemId : id == currentItemId); - queue.push_back(row); - } - m_queue = std::move(queue); - emit stateChanged(); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_queueGeneration.current()) - return; - reportCommandError(QStringLiteral("load remote queue"), error); - }, - "remote queue details"); -} - -void RemoteControlController::loadTrickplay(const QString& itemId, const QString& mediaSourceId) -{ - const QString normalizedMediaSourceId = mediaSourceId.trimmed(); - if (itemId == m_trickplayItemId - && (normalizedMediaSourceId.isEmpty() || normalizedMediaSourceId == m_trickplayMediaSourceId)) - return; - - m_trickplayItemId = itemId; - m_trickplayMediaSourceId = normalizedMediaSourceId; - m_trickplayTimeline.clear(); - emit trickplayChanged(); - const RequestGeneration::Token generation = m_trickplayGeneration.next(); - if (!m_api || itemId.isEmpty()) - return; - - Async::runScoped( - this, m_api->fetchTrickplayInfo(itemId, normalizedMediaSourceId), - [this, generation, itemId](const TrickplayInfo& trickplay) { - if (!m_trickplayGeneration.isCurrent(generation) || m_trickplayItemId != itemId) - return; - PlaybackSession session; - session.itemId = itemId; - session.trickplay = trickplay; - m_trickplayTimeline.setSession(session); - emit trickplayChanged(); - }, - [this, generation, itemId](const std::exception_ptr& error) { - if (!m_trickplayGeneration.isCurrent(generation) || m_trickplayItemId != itemId) - return; - qInfo() << "remote trickplay: metadata unavailable for" << itemId << ":" << exceptionMessage(error); - }, - "remote trickplay metadata"); -} - -QVariantMap RemoteControlController::trickplayForTicks(qint64 positionTicks) const -{ - QVariantMap result; - if (!m_api || !m_trickplayTimeline.trickplayAvailable()) { - result.insert(QStringLiteral("available"), false); - return result; - } - - const PlaybackTimeline::TrickplayFrame frame - = m_trickplayTimeline.trickplayFrameAt(std::max(0, positionTicks) / 10'000'000.0); - if (!frame.available) { - result.insert(QStringLiteral("available"), false); - return result; - } - result.insert(QStringLiteral("available"), true); - result.insert(QStringLiteral("url"), - m_api->trickplayTileUrl(m_trickplayItemId, m_trickplayTimeline.trickplayWidth(), frame.sheetIndex)); - result.insert(QStringLiteral("width"), frame.width); - result.insert(QStringLiteral("height"), frame.height); - result.insert(QStringLiteral("offsetX"), frame.offsetX); - result.insert(QStringLiteral("offsetY"), frame.offsetY); - result.insert(QStringLiteral("sheetWidth"), frame.sheetWidth); - result.insert(QStringLiteral("sheetHeight"), frame.sheetHeight); - return result; -} - -void RemoteControlController::previewSeek(qint64 positionTicks, bool active) -{ - if (!targetSelected() || !m_link || m_trickplayItemId.isEmpty()) - return; - const qint64 ticks = std::max(0, positionTicks); - if (ticks == m_previewSentTicks && active == m_previewSentActive) - return; - m_previewSentTicks = ticks; - m_previewSentActive = active; - // Only the newest position is worth showing, so an older one still - // waiting for the link is replaced rather than queued behind it. The - // teardown is not: it is what puts the other screen back. - m_link->send(m_selectedSessionId, SpoolRemoteProtocol::seekPreviewMessage(m_trickplayItemId, ticks, active), - active ? SpoolLink::Delivery::Coalesced : SpoolLink::Delivery::Reliable); -} - -void RemoteControlController::cancelSeekPreview() -{ - if (m_previewSentActive) - previewSeek(m_previewSentTicks, false); -} - -void RemoteControlController::requestSyncPlayJoin(const QString& groupId) -{ - if (!targetSelected() || !m_link || groupId.isEmpty()) - return; - m_link->send(m_selectedSessionId, SpoolRemoteProtocol::syncPlayJoinMessage(groupId)); -} - -bool RemoteControlController::supports(const QString& command) const -{ - return m_supportedCommands.contains(command); -} - -qint64 RemoteControlController::predictedPositionTicks() const -{ - if (m_paused || m_positionTicks <= 0 || m_stateReceivedAtMs <= 0) - return m_positionTicks; - const qint64 elapsedMs = std::max(0, QDateTime::currentMSecsSinceEpoch() - m_stateReceivedAtMs); - const qint64 predicted = m_positionTicks + static_cast(elapsedMs * 10'000.0 * m_playbackRate); - return m_runtimeTicks > 0 ? std::min(predicted, m_runtimeTicks) : predicted; -} - -void RemoteControlController::stagePendingPlayback( - const std::vector& items, qint64 startPositionTicks, const QString& command) -{ - if (command != QStringLiteral("PlayNow") && command != QStringLiteral("PlayShuffle")) - return; - - QVariantList queue; - QJsonArray rawQueue; - QVariantMap selectedItem; - queue.reserve(static_cast(items.size())); - for (int index = 0; index < static_cast(items.size()); ++index) { - const MovieItem& item = items[static_cast(index)]; - if (item.id.isEmpty() || !isPlayableItem(item)) - continue; - QVariantMap row = metaToJson(item).toVariantMap(); - row.insert(QStringLiteral("movieId"), item.id); - row.insert(QStringLiteral("current"), item.id == m_pendingItemId); - queue.push_back(row); - rawQueue.append(QJsonObject { { QStringLiteral("Id"), item.id } }); - if (item.id == m_pendingItemId) - selectedItem = row; - } - if (selectedItem.isEmpty() && !queue.isEmpty()) - selectedItem = queue.front().toMap(); - - m_queueGeneration.invalidate(); - m_nowPlayingItem = selectedItem; - m_queue = std::move(queue); - m_rawQueue = std::move(rawQueue); - m_audioTracks.clear(); - m_subtitleTracks.clear(); - m_positionTicks = std::max(0, startPositionTicks); - loadTrickplay(selectedItem.value(QStringLiteral("movieId")).toString()); - m_runtimeTicks = selectedItem.value(QStringLiteral("runtimeTicks")).toLongLong(); - m_stateReceivedAtMs = QDateTime::currentMSecsSinceEpoch(); - m_playbackRate = 1.0; - m_paused = false; - updateMediaSession(); - emit stateChanged(); -} - -bool RemoteControlController::playItems( - const std::vector& items, int startIndex, const QString& command, bool fromStart) -{ - if (!targetSelected()) - return false; - QStringList ids; - int remoteStartIndex = -1; - qint64 startPositionTicks = -1; - QString title; - for (int index = 0; index < static_cast(items.size()); ++index) { - const MovieItem& item = items[static_cast(index)]; - if (item.id.isEmpty() || !isPlayableItem(item)) - continue; - if (index == startIndex) { - remoteStartIndex = ids.size(); - startPositionTicks = fromStart ? 0 : std::max(0, item.resumeTicks); - title = item.title; - } - ids.push_back(item.id); - } - if (ids.isEmpty()) { - emit errorText(QStringLiteral("This list has no remotely playable items.")); - return true; - } - if (remoteStartIndex < 0) - remoteStartIndex = 0; - // The result is the caller's, not swallowed: every caller treats a true - // here as "handled remotely, do not play locally", so reporting success - // for a command that never went out strands the user with nothing playing - // anywhere. - if (!runPlay(ids, command, remoteStartIndex, startPositionTicks, title)) - return false; - stagePendingPlayback(items, startPositionTicks, command); - return true; -} - -void RemoteControlController::beginPendingPlayback(const QString& target, const QString& itemId, const QString& title) -{ - clearPendingCommands(); - m_pendingPreviousItemId = m_nowPlayingItem.value(QStringLiteral("movieId")).toString(); - m_pendingTargetSessionId = target; - m_pendingItemId = itemId; - m_pendingTitle = title; - // Restarted for each request, so superseding one does not inherit the - // remains of the earlier request's timeout. - m_pendingTimeoutTimer.start(); - if (m_playbackPending) - return; - m_playbackPending = true; - emit playbackPendingChanged(); -} - -void RemoteControlController::playItemIds( - const QStringList& itemIds, const QString& command, int startIndex, qint64 startPositionTicks) -{ - runPlay(itemIds, command, startIndex, startPositionTicks); -} - -bool RemoteControlController::runPlay( - QStringList itemIds, QString command, int startIndex, qint64 startPositionTicks, QString pendingTitle) -{ - if (!targetSelected() || !m_api) - return false; - itemIds.removeAll(QString()); - if (itemIds.isEmpty()) - return false; - - const bool startsPlayback = command == QStringLiteral("PlayNow") || command == QStringLiteral("PlayShuffle"); - const QString target = m_selectedSessionId; - // Taking a generation retires the previous one, so a request still in - // flight stops being able to change anything here. That is what lets a - // newer choice supersede an older one: refusing it instead left the - // target playing the old item, left this device showing the old item, and - // discarded what the user actually asked for without saying so. - const RequestGeneration::Token generation = m_playGeneration.next(); - if (startsPlayback) { - const int requestedIndex = std::clamp(startIndex, 0, static_cast(itemIds.size()) - 1); - beginPendingPlayback(target, itemIds.at(requestedIndex), pendingTitle.trimmed()); - const QString subject = m_pendingTitle.isEmpty() ? QStringLiteral("playback") : m_pendingTitle; - emit feedbackText(QStringLiteral("Starting %1 on %2…").arg(subject, m_selectedTargetName)); - } - - Async::runScoped( - this, m_api->sendRemotePlay(target, std::move(itemIds), std::move(command), startPositionTicks, startIndex), - [this, generation, startsPlayback]() { - if (!m_playGeneration.isCurrent(generation)) - return; - if (startsPlayback) { - refreshTargets(); - m_pendingRefreshTimer.start(); - } else { - QTimer::singleShot(300, this, &RemoteControlController::refreshTargets); - } - }, - [this, generation, startsPlayback](const std::exception_ptr& error) { - if (!m_playGeneration.isCurrent(generation)) - return; - if (startsPlayback) { - const QString targetName = m_selectedTargetName; - setPlaybackPending(false); - refreshTargets(); - emit feedbackText(QStringLiteral("Could not start playback on %1").arg(targetName)); - } - reportCommandError(QStringLiteral("start remote playback"), error); - }, - "remote play command"); - return true; -} - -void RemoteControlController::sendPlaystate(const QString& command, qint64 seekPositionTicks) -{ - if (!targetSelected() || !m_api) - return; - const QString target = m_selectedSessionId; - const bool pauseCommand = command == QStringLiteral("Pause") || command == QStringLiteral("Unpause"); - const RequestGeneration::Token pauseGeneration - = pauseCommand ? m_pauseGeneration.next() : m_pauseGeneration.current(); - Async::runScoped( - this, m_api->sendRemotePlaystate(target, command, seekPositionTicks), - [this]() { QTimer::singleShot(250, this, &RemoteControlController::refreshTargets); }, - [this, command, pauseCommand, pauseGeneration](const std::exception_ptr& error) { - if (pauseCommand && m_pauseGeneration.isCurrent(pauseGeneration)) { - m_pendingPaused.reset(); - m_pendingPauseDeadlineMs = 0; - refreshTargets(); - } - reportCommandError(command, error); - }, - "remote playstate command"); -} - -void RemoteControlController::sendGeneralCommand(const QString& command, const QVariantMap& arguments) -{ - if (!targetSelected() || !m_api) - return; - const QString target = m_selectedSessionId; - Async::runScoped( - this, m_api->sendRemoteGeneralCommand(target, command, QJsonObject::fromVariantMap(arguments)), - [this]() { QTimer::singleShot(250, this, &RemoteControlController::refreshTargets); }, - [this, command](const std::exception_ptr& error) { reportCommandError(command, error); }, - "remote general command"); -} - -void RemoteControlController::requestPaused(bool paused) -{ - if (!targetSelected()) - return; - m_pendingPaused = paused; - m_pendingPauseDeadlineMs = QDateTime::currentMSecsSinceEpoch() + 8'000; - m_paused = paused; - m_stateReceivedAtMs = QDateTime::currentMSecsSinceEpoch(); - updateMediaSession(); - emit stateChanged(); - sendPlaystate(paused ? QStringLiteral("Pause") : QStringLiteral("Unpause")); -} - -void RemoteControlController::togglePause() -{ - requestPaused(!m_paused); -} - -void RemoteControlController::stopPlayback() -{ - sendPlaystate(QStringLiteral("Stop")); -} - -void RemoteControlController::nextTrack() -{ - sendPlaystate(QStringLiteral("NextTrack")); -} - -void RemoteControlController::previousTrack() -{ - sendPlaystate(QStringLiteral("PreviousTrack")); -} - -void RemoteControlController::seek(qint64 positionTicks) -{ - const qint64 clamped = std::clamp(positionTicks, 0, m_runtimeTicks > 0 ? m_runtimeTicks : positionTicks); - m_positionTicks = clamped; - m_stateReceivedAtMs = QDateTime::currentMSecsSinceEpoch(); - m_pendingSeekTicks = clamped; - m_pendingSeekAtMs = m_stateReceivedAtMs; - m_pendingSeekDeadlineMs = m_stateReceivedAtMs + kSeekConfirmTimeoutMs; - sendPlaystate(QStringLiteral("Seek"), clamped); - updateMediaSession(); - emit stateChanged(); -} - -void RemoteControlController::seekRelative(qint64 deltaTicks) -{ - seek(std::max(0, predictedPositionTicks() + deltaTicks)); -} - -void RemoteControlController::setVolume(int volume) -{ - m_volume = std::clamp(volume, 0, 100); - sendGeneralCommand(QStringLiteral("SetVolume"), { { QStringLiteral("Volume"), m_volume } }); - updateMediaSession(); - emit stateChanged(); -} - -void RemoteControlController::adjustVolume(int delta) -{ - setVolume(m_volume + delta); -} - -void RemoteControlController::toggleMute() -{ - m_muted = !m_muted; - sendGeneralCommand(m_muted ? QStringLiteral("Mute") : QStringLiteral("Unmute")); - emit stateChanged(); -} - -void RemoteControlController::selectAudioTrack(int streamIndex) -{ - sendGeneralCommand(QStringLiteral("SetAudioStreamIndex"), { { QStringLiteral("Index"), streamIndex } }); -} - -void RemoteControlController::selectSubtitleTrack(int streamIndex) -{ - sendGeneralCommand(QStringLiteral("SetSubtitleStreamIndex"), { { QStringLiteral("Index"), streamIndex } }); -} - -void RemoteControlController::setRepeatMode(const QString& mode) -{ - m_repeatMode = mode; - sendGeneralCommand(QStringLiteral("SetRepeatMode"), { { QStringLiteral("RepeatMode"), mode } }); - emit stateChanged(); -} - -void RemoteControlController::setShuffled(bool shuffled) -{ - m_shuffled = shuffled; - sendGeneralCommand(QStringLiteral("SetShuffleQueue"), - { { QStringLiteral("ShuffleMode"), shuffled ? QStringLiteral("Shuffle") : QStringLiteral("Sorted") } }); - emit stateChanged(); -} - -void RemoteControlController::playQueueItem(int index) -{ - if (index < 0 || index >= m_rawQueue.size()) - return; - QStringList ids; - for (const QJsonValue& value : m_rawQueue) - ids.push_back(value.toObject().value(QStringLiteral("Id")).toString()); - runPlay(ids, QStringLiteral("PlayNow"), index, 0); -} - -void RemoteControlController::moveQueueItem(int from, int to) -{ - if (from < 0 || from >= m_rawQueue.size() || to < 0 || to >= m_rawQueue.size() || from == to) - return; - QJsonArray reordered = m_rawQueue; - const QJsonValue moved = reordered.takeAt(from); - reordered.insert(to, moved); - QStringList ids; - int current = 0; - const QString currentPlaylistId - = m_sessions.value(m_selectedSessionId).value(QStringLiteral("PlaylistItemId")).toString(); - for (int index = 0; index < reordered.size(); ++index) { - const QJsonObject row = reordered.at(index).toObject(); - ids.push_back(row.value(QStringLiteral("Id")).toString()); - if (!currentPlaylistId.isEmpty() && row.value(QStringLiteral("PlaylistItemId")).toString() == currentPlaylistId) - current = index; - } - runPlay(ids, QStringLiteral("PlayNow"), current, predictedPositionTicks()); -} - -void RemoteControlController::removeQueueItem(int index) -{ - if (index < 0 || index >= m_rawQueue.size()) - return; - QJsonArray remaining = m_rawQueue; - const QJsonObject removed = remaining.takeAt(index).toObject(); - if (remaining.isEmpty()) { - stopPlayback(); - return; - } - QStringList ids; - int current = 0; - const QString currentPlaylistId - = m_sessions.value(m_selectedSessionId).value(QStringLiteral("PlaylistItemId")).toString(); - for (int rowIndex = 0; rowIndex < remaining.size(); ++rowIndex) { - const QJsonObject row = remaining.at(rowIndex).toObject(); - ids.push_back(row.value(QStringLiteral("Id")).toString()); - if (!currentPlaylistId.isEmpty() && row.value(QStringLiteral("PlaylistItemId")).toString() == currentPlaylistId) - current = rowIndex; - } - const bool removedCurrent = removed.value(QStringLiteral("PlaylistItemId")).toString() == currentPlaylistId; - runPlay(ids, QStringLiteral("PlayNow"), current, removedCurrent ? 0 : predictedPositionTicks()); -} - -void RemoteControlController::updateMediaSession() -{ - if (!m_mediaSession || !targetSelected() || m_nowPlayingItem.isEmpty()) { - if (m_mediaSession) - m_mediaSession->clear(); - return; - } - RemoteMediaSessionState state; - state.title = m_nowPlayingItem.value(QStringLiteral("title")).toString(); - state.artist = m_nowPlayingItem.value(QStringLiteral("seriesName")).toString(); - if (state.artist.isEmpty()) - state.artist = m_nowPlayingItem.value(QStringLiteral("albumArtist")).toString(); - state.album = m_nowPlayingItem.value(QStringLiteral("album")).toString(); - state.targetName = m_selectedTargetName; - state.durationMs = m_runtimeTicks / 10'000; - state.positionMs = predictedPositionTicks() / 10'000; - state.playbackRate = m_playbackRate; - state.volume = m_volume; - state.playing = !m_paused; - state.canSeek = m_runtimeTicks > 0; - m_mediaSession->update(state); -} - -void RemoteControlController::reportCommandError(const QString& action, const std::exception_ptr& error) -{ - emit errorText(QStringLiteral("Could not %1: %2").arg(action, exceptionMessage(error))); -} - -} // namespace JellyfinNative diff --git a/src/app/RemoteControlController.h b/src/app/RemoteControlController.h deleted file mode 100644 index a106f4d7..00000000 --- a/src/app/RemoteControlController.h +++ /dev/null @@ -1,266 +0,0 @@ -#pragma once - -#include "../common/JellyfinTypes.h" -#include "../common/RequestGeneration.h" -#include "../player/PlaybackTimeline.h" -#include "SpoolLink.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace JellyfinNative { - -class JellyfinApiFacade; -class PlatformRemoteMediaSession; - -class RemoteControlController final : public QObject { - Q_OBJECT - Q_PROPERTY(QVariantList targets READ targets NOTIFY targetsChanged) - Q_PROPERTY(QString selectedSessionId READ selectedSessionId NOTIFY targetChanged) - Q_PROPERTY(QString selectedTargetName READ selectedTargetName NOTIFY targetChanged) - Q_PROPERTY(QString selectedTargetDetail READ selectedTargetDetail NOTIFY targetChanged) - Q_PROPERTY(bool targetSelected READ targetSelected NOTIFY targetChanged) - Q_PROPERTY(QVariantMap nowPlayingItem READ nowPlayingItem NOTIFY stateChanged) - Q_PROPERTY(QVariantList queue READ queue NOTIFY stateChanged) - Q_PROPERTY(QVariantList audioTracks READ audioTracks NOTIFY stateChanged) - Q_PROPERTY(QVariantList subtitleTracks READ subtitleTracks NOTIFY stateChanged) - Q_PROPERTY(QStringList supportedCommands READ supportedCommands NOTIFY stateChanged) - Q_PROPERTY(qint64 positionTicks READ positionTicks NOTIFY stateChanged) - Q_PROPERTY(qint64 runtimeTicks READ runtimeTicks NOTIFY stateChanged) - Q_PROPERTY(bool paused READ paused NOTIFY stateChanged) - Q_PROPERTY(bool muted READ muted NOTIFY stateChanged) - Q_PROPERTY(int volume READ volume NOTIFY stateChanged) - Q_PROPERTY(QString repeatMode READ repeatMode NOTIFY stateChanged) - Q_PROPERTY(bool shuffled READ shuffled NOTIFY stateChanged) - Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) - Q_PROPERTY(bool playbackPending READ playbackPending NOTIFY playbackPendingChanged) - Q_PROPERTY(QString pendingTitle READ pendingTitle NOTIFY playbackPendingChanged) - Q_PROPERTY(bool trickplayAvailable READ trickplayAvailable NOTIFY trickplayChanged) - -public: - explicit RemoteControlController(JellyfinApiFacade *api, QObject *parent = nullptr); - ~RemoteControlController() override; - - QVariantList targets() const - { - return m_targets; - } - QString selectedSessionId() const - { - return m_selectedSessionId; - } - QString selectedTargetName() const - { - return m_selectedTargetName; - } - QString selectedTargetDetail() const - { - return m_selectedTargetDetail; - } - bool targetSelected() const - { - return !m_selectedSessionId.isEmpty(); - } - QVariantMap nowPlayingItem() const - { - return m_nowPlayingItem; - } - QVariantList queue() const - { - return m_queue; - } - QVariantList audioTracks() const - { - return m_audioTracks; - } - QVariantList subtitleTracks() const - { - return m_subtitleTracks; - } - QStringList supportedCommands() const - { - return m_supportedCommands; - } - qint64 positionTicks() const - { - return m_positionTicks; - } - qint64 runtimeTicks() const - { - return m_runtimeTicks; - } - bool paused() const - { - return m_paused; - } - bool muted() const - { - return m_muted; - } - int volume() const - { - return m_volume; - } - QString repeatMode() const - { - return m_repeatMode; - } - bool shuffled() const - { - return m_shuffled; - } - bool busy() const - { - return m_busy; - } - bool playbackPending() const - { - return m_playbackPending; - } - QString pendingTitle() const - { - return m_pendingTitle; - } - bool trickplayAvailable() const - { - return m_trickplayTimeline.trickplayAvailable(); - } - - void start(); - void stop(); - void applySessions(const QJsonArray& sessions); - bool playItems(const std::vector& items, int startIndex, const QString& command, bool fromStart); - - Q_INVOKABLE void refreshTargets(); - Q_INVOKABLE void selectTarget(const QString& sessionId); - Q_INVOKABLE void clearTarget(); - Q_INVOKABLE bool supports(const QString& command) const; - Q_INVOKABLE qint64 predictedPositionTicks() const; - Q_INVOKABLE void playItemIds(const QStringList& itemIds, const QString& command = QStringLiteral("PlayNow"), - int startIndex = 0, qint64 startPositionTicks = -1); - Q_INVOKABLE QVariantMap trickplayForTicks(qint64 positionTicks) const; - Q_INVOKABLE void previewSeek(qint64 positionTicks, bool active); - Q_INVOKABLE void cancelSeekPreview(); - // Asks the target to join a SyncPlay group itself. Membership is the - // client's own to hold, so the group sees the device that is playing - // rather than the one holding the remote. - Q_INVOKABLE void requestSyncPlayJoin(const QString& groupId); - SpoolLink *link() const - { - return m_link; - } - Q_INVOKABLE void sendPlaystate(const QString& command, qint64 seekPositionTicks = -1); - Q_INVOKABLE void sendGeneralCommand(const QString& command, const QVariantMap& arguments = {}); - Q_INVOKABLE void togglePause(); - Q_INVOKABLE void stopPlayback(); - Q_INVOKABLE void nextTrack(); - Q_INVOKABLE void previousTrack(); - Q_INVOKABLE void seek(qint64 positionTicks); - Q_INVOKABLE void seekRelative(qint64 deltaTicks); - Q_INVOKABLE void setVolume(int volume); - Q_INVOKABLE void adjustVolume(int delta); - Q_INVOKABLE void toggleMute(); - Q_INVOKABLE void selectAudioTrack(int streamIndex); - Q_INVOKABLE void selectSubtitleTrack(int streamIndex); - Q_INVOKABLE void setRepeatMode(const QString& mode); - Q_INVOKABLE void setShuffled(bool shuffled); - Q_INVOKABLE void playQueueItem(int index); - Q_INVOKABLE void moveQueueItem(int from, int to); - Q_INVOKABLE void removeQueueItem(int index); - -signals: - void targetsChanged(); - void targetChanged(); - void stateChanged(); - void busyChanged(); - void playbackPendingChanged(); - void trickplayChanged(); - void feedbackText(const QString& text); - void errorText(const QString& text); - -private: - void setBusy(bool busy); - void setPlaybackPending(bool pending); - void requestPaused(bool paused); - void stagePendingPlayback(const std::vector& items, qint64 startPositionTicks, const QString& command); - void beginPendingPlayback(const QString& target, const QString& itemId, const QString& title); - void applySelectedSession(bool selectionChanged = false); - void clearPendingCommands(); - void clearSelectedState(bool notify = true); - void refreshQueueDetails(const QJsonArray& rawQueue); - void loadTrickplay(const QString& itemId, const QString& mediaSourceId = {}); - void updateMediaSession(); - bool runPlay( - QStringList itemIds, QString command, int startIndex, qint64 startPositionTicks, QString pendingTitle = {}); - void reportCommandError(const QString& action, const std::exception_ptr& error); - - JellyfinApiFacade *m_api = nullptr; - std::unique_ptr m_mediaSession; - QTimer m_refreshTimer; - QTimer m_pendingRefreshTimer; - QTimer m_pendingTimeoutTimer; - QHash m_sessions; - QVariantList m_targets; - QString m_selectedSessionId; - QString m_selectedTargetName; - QString m_selectedTargetDetail; - QVariantMap m_nowPlayingItem; - QVariantList m_queue; - QVariantList m_audioTracks; - QVariantList m_subtitleTracks; - QStringList m_supportedCommands; - QJsonArray m_rawQueue; - QString m_pendingTargetSessionId; - QString m_pendingItemId; - // What the target was playing when the command went out. It bounds how - // long an unconfirmed request may hold the displayed item: as soon as the - // target reports something that is neither this nor what was asked for, - // somebody has driven it directly and this device should follow. - QString m_pendingPreviousItemId; - QString m_pendingTitle; - RequestGeneration m_sessionsGeneration; - RequestGeneration m_queueGeneration; - RequestGeneration m_playGeneration; - RequestGeneration m_pauseGeneration; - RequestGeneration m_trickplayGeneration; - PlaybackTimeline m_trickplayTimeline; - QString m_trickplayItemId; - QString m_trickplayMediaSourceId; - // Pacing and coalescing belong to the transport, which knows what the - // path can carry; this only has to say what is worth sending. - SpoolLink *m_link = nullptr; - QString m_localSessionId; - qint64 m_previewSentTicks = -1; - bool m_previewSentActive = false; - qint64 m_positionTicks = 0; - qint64 m_runtimeTicks = 0; - qint64 m_stateReceivedAtMs = 0; - qint64 m_pendingPauseDeadlineMs = 0; - // Where a seek this device asked for put the other end. The server keeps - // reporting the position from before the seek until the target reports its - // own, so the bar would snap back and only jump forward seconds later. - std::optional m_pendingSeekTicks; - qint64 m_pendingSeekAtMs = 0; - qint64 m_pendingSeekDeadlineMs = 0; - double m_playbackRate = 1.0; - int m_volume = 100; - bool m_paused = true; - std::optional m_pendingPaused; - bool m_muted = false; - bool m_shuffled = false; - bool m_busy = false; - bool m_playbackPending = false; - QString m_repeatMode = QStringLiteral("RepeatNone"); -}; - -} // namespace JellyfinNative diff --git a/src/app/RemoteTargetsController.cpp b/src/app/RemoteTargetsController.cpp new file mode 100644 index 00000000..33082f5e --- /dev/null +++ b/src/app/RemoteTargetsController.cpp @@ -0,0 +1,914 @@ +#include "RemoteTargetsController.h" + +#include "../common/AsyncTask.h" +#include "../platform/PlatformRemoteMediaSession.h" +#include "../provider/ProviderRegistry.h" +#include "../provider/ProviderUiContext.h" +#include "../provider/SourceHub.h" +#include "GroupPlaybackController.h" +#include "TrickplayService.h" + +#include +#include +#include +#include +#include +#include + +namespace Spool { +namespace { + const QString ListScope = QStringLiteral("remote-target-list"); + const QString ConnectScope = QStringLiteral("remote-target-connect"); + const QString ReadScope = QStringLiteral("remote-target-state"); + const QString CommandScope = QStringLiteral("remote-target-command"); + const QString QueueScope = QStringLiteral("remote-target-queue"); + QVariantMap action(const char *name) + { + return { { "action", QLatin1String(name) } }; + } + bool gone(const QString& code) + { + return code.contains("target_unavailable") || code.contains("target_not_found") + || code.contains("remote_target_gone") || code.contains("target_unauthorized") + || code.contains("source_unavailable") || code.contains("unsupported_extension"); + } +} + +RemoteTargetsController::RemoteTargetsController( + SourceHub *hub, ProviderRegistry *registry, GroupPlaybackController *group, QObject *parent) + : QObject(parent) + , m_hub(hub) + , m_registry(registry) + , m_group(group) + , m_mediaSession(createPlatformRemoteMediaSession()) +{ + m_clock.start(); + rebuildTargets(); + connect(&m_pollTimer, &QTimer::timeout, this, &RemoteTargetsController::requestState); + m_listTimer.setInterval(30000); + connect(&m_listTimer, &QTimer::timeout, this, &RemoteTargetsController::refreshTargets); + m_queueTimer.setInterval(5000); + // Coarse timers may fire early: the minimum-age guard would then skip an + // entire interval and turn a five-second refresh into a ten-second wait. + m_queueTimer.setTimerType(Qt::PreciseTimer); + connect(&m_queueTimer, &QTimer::timeout, this, [this] { + if (m_state.value("queueRevision").toString().isEmpty() && m_clock.elapsed() - m_lastQueueRead >= 5000) + requestQueuePage(true); + }); + if (auto *app = qobject_cast(QCoreApplication::instance())) { + m_foreground = app->applicationState() == Qt::ApplicationActive; + connect(app, &QGuiApplication::applicationStateChanged, this, + [this](Qt::ApplicationState state) { setForeground(state == Qt::ApplicationActive); }); + } + connect(hub, &SourceHub::extensionSupportChanged, this, [this](const QString& account) { + emit availableChanged(); + if (!m_hub->remoteAvailable(account)) { + m_accountTargets.remove(account); + rebuildTargets(); + if (m_selection.accountId == account) + failTarget( + m_selection, tr("The selected target is no longer available. Choose a target or This device.")); + } else if (m_selection.accountId == account) { + requestState(); + } + if (m_chooserVisible && m_foreground) + refreshTargets(); + }); + connect(registry, &ProviderRegistry::accountsChanged, this, [this] { + emit availableChanged(); + if (!m_selection.targetId.isEmpty() && !m_hub->remoteAvailable(m_selection.accountId)) + failTarget( + m_selection, tr("Remote control is unavailable for this account. Choose This device to play locally.")); + }); + connect(registry, &ProviderRegistry::sourceStarted, this, [this](Provider *) { + emit availableChanged(); + if (m_chooserVisible && m_foreground) + refreshTargets(); + }); + connect(registry, &ProviderRegistry::sourceStopped, this, [this](const QString& account) { + if (m_trickplay && m_selection.accountId == account) { + m_trickplay->clear(); + m_state.remove("preview"); + emit stateChanged(); + } + }); + connect(hub, &SourceHub::accountEvent, this, + [this](const QString& account, const QString& type, const QVariantMap& payload) { + if (type != "remoteChanged" || account != m_selection.accountId || !m_foreground + || payload.value("targetId").toString() != m_selection.targetId) + return; + // An event invalidates only this account/target. Never translate it into + // an inbound command and never rediscover all accounts in response. + requestState(); + }); + if (group) { + connect(group, &GroupPlaybackController::groupChanged, this, [this] { + if (m_group->enabled()) { + if (!m_selection.targetId.isEmpty()) + disconnectTarget(); // detach only: the peer keeps playing + } else if (m_leavingGroup && !m_pendingTarget.isEmpty()) { + const QString target = m_pendingTarget; + m_pendingTarget.clear(); + m_leavingGroup = false; + emit groupLeaveConfirmationChanged(); + selectTarget(target); + } + }); + } + connect( + m_mediaSession.get(), &PlatformRemoteMediaSession::playRequested, this, [this] { send(action("unpause")); }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::pauseRequested, this, [this] { send(action("pause")); }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::playPauseRequested, this, + [this] { send(action(m_state.value("state") == "playing" ? "pause" : "unpause")); }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::stopRequested, this, [this] { send(action("stop")); }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::nextRequested, this, [this] { send(action("next")); }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::previousRequested, this, + [this] { send(action("previous")); }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::seekRequested, this, [this](qint64 ms) { + if (ms >= 0 && ms <= std::numeric_limits::max() / 10000) + send({ { "action", "seek" }, { "positionTicks", QString::number(ms * 10000) } }); + }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::seekRelativeRequested, this, [this](qint64 delta) { + if (m_position.isEmpty()) + return; + const qint64 maximum = m_runtime.isEmpty() ? std::numeric_limits::max() : m_runtime.toLongLong(); + qint64 ticks = std::clamp(m_position.toLongLong(), qint64(0), maximum); + if (delta > 0) + ticks = delta > (maximum - ticks) / 10000 ? maximum : ticks + delta * 10000; + else if (delta < 0) + ticks = delta < -(ticks / 10000) ? 0 : ticks + delta * 10000; + send({ { "action", "seek" }, { "positionTicks", QString::number(ticks) } }); + }); + connect(m_mediaSession.get(), &PlatformRemoteMediaSession::volumeRequested, this, + [this](int volume) { send({ { "action", "volume" }, { "value", volume } }); }); +} + +RemoteTargetsController::~RemoteTargetsController() +{ + cancelSelection(); + for (const auto& account : std::as_const(m_listPending)) + m_registry->cancelSourceScope(account, ListScope); + m_mediaSession->clear(); +} + +QVariantMap RemoteTargetsController::state() const +{ + auto result = m_state; + if (!m_position.isEmpty()) + result.insert("positionTicks", m_position); + if (!m_runtime.isEmpty()) + result.insert("runtimeTicks", m_runtime); + return result; +} + +bool RemoteTargetsController::isCurrent(const Selection& selection) const +{ + return selection.generation == m_selection.generation && selection.targetId == m_selection.targetId + && selection.accountId == m_selection.accountId; +} + +bool RemoteTargetsController::available() const +{ + return !m_selection.targetId.isEmpty() || !m_problem.isEmpty() + || std::any_of(m_registry->accountList().begin(), m_registry->accountList().end(), + [this](const ProviderAccount& account) { return m_hub->remoteAvailable(account.id); }); +} + +void RemoteTargetsController::setProblem(QString value) +{ + if (m_problem != value) { + m_problem = std::move(value); + emit problemChanged(); + emit availableChanged(); + } +} + +void RemoteTargetsController::updateTimers() +{ + const bool attached = m_foreground && !m_selection.targetId.isEmpty() && !m_connecting; + const int interval = m_controlsVisible ? 1000 : 3000; + if (attached) { + if (!m_pollTimer.isActive() || m_pollTimer.interval() != interval) + m_pollTimer.start(interval); + } else { + m_pollTimer.stop(); + } + if (m_foreground && m_chooserVisible) { + if (!m_listTimer.isActive()) + m_listTimer.start(); + } else { + m_listTimer.stop(); + } + if (attached && m_queueVisible) { + if (!m_queueTimer.isActive()) + m_queueTimer.start(); + } else { + m_queueTimer.stop(); + } +} + +void RemoteTargetsController::setControlsVisible(bool value) +{ + if (m_controlsVisible == value) + return; + m_controlsVisible = value; + updateTimers(); +} +void RemoteTargetsController::setChooserVisible(bool value) +{ + if (m_chooserVisible == value) + return; + m_chooserVisible = value; + if (value && m_foreground) + refreshTargets(); + else { + ++m_listGeneration; + for (const auto& account : std::as_const(m_listPending)) + m_registry->cancelSourceScope(account, ListScope); + m_listPending.clear(); + m_listActive = 0; + emit busyChanged(); + } + updateTimers(); + emit visibilityChanged(); +} +void RemoteTargetsController::setQueueVisible(bool value) +{ + if (m_queueVisible == value) + return; + m_queueVisible = value; + if (value) + requestQueuePage(true); + else { + ++m_queueGeneration; + m_registry->cancelSourceScope(m_selection.accountId, QueueScope); + m_queueBusy = false; + m_queueResetPending = false; + emit queueChanged(); + } + updateTimers(); + emit visibilityChanged(); +} +void RemoteTargetsController::setForeground(bool value) +{ + if (m_foreground == value) + return; + m_foreground = value; + if (value) { + requestState(); + if (m_chooserVisible) + refreshTargets(); + if (m_queueVisible) + requestQueuePage(true); + } else { + ++m_commandEpoch; + ++m_readSerial; + ++m_queueGeneration; + ++m_listGeneration; + m_registry->cancelSourceScope(m_selection.accountId, ReadScope); + m_registry->cancelSourceScope(m_selection.accountId, QueueScope); + for (const auto& account : std::as_const(m_listPending)) + m_registry->cancelSourceScope(account, ListScope); + m_listPending.clear(); + m_listActive = 0; + m_readBusy = m_readAgain = m_queueBusy = m_queueResetPending = false; + emit busyChanged(); + emit queueChanged(); + } + updateTimers(); +} + +void RemoteTargetsController::refreshTargets() +{ + if (!m_foreground || !m_chooserVisible || m_listActive > 0) + return; + ++m_listGeneration; + m_listNext = 0; + m_listAccounts.clear(); + for (const auto& account : m_registry->accountList()) + if (m_hub->remoteAvailable(account.id)) + m_listAccounts.append(account.id); + for (auto it = m_accountTargets.begin(); it != m_accountTargets.end();) { + if (!m_listAccounts.contains(it.key())) + it = m_accountTargets.erase(it); + else + ++it; + } + rebuildTargets(); + launchListRequests(); +} +void RemoteTargetsController::launchListRequests() +{ + while (m_listActive < 4 && m_listNext < m_listAccounts.size()) { + const auto account = m_listAccounts.at(m_listNext); + if (m_listPending.contains(account)) + break; + ++m_listNext; + const auto generation = m_listGeneration; + ++m_listActive; + m_listPending.insert(account); + Async::runScoped( + this, m_hub->remoteTargets(account, ListScope), + [this, account, generation]( + QVariantList targets) { finishList(account, generation, std::move(targets), false); }, + [this, account, generation](const std::exception_ptr&) { finishList(account, generation, {}, true); }, + "remote target discovery"); + } + emit busyChanged(); +} +void RemoteTargetsController::finishList(QString account, quint64 generation, QVariantList targets, bool failed) +{ + if (generation != m_listGeneration) + return; + --m_listActive; + m_listPending.remove(account); + if (!failed) { + m_accountTargets.insert(account, std::move(targets)); + if (m_selection.accountId == account) { + const auto& rows = m_accountTargets[account]; + const auto found = std::find_if(rows.begin(), rows.end(), + [this](const QVariant& row) { return row.toMap().value("id") == m_selection.targetId; }); + if (found == rows.end()) + failTarget(m_selection, tr("The selected target disappeared. Choose a target or This device.")); + else { + m_target = found->toMap(); + emit selectionChanged(); + } + } + } else { + m_accountTargets.remove(account); + setProblem(tr("Some remote targets could not be discovered. Refresh to try again.")); + } + rebuildTargets(); + launchListRequests(); +} +void RemoteTargetsController::rebuildTargets() +{ + QVariantList rows { QVariantMap { { "id", QString() }, { "name", tr("This device") }, { "isLocal", true }, + { "commands", QStringList() }, { "queueEditing", "none" } } }; + // Persistent connection order, with progressive results within that order. + for (const auto& account : m_registry->accountList()) { + for (const auto& value : m_accountTargets.value(account.id)) { + auto row = value.toMap(); + row.insert("accountLabel", account.label); + if (const auto *module = m_registry->module(account.module)) + row.insert("providerName", module->manifest.name); + QString detail = row.value("providerName").toString() + QStringLiteral(" · ") + account.label; + if (!row.value("detail").toString().isEmpty()) + detail += QStringLiteral(" — ") + row.value("detail").toString(); + row.insert("detail", detail); + rows.append(row); + } + } + if (rows != m_targets) { + m_targets = std::move(rows); + emit targetsChanged(); + } +} + +void RemoteTargetsController::selectTarget(QString targetId) +{ + if (targetId.isEmpty()) { + disconnectTarget(); + return; + } + if (targetId == m_selection.targetId) + return; + auto found = std::find_if( + m_targets.begin(), m_targets.end(), [&](const QVariant& row) { return row.toMap().value("id") == targetId; }); + if (found == m_targets.end()) { + setProblem(tr("That target is no longer available. Refresh the target list.")); + return; + } + if (m_group && m_group->enabled()) { + m_pendingTarget = targetId; + m_leavingGroup = false; + emit groupLeaveConfirmationChanged(); + return; + } + connectTarget(found->toMap()); +} +void RemoteTargetsController::confirmLeaveGroup(bool accepted) +{ + if (m_pendingTarget.isEmpty()) + return; + if (!accepted) { + m_pendingTarget.clear(); + m_leavingGroup = false; + emit groupLeaveConfirmationChanged(); + return; + } + if (m_group && m_group->enabled()) { + m_leavingGroup = true; + m_group->leaveGroup(); + } else { + const QString target = m_pendingTarget; + m_pendingTarget.clear(); + emit groupLeaveConfirmationChanged(); + selectTarget(target); + } +} +void RemoteTargetsController::cancelSelection() +{ + if (m_trickplay) + m_trickplay->clear(); + const auto account = m_selection.accountId; + ++m_selection.generation; + ++m_commandEpoch; + ++m_readSerial; + ++m_queueGeneration; + if (m_advancedControls) { + m_advancedControls->close(); + m_advancedControls.clear(); + } + for (const auto& scope : { ConnectScope, ReadScope, CommandScope, QueueScope }) + if (!account.isEmpty()) + m_registry->cancelSourceScope(account, scope); +} +void RemoteTargetsController::clearQueue() +{ + m_queue.clear(); + m_cursor.reset(); + m_seenCursors.clear(); + m_queuePages = 0; + m_queueExhausted = true; + m_queueBusy = m_queueResetPending = false; + m_loadedRevision.clear(); + emit queueChanged(); +} +void RemoteTargetsController::disconnectTarget() +{ + cancelSelection(); + m_selection.targetId.clear(); + m_selection.accountId.clear(); + m_target.clear(); + m_state.clear(); + m_position.clear(); + m_runtime.clear(); + m_optimistic.clear(); + m_ackSequence.reset(); + m_connecting = m_commandBusy = m_readBusy = m_readAgain = false; + m_pendingTarget.clear(); + m_leavingGroup = false; + clearQueue(); + m_mediaSession->clear(); + updateTimers(); + setProblem({}); + emit selectionChanged(); + emit stateChanged(); + emit positionChanged(); + emit busyChanged(); + emit groupLeaveConfirmationChanged(); +} +void RemoteTargetsController::failTarget(const Selection& selection, const QString& problem) +{ + if (!isCurrent(selection)) + return; + disconnectTarget(); + setProblem(problem); +} +void RemoteTargetsController::connectTarget(QVariantMap target) +{ + disconnectTarget(); + m_target = std::move(target); + m_selection.targetId = m_target.value("id").toString(); + m_selection.accountId = m_target.value("accountId").toString(); + m_connecting = true; + const auto selected = m_selection; + emit selectionChanged(); + emit busyChanged(); + Async::runScoped( + this, connectSelected(selected, m_target), [] {}, + [this, selected](const std::exception_ptr&) { + failTarget(selected, + tr("Could not connect to this target. No playback was transferred. Choose a target or This device.")); + }, + "remote target connection"); +} +QCoro::Task RemoteTargetsController::connectSelected(Selection selected, QVariantMap target) +{ + QPointer guard(this); + const auto origins = target.value("origins").toStringList(); + if (!origins.isEmpty()) { + // Only the preferred endpoint is granted, never all discovered peers. + // The provider chooses its endpoint consistently with this first entry. + co_await m_registry->requestAccountOrigin(selected.accountId, QUrl(origins.first()), ConnectScope); + if (!guard || !isCurrent(selected)) + co_return; + } + auto response = co_await m_hub->remoteState(selected.targetId, true, ConnectScope); + if (!guard || !isCurrent(selected)) + co_return; + m_connecting = false; + applyState(std::move(response)); + updateTimers(); + emit busyChanged(); + if (m_queueVisible) + requestQueuePage(true); + if (m_foreground && m_chooserVisible) { + // Selection can verify richer peer capabilities after origin consent. + // Refresh just this account through the same four-slot chooser scheduler. + if (m_listActive == 0) { + m_listAccounts.clear(); + m_listNext = 0; + } + m_listAccounts.append(selected.accountId); + launchListRequests(); + } +} + +void RemoteTargetsController::requestState() +{ + if (!m_foreground || m_selection.targetId.isEmpty() || m_connecting) + return; + if (m_readBusy || m_commandBusy) { + m_readAgain = true; + return; + } + m_readBusy = true; + m_readAgain = false; + const auto selected = m_selection; + const auto epoch = m_commandEpoch; + const auto serial = ++m_readSerial; + Async::runScoped( + this, m_hub->remoteState(selected.targetId, false, ReadScope), + [this, selected, epoch, serial](QVariantMap response) { + if (!isCurrent(selected) || serial != m_readSerial) + return; + m_readBusy = false; + if (epoch == m_commandEpoch && m_foreground) + applyState(std::move(response)); + if (m_readAgain) + requestState(); + }, + [this, selected, epoch, serial](const std::exception_ptr& error) { + if (!isCurrent(selected) || serial != m_readSerial) + return; + m_readBusy = false; + if (epoch == m_commandEpoch && m_foreground) + failTarget(selected, + gone(exceptionMessage(error)) + ? tr("The selected target is unavailable. Choose a target or This device.") + : tr("The remote target could not be reached. Playback was not moved to this device.")); + else if (m_readAgain) + requestState(); + }, + "remote target state"); +} +void RemoteTargetsController::applyState(QVariantMap response) +{ + const auto now = m_clock.elapsed(); + const bool acknowledged = m_ackSequence && response.contains("commandSequence") + && response.value("commandSequence").toULongLong() >= *m_ackSequence; + if (!m_optimistic.isEmpty() && now < m_optimisticUntil && !acknowledged) { + for (auto it = m_optimistic.begin(); it != m_optimistic.end();) { + bool confirmed = response.value(it.key()) == it.value(); + if (it.key() == "positionTicks" && response.contains(it.key())) { + const long double advance = m_state.value("state") == "playing" + ? static_cast(now - m_optimisticAt) * 10000 * m_state.value("rate", 1.0).toDouble() + : 0; + const long double expected = it.value().toString().toLongLong() + advance; + confirmed + = std::abs(static_cast(response.value(it.key()).toString().toLongLong()) - expected) + <= 25000000; + } + if (confirmed && !m_ackSequence) + it = m_optimistic.erase(it); + else { + response.insert(it.key(), it.value()); + ++it; + } + } + } else { + m_optimistic.clear(); + m_ackSequence.reset(); + } + const QString position = response.take("positionTicks").toString(); + const QString runtime = response.take("runtimeTicks").toString(); + const bool positionChangedValue = position != m_position || runtime != m_runtime; + m_position = position; + m_runtime = runtime; + // A genuine acknowledgement is transport state, not a media model change. + response.remove("commandSequence"); + const QString revision = response.value("queueRevision").toString(); + const bool revisionChanged = revision != m_state.value("queueRevision").toString(); + if (m_trickplay + && (m_state.value("preview") != response.value("preview") || m_state.value("item") != response.value("item"))) { + const auto descriptor = response.value("preview").toMap(); + TrickplayInfo info; + info.width = descriptor.value("width").toInt(); + info.height = descriptor.value("height").toInt(); + info.tileWidth = descriptor.value("columns").toInt(); + info.tileHeight = descriptor.value("rows").toInt(); + info.thumbnailCount = descriptor.value("count").toInt(); + info.intervalMs = descriptor.value("intervalMs").toInt(); + info.urlTemplate = descriptor.value("urlTemplate").toString(); + info.format = descriptor.value("format").toString(); + info.url = descriptor.value("url").toString(); + m_trickplay->setSession(info, position.toDouble() / 10000000.0); + } + if (m_state != response) { + m_state = std::move(response); + emit stateChanged(); + } + if (positionChangedValue) + emit positionChanged(); + updateMediaSession(); + if (m_queueVisible && !revision.isEmpty() && (revisionChanged || (!m_queueBusy && revision != m_loadedRevision))) + requestQueuePage(true); +} + +void RemoteTargetsController::beginOptimistic(const QVariantMap& command) +{ + const auto name = command.value("action").toString(); + m_ackSequence.reset(); + if (name == "pause" || name == "unpause" || name == "stop") + m_optimistic.insert("state", name == "pause" ? "paused" : name == "unpause" ? "playing" : "stopped"); + else if (name == "seek") + m_optimistic.insert("positionTicks", command.value("positionTicks")); + else if (name == "volume") + m_optimistic.insert("volume", command.value("value")); + else if (name == "mute") + m_optimistic.insert("muted", command.value("value")); + else if (name == "shuffle") + m_optimistic.insert("shuffled", command.value("value")); + else if (name == "repeat") + m_optimistic.insert("repeatMode", command.value("mode")); + if (name == "seek") + m_optimisticAt = m_clock.elapsed(); + m_optimisticUntil = m_clock.elapsed() + 8000; + auto snapshot = state(); + for (auto it = m_optimistic.cbegin(); it != m_optimistic.cend(); ++it) + snapshot.insert(it.key(), it.value()); + // Publish directly; applyState would treat our own optimistic snapshot as + // confirmation from the backend. + const auto position = snapshot.take("positionTicks").toString(); + snapshot.remove("runtimeTicks"); + if (snapshot != m_state) { + m_state = std::move(snapshot); + emit stateChanged(); + } + if (position != m_position) { + m_position = position; + emit positionChanged(); + } + updateMediaSession(); +} + +QCoro::Task RemoteTargetsController::command(Selection selected, QVariantMap value) +{ + if (!isCurrent(selected) || selected.targetId.isEmpty() || m_connecting || m_commandBusy) + co_return false; + const auto name = value.value("action").toString(); + if (!m_state.value("commands").toStringList().contains(name)) { + setProblem(tr("This target does not support that command.")); + co_return false; + } + if (name == "queuePlay" || name == "queueRemove" || name == "queueMove") { + const auto entry = value.value("entryId").toString(); + auto found = std::find_if( + m_queue.begin(), m_queue.end(), [&](const QVariant& row) { return row.toMap().value("entryId") == entry; }); + if (m_queueBusy || found == m_queue.end() + || ((name == "queueRemove" || name == "queueMove") && m_target.value("queueEditing") == "none")) { + setProblem(tr("Refresh the queue before changing that entry.")); + co_return false; + } + if (name == "queueMove") { + bool validIndex = false; + const int index = value.value("index").toInt(&validIndex); + const int from = int(std::distance(m_queue.begin(), found)); + if (!validIndex || index < 0 || index >= m_queue.size() || value.value("index").toDouble() != index) { + setProblem(tr("Load the adjacent queue page before moving across its boundary.")); + co_return false; + } + const int predecessor = index == 0 ? -1 : index > from ? index : index - 1; + const QVariant after + = predecessor < 0 ? QVariant::fromValue(nullptr) : m_queue.at(predecessor).toMap().value("entryId"); + if (!value.contains("afterEntryId") + || (after.isNull() ? !value.value("afterEntryId").isNull() : value.value("afterEntryId") != after)) { + setProblem(tr("The queue changed. Refresh it before moving this entry.")); + co_return false; + } + } + } + QPointer guard(this); + m_commandBusy = true; + ++m_commandEpoch; // even an already-running HTTP read is now pre-command + m_readAgain = false; + setProblem({}); + const auto authoritative = state(); + beginOptimistic(value); + emit busyChanged(); + try { + const auto result = co_await m_hub->remoteCommand(selected.targetId, value, authoritative, CommandScope); + if (!guard || !isCurrent(selected)) + co_return false; + if (result.contains("commandSequence")) + m_ackSequence = result.value("commandSequence").toULongLong(); + m_commandBusy = false; + emit busyChanged(); + requestState(); + if (m_queueVisible && (name.startsWith("queue") || name == "play")) + requestQueuePage(true); + co_return true; + } catch (const std::exception& error) { + if (!guard || !isCurrent(selected)) + co_return false; + m_commandBusy = false; + m_optimistic.clear(); + m_ackSequence.reset(); + emit busyChanged(); + if (gone(QString::fromUtf8(error.what()))) { + failTarget(selected, tr("The target is unavailable. Playback remains on its current device.")); + } else { + setProblem(QString::fromUtf8(error.what()).contains("mixed_source_queue") + ? tr("A remote queue can only contain media from the selected target's account.") + : tr("The remote command could not be confirmed. Refresh before trying again.")); + requestState(); + if (m_queueVisible && (name.startsWith("queue") || name == "play")) + requestQueuePage(true); // uncertain mutation: read, never retry + } + co_return false; + } +} +void RemoteTargetsController::send(QVariantMap value) +{ + Async::runScoped( + this, command(m_selection, std::move(value)), [](bool) {}, + [this](const std::exception_ptr&) { setProblem(tr("The remote command could not be sent.")); }, + "remote target command"); +} +QCoro::Task RemoteTargetsController::play( + Selection selected, QStringList ids, int index, QString position, QString mode, QString variant) +{ + if (!isCurrent(selected) || selected.targetId.isEmpty()) + co_return false; + // Validate ownership before any provider request, including metadata reads + // a caller may otherwise perform after dispatch. + for (const auto& id : ids) + if (m_hub->accountOf(id) != selected.accountId) { + setProblem(tr("A remote queue can only contain media from the selected target's account.")); + co_return false; + } + QVariantMap value { { "action", "play" }, { "itemIds", ids }, { "index", index }, { "positionTicks", position }, + { "mode", mode } }; + if (!variant.isEmpty()) + value.insert("variantId", variant); + co_return co_await command(std::move(selected), std::move(value)); +} + +void RemoteTargetsController::requestQueuePage(bool reset) +{ + if (!m_foreground || !m_queueVisible || m_selection.targetId.isEmpty() || m_connecting) + return; + if (m_queueBusy) { + m_queueResetPending = m_queueResetPending || reset; + return; + } + if (!reset && m_queueExhausted) + return; + m_queueBusy = true; + m_queueResetPending = false; + m_lastQueueRead = m_clock.elapsed(); + const auto selected = m_selection; + const auto generation = ++m_queueGeneration; + emit queueChanged(); + Async::runScoped( + this, loadQueue(selected, reset, generation), + [this, selected, generation] { + if (!isCurrent(selected) || generation != m_queueGeneration) + return; + m_queueBusy = false; + emit queueChanged(); + if (m_queueResetPending) + requestQueuePage(true); + }, + [this, selected, generation](const std::exception_ptr&) { + if (!isCurrent(selected) || generation != m_queueGeneration) + return; + clearQueue(); + setProblem(tr("The remote queue could not be refreshed. Retry before changing an entry.")); + }, + "remote target queue"); +} +QCoro::Task RemoteTargetsController::loadQueue(Selection selected, bool reset, quint64 generation) +{ + QPointer guard(this); + const auto revision = m_state.value("queueRevision").toString(); + auto cursor = reset ? std::optional() : m_cursor; + auto seen = reset ? QSet() : m_seenCursors; + int pages = reset ? 0 : m_queuePages; + QVariantList rows = reset ? QVariantList() : m_queue; + QSet entries; + for (const auto& row : rows) + entries.insert(row.toMap().value("entryId").toString()); + const qsizetype desired = reset ? std::max(1, m_queue.size()) : m_queue.size() + 1; + bool exhausted = false; + do { + if (pages >= 256 || rows.size() >= 10000) + throw std::runtime_error("response_limit"); + auto page = co_await m_hub->remoteQueue(selected.targetId, cursor, QueueScope); + if (!guard || !isCurrent(selected) || generation != m_queueGeneration) + co_return; + if (revision != m_state.value("queueRevision").toString()) { + m_queueResetPending = true; + co_return; + } + ++pages; + if (!page.exhausted && (!page.nextCursor || page.nextCursor->isEmpty() || seen.contains(*page.nextCursor))) + throw std::runtime_error("invalid_pagination"); + if (!page.exhausted) + seen.insert(*page.nextCursor); + for (const auto& item : page.items) { + if (item.playlistItemId.isEmpty() || entries.contains(item.playlistItemId)) + throw std::runtime_error("invalid_remote_queue"); + entries.insert(item.playlistItemId); + rows.append(m_hub->remoteQueueRow(item)); + } + if (rows.size() > 10000) + throw std::runtime_error("response_limit"); + cursor = page.exhausted ? std::nullopt : page.nextCursor; + exhausted = page.exhausted; + } while (!exhausted && rows.size() < desired); + m_queue = std::move(rows); + m_cursor = std::move(cursor); + m_seenCursors = std::move(seen); + m_queuePages = pages; + m_queueExhausted = exhausted; + m_loadedRevision = revision; +} + +QObject *RemoteTargetsController::createAdvancedControls() +{ + if (m_selection.targetId.isEmpty() || m_connecting || !m_target.value("customControls").toBool()) + return nullptr; + if (m_advancedControls && !m_advancedControls->closed()) + return m_advancedControls; + auto *context = qobject_cast(m_registry->openPicker(m_selection.accountId, + { { "kind", "remoteControls" }, { "targetId", SourceHub::rawId(m_selection.targetId) } })); + if (!context) { + setProblem(tr("Advanced controls are unavailable for this target.")); + return nullptr; + } + m_advancedControls = context; + return context; +} + +void RemoteTargetsController::openAdvancedControls() +{ + auto *context = qobject_cast(createAdvancedControls()); + if (!context) + return; + const auto selected = m_selection; + QPointer picker(context); + QTimer::singleShot(0, this, [this, selected, picker] { + if (!picker) + return; + if (!isCurrent(selected) || picker->closed()) { + picker->close(); + return; + } + emit m_registry->componentRequested(picker); + }); +} +void RemoteTargetsController::updateMediaSession() +{ + if (m_selection.targetId.isEmpty() || m_state.isEmpty()) { + m_mediaSession->clear(); + return; + } + RemoteMediaSessionState session; + session.title = m_state.value("title").toString(); + const auto item = m_state.value("item").toMap(); + session.artist = item.value("albumArtist").toString(); + session.album = item.value("album").toString(); + session.targetName = m_target.value("name").toString(); + session.positionMs = m_position.toLongLong() / 10000; + session.durationMs = m_runtime.toLongLong() / 10000; + session.playing = m_state.value("state") == "playing"; + session.playbackRate = m_state.value("rate", 1.0).toDouble(); + session.canSeek = m_state.value("commands").toStringList().contains("seek"); + if (m_state.contains("volume")) + session.volume = m_state.value("volume").toInt(); + m_mediaSession->update(session); +} +} // namespace Spool + +namespace Spool { +void RemoteTargetsController::setTrickplayService(TrickplayService *service) +{ + m_trickplay = service; + if (service) { + service->setImageProviderName(QStringLiteral("remote-trickplay")); + connect(service, &TrickplayService::changed, this, &RemoteTargetsController::trickplayChanged); + } +} +QVariantMap RemoteTargetsController::trickplayForSeconds(double seconds) const +{ + return m_trickplay ? m_trickplay->frame(seconds) : QVariantMap { { "available", false } }; +} +bool RemoteTargetsController::trickplayAvailable() const +{ + return m_trickplay && m_trickplay->available(); +} +} // namespace Spool diff --git a/src/app/RemoteTargetsController.h b/src/app/RemoteTargetsController.h new file mode 100644 index 00000000..e7845222 --- /dev/null +++ b/src/app/RemoteTargetsController.h @@ -0,0 +1,211 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Spool { +class SourceHub; +class ProviderRegistry; +class GroupPlaybackController; +class ProviderUiContext; +class PlatformRemoteMediaSession; +class TrickplayService; + +// Outbound control only. Neither selection nor a remote snapshot is an inbound +// playback command; every asynchronous result belongs to one selection epoch. +class RemoteTargetsController final : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantList targets READ targets NOTIFY targetsChanged) + Q_PROPERTY(bool available READ available NOTIFY availableChanged) + Q_PROPERTY(QString selectedTargetId READ selectedTargetId NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedTarget READ selectedTarget NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap state READ state NOTIFY stateChanged) + Q_PROPERTY(QString positionTicks READ positionTicks NOTIFY positionChanged) + Q_PROPERTY(QString runtimeTicks READ runtimeTicks NOTIFY positionChanged) + Q_PROPERTY(QVariantList queue READ queue NOTIFY queueChanged) + Q_PROPERTY(bool queueHasMore READ queueHasMore NOTIFY queueChanged) + Q_PROPERTY(bool queueBusy READ queueBusy NOTIFY queueChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString problem READ problem NOTIFY problemChanged) + Q_PROPERTY(bool trickplayAvailable READ trickplayAvailable NOTIFY trickplayChanged) + Q_PROPERTY(bool chooserVisible READ chooserVisible NOTIFY visibilityChanged) + Q_PROPERTY(bool queueVisible READ queueVisible NOTIFY visibilityChanged) + Q_PROPERTY( + bool groupLeaveConfirmationPending READ groupLeaveConfirmationPending NOTIFY groupLeaveConfirmationChanged) +public: + struct Selection { + QString targetId; + QString accountId; + quint64 generation; + }; + RemoteTargetsController(SourceHub *, ProviderRegistry *, GroupPlaybackController *, QObject *parent = nullptr); + ~RemoteTargetsController() override; + QVariantList targets() const + { + return m_targets; + } + bool available() const; + QString selectedTargetId() const + { + return m_selection.targetId; + } + QVariantMap selectedTarget() const + { + return m_target; + } + QVariantMap state() const; + QString positionTicks() const + { + return m_position; + } + QString runtimeTicks() const + { + return m_runtime; + } + QVariantList queue() const + { + return m_queue; + } + bool queueHasMore() const + { + return !m_queueExhausted; + } + bool queueBusy() const + { + return m_queueBusy; + } + bool busy() const + { + return m_connecting || m_commandBusy || m_listActive > 0; + } + QString problem() const + { + return m_problem; + } + bool chooserVisible() const + { + return m_chooserVisible; + } + bool queueVisible() const + { + return m_queueVisible; + } + bool groupLeaveConfirmationPending() const + { + return !m_pendingTarget.isEmpty(); + } + Selection selection() const + { + return m_selection; + } + bool isCurrent(const Selection&) const; + QCoro::Task play( + Selection, QStringList scopedItemIds, int index, QString positionTicks, QString mode, QString variantId = {}); + Q_INVOKABLE void refreshTargets(); + Q_INVOKABLE void selectTarget(QString targetId); + Q_INVOKABLE void disconnectTarget(); + Q_INVOKABLE void send(QVariantMap command); + Q_INVOKABLE void requestQueuePage(bool reset); + Q_INVOKABLE void setControlsVisible(bool visible); + Q_INVOKABLE void setChooserVisible(bool visible); + Q_INVOKABLE void setQueueVisible(bool visible); + Q_INVOKABLE void confirmLeaveGroup(bool accepted); + Q_INVOKABLE void openAdvancedControls(); + Q_INVOKABLE QObject *createAdvancedControls(); + // Also used by lifecycle adapters on platforms without QGuiApplication. + void setForeground(bool foreground); + void setTrickplayService(TrickplayService *service); + Q_INVOKABLE QVariantMap trickplayForSeconds(double seconds) const; + bool trickplayAvailable() const; +signals: + void targetsChanged(); + void availableChanged(); + void selectionChanged(); + void stateChanged(); + void positionChanged(); + void queueChanged(); + void busyChanged(); + void problemChanged(); + void visibilityChanged(); + void groupLeaveConfirmationChanged(); + void trickplayChanged(); + +private: + void updateTimers(); + void launchListRequests(); + void finishList(QString account, quint64 generation, QVariantList targets, bool failed); + void rebuildTargets(); + void connectTarget(QVariantMap target); + QCoro::Task connectSelected(Selection selection, QVariantMap target); + void requestState(); + void applyState(QVariantMap state); + void failTarget(const Selection&, const QString& problem); + void setProblem(QString problem); + void clearQueue(); + void updateMediaSession(); + void beginOptimistic(const QVariantMap& command); + QCoro::Task command(Selection selection, QVariantMap command); + QCoro::Task loadQueue(Selection selection, bool reset, quint64 queueGeneration); + void cancelSelection(); + + SourceHub *m_hub; + ProviderRegistry *m_registry; + GroupPlaybackController *m_group; + QPointer m_advancedControls; + std::unique_ptr m_mediaSession; + Selection m_selection { {}, {}, 0 }; + QVariantMap m_target; + QVariantMap m_state; + QString m_position; + QString m_runtime; + QVariantList m_targets; + TrickplayService *m_trickplay = nullptr; + QHash m_accountTargets; + QStringList m_listAccounts; + QSet m_listPending; + int m_listNext = 0; + int m_listActive = 0; + quint64 m_listGeneration = 0; + quint64 m_commandEpoch = 0; + quint64 m_queueGeneration = 0; + quint64 m_readSerial = 0; + bool m_connecting = false; + bool m_commandBusy = false; + bool m_readBusy = false; + bool m_readAgain = false; + bool m_controlsVisible = false; + bool m_chooserVisible = false; + bool m_queueVisible = false; + bool m_foreground = true; + QString m_problem; + QString m_pendingTarget; + bool m_leavingGroup = false; + QVariantList m_queue; + bool m_queueBusy = false; + bool m_queueResetPending = false; + bool m_queueExhausted = true; + int m_queuePages = 0; + std::optional m_cursor; + QSet m_seenCursors; + QString m_loadedRevision; + QElapsedTimer m_clock; + qint64 m_lastQueueRead = -5000; + QVariantMap m_optimistic; + qint64 m_optimisticUntil = 0; + qint64 m_optimisticAt = 0; + std::optional m_ackSequence; + QTimer m_pollTimer; + QTimer m_listTimer; + QTimer m_queueTimer; +}; +} // namespace Spool diff --git a/src/app/RouterController.cpp b/src/app/RouterController.cpp index 453c7b68..5a5c50dd 100644 --- a/src/app/RouterController.cpp +++ b/src/app/RouterController.cpp @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -19,7 +19,8 @@ namespace { { static const QSet routes = { QStringLiteral("home"), QStringLiteral("libraryGrid"), QStringLiteral("itemDetails"), QStringLiteral("personDetails"), QStringLiteral("search"), - QStringLiteral("settings"), QStringLiteral("subtitleSettings"), QStringLiteral("openSourceNotices") }; + QStringLiteral("settings"), QStringLiteral("settingsSync"), QStringLiteral("subtitleSettings"), + QStringLiteral("openSourceNotices") }; return routes.contains(route); } @@ -59,6 +60,12 @@ RouterController::RouterController(QObject *parent) { } +RouterController::RouterController(const QString& startRoute, QObject *parent) + : QObject(parent) + , m_route(startRoute) +{ +} + QString RouterController::route() const { return m_route; @@ -279,4 +286,4 @@ bool RouterController::restoreSnapshot() return true; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/RouterController.h b/src/app/RouterController.h index c5ac9b19..abdd2f8d 100644 --- a/src/app/RouterController.h +++ b/src/app/RouterController.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class RouterController final : public QObject { Q_OBJECT @@ -18,6 +18,9 @@ class RouterController final : public QObject { public: explicit RouterController(QObject *parent = nullptr); + // `startRoute` is what the shell shows before the app has initialised: + // the provider's login page when it has one, otherwise home. + explicit RouterController(const QString& startRoute, QObject *parent = nullptr); QString route() const; QString previousRoute() const; @@ -60,4 +63,4 @@ class RouterController final : public QObject { bool m_recoveryRequested = false; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SearchController.cpp b/src/app/SearchController.cpp index 495d7df6..4800a263 100644 --- a/src/app/SearchController.cpp +++ b/src/app/SearchController.cpp @@ -1,22 +1,23 @@ #include "SearchController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "LibraryPrefetchController.h" #include +#include +#include #include -namespace JellyfinNative { +namespace Spool { namespace { constexpr int kSearchDebounceMs = 260; } -SearchController::SearchController(JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent) +SearchController::SearchController(SearchSource *source, LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(source) , m_prefetch(prefetch) { m_debounceTimer.setSingleShot(true); @@ -28,6 +29,9 @@ void SearchController::setQuery(const QString& query) { const QString trimmed = query.trimmed(); if (m_query != trimmed) { + // A response arriving during debounce belongs to the old text, even + // though the replacement request has not been submitted yet. + m_searchGeneration.invalidate(); m_query = trimmed; emit queryChanged(); } @@ -56,14 +60,26 @@ void SearchController::submit() const RequestGeneration::Token generation = m_searchGeneration.next(); setBusy(true); + // Results show as each source answers; busy lasts until the last one. + auto answered = std::make_shared(false); + QPointer guard(this); + auto update = [this, guard, generation, answered](std::vector items) { + if (!guard || !m_searchGeneration.isCurrent(generation)) + return; + *answered = true; + if (m_prefetch) + m_prefetch->prefetchPosters(items); + setResults(std::move(items)); + emit resultsChanged(); + }; Async::runLatest( - this, m_api->searchItems(m_query), m_searchGeneration, generation, - [this](std::vector items) { - if (m_prefetch) - m_prefetch->prefetchPosters(items); - setResults(std::move(items)); + this, m_api->searchProgressively(m_query, 80, std::move(update)), m_searchGeneration, generation, + [this, answered] { + if (!*answered) { + clearResults(); + emit resultsChanged(); + } setBusy(false); - emit resultsChanged(); }, [this](const std::exception_ptr& error) { clearResults(); @@ -77,6 +93,7 @@ void SearchController::search(const QString& query) { const QString trimmed = query.trimmed(); if (m_query != trimmed) { + m_searchGeneration.invalidate(); m_query = trimmed; emit queryChanged(); } @@ -98,7 +115,9 @@ void SearchController::clear() void SearchController::loadSuggestions() { - if (!authenticated() || m_suggestionsLoaded || m_suggestionsBusy) + if (!authenticated()) + return; + if (m_suggestionsLoaded || m_suggestionsBusy) return; const RequestGeneration::Token generation = m_suggestionsGeneration.next(); @@ -199,7 +218,7 @@ void SearchController::setResults(std::vector items) bool SearchController::authenticated() const { - return m_api && !m_api->session().accessToken.isEmpty(); + return m_api && m_api->signedIn(); } void SearchController::setBusy(bool busy) @@ -218,4 +237,4 @@ void SearchController::setSuggestionsBusy(bool busy) emit suggestionsChanged(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SearchController.h b/src/app/SearchController.h index 2bd353de..97f980ff 100644 --- a/src/app/SearchController.h +++ b/src/app/SearchController.h @@ -2,31 +2,31 @@ #include "../common/RequestGeneration.h" #include "../models/MovieGridModel.h" +#include "../provider/SearchSource.h" #include #include #include #include -namespace JellyfinNative { +namespace Spool { -class JellyfinApiFacade; class LibraryPrefetchController; class SearchController final : public QObject { Q_OBJECT Q_PROPERTY(QString query READ query NOTIFY queryChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) - Q_PROPERTY(JellyfinNative::MovieGridModel *movieResults READ movieResults CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *seriesResults READ seriesResults CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *episodeResults READ episodeResults CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *otherResults READ otherResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *movieResults READ movieResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *seriesResults READ seriesResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *episodeResults READ episodeResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *otherResults READ otherResults CONSTANT) Q_PROPERTY(int resultCount READ resultCount NOTIFY resultsChanged) - Q_PROPERTY(JellyfinNative::MovieGridModel *suggestions READ suggestions CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *suggestions READ suggestions CONSTANT) Q_PROPERTY(bool suggestionsBusy READ suggestionsBusy NOTIFY suggestionsChanged) public: - SearchController(JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent = nullptr); + SearchController(SearchSource *source, LibraryPrefetchController *prefetch, QObject *parent = nullptr); QString query() const { @@ -90,7 +90,7 @@ class SearchController final : public QObject { void clearResults(); void setResults(std::vector items); - JellyfinApiFacade *m_api = nullptr; + SearchSource *m_api = nullptr; LibraryPrefetchController *m_prefetch = nullptr; MovieGridModel m_movieResults; MovieGridModel m_seriesResults; @@ -106,4 +106,4 @@ class SearchController final : public QObject { QTimer m_debounceTimer; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SessionController.cpp b/src/app/SessionController.cpp deleted file mode 100644 index 0c3760bd..00000000 --- a/src/app/SessionController.cpp +++ /dev/null @@ -1,478 +0,0 @@ -#include "SessionController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../cache/DatabaseManager.h" -#include "../common/AsyncTask.h" - -#include -#include -#include - -#include - -namespace JellyfinNative { - -SessionController::SessionController(DatabaseManager *database, JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_database(database) - , m_api(api) -{ - connect(m_api, &JellyfinApiFacade::deviceProfileChanged, this, [this]() { - if (!authenticated()) - return; - Async::runScoped( - this, m_api->postCapabilities(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "session: updated capability report failed" << exceptionMessage(error); - }); - }); -} - -QString SessionController::serverUrl() const -{ - return m_serverUrl; -} - -QString SessionController::serverName() const -{ - return m_serverName; -} - -QString SessionController::username() const -{ - return m_username; -} - -QString SessionController::password() const -{ - return m_password; -} - -bool SessionController::authenticated() const -{ - return !m_api->session().accessToken.isEmpty(); -} - -QString SessionController::activeProfileId() const -{ - return m_activeProfileId; -} - -QString SessionController::activeProfileLabel() const -{ - const auto it = std::find_if(m_profiles.cbegin(), m_profiles.cend(), - [this](const AccountProfile& profile) { return profile.profileId == m_activeProfileId; }); - return it == m_profiles.cend() ? QString() : it->displayLabel(); -} - -bool SessionController::profileSignInRequired() const -{ - return m_profileSignInRequired; -} - -QVariantList SessionController::accountProfiles() const -{ - QVariantList result; - result.reserve(static_cast(m_profiles.size())); - for (const AccountProfile& profile : m_profiles) { - const QUrl url(profile.serverUrl); - const QString host = url.host().isEmpty() ? profile.serverUrl : url.host(); - result.push_back(QVariantMap { { QStringLiteral("profileId"), profile.profileId }, - { QStringLiteral("serverName"), profile.serverName }, { QStringLiteral("serverUrl"), profile.serverUrl }, - { QStringLiteral("serverHost"), host }, { QStringLiteral("userName"), profile.userName }, - { QStringLiteral("avatarTag"), profile.avatarTag }, - { QStringLiteral("needsAuthentication"), profile.needsAuthentication || profile.accessToken.isEmpty() } }); - } - return result; -} - -QStringList SessionController::localStorageKeys() -{ - return { QStringLiteral("login/serverUrl"), QStringLiteral("login/username") }; -} - -QCoro::Task SessionController::initializeAsync() -{ - StartupState state = co_await m_database->loadStartupStateAsync(localStorageKeys()); - co_return initializeFromStorage(std::move(state.values), std::move(state.profiles)); -} - -bool SessionController::initializeFromStorage(QVariantMap values, std::vector profiles) -{ - m_serverUrl = canonicalServerUrl(values.value(QStringLiteral("login/serverUrl")).toString()); - m_username = values.value(QStringLiteral("login/username")).toString(); - m_profiles = std::move(profiles); - sortProfiles(); - - m_api->setSession({}); - emit serverUrlChanged(); - emit usernameChanged(); - emit accountProfilesChanged(); - if (m_profiles.size() == 1) - applyStoredProfile(m_profiles.front(), true); - return !m_profiles.empty(); -} - -void SessionController::setServerUrl(const QString& serverUrl) -{ - QString normalized = canonicalServerUrl(serverUrl); - if (normalized.isEmpty()) { - normalized = serverUrl.trimmed(); - while (normalized.endsWith(QLatin1Char('/'))) - normalized.chop(1); - } - if (m_serverUrl == normalized) - return; - m_serverUrl = normalized; - emit serverUrlChanged(); -} - -void SessionController::setServerName(const QString& serverName) -{ - const QString normalized = serverName.trimmed(); - if (m_serverName == normalized) - return; - m_serverName = normalized; - emit serverNameChanged(); -} - -void SessionController::setUsername(const QString& username) -{ - if (m_username == username) - return; - m_username = username; - emit usernameChanged(); -} - -void SessionController::setPassword(const QString& password) -{ - if (m_password == password) - return; - m_password = password; - emit passwordChanged(); -} - -void SessionController::login() -{ - if (m_serverUrl.isEmpty() || m_username.isEmpty()) { - emit errorOccurred(QStringLiteral("Server and username are required.")); - return; - } - - emit busyChanged(true, QStringLiteral("Signing in…")); - m_api->setServerUrl(m_serverUrl); - auto authentication = m_api->authenticateByName(m_username, m_password); - clearPassword(); - Async::runScoped( - this, std::move(authentication), - [this](const AuthSession& session) { - emit busyChanged(false, {}); - activateSession(session, true); - }, - [this](const std::exception_ptr& error) { - emit busyChanged(false, {}); - emit errorOccurred(exceptionMessage(error)); - }); -} - -void SessionController::activateProfile(const QString& profileId) -{ - emit busyChanged(true, QStringLiteral("Opening profile…")); - Async::runScoped( - this, activateProfileAsync(profileId), []() {}, - [this](const std::exception_ptr& error) { - emit busyChanged(false, {}); - emit errorOccurred(exceptionMessage(error)); - }, - "profile activation"); -} - -QCoro::Task SessionController::activateProfileAsync(const QString& profileId) -{ - const std::optional stored = co_await m_database->activateAccountProfileAsync(profileId); - emit busyChanged(false, {}); - if (!stored) { - emit errorOccurred(QStringLiteral("That saved account is no longer available.")); - co_return; - } - if (!applyStoredProfile(*stored, false)) - emit errorOccurred(QStringLiteral("Authentication required for this account.")); -} - -bool SessionController::applyStoredProfile(AccountProfile profile, bool persistUsage) -{ - const bool authenticationRequired = profile.needsAuthentication || profile.accessToken.isEmpty(); - if (persistUsage && !authenticationRequired) { - profile.lastUsedAt = QDateTime::currentMSecsSinceEpoch(); - m_database->upsertAccountProfile(profile); - } - - const auto local = std::find_if(m_profiles.begin(), m_profiles.end(), - [&profile](const AccountProfile& candidate) { return candidate.profileId == profile.profileId; }); - if (local != m_profiles.end()) - *local = profile; - else - m_profiles.push_back(profile); - sortProfiles(); - emit accountProfilesChanged(); - - setServerName(profile.serverName); - setServerUrl(profile.serverUrl); - setUsername(profile.userName); - clearPassword(); - if (authenticationRequired) { - if (!m_profileSignInRequired) - m_profileSignInRequired = true; - // This is also the navigation event. Re-emit when an already-expired - // profile is chosen again after returning to the profile grid. - emit profileSignInRequiredChanged(); - return false; - } - - if (m_profileSignInRequired) { - m_profileSignInRequired = false; - emit profileSignInRequiredChanged(); - } - m_activeProfileId = profile.profileId; - emit activeProfileChanged(); - activateSession({ profile.userId, profile.userName, profile.accessToken, profile.serverId }, false); - return true; -} - -void SessionController::prepareProfileSignIn(const QString& profileId) -{ - const auto it = std::find_if(m_profiles.cbegin(), m_profiles.cend(), - [&profileId](const AccountProfile& profile) { return profile.profileId == profileId; }); - if (it == m_profiles.cend()) { - emit errorOccurred(QStringLiteral("That saved account is no longer available.")); - return; - } - setProfileSignInFields(*it); -} - -void SessionController::setProfileSignInFields(const AccountProfile& profile) -{ - setServerUrl(profile.serverUrl); - setServerName(profile.serverName); - setUsername(profile.userName); - clearPassword(); - if (!m_profileSignInRequired) { - m_profileSignInRequired = true; - emit profileSignInRequiredChanged(); - } -} - -void SessionController::updateProfileServer(const QString& profileId, const QString& name, const QString& url) -{ - const QString canonicalUrl = canonicalServerUrl(url); - const auto it = std::find_if(m_profiles.begin(), m_profiles.end(), - [&profileId](const AccountProfile& profile) { return profile.profileId == profileId; }); - if (it == m_profiles.end() || canonicalUrl.isEmpty()) { - emit errorOccurred(QStringLiteral("Enter a valid server address.")); - return; - } - it->serverName = name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed(); - it->serverUrl = canonicalUrl; - m_database->upsertAccountProfile(*it); - if (m_activeProfileId == profileId) { - setServerName(it->serverName); - setServerUrl(it->serverUrl); - m_api->setServerUrl(it->serverUrl); - emit activeProfileChanged(); - } - emit accountProfilesChanged(); -} - -void SessionController::removeProfile(const QString& profileId) -{ - const bool removingActive = profileId == m_activeProfileId; - const auto oldSize = m_profiles.size(); - std::erase_if(m_profiles, [&profileId](const AccountProfile& profile) { return profile.profileId == profileId; }); - if (m_profiles.size() == oldSize) - return; - m_database->removeAccountProfile(profileId); - emit accountProfilesChanged(); - if (removingActive) { - clearActiveSession(); - emit loggedOut(); - } -} - -void SessionController::clearProfiles() -{ - if (m_profiles.empty()) - return; - m_profiles.clear(); - m_database->clearAccountProfiles(); - emit accountProfilesChanged(); - clearActiveSession(); - emit loggedOut(); -} - -void SessionController::deactivate() -{ - clearPassword(); - m_profileSignInRequired = false; - emit profileSignInRequiredChanged(); - clearActiveSession(); - emit loggedOut(); -} - -void SessionController::acceptSession(const AuthSession& session) -{ - activateSession(session, true); -} - -void SessionController::logout() -{ - clearPassword(); - if (!m_activeProfileId.isEmpty()) { - const auto it = std::find_if(m_profiles.begin(), m_profiles.end(), - [this](const AccountProfile& profile) { return profile.profileId == m_activeProfileId; }); - if (it != m_profiles.end()) { - it->accessToken.clear(); - it->needsAuthentication = true; - m_database->expireAccountProfile(it->profileId); - emit accountProfilesChanged(); - } - } - clearActiveSession(); - emit loggedOut(); -} - -void SessionController::expireSession(const QString& message) -{ - if (m_api->session().accessToken.isEmpty()) - return; - qWarning() << "session: authentication expired"; - const QString expiredProfileId = m_activeProfileId; - logout(); - if (!expiredProfileId.isEmpty()) - prepareProfileSignIn(expiredProfileId); - emit errorOccurred(message); -} - -bool SessionController::handleUnauthorized(const std::exception_ptr& error) -{ - const QString message = exceptionMessage(error); - if (!message.contains(QStringLiteral("(401)")) - && !message.contains(QStringLiteral("Unauthorized"), Qt::CaseInsensitive)) { - return false; - } - - if (!m_api->session().accessToken.isEmpty()) - expireSession(QStringLiteral("Your Jellyfin session has expired. Sign in again.")); - return true; -} - -void SessionController::activateSession(const AuthSession& session, bool persist) -{ - clearPassword(); - if (session.accessToken.isEmpty()) { - emit errorOccurred(QStringLiteral("The server returned an empty session.")); - return; - } - - AuthSession activeSession = session; - if (activeSession.userName.isEmpty()) - activeSession.userName = m_username; - if (!activeSession.userName.isEmpty() && m_username != activeSession.userName) { - m_username = activeSession.userName; - emit usernameChanged(); - } - - m_api->setServerUrl(m_serverUrl); - m_api->setSession(activeSession); - if (m_profileSignInRequired) { - m_profileSignInRequired = false; - emit profileSignInRequiredChanged(); - } - if (persist) { - m_database->saveLoginHints(m_serverUrl, m_username); - upsertActiveProfile(activeSession); - } - - if (m_username.isEmpty() && !activeSession.userId.isEmpty()) { - Async::runScoped( - this, m_api->fetchCurrentUserName(), - [this](const QString& userName) { - const QString normalized = userName.trimmed(); - if (normalized.isEmpty() || !m_username.isEmpty()) - return; - m_username = normalized; - emit usernameChanged(); - AuthSession refreshed = m_api->session(); - refreshed.userName = normalized; - m_api->setSession(refreshed); - m_database->saveLoginHints(m_serverUrl, normalized); - upsertActiveProfile(refreshed); - }, - [](const std::exception_ptr& error) { - qWarning() << "session: user name backfill failed" << exceptionMessage(error); - }); - } - - emit authenticatedChanged(activeSession); - emit authenticatedStateChanged(); - Async::runScoped( - this, m_api->postCapabilities(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "session: capability report failed" << exceptionMessage(error); - }); -} - -void SessionController::upsertActiveProfile(const AuthSession& session) -{ - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const QString id = accountProfileId(session.serverId, m_serverUrl, session.userId); - const auto existing = std::find_if( - m_profiles.begin(), m_profiles.end(), [&id](const AccountProfile& profile) { return profile.profileId == id; }); - const qint64 createdAt = existing == m_profiles.end() ? now : existing->createdAt; - const QString avatarTag = existing == m_profiles.end() ? QString() : existing->avatarTag; - AccountProfile profile { id, session.serverId, - m_serverName.isEmpty() ? QStringLiteral("Jellyfin Server") : m_serverName, canonicalServerUrl(m_serverUrl), - session.userId, session.userName.isEmpty() ? m_username : session.userName, session.accessToken, avatarTag, now, - createdAt, false }; - if (existing == m_profiles.end()) - m_profiles.push_back(profile); - else - *existing = profile; - m_activeProfileId = id; - m_database->upsertAccountProfile(profile); - sortProfiles(); - emit activeProfileChanged(); - emit accountProfilesChanged(); -} - -void SessionController::clearActiveSession() -{ - const bool wasAuthenticated = authenticated(); - const bool hadActiveProfile = !m_activeProfileId.isEmpty(); - m_activeProfileId.clear(); - m_api->setSession({}); - clearPassword(); - if (hadActiveProfile) - emit activeProfileChanged(); - if (wasAuthenticated) - emit authenticatedStateChanged(); -} - -void SessionController::clearPassword() -{ - if (m_password.isEmpty()) - return; - m_password.fill(QLatin1Char('\0')); - m_password.clear(); - emit passwordChanged(); -} - -void SessionController::sortProfiles() -{ - std::stable_sort(m_profiles.begin(), m_profiles.end(), [](const AccountProfile& left, const AccountProfile& right) { - if (left.lastUsedAt != right.lastUsedAt) - return left.lastUsedAt > right.lastUsedAt; - return left.createdAt < right.createdAt; - }); -} - -} // namespace JellyfinNative diff --git a/src/app/SessionController.h b/src/app/SessionController.h deleted file mode 100644 index 0f38a6a2..00000000 --- a/src/app/SessionController.h +++ /dev/null @@ -1,99 +0,0 @@ -#pragma once - -#include "../common/JellyfinTypes.h" -#include "AccountProfile.h" - -#include -#include -#include -#include -#include - -#include -#include - -namespace JellyfinNative { - -class DatabaseManager; -class JellyfinApiFacade; - -class SessionController final : public QObject { - Q_OBJECT - Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged) - Q_PROPERTY(QString serverName READ serverName WRITE setServerName NOTIFY serverNameChanged) - Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY usernameChanged) - Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged) - Q_PROPERTY(bool authenticated READ authenticated NOTIFY authenticatedStateChanged) - Q_PROPERTY(QString activeProfileId READ activeProfileId NOTIFY activeProfileChanged) - Q_PROPERTY(QString activeProfileLabel READ activeProfileLabel NOTIFY activeProfileChanged) - Q_PROPERTY(QVariantList accountProfiles READ accountProfiles NOTIFY accountProfilesChanged) - Q_PROPERTY(bool profileSignInRequired READ profileSignInRequired NOTIFY profileSignInRequiredChanged) - -public: - SessionController(DatabaseManager *database, JellyfinApiFacade *api, QObject *parent = nullptr); - - QString serverUrl() const; - QString serverName() const; - QString username() const; - QString password() const; - bool authenticated() const; - QString activeProfileId() const; - QString activeProfileLabel() const; - QVariantList accountProfiles() const; - bool profileSignInRequired() const; - - QCoro::Task initializeAsync(); - static QStringList localStorageKeys(); - bool initializeFromStorage(QVariantMap values, std::vector profiles); - Q_INVOKABLE void setServerUrl(const QString& serverUrl); - Q_INVOKABLE void setServerName(const QString& serverName); - Q_INVOKABLE void setUsername(const QString& username); - Q_INVOKABLE void setPassword(const QString& password); - Q_INVOKABLE void login(); - Q_INVOKABLE void activateProfile(const QString& profileId); - Q_INVOKABLE void prepareProfileSignIn(const QString& profileId); - Q_INVOKABLE void updateProfileServer(const QString& profileId, const QString& name, const QString& url); - Q_INVOKABLE void removeProfile(const QString& profileId); - Q_INVOKABLE void clearProfiles(); - Q_INVOKABLE void deactivate(); - void acceptSession(const AuthSession& session); - Q_INVOKABLE void logout(); - void expireSession(const QString& message); - bool handleUnauthorized(const std::exception_ptr& error); - -signals: - void serverUrlChanged(); - void serverNameChanged(); - void usernameChanged(); - void passwordChanged(); - void busyChanged(bool busy, const QString& text); - void errorOccurred(const QString& message); - void authenticatedChanged(const JellyfinNative::AuthSession& session); - void authenticatedStateChanged(); - void activeProfileChanged(); - void accountProfilesChanged(); - void profileSignInRequiredChanged(); - void loggedOut(); - -private: - QCoro::Task activateProfileAsync(const QString& profileId); - bool applyStoredProfile(AccountProfile profile, bool persistUsage); - void activateSession(const AuthSession& session, bool persist); - void upsertActiveProfile(const AuthSession& session); - void setProfileSignInFields(const AccountProfile& profile); - void clearActiveSession(); - void clearPassword(); - void sortProfiles(); - - DatabaseManager *m_database = nullptr; - JellyfinApiFacade *m_api = nullptr; - QString m_serverUrl; - QString m_serverName; - QString m_username; - QString m_password; - QString m_activeProfileId; - std::vector m_profiles; - bool m_profileSignInRequired = false; -}; - -} // namespace JellyfinNative diff --git a/src/app/SettingsController.cpp b/src/app/SettingsController.cpp index 544eb96d..0cbd0049 100644 --- a/src/app/SettingsController.cpp +++ b/src/app/SettingsController.cpp @@ -1,21 +1,28 @@ #include "SettingsController.h" -#include "../api/JellyfinApiFacade.h" #include "../cache/DatabaseManager.h" #include "../common/AsyncTask.h" +#include "../diagnostics/InputLatencyMonitor.h" #include "../platform/PlatformSettingsPolicy.h" #include "../player/PlayerController.h" #include "../player/RenderTargetProfile.h" +#include "../provider/ProviderRegistry.h" #include "ArtworkService.h" +#include "LocalizationManager.h" #include "SettingsSchema.h" +#include "SettingsSyncController.h" +#include +#include +#include +#include #include -#include +#include #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -34,29 +41,152 @@ namespace { return *spec; } + bool externalStore(const SettingSpec& spec) + { + return spec.target == SettingTarget::Locale || spec.target == SettingTarget::LatencyGuard + || spec.target == SettingTarget::LatencyOverlay; + } + + bool trackDefault(const SettingSpec& spec) + { + return spec.nativePreference[0] != '\0'; + } + + QString applicationKey(const QString& key) + { + return QStringLiteral("settings/application/") + key; + } + + QString encodeApplication(const QVariantMap& record) + { + return QString::fromUtf8(QJsonDocument::fromVariant(record).toJson(QJsonDocument::Compact)); + } + } // namespace -SettingsController::SettingsController(DatabaseManager *database, JellyfinApiFacade *api, PlayerController *player, - ArtworkService *artwork, QObject *parent) +SettingsController::SettingsController( + DatabaseManager *database, PlayerController *player, ArtworkService *artwork, QObject *parent) : QObject(parent) , m_database(database) - , m_api(api) , m_player(player) , m_artwork(artwork) , m_uiScalePercent(platformDefaultUiScalePercent()) { + if (m_player) { + connect(m_player, &PlayerController::sessionActiveChanged, this, [this] { + if (!m_player->sessionActive()) + Async::runScoped( + this, applyDeferredTrackDefaults(), [] {}, + [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }, + "apply deferred track defaults"); + }); + } } QStringList SettingsController::subtitleLanguageOptions() const { + loadSubtitleLanguages(); return m_subtitleLanguageLabels; } int SettingsController::subtitleLanguageIndex() const { + loadSubtitleLanguages(); const int index = m_subtitleLanguageCodes.indexOf(m_subtitlePreferences.language); return index >= 0 ? index : 0; } +int SettingsController::audioLanguageIndex() const +{ + loadSubtitleLanguages(); + return qMax(0, m_subtitleLanguageCodes.indexOf(m_subtitlePreferences.audioLanguage)); +} + +void SettingsController::setAudioLanguageIndex(int index) +{ + loadSubtitleLanguages(); + if (index >= 0 && index < m_subtitleLanguageCodes.size()) + setValue(QStringLiteral("audio/language"), m_subtitleLanguageCodes.at(index)); +} + +void SettingsController::attachLocalization(LocalizationManager *localization) +{ + if (m_localization) + disconnect(m_localization, nullptr, this, nullptr); + m_localization = localization; + if (!localization) + return; + const auto mirror = [this] { + mirrorExternalValue(QStringLiteral("i18n/locale"), + m_localization->useSystemLocale() ? QStringLiteral("system") : m_localization->currentLocale()); + }; + connect(localization, &LocalizationManager::localeChanged, this, mirror); + mirror(); +} + +void SettingsController::attachInputLatency(InputLatencyMonitor *monitor) +{ + if (m_inputLatency) + disconnect(m_inputLatency, nullptr, this, nullptr); + m_inputLatency = monitor; + if (!monitor) + return; + connect(monitor, &InputLatencyMonitor::enabledChanged, this, + [this] { mirrorExternalValue(QStringLiteral("shell/latencyGuard"), m_inputLatency->enabled()); }); + connect(monitor, &InputLatencyMonitor::overlayEnabledChanged, this, + [this] { mirrorExternalValue(QStringLiteral("shell/latencyOverlay"), m_inputLatency->overlayEnabled()); }); + mirrorExternalValue(QStringLiteral("shell/latencyGuard"), monitor->enabled()); + mirrorExternalValue(QStringLiteral("shell/latencyOverlay"), monitor->overlayEnabled()); +} + +void SettingsController::attachSync(SettingsSyncController *sync) +{ + m_sync = sync; +} + +void SettingsController::mirrorExternalValue(const QString& key, const QVariant& value) +{ + if (m_applyingExternal || m_values.value(key) == value) + return; + // Internal monitor/locale changes are not committed user edits. + m_commitGenerations[key] = ++m_commitGeneration; + m_values.insert(key, value); + emit settingChanged(key); + emit settingsValuesChanged(); +} + +void SettingsController::applyExternalValue(const SettingSpec& spec, const QVariant& value) +{ + m_applyingExternal = true; + if (spec.target == SettingTarget::Locale && m_localization) { + if (value.toString().isEmpty()) + m_localization->useSystemDefault(); + else + m_localization->setLocale(value.toString()); + } else if (spec.target == SettingTarget::LatencyGuard && m_inputLatency) { + m_inputLatency->setEnabled(value.toBool()); + } else if (spec.target == SettingTarget::LatencyOverlay && m_inputLatency) { + m_inputLatency->setOverlayEnabled(value.toBool()); + } + m_applyingExternal = false; + QSettings settings; + settings.sync(); + if (settings.status() != QSettings::NoError) + throw std::runtime_error("Could not persist external settings"); +} + +bool SettingsController::supportsSyncValue(const QString& key, const QVariant& value) const +{ + const SettingSpec *spec = findSettingSpec(key); + if (!spec || !settingAcceptsRemoteValue(*spec, value)) + return false; + if (spec->target == SettingTarget::Locale) + return m_localization + && (value.toString().isEmpty() || m_localization->availableLocales().contains(value.toString())); + if (spec->target == SettingTarget::SubtitleFont && value.toString().startsWith(QStringLiteral("system:"))) + return systemSubtitleFonts().contains(value.toString().mid(7)); + return true; +} + QStringList SettingsController::systemSubtitleFonts() const { // Both CONSTANT list properties below must be cached: QML re-invokes the @@ -68,8 +198,11 @@ QStringList SettingsController::systemSubtitleFonts() const QVariantList SettingsController::settingsSchema() const { - static const QVariantList schema = settingSchemaModel(); - return schema; + // Cached for the same reason as the fonts above, and because the row set + // is fixed once the provider is chosen. + if (m_schema.isEmpty()) + m_schema = settingSchemaModel(); + return m_schema; } QVariantMap SettingsController::values() const @@ -98,9 +231,10 @@ QStringList SettingsController::localSettingKeys() for (const SettingSpec& spec : settingSpecs()) { if (!spec.persisted) continue; - if (platformUsesPerOutputAudioDelay() && spec.target == SettingTarget::AudioDelay) - continue; - keys.append(keyString(spec)); + if (externalStore(spec) || trackDefault(spec)) + keys.append(applicationKey(keyString(spec))); + if (!externalStore(spec) && !(platformUsesPerOutputAudioDelay() && spec.target == SettingTarget::AudioDelay)) + keys.append(keyString(spec)); } keys.append(QString::fromLatin1(kPlayerControlTooltipSessionsKey)); return keys; @@ -131,11 +265,11 @@ QString SettingsController::stepDownRenderQuality() // ourselves at all. if (m_videoOutputMode == QLatin1String("direct") || !platformSupportsDirectVideoOutput()) return {}; - setValue(QStringLiteral("playback/videoOutput"), QStringLiteral("direct")); + submitValues({ { QStringLiteral("playback/videoOutput"), QStringLiteral("direct") } }, ChangeOrigin::Automatic); return QStringLiteral("direct"); } const QString next = QString::fromLatin1(rungs[current + 1]); - setValue(QStringLiteral("playback/renderQuality"), next); + submitValues({ { QStringLiteral("playback/renderQuality"), next } }, ChangeOrigin::Automatic); return next; } @@ -146,6 +280,19 @@ void SettingsController::applyLocalValues(const QVariantMap& storedValues) if (!spec.persisted) continue; const QString key = keyString(spec); + if (externalStore(spec) || trackDefault(spec)) { + const auto record = QJsonDocument::fromJson(storedValues.value(applicationKey(key)).toString().toUtf8()) + .toVariant() + .toMap(); + if (!record.isEmpty()) { + m_pendingApplications.insert(key, record); + const quint64 generation = record.value(QStringLiteral("generation")).toString().toULongLong(); + m_commitGeneration = qMax(m_commitGeneration, generation); + m_commitGenerations.insert(key, generation); + } + } + if (externalStore(spec)) + continue; if (platformUsesPerOutputAudioDelay() && spec.target == SettingTarget::AudioDelay) { const QVariant normalized = normalizedSettingValue(spec, settingDefaultValue(spec)); m_values.insert(key, normalized); @@ -154,9 +301,7 @@ void SettingsController::applyLocalValues(const QVariantMap& storedValues) } const QVariant rawValue = storedValues.value(key); QVariant defaultValue = settingDefaultValue(spec); - if (spec.target == SettingTarget::CastButtonEnabled) - defaultValue = platformDefaultCastButtonEnabled(); - else if (spec.target == SettingTarget::RemoteControlTargetEnabled) + if (spec.target == SettingTarget::RemoteControlTargetEnabled) defaultValue = platformDefaultRemoteControlTargetEnabled(); else if (spec.target == SettingTarget::RenderQuality) defaultValue = QString::fromLatin1(platformDefaultRenderQuality()); @@ -219,76 +364,25 @@ void SettingsController::applyLocalValues(const QVariantMap& storedValues) emit remoteControlSettingsChanged(); } -void SettingsController::loadRemote() +void SettingsController::loadSubtitleLanguages() const { - if (m_remoteLoadStarted || !m_api || m_api->session().accessToken.isEmpty()) + if (m_subtitleLanguageCodes.size() > 1) return; - m_remoteLoadStarted = true; - - Async::runScoped( - this, m_api->fetchCultures(), - [this](const QJsonArray& cultures) { - QStringList codes { QString() }; - QStringList labels { QStringLiteral("Any language") }; - QSet seen { QString() }; - - for (const QJsonValue& value : cultures) { - const QJsonObject culture = value.toObject(); - const QString code = culture.value(QStringLiteral("ThreeLetterISOLanguageName")).toString(); - if (code.isEmpty() || seen.contains(code)) - continue; - QString label = culture.value(QStringLiteral("DisplayName")).toString(); - if (label.isEmpty()) - label = code.toUpper(); - seen.insert(code); - codes.push_back(code); - labels.push_back(label); - } - - if (!m_subtitlePreferences.language.isEmpty() && !seen.contains(m_subtitlePreferences.language)) { - codes.push_back(m_subtitlePreferences.language); - labels.push_back(m_subtitlePreferences.language.toUpper()); - } - - m_subtitleLanguageCodes = codes; - m_subtitleLanguageLabels = labels; - emit subtitleSettingsChanged(); - }, - [](const std::exception_ptr& error) { - qWarning() << "subtitles: culture list failed" << exceptionMessage(error); - }); - - Async::runScoped( - this, m_api->fetchUserConfiguration(), - [this](const QJsonObject& configuration) { - m_userConfiguration = configuration; - m_subtitlePreferences.audioLanguage - = configuration.value(QStringLiteral("AudioLanguagePreference")).toString(); - const SettingSpec& languageSpec = specForKey("subtitles/language"); - const SettingSpec& modeSpec = specForKey("subtitles/mode"); - const SettingSpec& audioModeSpec = specForKey("audio/trackMode"); - setSchemaValue(languageSpec, configuration.value(QStringLiteral("SubtitleLanguagePreference")).toString(), - true, false, false); - setSchemaValue(modeSpec, - configuration.value(QStringLiteral("SubtitleMode")).toString(QStringLiteral("Default")), true, false, - false); - setSchemaValue(audioModeSpec, - configuration.value(QStringLiteral("PlayDefaultAudioTrack")).toBool(true) ? QStringLiteral("Default") - : QStringLiteral("Smart"), - true, false, false); - applySubtitlePreferencesToPlayer(); - emit settingsValuesChanged(); - emit subtitleSettingsChanged(); - }, - [](const std::exception_ptr& error) { - qWarning() << "subtitles: user configuration failed" << exceptionMessage(error); - }); -} - -void SettingsController::clearRemote() -{ - m_userConfiguration = {}; - m_remoteLoadStarted = false; + // Every language Qt knows, by display name; the preference is stored as + // the ISO 639-2 code players match against. + QList> languages; + for (int value = QLocale::Abkhazian; value <= QLocale::LastLanguage; ++value) { + const auto language = static_cast(value); + const QString code = QLocale::languageToCode(language, QLocale::ISO639Part2); + if (code.size() == 3) + languages.append({ QLocale::languageToString(language), code }); + } + std::sort(languages.begin(), languages.end(), + [](const auto& a, const auto& b) { return a.first.localeAwareCompare(b.first) < 0; }); + for (const auto& [label, code] : std::as_const(languages)) { + m_subtitleLanguageCodes.push_back(code); + m_subtitleLanguageLabels.push_back(label); + } } void SettingsController::completePlayerControlTooltipSession() @@ -304,39 +398,300 @@ void SettingsController::completePlayerControlTooltipSession() void SettingsController::setValue(const QString& key, const QVariant& value) { - const SettingSpec *spec = findSettingSpec(key); - if (!spec) { - qWarning() << "settings: unknown key" << key; - return; + submitValues({ { key, value } }, ChangeOrigin::User); +} + +void SettingsController::submitValues(QVariantMap values, ChangeOrigin origin) +{ + Async::runScoped( + this, applyValues(std::move(values), origin), [] {}, + [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }, "save settings"); +} + +QCoro::Task SettingsController::applyValues( + QVariantMap values, ChangeOrigin origin, QVariantMap additionalSerializedSettings) +{ + if (origin == ChangeOrigin::Preview) { + for (auto it = values.cbegin(); it != values.cend(); ++it) + previewValue(it.key(), it.value()); + co_return; } - if (!spec->persisted) { - qWarning() << "settings: external row cannot be persisted through SettingsController" << key; - return; + QVariantMap normalized; + for (auto it = values.cbegin(); it != values.cend(); ++it) { + const SettingSpec *spec = findSettingSpec(it.key()); + if (!spec || !spec->persisted) + continue; + if (origin == ChangeOrigin::RemoteSync && !supportsSyncValue(it.key(), it.value())) + continue; + if (externalStore(*spec) + && ((spec->target == SettingTarget::Locale && !m_localization) + || (spec->target != SettingTarget::Locale && !m_inputLatency))) + continue; + QVariant result = normalizedSettingValue(*spec, it.value()); + if (spec->target == SettingTarget::MpvConfigDirectory) { + const auto policy = validatedPlatformMpvConfigPolicy(QStringLiteral("custom"), result.toString()); + if (!policy.valid) + throw std::runtime_error(policy.error.toStdString()); + result = policy.directory; + } + normalized.insert(it.key(), result); } - if (spec->target == SettingTarget::AudioDelay) { - setAudioDelayMs(value.toInt()); - return; + if (normalized.contains(QStringLiteral("playback/mpvConfigMode"))) { + const auto policy + = validatedPlatformMpvConfigPolicy(normalized.value(QStringLiteral("playback/mpvConfigMode")).toString(), + normalized.value(QStringLiteral("playback/mpvConfigDirectory"), m_mpvConfigDirectory).toString()); + if (!policy.valid) + throw std::runtime_error(policy.error.toStdString()); } - if (spec->target == SettingTarget::MpvConfigMode) { - const QString mode = normalizedSettingValue(*spec, value).toString(); - const MpvConfigPolicy policy = validatedPlatformMpvConfigPolicy(mode, m_mpvConfigDirectory); - if (!policy.valid) { - emit errorOccurred(policy.error); - return; + + const quint64 generation = ++m_commitGeneration; + QVariantMap changed; + QVariantMap external; + QVariantMap serialized = std::move(additionalSerializedSettings); + for (auto it = normalized.cbegin(); it != normalized.cend(); ++it) { + const auto& spec = *findSettingSpec(it.key()); + const bool hadPending = m_pendingApplications.contains(it.key()); + if (hadPending && (!externalStore(spec) || origin == ChangeOrigin::User)) { + m_pendingApplications.remove(it.key()); + serialized.insert(applicationKey(it.key()), QString()); + } + if (value(it.key()) == it.value() && !hadPending) + continue; + m_commitGenerations.insert(it.key(), generation); + changed.insert(it.key(), it.value()); + const bool deferred + = origin == ChangeOrigin::RemoteSync && trackDefault(spec) && m_player && m_player->sessionActive(); + if (externalStore(spec) || deferred) { + const QVariantMap record { { QStringLiteral("value"), it.value() }, + { QStringLiteral("origin"), static_cast(origin) }, + { QStringLiteral("generation"), QString::number(generation) }, + { QStringLiteral("accountId"), m_sync ? m_sync->accountId() : QString() }, + { QStringLiteral("deferred"), deferred } }; + m_pendingApplications.insert(it.key(), record); + serialized.insert(applicationKey(it.key()), encodeApplication(record)); + if (!deferred) + external.insert(it.key(), it.value()); + } else { + const QString storageKey = spec.target == SettingTarget::AudioDelay && platformUsesPerOutputAudioDelay() + ? platformAudioDelayStorageKey(m_currentAudioOutput) + : it.key(); + serialized.insert(storageKey, serializedSettingValue(spec, it.value())); + if (spec.target == SettingTarget::AudioDelay) + m_audioOutputLoadGeneration.invalidate(); + m_values.insert(it.key(), it.value()); } - setSchemaValue(*spec, mode, true, true, true); - return; } - if (spec->target == SettingTarget::MpvConfigDirectory) { - const MpvConfigPolicy policy = validatedPlatformMpvConfigPolicy(QStringLiteral("custom"), value.toString()); - if (!policy.valid) { - emit errorOccurred(policy.error); - return; + // Mutate all members before applying effects, so each effect observes the + // complete normalized batch rather than half of a preference pair. + m_batchEffects = true; + QVariantMap immediate; + for (auto it = changed.cbegin(); it != changed.cend(); ++it) { + if (external.contains(it.key()) || m_pendingApplications.contains(it.key())) + continue; + immediate.insert(it.key(), it.value()); + applySchemaValue(*findSettingSpec(it.key()), it.value(), false); + } + for (auto it = immediate.cbegin(); it != immediate.cend(); ++it) + applySchemaValue(*findSettingSpec(it.key()), it.value(), true); + m_batchEffects = false; + bool subtitleEffects = false; + bool artworkEffects = false; + bool streamingEffects = false; + for (auto it = immediate.cbegin(); it != immediate.cend(); ++it) { + const auto& spec = *findSettingSpec(it.key()); + subtitleEffects |= it.key().startsWith(QStringLiteral("subtitles/")) || trackDefault(spec); + artworkEffects |= it.key().startsWith(QStringLiteral("artwork/")); + streamingEffects |= spec.target == SettingTarget::MaxStreamingHeight + || spec.target == SettingTarget::ManualStreamingBitrate || spec.target == SettingTarget::MaxStreamingBitrate + || spec.target == SettingTarget::UnlimitedLocalBitrate || spec.target == SettingTarget::PreferRemux; + } + if (subtitleEffects) + applySubtitlePreferencesToPlayer(); + if (artworkEffects) + applyArtworkEncoding(); + if (streamingEffects) + applyPlaybackPreferences(); + + const bool userCommit = origin == ChangeOrigin::User && !changed.isEmpty(); + if (userCommit && m_sync) { + const QVariantMap ledger = m_sync->prepareLocalCommit(changed); + for (auto it = ledger.cbegin(); it != ledger.cend(); ++it) + serialized.insert(it.key(), it.value()); + } + if (!immediate.isEmpty()) { + for (auto it = immediate.cbegin(); it != immediate.cend(); ++it) + emit settingChanged(it.key()); + emitBatchSignals(immediate); + // Mixed external/SQLite batches notify after external application. + if (external.isEmpty()) + emit settingsValuesChanged(); + } + QPointer guard(this); + for (auto it = serialized.cbegin(); it != serialized.cend(); ++it) + m_serializedGenerations.insert(it.key(), generation); + try { + if (!serialized.isEmpty()) + co_await m_database->saveSettings(serialized); + if (!guard) + co_return; + for (auto it = serialized.cbegin(); it != serialized.cend(); ++it) { + if (m_serializedGenerations.value(it.key()) == generation) + m_failedSerializedSettings.remove(it.key()); } - setSchemaValue(*spec, policy.directory, true, true, true); - return; + QVariantMap cleared; + bool externalChanged = false; + for (auto it = external.cbegin(); it != external.cend(); ++it) { + if (m_commitGenerations.value(it.key()) != generation) + continue; + applyExternalValue(*findSettingSpec(it.key()), it.value()); + m_values.insert(it.key(), it.value()); + m_pendingApplications.remove(it.key()); + cleared.insert(applicationKey(it.key()), QString()); + externalChanged = true; + emit settingChanged(it.key()); + } + if (externalChanged || (!external.isEmpty() && !immediate.isEmpty())) + emit settingsValuesChanged(); + if (!cleared.isEmpty()) { + serialized = cleared; + co_await m_database->saveSettings(std::move(cleared)); + } + if (guard && userCommit) + emit userValuesCommitted(changed); + } catch (...) { + if (guard) { + for (auto it = serialized.cbegin(); it != serialized.cend(); ++it) { + if (m_serializedGenerations.value(it.key()) != generation) + continue; + m_failedSerializedSettings.insert(it.key(), it.value()); + if (it.key().startsWith(QStringLiteral("settings/application/")) && !it.value().toString().isEmpty()) { + const QString key = it.key().mid(21); + if (m_commitGenerations.value(key) == generation) + m_pendingApplications.insert( + key, QJsonDocument::fromJson(it.value().toString().toUtf8()).toVariant().toMap()); + } + } + } + if (guard && userCommit) { + emit userValuesCommitFailed(); + emit settingsPersistenceFailed(exceptionMessage(std::current_exception())); + } + throw; + } +} + +void SettingsController::cancelRemoteApplications(const QString& key, bool discardSyncLedger) +{ + // Control changes persist a new ledger through the sync controller. A + // retry of an older failed settings transaction must not resurrect it. + for (auto it = m_serializedGenerations.begin(); it != m_serializedGenerations.end(); ++it) { + if (discardSyncLedger && it.key().startsWith(QStringLiteral("settingsSync/"))) { + it.value() = ++m_commitGeneration; + m_failedSerializedSettings.remove(it.key()); + } + } + QVariantMap cleared; + for (auto it = m_pendingApplications.begin(); it != m_pendingApplications.end();) { + const QVariantMap record = it.value().toMap(); + if ((key.isEmpty() || it.key() == key) + && record.value(QStringLiteral("origin")).toInt() == static_cast(ChangeOrigin::RemoteSync)) { + m_commitGenerations[it.key()] = ++m_commitGeneration; + cleared.insert(applicationKey(it.key()), QString()); + m_serializedGenerations[applicationKey(it.key())] = m_commitGeneration; + m_failedSerializedSettings.remove(applicationKey(it.key())); + it = m_pendingApplications.erase(it); + } else { + ++it; + } + } + if (!cleared.isEmpty()) + Async::runScoped( + this, applyValues({}, ChangeOrigin::Initialization, std::move(cleared)), [] {}, + [this](const std::exception_ptr& error) { emit settingsPersistenceFailed(exceptionMessage(error)); }, + "invalidate pending remote application"); +} + +QCoro::Task SettingsController::retryPendingPersistence() +{ + QPointer guard(this); + const QVariantMap retry = m_failedSerializedSettings; + if (!retry.isEmpty()) + co_await m_database->saveSettings(retry); + if (!guard) + co_return; + for (auto it = retry.cbegin(); it != retry.cend(); ++it) { + if (m_failedSerializedSettings.value(it.key()) == it.value()) + m_failedSerializedSettings.remove(it.key()); + } + co_await recoverUnfinishedApplications(); +} + +QCoro::Task SettingsController::recoverUnfinishedApplications() +{ + QPointer guard(this); + const QVariantMap pending = m_pendingApplications; + for (auto it = pending.cbegin(); it != pending.cend(); ++it) { + if (!guard) + co_return; + const SettingSpec *spec = findSettingSpec(it.key()); + const QVariantMap record = it.value().toMap(); + const quint64 generation = record.value(QStringLiteral("generation")).toString().toULongLong(); + if (!spec || m_commitGenerations.value(it.key()) != generation) + continue; + const bool remote + = record.value(QStringLiteral("origin")).toInt() == static_cast(ChangeOrigin::RemoteSync); + if (remote + && (!m_sync + || !m_sync->remoteApplicationAllowed(it.key(), record.value(QStringLiteral("accountId")).toString()))) { + cancelRemoteApplications(it.key()); + continue; + } + if (remote && !m_sync->remoteApplicationReady()) + continue; + if (record.value(QStringLiteral("deferred")).toBool() && m_player && m_player->sessionActive()) + continue; + if (externalStore(*spec)) { + if ((spec->target == SettingTarget::Locale && !m_localization) + || (spec->target != SettingTarget::Locale && !m_inputLatency)) + continue; + applyExternalValue(*spec, record.value(QStringLiteral("value"))); + m_values.insert(it.key(), record.value(QStringLiteral("value"))); + emit settingChanged(it.key()); + emit settingsValuesChanged(); + } else { + m_pendingApplications.remove(it.key()); + co_await applyValues({ { it.key(), record.value(QStringLiteral("value")) } }, ChangeOrigin::Initialization, + { { applicationKey(it.key()), QString() } }); + continue; + } + if (m_commitGenerations.value(it.key()) != generation && externalStore(*spec)) + continue; + m_pendingApplications.remove(it.key()); + co_await applyValues({}, ChangeOrigin::Initialization, { { applicationKey(it.key()), QString() } }); } - setSchemaValue(*spec, value, true, true, true); +} + +QCoro::Task SettingsController::applyDeferredTrackDefaults() +{ + if (!m_sync || !m_sync->remoteApplicationReady()) + co_return; + QVariantMap deferred; + for (auto it = m_pendingApplications.cbegin(); it != m_pendingApplications.cend(); ++it) { + const QVariantMap record = it.value().toMap(); + if (record.value(QStringLiteral("deferred")).toBool() + && m_sync->remoteApplicationAllowed(it.key(), record.value(QStringLiteral("accountId")).toString())) + deferred.insert(it.key(), record.value(QStringLiteral("value"))); + } + if (deferred.isEmpty()) + co_return; + // No suspension until applyValues has captured/invalidated every old + // generation. Later local edits therefore win, including during its save. + QVariantMap cleared; + for (auto it = deferred.cbegin(); it != deferred.cend(); ++it) { + m_pendingApplications.remove(it.key()); + cleared.insert(applicationKey(it.key()), QString()); + } + co_await applyValues(std::move(deferred), ChangeOrigin::Initialization, std::move(cleared)); } void SettingsController::previewValue(const QString& key, const QVariant& value) @@ -396,29 +751,7 @@ void SettingsController::setNightModeEnabled(bool enabled) } void SettingsController::setAudioDelayMs(int delayMs) { - const SettingSpec& spec = specForKey("settings/audioDelayMs"); - if (!platformUsesPerOutputAudioDelay()) { - setSchemaValue(spec, delayMs, true, true, true); - return; - } - - const int normalized = normalizedSettingValue(spec, delayMs).toInt(); - if (m_audioDelayMs == normalized) - return; - - const int previous = m_audioDelayMs; - m_audioOutputLoadGeneration.invalidate(); - m_audioDelayMs = normalized; - m_values.insert(keyString(spec), normalized); - m_database->saveSetting( - platformAudioDelayStorageKey(m_currentAudioOutput), serializedSettingValue(spec, normalized)); - applyAudioDelayToPlayer(); - qInfo() << "app: audio delay trim for" << normalizedPlatformAudioRoute(m_currentAudioOutput) << previous << "->" - << m_audioDelayMs << "ms; automatic" << m_automaticAudioDelayMs << "ms; effective" - << qBound(-2000, m_automaticAudioDelayMs + m_audioDelayMs, 2000) << "ms"; - emit settingChanged(keyString(spec)); - emit settingsValuesChanged(); - emit audioDelayChanged(); + setValue(QStringLiteral("settings/audioDelayMs"), delayMs); } void SettingsController::setUiScalePercent(int percent) { @@ -426,46 +759,19 @@ void SettingsController::setUiScalePercent(int percent) } void SettingsController::setSubtitleLanguageIndex(int index) { + loadSubtitleLanguages(); if (index >= 0 && index < m_subtitleLanguageCodes.size()) setValue(QStringLiteral("subtitles/language"), m_subtitleLanguageCodes.at(index)); } void SettingsController::resetSubtitleAppearance() { + QVariantMap defaults; for (const SettingSpec& spec : settingSpecs()) { if (spec.persisted && QLatin1String(spec.group) == QLatin1String("Subtitle Appearance")) - setValue(keyString(spec), settingDefaultValue(spec)); - } -} -bool SettingsController::setSchemaValue( - const SettingSpec& spec, const QVariant& value, bool persist, bool apply, bool notify) -{ - const QString key = keyString(spec); - const QVariant normalized = normalizedSettingValue(spec, value); - if (m_values.contains(key) && m_values.value(key) == normalized) - return false; - - const int previousAudioDelayMs = m_audioDelayMs; - const QString previousAudioOutputMode = m_audioOutputMode; - - m_values.insert(key, normalized); - applySchemaValue(spec, normalized, apply); - - if (persist) - m_database->saveSetting(key, serializedSettingValue(spec, normalized)); - - if (apply && spec.target == SettingTarget::AudioDelay) { - qInfo() << "app: audio delay changed" << previousAudioDelayMs << "->" << m_audioDelayMs << "ms"; - } else if (apply && spec.target == SettingTarget::AudioOutput) { - qInfo() << "app: audio output mode changed" << previousAudioOutputMode << "->" << m_audioOutputMode; - } - - if (notify) { - emit settingChanged(key); - emit settingsValuesChanged(); - emitSchemaSignals(spec); + defaults.insert(keyString(spec), settingDefaultValue(spec)); } - return true; + submitValues(std::move(defaults), ChangeOrigin::User); } void SettingsController::applySchemaValue(const SettingSpec& spec, const QVariant& value, bool apply) @@ -473,18 +779,24 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian switch (spec.target) { case SettingTarget::External: break; + case SettingTarget::Locale: + case SettingTarget::LatencyGuard: + case SettingTarget::LatencyOverlay: + break; + case SettingTarget::AudioLanguage: + m_subtitlePreferences.audioLanguage = value.toString(); + if (apply) + applySubtitlePreferencesToPlayer(); + break; case SettingTarget::NightMode: m_nightModeEnabled = value.toBool(); if (apply && m_player) m_player->setNightModeEnabled(m_nightModeEnabled); break; - case SettingTarget::CastButtonEnabled: - m_castButtonEnabled = value.toBool(); - break; case SettingTarget::RemoteControlTargetEnabled: m_remoteControlTargetEnabled = value.toBool(); - if (m_api) - m_api->setRemoteControlTargetEnabled(m_remoteControlTargetEnabled); + if (apply || !m_localSettingsLoaded) + emit remoteControlTargetEnabledChanged(m_remoteControlTargetEnabled); break; case SettingTarget::ToneMappingVisualization: m_toneMappingVisualizationEnabled = value.toBool(); @@ -535,11 +847,11 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian break; case SettingTarget::VideoOutputMode: m_videoOutputMode = value.toString(); - if (m_player) + if (m_player && (apply || !m_localSettingsLoaded)) m_player->setDirectVideoOutput(m_videoOutputMode == QLatin1String("direct")); break; case SettingTarget::SoftwareRenderer: - if (m_player) + if (m_player && (apply || !m_localSettingsLoaded)) m_player->setSoftwareRenderer(value.toString().toLatin1()); break; case SettingTarget::AutoAdjustRenderQuality: @@ -547,29 +859,31 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian break; case SettingTarget::RenderQuality: m_renderQuality = value.toString(); - if (m_player) + if (m_player && (apply || !m_localSettingsLoaded)) m_player->setRenderQuality(MpvOptionProfile::renderQualityFromName(m_renderQuality)); break; case SettingTarget::HardwareDecoding: - if (m_player) + if (m_player && (apply || !m_localSettingsLoaded)) m_player->setHardwareDecoding(value.toBool()); break; case SettingTarget::HdrOutputMode: - if (m_player) + if (m_player && (apply || !m_localSettingsLoaded)) m_player->setHdrOutputPreference(value.toString()); // The mpv side of this applies immediately; the swapchain side cannot, // because Qt fixes a window's format when the window is created. Keep // it somewhere readable before the next window exists. - RenderTargetPolicy::rememberPreference(RenderTargetPolicy::preferenceFromName(value.toString())); + if (apply || !m_localSettingsLoaded) + RenderTargetPolicy::rememberPreference(RenderTargetPolicy::preferenceFromName(value.toString())); break; case SettingTarget::GraphicsApi: // Qt fixes the scene graph's backend for the life of the process, so // like the swapchain format this is recorded for the next launch and // read back before QGuiApplication exists. - RenderTargetPolicy::rememberGraphicsApi(RenderTargetPolicy::graphicsApiFromName(value.toString())); + if (apply || !m_localSettingsLoaded) + RenderTargetPolicy::rememberGraphicsApi(RenderTargetPolicy::graphicsApiFromName(value.toString())); break; case SettingTarget::HdrPeakBrightness: - if (m_player) + if (m_player && (apply || !m_localSettingsLoaded)) m_player->setHdrPeakNits(value.toInt()); break; case SettingTarget::UiScale: @@ -578,7 +892,8 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian case SettingTarget::AutomaticUpdates: // Announced even on the initial load, which is what starts the first // check: nothing checks for updates until settings have said it may. - emit automaticUpdatesChanged(value.toBool()); + if (apply || !m_localSettingsLoaded) + emit automaticUpdatesChanged(value.toBool()); break; case SettingTarget::ArtworkFormat: m_artworkFormat = value.toString(); @@ -598,7 +913,6 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian case SettingTarget::AudioTrackMode: m_subtitlePreferences.audioMode = value.toString(); if (apply) { - saveSubtitleUserConfiguration(); applySubtitlePreferencesToPlayer(); } break; @@ -607,14 +921,12 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian case SettingTarget::SubtitleLanguage: m_subtitlePreferences.language = value.toString(); if (apply) { - saveSubtitleUserConfiguration(); applySubtitlePreferencesToPlayer(); } break; case SettingTarget::SubtitleMode: m_subtitlePreferences.mode = value.toString(); if (apply) { - saveSubtitleUserConfiguration(); applySubtitlePreferencesToPlayer(); } break; @@ -766,86 +1078,40 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian } } -void SettingsController::emitSchemaSignals(const SettingSpec& spec) +void SettingsController::emitBatchSignals(const QVariantMap& values) { - switch (spec.target) { - case SettingTarget::External: - break; - case SettingTarget::NightMode: - emit nightModeChanged(); - break; - case SettingTarget::CastButtonEnabled: - case SettingTarget::RemoteControlTargetEnabled: - emit remoteControlSettingsChanged(); - break; - case SettingTarget::ToneMappingVisualization: - case SettingTarget::MaxStreamingHeight: - case SettingTarget::ManualStreamingBitrate: - case SettingTarget::MaxStreamingBitrate: - case SettingTarget::UnlimitedLocalBitrate: - case SettingTarget::PreferRemux: - case SettingTarget::ForwardCacheSize: - case SettingTarget::PlayerVolumeSlider: - case SettingTarget::AudioOutput: - case SettingTarget::HardwareDecoding: - break; - case SettingTarget::AudioDelay: - emit audioDelayChanged(); - break; - case SettingTarget::UiScale: - emit appearanceChanged(); - break; - case SettingTarget::ArtworkFormat: - case SettingTarget::ArtworkWebpQuality: - case SettingTarget::ArtworkJpegQuality: - break; - case SettingTarget::AudioTrackMode: - case SettingTarget::RememberSeriesAudioTrack: - break; - case SettingTarget::SubtitleLanguage: - case SettingTarget::SubtitleMode: - case SettingTarget::SubtitleStyling: - case SettingTarget::SubtitleTextWeight: - case SettingTarget::SubtitleFont: - case SettingTarget::SubtitleTextColor: - case SettingTarget::SubtitleTextColorOverride: - case SettingTarget::SubtitleDropShadow: - case SettingTarget::SubtitleTextBackground: - case SettingTarget::SubtitleVerticalPosition: - case SettingTarget::SubtitleScale: - case SettingTarget::SubtitlePositionAndSizeOverride: - case SettingTarget::SubtitleBitmapSharpness: - case SettingTarget::SubtitleRecolorImages: - case SettingTarget::SubtitleBitmapShadowEnabled: - case SettingTarget::SubtitleBitmapShadowCoreSize: - case SettingTarget::SubtitleBitmapShadowCoreGrow: - case SettingTarget::SubtitleBitmapShadowCoreOpacity: - case SettingTarget::SubtitleBitmapShadowSpreadEnabled: - case SettingTarget::SubtitleBitmapShadowSpreadSize: - case SettingTarget::SubtitleBitmapShadowSpreadGrow: - case SettingTarget::SubtitleBitmapShadowSpreadX: - case SettingTarget::SubtitleBitmapShadowSpreadY: - case SettingTarget::SubtitleBitmapShadowSpreadOpacity: - case SettingTarget::SubtitleBitmapShadowDither: - case SettingTarget::SubtitleAllowInBlackBars: - case SettingTarget::SubtitleHdrBrightness: + bool subtitle = false; + bool buttons = false; + for (auto it = values.cbegin(); it != values.cend(); ++it) { + const auto& spec = *findSettingSpec(it.key()); + switch (spec.target) { + case SettingTarget::NightMode: + emit nightModeChanged(); + break; + case SettingTarget::RemoteControlTargetEnabled: + emit remoteControlSettingsChanged(); + break; + case SettingTarget::AudioDelay: + emit audioDelayChanged(); + break; + case SettingTarget::UiScale: + emit appearanceChanged(); + break; + default: + break; + } + subtitle |= it.key().startsWith(QStringLiteral("subtitles/")); + buttons |= QLatin1String(spec.group) == QLatin1String("Remote buttons"); + } + if (subtitle) emit subtitleSettingsChanged(); - break; - case SettingTarget::RedButton: - case SettingTarget::GreenButton: - case SettingTarget::YellowButton: - case SettingTarget::BlueButton: + if (buttons) emit buttonRemapChanged(); - break; - case SettingTarget::MpvConfigMode: - case SettingTarget::MpvConfigDirectory: - break; - } } void SettingsController::applyArtworkEncoding() { - if (!m_artwork) + if (!m_artwork || m_batchEffects) return; // "auto" is resolved here rather than in the schema so the stored value // stays portable: the same profile restored on a TV and on a desktop asks @@ -858,11 +1124,11 @@ void SettingsController::applyArtworkEncoding() void SettingsController::applyPlaybackPreferences() { - if (!m_api) + if (m_batchEffects) return; const qint64 manualBitrate = m_manualStreamingBitrate ? static_cast(m_maxStreamingBitrateMbps) * 1'000'000 : 0; - m_api->setPlaybackPreferences(manualBitrate, m_unlimitedLocalBitrate, m_preferRemux, m_maxStreamingHeight); + emit playbackPreferencesChanged(manualBitrate, m_unlimitedLocalBitrate, m_preferRemux, m_maxStreamingHeight); } void SettingsController::applyAudioDelayToPlayer() @@ -940,23 +1206,6 @@ void SettingsController::applyLoadedAudioDelay(const QString& output, int delayM emit audioDelayChanged(); } -void SettingsController::saveSubtitleUserConfiguration() -{ - if (!m_api || m_api->session().accessToken.isEmpty()) - return; - - QJsonObject configuration = m_userConfiguration; - configuration.insert(QStringLiteral("SubtitleLanguagePreference"), m_subtitlePreferences.language); - configuration.insert(QStringLiteral("SubtitleMode"), m_subtitlePreferences.mode); - const bool smartAudio = m_subtitlePreferences.audioMode == QStringLiteral("Smart"); - configuration.insert(QStringLiteral("PlayDefaultAudioTrack"), !smartAudio); - m_userConfiguration = configuration; - - Async::runScoped( - this, m_api->updateUserConfiguration(configuration), []() {}, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - void SettingsController::applyMpvConfigPolicy() { const MpvConfigPolicy policy = validatedPlatformMpvConfigPolicy(m_mpvConfigMode, m_mpvConfigDirectory); @@ -966,8 +1215,8 @@ void SettingsController::applyMpvConfigPolicy() void SettingsController::applySubtitlePreferencesToPlayer() { - if (m_player) + if (m_player && !m_batchEffects) m_player->setSubtitlePreferences(m_subtitlePreferences); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsController.h b/src/app/SettingsController.h index 3c034f98..33f49800 100644 --- a/src/app/SettingsController.h +++ b/src/app/SettingsController.h @@ -1,22 +1,25 @@ #pragma once -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" +#include "../media/MediaTypes.h" #include "../platform/MpvConfigPolicy.h" #include -#include +#include #include #include #include #include -namespace JellyfinNative { +namespace Spool { +enum class ChangeOrigin { User, RemoteSync, Initialization, Automatic, Preview }; class DatabaseManager; class ArtworkService; -class JellyfinApiFacade; class PlayerController; +class LocalizationManager; +class InputLatencyMonitor; +class SettingsSyncController; struct SettingSpec; class SettingsController final : public QObject { @@ -33,6 +36,7 @@ class SettingsController final : public QObject { Q_PROPERTY(QStringList subtitleLanguageOptions READ subtitleLanguageOptions NOTIFY subtitleSettingsChanged) Q_PROPERTY(int subtitleLanguageIndex READ subtitleLanguageIndex WRITE setSubtitleLanguageIndex NOTIFY subtitleSettingsChanged) + Q_PROPERTY(int audioLanguageIndex READ audioLanguageIndex WRITE setAudioLanguageIndex NOTIFY settingsValuesChanged) Q_PROPERTY(QString redButtonAction MEMBER m_redButtonAction NOTIFY buttonRemapChanged) Q_PROPERTY(QString greenButtonAction MEMBER m_greenButtonAction NOTIFY buttonRemapChanged) Q_PROPERTY(QString yellowButtonAction MEMBER m_yellowButtonAction NOTIFY buttonRemapChanged) @@ -42,7 +46,6 @@ class SettingsController final : public QObject { Q_PROPERTY(QVariantMap values READ values NOTIFY settingsValuesChanged) Q_PROPERTY( bool playerControlTooltipsEnabled READ playerControlTooltipsEnabled NOTIFY playerControlTooltipsEnabledChanged) - Q_PROPERTY(bool castButtonEnabled READ castButtonEnabled NOTIFY remoteControlSettingsChanged) Q_PROPERTY(bool remoteControlTargetEnabled READ remoteControlTargetEnabled NOTIFY remoteControlSettingsChanged) public: @@ -57,8 +60,8 @@ class SettingsController final : public QObject { // the next playback starts where this one ended up. QString stepDownRenderQuality(); - SettingsController(DatabaseManager *database, JellyfinApiFacade *api, PlayerController *player, - ArtworkService *artwork, QObject *parent = nullptr); + SettingsController( + DatabaseManager *database, PlayerController *player, ArtworkService *artwork, QObject *parent = nullptr); int uiScalePercent() const { @@ -75,19 +78,20 @@ class SettingsController final : public QObject { QString audioDelayTargetLabel() const; int subtitleLanguageIndex() const; + int audioLanguageIndex() const; + Q_INVOKABLE void setAudioLanguageIndex(int index); QStringList subtitleLanguageOptions() const; QStringList systemSubtitleFonts() const; QVariantList settingsSchema() const; + // The active provider's capabilities decide which rows exist (the + // account rows need auth). Set before QML first reads the schema; the + // schema is built once, on that first read. QVariantMap values() const; Q_INVOKABLE QVariant value(const QString& key) const; bool playerControlTooltipsEnabled() const { return m_playerControlTooltipSessions < 3; } - bool castButtonEnabled() const - { - return m_castButtonEnabled; - } bool remoteControlTargetEnabled() const { return m_remoteControlTargetEnabled; @@ -96,9 +100,17 @@ class SettingsController final : public QObject { static QStringList localSettingKeys(); void applyLocalValues(const QVariantMap& storedValues); + void attachLocalization(LocalizationManager *localization); + void attachInputLatency(InputLatencyMonitor *monitor); + void attachSync(SettingsSyncController *sync); + QCoro::Task applyValues( + QVariantMap values, ChangeOrigin origin, QVariantMap additionalSerializedSettings = {}); + bool supportsSyncValue(const QString& key, const QVariant& value) const; + QCoro::Task recoverUnfinishedApplications(); + QCoro::Task retryPendingPersistence(); + QCoro::Task applyDeferredTrackDefaults(); + void cancelRemoteApplications(const QString& key = {}, bool discardSyncLedger = true); QCoro::Task loadLocalAsync(); - Q_INVOKABLE void loadRemote(); - void clearRemote(); Q_INVOKABLE void setValue(const QString& key, const QVariant& value); Q_INVOKABLE void previewValue(const QString& key, const QVariant& value); Q_INVOKABLE void completePlayerControlTooltipSession(); @@ -119,6 +131,12 @@ class SettingsController final : public QObject { void updateAudioOutputRoute(const QString& output, int displayLatencyMs, int outputLatencyMs); signals: + // Pushes toward the media source. + void playbackPreferencesChanged( + qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight); + void remoteControlTargetEnabledChanged(bool enabled); + // Pulls from it. + void nightModeChanged(); void audioDelayChanged(); void audioOutputDeviceChanged(); @@ -127,34 +145,47 @@ class SettingsController final : public QObject { void appearanceChanged(); void settingChanged(const QString& key); void settingsValuesChanged(); + void userValuesCommitted(QVariantMap values); + void userValuesCommitFailed(); + void settingsPersistenceFailed(QString message); void playerControlTooltipsEnabledChanged(); void remoteControlSettingsChanged(); void errorOccurred(const QString& message); private: - bool setSchemaValue(const SettingSpec& spec, const QVariant& value, bool persist, bool apply, bool notify); + void submitValues(QVariantMap values, ChangeOrigin origin); + void mirrorExternalValue(const QString& key, const QVariant& value); + void applyExternalValue(const SettingSpec& spec, const QVariant& value); void applySchemaValue(const SettingSpec& spec, const QVariant& value, bool apply); - void emitSchemaSignals(const SettingSpec& spec); + void emitBatchSignals(const QVariantMap& values); void applyPlaybackPreferences(); void applyArtworkEncoding(); void applyAudioDelayToPlayer(); void loadCurrentAudioDelay(); void applyLoadedAudioDelay(const QString& output, int delayMs); - void saveSubtitleUserConfiguration(); + void loadSubtitleLanguages() const; void applySubtitlePreferencesToPlayer(); void applyMpvConfigPolicy(); DatabaseManager *m_database = nullptr; - JellyfinApiFacade *m_api = nullptr; PlayerController *m_player = nullptr; ArtworkService *m_artwork = nullptr; + LocalizationManager *m_localization = nullptr; + InputLatencyMonitor *m_inputLatency = nullptr; + SettingsSyncController *m_sync = nullptr; + QHash m_commitGenerations; + QVariantMap m_pendingApplications; + QHash m_serializedGenerations; + QVariantMap m_failedSerializedSettings; + quint64 m_commitGeneration = 0; + bool m_applyingExternal = false; + bool m_batchEffects = false; + mutable QVariantList m_schema; QString m_artworkFormat = QStringLiteral("auto"); int m_artworkWebpQuality = 75; int m_artworkJpegQuality = 82; QVariantMap m_values; - bool m_remoteLoadStarted = false; bool m_nightModeEnabled = false; - bool m_castButtonEnabled = true; bool m_remoteControlTargetEnabled = true; bool m_toneMappingVisualizationEnabled = false; int m_maxStreamingHeight = 0; @@ -178,13 +209,12 @@ class SettingsController final : public QObject { int m_uiScalePercent; int m_playerControlTooltipSessions = 0; SubtitlePreferences m_subtitlePreferences; - QStringList m_subtitleLanguageCodes { QString() }; - QStringList m_subtitleLanguageLabels { QStringLiteral("Any language") }; - QJsonObject m_userConfiguration; + mutable QStringList m_subtitleLanguageCodes { QString() }; + mutable QStringList m_subtitleLanguageLabels { QStringLiteral("Any language") }; QString m_redButtonAction = QStringLiteral("none"); QString m_greenButtonAction = QStringLiteral("skipBackAndEnableSubs"); QString m_yellowButtonAction = QStringLiteral("none"); QString m_blueButtonAction = QStringLiteral("none"); }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index de63423d..95fba0da 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -1,15 +1,16 @@ #include "SettingsSchema.h" -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "../platform/PlatformSettingsPolicy.h" +#include #include #include #include #include -namespace JellyfinNative { +namespace Spool { namespace { // Enhanced puts every frame through libplacebo, which is where the @@ -61,6 +62,8 @@ namespace { constexpr SettingChoice kRailLabelChoices[] = { { "Never", "Never" }, { "On focus", "On focus" }, { "Always", "Always" } }; constexpr SettingChoice kTextRenderModeChoices[] = { { "0", "Standard" }, { "1", "Curve" } }; + constexpr SettingChoice kProviderUpdateChoices[] + = { { "auto", "Automatic" }, { "ask", "Ask first" }, { "manual", "Only when I check" } }; constexpr SettingChoice kArtworkFormatChoices[] = { { "auto", "Automatic" }, { "webp", "WebP (smaller downloads)" }, { "jpeg", "JPEG (faster to decode)" } }; constexpr SettingChoice kTechnicalMetadataChoices[] @@ -152,30 +155,15 @@ namespace { // Rows the settings page owns end to end: the schema only describes how to // draw them, and SettingsController never stores their value. - SettingSpec pageSpec(const char *key, const char *group, const char *title, const char *description, - SettingType type, const SettingChoice *choices, qsizetype choiceCount) + SettingSpec pageSpec( + const char *key, const char *group, const char *title, const char *description, SettingType type) { SettingSpec spec { key, group, title, description, type, "", SettingTarget::External, - type == SettingType::Select ? SettingNormalizer::Choice : SettingNormalizer::String }; - spec.choices = choices; - spec.choiceCount = choiceCount; + SettingNormalizer::String }; spec.persisted = false; return spec; } - SettingSpec pageSpec( - const char *key, const char *group, const char *title, const char *description, SettingType type) - { - return pageSpec(key, group, title, description, type, nullptr, 0); - } - - template - SettingSpec pageSpec(const char *key, const char *group, const char *title, const char *description, - SettingType type, const SettingChoice (&choices)[N]) - { - return pageSpec(key, group, title, description, type, choices, static_cast(N)); - } - QString typeName(SettingType type) { switch (type) { @@ -301,278 +289,368 @@ SettingSpec SettingSpec::duringHdrPlayback() const const QVector& settingSpecs() { const PlatformAudioOutputPolicy& audioOutput = platformAudioOutputPolicy(); - static const QVector specs { - - sliderSpec("appearance/uiScalePercent", "Appearance", "Interface scale", "", "100", 50, 180, 5, "%", - SettingTarget::UiScale), - pageSpec("theme/accent", "Appearance", "Accent colour", "", SettingType::Select, kAccentChoices), - pageSpec("theme/reducedMotion", "Appearance", "Reduced motion", "Turns off focus and page animation", - SettingType::Toggle), - pageSpec( - "i18n/locale", "Appearance", "Language", "Some text only changes after a restart", SettingType::Select), - pageSpec("theme/technicalMetadata", "Appearance", "Technical details", "Codec, resolution, and audio format", - SettingType::Select, kTechnicalMetadataChoices) - .advanced(), - pageSpec("theme/railLabels", "Appearance", "Navigation labels", "", SettingType::Select, kRailLabelChoices) - .advanced(), - pageSpec("theme/antialiasedText", "Appearance", "Smooth text", "", SettingType::Toggle).advanced(), - pageSpec("theme/renderMode", "Appearance", "Text rendering", "Curve stays sharp at any scale", - SettingType::Select, kTextRenderModeChoices) - .advanced(), - selectSpec("artwork/format", "Appearance", "Artwork format", - "Automatic picks JPEG on TVs, where decoding costs more than downloading", "auto", kArtworkFormatChoices, - SettingTarget::ArtworkFormat) - .advanced(), - sliderSpec("artwork/webpQuality", "Appearance", "WebP quality", "Applies to artwork fetched as WebP", "75", 40, - 100, 1, "", SettingTarget::ArtworkWebpQuality) - .advanced(), - sliderSpec("artwork/jpegQuality", "Appearance", "JPEG quality", - "JPEG needs a higher number than WebP to look the same", "82", 40, 100, 1, "", - SettingTarget::ArtworkJpegQuality) - .advanced(), - toggleSpec("remote/showCastButton", "Remote Control", "Show Cast button", - "Choose and control another Jellyfin client", true, SettingTarget::CastButtonEnabled), - toggleSpec("remote/acceptCommands", "Remote Control", "Allow remote control", - "Let other Jellyfin clients play and control media on this device", true, - SettingTarget::RemoteControlTargetEnabled), - selectSpec("audio/trackMode", "Playback", "Audio track", "Which track plays when a video starts", "Default", - kAudioTrackModeChoices, SettingTarget::AudioTrackMode), - toggleSpec("playback/rememberSeriesAudioTrack", "Playback", "Remember audio track per series", - "Keeps your choice for the rest of the episodes", true, SettingTarget::RememberSeriesAudioTrack), - toggleSpec("settings/nightMode", "Playback", "Night mode", "Lifts quiet dialogue and tames loud scenes", false, - SettingTarget::NightMode), - sliderSpec("settings/audioDelayMs", "Playback", "Audio sync", "Nudge the audio earlier or later", "0", -2000, - 2000, 10, "ms", SettingTarget::AudioDelay), - toggleSpec("playback/showVolumeSlider", "Playback", "Volume slider in the player", "", true, - SettingTarget::PlayerVolumeSlider) - .onDesktop(), - sliderSpec("playback/controlFadeDelaySeconds", "Playback", "Hide player controls after", "", "4", 1, 10, 1, "s", - SettingTarget::External), - selectSpec("playback/videoOutput", "Playback", "Video output", - "Enhanced processes each frame on the GPU. Direct sends it straight to the display", "enhanced", - kVideoOutputChoices, SettingTarget::VideoOutputMode) - .onAndroid() - .advanced(), - selectSpec("playback/softwareRenderer", "Playback", "Software video renderer", - "For codecs Starfish cannot decode. Automatic uses gpu; gpu-next is experimental. " - "Keeps lightweight rendering settings. Applies to the next playback", - "auto", kSoftwareRendererChoices, SettingTarget::SoftwareRenderer) - .onWebOS() - .advanced(), - toggleSpec("playback/hardwareDecoding", "Playback", "Hardware decoding", - "Use the GPU to decode video when supported. Turn off to use the CPU. Applies to the next playback", true, - SettingTarget::HardwareDecoding) - .onDesktop(), - selectSpec("playback/renderQuality", "Playback", "Picture quality", - "How much work the GPU does on each frame. Lowered automatically if playback drops frames", "balanced", - kRenderQualityChoices, SettingTarget::RenderQuality) - .advanced(), - toggleSpec("playback/autoAdjustQuality", "Playback", "Adjust quality automatically", - "Step down a rung when playback drops frames on this device", true, SettingTarget::AutoAdjustRenderQuality) - .advanced(), - selectSpec("playback/graphicsApi", "Playback", "Graphics backend", - "Applies when Spool next starts. Automatic picks the backend this platform presents HDR through. " - "OpenGL is the SDR compatibility choice", - "auto", kGraphicsApiChoices, SettingTarget::GraphicsApi) - .onDesktop() - .advanced(), - selectSpec("playback/hdrOutput", "Playback", "HDR output", - "Applies when Spool next starts. Automatic enables HDR on supported Linux Wayland Vulkan and Windows " - "Direct3D 11 displays with OS HDR enabled. Unsupported outputs stay SDR", - "auto", kHdrOutputChoices, SettingTarget::HdrOutputMode) - .onDesktop() - .expert(), - sliderSpec("playback/hdrPeakNits", "Playback", "Display peak brightness", - "What the display can actually reach. Zero uses OS or compositor-reported luminance when available", "0", 0, - 4000, 50, " nits", SettingTarget::HdrPeakBrightness) - .onDesktop() - .expert(), - selectSpec("settings/audioOutputMode", "Playback", "Audio output", "Applies the next time something plays", - audioOutput.defaultValue, audioOutput.choices, audioOutput.choiceCount, SettingTarget::AudioOutput, - SettingNormalizer::AudioOutput) - .advanced(), - selectSpec("playback/mpvConfigMode", "Playback", "mpv configuration", - "Your own mpv config can change or break playback", "disabled", kMpvConfigModeChoices, - SettingTarget::MpvConfigMode) - .expert() - .onDesktop(), - textSpec("playback/mpvConfigDirectory", "Playback", "mpv directory", - "Must hold mpv.conf and any scripts you want", SettingTarget::MpvConfigDirectory) - .expert() - .onDesktop() - .whenSetTo("playback/mpvConfigMode", "custom"), - - selectSpec("playback/maxStreamingHeight", "Streaming", "Resolution limit", - "Anything larger is scaled down by the server", "0", kMaxStreamingHeightChoices, - SettingTarget::MaxStreamingHeight), - toggleSpec("playback/manualStreamingBitrate", "Streaming", "Set my own bitrate limit", - "Otherwise the limit is measured for you", false, SettingTarget::ManualStreamingBitrate), - sliderSpec("playback/maxStreamingBitrateMbps", "Streaming", "Bitrate limit", - "Anything higher is transcoded by the server", "120", 5, 1000, 5, "Mbps", - SettingTarget::MaxStreamingBitrate) - .whenSetTo("playback/manualStreamingBitrate", "true"), - toggleSpec("playback/unlimitedLocalBitrate", "Streaming", "No limit on the local network", - "Applies when the server sees you as local", false, SettingTarget::UnlimitedLocalBitrate) - .advanced(), - toggleSpec("playback/preferRemux", "Streaming", "Prefer remuxing", - "Repackages instead of re-encoding where it can", true, SettingTarget::PreferRemux) - .advanced(), - sliderSpec("playback/forwardCacheSizeMiB", "Streaming", "Read-ahead buffer", - "Applies the next time something plays", "32", 16, 2048, 1, "MB", SettingTarget::ForwardCacheSize, - SettingNormalizer::PowerOfTwoRange) - .advanced(), - - selectSpec("subtitles/language", "Subtitles", "Preferred language", "Used when subtitles are picked for you", - "", nullptr, 0, SettingTarget::SubtitleLanguage), - selectSpec("subtitles/mode", "Subtitles", "When to show subtitles", "", "Default", kSubtitleModeChoices, - SettingTarget::SubtitleMode), - pageSpec("action/subtitleSettings", "Subtitles", "Subtitle appearance", "Size, position, colour, and font", - SettingType::Action), - - // Shown by the subtitle appearance panel, which can sit over live video. - selectSpec("subtitles/styling", "Subtitle Appearance", "Style", - "Automatic styles plain text and leaves authored subtitles alone", "Auto", kSubtitleStylingChoices, - SettingTarget::SubtitleStyling) - .advanced(), - sliderSpec("subtitles/scalePercent", "Subtitle Appearance", "Text Size", "Matches text and image subtitle size", - "100", 50, 200, 5, "%", SettingTarget::SubtitleScale), - sliderSpec("subtitles/verticalPositionPercent", "Subtitle Appearance", "Vertical position", - "Higher values move subtitles up the screen", "95", 0, 100, 1, "%", - SettingTarget::SubtitleVerticalPosition), - toggleSpec("subtitles/alwaysOverridePositionAndSize", "Subtitle Appearance", - "Always override size and position", "Also moves subtitles that place themselves", false, - SettingTarget::SubtitlePositionAndSizeOverride), - toggleSpec("subtitles/allowInBlackBars", "Subtitle Appearance", "Allow in black bars", "", true, - SettingTarget::SubtitleAllowInBlackBars) - .advanced(), - toggleSpec("subtitles/overrideTextColor", "Subtitle Appearance", "Override text colour", - "Use the selected colour instead of authored text colours", false, - SettingTarget::SubtitleTextColorOverride), - selectSpec("subtitles/textColor", "Subtitle Appearance", "Text colour", "", "#ffffff", - kSubtitleTextColorChoices, SettingTarget::SubtitleTextColor, SettingNormalizer::SubtitleColor) - .advanced(), - selectSpec("subtitles/textWeight", "Subtitle Appearance", "Text weight", "Bold reads better on busy scenes", - "normal", kSubtitleTextWeightChoices, SettingTarget::SubtitleTextWeight) - .advanced(), - selectSpec("subtitles/font", "Subtitle Appearance", "Font", "", "", kSubtitleFontChoices, - SettingTarget::SubtitleFont, SettingNormalizer::SubtitleFont) - .advanced(), - selectSpec("subtitles/dropShadow", "Subtitle Appearance", "Outline", "", "", kSubtitleDropShadowChoices, - SettingTarget::SubtitleDropShadow) - .advanced(), - selectSpec("subtitles/textBackground", "Subtitle Appearance", "Background", "", "transparent", - kSubtitleBackgroundChoices, SettingTarget::SubtitleTextBackground) - .advanced(), - toggleSpec("subtitles/recolorImageSubtitles", "Subtitle Appearance", "Use text colour for images", "", false, - SettingTarget::SubtitleRecolorImages) - .advanced(), - sliderSpec("subtitles/bitmapSharpnessPercent", "Subtitle Appearance", "Sharpness", "0% smoother · 100% sharper", - "45", 0, 100, 5, "%", SettingTarget::SubtitleBitmapSharpness) - .advanced(), - toggleSpec("subtitles/bitmapShadowEnabled", "Subtitle Appearance", "Image shadow", - "Add a two-layer contrast shadow to image subtitles", true, SettingTarget::SubtitleBitmapShadowEnabled) - .advanced(), - sliderSpec("subtitles/bitmapShadowCoreSize", "Subtitle Appearance", "Edge shadow softness", - "Softness of the tight edge-protection layer", "1", 1, 4, 1, "px", - SettingTarget::SubtitleBitmapShadowCoreSize) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/bitmapShadowCoreGrow", "Subtitle Appearance", "Edge shadow grow", - "Expand the tight shadow beyond the subtitle edge", "1", 0, 4, 1, "px", - SettingTarget::SubtitleBitmapShadowCoreGrow) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/bitmapShadowCoreOpacityPercent", "Subtitle Appearance", "Edge shadow strength", "", "70", - 0, 100, 5, "%", SettingTarget::SubtitleBitmapShadowCoreOpacity) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - toggleSpec("subtitles/bitmapShadowSpreadEnabled", "Subtitle Appearance", "Wide shadow", - "Add a soft offset layer behind the edge shadow", true, SettingTarget::SubtitleBitmapShadowSpreadEnabled) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/bitmapShadowSpreadSize", "Subtitle Appearance", "Wide shadow softness", "", "6", 1, 16, 1, - "px", SettingTarget::SubtitleBitmapShadowSpreadSize) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/bitmapShadowSpreadGrow", "Subtitle Appearance", "Wide shadow grow", "", "0", 0, 8, 1, - "px", SettingTarget::SubtitleBitmapShadowSpreadGrow) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/bitmapShadowSpreadX", "Subtitle Appearance", "Wide shadow horizontal offset", "", "2", - -16, 16, 1, "px", SettingTarget::SubtitleBitmapShadowSpreadX) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/bitmapShadowSpreadY", "Subtitle Appearance", "Wide shadow vertical offset", "", "3", -16, - 16, 1, "px", SettingTarget::SubtitleBitmapShadowSpreadY) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/bitmapShadowSpreadOpacityPercent", "Subtitle Appearance", "Wide shadow strength", "", - "30", 0, 100, 5, "%", SettingTarget::SubtitleBitmapShadowSpreadOpacity) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - toggleSpec("subtitles/bitmapShadowDither", "Subtitle Appearance", "Shadow dithering", - "Reduce banding in wide shadow gradients", true, SettingTarget::SubtitleBitmapShadowDither) - .advanced() - .whenSetTo("subtitles/bitmapShadowEnabled", "true"), - sliderSpec("subtitles/hdrBrightnessPercent", "Subtitle Appearance", "HDR Subtitle Brightness", - "100% keeps the original brightness", "50", 5, 100, 5, "%", SettingTarget::SubtitleHdrBrightness) - .advanced() - .duringHdrPlayback(), - pageSpec("action/resetSubtitleAppearance", "Subtitle Appearance", "Reset appearance", "", SettingType::Action) - .advanced(), - - selectSpec( - "input/redButton", "Remote buttons", "Red", "", "none", kButtonActionChoices, SettingTarget::RedButton) - .expert() - .onWebOS(), - selectSpec("input/greenButton", "Remote buttons", "Green", "", "skipBackAndEnableSubs", kButtonActionChoices, - SettingTarget::GreenButton) - .expert() - .onWebOS(), - selectSpec("input/yellowButton", "Remote buttons", "Yellow", "", "none", kButtonActionChoices, - SettingTarget::YellowButton) - .expert() - .onWebOS(), - selectSpec( - "input/blueButton", "Remote buttons", "Blue", "", "none", kButtonActionChoices, SettingTarget::BlueButton) - .expert() - .onWebOS(), - - // Only platforms with an in-app installer expose this preference. - toggleSpec("updates/automatic", "Updates", "Automatic updates", "Check for new versions", true, - SettingTarget::AutomaticUpdates) -#if defined(JELLYFIN_NATIVE_WEBOS) - .onWebOS(), + static const QVector specs = [&] { + QVector rows { + + sliderSpec("appearance/uiScalePercent", "Appearance", "Interface scale", "", "100", 50, 180, 5, "%", + SettingTarget::UiScale), + selectSpec("i18n/locale", "Appearance", "Language", "Some text only changes after a restart", "system", + nullptr, 0, SettingTarget::Locale), + selectSpec("theme/accent", "Appearance", "Accent colour", "", "0", kAccentChoices, SettingTarget::External), + toggleSpec("theme/reducedMotion", "Appearance", "Reduced motion", "", false, SettingTarget::External), + selectSpec("theme/technicalMetadata", "Appearance", "Technical details", + "Codec, resolution, and audio format", "Always", kTechnicalMetadataChoices, SettingTarget::External) + .advanced(), + selectSpec("theme/railLabels", "Appearance", "Navigation labels", "", "On focus", kRailLabelChoices, + SettingTarget::External) + .advanced(), + toggleSpec("theme/antialiasedText", "Appearance", "Smooth text", "", true, SettingTarget::External) + .advanced(), + selectSpec("theme/renderMode", "Appearance", "Text rendering", "", "0", kTextRenderModeChoices, + SettingTarget::External) + .advanced(), + selectSpec("artwork/format", "Appearance", "Artwork format", + "Automatic picks JPEG on TVs, where decoding costs more than downloading", "auto", + kArtworkFormatChoices, SettingTarget::ArtworkFormat) + .advanced(), + sliderSpec("artwork/webpQuality", "Appearance", "WebP quality", "", "75", 40, 100, 1, "", + SettingTarget::ArtworkWebpQuality) + .advanced(), + sliderSpec("artwork/jpegQuality", "Appearance", "JPEG quality", + "JPEG needs a higher number than WebP to look the same", "82", 40, 100, 1, "", + SettingTarget::ArtworkJpegQuality) + .advanced(), + toggleSpec("remote/acceptCommands", "Remote Control", "Allow remote control", "", true, + SettingTarget::RemoteControlTargetEnabled), + selectSpec("audio/language", "Playback", "Preferred audio language", "", "", nullptr, 0, + SettingTarget::AudioLanguage), + selectSpec("audio/trackMode", "Playback", "Audio track", "", "Default", kAudioTrackModeChoices, + SettingTarget::AudioTrackMode), + toggleSpec("playback/rememberSeriesAudioTrack", "Playback", "Remember audio track per series", "", true, + SettingTarget::RememberSeriesAudioTrack), + toggleSpec("settings/nightMode", "Playback", "Night mode", "Lifts quiet dialogue and tames loud scenes", + false, SettingTarget::NightMode), + sliderSpec("settings/audioDelayMs", "Playback", "Audio sync", "", "0", -2000, 2000, 10, "ms", + SettingTarget::AudioDelay), + toggleSpec("playback/showVolumeSlider", "Playback", "Volume slider in the player", "", true, + SettingTarget::PlayerVolumeSlider) + .onDesktop(), + sliderSpec("playback/controlFadeDelaySeconds", "Playback", "Hide player controls after", "", "4", 1, 10, 1, + "s", SettingTarget::External), + toggleSpec("playback/seekPreviews", "Playback", "Seek previews", + "Thumbnails while seeking. Off hides them and stops downloading them", true, SettingTarget::External), + toggleSpec("playback/accurateTrickplay", "Playback", "Accurate seek previews", + "Uses a slower, accurate JPEG transform. Off uses the faster transform; both preserve encoded " + "resolution", + false, SettingTarget::External) + .advanced() + .whenSetTo("playback/seekPreviews", "true"), + sliderSpec("playback/trickplayPreviewScalePercent", "Playback", "Seek preview size", + "Relative to the interface scale, independent of the server's thumbnail resolution", "100", 25, 200, 5, + "%", SettingTarget::External) + .advanced() + .whenSetTo("playback/seekPreviews", "true"), + selectSpec("playback/videoOutput", "Playback", "Video output", + "Enhanced processes each frame on the GPU. Direct sends it straight to the display", "enhanced", + kVideoOutputChoices, SettingTarget::VideoOutputMode) + .onAndroid() + .advanced(), + selectSpec("playback/softwareRenderer", "Playback", "Software video renderer", + "For codecs Starfish cannot decode. Automatic uses gpu; gpu-next is experimental. " + "Keeps lightweight rendering settings. Applies to the next playback", + "auto", kSoftwareRendererChoices, SettingTarget::SoftwareRenderer) + .onWebOS() + .advanced(), + toggleSpec("playback/hardwareDecoding", "Playback", "Hardware decoding", + "Use the GPU to decode video when supported. Turn off to use the CPU. Applies to the next playback", + true, SettingTarget::HardwareDecoding) + .onDesktop(), + selectSpec("playback/renderQuality", "Playback", "Picture quality", + "How much work the GPU does on each frame. Lowered automatically if playback drops frames", "balanced", + kRenderQualityChoices, SettingTarget::RenderQuality) + .advanced(), + toggleSpec("playback/autoAdjustQuality", "Playback", "Adjust quality automatically", + "Step down a rung when playback drops frames on this device", true, + SettingTarget::AutoAdjustRenderQuality) + .advanced(), + selectSpec("playback/graphicsApi", "Playback", "Graphics backend", + "Applies when Spool next starts. Automatic picks the backend this platform presents HDR through. " + "OpenGL is the SDR compatibility choice", + "auto", kGraphicsApiChoices, SettingTarget::GraphicsApi) + .onDesktop() + .advanced(), + selectSpec("playback/hdrOutput", "Playback", "HDR output", + "Applies when Spool next starts. Automatic enables HDR on supported Linux Wayland Vulkan and Windows " + "Direct3D 11 displays with OS HDR enabled. Unsupported outputs stay SDR", + "auto", kHdrOutputChoices, SettingTarget::HdrOutputMode) + .onDesktop() + .expert(), + sliderSpec("playback/hdrPeakNits", "Playback", "Display peak brightness", + "What the display can actually reach. Zero uses OS or compositor-reported luminance when available", + "0", 0, 4000, 50, " nits", SettingTarget::HdrPeakBrightness) + .onDesktop() + .expert(), + selectSpec("settings/audioOutputMode", "Playback", "Audio output", "Applies the next time something plays", + audioOutput.defaultValue, audioOutput.choices, audioOutput.choiceCount, SettingTarget::AudioOutput, + SettingNormalizer::AudioOutput) + .advanced(), + selectSpec("playback/mpvConfigMode", "Playback", "mpv configuration", + "Your own mpv config can change or break playback", "disabled", kMpvConfigModeChoices, + SettingTarget::MpvConfigMode) + .expert() + .onDesktop(), + textSpec("playback/mpvConfigDirectory", "Playback", "mpv directory", + "Must hold mpv.conf and any scripts you want", SettingTarget::MpvConfigDirectory) + .expert() + .onDesktop() + .whenSetTo("playback/mpvConfigMode", "custom"), + + selectSpec("playback/maxStreamingHeight", "Streaming", "Resolution limit", + "Anything larger is scaled down by the server", "0", kMaxStreamingHeightChoices, + SettingTarget::MaxStreamingHeight), + toggleSpec("playback/manualStreamingBitrate", "Streaming", "Set my own bitrate limit", + "Otherwise the limit is measured for you", false, SettingTarget::ManualStreamingBitrate), + pageSpec("action/connectionSpeed", "Streaming", "Connection speed", "", SettingType::Action), + sliderSpec("playback/maxStreamingBitrateMbps", "Streaming", "Bitrate limit", + "Anything higher is transcoded by the server", "120", 5, 1000, 5, "Mbps", + SettingTarget::MaxStreamingBitrate) + .whenSetTo("playback/manualStreamingBitrate", "true"), + toggleSpec("playback/unlimitedLocalBitrate", "Streaming", "No limit on the local network", + "Applies when the server sees you as local", false, SettingTarget::UnlimitedLocalBitrate) + .advanced(), + toggleSpec("playback/preferRemux", "Streaming", "Prefer remuxing", + "Repackages instead of re-encoding where it can", true, SettingTarget::PreferRemux) + .advanced(), + sliderSpec("playback/forwardCacheSizeMiB", "Streaming", "Read-ahead buffer", + "Applies the next time something plays", "32", 16, 2048, 1, "MB", SettingTarget::ForwardCacheSize, + SettingNormalizer::PowerOfTwoRange) + .advanced(), + + selectSpec("subtitles/language", "Subtitles", "Preferred language", + "Used when subtitles are picked for you", "", nullptr, 0, SettingTarget::SubtitleLanguage), + selectSpec("subtitles/mode", "Subtitles", "When to show subtitles", "", "Default", kSubtitleModeChoices, + SettingTarget::SubtitleMode), + pageSpec("action/subtitleSettings", "Subtitles", "Subtitle appearance", "", SettingType::Action), + + // Shown by the subtitle appearance panel, which can sit over live video. + selectSpec("subtitles/styling", "Subtitle Appearance", "Style", + "Automatic styles plain text and leaves authored subtitles alone", "Auto", kSubtitleStylingChoices, + SettingTarget::SubtitleStyling) + .advanced(), + sliderSpec("subtitles/scalePercent", "Subtitle Appearance", "Text Size", + "Matches text and image subtitle size", "100", 50, 200, 5, "%", SettingTarget::SubtitleScale), + sliderSpec("subtitles/verticalPositionPercent", "Subtitle Appearance", "Vertical position", + "Higher values move subtitles up the screen", "95", 0, 100, 1, "%", + SettingTarget::SubtitleVerticalPosition), + toggleSpec("subtitles/alwaysOverridePositionAndSize", "Subtitle Appearance", + "Always override size and position", "Also moves subtitles that place themselves", false, + SettingTarget::SubtitlePositionAndSizeOverride), + toggleSpec("subtitles/allowInBlackBars", "Subtitle Appearance", "Allow in black bars", "", true, + SettingTarget::SubtitleAllowInBlackBars) + .advanced(), + toggleSpec("subtitles/overrideTextColor", "Subtitle Appearance", "Override text colour", + "Use the selected colour instead of authored text colours", false, + SettingTarget::SubtitleTextColorOverride), + selectSpec("subtitles/textColor", "Subtitle Appearance", "Text colour", "", "#ffffff", + kSubtitleTextColorChoices, SettingTarget::SubtitleTextColor, SettingNormalizer::SubtitleColor) + .advanced(), + selectSpec("subtitles/textWeight", "Subtitle Appearance", "Text weight", "Bold reads better on busy scenes", + "normal", kSubtitleTextWeightChoices, SettingTarget::SubtitleTextWeight) + .advanced(), + selectSpec("subtitles/font", "Subtitle Appearance", "Font", "", "", kSubtitleFontChoices, + SettingTarget::SubtitleFont, SettingNormalizer::SubtitleFont) + .advanced(), + selectSpec("subtitles/dropShadow", "Subtitle Appearance", "Outline", "", "", kSubtitleDropShadowChoices, + SettingTarget::SubtitleDropShadow) + .advanced(), + selectSpec("subtitles/textBackground", "Subtitle Appearance", "Background", "", "transparent", + kSubtitleBackgroundChoices, SettingTarget::SubtitleTextBackground) + .advanced(), + toggleSpec("subtitles/recolorImageSubtitles", "Subtitle Appearance", "Use text colour for images", "", + false, SettingTarget::SubtitleRecolorImages) + .advanced(), + sliderSpec("subtitles/bitmapSharpnessPercent", "Subtitle Appearance", "Sharpness", + "0% smoother · 100% sharper", "45", 0, 100, 5, "%", SettingTarget::SubtitleBitmapSharpness) + .advanced(), + toggleSpec("subtitles/bitmapShadowEnabled", "Subtitle Appearance", "Image shadow", + "Add a two-layer contrast shadow to image subtitles", true, SettingTarget::SubtitleBitmapShadowEnabled) + .advanced(), + sliderSpec("subtitles/bitmapShadowCoreSize", "Subtitle Appearance", "Edge shadow softness", + "Softness of the tight edge-protection layer", "1", 1, 4, 1, "px", + SettingTarget::SubtitleBitmapShadowCoreSize) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/bitmapShadowCoreGrow", "Subtitle Appearance", "Edge shadow grow", + "Expand the tight shadow beyond the subtitle edge", "1", 0, 4, 1, "px", + SettingTarget::SubtitleBitmapShadowCoreGrow) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/bitmapShadowCoreOpacityPercent", "Subtitle Appearance", "Edge shadow strength", "", + "70", 0, 100, 5, "%", SettingTarget::SubtitleBitmapShadowCoreOpacity) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + toggleSpec("subtitles/bitmapShadowSpreadEnabled", "Subtitle Appearance", "Wide shadow", + "Add a soft offset layer behind the edge shadow", true, + SettingTarget::SubtitleBitmapShadowSpreadEnabled) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/bitmapShadowSpreadSize", "Subtitle Appearance", "Wide shadow softness", "", "6", 1, + 16, 1, "px", SettingTarget::SubtitleBitmapShadowSpreadSize) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/bitmapShadowSpreadGrow", "Subtitle Appearance", "Wide shadow grow", "", "0", 0, 8, 1, + "px", SettingTarget::SubtitleBitmapShadowSpreadGrow) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/bitmapShadowSpreadX", "Subtitle Appearance", "Wide shadow horizontal offset", "", "2", + -16, 16, 1, "px", SettingTarget::SubtitleBitmapShadowSpreadX) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/bitmapShadowSpreadY", "Subtitle Appearance", "Wide shadow vertical offset", "", "3", + -16, 16, 1, "px", SettingTarget::SubtitleBitmapShadowSpreadY) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/bitmapShadowSpreadOpacityPercent", "Subtitle Appearance", "Wide shadow strength", "", + "30", 0, 100, 5, "%", SettingTarget::SubtitleBitmapShadowSpreadOpacity) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + toggleSpec("subtitles/bitmapShadowDither", "Subtitle Appearance", "Shadow dithering", + "Reduce banding in wide shadow gradients", true, SettingTarget::SubtitleBitmapShadowDither) + .advanced() + .whenSetTo("subtitles/bitmapShadowEnabled", "true"), + sliderSpec("subtitles/hdrBrightnessPercent", "Subtitle Appearance", "HDR Subtitle Brightness", + "100% keeps the original brightness", "50", 5, 100, 5, "%", SettingTarget::SubtitleHdrBrightness) + .advanced() + .duringHdrPlayback(), + pageSpec( + "action/resetSubtitleAppearance", "Subtitle Appearance", "Reset appearance", "", SettingType::Action) + .advanced(), + + selectSpec( + "input/redButton", "Remote buttons", "Red", "", "none", kButtonActionChoices, SettingTarget::RedButton) + .expert() + .onWebOS(), + selectSpec("input/greenButton", "Remote buttons", "Green", "", "skipBackAndEnableSubs", + kButtonActionChoices, SettingTarget::GreenButton) + .expert() + .onWebOS(), + selectSpec("input/yellowButton", "Remote buttons", "Yellow", "", "none", kButtonActionChoices, + SettingTarget::YellowButton) + .expert() + .onWebOS(), + selectSpec("input/blueButton", "Remote buttons", "Blue", "", "none", kButtonActionChoices, + SettingTarget::BlueButton) + .expert() + .onWebOS(), + + // Only platforms with an in-app installer expose this preference. + toggleSpec("updates/automatic", "Updates", "Automatic updates", "", true, SettingTarget::AutomaticUpdates) +#if defined(SPOOL_WEBOS) + .onWebOS(), #else - .onAndroid(), + .onAndroid(), #endif - pageSpec("session/account", "Account", "Signed in as", "", SettingType::ReadOnly), - pageSpec("action/switchUser", "Account", "Switch profile", "", SettingType::Action), - pageSpec("action/logout", "Account", "Sign out", "Keeps this profile on the device", SettingType::Action), - pageSpec("action/manageCertificates", "Account", "Remembered certificates", - "Server certificates you chose to trust", SettingType::Action), - - pageSpec("action/exportDiagnostics", "Diagnostics", "Export diagnostics", - "Collects support information for you to review and send", SettingType::Action), - pageSpec("action/clearLogs", "Diagnostics", "Clear logs", "", SettingType::Action), - pageSpec("shell/diagnostics", "Diagnostics", "Diagnostics overlay", "Live playback and performance figures", - SettingType::Toggle) - .expert(), - toggleSpec("settings/toneMappingVisualization", "Diagnostics", "Tone mapping overlay", - "False-colour view of how HDR is mapped", false, SettingTarget::ToneMappingVisualization) - .expert() - .onDesktop(), - pageSpec("shell/latencyGuard", "Diagnostics", "Record input latency", "", SettingType::Toggle).expert(), - pageSpec("shell/latencyOverlay", "Diagnostics", "Warn about slow input", "", SettingType::Toggle).expert(), - pageSpec("action/clearLatencyStatistics", "Diagnostics", "Clear latency samples", "", SettingType::Action) - .expert(), - - pageSpec("about/version", "About", "Spool for Jellyfin", "", SettingType::ReadOnly), - pageSpec("about/locale", "About", "Active language", "", SettingType::ReadOnly), - pageSpec("action/openSourceNotices", "About", "Open-source notices", - "Licences and source for the bundled software", SettingType::Action), - }; + pageSpec("action/downloads", "Downloads", "Downloads", "Manage downloads and play saved files offline", + SettingType::Action), + pageSpec("action/downloadDestination", "Downloads", "Download folder", "Applies only to new downloads", + SettingType::Action), + pageSpec("action/mobileDownloadDestination", "Downloads", "Download storage", + "Applies only to new downloads", SettingType::Action) + .advanced(), + + pageSpec("action/accounts", "Accounts", "Accounts", "", SettingType::Action), + pageSpec("action/providers", "Accounts", "Providers", "", SettingType::Action), + selectSpec("providers/updates", "Accounts", "Provider updates", "", "ask", kProviderUpdateChoices, + SettingTarget::External), + pageSpec("action/manageCertificates", "Accounts", "Remembered certificates", "", SettingType::Action), + + pageSpec("action/exportDiagnostics", "Diagnostics", "Export diagnostics", "", SettingType::Action), + pageSpec("action/clearLogs", "Diagnostics", "Clear logs", "", SettingType::Action), + toggleSpec("shell/diagnostics", "Diagnostics", "Diagnostics overlay", "", false, SettingTarget::External) + .expert(), + toggleSpec("settings/toneMappingVisualization", "Diagnostics", "Tone mapping overlay", + "False-colour view of how HDR is mapped", false, SettingTarget::ToneMappingVisualization) + .expert() + .onDesktop(), + toggleSpec( + "shell/latencyGuard", "Diagnostics", "Record input latency", "", true, SettingTarget::LatencyGuard) + .expert(), + toggleSpec("shell/latencyOverlay", "Diagnostics", "Warn about slow input", "", false, + SettingTarget::LatencyOverlay) + .expert(), + pageSpec("action/clearLatencyStatistics", "Diagnostics", "Clear latency samples", "", SettingType::Action) + .expert(), + + pageSpec("about/version", "About", "Spool", "", SettingType::ReadOnly), + pageSpec("about/locale", "About", "Active language", "", SettingType::ReadOnly), + pageSpec("action/openSourceNotices", "About", "Open-source notices", "", SettingType::Action), + }; + // Deliberate classification of today's rows. New rows remain Never until + // explicitly classified here; platform visibility does not confer consent. + const QStringList portable { QStringLiteral("i18n/locale"), QStringLiteral("theme/accent"), + QStringLiteral("theme/reducedMotion"), QStringLiteral("theme/technicalMetadata"), + QStringLiteral("theme/railLabels"), QStringLiteral("audio/language"), QStringLiteral("audio/trackMode"), + QStringLiteral("playback/rememberSeriesAudioTrack"), QStringLiteral("settings/nightMode"), + QStringLiteral("playback/preferRemux"), QStringLiteral("subtitles/language"), + QStringLiteral("subtitles/mode"), QStringLiteral("subtitles/styling"), + QStringLiteral("subtitles/scalePercent"), QStringLiteral("subtitles/verticalPositionPercent"), + QStringLiteral("subtitles/alwaysOverridePositionAndSize"), QStringLiteral("subtitles/allowInBlackBars"), + QStringLiteral("subtitles/overrideTextColor"), QStringLiteral("subtitles/textColor"), + QStringLiteral("subtitles/textWeight"), QStringLiteral("subtitles/font"), + QStringLiteral("subtitles/dropShadow"), QStringLiteral("subtitles/textBackground"), + QStringLiteral("subtitles/recolorImageSubtitles"), QStringLiteral("subtitles/bitmapSharpnessPercent"), + QStringLiteral("subtitles/bitmapShadowEnabled"), QStringLiteral("subtitles/bitmapShadowCoreSize"), + QStringLiteral("subtitles/bitmapShadowCoreGrow"), + QStringLiteral("subtitles/bitmapShadowCoreOpacityPercent"), + QStringLiteral("subtitles/bitmapShadowSpreadEnabled"), QStringLiteral("subtitles/bitmapShadowSpreadSize"), + QStringLiteral("subtitles/bitmapShadowSpreadGrow"), QStringLiteral("subtitles/bitmapShadowSpreadX"), + QStringLiteral("subtitles/bitmapShadowSpreadY"), + QStringLiteral("subtitles/bitmapShadowSpreadOpacityPercent"), + QStringLiteral("subtitles/bitmapShadowDither") }; + const QStringList device { QStringLiteral("artwork/format"), QStringLiteral("artwork/webpQuality"), + QStringLiteral("playback/maxStreamingHeight"), QStringLiteral("playback/manualStreamingBitrate"), + QStringLiteral("playback/maxStreamingBitrateMbps"), QStringLiteral("playback/unlimitedLocalBitrate"), + QStringLiteral("artwork/jpegQuality"), QStringLiteral("settings/audioDelayMs"), + QStringLiteral("playback/showVolumeSlider"), QStringLiteral("playback/controlFadeDelaySeconds"), + QStringLiteral("playback/seekPreviews"), QStringLiteral("playback/accurateTrickplay"), + QStringLiteral("playback/trickplayPreviewScalePercent"), QStringLiteral("playback/videoOutput"), + QStringLiteral("playback/softwareRenderer"), QStringLiteral("playback/hardwareDecoding"), + QStringLiteral("playback/renderQuality"), QStringLiteral("playback/autoAdjustQuality"), + QStringLiteral("playback/graphicsApi"), QStringLiteral("playback/hdrOutput"), + QStringLiteral("playback/hdrPeakNits"), QStringLiteral("settings/audioOutputMode"), + QStringLiteral("playback/forwardCacheSizeMiB"), QStringLiteral("subtitles/hdrBrightnessPercent"), + QStringLiteral("input/redButton"), QStringLiteral("input/greenButton"), + QStringLiteral("input/yellowButton"), QStringLiteral("input/blueButton"), + QStringLiteral("updates/automatic"), QStringLiteral("providers/updates"), + QStringLiteral("shell/diagnostics"), QStringLiteral("settings/toneMappingVisualization"), + QStringLiteral("shell/latencyGuard"), QStringLiteral("shell/latencyOverlay"), + QStringLiteral("theme/antialiasedText"), QStringLiteral("theme/renderMode") }; + for (SettingSpec& spec : rows) { + const QString key = QString::fromLatin1(spec.key); + if (portable.contains(key)) + spec.syncPolicy = SettingSyncPolicy::PortableDefault; + else if (device.contains(key)) + spec.syncPolicy = SettingSyncPolicy::DeviceOptIn; + switch (spec.target) { + case SettingTarget::AudioLanguage: + spec.nativePreference = "audioLanguage"; + break; + case SettingTarget::AudioTrackMode: + spec.nativePreference = "audioMode"; + break; + case SettingTarget::SubtitleLanguage: + spec.nativePreference = "subtitleLanguage"; + break; + case SettingTarget::SubtitleMode: + spec.nativePreference = "subtitleMode"; + break; + default: + break; + } + } + return rows; + }(); return specs; } @@ -605,6 +683,13 @@ QVariant settingDefaultValue(const SettingSpec& spec) QVariant normalizedSettingValue(const SettingSpec& spec, const QVariant& value) { + if (spec.target == SettingTarget::AudioLanguage || spec.target == SettingTarget::SubtitleLanguage) { + const QString code = value.toString().trimmed().toLower(); + if (code.isEmpty()) + return QString(); + const auto language = QLocale::codeToLanguage(code); + return language == QLocale::AnyLanguage ? QString() : QLocale::languageToCode(language, QLocale::ISO639Part2); + } switch (spec.normalizer) { case SettingNormalizer::Bool: return boolValue(value); @@ -643,6 +728,48 @@ QString serializedSettingValue(const SettingSpec& spec, const QVariant& value) return normalized.toString(); } +SettingSyncPolicy settingSyncPolicy(const SettingSpec& spec, const QVariant& value) +{ + if (spec.target == SettingTarget::SubtitleFont && value.toString().startsWith(QStringLiteral("system:"))) + return SettingSyncPolicy::DeviceOptIn; + return spec.syncPolicy; +} + +bool settingSupportedOnPlatform(const SettingSpec& spec) +{ + if (spec.platform == SettingPlatform::All) + return true; +#if defined(SPOOL_WEBOS) + return spec.platform == SettingPlatform::WebOS; +#elif defined(Q_OS_ANDROID) + return spec.platform == SettingPlatform::Android; +#else + return spec.platform == SettingPlatform::Desktop; +#endif +} + +bool settingAcceptsValue(const SettingSpec& spec, const QVariant& value) +{ + if (spec.type == SettingType::Toggle) + return value.metaType().id() == QMetaType::Bool; + if (spec.type == SettingType::Slider) { + if (value.metaType().id() == QMetaType::Bool || value.metaType().id() == QMetaType::QString) + return false; + bool ok = false; + const double number = value.toDouble(&ok); + return ok && std::isfinite(number) && number == normalizedSettingValue(spec, value).toInt(); + } + if (value.metaType().id() != QMetaType::QString) + return false; + return normalizedSettingValue(spec, value).toString() == value.toString(); +} + +bool settingAcceptsRemoteValue(const SettingSpec& spec, const QVariant& value) +{ + return spec.persisted && spec.syncPolicy != SettingSyncPolicy::Never && settingSupportedOnPlatform(spec) + && settingAcceptsValue(spec, value); +} + QVariantList settingSchemaModel() { QVariantList model; @@ -660,6 +787,11 @@ QVariantList settingSchemaModel() { QStringLiteral("dependsOnKey"), QLatin1String(spec.dependsOnKey) }, { QStringLiteral("dependsOnValue"), QLatin1String(spec.dependsOnValue) }, { QStringLiteral("requiresHdrPlayback"), spec.requiresHdrPlayback }, + { QStringLiteral("syncPolicy"), + spec.syncPolicy == SettingSyncPolicy::PortableDefault ? QStringLiteral("portable") + : spec.syncPolicy == SettingSyncPolicy::DeviceOptIn ? QStringLiteral("device") + : QStringLiteral("never") }, + { QStringLiteral("nativePreference"), QLatin1String(spec.nativePreference) }, { QStringLiteral("from"), spec.minimum }, { QStringLiteral("to"), spec.maximum }, { QStringLiteral("step"), spec.step }, { QStringLiteral("unitText"), QLatin1String(spec.unit) } }; QVariantList values; @@ -677,4 +809,4 @@ QVariantList settingSchemaModel() return model; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsSchema.h b/src/app/SettingsSchema.h index b3f59521..3b0bdfc3 100644 --- a/src/app/SettingsSchema.h +++ b/src/app/SettingsSchema.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { enum class SettingType { Action, @@ -28,10 +28,15 @@ enum class SettingPlatform { Android, }; +enum class SettingSyncPolicy { Never, PortableDefault, DeviceOptIn }; + enum class SettingTarget { External, + Locale, + LatencyGuard, + LatencyOverlay, + AudioLanguage, NightMode, - CastButtonEnabled, RemoteControlTargetEnabled, ToneMappingVisualization, MaxStreamingHeight, @@ -133,6 +138,8 @@ struct SettingSpec { const char *dependsOnValue = ""; bool persisted = true; bool requiresHdrPlayback = false; + SettingSyncPolicy syncPolicy = SettingSyncPolicy::Never; + const char *nativePreference = ""; // Declaration modifiers. Each returns a copy so specs read as one // expression: slider(...).advanced().onDesktop(). @@ -149,7 +156,14 @@ const QVector& settingSpecs(); const SettingSpec *findSettingSpec(const QString& key); QVariant settingDefaultValue(const SettingSpec& spec); QVariant normalizedSettingValue(const SettingSpec& spec, const QVariant& value); +SettingSyncPolicy settingSyncPolicy(const SettingSpec& spec, const QVariant& value); +bool settingSupportedOnPlatform(const SettingSpec& spec); +// Reject unsupported types and values rather than silently normalizing them to +// a clamped value or fallback. Transport admission is a separate policy. +bool settingAcceptsValue(const SettingSpec& spec, const QVariant& value); +// Cross-device writes additionally require persisted, syncable platform settings. +bool settingAcceptsRemoteValue(const SettingSpec& spec, const QVariant& value); QString serializedSettingValue(const SettingSpec& spec, const QVariant& value); QVariantList settingSchemaModel(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsSyncController.cpp b/src/app/SettingsSyncController.cpp new file mode 100644 index 00000000..a5cd06f5 --- /dev/null +++ b/src/app/SettingsSyncController.cpp @@ -0,0 +1,1267 @@ +#include "SettingsSyncController.h" + +#include "../cache/DatabaseManager.h" +#include "../common/AsyncTask.h" +#include "../provider/ProviderRegistry.h" +#include "SettingsController.h" +#include "SettingsSchema.h" + +#include +#include +#include +#include +#include +#include + +namespace Spool { +namespace { + namespace Doc = SettingsSyncDocument; + const QString Preferences = QStringLiteral("spool.playback-preferences"); + const QString Storage = QStringLiteral("spool.settings-storage"); + const QString DocumentId = QStringLiteral("278fca80-aaf9-4d32-8458-388836790234"); + const QString Scope = QStringLiteral("settings-sync"); + QString stateKey(const QString& account) + { + return QStringLiteral("settingsSync/state/") + account; + } + QString json(const QVariantMap& value) + { + return QString::fromUtf8(QJsonDocument::fromVariant(value).toJson(QJsonDocument::Compact)); + } + QVariantMap parseMap(const QString& text, int maximum = 256 * 1024) + { + if (text.isEmpty()) + return {}; + const auto bytes = text.toUtf8(); + if (bytes.size() > maximum) + throw std::runtime_error("oversized_local_sync_state"); + QJsonParseError error; + const auto document = QJsonDocument::fromJson(bytes, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + throw std::runtime_error("invalid_local_sync_state"); + return document.object().toVariantMap(); + } + QVariantMap entryMap(const Doc::Entry& entry) + { + return { { QStringLiteral("value"), entry.value }, { QStringLiteral("clock"), entry.clock }, + { QStringLiteral("nonce"), entry.nonce } }; + } + Doc::Entry mapEntry(const QVariantMap& value) + { + return { value.value(QStringLiteral("value")), value.value(QStringLiteral("clock")).toString(), + value.value(QStringLiteral("nonce")).toString() }; + } + bool offlineProblem(const QString& problem) + { + return problem == QStringLiteral("network_error") || problem == QStringLiteral("network_offline") + || problem == QStringLiteral("operation_timeout") || problem == QStringLiteral("source_unavailable"); + } +} + +SettingsSyncController::SettingsSyncController( + SettingsController *settings, DatabaseManager *database, ProviderRegistry *registry, QObject *parent) + : QObject(parent) + , m_settings(settings) + , m_database(database) + , m_registry(registry) +{ + m_clock.start(); + m_debounce.setSingleShot(true); + connect(&m_debounce, &QTimer::timeout, this, &SettingsSyncController::startCycle); + m_refreshTimer.setInterval(60000); + connect(&m_refreshTimer, &QTimer::timeout, this, &SettingsSyncController::refresh); + connect(settings, &SettingsController::userValuesCommitted, this, &SettingsSyncController::committed); + connect(settings, &SettingsController::settingsPersistenceFailed, this, &SettingsSyncController::persistenceFailed); + connect(settings, &SettingsController::userValuesCommitFailed, this, [this] { + if (m_pendingLocalCommits > 0) + --m_pendingLocalCommits; + }); + connect(registry, &ProviderRegistry::accountsChanged, this, &SettingsSyncController::reconcileAccounts); + connect(registry, &ProviderRegistry::restoredChanged, this, &SettingsSyncController::reconcileAccounts); + connect(registry, &ProviderRegistry::extensionsChanged, this, [this](const QString& account) { + if (account == m_accountId) { + invalidate(); + m_settings->cancelRemoteApplications({}, false); + m_nativeKnown = false; + m_nativeWritable.clear(); + m_confirmed.clear(); + } + reconcileAccounts(); + if (account == m_accountId) + schedule(); + }); + connect(registry, &ProviderRegistry::sourceStopped, this, [this](const QString& account) { + if (account == m_accountId) { + invalidate(); + m_settings->cancelRemoteApplications({}, false); + m_lastConnectionState.clear(); + m_nativeKnown = false; + m_nativeWritable.clear(); + m_confirmed.clear(); + emit changed(); + } + }); +} + +SettingsSyncController::~SettingsSyncController() +{ + m_registry->cancelSourceScope(m_accountId, Scope); +} + +QCoro::Task SettingsSyncController::loadLocalAsync() +{ + QPointer guard(this); + const auto controls = co_await m_database->loadValuesAsync({ QStringLiteral("settingsSync/enabled"), + QStringLiteral("settingsSync/accountId"), QStringLiteral("settingsSync/overrides") }); + if (!guard) + co_return; + m_enabled = controls.value(QStringLiteral("settingsSync/enabled"), QStringLiteral("true")).toString() + != QStringLiteral("false"); + m_accountId = controls.value(QStringLiteral("settingsSync/accountId")).toString(); + try { + m_overrides = parseMap(controls.value(QStringLiteral("settingsSync/overrides")).toString(), 64 * 1024); + if (!m_accountId.isEmpty()) { + const auto stored = co_await m_database->loadSettingAsync(stateKey(m_accountId)); + if (!guard) + co_return; + restoreLedger(stored); + } + } catch (const std::exception&) { + if (!guard) + co_return; + m_persistenceFailed = true; + m_problem = tr("Saved settings sync state is invalid. Turn sync off before linking again."); + } + m_loaded = true; + // Recovery consumes explicit unfinished applications, never a replica/local + // difference. In particular an off -> local locale edit -> restart is inert. + co_await m_settings->recoverUnfinishedApplications(); + if (!guard) + co_return; + reconcileAccounts(); + if (m_enabled && m_foreground) + m_refreshTimer.start(); + emit changed(); +} + +QVariantList SettingsSyncController::accounts() const +{ + QVariantList result; + for (const auto& item : m_registry->accounts()) { + auto row = item.toMap(); + const auto id = row.value(QStringLiteral("id")).toString(); + const auto *module = m_registry->module(row.value(QStringLiteral("moduleId")).toString()); + const bool declared = module + && (module->manifest.extensions.value(Preferences).toInt() == 1 + || module->manifest.extensions.value(Storage).toInt() == 1); + row.insert(QStringLiteral("syncSupported"), + row.value(QStringLiteral("connectionState")).toString() == QStringLiteral("active") + ? m_registry->extensionVersion(id, Preferences) == 1 || m_registry->extensionVersion(id, Storage) == 1 + : declared); + row.insert(QStringLiteral("syncLabel"), + row.value(QStringLiteral("providerName")).toString() + QStringLiteral(" — ") + + row.value(QStringLiteral("label")).toString()); + result.append(row); + } + return result; +} + +bool SettingsSyncController::accountActive() const +{ + for (const auto& item : m_registry->accounts()) { + const auto row = item.toMap(); + if (row.value(QStringLiteral("id")).toString() == m_accountId) + return row.value(QStringLiteral("enabled")).toBool() + && row.value(QStringLiteral("connectionState")).toString() == QStringLiteral("active"); + } + return false; +} + +bool SettingsSyncController::keyEnabled(const QString& key, const QVariant& value) const +{ + const auto *spec = findSettingSpec(key); + if (!spec || !settingSupportedOnPlatform(*spec)) + return false; + const auto policy = settingSyncPolicy(*spec, value); + if (policy == SettingSyncPolicy::Never) + return false; + const auto overrides = m_overrides.value(m_accountId).toMap(); + if (overrides.contains(key)) + return overrides.value(key).toBool(); + return policy == SettingSyncPolicy::PortableDefault; +} + +bool SettingsSyncController::valueSupported(const QString& key, const QVariant& value) const +{ + return m_settings->supportsSyncValue(key, value); +} + +bool SettingsSyncController::retainDocumentKey(const QString& key) +{ + if (const auto *spec = findSettingSpec(key)) + return spec->syncPolicy != SettingSyncPolicy::Never; + // Unknown future settings survive round trips, but reserved stores are never + // application settings even when a peer supplies them as document entries. + for (const auto& prefix : + { "settingsSync/", "settings/", "accounts/", "providers/", "credentials/", "origins/", "certificates/", + "trust/", "tls/", "client/", "device/", "uiSession/", "session/", "cache/", "diagnostics/" }) + if (key.startsWith(QLatin1String(prefix))) + return false; + if (key == QStringLiteral("deviceId") || key == QStringLiteral("deviceIdentity") + || key == QStringLiteral("providerAccounts") || key == QStringLiteral("token") + || key == QStringLiteral("password")) + return false; + return true; +} + +QString SettingsSyncController::backend(const QString& key) const +{ + const auto *spec = findSettingSpec(key); + if (!spec || spec->syncPolicy == SettingSyncPolicy::Never) + return QStringLiteral("none"); + if (spec->nativePreference[0] && !m_nativeKnown) + return QStringLiteral("none"); + if (m_nativeKnown && spec->nativePreference[0] + && m_nativeWritable.contains(QString::fromLatin1(spec->nativePreference))) + return QStringLiteral("native"); + if (m_storageAvailable) + return QStringLiteral("spool"); + return QStringLiteral("none"); +} + +QVariantMap SettingsSyncController::states() const +{ + QVariantMap result; + QString label; + for (const auto& item : accounts()) { + const auto row = item.toMap(); + if (row.value(QStringLiteral("id")).toString() == m_accountId) { + label = row.value(QStringLiteral("syncLabel")).toString(); + break; + } + } + const bool active = accountActive(); + for (const auto& spec : settingSpecs()) { + const auto key = QString::fromLatin1(spec.key); + const bool eligible = spec.syncPolicy != SettingSyncPolicy::Never && settingSupportedOnPlatform(spec); + const bool on = eligible && keyEnabled(key, m_settings->value(key)); + const auto channel = eligible ? backend(key) : QStringLiteral("none"); + const auto record = m_keys.value(key).toMap(); + const auto channelProblem = channel == QStringLiteral("native") ? m_nativeProblem : m_storageProblem; + QString status; + QString help; + if (!eligible || !m_enabled || !on) + status = QStringLiteral("off"); + else if (m_accountId.isEmpty()) + status = QStringLiteral("unsupported"); + else if (!active) + status = QStringLiteral("offline"); + else if (!m_persistenceFailed + && (offlineProblem(m_problem) + || (!channelProblem.isEmpty() + && (channel == QStringLiteral("native") ? m_nativeOffline : m_storageOffline)))) + status = QStringLiteral("offline"); + else if (m_persistenceFailed || !m_problem.isEmpty() + || (channel == QStringLiteral("native") && !m_nativeProblem.isEmpty()) + || (channel == QStringLiteral("spool") && !m_storageProblem.isEmpty())) + status = QStringLiteral("error"); + else if (channel == QStringLiteral("none") && m_nativeKnown) + status = QStringLiteral("unsupported"); + else if (m_replica.contains(key) + && (!valueSupported(key, m_replica.value(key).value) || !keyEnabled(key, m_replica.value(key).value))) + status = QStringLiteral("unsupported"); + else if (m_busy) + status = QStringLiteral("saving"); + else if (!m_confirmed.contains(key) || record.value(QStringLiteral("bootstrap"), true).toBool() + || record.contains(QStringLiteral("intent"))) + status = QStringLiteral("pending"); + else + status = QStringLiteral("synced"); + if (channel == QStringLiteral("native")) + help = tr("Changes the service's own preference."); + else if (channel == QStringLiteral("spool")) + help = tr("Spool-specific sync. Does not change the service's settings."); + else + help = tr("This account does not support synchronizing this setting."); + if (!label.isEmpty()) + help += QLatin1Char(' ') + label; + help += QLatin1Char(' ') + (status == QStringLiteral("off") ? tr("Local only.") : status); + if (!channelProblem.isEmpty()) + help += QLatin1Char(' ') + channelProblem; + if (!m_problem.isEmpty()) + help += QLatin1Char(' ') + m_problem; + result.insert(key, + QVariantMap { { QStringLiteral("eligible"), eligible }, { QStringLiteral("enabled"), on }, + { QStringLiteral("backend"), channel }, { QStringLiteral("status"), status }, + { QStringLiteral("help"), help } }); + } + return result; +} + +QString SettingsSyncController::status() const +{ + if (!m_enabled) + return QStringLiteral("off"); + if (m_persistenceFailed) + return QStringLiteral("saveFailed"); + if (m_accountId.isEmpty()) { + for (const auto& item : accounts()) + if (item.toMap().value(QStringLiteral("syncSupported")).toBool() + && item.toMap().value(QStringLiteral("connectionState")).toString() == QStringLiteral("starting")) + return QStringLiteral("starting"); + return QStringLiteral("noAccount"); + } + for (const auto& item : accounts()) { + const auto row = item.toMap(); + if (row.value(QStringLiteral("id")).toString() != m_accountId) + continue; + if (row.value(QStringLiteral("needsSignIn")).toBool()) + return QStringLiteral("signedOut"); + const auto connection = row.value(QStringLiteral("connectionState")).toString(); + if (connection == QStringLiteral("starting")) + return QStringLiteral("starting"); + if (connection == QStringLiteral("failed")) + return QStringLiteral("failed"); + if (!accountActive()) + return QStringLiteral("inactive"); + if (!row.value(QStringLiteral("syncSupported")).toBool()) + return QStringLiteral("unsupported"); + if (offlineProblem(m_problem) || m_nativeOffline || m_storageOffline) + return QStringLiteral("offline"); + if (!m_problem.isEmpty() || !m_nativeProblem.isEmpty() || !m_storageProblem.isEmpty()) + return QStringLiteral("error"); + if (busy()) + return QStringLiteral("syncing"); + for (const auto& value : m_keys) + if (value.toMap().contains(QStringLiteral("intent"))) + return QStringLiteral("pending"); + return QStringLiteral("synced"); + } + return QStringLiteral("removed"); +} + +QString SettingsSyncController::summary() const +{ + const auto state = status(); + if (state == QStringLiteral("off")) + return tr("Settings stay on this device."); + if (state == QStringLiteral("noAccount")) + return tr("Choose an account that supports settings sync."); + if (state == QStringLiteral("removed")) + return tr("Your sync account was removed. Choose another account."); + if (state == QStringLiteral("signedOut")) + return tr("Sign in to your sync account again to resume."); + if (state == QStringLiteral("starting")) + return tr("Connecting to your sync account…"); + if (state == QStringLiteral("inactive")) + return tr("Sync is paused while this account is not the active profile."); + if (state == QStringLiteral("failed")) + return tr("Cannot reach your sync account. Reconnect to resume."); + if (state == QStringLiteral("unsupported")) + return tr("This account does not support settings sync."); + if (state == QStringLiteral("offline")) + return tr("Changes are kept on this device until you are back online."); + if (state == QStringLiteral("saveFailed")) + return tr("Could not save settings on this device. Retry to recover."); + if (state == QStringLiteral("error")) + return tr("Could not sync settings. Your local settings are kept."); + if (state == QStringLiteral("syncing")) + return tr("Synchronizing settings…"); + if (state == QStringLiteral("pending")) + return tr("Local changes are waiting to sync."); + return tr("Settings sync is up to date."); +} + +QString SettingsSyncController::problemDetail() const +{ + QStringList details; + for (const auto& problem : { m_problem, m_nativeProblem, m_storageProblem }) + if (!problem.isEmpty()) + details.append(problem); + return details.join(QLatin1Char('\n')); +} + +bool SettingsSyncController::customized() const +{ + return !m_overrides.value(m_accountId).toMap().isEmpty(); +} + +QString SettingsSyncController::accountChangeWarning() const +{ + return tr("Changing the settings sync account discards unsent changes for the previous account. " + "The selected account's saved settings will be loaded first. Continue?"); +} + +quint64 SettingsSyncController::keyGeneration(const QString& key) const +{ + return m_keys.value(key).toMap().value(QStringLiteral("generation")).toString().toULongLong(); +} + +QVariantMap SettingsSyncController::serializedLedger() const +{ + if (m_accountId.isEmpty()) + return {}; + // A union of individually bounded remote winners can exceed the wire + // budget. Keep that bounded local union durably; encoding for upload then + // reports the size error without forgetting an acknowledged winner. + QVariantMap replica; + for (auto it = m_replica.cbegin(); it != m_replica.cend(); ++it) + if (retainDocumentKey(it.key())) + replica.insert(it.key(), entryMap(*it)); + const QVariantMap state { { QStringLiteral("format"), 1 }, { QStringLiteral("counter"), m_counter }, + { QStringLiteral("keys"), m_keys }, + { QStringLiteral("replica"), + QVariantMap { { QStringLiteral("format"), 1 }, { QStringLiteral("entries"), replica } } } }; + const auto serialized = json(state); + if (serialized.toUtf8().size() > 256 * 1024) + throw std::runtime_error("oversized_local_sync_state"); + return { { stateKey(m_accountId), serialized } }; +} + +QVariantMap SettingsSyncController::serializedControls() const +{ + return { { QStringLiteral("settingsSync/enabled"), m_enabled ? QStringLiteral("true") : QStringLiteral("false") }, + { QStringLiteral("settingsSync/accountId"), m_accountId }, + { QStringLiteral("settingsSync/overrides"), json(m_overrides) } }; +} + +void SettingsSyncController::restoreLedger(const QString& serialized) +{ + const auto state = parseMap(serialized); + if (state.isEmpty()) + return; + if (state.value(QStringLiteral("format")).toInt() != 1) + throw std::runtime_error("invalid_local_sync_format"); + const auto counter = state.value(QStringLiteral("counter")).toString(); + const auto replica = state.value(QStringLiteral("replica")).toMap(); + const auto replicaRows = replica.value(QStringLiteral("entries")).toMap(); + if (!Doc::validClock(counter) || replica.value(QStringLiteral("format")).toInt() != 1 || replicaRows.size() > 512) + throw std::runtime_error("invalid_local_sync_replica"); + m_counter = counter; + for (auto it = replicaRows.cbegin(); it != replicaRows.cend(); ++it) { + const auto decoded = Doc::decode(QVariantMap { { QStringLiteral("format"), 1 }, + { QStringLiteral("entries"), QVariantMap { { it.key(), it.value() } } } }, + retainDocumentKey); + if (decoded.status != Doc::DecodeStatus::Valid) + throw std::runtime_error("invalid_local_sync_entry"); + m_replica.insert(decoded.entries); + } + m_counter = Doc::maximumClock(m_replica, m_counter); + const auto keys = state.value(QStringLiteral("keys")).toMap(); + if (keys.size() > settingSpecs().size()) + throw std::runtime_error("invalid_local_sync_keys"); + for (auto it = keys.cbegin(); it != keys.cend(); ++it) { + const auto *spec = findSettingSpec(it.key()); + if (!spec || spec->syncPolicy == SettingSyncPolicy::Never) + continue; + auto row = it.value().toMap(); + const auto intent = row.value(QStringLiteral("intent")).toMap(); + if (row.contains(QStringLiteral("generation"))) { + bool valid = false; + row.value(QStringLiteral("generation")).toString().toULongLong(&valid); + if (!valid) + throw std::runtime_error("invalid_local_commit_generation"); + } + if (intent.contains(QStringLiteral("entry"))) { + const auto stamp = mapEntry(intent.value(QStringLiteral("entry")).toMap()); + if (!Doc::validClock(stamp.clock) || !Doc::validNonce(stamp.nonce)) + throw std::runtime_error("invalid_local_intent_stamp"); + } + if (!intent.isEmpty() && !valueSupported(it.key(), intent.value(QStringLiteral("value")))) + row.remove(QStringLiteral("intent")); + if (!m_enabled || !keyEnabled(it.key(), m_settings->value(it.key()))) { + row.remove(QStringLiteral("intent")); + row.insert(QStringLiteral("bootstrap"), true); + m_replica.remove(it.key()); + } + m_keys.insert(it.key(), row); + } +} + +void SettingsSyncController::resetKey(const QString& key, bool cancelApplications) +{ + auto row = m_keys.value(key).toMap(); + row.remove(QStringLiteral("intent")); + row.remove(QStringLiteral("policyPaused")); + row.insert(QStringLiteral("bootstrap"), true); + m_keys.insert(key, row); + m_replica.remove(key); + m_deferred.remove(key); + m_confirmed.remove(key); + if (cancelApplications) + m_settings->cancelRemoteApplications(key); +} + +void SettingsSyncController::invalidate() +{ + m_generation.invalidate(); + m_deferred.clear(); + m_debounce.stop(); + m_registry->cancelSourceScope(m_accountId, Scope); +} + +void SettingsSyncController::setEnabled(bool enabled) +{ + if (m_enabled == enabled) + return; + invalidate(); + m_enabled = enabled; + m_problem.clear(); + m_nativeProblem.clear(); + m_storageProblem.clear(); + m_persistenceFailed = false; + for (const auto& spec : settingSpecs()) + if (spec.syncPolicy != SettingSyncPolicy::Never) + resetKey(QString::fromLatin1(spec.key)); + if (enabled && m_foreground) + m_refreshTimer.start(); + else + m_refreshTimer.stop(); + persistControls(serializedLedger()); + emit changed(); +} + +void SettingsSyncController::setAccountId(const QString& id) +{ + if (id.isEmpty() || id == m_accountId) + return; + bool exists = false; + for (const auto& account : m_registry->accountList()) + if (account.id == id) + exists = true; + if (!exists) + return; + if (m_accountId.isEmpty()) { + selectAccount(id); + return; + } + m_pendingAccountId = id; + emit changed(); +} + +void SettingsSyncController::confirmAccountChange(bool approved) +{ + const auto id = std::exchange(m_pendingAccountId, {}); + if (approved && !id.isEmpty()) + selectAccount(id); + emit changed(); +} + +void SettingsSyncController::selectAccount(const QString& id) +{ + invalidate(); + QVariantMap cleared; + if (!m_accountId.isEmpty()) + cleared.insert(stateKey(m_accountId), QString()); + m_settings->cancelRemoteApplications(); + m_accountId = id; + m_keys.clear(); + m_replica.clear(); + m_counter = QStringLiteral("0"); + m_confirmed.clear(); + m_deferred.clear(); + m_nativeKnown = false; + m_nativeWritable.clear(); + m_storageAvailable = m_registry->extensionVersion(id, Storage) == 1; + m_lastConnectionState.clear(); + m_problem.clear(); + m_nativeProblem.clear(); + m_storageProblem.clear(); + m_persistenceFailed = false; + cleared.insert(stateKey(id), QString()); + persistControls(cleared); + emit changed(); +} + +void SettingsSyncController::setSettingEnabled(const QString& key, bool enabled) +{ + setSettingsEnabled({ key }, enabled); +} + +void SettingsSyncController::setSettingsEnabled(const QStringList& keys, bool enabled) +{ + if (m_accountId.isEmpty()) + return; + auto overrides = m_overrides.value(m_accountId).toMap(); + QStringList changedKeys; + for (const auto& key : keys) { + const auto *spec = findSettingSpec(key); + if (!spec || spec->syncPolicy == SettingSyncPolicy::Never || !settingSupportedOnPlatform(*spec) + || (overrides.contains(key) && overrides.value(key).toBool() == enabled)) + continue; + overrides.insert(key, enabled); + changedKeys.append(key); + } + if (changedKeys.isEmpty()) + return; + invalidate(); + m_overrides.insert(m_accountId, overrides); + for (const auto& key : changedKeys) + resetKey(key); + persistControls(serializedLedger()); + emit changed(); +} + +void SettingsSyncController::resetSettingOverrides() +{ + const auto overrides = m_overrides.value(m_accountId).toMap(); + if (overrides.isEmpty()) + return; + invalidate(); + m_overrides.remove(m_accountId); + for (auto it = overrides.cbegin(); it != overrides.cend(); ++it) + resetKey(it.key()); + persistControls(serializedLedger()); + emit changed(); +} + +void SettingsSyncController::persistControls(QVariantMap extra) +{ + auto serialized = serializedControls(); + for (auto it = extra.cbegin(); it != extra.cend(); ++it) + serialized.insert(it.key(), it.value()); + ++m_controlWrites; + Async::runScoped( + this, m_database->saveSettings(serialized), + [this] { + --m_controlWrites; + schedule(); + emit changed(); + }, + [this](const std::exception_ptr&) { + --m_controlWrites; + persistenceFailed(); + }, + "settings sync controls"); +} + +void SettingsSyncController::reconcileAccounts() +{ + if (!m_loaded) + return; + if (m_accountId.isEmpty()) { + // Registry order is persisted connection order, not name, last-used, or + // the order in which parallel describe requests happened to complete. + for (const auto& item : m_registry->accounts()) { + const auto row = item.toMap(); + if (!row.value(QStringLiteral("enabled")).toBool() && !row.value(QStringLiteral("pendingEnabled")).toBool()) + continue; + const auto state = row.value(QStringLiteral("connectionState")).toString(); + if (state == QStringLiteral("starting")) { + const auto *module = m_registry->module(row.value(QStringLiteral("moduleId")).toString()); + if (module + && (module->manifest.extensions.value(Preferences).toInt() == 1 + || module->manifest.extensions.value(Storage).toInt() == 1)) + break; + continue; + } + const auto id = row.value(QStringLiteral("id")).toString(); + if (state == QStringLiteral("active") + && (m_registry->extensionVersion(id, Preferences) == 1 + || m_registry->extensionVersion(id, Storage) == 1)) { + selectAccount(id); + break; + } + } + } else { + bool found = false; + for (const auto& item : m_registry->accounts()) { + const auto row = item.toMap(); + if (row.value(QStringLiteral("id")).toString() != m_accountId) + continue; + found = true; + const auto state = row.value(QStringLiteral("connectionState")).toString(); + m_storageAvailable = m_registry->extensionVersion(m_accountId, Storage) == 1; + if (state != m_lastConnectionState) { + invalidate(); + m_lastConnectionState = state; + m_confirmed.clear(); + if (state != QStringLiteral("active")) + m_settings->cancelRemoteApplications({}, false); + if (state == QStringLiteral("active")) + schedule(); + } + break; + } + if (!found && m_registry->restored()) { + invalidate(); + m_keys.clear(); + m_replica.clear(); + m_deferred.clear(); + m_confirmed.clear(); + m_settings->cancelRemoteApplications(); + // Keep a selected-account tombstone so removal cannot silently + // import a different person's settings on this or the next launch. + m_problem = tr("The settings sync account was removed. Choose a new sync account."); + persistControls({ { stateKey(m_accountId), QString() } }); + } + } + emit changed(); +} + +bool SettingsSyncController::current(Token token) const +{ + return m_generation.isCurrent(token) && m_enabled && m_foreground && accountActive(); +} + +void SettingsSyncController::schedule(int delay, bool manual) +{ + m_requested = true; + m_manualRequested = m_manualRequested || manual; + if (!m_loaded || !m_enabled || !m_foreground || !accountActive() || m_persistenceFailed || m_controlWrites + || m_pendingLocalCommits || m_busy) + return; + if (!m_manualRequested) + delay = std::max(delay, int(std::max(0, 30000 - (m_clock.elapsed() - m_lastFailure)))); + m_debounce.start(delay); +} + +void SettingsSyncController::refresh() +{ + if (m_clock.elapsed() - m_lastAttempt >= 60000) + schedule(); +} + +void SettingsSyncController::retry() +{ + if (m_persistenceFailed) { + ++m_controlWrites; + Async::runScoped( + this, m_settings->retryPendingPersistence(), + [this] { + --m_controlWrites; + m_persistenceFailed = false; + m_problem.clear(); + persistControls(serializedLedger()); + schedule(0, true); + }, + [this](const std::exception_ptr&) { + --m_controlWrites; + persistenceFailed(); + }, + "recover local settings transaction"); + return; + } + schedule(0, true); +} + +void SettingsSyncController::setForeground(bool foreground) +{ + if (m_foreground == foreground) + return; + m_foreground = foreground; + if (foreground) { + if (m_enabled) + m_refreshTimer.start(); + refresh(); + } else { + invalidate(); + m_settings->cancelRemoteApplications({}, false); + m_refreshTimer.stop(); + } +} + +void SettingsSyncController::startCycle() +{ + if (m_busy || !m_loaded || !m_enabled || !m_foreground || !accountActive() || m_controlWrites + || m_pendingLocalCommits || m_persistenceFailed) + return; + m_requested = false; + m_manualRequested = false; + m_busy = true; + m_lastAttempt = m_clock.elapsed(); + m_problem.clear(); + m_nativeProblem.clear(); + m_storageProblem.clear(); + m_nativeOffline = false; + m_storageOffline = false; + const auto token = m_generation.current(); + emit changed(); + Async::runScoped( + this, cycle(token), [this, token] { finishCycle(token); }, + [this, token](const std::exception_ptr& error) { finishCycle(token, exceptionMessage(error)); }, + "settings sync"); +} + +void SettingsSyncController::finishCycle(Token token, const QString& problem) +{ + m_busy = false; + if (m_generation.isCurrent(token)) { + if (!problem.isEmpty()) + m_problem = tr("Settings could not be synchronized. Retry. (%1)").arg(problem); + if (!m_problem.isEmpty() || !m_nativeProblem.isEmpty() || !m_storageProblem.isEmpty()) + m_lastFailure = m_clock.elapsed(); + } + emit changed(); + emit cycleFinished(); + if (m_requested) + schedule(500); +} + +void SettingsSyncController::setIntent(const QString& key, const QVariant& value, bool provisional) +{ + auto row = m_keys.value(key).toMap(); + QVariantMap intent { { QStringLiteral("value"), value }, + { QStringLiteral("generation"), row.value(QStringLiteral("generation"), QStringLiteral("0")) }, + { QStringLiteral("provisional"), provisional } }; + if (!provisional && backend(key) == QStringLiteral("spool")) { + const auto entry = Doc::makeEntry(value, m_counter); + m_counter = entry.clock; + intent.insert(QStringLiteral("entry"), entryMap(entry)); + m_replica.insert(key, entry); + } + row.insert(QStringLiteral("intent"), intent); + m_keys.insert(key, row); + m_confirmed.remove(key); +} + +QVariantMap SettingsSyncController::prepareLocalCommit(const QVariantMap& values) +{ + ++m_pendingLocalCommits; + for (auto it = values.cbegin(); it != values.cend(); ++it) { + const auto *spec = findSettingSpec(it.key()); + if (!spec || spec->syncPolicy == SettingSyncPolicy::Never) + continue; + auto row = m_keys.value(it.key()).toMap(); + row.insert(QStringLiteral("generation"), QString::number(keyGeneration(it.key()) + 1)); + m_keys.insert(it.key(), row); + m_deferred.remove(it.key()); + if (!m_enabled || m_accountId.isEmpty() || !keyEnabled(it.key(), it.value())) { + resetKey(it.key(), false); + if (spec->syncPolicy == SettingSyncPolicy::PortableDefault + && settingSyncPolicy(*spec, it.value()) == SettingSyncPolicy::DeviceOptIn) { + auto paused = m_keys.value(it.key()).toMap(); + paused.insert(QStringLiteral("policyPaused"), true); + m_keys.insert(it.key(), paused); + } + continue; + } + if (row.value(QStringLiteral("policyPaused")).toBool()) { + // Returning from a device font is a policy re-enable, not a new + // clocked preference that may defeat the requested remote baseline. + resetKey(it.key(), false); + continue; + } + const bool provisional + = row.value(QStringLiteral("bootstrap"), true).toBool() || backend(it.key()) == QStringLiteral("none"); + setIntent(it.key(), it.value(), provisional); + } + return serializedLedger(); +} + +void SettingsSyncController::committed(const QVariantMap&) +{ + if (m_pendingLocalCommits > 0) + --m_pendingLocalCommits; + schedule(500); + emit changed(); +} + +void SettingsSyncController::persistenceFailed() +{ + m_persistenceFailed = true; + invalidate(); + m_problem = tr("Settings could not be saved locally. Sync is paused; Retry after storage is available."); + emit changed(); +} + +bool SettingsSyncController::remoteApplicationAllowed(const QString& key, const QString& account) const +{ + return m_enabled && account == m_accountId && keyEnabled(key, m_settings->value(key)); +} + +void SettingsSyncController::beginEdit(const QString& key) +{ + m_editing.insert(key); +} + +void SettingsSyncController::endEdit(const QString& key, bool changedValue) +{ + m_editing.remove(key); + if (m_deferred.contains(key)) { + const auto value = m_deferred.take(key); + if (!changedValue && value.generation == keyGeneration(key)) { + Async::runScoped( + this, applyDeferred(key, value, m_generation.current()), [] {}, + [this](const std::exception_ptr&) { persistenceFailed(); }, "deferred settings sync"); + } + } + schedule(500); +} + +QCoro::Task SettingsSyncController::applyDeferred(QString key, Deferred value, Token token) +{ + if (!current(token) || value.generation != keyGeneration(key) || !keyEnabled(key, value.value) + || !valueSupported(key, value.value)) + co_return; + co_await m_settings->applyValues({ { key, value.value } }, ChangeOrigin::RemoteSync, serializedLedger()); +} + +QVariantMap SettingsSyncController::applications(const QVariantMap& candidates) +{ + QVariantMap apply; + for (auto it = candidates.cbegin(); it != candidates.cend(); ++it) { + if (!keyEnabled(it.key(), m_settings->value(it.key())) || !keyEnabled(it.key(), it.value()) + || !valueSupported(it.key(), it.value())) + continue; + const auto intent = m_keys.value(it.key()).toMap().value(QStringLiteral("intent")).toMap(); + if (!intent.isEmpty() && intent.value(QStringLiteral("value")) != it.value()) + continue; + if (m_editing.contains(it.key())) { + m_deferred.insert(it.key(), { it.value(), keyGeneration(it.key()) }); + continue; + } + apply.insert(it.key(), it.value()); + } + return apply; +} + +QCoro::Task SettingsSyncController::persistReplica(QVariantMap candidates, Token token) +{ + if (!current(token)) + co_return; + co_await m_settings->applyValues(applications(candidates), ChangeOrigin::RemoteSync, serializedLedger()); +} + +QCoro::Task SettingsSyncController::cycle(Token token) +{ + QPointer guard(this); + co_await m_settings->recoverUnfinishedApplications(); + if (!guard || !current(token)) + co_return; + if (m_registry->extensionVersion(m_accountId, Preferences) == 1) { + try { + co_await nativeCycle(token); + } catch (const std::exception& error) { + if (!guard || !current(token)) + co_return; + const QString code = QString::fromUtf8(error.what()); + m_nativeOffline = offlineProblem(code); + if (code == QStringLiteral("preferences_unavailable") || code == QStringLiteral("permission_denied")) { + // A known absent/read-denied native channel is not a writer. + // The storage path performs its own remote-first bootstrap. + m_nativeKnown = true; + m_nativeWritable.clear(); + } + m_nativeProblem = tr("Service preferences are unavailable. Retry. (%1)").arg(code); + } + if (!guard || !current(token)) + co_return; + } else { + m_nativeKnown = true; + m_nativeWritable.clear(); + } + if (m_storageAvailable) { + try { + co_await storageCycle(token); + } catch (const std::exception& error) { + if (!guard || !current(token)) + co_return; + m_storageOffline = offlineProblem(QString::fromUtf8(error.what())); + m_storageProblem + = tr("Spool-specific sync is unavailable. Retry. (%1)").arg(QString::fromUtf8(error.what())); + } + } +} + +QCoro::Task SettingsSyncController::nativeCycle(Token token) +{ + QPointer guard(this); + QMap readGenerations; + for (const auto& spec : settingSpecs()) + if (spec.nativePreference[0]) + readGenerations.insert(QString::fromLatin1(spec.key), keyGeneration(QString::fromLatin1(spec.key))); + const auto response + = co_await m_registry->callExtension(m_accountId, Preferences, QStringLiteral("preferencesRead"), {}, Scope); + if (!guard || !current(token)) + co_return; + m_nativeKnown = true; + m_nativeWritable.clear(); + for (const auto& value : response.value(QStringLiteral("writable")).toList()) + m_nativeWritable.insert(value.toString()); + const auto observed = response.value(QStringLiteral("values")).toMap(); + QVariantMap candidates; + for (const auto& spec : settingSpecs()) { + if (!spec.nativePreference[0]) + continue; + const auto key = QString::fromLatin1(spec.key); + const auto field = QString::fromLatin1(spec.nativePreference); + if (backend(key) != QStringLiteral("native") || !keyEnabled(key, m_settings->value(key))) + continue; + auto row = m_keys.value(key).toMap(); + if (row.value(QStringLiteral("backend")).toString() != QStringLiteral("native")) { + row.insert(QStringLiteral("backend"), QStringLiteral("native")); + row.insert(QStringLiteral("bootstrap"), true); + m_replica.remove(key); + } + const bool bootstrap = row.value(QStringLiteral("bootstrap"), true).toBool(); + row.insert(QStringLiteral("bootstrap"), false); + m_keys.insert(key, row); + auto intent = row.value(QStringLiteral("intent")).toMap(); + if (!intent.isEmpty()) { + intent.insert(QStringLiteral("provisional"), false); + intent.remove(QStringLiteral("entry")); + row.insert(QStringLiteral("intent"), intent); + m_keys.insert(key, row); + } else if (observed.contains(field)) { + if (readGenerations.value(key) == keyGeneration(key)) + candidates.insert(key, observed.value(field)); + } else if (bootstrap && valueSupported(key, m_settings->value(key))) { + setIntent(key, m_settings->value(key), false); + } + } + co_await persistReplica(candidates, token); + if (!guard || !current(token)) + co_return; + if (m_pendingLocalCommits || m_controlWrites) { + m_requested = true; + co_return; + } + + QVariantMap writeValues; + QMap captured; + for (const auto& spec : settingSpecs()) { + if (!spec.nativePreference[0]) + continue; + const auto key = QString::fromLatin1(spec.key); + if (backend(key) != QStringLiteral("native") || m_editing.contains(key) + || !keyEnabled(key, m_settings->value(key))) + continue; + const auto intent = m_keys.value(key).toMap().value(QStringLiteral("intent")).toMap(); + if (intent.isEmpty()) { + if (observed.contains(QString::fromLatin1(spec.nativePreference)) + && valueSupported(key, observed.value(QString::fromLatin1(spec.nativePreference)))) + m_confirmed.insert(key); + continue; + } + writeValues.insert(QString::fromLatin1(spec.nativePreference), intent.value(QStringLiteral("value"))); + captured.insert(key, keyGeneration(key)); + } + if (writeValues.isEmpty()) + co_return; + co_await m_registry->callExtension(m_accountId, Preferences, QStringLiteral("preferencesWrite"), + { { QStringLiteral("values"), writeValues } }, Scope); + if (!guard || !current(token)) + co_return; + const auto readback + = co_await m_registry->callExtension(m_accountId, Preferences, QStringLiteral("preferencesRead"), {}, Scope); + if (!guard || !current(token)) + co_return; + const auto actual = readback.value(QStringLiteral("values")).toMap(); + bool differed = false; + QSet acknowledged; + for (auto it = captured.cbegin(); it != captured.cend(); ++it) { + const auto *spec = findSettingSpec(it.key()); + const auto field = QString::fromLatin1(spec->nativePreference); + if (!actual.contains(field) || actual.value(field) != writeValues.value(field)) { + differed = true; + continue; + } + if (it.value() != keyGeneration(it.key())) + continue; + auto row = m_keys.value(it.key()).toMap(); + row.remove(QStringLiteral("intent")); + m_keys.insert(it.key(), row); + acknowledged.insert(it.key()); + } + // The exact generations are removed in the durable ledger before a green + // state can appear. A commit during this await still has its own new intent. + co_await persistReplica({}, token); + if (!guard || !current(token)) + co_return; + for (const auto& key : acknowledged) + if (captured.value(key) == keyGeneration(key)) + m_confirmed.insert(key); + if (differed) + throw std::runtime_error("native_preference_readback_differed"); +} + +void SettingsSyncController::observeDocument(const Entries& observed, const QMap& readGenerations) +{ + m_counter = Doc::maximumClock(observed, m_counter); + for (const auto& spec : settingSpecs()) { + const auto key = QString::fromLatin1(spec.key); + if (backend(key) != QStringLiteral("spool") || !keyEnabled(key, m_settings->value(key))) { + m_replica.remove(key); + continue; + } + auto row = m_keys.value(key).toMap(); + if (row.value(QStringLiteral("backend")).toString() != QStringLiteral("spool")) { + row.insert(QStringLiteral("backend"), QStringLiteral("spool")); + row.insert(QStringLiteral("bootstrap"), true); + } + const bool bootstrap = row.value(QStringLiteral("bootstrap"), true).toBool(); + if (bootstrap) { + m_replica.remove(key); + if (observed.contains(key)) + m_replica.insert(key, observed.value(key)); + row.insert(QStringLiteral("bootstrap"), false); + m_keys.insert(key, row); + const auto intent = row.value(QStringLiteral("intent")).toMap(); + if (!intent.isEmpty()) + setIntent(key, intent.value(QStringLiteral("value")), false); + else if (!observed.contains(key) && valueSupported(key, m_settings->value(key))) + setIntent(key, m_settings->value(key), false); + } else { + if (observed.contains(key) + && (!m_replica.contains(key) || Doc::compareStamps(observed.value(key), m_replica.value(key)) > 0)) + m_replica.insert(key, observed.value(key)); + const auto intent = row.value(QStringLiteral("intent")).toMap(); + if (intent.value(QStringLiteral("provisional")).toBool()) + setIntent(key, intent.value(QStringLiteral("value")), false); + } + // An explicit remote winner may outrank a previously committed local + // edit. It remains a retained maximum, not a new user commit/echo. + auto latest = m_keys.value(key).toMap(); + const auto intent = latest.value(QStringLiteral("intent")).toMap(); + if (intent.contains(QStringLiteral("entry")) && m_replica.contains(key) && readGenerations.contains(key) + && readGenerations.value(key) == keyGeneration(key) + && Doc::compareStamps(m_replica.value(key), mapEntry(intent.value(QStringLiteral("entry")).toMap())) > 0) { + latest.remove(QStringLiteral("intent")); + m_keys.insert(key, latest); + } + } +} + +SettingsSyncController::Entries SettingsSyncController::documentToWrite(const Entries& observed) const +{ + Entries result = observed; + for (const auto& spec : settingSpecs()) { + const auto key = QString::fromLatin1(spec.key); + if (!retainDocumentKey(key) || backend(key) == QStringLiteral("native")) { + result.remove(key); + continue; + } + if (backend(key) != QStringLiteral("spool") || !keyEnabled(key, m_settings->value(key)) + || m_editing.contains(key)) + continue; + const auto entry = m_replica.constFind(key); + // A system font without explicit opt-in is carried only from this + // latest server read; it must never be repaired from a retained copy. + if (entry == m_replica.cend() || !keyEnabled(key, entry->value)) + continue; + if (!result.contains(key) || Doc::compareStamps(*entry, result.value(key)) > 0) + result.insert(key, *entry); + } + return result; +} + +QCoro::Task SettingsSyncController::storageCycle(Token token) +{ + QPointer guard(this); + const auto info = co_await m_registry->callExtension(m_accountId, Storage, QStringLiteral("dataInfo"), {}, Scope); + if (!guard || !current(token)) + co_return; + const auto maximum = std::min(Doc::MaximumBytes, info.value(QStringLiteral("maxBytes")).toInt()); + if (maximum <= 0) + throw std::runtime_error("invalid_storage_limit"); + const bool conditional = info.value(QStringLiteral("conditionalWrites")).toBool(); + QMap readGenerations; + for (const auto& spec : settingSpecs()) + readGenerations.insert(QString::fromLatin1(spec.key), keyGeneration(QString::fromLatin1(spec.key))); + auto response = co_await m_registry->callExtension( + m_accountId, Storage, QStringLiteral("dataRead"), { { QStringLiteral("key"), DocumentId } }, Scope); + if (!guard || !current(token)) + co_return; + const auto decode = [maximum](const QVariantMap& data) { + if (!data.value(QStringLiteral("found")).toBool()) + return Entries {}; + const auto document = Doc::decode(data.value(QStringLiteral("value")), retainDocumentKey, maximum); + if (document.status == Doc::DecodeStatus::UnsupportedFormat) + throw std::runtime_error("Update Spool to read this settings document."); + if (document.status != Doc::DecodeStatus::Valid) + throw std::runtime_error("Malformed settings document; it has not been overwritten."); + return document.entries; + }; + auto observed = decode(response); + for (int attempt = 0; attempt != 2; ++attempt) { + observeDocument(observed, readGenerations); + QVariantMap candidates; + for (auto it = m_replica.cbegin(); it != m_replica.cend(); ++it) + candidates.insert(it.key(), it->value); + co_await persistReplica(candidates, token); + if (!guard || !current(token)) + co_return; + if (m_pendingLocalCommits || m_controlWrites) { + m_requested = true; + co_return; + } + const auto desired = documentToWrite(observed); + const auto encoded = Doc::encode(desired, retainDocumentKey, maximum); + const bool needsWrite = encoded != Doc::encode(observed, retainDocumentKey, maximum); + QMap captured; + for (auto it = desired.cbegin(); it != desired.cend(); ++it) + if (backend(it.key()) == QStringLiteral("spool") && keyEnabled(it.key(), m_settings->value(it.key())) + && !m_editing.contains(it.key())) + captured.insert(it.key(), keyGeneration(it.key())); + bool conflict = false; + if (needsWrite) { + QVariantMap args { { QStringLiteral("key"), DocumentId }, { QStringLiteral("value"), encoded } }; + if (conditional) { + if (response.value(QStringLiteral("found")).toBool()) { + const auto revision = response.value(QStringLiteral("revision")).toString(); + if (revision.isEmpty()) + throw std::runtime_error("missing_storage_revision"); + args.insert(QStringLiteral("expectedRevision"), revision); + } else + args.insert(QStringLiteral("expectedRevision"), QVariant::fromValue(nullptr)); + } + try { + co_await m_registry->callExtension(m_accountId, Storage, QStringLiteral("dataWrite"), args, Scope); + } catch (const std::exception& error) { + if (!conditional || !QString::fromUtf8(error.what()).contains(QStringLiteral("conflict"))) + throw; + conflict = true; + } + if (!guard || !current(token)) + co_return; + response = co_await m_registry->callExtension( + m_accountId, Storage, QStringLiteral("dataRead"), { { QStringLiteral("key"), DocumentId } }, Scope); + if (!guard || !current(token)) + co_return; + observed = decode(response); + readGenerations = captured; + } + // Always merge the readback before acknowledging. A lower or missing + // weak-store readback is not permission to forget the local maximum. + observeDocument(observed, readGenerations); + QSet acknowledged; + for (auto it = captured.cbegin(); it != captured.cend(); ++it) { + const auto entry = desired.constFind(it.key()); + const auto actual = observed.constFind(it.key()); + if (conflict || entry == desired.cend() || actual == observed.cend() + || Doc::compareStamps(*entry, *actual) != 0 || entry->value != actual->value + || it.value() != keyGeneration(it.key())) + continue; + auto row = m_keys.value(it.key()).toMap(); + const auto intent = row.value(QStringLiteral("intent")).toMap(); + if (!intent.isEmpty() + && (intent.value(QStringLiteral("value")) != entry->value || !intent.contains(QStringLiteral("entry")) + || Doc::compareStamps(mapEntry(intent.value(QStringLiteral("entry")).toMap()), *entry) != 0)) + continue; + row.remove(QStringLiteral("intent")); + m_keys.insert(it.key(), row); + if (keyEnabled(it.key(), actual->value) && valueSupported(it.key(), actual->value)) + acknowledged.insert(it.key()); + } + QVariantMap readbackCandidates; + for (auto it = m_replica.cbegin(); it != m_replica.cend(); ++it) + readbackCandidates.insert(it.key(), it->value); + co_await persistReplica(readbackCandidates, token); + if (!guard || !current(token)) + co_return; + for (const auto& key : acknowledged) + if (captured.value(key) == keyGeneration(key)) + m_confirmed.insert(key); + const auto repaired = Doc::encode(documentToWrite(observed), retainDocumentKey, maximum); + if (!conflict && repaired == Doc::encode(observed, retainDocumentKey, maximum)) + co_return; + if (m_pendingLocalCommits) { + m_requested = true; + co_return; + } + } + throw std::runtime_error("Settings changed concurrently. Pending changes are retained; Retry to converge."); +} + +} // namespace Spool diff --git a/src/app/SettingsSyncController.h b/src/app/SettingsSyncController.h new file mode 100644 index 00000000..fbf89b9a --- /dev/null +++ b/src/app/SettingsSyncController.h @@ -0,0 +1,166 @@ +#pragma once + +#include "../common/RequestGeneration.h" +#include "SettingsSyncDocument.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace Spool { +class SettingsController; +class DatabaseManager; +class ProviderRegistry; +struct SettingSpec; + +// One selected account owns both channels. The local ledger is committed in +// the same database transaction as user values; network work never owns values. +class SettingsSyncController final : public QObject { + Q_OBJECT + Q_PROPERTY(bool enabled READ enabled NOTIFY changed) + Q_PROPERTY(QString accountId READ accountId NOTIFY changed) + Q_PROPERTY(QVariantList accounts READ accounts NOTIFY changed) + Q_PROPERTY(QVariantMap states READ states NOTIFY changed) + Q_PROPERTY(QString summary READ summary NOTIFY changed) + Q_PROPERTY(QString status READ status NOTIFY changed) + Q_PROPERTY(QString problemDetail READ problemDetail NOTIFY changed) + Q_PROPERTY(bool customized READ customized NOTIFY changed) + Q_PROPERTY(bool accountChangePending READ accountChangePending NOTIFY changed) + Q_PROPERTY(QString pendingAccountId READ pendingAccountId NOTIFY changed) + Q_PROPERTY(QString accountChangeWarning READ accountChangeWarning NOTIFY changed) + Q_PROPERTY(bool busy READ busy NOTIFY changed) +public: + SettingsSyncController(SettingsController *, DatabaseManager *, ProviderRegistry *, QObject * = nullptr); + ~SettingsSyncController() override; + QCoro::Task loadLocalAsync(); + bool enabled() const + { + return m_enabled; + } + QString accountId() const + { + return m_accountId; + } + QVariantList accounts() const; + QVariantMap states() const; + QString summary() const; + QString status() const; + QString problemDetail() const; + bool customized() const; + bool accountChangePending() const + { + return !m_pendingAccountId.isEmpty(); + } + QString pendingAccountId() const + { + return m_pendingAccountId; + } + QString accountChangeWarning() const; + bool busy() const + { + return m_busy || m_controlWrites != 0; + } + + Q_INVOKABLE void setEnabled(bool enabled); + Q_INVOKABLE void setAccountId(const QString& accountId); + Q_INVOKABLE void confirmAccountChange(bool approved); + Q_INVOKABLE void setSettingEnabled(const QString& key, bool enabled); + Q_INVOKABLE void setSettingsEnabled(const QStringList& keys, bool enabled); + Q_INVOKABLE void resetSettingOverrides(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void refresh(); + Q_INVOKABLE void setForeground(bool foreground); + Q_INVOKABLE void beginEdit(const QString& key); + Q_INVOKABLE void endEdit(const QString& key, bool changed); + + // No I/O: SettingsController includes these serialized rows in its commit. + QVariantMap prepareLocalCommit(const QVariantMap& normalizedValues); + bool remoteApplicationAllowed(const QString& key, const QString& account) const; + bool remoteApplicationReady() const + { + return m_loaded && m_enabled && m_foreground && accountActive(); + } + +signals: + void changed(); + void cycleFinished(); + +private: + using Entries = SettingsSyncDocument::Entries; + using Token = RequestGeneration::Token; + struct Deferred { + QVariant value; + quint64 generation = 0; + }; + bool current(Token token) const; + bool accountActive() const; + bool keyEnabled(const QString& key, const QVariant& value) const; + bool valueSupported(const QString& key, const QVariant& value) const; + static bool retainDocumentKey(const QString& key); + QString backend(const QString& key) const; + quint64 keyGeneration(const QString& key) const; + QVariantMap serializedLedger() const; + QVariantMap serializedControls() const; + void restoreLedger(const QString& serialized); + void resetKey(const QString& key, bool cancelApplications = true); + void invalidate(); + void reconcileAccounts(); + void selectAccount(const QString& accountId); + void persistControls(QVariantMap extra = {}); + void schedule(int delay = 0, bool manual = false); + void startCycle(); + void finishCycle(Token token, const QString& problem = {}); + void committed(const QVariantMap& values); + void persistenceFailed(); + void setIntent(const QString& key, const QVariant& value, bool provisional); + void observeDocument(const Entries& observed, const QMap& readGenerations); + Entries documentToWrite(const Entries& observed) const; + QVariantMap applications(const QVariantMap& candidates); + QCoro::Task persistReplica(QVariantMap candidates, Token token); + QCoro::Task cycle(Token token); + QCoro::Task nativeCycle(Token token); + QCoro::Task storageCycle(Token token); + QCoro::Task applyDeferred(QString key, Deferred value, Token token); + + SettingsController *m_settings; + DatabaseManager *m_database; + ProviderRegistry *m_registry; + RequestGeneration m_generation; + QTimer m_debounce; + QTimer m_refreshTimer; + QElapsedTimer m_clock; + qint64 m_lastAttempt = -60000; + qint64 m_lastFailure = -30000; + bool m_enabled = true; + bool m_loaded = false; + bool m_foreground = true; + bool m_busy = false; + bool m_requested = false; + bool m_manualRequested = false; + bool m_persistenceFailed = false; + int m_controlWrites = 0; + int m_pendingLocalCommits = 0; + QString m_accountId; + QString m_pendingAccountId; + QString m_problem; + QString m_nativeProblem; + QString m_storageProblem; + QVariantMap m_overrides; + QVariantMap m_keys; + Entries m_replica; + QString m_counter = QStringLiteral("0"); + QSet m_nativeWritable; + bool m_nativeKnown = false; + bool m_storageAvailable = false; + bool m_nativeOffline = false; + bool m_storageOffline = false; + QSet m_editing; + QMap m_deferred; + QSet m_confirmed; + QString m_lastConnectionState; +}; +} // namespace Spool diff --git a/src/app/SettingsSyncDocument.cpp b/src/app/SettingsSyncDocument.cpp new file mode 100644 index 00000000..f091477f --- /dev/null +++ b/src/app/SettingsSyncDocument.cpp @@ -0,0 +1,284 @@ +#include "SettingsSyncDocument.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace Spool::SettingsSyncDocument { +namespace { + + bool numericType(int type) + { + return type == QMetaType::Int || type == QMetaType::UInt || type == QMetaType::LongLong + || type == QMetaType::ULongLong || type == QMetaType::Double || type == QMetaType::Float; + } + + // Validate types/depth and account for exact compact JSON bytes before creating + // the document. Stop as soon as the budget is exceeded, including large lists. + bool consumeJson(const QVariant& value, int depth, qsizetype& remaining) + { + if (depth > MaximumDepth || remaining < 0) + return false; + const int type = value.metaType().id(); + if (type == QMetaType::QVariantMap) { + remaining -= 2; + const QVariantMap map = value.toMap(); + bool first = true; + for (auto it = map.cbegin(); it != map.cend(); ++it) { + remaining -= first ? 1 : 2; // colon, and comma after the first member + first = false; + if (!consumeJson(it.key(), depth + 1, remaining) || !consumeJson(it.value(), depth + 1, remaining)) + return false; + } + } else if (type == QMetaType::QVariantList) { + remaining -= 2; + const QVariantList list = value.toList(); + bool first = true; + for (const QVariant& item : list) { + if (!first) + --remaining; + first = false; + if (!consumeJson(item, depth + 1, remaining)) + return false; + } + } else if (!value.isValid() || type == QMetaType::Nullptr) { + remaining -= 4; + } else if (type == QMetaType::QString) { + // Each UTF-16 code unit takes at least one JSON byte. This precheck + // avoids allocating a JSON encoding for an already oversized string. + if (value.toString().size() > remaining) + return false; + remaining + -= QJsonDocument(QJsonArray { QJsonValue(value.toString()) }).toJson(QJsonDocument::Compact).size() - 2; + } else if (type == QMetaType::Bool || numericType(type)) { + if (numericType(type) && !std::isfinite(value.toDouble())) + return false; + remaining + -= QJsonDocument(QJsonArray { QJsonValue::fromVariant(value) }).toJson(QJsonDocument::Compact).size() + - 2; + } else { + // QVariant's implicit conversions must not silently turn dates, paths, + // bytes or custom metatypes into uploadable JSON strings. + return false; + } + return remaining >= 0; + } + + bool boundedJson(const QVariant& value, qsizetype maximumBytes) + { + if (maximumBytes <= 0) + return false; + qsizetype remaining = qMin(maximumBytes, MaximumBytes); + return consumeJson(value, 0, remaining); + } + + QStringView significantDigits(const QString& clock) + { + qsizetype first = 0; + while (first + 1 < clock.size() && clock.at(first) == u'0') + ++first; + return QStringView(clock).mid(first); + } + + DecodeResult malformed(const QString& problem) + { + return { DecodeStatus::Malformed, {}, problem }; + } + + bool keep(const KeyFilter& filter, const QString& key) + { + return !filter || filter(key); + } + +} // namespace + +bool validClock(const QString& clock) +{ + if (clock.isEmpty()) + return false; + for (const QChar digit : clock) { + if (digit < u'0' || digit > u'9') + return false; + } + return true; +} + +bool validNonce(const QString& nonce) +{ + if (nonce.size() != 32) + return false; + for (const QChar digit : nonce) { + if (!((digit >= u'0' && digit <= u'9') || (digit >= u'a' && digit <= u'f'))) + return false; + } + return true; +} + +DecodeResult decode(const QVariant& document, const KeyFilter& retainKey, qsizetype maximumBytes) +{ + if (!boundedJson(document, maximumBytes)) + return malformed(QStringLiteral("Settings sync data exceeds its bounds or is not valid JSON.")); + if (document.metaType().id() != QMetaType::QVariantMap) + return malformed(QStringLiteral("Settings sync data must be an object.")); + const QVariantMap root = document.toMap(); + const QVariant format = root.value(QStringLiteral("format")); + if (!numericType(format.metaType().id()) || format.toDouble() < 1 + || std::floor(format.toDouble()) != format.toDouble()) + return malformed(QStringLiteral("Settings sync data has an invalid format.")); + if (format.toDouble() != 1) + return { DecodeStatus::UnsupportedFormat, {}, + QStringLiteral("Update Spool to read this settings sync format.") }; + const QVariant rawEntries = root.value(QStringLiteral("entries")); + if (rawEntries.metaType().id() != QMetaType::QVariantMap) + return malformed(QStringLiteral("Settings sync entries must be an object.")); + Entries entries; + const QVariantMap rows = rawEntries.toMap(); + for (auto it = rows.cbegin(); it != rows.cend(); ++it) { + if (it.key().isEmpty() || it.value().metaType().id() != QMetaType::QVariantMap) + return malformed(QStringLiteral("Settings sync contains an invalid entry.")); + const QVariantMap row = it.value().toMap(); + const QVariant clock = row.value(QStringLiteral("clock")); + const QVariant nonce = row.value(QStringLiteral("nonce")); + if (!row.contains(QStringLiteral("value")) || clock.metaType().id() != QMetaType::QString + || !validClock(clock.toString()) || nonce.metaType().id() != QMetaType::QString + || !validNonce(nonce.toString())) + return malformed(QStringLiteral("Settings sync contains an invalid value or stamp.")); + if (keep(retainKey, it.key())) + entries.insert(it.key(), { row.value(QStringLiteral("value")), clock.toString(), nonce.toString() }); + } + return { DecodeStatus::Valid, std::move(entries), {} }; +} + +DecodeResult decodeJson(const QByteArray& json, const KeyFilter& retainKey, qsizetype maximumBytes) +{ + if (maximumBytes <= 0 || json.size() > qMin(maximumBytes, MaximumBytes)) + return malformed(QStringLiteral("Settings sync data exceeds its size limit.")); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(json, &error); + if (error.error != QJsonParseError::NoError) + return malformed(QStringLiteral("Settings sync data is malformed JSON.")); + return decode(document.toVariant(), retainKey, maximumBytes); +} + +QVariantMap encode(const Entries& entries, const KeyFilter& retainKey, qsizetype maximumBytes) +{ + QVariantMap rows; + // Include the fixed envelope before copying any entries into the result. + constexpr qsizetype envelopeBytes = sizeof("{\"format\":1,\"entries\":{}}") - 1; + qsizetype remaining = qMin(maximumBytes, MaximumBytes) - envelopeBytes; + if (remaining < 0) + throw std::invalid_argument("Settings sync document exceeds its size limit"); + for (auto it = entries.cbegin(); it != entries.cend(); ++it) { + if (!keep(retainKey, it.key())) + continue; + const Entry& entry = it.value(); + if (it.key().isEmpty() || !validClock(entry.clock) || !validNonce(entry.nonce)) + throw std::invalid_argument("Invalid settings sync entry stamp"); + const QVariantMap row { { QStringLiteral("value"), entry.value }, { QStringLiteral("clock"), entry.clock }, + { QStringLiteral("nonce"), entry.nonce } }; + remaining -= rows.isEmpty() ? 1 : 2; // colon, then comma between entries + if (!consumeJson(it.key(), 2, remaining) || !consumeJson(row, 2, remaining)) + throw std::invalid_argument("Settings sync document exceeds bounds or contains non-JSON values"); + rows.insert(it.key(), row); + } + return { { QStringLiteral("format"), 1 }, { QStringLiteral("entries"), rows } }; +} + +int compareClocks(const QString& left, const QString& right) +{ + if (!validClock(left) || !validClock(right)) + throw std::invalid_argument("Invalid settings sync clock"); + const QStringView a = significantDigits(left); + const QStringView b = significantDigits(right); + if (a.size() != b.size()) + return a.size() < b.size() ? -1 : 1; + const int order = a.compare(b); + return order < 0 ? -1 : order > 0 ? 1 : 0; +} + +int compareStamps(const Entry& left, const Entry& right) +{ + if (!validNonce(left.nonce) || !validNonce(right.nonce)) + throw std::invalid_argument("Invalid settings sync nonce"); + const int clocks = compareClocks(left.clock, right.clock); + if (clocks != 0) + return clocks; + const int order = left.nonce.compare(right.nonce); + return order < 0 ? -1 : order > 0 ? 1 : 0; +} + +Entries merge(const Entries& retained, const Entries& observed, const KeyFilter& retainKey) +{ + Entries result; + const auto add = [&](const Entries& source) { + for (auto it = source.cbegin(); it != source.cend(); ++it) { + if (!keep(retainKey, it.key())) + continue; + const auto existing = result.constFind(it.key()); + if (existing == result.cend() || compareStamps(it.value(), existing.value()) > 0) + result.insert(it.key(), it.value()); + } + }; + add(retained); + add(observed); + return result; +} + +QString maximumClock(const Entries& entries, QString persistedCounter) +{ + if (!validClock(persistedCounter)) + throw std::invalid_argument("Invalid persisted settings sync counter"); + for (const Entry& entry : entries) { + if (compareClocks(entry.clock, persistedCounter) > 0) + persistedCounter = entry.clock; + } + return significantDigits(persistedCounter).toString(); +} + +QString nextClock(const QString& observedMaximum) +{ + if (!validClock(observedMaximum)) + throw std::invalid_argument("Invalid settings sync clock"); + QString result = significantDigits(observedMaximum).toString(); + for (qsizetype i = result.size(); i > 0; --i) { + if (result.at(i - 1) != u'9') { + result[i - 1] = QChar(result.at(i - 1).unicode() + 1); + return result; + } + result[i - 1] = u'0'; + } + result.prepend(u'1'); + return result; +} + +QString randomNonce() +{ + static constexpr char digits[] = "0123456789abcdef"; + QString nonce(32, u'0'); + for (qsizetype word = 0; word < 4; ++word) { + quint32 random = QRandomGenerator::system()->generate(); + for (qsizetype digit = 0; digit < 8; ++digit) { + nonce[word * 8 + digit] = QLatin1Char(digits[random & 0xf]); + random >>= 4; + } + } + return nonce; +} + +Entry makeEntry(QVariant value, const QString& observedMaximum, QString nonce) +{ + if (nonce.isEmpty()) + nonce = randomNonce(); + if (!validNonce(nonce)) + throw std::invalid_argument("Invalid settings sync nonce"); + return { std::move(value), nextClock(observedMaximum), std::move(nonce) }; +} + +} // namespace Spool::SettingsSyncDocument diff --git a/src/app/SettingsSyncDocument.h b/src/app/SettingsSyncDocument.h new file mode 100644 index 00000000..f804049c --- /dev/null +++ b/src/app/SettingsSyncDocument.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace Spool::SettingsSyncDocument { + +inline constexpr qsizetype MaximumBytes = 64 * 1024; +inline constexpr int MaximumDepth = 16; + +struct Entry { + QVariant value; + QString clock; + QString nonce; +}; +using Entries = QMap; +// Return false for known Never/sensitive keys; unknown future keys should return true. +using KeyFilter = std::function; + +enum class DecodeStatus { Valid, UnsupportedFormat, Malformed }; +struct DecodeResult { + DecodeStatus status = DecodeStatus::Malformed; + Entries entries; + QString problem; +}; + +DecodeResult decode(const QVariant& document, const KeyFilter& retainKey = {}, qsizetype maximumBytes = MaximumBytes); +DecodeResult decodeJson(const QByteArray& json, const KeyFilter& retainKey = {}, qsizetype maximumBytes = MaximumBytes); +// Throws std::invalid_argument on invalid entries or bounds; never truncates data. +QVariantMap encode(const Entries& entries, const KeyFilter& retainKey = {}, qsizetype maximumBytes = MaximumBytes); + +bool validClock(const QString& clock); +bool validNonce(const QString& nonce); +// Clock comparison is arbitrary precision; valid decimal strings are required. +int compareClocks(const QString& left, const QString& right); +int compareStamps(const Entry& left, const Entry& right); +// Equal stamps keep the retained entry. Missing/lower observations cannot erase it. +Entries merge(const Entries& retained, const Entries& observed, const KeyFilter& retainKey = {}); +QString maximumClock(const Entries& entries, QString persistedCounter = QStringLiteral("0")); +QString nextClock(const QString& observedMaximum); +// Four independent system-generated 32-bit words: no UUID version/variant bits. +QString randomNonce(); +// The caller persists the returned clock with its intent; inject a nonce for tests. +Entry makeEntry(QVariant value, const QString& observedMaximum, QString nonce = {}); + +} // namespace Spool::SettingsSyncDocument diff --git a/src/app/SpoolLink.cpp b/src/app/SpoolLink.cpp deleted file mode 100644 index 40322d9b..00000000 --- a/src/app/SpoolLink.cpp +++ /dev/null @@ -1,606 +0,0 @@ -#include "SpoolLink.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace JellyfinNative { -namespace { - - constexpr int kKeyBytes = 32; - - // Long enough that a punched pinhole cannot be reached by chance, short - // enough that a stalled handshake gives up while the user still cares. - constexpr int kProbeIntervalMs = 250; - constexpr int kProbeGiveUpMs = 6'000; - constexpr int kKeepaliveIntervalMs = 10'000; - // Most consumer NATs hold a UDP mapping for 30 s or more; a peer that has - // answered nothing for this long is gone. - constexpr int kPeerLostMs = 32'000; - - // The floor is a display refresh: sending faster cannot change what the - // other end draws. The ceiling keeps a slow link from queueing without - // bound. - constexpr int kMinPacingMs = 16; - constexpr int kMaxPacingMs = 250; - - const QString kFromField = QStringLiteral("From"); - const QString kKeyField = QStringLiteral("Key"); - const QString kCandidatesField = QStringLiteral("Candidates"); - - // Public STUN, used only to learn this device's own reflexive address for - // IPv4. Nothing about the session is sent. - const char *kStunHost = "stun.l.google.com"; - constexpr quint16 kStunPort = 19302; - constexpr quint32 kStunCookie = 0x2112A442; - - bool isUsableCandidateAddress(const QHostAddress& address) - { - if (address.isNull() || address.isLoopback() || address.isLinkLocal() || address.isMulticast()) - return false; - // A unique-local or global v6 address is reachable between peers; a v4 - // private address only helps when both sit on the same network, which - // is exactly the case worth trying first. - return true; - } - - QByteArray stunBindingRequest(const QByteArray& transaction) - { - QByteArray request(20, '\0'); - qToBigEndian(0x0001, request.data()); - qToBigEndian(0, request.data() + 2); - qToBigEndian(kStunCookie, request.data() + 4); - std::copy(transaction.cbegin(), transaction.cend(), request.begin() + 8); - return request; - } - -} // namespace - -SpoolLink::SpoolLink(JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_api(api) -{ - m_clock.start(); - - m_probeTimer.setInterval(kProbeIntervalMs); - connect(&m_probeTimer, &QTimer::timeout, this, &SpoolLink::sendProbes); - - m_keepaliveTimer.setInterval(kKeepaliveIntervalMs); - connect(&m_keepaliveTimer, &QTimer::timeout, this, &SpoolLink::sendKeepalive); - - m_flushTimer.setSingleShot(true); - connect(&m_flushTimer, &QTimer::timeout, this, &SpoolLink::flushOutbox); -} - -SpoolLink::~SpoolLink() = default; - -void SpoolLink::setLocalSessionId(const QString& sessionId) -{ - m_localSessionId = sessionId; -} - -void SpoolLink::setState(State state) -{ - if (m_state == state) - return; - m_state = state; - emit stateChanged(); -} - -void SpoolLink::setRoundTripMs(int roundTripMs) -{ - if (m_roundTripMs == roundTripMs) - return; - m_roundTripMs = roundTripMs; - emit roundTripChanged(); -} - -void SpoolLink::reset() -{ - stopProbing(); - m_keepaliveTimer.stop(); - m_peerSessionId.clear(); - m_key.clear(); - m_offered = false; - m_peerCandidates.clear(); - m_peerEndpoint = {}; - m_reliableOutbox.clear(); - m_coalescedOutbox.clear(); - m_replayWindow.reset(); - m_bestRoundTripMs = -1; - m_lastPongMs = 0; - m_clockOffsetMs.reset(); - setRoundTripMs(-1); - setState(State::Idle); -} - -bool SpoolLink::ensureSocket() -{ - if (m_socket) - return true; - - m_socket = new QUdpSocket(this); - // One dual-stack socket, so a v6 path and a v4 path share a port and the - // peer only has to be told one number per address family. - if (!m_socket->bind(QHostAddress(QHostAddress::Any), 0, QAbstractSocket::DefaultForPlatform)) { - qInfo() << "spool link: could not bind a local socket:" << m_socket->errorString(); - delete m_socket; - m_socket = nullptr; - return false; - } - connect(m_socket, &QUdpSocket::readyRead, this, &SpoolLink::readDatagrams); - return true; -} - -void SpoolLink::gatherCandidates() -{ - m_localCandidates.clear(); - if (!m_socket) - return; - const quint16 port = m_socket->localPort(); - for (const QHostAddress& address : QNetworkInterface::allAddresses()) { - if (!isUsableCandidateAddress(address)) - continue; - Endpoint endpoint; - // Qt keeps the scope id on link-local and some global v6 addresses; it - // is meaningless to the peer and breaks parsing on the far side. - endpoint.address = QHostAddress(address.toString().section(QLatin1Char('%'), 0, 0)); - endpoint.port = port; - if (endpoint.isValid() && !m_localCandidates.contains(endpoint)) - m_localCandidates.push_back(endpoint); - } -} - -void SpoolLink::requestReflexiveCandidate() -{ - if (!m_socket) - return; - m_stunTransaction.resize(12); - QRandomGenerator::system()->fillRange(reinterpret_cast(m_stunTransaction.data()), 3); - // Sent from the same socket the peer will talk to, so the mapping STUN - // reports is the mapping the peer can reach. - QHostInfo::lookupHost(QString::fromLatin1(kStunHost), this, [this](const QHostInfo& info) { - if (!m_socket || info.addresses().isEmpty() || m_stunTransaction.isEmpty()) - return; - for (const QHostAddress& address : info.addresses()) { - if (address.protocol() == QAbstractSocket::IPv4Protocol) { - m_socket->writeDatagram(stunBindingRequest(m_stunTransaction), address, kStunPort); - return; - } - } - }); -} - -void SpoolLink::connectToPeer(const QString& sessionId) -{ - if (sessionId.isEmpty() || sessionId == m_localSessionId) { - reset(); - return; - } - if (sessionId == m_peerSessionId && m_state != State::Idle) - return; - - const bool sameSession = sessionId == m_peerSessionId; - reset(); - m_peerSessionId = sessionId; - if (!ensureSocket()) - return; - - gatherCandidates(); - requestReflexiveCandidate(); - - m_key.resize(kKeyBytes); - QRandomGenerator::system()->fillRange(reinterpret_cast(m_key.data()), kKeyBytes / 4); - m_offered = true; - - // Give a reflexive candidate a moment to arrive, then offer whatever was - // gathered. Local addresses alone are enough on a shared network. - QTimer::singleShot(sameSession ? 0 : 400, this, [this]() { - if (!m_peerSessionId.isEmpty() && m_offered) - sendOffer(); - }); -} - -void SpoolLink::sendOffer() -{ - if (m_localSessionId.isEmpty() || m_peerSessionId.isEmpty() || m_localCandidates.isEmpty()) - return; - QStringList candidates; - candidates.reserve(m_localCandidates.size()); - for (const Endpoint& endpoint : m_localCandidates) - candidates.push_back(endpoint.toString()); - - SpoolRemoteProtocol::Message offer { SpoolRemoteProtocol::Command::LinkOffer, - { - { kFromField, m_localSessionId }, - { kKeyField, QString::fromLatin1(m_key.toBase64()) }, - { kCandidatesField, candidates.join(QLatin1Char(' ')) }, - } }; - deliverViaServer(m_peerSessionId, offer); - beginProbing(); -} - -void SpoolLink::sendAnswer() -{ - if (m_localSessionId.isEmpty() || m_peerSessionId.isEmpty() || m_localCandidates.isEmpty()) - return; - QStringList candidates; - candidates.reserve(m_localCandidates.size()); - for (const Endpoint& endpoint : m_localCandidates) - candidates.push_back(endpoint.toString()); - - SpoolRemoteProtocol::Message answer { SpoolRemoteProtocol::Command::LinkAnswer, - { - { kFromField, m_localSessionId }, - { kCandidatesField, candidates.join(QLatin1Char(' ')) }, - } }; - deliverViaServer(m_peerSessionId, answer); -} - -bool SpoolLink::handleServerMessage(const SpoolRemoteProtocol::Message& message) -{ - const bool isOffer = message.command == SpoolRemoteProtocol::Command::LinkOffer; - const bool isAnswer = message.command == SpoolRemoteProtocol::Command::LinkAnswer; - if (!isOffer && !isAnswer) - return false; - - const QString from = message.text(kFromField); - if (from.isEmpty()) - return true; - - QList candidates; - const QStringList encoded = message.text(kCandidatesField).split(QLatin1Char(' '), Qt::SkipEmptyParts); - for (const QString& text : encoded) { - if (const std::optional endpoint = Endpoint::fromString(text)) - candidates.push_back(*endpoint); - } - if (candidates.isEmpty()) - return true; - - if (isOffer) { - const QByteArray key = QByteArray::fromBase64(message.text(kKeyField).toLatin1()); - if (key.size() != kKeyBytes) - return true; - // The offering side owns the key, so a simultaneous offer from both - // ends settles on one of them rather than on two different keys. - if (m_offered && m_localSessionId > from) - return true; - if (!ensureSocket()) - return true; - stopProbing(); - m_peerSessionId = from; - m_key = key; - m_offered = false; - m_peerEndpoint = {}; - m_replayWindow.reset(); - m_peerCandidates = candidates; - gatherCandidates(); - sendAnswer(); - beginProbing(); - return true; - } - - if (from != m_peerSessionId || m_key.isEmpty()) - return true; - m_peerCandidates = candidates; - beginProbing(); - return true; -} - -void SpoolLink::beginProbing() -{ - if (m_peerCandidates.isEmpty() || m_key.isEmpty() || !m_socket) - return; - if (m_state != State::Connected) - setState(State::Probing); - m_probeTimer.start(); - sendProbes(); - QTimer::singleShot(kProbeGiveUpMs, this, [this]() { - if (m_state == State::Probing) { - // Nothing answered. The server path stays in service, so this is a - // missed optimisation rather than a failure. - qInfo() << "spool link: no direct path to" << m_peerSessionId << "- staying on the server"; - stopProbing(); - setState(State::Idle); - } - }); -} - -void SpoolLink::stopProbing() -{ - m_probeTimer.stop(); -} - -void SpoolLink::sendProbes() -{ - if (m_state == State::Connected) { - stopProbing(); - return; - } - // Both ends probe at once: that simultaneous exchange is what opens the - // pinhole each side's firewall needs for the other's datagrams. - for (const Endpoint& candidate : m_peerCandidates) - transmit(Datagram::Probe, {}, candidate); -} - -void SpoolLink::sendKeepalive() -{ - if (m_state != State::Connected) - return; - if (m_lastPongMs > 0 && m_clock.elapsed() - m_lastPongMs > kPeerLostMs) { - qInfo() << "spool link: peer stopped answering, falling back to the server"; - const QString peer = m_peerSessionId; - reset(); - connectToPeer(peer); - return; - } - m_pingSentMs = m_clock.elapsed(); - QByteArray payload(8, '\0'); - qToBigEndian(m_pingSentMs, payload.data()); - transmit(Datagram::Ping, payload, m_peerEndpoint); -} - -bool SpoolLink::transmit(Datagram type, const QByteArray& payload, const Endpoint& to) -{ - if (!m_socket || m_key.isEmpty() || !to.isValid()) - return false; - const QByteArray datagram = SpoolLinkCodec::seal(m_key, type, m_nextSequence++, payload); - return m_socket->writeDatagram(datagram, to.address, to.port) == datagram.size(); -} - -void SpoolLink::readDatagrams() -{ - while (m_socket && m_socket->hasPendingDatagrams()) { - QByteArray buffer(int(m_socket->pendingDatagramSize()), '\0'); - QHostAddress sender; - quint16 senderPort = 0; - const qint64 read = m_socket->readDatagram(buffer.data(), buffer.size(), &sender, &senderPort); - if (read <= 0) - continue; - buffer.truncate(int(read)); - - if (buffer.size() >= 20 && (static_cast(buffer[0]) & 0xC0) == 0) { - handleStunResponse(buffer); - continue; - } - Endpoint from; - from.address = sender; - from.port = senderPort; - handleLinkDatagram(buffer, from); - } -} - -void SpoolLink::handleStunResponse(const QByteArray& datagram) -{ - if (qFromBigEndian(datagram.constData()) != 0x0101) - return; - if (datagram.mid(8, 12) != m_stunTransaction) - return; - - qsizetype offset = 20; - // The declared length, never trusted past what actually arrived. - const qsizetype length - = std::min(qFromBigEndian(datagram.constData() + 2) + 20, datagram.size()); - while (offset + 4 <= length) { - const quint16 type = qFromBigEndian(datagram.constData() + offset); - const quint16 size = qFromBigEndian(datagram.constData() + offset + 2); - const qsizetype valueAt = offset + 4; - if (valueAt + size > datagram.size()) - return; - - // XOR-MAPPED-ADDRESS. The obfuscation exists so middleboxes cannot - // rewrite the address they are reporting. - if (type == 0x0020 && size >= 8) { - const quint8 family = static_cast(datagram[valueAt + 1]); - const quint16 port - = qFromBigEndian(datagram.constData() + valueAt + 2) ^ quint16(kStunCookie >> 16); - if (family == 0x01) { - const quint32 address = qFromBigEndian(datagram.constData() + valueAt + 4) ^ kStunCookie; - Endpoint reflexive; - reflexive.address = QHostAddress(address); - reflexive.port = port; - if (reflexive.isValid() && !m_localCandidates.contains(reflexive)) { - m_localCandidates.push_back(reflexive); - qInfo() << "spool link: reflexive candidate" << reflexive.toString(); - } - } - return; - } - offset = valueAt + ((size + 3) & ~3); - } -} - -void SpoolLink::handleLinkDatagram(const QByteArray& datagram, const Endpoint& from) -{ - Datagram type {}; - QByteArray payload; - quint64 sequence = 0; - if (!SpoolLinkCodec::open(m_key, datagram, type, payload, sequence) || !m_replayWindow.accept(sequence)) - return; - - const qint64 now = m_clock.elapsed(); - switch (type) { - case Datagram::Probe: - // Answer from the address it arrived on, which is the mapping that - // actually works rather than the one the peer guessed. - transmit(Datagram::ProbeAck, {}, from); - break; - - case Datagram::ProbeAck: - if (m_state != State::Connected) { - m_peerEndpoint = from; - m_lastPongMs = now; - stopProbing(); - setState(State::Connected); - qInfo() << "spool link: direct path to" << m_peerSessionId << "via" << from.toString(); - sendKeepalive(); - flushOutbox(); - } - break; - - case Datagram::Ping: { - if (payload.size() < 8) - break; - QByteArray reply(24, '\0'); - // t1 as the peer sent it, then our receive and send instants: enough - // for the far side to solve for offset and round trip together. - std::copy(payload.cbegin(), payload.cbegin() + 8, reply.begin()); - qToBigEndian(now, reply.data() + 8); - qToBigEndian(m_clock.elapsed(), reply.data() + 16); - transmit(Datagram::Pong, reply, from); - break; - } - - case Datagram::Pong: { - if (payload.size() < 24) - break; - const qint64 t1 = qFromBigEndian(payload.constData()); - const qint64 t2 = qFromBigEndian(payload.constData() + 8); - const qint64 t3 = qFromBigEndian(payload.constData() + 16); - const qint64 t4 = now; - m_lastPongMs = now; - noteExchange((t4 - t1) - (t3 - t2), ((t2 - t1) + (t3 - t4)) / 2); - break; - } - - case Datagram::Payload: - if (const std::optional message = SpoolRemoteProtocol::deserialize(payload)) { - emit messageReceived(*message); - } - break; - } -} - -void SpoolLink::noteExchange(qint64 roundTripMs, qint64 offsetMs) -{ - if (roundTripMs < 0) - return; - setRoundTripMs(int(std::min(roundTripMs, kMaxPacingMs * 4))); - // Offset error is bounded by how unevenly the two legs were delayed, so - // the sample with the smallest round trip is the most trustworthy one - // rather than the average of many. - if (m_bestRoundTripMs < 0 || roundTripMs <= m_bestRoundTripMs) { - m_bestRoundTripMs = roundTripMs; - m_clockOffsetMs = offsetMs; - } -} - -int SpoolLink::pacingIntervalMs() const -{ - // Sending faster than the path can answer only builds a queue; slower than - // a frame cannot be seen. Between those, follow the measurement. - const int measured = m_roundTripMs >= 0 ? m_roundTripMs : kMaxPacingMs; - return std::clamp(measured, kMinPacingMs, kMaxPacingMs); -} - -void SpoolLink::send(const QString& sessionId, const SpoolRemoteProtocol::Message& message, Delivery delivery) -{ - if (sessionId.isEmpty() || !message.isValid()) - return; - // Only the selected peer has a direct path; anything else is the server's - // job and goes out immediately. - if (sessionId != m_peerSessionId) { - deliverViaServer(sessionId, message); - return; - } - - if (delivery == Delivery::Coalesced) { - const auto stale = std::find_if(m_coalescedOutbox.begin(), m_coalescedOutbox.end(), - [&message](const SpoolRemoteProtocol::Message& queued) { return queued.command == message.command; }); - if (stale != m_coalescedOutbox.end()) - *stale = message; - else - m_coalescedOutbox.push_back(message); - } else { - m_reliableOutbox.push_back(message); - } - scheduleFlush(); -} - -// Always returns through the event loop, never straight into flushOutbox(). -// A synchronous hand-back would let the two call each other without ever -// unwinding, which wedges the thread that draws the interface. -void SpoolLink::scheduleFlush() -{ - if (m_flushTimer.isActive()) - return; - const qint64 sinceLast = m_clock.elapsed() - m_lastFlushMs; - const int interval = pacingIntervalMs(); - m_flushTimer.start(int(std::max(0, interval - sinceLast))); -} - -void SpoolLink::flushOutbox() -{ - if (m_reliableOutbox.isEmpty() && m_coalescedOutbox.isEmpty()) - return; - - if (connected()) { - // The direct path has no outstanding-request limit worth enforcing: - // a datagram is gone the moment it is written. - const QList pending = m_reliableOutbox + m_coalescedOutbox; - m_reliableOutbox.clear(); - m_coalescedOutbox.clear(); - m_lastFlushMs = m_clock.elapsed(); - for (const SpoolRemoteProtocol::Message& message : pending) - transmit(Datagram::Payload, SpoolRemoteProtocol::serialize(message), m_peerEndpoint); - return; - } - - // Through the server, one request at a time. More than that and the - // replies queue behind each other and arrive long after they mattered. - // Nothing is scheduled here: the request in flight rearms the flush when - // it finishes, which is the only moment the answer can change. - if (m_serverSendInFlight) - return; - const SpoolRemoteProtocol::Message message - = m_reliableOutbox.isEmpty() ? m_coalescedOutbox.takeFirst() : m_reliableOutbox.takeFirst(); - m_lastFlushMs = m_clock.elapsed(); - deliverViaServer(m_peerSessionId, message); -} - -void SpoolLink::deliverViaServer(const QString& sessionId, const SpoolRemoteProtocol::Message& message) -{ - if (!m_api || sessionId.isEmpty()) - return; - const bool paced = sessionId == m_peerSessionId; - if (paced) - m_serverSendInFlight = true; - const qint64 sentAtMs = m_clock.elapsed(); - - Async::runScoped( - this, - m_api->sendRemoteGeneralCommand( - sessionId, SpoolRemoteProtocol::envelopeCommand(), SpoolRemoteProtocol::encode(message)), - [this, paced, sentAtMs]() { - if (!paced) - return; - m_serverSendInFlight = false; - // With no direct path, the server round trip is the pace the link - // can actually keep. - if (!connected()) - setRoundTripMs(int(m_clock.elapsed() - sentAtMs)); - if (!m_reliableOutbox.isEmpty() || !m_coalescedOutbox.isEmpty()) - scheduleFlush(); - }, - [this, paced](const std::exception_ptr&) { - if (!paced) - return; - m_serverSendInFlight = false; - if (!m_reliableOutbox.isEmpty() || !m_coalescedOutbox.isEmpty()) - scheduleFlush(); - }, - "spool link server message"); -} - -} // namespace JellyfinNative diff --git a/src/app/SpoolLink.h b/src/app/SpoolLink.h deleted file mode 100644 index e7a46c98..00000000 --- a/src/app/SpoolLink.h +++ /dev/null @@ -1,172 +0,0 @@ -#pragma once - -#include "SpoolLinkCodec.h" -#include "SpoolRemoteProtocol.h" - -#include -#include -#include -#include -#include -#include -#include - -#include - -class QUdpSocket; - -namespace JellyfinNative { - -class JellyfinApiFacade; - -// Carries Spool-to-Spool messages by whichever path is available: a direct -// datagram link to the peer when one can be opened, the Jellyfin server -// otherwise. Callers hand a message to send() and never learn which was used. -// -// Two clients in the same country talking through a server on another -// continent pay two intercontinental legs for every message. A direct path -// turns a ~500 ms round trip into a local one, which is what makes scrubbing -// feel attached to the finger and what a clock comparison needs to be worth -// anything. -// -// The peers find each other over the server, which is already the trusted, -// authenticated channel between them: it relays the shared key and the -// candidate addresses. That grants the server nothing it did not already -// have, since it can command both clients directly. -class SpoolLink final : public QObject { - Q_OBJECT - -public: - enum class State { - Idle, - Probing, - Connected, - }; - - // Reliable messages are always delivered. Coalesced ones are only worth - // sending while they are current: a newer message with the same command - // replaces one still waiting, because showing the position before last is - // worse than showing nothing. - enum class Delivery { - Reliable, - Coalesced, - }; - - explicit SpoolLink(JellyfinApiFacade *api, QObject *parent = nullptr); - ~SpoolLink() override; - - // This client's own session, so a peer knows where to answer. - void setLocalSessionId(const QString& sessionId); - QString localSessionId() const - { - return m_localSessionId; - } - - // Open (or keep) a direct path to this peer. Repeat calls for the peer - // already selected are ignored, so this is safe to call on every refresh. - void connectToPeer(const QString& sessionId); - void reset(); - - void send( - const QString& sessionId, const SpoolRemoteProtocol::Message& message, Delivery delivery = Delivery::Reliable); - - // A message the server delivered. Returns true when it was link - // signalling, which the application must not see. - bool handleServerMessage(const SpoolRemoteProtocol::Message& message); - - State state() const - { - return m_state; - } - bool connected() const - { - return m_state == State::Connected; - } - - // Round trip on whichever path is carrying messages, in milliseconds, or - // -1 before anything has been measured. - int roundTripMs() const - { - return m_roundTripMs; - } - - // How far the peer's monotonic clock reads ahead of ours, from the - // exchange with the lowest round trip seen. The basis for lining up two - // devices' playback clocks. - std::optional clockOffsetMs() const - { - return m_clockOffsetMs; - } - -signals: - void messageReceived(const SpoolRemoteProtocol::Message& message); - void stateChanged(); - void roundTripChanged(); - -private: - using Endpoint = SpoolLinkCodec::Endpoint; - using Datagram = SpoolLinkCodec::Datagram; - - bool ensureSocket(); - void gatherCandidates(); - void requestReflexiveCandidate(); - void sendOffer(); - void sendAnswer(); - void beginProbing(); - void stopProbing(); - void setState(State state); - void setRoundTripMs(int roundTripMs); - - void readDatagrams(); - void handleStunResponse(const QByteArray& datagram); - void handleLinkDatagram(const QByteArray& datagram, const Endpoint& from); - - bool transmit(Datagram type, const QByteArray& payload, const Endpoint& to); - - void sendProbes(); - void sendKeepalive(); - void noteExchange(qint64 roundTripMs, qint64 offsetMs); - - void flushOutbox(); - void scheduleFlush(); - int pacingIntervalMs() const; - void deliverViaServer(const QString& sessionId, const SpoolRemoteProtocol::Message& message); - - JellyfinApiFacade *m_api = nullptr; - QUdpSocket *m_socket = nullptr; - - QString m_localSessionId; - QString m_peerSessionId; - QByteArray m_key; - // Set on the side that generated the key, which is the side that answers - // an offer rather than making one if both happen to start at once. - bool m_offered = false; - - QList m_localCandidates; - QList m_peerCandidates; - Endpoint m_peerEndpoint; - - QTimer m_probeTimer; - QTimer m_keepaliveTimer; - QTimer m_flushTimer; - QElapsedTimer m_clock; - QByteArray m_stunTransaction; - - // Newest message per command, plus everything that must not be dropped. - QList m_reliableOutbox; - QList m_coalescedOutbox; - bool m_serverSendInFlight = false; - qint64 m_lastFlushMs = 0; - - quint64 m_nextSequence = 1; - SpoolLinkCodec::ReplayWindow m_replayWindow; - qint64 m_pingSentMs = 0; - qint64 m_lastPongMs = 0; - qint64 m_bestRoundTripMs = -1; - - State m_state = State::Idle; - int m_roundTripMs = -1; - std::optional m_clockOffsetMs; -}; - -} // namespace JellyfinNative diff --git a/src/app/SpoolLinkCodec.cpp b/src/app/SpoolLinkCodec.cpp deleted file mode 100644 index bc7f0169..00000000 --- a/src/app/SpoolLinkCodec.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "SpoolLinkCodec.h" - -#include -#include -#include - -#include - -namespace JellyfinNative { -namespace { - - constexpr char kMagic[4] = { 'S', 'P', 'L', 'K' }; - constexpr quint8 kVersion = 1; - constexpr int kHeaderBytes = 16; - constexpr int kTagBytes = 32; - -} // namespace - -QString SpoolLinkCodec::Endpoint::toString() const -{ - return address.protocol() == QAbstractSocket::IPv6Protocol - ? QStringLiteral("[%1]:%2").arg(address.toString()).arg(port) - : QStringLiteral("%1:%2").arg(address.toString()).arg(port); -} - -std::optional SpoolLinkCodec::Endpoint::fromString(const QString& text) -{ - const int separator = text.lastIndexOf(QLatin1Char(':')); - if (separator <= 0) - return std::nullopt; - - QString host = text.left(separator); - if (host.startsWith(QLatin1Char('[')) && host.endsWith(QLatin1Char(']'))) - host = host.mid(1, host.size() - 2); - - bool ok = false; - const uint port = text.mid(separator + 1).toUInt(&ok); - if (!ok || port == 0 || port > 65535) - return std::nullopt; - - Endpoint endpoint; - if (!endpoint.address.setAddress(host)) - return std::nullopt; - endpoint.port = static_cast(port); - return endpoint; -} - -QByteArray SpoolLinkCodec::seal(const QByteArray& key, Datagram type, quint64 sequence, const QByteArray& payload) -{ - QByteArray datagram; - datagram.reserve(kHeaderBytes + payload.size() + kTagBytes); - datagram.append(kMagic, 4); - datagram.append(static_cast(kVersion)); - datagram.append(static_cast(type)); - datagram.append(2, '\0'); - - QByteArray sequenceBytes(8, '\0'); - qToBigEndian(sequence, sequenceBytes.data()); - datagram.append(sequenceBytes); - datagram.append(payload); - - QMessageAuthenticationCode code(QCryptographicHash::Sha256, key); - code.addData(datagram); - datagram.append(code.result()); - return datagram; -} - -bool SpoolLinkCodec::open( - const QByteArray& key, const QByteArray& datagram, Datagram& type, QByteArray& payload, quint64& sequence) -{ - if (datagram.size() < kHeaderBytes + kTagBytes || key.isEmpty()) - return false; - if (!std::equal(kMagic, kMagic + 4, datagram.cbegin()) || static_cast(datagram[4]) != kVersion) - return false; - - const QByteArray body = datagram.left(datagram.size() - kTagBytes); - QMessageAuthenticationCode code(QCryptographicHash::Sha256, key); - code.addData(body); - // Constant time: comparing tags with an early exit would leak how much of - // a forged one was right. - const QByteArray expected = code.result(); - const QByteArray actual = datagram.right(kTagBytes); - if (expected.size() != actual.size()) - return false; - quint8 difference = 0; - for (int index = 0; index < expected.size(); ++index) - difference |= static_cast(expected[index]) ^ static_cast(actual[index]); - if (difference != 0) - return false; - - type = static_cast(static_cast(datagram[5])); - sequence = qFromBigEndian(datagram.constData() + 8); - payload = body.mid(kHeaderBytes); - return true; -} - -void SpoolLinkCodec::ReplayWindow::reset() -{ - m_highest = 0; - m_seen = 0; -} - -bool SpoolLinkCodec::ReplayWindow::accept(quint64 sequence) -{ - constexpr quint64 kWindow = 64; - if (sequence == 0) - return false; - if (sequence > m_highest) { - const quint64 shift = sequence - m_highest; - m_seen = shift >= kWindow ? 0 : (m_seen << shift); - m_seen |= 1; - m_highest = sequence; - return true; - } - const quint64 age = m_highest - sequence; - if (age >= kWindow) - return false; - const quint64 bit = quint64(1) << age; - if (m_seen & bit) - return false; - m_seen |= bit; - return true; -} - -} // namespace JellyfinNative diff --git a/src/app/SpoolLinkCodec.h b/src/app/SpoolLinkCodec.h deleted file mode 100644 index 5b2cb56d..00000000 --- a/src/app/SpoolLinkCodec.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -namespace JellyfinNative { - -// The wire format, with no I/O in it. Kept apart from the socket so the -// framing, the authentication and the replay rule can be tested directly -// rather than through two devices and a network. -namespace SpoolLinkCodec { - - enum class Datagram : quint8 { - Probe = 1, - ProbeAck = 2, - Payload = 3, - Ping = 4, - Pong = 5, - }; - - struct Endpoint { - QHostAddress address; - quint16 port = 0; - - bool operator==(const Endpoint& other) const - { - return port == other.port && address.isEqual(other.address); - } - bool isValid() const - { - return port != 0 && !address.isNull(); - } - QString toString() const; - static std::optional fromString(const QString& text); - }; - - QByteArray seal(const QByteArray& key, Datagram type, quint64 sequence, const QByteArray& payload); - bool open( - const QByteArray& key, const QByteArray& datagram, Datagram& type, QByteArray& payload, quint64& sequence); - - // Accepts each sequence number once. A datagram that arrives out of order - // is still new; one that arrives twice never is. - class ReplayWindow { - public: - bool accept(quint64 sequence); - void reset(); - - private: - quint64 m_highest = 0; - quint64 m_seen = 0; - }; - -} // namespace SpoolLinkCodec - -} // namespace JellyfinNative diff --git a/src/app/SpoolRemoteProtocol.cpp b/src/app/SpoolRemoteProtocol.cpp deleted file mode 100644 index 813d1cfe..00000000 --- a/src/app/SpoolRemoteProtocol.cpp +++ /dev/null @@ -1,180 +0,0 @@ -#include "SpoolRemoteProtocol.h" - -#include - -#include - -namespace JellyfinNative::SpoolRemoteProtocol { -namespace { - - const QString kProtocolKey = QStringLiteral("SpoolProtocol"); - const QString kCommandKey = QStringLiteral("SpoolCommand"); - const QString kStringKey = QStringLiteral("String"); - const QString kProtocolVersion = QStringLiteral("1"); - - bool isEnvelopeKey(const QString& key) - { - return key == kProtocolKey || key == kCommandKey || key == kStringKey; - } - - // Jellyfin rejects a command whose arguments are not all strings, so every - // field is written as one and read back with the type it was given. - QString fieldToString(const QVariant& value) - { - if (value.typeId() == QMetaType::Bool) - return value.toBool() ? QStringLiteral("true") : QStringLiteral("false"); - return value.toString(); - } - -} // namespace - -QString envelopeCommand() -{ - return QStringLiteral("SendString"); -} - -QString Message::text(const QString& key) const -{ - return fields.value(key).toString(); -} - -std::optional Message::number(const QString& key) const -{ - const auto field = fields.constFind(key); - if (field == fields.constEnd()) - return std::nullopt; - bool ok = false; - const qint64 value = field->toString().toLongLong(&ok); - if (!ok) - return std::nullopt; - return value; -} - -std::optional Message::flag(const QString& key) const -{ - const QString value = fields.value(key).toString(); - if (value == QLatin1String("true")) - return true; - if (value == QLatin1String("false")) - return false; - return std::nullopt; -} - -QJsonObject encode(const Message& message) -{ - QJsonObject arguments { - { kProtocolKey, kProtocolVersion }, - { kCommandKey, message.command }, - { kStringKey, QString() }, - }; - for (auto field = message.fields.cbegin(); field != message.fields.cend(); ++field) { - if (isEnvelopeKey(field.key())) - continue; - arguments.insert(field.key(), fieldToString(field.value())); - } - return arguments; -} - -std::optional decode(const QString& envelope, const QJsonObject& arguments) -{ - if (envelope != envelopeCommand() || arguments.value(kProtocolKey).toString() != kProtocolVersion) - return std::nullopt; - - Message message; - message.command = arguments.value(kCommandKey).toString(); - if (message.command.isEmpty()) - return std::nullopt; - - for (auto argument = arguments.constBegin(); argument != arguments.constEnd(); ++argument) { - // Anything that is not a string did not come from a Spool client, and - // reading it as one would invent a value nobody sent. - if (!argument.value().isString()) - return std::nullopt; - if (isEnvelopeKey(argument.key())) - continue; - message.fields.insert(argument.key(), argument.value().toString()); - } - return message; -} - -QByteArray serialize(const Message& message) -{ - QJsonObject fields; - for (auto field = message.fields.cbegin(); field != message.fields.cend(); ++field) - fields.insert(field.key(), fieldToString(field.value())); - const QJsonObject object { - { QStringLiteral("c"), message.command }, - { QStringLiteral("f"), fields }, - }; - return QJsonDocument(object).toJson(QJsonDocument::Compact); -} - -std::optional deserialize(const QByteArray& payload) -{ - QJsonParseError error {}; - const QJsonDocument document = QJsonDocument::fromJson(payload, &error); - if (error.error != QJsonParseError::NoError || !document.isObject()) - return std::nullopt; - - const QJsonObject object = document.object(); - Message message; - message.command = object.value(QStringLiteral("c")).toString(); - if (message.command.isEmpty()) - return std::nullopt; - - const QJsonObject fields = object.value(QStringLiteral("f")).toObject(); - for (auto field = fields.constBegin(); field != fields.constEnd(); ++field) { - if (!field.value().isString()) - return std::nullopt; - message.fields.insert(field.key(), field.value().toString()); - } - return message; -} - -Message seekPreviewMessage(const QString& itemId, qint64 positionTicks, bool active) -{ - return Message { Command::SeekPreview, - { - { QStringLiteral("ItemId"), itemId }, - { QStringLiteral("PositionTicks"), QString::number(std::max(0, positionTicks)) }, - { QStringLiteral("Active"), active }, - } }; -} - -std::optional seekPreview(const Message& message) -{ - if (message.command != Command::SeekPreview) - return std::nullopt; - const std::optional positionTicks = message.number(QStringLiteral("PositionTicks")); - const std::optional active = message.flag(QStringLiteral("Active")); - if (!positionTicks || !active) - return std::nullopt; - - SeekPreview preview; - preview.itemId = message.text(QStringLiteral("ItemId")); - preview.positionTicks = std::max(0, *positionTicks); - preview.active = *active; - // A preview to show has to say what it is showing; one being torn down - // does not. - if (preview.active && preview.itemId.isEmpty()) - return std::nullopt; - return preview; -} - -Message syncPlayJoinMessage(const QString& groupId) -{ - return Message { Command::SyncPlayJoin, { { QStringLiteral("GroupId"), groupId } } }; -} - -std::optional syncPlayJoin(const Message& message) -{ - if (message.command != Command::SyncPlayJoin) - return std::nullopt; - SyncPlayJoin join; - join.groupId = message.text(QStringLiteral("GroupId")); - if (join.groupId.isEmpty()) - return std::nullopt; - return join; -} - -} // namespace JellyfinNative::SpoolRemoteProtocol diff --git a/src/app/SpoolRemoteProtocol.h b/src/app/SpoolRemoteProtocol.h deleted file mode 100644 index 7537762f..00000000 --- a/src/app/SpoolRemoteProtocol.h +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -namespace JellyfinNative::SpoolRemoteProtocol { - -// Jellyfin validates a GeneralCommand's name against its own enum and carries -// its arguments as a string-to-string map, so every Spool-to-Spool message -// rides inside SendString with an empty payload: a client that does not know -// Spool is told to type nothing, which is harmless. -QString envelopeCommand(); - -namespace Command { - inline const QString SeekPreview = QStringLiteral("SeekPreview"); - inline const QString SyncPlayJoin = QStringLiteral("SyncPlayJoin"); - inline const QString LinkOffer = QStringLiteral("LinkOffer"); - inline const QString LinkAnswer = QStringLiteral("LinkAnswer"); -} - -// One wire form for every command. Adding a message means naming it and -// listing its fields; the envelope, the version gate, the string-map rules and -// both transports stay exactly as they are. -struct Message { - QString command; - QVariantMap fields; - - QString text(const QString& key) const; - std::optional number(const QString& key) const; - std::optional flag(const QString& key) const; - bool isValid() const - { - return !command.isEmpty(); - } -}; - -QJsonObject encode(const Message& message); -std::optional decode(const QString& envelope, const QJsonObject& arguments); - -// The same message as a self-contained blob, for a transport that carries -// bytes rather than Jellyfin command arguments. -QByteArray serialize(const Message& message); -std::optional deserialize(const QByteArray& payload); - -// Typed views. Each one names only its own fields, because everything they -// would otherwise have in common already lives above. -struct SeekPreview { - QString itemId; - qint64 positionTicks = 0; - bool active = false; -}; -Message seekPreviewMessage(const QString& itemId, qint64 positionTicks, bool active); -std::optional seekPreview(const Message& message); - -struct SyncPlayJoin { - QString groupId; -}; -Message syncPlayJoinMessage(const QString& groupId); -std::optional syncPlayJoin(const Message& message); - -} // namespace JellyfinNative::SpoolRemoteProtocol diff --git a/src/app/SyncPlayController.cpp b/src/app/SyncPlayController.cpp deleted file mode 100644 index 22cf6f20..00000000 --- a/src/app/SyncPlayController.cpp +++ /dev/null @@ -1,1129 +0,0 @@ -#include "SyncPlayController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" -#include "../common/JellyfinTypes.h" -#include "../common/TlsTrust.h" -#include "../player/PlayQueueController.h" -#include "../player/PlayerController.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr int kGreedyTimeSyncIntervalMs = 1'000; - constexpr int kSteadyTimeSyncIntervalMs = 60'000; - constexpr int kReconnectDelayMs = 3'000; - // A socket that drops and comes back is not news. Suspending the app tears - // the connection down underneath us -- "software caused connection abort" - // on the way out, a refusal or two on the way back in -- and the reconnect - // below is what that is for. Only say something once reconnecting has - // failed this many times running, which is long enough that the only - // survivors are faults a person could actually act on. - constexpr int kSocketFailuresBeforeReporting = 4; - // Browser clients can hide large rate changes in their media pipeline. - // mpv has to retime real audio output, so keep small clock errors inaudible - // and seek once rate correction would take too long. - constexpr int kMinDelaySpeedToSyncMs = 100; - constexpr int kMinDelaySkipToSyncMs = 400; - constexpr int kSpeedToSyncDurationMs = 1'000; - constexpr double kSpeedToSyncMinSpeed = 0.2; - constexpr int kSyncMethodThresholdMs = 3'000; - constexpr int kSyncCooldownMs = kSyncMethodThresholdMs / 2; - constexpr double kSpeedToSyncMinRate = 0.97; - constexpr double kSpeedToSyncMaxRate = 1.03; - constexpr int kSpeedToSyncMaxDurationMs = 10'000; - constexpr int kInternalSeekBufferingSuppressionMs = 3'000; - constexpr qint64 kTicksPerSecond = 10'000'000; - constexpr qint64 kTicksPerMillisecond = 10'000; - - qint64 dateTimeMs(const QJsonValue& value) - { - QDateTime time = QDateTime::fromString(value.toString(), Qt::ISODateWithMs); - if (!time.isValid()) - time = QDateTime::fromString(value.toString(), Qt::ISODate); - return time.isValid() ? time.toUTC().toMSecsSinceEpoch() : 0; - } - - QString groupErrorMessage(const QString& type) - { - if (type == QStringLiteral("GroupDoesNotExist")) - return QStringLiteral("The SyncPlay group no longer exists."); - if (type == QStringLiteral("LibraryAccessDenied")) - return QStringLiteral("This account cannot access the SyncPlay media."); - if (type == QStringLiteral("CreateGroupDenied")) - return QStringLiteral("The server denied creating a SyncPlay group."); - if (type == QStringLiteral("JoinGroupDenied")) - return QStringLiteral("The server denied joining the SyncPlay group."); - if (type == QStringLiteral("SyncPlayIsDisabled")) - return QStringLiteral("SyncPlay is disabled on this server."); - return QStringLiteral("SyncPlay update failed: %1").arg(type); - } - -} // namespace - -SyncCorrection SyncPlayDriftPolicy::evaluate(double diffMs) -{ - const double absDiffMs = std::abs(diffMs); - if (absDiffMs < kMinDelaySpeedToSyncMs) - return {}; - - if (absDiffMs < kMinDelaySkipToSyncMs) { - // Web keeps the speed positive when the client is ahead by more than - // the correction window allows. - double speedToSyncTimeMs = kSpeedToSyncDurationMs; - if (diffMs <= -speedToSyncTimeMs * kSpeedToSyncMinSpeed) - speedToSyncTimeMs = absDiffMs / (1.0 - kSpeedToSyncMinSpeed); - - const double requestedSpeed = 1.0 + diffMs / speedToSyncTimeMs; - const double speed = std::clamp(requestedSpeed, kSpeedToSyncMinRate, kSpeedToSyncMaxRate); - if (speed != requestedSpeed) { - // Recover the same distance at the bounded rate. Hitting the - // duration cap leaves a remainder for the next correction. - speedToSyncTimeMs - = std::min(absDiffMs / std::abs(speed - 1.0), static_cast(kSpeedToSyncMaxDurationMs)); - } - return { SyncCorrection::Method::Speed, speed, static_cast(std::llround(speedToSyncTimeMs)) }; - } - - return { SyncCorrection::Method::Skip, 1.0, 0 }; -} - -SyncPlayController::SyncPlayController(JellyfinApiFacade *api, PlayerController *player, PlayQueueController *playQueue, - TlsTrustController *tlsTrust, QObject *parent) - : QObject(parent) - , m_api(api) - , m_player(player) - , m_playQueue(playQueue) -{ - m_timeSyncTimer.setSingleShot(true); - m_commandTimer.setSingleShot(true); - m_reconnectTimer.setSingleShot(true); - m_reconnectTimer.setInterval(kReconnectDelayMs); - // jellyfin-mpv-shim waits one second before declaring buffering. mpv's - // seeking flag is often only a short command transition, not starvation. - m_bufferingDebounceTimer.setSingleShot(true); - m_bufferingDebounceTimer.setInterval(1'000); - m_speedCorrectionTimer.setSingleShot(true); - m_correctionTimer.setInterval(1'000); - - connect(&m_socket, &QWebSocket::connected, this, [this]() { - qInfo() << "syncplay: websocket connected"; - m_socketFailures = 0; - m_reconnectTimer.stop(); - beginTimeSync(); - m_correctionTimer.start(); - emit connectionChanged(); - }); - connect(&m_socket, &QWebSocket::disconnected, this, [this]() { - qInfo() << "syncplay: websocket disconnected"; - m_timeSyncTimer.stop(); - m_correctionTimer.stop(); - emit connectionChanged(); - if (m_socketDesired) - m_reconnectTimer.start(); - }); - connect(&m_socket, &QWebSocket::textMessageReceived, this, &SyncPlayController::handleSocketTextMessage); - if (tlsTrust) - tlsTrust->attachWebSocket(&m_socket, [this]() { return socketUrl(); }, QStringLiteral("SyncPlay")); - connect(&m_socket, &QWebSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { - const QString reason = m_socket.errorString(); - // Not while we still intend to reconnect and have not been failing at - // it: the app being backgrounded and brought back produces exactly - // this, and a toast for something the app fixes by itself a moment - // later is noise about a problem the person does not have. - if (m_socketDesired && ++m_socketFailures < kSocketFailuresBeforeReporting) { - qInfo() << "syncplay: websocket error" << reason << "(attempt" << m_socketFailures << "of" - << kSocketFailuresBeforeReporting << "before reporting)"; - return; - } - qWarning() << "syncplay: websocket error" << reason; - emit errorText(QStringLiteral("SyncPlay socket error: %1").arg(reason)); - }); - connect(&m_timeSyncTimer, &QTimer::timeout, this, &SyncPlayController::requestTimeSync); - connect(&m_commandTimer, &QTimer::timeout, this, &SyncPlayController::executeScheduledCommand); - connect(&m_correctionTimer, &QTimer::timeout, this, &SyncPlayController::correctPlaybackDrift); - connect(&m_speedCorrectionTimer, &QTimer::timeout, this, &SyncPlayController::finishSpeedCorrection); - connect(&m_reconnectTimer, &QTimer::timeout, this, &SyncPlayController::connectSocket); - connect(&m_bufferingDebounceTimer, &QTimer::timeout, this, [this]() { sendPlayerBufferingState(true); }); - if (m_player) { - connect(m_player, &PlayerController::playbackStateChanged, this, &SyncPlayController::handlePlayerStateChanged); - } -} - -bool SyncPlayController::socketConnected() const -{ - return m_socket.state() == QAbstractSocket::ConnectedState; -} - -void SyncPlayController::refreshGroups() -{ - if (!m_api) - return; - connectSocket(); - Async::runScoped( - this, m_api->fetchSyncPlayGroups(), - [this](const QJsonArray& groups) { - m_groups = groups; - if (!m_groupId.isEmpty()) { - for (const QJsonValue& value : groups) { - const QJsonObject group = value.toObject(); - if (group.value(QStringLiteral("GroupId")).toString() == m_groupId) { - applyGroupInfo(m_groupId, group); - break; - } - } - } - emit groupsChanged(); - }, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("refresh groups"), error); }); -} - -void SyncPlayController::connectSocket() -{ - m_socketDesired = true; - if (!m_api || m_api->session().accessToken.isEmpty() || m_api->serverUrl().isEmpty()) { - return; - } - if (m_socket.state() == QAbstractSocket::ConnectedState) { - if (!m_clock.ready()) - beginTimeSync(); - return; - } - if (m_socket.state() == QAbstractSocket::ConnectingState) - return; - - QNetworkRequest request(socketUrl()); - // The server resolves the session behind a socket from the authorization - // header alone; the deviceId query parameter every client sends is never - // read. Without the header it falls back to the device registered against - // the token, so a second instance sharing that token would land on the - // first one's session and silently steal its group traffic. - request.setRawHeader("Authorization", m_api->authorizationHeader().toUtf8()); - request.setRawHeader("X-Emby-Token", m_api->session().accessToken.toUtf8()); - m_socket.open(request); -} - -void SyncPlayController::disconnectSocket() -{ - m_socketDesired = false; - m_socketFailures = 0; - m_reconnectTimer.stop(); - m_timeSyncTimer.stop(); - m_commandTimer.stop(); - m_correctionTimer.stop(); - m_clock.reset(); - m_timeSyncInFlight = false; - if (m_socket.state() != QAbstractSocket::UnconnectedState) - m_socket.close(); - clearGroup(); - m_groups = {}; - emit connectionChanged(); - emit groupsChanged(); -} - -void SyncPlayController::createGroup(const QString& name) -{ - if (!m_api) - return; - connectSocket(); - Async::runScoped( - this, m_api->createSyncPlayGroup(name), [this]() { refreshGroups(); }, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("create group"), error); }); -} - -void SyncPlayController::joinGroup(const QString& groupId) -{ - if (!m_api || groupId.isEmpty()) - return; - connectSocket(); - Async::runScoped( - this, m_api->joinSyncPlayGroup(groupId), - [this, groupId]() { - m_groupId = groupId; - if (m_player) - m_player->setSyncPlaybackSpeed(1.0); - for (const QJsonValue& value : m_groups) { - const QJsonObject group = value.toObject(); - if (group.value(QStringLiteral("GroupId")).toString() == groupId) { - applyGroupInfo(groupId, group); - break; - } - } - m_joinedAtServerMs - = QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - emit groupChanged(); - sendPlayerBufferingState(true); - }, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("join group"), error); }); -} - -void SyncPlayController::leaveGroup() -{ - if (!m_api || m_groupId.isEmpty()) - return; - auto request = m_api->leaveSyncPlayGroup(); - clearGroup(); - Async::runScoped( - this, std::move(request), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("leave group"), error); }); -} - -void SyncPlayController::requestUnpauseWhenReady() -{ - if (!enabled()) - return; - m_queueHandoff.arm(); - setWaitingForGroupPlayback(true); -} - -void SyncPlayController::cancelPendingUnpause() -{ - m_queueHandoff.cancel(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); -} - -void SyncPlayController::requestTogglePause() -{ - if (!enabled() || !m_api || !m_player || !m_player->sessionActive()) - return; - - if (m_player->paused()) { - requestGroupUnpause(); - } else { - // Pause locally immediately, then let the scheduled server command - // establish the authoritative position. Setting an explicit state is - // idempotent when the server acknowledgement arrives before mpv's - // property-change event. - m_player->setPaused(true); - Async::runScoped( - this, m_api->syncPlayPause(), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("pause group"), error); }, - "SyncPlay pause request"); - } -} - -void SyncPlayController::requestSeek(double positionSeconds) -{ - if (!enabled() || !m_api || !m_player || !std::isfinite(positionSeconds)) - return; - m_seekResume.arm(!m_player->paused()); - const qint64 ticks = static_cast(std::max(0.0, positionSeconds) * kTicksPerSecond); - Async::runScoped( - this, m_api->syncPlaySeek(ticks), []() {}, - [this](const std::exception_ptr& error) { - m_seekResume.cancel(); - reportRequestError(QStringLiteral("seek group"), error); - }, - "SyncPlay seek request"); -} - -void SyncPlayController::requestRelativeSeek(double deltaSeconds) -{ - if (!m_player) - return; - requestSeek(m_player->positionSeconds() + deltaSeconds); -} - -void SyncPlayController::requestNextItem() -{ - if (!enabled() || !m_api || m_playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayNextItem(m_playlistItemId), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("next group item"), error); }, - "SyncPlay next item request"); -} - -void SyncPlayController::requestPreviousItem() -{ - if (!enabled() || !m_api || m_playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayPreviousItem(m_playlistItemId), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("previous group item"), error); }, - "SyncPlay previous item request"); -} - -void SyncPlayController::requestMoveItem(const QString& playlistItemId, int newIndex) -{ - if (!enabled() || !m_api || playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayMovePlaylistItem(playlistItemId, newIndex), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("move group queue item"), error); }, - "SyncPlay move playlist item request"); -} - -void SyncPlayController::requestRemoveItems(const QStringList& playlistItemIds) -{ - if (!enabled() || !m_api || playlistItemIds.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayRemoveFromPlaylist(playlistItemIds), []() {}, - [this]( - const std::exception_ptr& error) { reportRequestError(QStringLiteral("remove group queue items"), error); }, - "SyncPlay remove from playlist request"); -} - -void SyncPlayController::requestQueueItems(const QStringList& itemIds, bool queueNext) -{ - if (!enabled() || !m_api || itemIds.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayQueue(itemIds, queueNext), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("queue items for group"), error); }, - "SyncPlay queue request"); -} - -void SyncPlayController::requestPlayItem(const QString& playlistItemId) -{ - if (!enabled() || !m_api || playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlaySetPlaylistItem(playlistItemId), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("play group queue item"), error); }, - "SyncPlay set playlist item request"); -} - -void SyncPlayController::handleSocketTextMessage(const QString& message) -{ - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(message.toUtf8(), &parseError); - if (!document.isObject()) { - qWarning() << "syncplay: invalid websocket message" << parseError.errorString(); - return; - } - - const QJsonObject object = document.object(); - const QString type = object.value(QStringLiteral("MessageType")).toString(); - if (type == QStringLiteral("ForceKeepAlive")) { - sendKeepAlive(); - return; - } - if (type == QStringLiteral("KeepAlive")) - return; - - const QJsonValue dataValue = object.value(QStringLiteral("Data")); - if (type == QStringLiteral("Sessions")) { - emit sessionsUpdated(dataValue.toArray()); - return; - } - - const QJsonObject data = dataValue.toObject(); - if (type == QStringLiteral("SyncPlayCommand")) - handleSyncPlayCommand(data); - else if (type == QStringLiteral("SyncPlayGroupUpdate")) - handleSyncPlayGroupUpdate(data); - else if (type == QStringLiteral("Play")) - emit remotePlayCommand(data); - else if (type == QStringLiteral("Playstate")) - emit remotePlaystateCommand(data); - else if (type == QStringLiteral("GeneralCommand")) - emit remoteGeneralCommand(data); -} - -void SyncPlayController::handleSyncPlayCommand(const QJsonObject& data) -{ - if (!m_player || m_groupId.isEmpty()) - return; - - const QString command = data.value(QStringLiteral("Command")).toString(); - const QString groupId = data.value(QStringLiteral("GroupId")).toString(); - if (!groupId.isEmpty() && groupId != m_groupId) - return; - - const qint64 emittedAtMs = dateTimeMs(data.value(QStringLiteral("EmittedAt"))); - if (emittedAtMs > 0 && m_joinedAtServerMs > 0 && emittedAtMs < m_joinedAtServerMs) { - qInfo() << "syncplay: ignoring command emitted before group join"; - return; - } - - const qint64 serverTimeMs = dateTimeMs(data.value(QStringLiteral("When"))); - const qint64 positionTicks = data.value(QStringLiteral("PositionTicks")).toVariant().toLongLong(); - const QString playlistItemId = data.value(QStringLiteral("PlaylistItemId")).toString(); - if (!playlistItemId.isEmpty() && !m_playlistItemId.isEmpty() && playlistItemId != m_playlistItemId) { - qInfo() << "syncplay: ignoring command for stale playlist item" << playlistItemId; - return; - } - m_scheduledPlaylistItemId = playlistItemId; - - const QString key - = QStringLiteral("%1|%2|%3|%4").arg(command).arg(serverTimeMs).arg(positionTicks).arg(m_playlistItemId); - if (key == m_lastCommandKey && m_commandTimer.isActive()) - return; - - m_lastCommandKey = key; - m_scheduledCommand = command; - m_scheduledPositionTicks = positionTicks; - m_scheduledServerTimeMs = serverTimeMs; - m_commandDue = false; - m_commandTimer.stop(); - m_bufferingDebounceTimer.stop(); - if (command == QStringLiteral("Unpause")) - setWaitingForGroupPlayback(true); - - const qint64 localNowMs = QDateTime::currentMSecsSinceEpoch(); - const qint64 delayMs = serverTimeMs > 0 ? (m_clock.ready() ? m_clock.localDelayUntil(serverTimeMs, localNowMs) - : std::max(0, serverTimeMs - localNowMs)) - : 0; - m_commandTimer.start(static_cast(std::min(delayMs, std::numeric_limits::max()))); -} - -void SyncPlayController::handleSyncPlayGroupUpdate(const QJsonObject& data) -{ - const QString type = data.value(QStringLiteral("Type")).toString(); - const QString groupId = data.value(QStringLiteral("GroupId")).toString(); - const QJsonValue updateData = data.value(QStringLiteral("Data")); - - if (type == QStringLiteral("GroupJoined") || type == QStringLiteral("GroupUpdate")) { - const QJsonObject info = updateData.toObject(); - applyGroupInfo(groupId, info); - // The server stamps this payload and then, for a group that is already - // playing, immediately emits the command that brings us in line. Both - // carry server time, so compare against the payload rather than - // against a local estimate taken a round trip later — that estimate is - // always ahead of the catch-up command and would discard it. - const qint64 stampedAtMs = dateTimeMs(info.value(QStringLiteral("LastUpdatedAt"))); - m_joinedAtServerMs = stampedAtMs > 0 - ? stampedAtMs - : QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - refreshGroups(); - sendPlayerBufferingState(true); - return; - } - if (type == QStringLiteral("UserJoined")) { - const QString participant = updateData.toString(); - if (!participant.isEmpty() && !m_participants.contains(participant)) { - m_participants.push_back(participant); - emit groupChanged(); - } - return; - } - if (type == QStringLiteral("UserLeft")) { - m_participants.removeAll(updateData.toString()); - emit groupChanged(); - return; - } - if (type == QStringLiteral("StateUpdate")) { - const QJsonObject state = updateData.toObject(); - m_groupState = state.value(QStringLiteral("State")).toString(); - m_groupStateReason = state.value(QStringLiteral("Reason")).toString(); - if (m_groupState == QStringLiteral("Waiting") && m_groupStateReason == QStringLiteral("Unpause")) { - setWaitingForGroupPlayback(true); - } else if (m_groupState == QStringLiteral("Paused") - && (m_groupStateReason == QStringLiteral("Ready") || m_groupStateReason == QStringLiteral("Pause")) - && !m_unpauseRequestPending) { - setWaitingForGroupPlayback(false); - } - if (m_seekResume.takeWhenReady(m_groupState, m_groupStateReason)) { - qInfo() << "syncplay: every participant is ready after seek; requesting group-wide unpause"; - requestGroupUnpause(); - } - emit groupChanged(); - return; - } - if (type == QStringLiteral("PlayQueue")) { - applyPlayQueueUpdate(updateData.toObject()); - return; - } - if (type == QStringLiteral("GroupLeft") || type == QStringLiteral("NotInGroup")) { - // NotInGroup says we are already out, so it is terminal rather than - // something to act on repeatedly. The server sends one per message we - // address to a group we have left, and answering each of them logged a - // fresh "leaving group" every second for a membership already cleared. - if (m_groupId.isEmpty()) - return; - // Worth a line: NotInGroup means the server disagrees with us about - // membership, which otherwise looks like a client that has simply - // stopped receiving group updates. - qInfo() << "syncplay: leaving group after" << type; - clearGroup(); - return; - } - if (type == QStringLiteral("GroupDoesNotExist") || type == QStringLiteral("LibraryAccessDenied") - || type == QStringLiteral("CreateGroupDenied") || type == QStringLiteral("JoinGroupDenied") - || type == QStringLiteral("SyncPlayIsDisabled")) { - emit errorText(groupErrorMessage(type)); - return; - } - qWarning() << "syncplay: unhandled group update" << type; -} - -void SyncPlayController::applyPlayQueueUpdate(const QJsonObject& queue) -{ - if (!m_api || !m_playQueue) - return; - - const qint64 updatedAtMs = dateTimeMs(queue.value(QStringLiteral("LastUpdate"))); - if (updatedAtMs > 0 && updatedAtMs < m_lastPlayQueueUpdateMs) { - qInfo() << "syncplay: ignoring stale play queue update"; - return; - } - m_lastPlayQueueUpdateMs = std::max(m_lastPlayQueueUpdateMs, updatedAtMs); - - const QJsonArray playlist = queue.value(QStringLiteral("Playlist")).toArray(); - const int playingIndex = queue.value(QStringLiteral("PlayingItemIndex")).toInt(-1); - if (playlist.isEmpty() || playingIndex < 0 || playingIndex >= playlist.size()) { - qWarning() << "syncplay: received empty or invalid play queue"; - return; - } - - QStringList itemIds; - QStringList playlistItemIds; - itemIds.reserve(playlist.size()); - playlistItemIds.reserve(playlist.size()); - for (const QJsonValue& value : playlist) { - const QJsonObject entry = value.toObject(); - itemIds.push_back(entry.value(QStringLiteral("ItemId")).toString()); - playlistItemIds.push_back(entry.value(QStringLiteral("PlaylistItemId")).toString()); - } - if (itemIds.contains(QString())) { - qWarning() << "syncplay: play queue contains an item without an id"; - return; - } - - const quint64 generation = ++m_playQueueGeneration; - m_queueHandoff.observeQueueUpdate(); - m_playlistItemId = playlistItemIds.at(playingIndex); - const QString selectedItemId = itemIds.at(playingIndex); - const bool selectedItemAlreadyActive - = m_player && m_player->sessionActive() && m_playQueue->currentItem().id == selectedItemId; - const qint64 requestedTicks = queue.value(QStringLiteral("StartPositionTicks")).toVariant().toLongLong(); - const QString reason = queue.value(QStringLiteral("Reason")).toString(); - qInfo() << "syncplay: resolving incoming queue" << itemIds.size() << "items, index" << playingIndex << "reason" - << reason; - - // A large SyncPlay playlist can take seconds to hydrate. Resolve and start - // the selected item first; the rest of the queue is not on the critical - // path for reporting Ready or executing the scheduled play command. - const auto hydrateQueue = [this, generation, itemIds, playlistItemIds, playingIndex]() { - Async::runScoped( - this, m_api->fetchItemsByIds(itemIds), - [this, generation, itemIds, playlistItemIds, playingIndex](const std::vector& fetched) { - if (generation != m_playQueueGeneration) - return; - - std::vector ordered; - ordered.reserve(static_cast(itemIds.size())); - int resolvedIndex = -1; - for (int requestedIndex = 0; requestedIndex < itemIds.size(); ++requestedIndex) { - const auto found = std::find_if(fetched.begin(), fetched.end(), - [&](const MovieItem& item) { return item.id == itemIds.at(requestedIndex); }); - if (found == fetched.end()) - continue; - MovieItem item = *found; - item.playlistItemId = playlistItemIds.at(requestedIndex); - if (requestedIndex == playingIndex) - resolvedIndex = static_cast(ordered.size()); - ordered.push_back(std::move(item)); - } - if (m_playQueue->matchesQueue(ordered, resolvedIndex)) { - // Usually this broadcast is the echo of an edit made here. - // Rebuilding would reset the model and tear down every row - // the queue panel is showing, to arrive back where we are. - return; - } - m_playQueue->setShuffled(false); - if (resolvedIndex < 0 || !m_playQueue->playNow(ordered, resolvedIndex)) { - qWarning() << "syncplay: could not hydrate the full play queue"; - return; - } - qInfo() << "syncplay: hydrated incoming queue" << ordered.size() << "items"; - }, - [this, generation](const std::exception_ptr& error) { - if (generation == m_playQueueGeneration) - qWarning() << "syncplay: full queue hydration failed:" << exceptionMessage(error); - }, - "SyncPlay full queue hydration"); - }; - - if (selectedItemAlreadyActive) { - m_playQueueLoading = false; - qInfo() << "syncplay: selected queue item is already active; preserving playback session"; - hydrateQueue(); - sendPlayerBufferingState(true); - sendPendingUnpause(); - return; - } - - m_playQueueLoading = true; - setWaitingForGroupPlayback(true); - Async::runScoped( - this, m_api->fetchItemsByIds({ selectedItemId }), - [this, generation, selectedItemId, selectedPlaylistItemId = m_playlistItemId, requestedTicks, hydrateQueue]( - const std::vector& fetched) { - if (generation != m_playQueueGeneration) - return; - const auto selected = std::find_if( - fetched.cbegin(), fetched.cend(), [&](const MovieItem& item) { return item.id == selectedItemId; }); - if (selected == fetched.cend()) { - m_playQueueLoading = false; - setWaitingForGroupPlayback(false); - emit errorText(QStringLiteral("SyncPlay could not resolve the selected queue item.")); - return; - } - - MovieItem item = *selected; - item.playlistItemId = selectedPlaylistItemId; - m_playQueue->setShuffled(false); - if (!m_playQueue->playNow(item)) { - m_playQueueLoading = false; - setWaitingForGroupPlayback(false); - emit errorText(QStringLiteral("SyncPlay could not start the selected queue item.")); - return; - } - - m_playQueueLoading = false; - m_waitingForPlaybackStart = true; - m_playerStateKnown = false; - prepareQueuePlayback(requestedTicks); - emit queuePlaybackRequested(requestedTicks); - hydrateQueue(); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_playQueueGeneration) - return; - m_playQueueLoading = false; - setWaitingForGroupPlayback(false); - reportRequestError(QStringLiteral("load selected queue item"), error); - }, - "SyncPlay selected item resolution"); -} - -void SyncPlayController::prepareQueuePlayback(qint64 positionTicks) -{ - if (!enabled() || !m_api) - return; - const qint64 serverNowMs - = QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - Async::runScoped( - this, - m_api->syncPlayReportBuffering(true, std::max(0, positionTicks), false, m_playlistItemId, - QDateTime::fromMSecsSinceEpoch(serverNowMs, QTimeZone(QTimeZone::UTC))), - []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("report buffering"), error); }, - "SyncPlay queue buffering report"); -} - -void SyncPlayController::applyGroupInfo(const QString& groupId, const QJsonObject& info) -{ - const QString infoGroupId = info.value(QStringLiteral("GroupId")).toString(); - m_groupId = !infoGroupId.isEmpty() ? infoGroupId : groupId; - m_groupName = info.value(QStringLiteral("GroupName")).toString(); - if (m_groupName.isEmpty()) - m_groupName = QStringLiteral("SyncPlay group"); - m_groupState = info.value(QStringLiteral("State")).toString(); - m_groupStateReason = info.value(QStringLiteral("Reason")).toString(); - if (m_player && !m_speedCorrectionActive) - m_player->setSyncPlaybackSpeed(1.0); - - QStringList participants; - for (const QJsonValue& value : info.value(QStringLiteral("Participants")).toArray()) { - const QString participant = value.toString(); - if (!participant.isEmpty()) - participants.push_back(participant); - } - m_participants = participants; - emit groupChanged(); -} - -void SyncPlayController::clearGroup() -{ - m_commandTimer.stop(); - m_speedCorrectionTimer.stop(); - m_bufferingDebounceTimer.stop(); - m_speedCorrectionActive = false; - if (m_player) - m_player->clearSyncPlaybackSpeed(); - m_groupId.clear(); - m_groupName.clear(); - m_groupState.clear(); - m_groupStateReason.clear(); - m_participants.clear(); - m_playlistItemId.clear(); - m_lastCommandKey.clear(); - m_scheduledCommand.clear(); - m_scheduledPlaylistItemId.clear(); - m_scheduledPositionTicks = 0; - m_scheduledServerTimeMs = 0; - m_joinedAtServerMs = 0; - m_queueHandoff.cancel(); - m_seekResume.cancel(); - ++m_playQueueGeneration; - m_playQueueLoading = false; - m_waitingForPlaybackStart = false; - m_commandDue = false; - m_unpauseRequestPending = false; - m_syncCorrectionAttempts = 0; - m_lastCorrectionAtMs = 0; - setWaitingForGroupPlayback(false); - setPlaybackDiff(0, false); - setSyncMethod(QStringLiteral("None")); - m_suppressSeekBufferingUntilMs = 0; - m_playerStateKnown = false; - emit groupChanged(); -} - -void SyncPlayController::executeScheduledCommand() -{ - if (!m_player || m_scheduledCommand.isEmpty()) - return; - - if (m_scheduledCommand != QStringLiteral("Stop") - && (m_playQueueLoading || m_waitingForPlaybackStart || !m_player->sessionActive())) { - m_commandDue = true; - qInfo() << "syncplay: deferring" << m_scheduledCommand << "until playback is ready"; - return; - } - - if (!m_scheduledPlaylistItemId.isEmpty() && !m_playlistItemId.isEmpty() - && m_scheduledPlaylistItemId != m_playlistItemId) { - qInfo() << "syncplay: dropping scheduled command for replaced queue item"; - m_commandDue = false; - return; - } - m_commandDue = false; - - const QString command = m_scheduledCommand; - qint64 positionTicks = m_scheduledPositionTicks; - if (command == QStringLiteral("Unpause") && m_scheduledServerTimeMs > 0) { - positionTicks = m_clock.estimatePositionTicks( - positionTicks, m_scheduledServerTimeMs, QDateTime::currentMSecsSinceEpoch()); - } - const double targetSeconds = static_cast(positionTicks) / kTicksPerSecond; - const double positionDelta = std::abs(m_player->positionSeconds() - targetSeconds); - qInfo() << "syncplay: executing" << command << "positionTicks" << positionTicks << "deltaMs" - << static_cast(positionDelta * 1'000.0); - - if (command == QStringLiteral("Pause")) { - m_seekResume.cancel(); - finishSpeedCorrection(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - m_player->setPaused(true); - if (positionDelta > 0.1) - m_player->seek(targetSeconds); - } else if (command == QStringLiteral("Unpause")) { - finishSpeedCorrection(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - m_syncCorrectionAttempts = 0; - // Web re-enables sync half a threshold after an unpause so the drift a - // player accumulates while it spins up never triggers a correction. - m_lastCorrectionAtMs = QDateTime::currentMSecsSinceEpoch(); - setPlaybackDiff(0, false); - setSyncMethod(QStringLiteral("None")); - if (positionDelta * 1'000.0 > kMinDelaySkipToSyncMs && !m_player->seeking()) { - m_suppressSeekBufferingUntilMs = QDateTime::currentMSecsSinceEpoch() + kInternalSeekBufferingSuppressionMs; - m_player->seek(targetSeconds); - } - m_player->setPaused(false); - } else if (command == QStringLiteral("Seek")) { - finishSpeedCorrection(); - m_player->setPaused(true); - m_player->seek(targetSeconds); - QTimer::singleShot(250, this, [this]() { sendPlayerBufferingState(true); }); - } else if (command == QStringLiteral("Stop")) { - m_seekResume.cancel(); - finishSpeedCorrection(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - m_syncCorrectionAttempts = 0; - setPlaybackDiff(0, false); - setSyncMethod(QStringLiteral("None")); - m_player->stopWithReason(QStringLiteral("syncplay-stop")); - } else { - emit errorText(QStringLiteral("Unsupported SyncPlay command: %1").arg(command)); - } -} - -void SyncPlayController::correctPlaybackDrift() -{ - if (!enabled() || !m_player || !m_player->sessionActive() || m_scheduledCommand != QStringLiteral("Unpause") - || m_scheduledServerTimeMs <= 0 || m_player->paused() || m_player->buffering() || m_player->seeking()) { - setPlaybackDiff(0, false); - if (m_speedCorrectionActive) - finishSpeedCorrection(); - return; - } - - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - const qint64 expectedTicks - = m_clock.estimatePositionTicks(m_scheduledPositionTicks, m_scheduledServerTimeMs, nowMs); - const qint64 actualTicks = static_cast(m_player->estimatedPositionSeconds() * kTicksPerSecond); - const qint64 signedDiffTicks = expectedTicks - actualTicks; - const qint64 diffMs = std::abs(signedDiffTicks) / kTicksPerMillisecond; - const double signedDiffMs = static_cast(signedDiffTicks) / kTicksPerMillisecond; - const SyncCorrection correction = SyncPlayDriftPolicy::evaluate(signedDiffMs); - if (correction.method == SyncCorrection::Method::None) { - m_syncCorrectionAttempts = 0; - if (!m_speedCorrectionActive) - setSyncMethod(QStringLiteral("None")); - return; - } - - if (m_speedCorrectionActive) { - // A large discontinuity should not wait for the gentle rate - // correction to finish; restore 1x before the authoritative seek. - if (correction.method == SyncCorrection::Method::Speed) - return; - finishSpeedCorrection(); - } - - // Keep diagnostics live during correction cooldowns even though another - // correction is intentionally suppressed. - if (nowMs - m_lastCorrectionAtMs < kSyncCooldownMs) - return; - - if (correction.method == SyncCorrection::Method::Speed) { - m_lastCorrectionAtMs = nowMs; - ++m_syncCorrectionAttempts; - m_speedCorrectionActive = true; - m_player->setSyncPlaybackSpeed(correction.speed); - setSyncMethod(QStringLiteral("SpeedToSync (x%1)").arg(correction.speed, 0, 'f', 2)); - qInfo() << "syncplay: correcting playback drift by speed" << signedDiffMs << "ms rate" << correction.speed - << "durationMs" << correction.durationMs; - m_speedCorrectionTimer.start(correction.durationMs); - return; - } - - m_lastCorrectionAtMs = nowMs; - ++m_syncCorrectionAttempts; - setSyncMethod(QStringLiteral("SkipToSync (%1)").arg(m_syncCorrectionAttempts)); - qInfo() << "syncplay: correcting playback drift" << diffMs << "ms"; - m_suppressSeekBufferingUntilMs = nowMs + kInternalSeekBufferingSuppressionMs; - m_player->seek(static_cast(expectedTicks) / kTicksPerSecond); -} - -void SyncPlayController::finishSpeedCorrection() -{ - m_speedCorrectionTimer.stop(); - if (m_player && enabled()) - m_player->setSyncPlaybackSpeed(1.0); - if (!m_speedCorrectionActive) - return; - m_speedCorrectionActive = false; - setSyncMethod(QStringLiteral("None")); -} - -void SyncPlayController::handlePlayerStateChanged() -{ - if (m_waitingForPlaybackStart) { - if (m_player && m_player->sessionActive() && m_player->fileLoaded()) { - m_waitingForPlaybackStart = false; - qInfo() << "syncplay: incoming queue file is loaded and paused; reporting ready"; - if (m_commandDue) - executeScheduledCommand(); - sendPlayerBufferingState(true); - sendPendingUnpause(); - } - // prepareQueuePlayback() already reported this participant as - // buffering. Do not let intermediate player state changes publish a - // false Ready before mpv has actually loaded the file. - return; - } - sendPlayerBufferingState(false); - sendPendingUnpause(); -} - -void SyncPlayController::sendPlayerBufferingState(bool force) -{ - if (!enabled() || !m_api || !m_player || !m_player->sessionActive()) - return; - - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - const bool internalSeek = m_player->seeking() && nowMs < m_suppressSeekBufferingUntilMs; - const bool buffering = internalSeek ? false : (m_player->buffering() || m_player->seeking()); - if (!force && buffering && (!m_playerStateKnown || !m_lastPlayerBuffering)) { - if (!m_bufferingDebounceTimer.isActive()) - m_bufferingDebounceTimer.start(); - return; - } - if (!buffering) - m_bufferingDebounceTimer.stop(); - if (!force && m_playerStateKnown && buffering == m_lastPlayerBuffering) { - return; - } - m_playerStateKnown = true; - m_lastPlayerBuffering = buffering; - - const qint64 positionTicks = static_cast(m_player->positionSeconds() * kTicksPerSecond); - const bool playing = !m_player->paused(); - const qint64 serverNowMs - = QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - Async::runScoped( - this, - m_api->syncPlayReportBuffering(buffering, positionTicks, playing, m_playlistItemId, - QDateTime::fromMSecsSinceEpoch(serverNowMs, QTimeZone(QTimeZone::UTC))), - []() {}, - [this, buffering](const std::exception_ptr& error) { - reportRequestError(buffering ? QStringLiteral("report buffering") : QStringLiteral("report ready"), error); - }, - buffering ? "SyncPlay buffering report" : "SyncPlay ready report"); -} -void SyncPlayController::sendPendingUnpause() -{ - if (!enabled() || !m_api || !m_player - || !m_queueHandoff.canSend( - m_playQueueLoading, m_waitingForPlaybackStart, m_player->sessionActive(), m_player->fileLoaded())) { - return; - } - - m_queueHandoff.cancel(); - qInfo() << "syncplay: requesting group-wide unpause"; - requestGroupUnpause(); -} - -void SyncPlayController::requestGroupUnpause() -{ - if (!enabled() || !m_api || m_unpauseRequestPending) - return; - - m_unpauseRequestPending = true; - setWaitingForGroupPlayback(true); - Async::runScoped( - this, m_api->syncPlayUnpause(), []() {}, - [this](const std::exception_ptr& error) { - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - reportRequestError(QStringLiteral("unpause group"), error); - }, - "SyncPlay group unpause"); -} - -void SyncPlayController::setWaitingForGroupPlayback(bool waiting) -{ - if (m_waitingForGroupPlayback == waiting) - return; - m_waitingForGroupPlayback = waiting; - emit syncStatusChanged(); -} - -void SyncPlayController::setPlaybackDiff(qint64 diffTicks, bool valid) -{ - const double diffMs = valid ? static_cast(diffTicks) / kTicksPerMillisecond : 0.0; - if (m_playbackDiffValid == valid && (!valid || qFuzzyCompare(m_playbackDiffMs, diffMs))) - return; - m_playbackDiffValid = valid; - m_playbackDiffMs = diffMs; - emit syncStatusChanged(); -} - -void SyncPlayController::setSyncMethod(const QString& method) -{ - if (m_syncMethod == method) - return; - m_syncMethod = method; - emit syncStatusChanged(); -} - -void SyncPlayController::beginTimeSync() -{ - m_timeSyncTimer.stop(); - m_clock.reset(); - m_greedyTimeSyncRemaining = 3; - m_timeSyncErrorReported = false; - requestTimeSync(); -} - -void SyncPlayController::requestTimeSync() -{ - if (!m_api || !socketConnected() || m_timeSyncInFlight) - return; - - m_timeSyncInFlight = true; - const qint64 requestSentMs = QDateTime::currentMSecsSinceEpoch(); - Async::runScoped( - this, m_api->fetchUtcTime(), - [this, requestSentMs](const QJsonObject& response) { - m_timeSyncInFlight = false; - const qint64 responseReceivedMs = QDateTime::currentMSecsSinceEpoch(); - const qint64 requestReceivedMs = dateTimeMs(response.value(QStringLiteral("RequestReceptionTime"))); - const qint64 responseSentMs = dateTimeMs(response.value(QStringLiteral("ResponseTransmissionTime"))); - if (requestReceivedMs <= 0 || responseSentMs <= 0) { - if (!m_timeSyncErrorReported) { - m_timeSyncErrorReported = true; - emit errorText(QStringLiteral("SyncPlay time response was incomplete.")); - } - scheduleTimeSync(); - return; - } - - m_clock.addMeasurement({ requestSentMs, requestReceivedMs, responseSentMs, responseReceivedMs }); - m_timeSyncErrorReported = false; - emit connectionChanged(); - - Async::runScoped( - this, m_api->syncPlayReportPing(static_cast(std::llround(m_clock.pingMs()))), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("report ping"), error); }, - "SyncPlay ping report"); - scheduleTimeSync(); - }, - [this](const std::exception_ptr& error) { - m_timeSyncInFlight = false; - if (!m_timeSyncErrorReported) { - m_timeSyncErrorReported = true; - reportRequestError(QStringLiteral("synchronize clock"), error); - } - scheduleTimeSync(); - }, - "SyncPlay time sync"); -} - -void SyncPlayController::scheduleTimeSync() -{ - if (!socketConnected()) - return; - const int interval = m_greedyTimeSyncRemaining > 0 ? kGreedyTimeSyncIntervalMs : kSteadyTimeSyncIntervalMs; - m_greedyTimeSyncRemaining = std::max(0, m_greedyTimeSyncRemaining - 1); - m_timeSyncTimer.start(interval); -} - -void SyncPlayController::reportRequestError(const QString& action, const std::exception_ptr& error) -{ - const QString message = exceptionMessage(error); - qWarning() << "syncplay:" << action << "failed:" << message; - emit errorText(QStringLiteral("SyncPlay %1 failed: %2").arg(action, message)); -} - -void SyncPlayController::sendKeepAlive() -{ - if (socketConnected()) { - m_socket.sendTextMessage(QStringLiteral(R"({"MessageType":"KeepAlive"})")); - } -} - -QUrl SyncPlayController::socketUrl() const -{ - QUrl url(m_api->serverUrl()); - url.setScheme(url.scheme() == QStringLiteral("https") ? QStringLiteral("wss") : QStringLiteral("ws")); - QString path = url.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - url.setPath(path + QStringLiteral("/socket")); - - QUrlQuery query; - if (!m_api->deviceId().isEmpty()) { - query.addQueryItem(QStringLiteral("deviceId"), m_api->deviceId()); - } - url.setQuery(query); - return url; -} - -} // namespace JellyfinNative diff --git a/src/app/TrickplayDecoder.cpp b/src/app/TrickplayDecoder.cpp new file mode 100644 index 00000000..fdc0949f --- /dev/null +++ b/src/app/TrickplayDecoder.cpp @@ -0,0 +1,266 @@ +#include "TrickplayDecoder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(SPOOL_QT_BUNDLED_JPEG) +#include +#else +#include +#endif + +namespace Spool { + +bool BifSequence::parse(QByteArray bytes, QString *error) +{ + m_bytes.clear(); + m_entries.clear(); + auto fail = [error](const char *message) { + if (error) + *error = QLatin1String(message); + return false; + }; + if (bytes.size() < 64 || bytes.first(8) != QByteArray::fromHex("894249460d0a1a0a")) + return fail("Invalid BIF signature or truncated header"); + const auto word = [&bytes](qsizetype offset) { + return qFromLittleEndian(reinterpret_cast(bytes.constData() + offset)); + }; + if (word(8) != 0) + return fail("Unsupported BIF version"); + const quint32 count = word(12); + const quint64 tableEnd = 64 + (quint64(count) + 1) * 8; + if (!count || count > 1000000 || tableEnd > quint64(bytes.size())) + return fail("Invalid or truncated BIF index"); + const quint64 multiplier = word(16) ? word(16) : 1000; + if (word(64 + qsizetype(count) * 8) != std::numeric_limits::max()) + return fail("Missing BIF terminal entry"); + QVector entries; + entries.reserve(int(count)); + quint64 previousTime = 0; + for (quint32 i = 0; i < count; ++i) { + const qsizetype position = 64 + qsizetype(i) * 8; + const quint32 timestamp = word(position); + const quint32 offset = word(position + 4); + const quint32 end = word(position + 12); + const quint64 time = quint64(timestamp) * multiplier; + if (timestamp == std::numeric_limits::max() || (i && time <= previousTime) || offset < tableEnd + || end <= offset || end > quint64(bytes.size())) + return fail("Invalid BIF timestamp or image boundary"); + entries.push_back({ time, offset, end, {} }); + previousTime = time; + } + m_entries = std::move(entries); + m_bytes = std::move(bytes); + if (error) + error->clear(); + return true; +} + +int BifSequence::frameAt(double seconds) const +{ + if (m_entries.isEmpty() || !std::isfinite(seconds)) + return -1; + const double milliseconds = std::max(0.0, seconds) * 1000.0; + const auto it = std::upper_bound(m_entries.cbegin(), m_entries.cend(), milliseconds, + [](double value, const Entry& entry) { return value < entry.timeMs; }); + return it == m_entries.cbegin() ? 0 : int(it - m_entries.cbegin() - 1); +} + +QByteArray BifSequence::frameBacking(int index, qsizetype *offset, qsizetype *length) const +{ + *offset = 0; + *length = 0; + if (index < 0 || index >= m_entries.size()) + return {}; + const Entry& entry = m_entries[index]; + *offset = entry.offset; + *length = entry.end - entry.offset; + return m_bytes; +} + +QSize BifSequence::frameSize(int index) const +{ + if (index < 0 || index >= m_entries.size()) + return {}; + const Entry& entry = m_entries[index]; + if (!entry.size.isValid()) { + // Header inspection borrows sequence bytes synchronously. Workers + // retain shared backing when borrowing the same JPEG range. + QBuffer buffer; + buffer.setData(QByteArray::fromRawData(m_bytes.constData() + entry.offset, entry.end - entry.offset)); + buffer.open(QIODevice::ReadOnly); + QImageReader reader(&buffer); + entry.size = reader.size(); + } + return entry.size; +} + +QImage decodeTrickplayFrame( + const QByteArray& bytes, const QRect& crop, const QSize& expectedSize, bool residentSheet, QString *error) +{ + QBuffer buffer; + buffer.setData(bytes); + buffer.open(QIODevice::ReadOnly); + QImageReader reader(&buffer); + reader.setAutoTransform(false); + const QSize size = reader.size(); + const qint64 pixels = qint64(size.width()) * size.height(); + if (!size.isValid() || size != expectedSize || pixels > 64 * 1024 * 1024 + || (reader.format() != QByteArrayLiteral("jpeg") && pixels * 4 > TrickplayDecodedByteBudget) + || (crop.isValid() + && (!QRect(QPoint(), size).contains(crop) || qint64(crop.width()) * crop.height() > 1024 * 1024)) + || (!crop.isValid() && pixels > 1024 * 1024) || (residentSheet && pixels * 4 > TrickplayDecodedByteBudget)) { + if (error) + *error = QStringLiteral("Invalid or oversized preview dimensions"); + return {}; + } + if (crop.isValid() && !residentSheet) + reader.setClipRect(crop); + QImage image = reader.read(); + const QSize outputSize = crop.isValid() && !residentSheet ? crop.size() : size; + if (image.isNull() || image.size() != outputSize) { + if (error) + *error = image.isNull() ? reader.errorString() : QStringLiteral("Invalid decoded preview dimensions"); + return {}; + } + // JPEG normally already produces RGB32. Other supported small images use + // the same byte accounting and texture layout. + if (image.format() != QImage::Format_RGB32) + image = image.convertToFormat(QImage::Format_RGB32); + return image; +} + +namespace { + struct RawDecoder { + jpeg_decompress_struct codec {}; + jpeg_error_mgr error {}; + std::jmp_buf jump; + bool created = false; + static void fail(j_common_ptr codec) + { + auto *self = reinterpret_cast(codec); + std::longjmp(self->jump, 1); + } + ~RawDecoder() + { + if (created) + jpeg_destroy_decompress(&codec); + } + }; +} + +std::shared_ptr decodeTrickplayTexture(const QByteArray& bytes, const QRect& crop, + const QSize& expectedSize, bool residentSheet, bool accurate, QString *error) +{ + auto frame = std::make_shared(); + // Keep the RGB32 admission envelope, even though raw output is smaller. + // Oversized sheets remain on Qt's codec-clipped RGB path. + const qint64 pixels = qint64(expectedSize.width()) * expectedSize.height(); + const bool rawEligible = expectedSize.isValid() && trickplayTextureCost(expectedSize) <= TrickplayDecodedByteBudget + && (residentSheet || !crop.isValid()) && (crop.isValid() || pixels <= 1024 * 1024) + && (!crop.isValid() + || (QRect(QPoint(), expectedSize).contains(crop) && qint64(crop.width()) * crop.height() <= 1024 * 1024)); + if (rawEligible && bytes.size() >= 2 && uchar(bytes[0]) == 0xff && uchar(bytes[1]) == 0xd8) { + // Heap-owned state and output survive libjpeg's error longjmp without + // bypassing destructors or reading modified automatic variables. + auto decoder = std::make_unique(); + decoder->codec.err = jpeg_std_error(&decoder->error); + decoder->error.error_exit = RawDecoder::fail; + if (setjmp(decoder->jump) == 0) { + jpeg_create_decompress(&decoder->codec); + decoder->created = true; + jpeg_mem_src(&decoder->codec, reinterpret_cast(bytes.constData()), + static_cast(bytes.size())); + jpeg_read_header(&decoder->codec, TRUE); + auto& codec = decoder->codec; + if (codec.data_precision == 8 && codec.jpeg_color_space == JCS_YCbCr && codec.num_components == 3 + && codec.image_width == unsigned(expectedSize.width()) + && codec.image_height == unsigned(expectedSize.height()) && codec.comp_info[0].h_samp_factor == 2 + && codec.comp_info[0].v_samp_factor == 2 && codec.comp_info[1].h_samp_factor == 1 + && codec.comp_info[1].v_samp_factor == 1 && codec.comp_info[2].h_samp_factor == 1 + && codec.comp_info[2].v_samp_factor == 1) { + codec.raw_data_out = TRUE; + codec.out_color_space = JCS_YCbCr; + codec.dct_method = accurate ? JDCT_ISLOW : JDCT_IFAST; + jpeg_start_decompress(&codec); + frame->size = expectedSize; + const int groups = (expectedSize.height() + 15) / 16; + for (int i = 0; i < 3; ++i) { + const auto& component = codec.comp_info[i]; + // No scaled IDCT: verify the coded and reconstructed shapes. + const int divisor = i == 0 ? 1 : 2; + if (component.downsampled_width != unsigned((expectedSize.width() + divisor - 1) / divisor) + || component.downsampled_height != unsigned((expectedSize.height() + divisor - 1) / divisor)) + RawDecoder::fail(reinterpret_cast(&codec)); + frame->strides[i] = int(component.width_in_blocks) * DCTSIZE; + frame->planes[i].resize(qsizetype(frame->strides[i]) * groups * component.v_samp_factor * DCTSIZE); + } + while (codec.output_scanline < codec.output_height) { + JSAMPROW rows[3][16]; + JSAMPARRAY planes[3] { rows[0], rows[1], rows[2] }; + for (int i = 0; i < 3; ++i) { + const int count = codec.comp_info[i].v_samp_factor * DCTSIZE; + const int first = int(codec.output_scanline / 16) * count; + for (int row = 0; row < count; ++row) + rows[i][row] = reinterpret_cast( + frame->planes[i].data() + qsizetype(first + row) * frame->strides[i]); + } + if (!jpeg_read_raw_data(&codec, planes, 16)) + RawDecoder::fail(reinterpret_cast(&codec)); + } + jpeg_finish_decompress(&codec); + if (error) + error->clear(); + return frame; + } + } + frame->planes = {}; + frame->strides = {}; + } + frame->rgb = decodeTrickplayFrame(bytes, crop, expectedSize, residentSheet, error); + if (frame->rgb.isNull()) + return {}; + frame->size = frame->rgb.size(); + return frame; +} + +QImage TrickplayTexture::image() const +{ + if (!planar()) + return rgb; + // Software scene graphs and unsupported RHI formats use an RGB fallback. + // Reconstruct the original 420 sampling, never an extra chroma downsample. + QImage result(size, QImage::Format_RGB32); + if (result.isNull()) + return {}; + const int cw = (size.width() + 1) / 2, ch = (size.height() + 1) / 2; + const auto chroma = [&](int plane, int x, int y) { + const float fx = (float(x) - .5f) * .5f, fy = (float(y) - .5f) * .5f; + const int ix = int(std::floor(fx)), iy = int(std::floor(fy)); + const float dx = fx - ix, dy = fy - iy; + const auto sample = [&](int sx, int sy) { + return float(uchar( + planes[plane][qsizetype(std::clamp(sy, 0, ch - 1)) * strides[plane] + std::clamp(sx, 0, cw - 1)])); + }; + return (sample(ix, iy) * (1 - dx) + sample(ix + 1, iy) * dx) * (1 - dy) + + (sample(ix, iy + 1) * (1 - dx) + sample(ix + 1, iy + 1) * dx) * dy - 128.f; + }; + const auto channel = [](float value) { return std::clamp(int(std::lround(value)), 0, 255); }; + for (int y = 0; y < size.height(); ++y) { + auto *row = reinterpret_cast(result.scanLine(y)); + for (int x = 0; x < size.width(); ++x) { + const float l = uchar(planes[0][qsizetype(y) * strides[0] + x]); + const float cb = chroma(1, x, y), cr = chroma(2, x, y); + row[x] = qRgb( + channel(l + 1.402f * cr), channel(l - .344136286f * cb - .714136286f * cr), channel(l + 1.772f * cb)); + } + } + return result; +} + +} // namespace Spool diff --git a/src/app/TrickplayDecoder.h b/src/app/TrickplayDecoder.h new file mode 100644 index 00000000..1830faa8 --- /dev/null +++ b/src/app/TrickplayDecoder.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +namespace Spool { + +// Roku BIF v0: timestamps are index values multiplied by the header's +// millisecond multiplier; the terminal entry supplies the last JPEG boundary. +class BifSequence final { +public: + bool parse(QByteArray bytes, QString *error); + int frameAt(double seconds) const; + int count() const + { + return m_entries.size(); + } + // Returns shared owned backing plus the selected JPEG range, without + // copying that range. The worker retains backing while borrowing bytes. + QByteArray frameBacking(int index, qsizetype *offset, qsizetype *length) const; + QSize frameSize(int index) const; + +private: + struct Entry { + quint64 timeMs; + quint32 offset; + quint32 end; + mutable QSize size; + }; + QByteArray m_bytes; + QVector m_entries; +}; + +inline constexpr qint64 TrickplayDecodedByteBudget = 50'000'000; +// Conservative reservation includes raw MCU padding for small/odd dimensions; +// RGB-equivalent charging prevents the planar cutover expanding the cache. +inline qint64 trickplayTextureCost(const QSize& size) +{ + const qint64 w = size.width(), h = size.height(); + if (w <= 0 || h <= 0) + return 0; + const qint64 groups = (h + 15) / 16; + const qint64 raw = ((w + 7) / 8 * 8) * groups * 16 + 2 * (((w + 1) / 2 + 7) / 8 * 8) * groups * 8; + return qMax(w * h * 4, raw); +} + +// Immutable after publication. Raw planes include MCU padding in their owned +// backing, but only the visible dimensions are uploaded. +struct TrickplayTexture { + QSize size; + QImage rgb; + std::array planes; + std::array strides {}; + bool planar() const + { + return !planes[0].isEmpty(); + } + QImage image() const; +}; + +std::shared_ptr decodeTrickplayTexture(const QByteArray& bytes, const QRect& crop, + const QSize& expectedSize, bool residentSheet, bool accurate, QString *error); + +// Resident sheets are delivered intact; oversized JPEG sheets use codec-side +// clipping. Expected geometry is checked before decoding either representation. +QImage decodeTrickplayFrame( + const QByteArray& bytes, const QRect& crop, const QSize& expectedSize, bool residentSheet, QString *error); + +} // namespace Spool diff --git a/src/app/TrickplayPreviewItem.cpp b/src/app/TrickplayPreviewItem.cpp new file mode 100644 index 00000000..2a8bf7b4 --- /dev/null +++ b/src/app/TrickplayPreviewItem.cpp @@ -0,0 +1,362 @@ +#include "TrickplayPreviewItem.h" +#include "TrickplayService.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(trickplayRender, "spool.trickplay.render", QtWarningMsg) + +namespace Spool { +namespace { + class PlaneTexture final : public QSGTexture { + public: + PlaneTexture(QRhi *rhi, QRhiTexture::Format format, const QSize& size) + : texture(rhi->newTexture(format, size)) + { + setFiltering(Linear); + setMipmapFiltering(None); + setHorizontalWrapMode(ClampToEdge); + setVerticalWrapMode(ClampToEdge); + } + qint64 comparisonKey() const override + { + return qint64(quintptr(texture.get())); + } + QRhiTexture *rhiTexture() const override + { + return texture.get(); + } + QSize textureSize() const override + { + return texture->pixelSize(); + } + bool hasAlphaChannel() const override + { + return false; + } + bool hasMipmaps() const override + { + return false; + } + void stage(QByteArray data, int stride) + { + pending = std::move(data); + rowStride = stride; + } + void stageImage(const QImage& image) + { + pendingImage = image; + } + void commitTextureOperations(QRhi *, QRhiResourceUpdateBatch *batch) override + { + if ((pending.isEmpty() && pendingImage.isNull()) || !batch) + return; + QRhiTextureSubresourceUploadDescription upload = pendingImage.isNull() + ? QRhiTextureSubresourceUploadDescription(pending) + : QRhiTextureSubresourceUploadDescription(pendingImage); + if (pendingImage.isNull()) + upload.setDataStride(quint32(rowStride)); + upload.setSourceSize(textureSize()); + batch->uploadTexture(texture.get(), QRhiTextureUploadDescription { QRhiTextureUploadEntry(0, 0, upload) }); + qCDebug(trickplayRender) << "upload" << textureSize() << "stride" << rowStride; + pending.clear(); + pendingImage = {}; + } + std::unique_ptr texture; + QByteArray pending; + QImage pendingImage; + int rowStride = 0; + }; + + class Material; + class Shader final : public QSGMaterialShader { + public: + explicit Shader(bool planar) + { + setShaderFileName(VertexStage, QStringLiteral(":/trickplay/shaders/preview.vert.qsb")); + setShaderFileName(FragmentStage, + planar ? QStringLiteral(":/trickplay/shaders/yuv.frag.qsb") + : QStringLiteral(":/trickplay/shaders/rgb.frag.qsb")); + } + bool updateUniformData(RenderState&, QSGMaterial *, QSGMaterial *) override; + void updateSampledImage(RenderState&, int, QSGTexture **, QSGMaterial *, QSGMaterial *) override; + }; + class Material final : public QSGMaterial { + public: + explicit Material(bool planar) + : yuv(planar) + { + setFlag(Blending); + } + QSGMaterialType *type() const override + { + static QSGMaterialType rgbType, yuvType; + return yuv ? &yuvType : &rgbType; + } + QSGMaterialShader *createShader(QSGRendererInterface::RenderMode) const override + { + return new Shader(yuv); + } + int compare(const QSGMaterial *other) const override + { + return this == other ? 0 : (std::less()(this, other) ? -1 : 1); + } + bool yuv; + std::array, 3> textures; + std::shared_ptr frame; + QImage fallback; + // options: alpha plane selector, BGRA swizzle; dimensions are actual JPEG, + // not metadata/display dimensions. All per-sheet constants are CPU uniforms. + std::array uniforms; + }; + bool Shader::updateUniformData(RenderState& state, QSGMaterial *value, QSGMaterial *old) + { + auto *material = static_cast(value); + QByteArray *buffer = state.uniformData(); + if (state.isMatrixDirty() || !old) + std::memcpy(buffer->data(), state.combinedMatrix().constData(), 64); + if (state.isOpacityDirty() || !old) { + const float opacity = state.opacity(); + std::memcpy(buffer->data() + 64, &opacity, sizeof(opacity)); + } + for (size_t i = 0; i < material->uniforms.size(); ++i) { + const auto& v = material->uniforms[i]; + const float data[] { v.x(), v.y(), v.z(), v.w() }; + std::memcpy(buffer->data() + 80 + 16 * i, data, 16); + } + return true; + } + void Shader::updateSampledImage( + RenderState& state, int binding, QSGTexture **texture, QSGMaterial *value, QSGMaterial *) + { + auto *material = static_cast(value); + const int index = binding - 1; + if (index < 0 || index >= (material->yuv ? 3 : 1)) + return; + auto *plane = material->textures[size_t(index)].get(); + plane->commitTextureOperations(state.rhi(), state.resourceUpdateBatch()); + *texture = plane; + } + class PreviewNode final : public QSGGeometryNode { + public: + PreviewNode(bool planar, QRhi *backend, QQuickWindow *ownerWindow) + : rhi(backend) + , window(ownerWindow) + , geometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 4) + , material(planar) + { + geometry.setDrawingMode(QSGGeometry::DrawTriangleStrip); + geometry.setVertexDataPattern(QSGGeometry::DynamicPattern); + setGeometry(&geometry); + setMaterial(&material); + } + bool configure(QRhi *rhi, const std::shared_ptr& frame, const QRectF& crop, + const QRectF& destination) + { + const bool newFrame = material.frame != frame; + auto format = material.yuv + ? (rhi->isTextureFormatSupported(QRhiTexture::R8) ? QRhiTexture::R8 : QRhiTexture::RED_OR_ALPHA8) + : (rhi->isTextureFormatSupported(QRhiTexture::BGRA8) ? QRhiTexture::BGRA8 : QRhiTexture::RGBA8); + if (!rhi->isTextureFormatSupported(format)) + return false; + const int limit = rhi->resourceLimit(QRhi::TextureSizeMax); + if (frame->size.width() > limit || frame->size.height() > limit) + return false; + if (newFrame && !material.yuv) + material.fallback = frame->image(); + const QSize chroma((frame->size.width() + 1) / 2, (frame->size.height() + 1) / 2); + const int count = material.yuv ? 3 : 1; + for (int i = 0; i < count; ++i) { + const QSize size = i == 0 ? frame->size : chroma; + auto& texture = material.textures[size_t(i)]; + const bool create + = !texture || texture->textureSize() != size || texture->rhiTexture()->format() != format; + if (create) { + texture = std::make_unique(rhi, format, size); + if (!texture->texture->create()) + return false; + qCDebug(trickplayRender) + << (material.yuv ? "planar Lanczos2" : "RGB Lanczos2") << "texture format" << format << size; + } + if (newFrame || create) { + if (material.yuv) + texture->stage(frame->planes[size_t(i)], frame->strides[size_t(i)]); + else + texture->stageImage(material.fallback); + } + } + material.frame = frame; + const float w = frame->size.width(), h = frame->size.height(); + const float cw = chroma.width(), ch = chroma.height(); + material.uniforms[0] = QVector4D( + material.yuv && format == QRhiTexture::RED_OR_ALPHA8 && !rhi->isFeatureSupported(QRhi::RedOrAlpha8IsRed) + ? 1.f + : 0.f, + !material.yuv && format == QRhiTexture::RGBA8 ? 1.f : 0.f, 0.f, 0.f); + material.uniforms[1] = QVector4D(w, h, 1.f / w, 1.f / h); + material.uniforms[2] + = QVector4D(crop.left() + .5f, crop.top() + .5f, crop.right() - .5f, crop.bottom() - .5f); + material.uniforms[3] = QVector4D(1.f / cw, 1.f / ch, .5f / cw, .5f / ch); + QSGGeometry::updateTexturedRectGeometry( + &geometry, destination, QRectF(crop.x() / w, crop.y() / h, crop.width() / w, crop.height() / h)); + geometry.markVertexDataDirty(); + markDirty(DirtyGeometry | DirtyMaterial); + return true; + } + QRhi *rhi; + QQuickWindow *window; + QSGGeometry geometry; + Material material; + }; + class SoftwareNode final : public QSGSimpleTextureNode { + public: + std::shared_ptr frame; + QImage image; + QRhi *rhi = nullptr; + QQuickWindow *window = nullptr; + }; +} // namespace + +TrickplayPreviewItem::TrickplayPreviewItem(QQuickItem *parent) + : QQuickItem(parent) +{ + setFlag(ItemHasContents); +} +TrickplayPreviewItem::~TrickplayPreviewItem() +{ + if (m_response) { + disconnect(m_response, nullptr, this, nullptr); + connect(m_response, &QQuickImageResponse::finished, m_response, &QObject::deleteLater); + m_response->cancel(); + } +} +void TrickplayPreviewItem::setSource(const QUrl& source) +{ + if (m_source == source) + return; + if (m_response) { + disconnect(m_response, nullptr, this, nullptr); + connect(m_response, &QQuickImageResponse::finished, m_response, &QObject::deleteLater); + m_response->cancel(); + m_response.clear(); + } + m_source = source; + m_frame.reset(); + emit sourceChanged(); + emit readyChanged(); + update(); + auto *engine = qmlEngine(this); + auto *provider = engine && source.scheme() == QLatin1String("image") + ? dynamic_cast(engine->imageProvider(source.host())) + : nullptr; + if (!provider) + return; + QString id = source.path(); + if (id.startsWith('/')) + id.remove(0, 1); + QQuickImageResponse *response = provider->requestImageResponse(id, {}); + if (!response) + return; + m_response = response; + connect(response, &QQuickImageResponse::finished, this, [this, response] { + if (m_response == response) { + std::unique_ptr factory(response->textureFactory()); + if (auto *preview = dynamic_cast(factory.get())) + m_frame = preview->texture; + else if (!response->errorString().isEmpty()) + qCDebug(trickplayRender) << response->errorString(); + m_response.clear(); + emit readyChanged(); + update(); + } + response->deleteLater(); + }); +} +void TrickplayPreviewItem::setCrop(const QRectF& crop) +{ + if (m_crop == crop) + return; + m_crop = crop; + emit cropChanged(); + update(); +} +QSGNode *TrickplayPreviewItem::updatePaintNode(QSGNode *old, UpdatePaintNodeData *) +{ + if (!m_frame || width() <= 0 || height() <= 0) { + delete old; + return nullptr; + } + const QRectF full(QPointF(), QSizeF(m_frame->size)); + const QRectF crop = m_crop.isValid() ? m_crop.intersected(full) : full; + if (crop.isEmpty()) { + delete old; + return nullptr; + } + auto *rhi + = static_cast(window()->rendererInterface()->getResource(window(), QSGRendererInterface::RhiResource)); + auto *fallback = dynamic_cast(old); + const bool retainFallback + = fallback && fallback->frame == m_frame && fallback->rhi == rhi && fallback->window == window(); + // A rejected frame stays on its cached RGB texture until either the decoded + // frame or rendering context changes. Crop/geometry updates need no retry. + if (rhi && !retainFallback) { + const bool planar = m_frame->planar() + && (rhi->isTextureFormatSupported(QRhiTexture::R8) + || rhi->isTextureFormatSupported(QRhiTexture::RED_OR_ALPHA8)); + auto *node = dynamic_cast(old); + if (!node || node->material.yuv != planar || node->rhi != rhi || node->window != window()) { + delete old; + node = new PreviewNode(planar, rhi, window()); + } + if (node->configure(rhi, m_frame, crop, boundingRect())) + return node; + delete node; + old = nullptr; + } + // Software/unsupported RHI paths retain a single RGB texture, not a CPU + // crop or a second resampling pass. Their quality is the backend's filter. + auto *node = dynamic_cast(old); + if (!node || node->rhi != rhi || node->window != window()) { + delete old; + node = new SoftwareNode; + node->setOwnsTexture(true); + node->setFiltering(QSGTexture::Linear); + node->rhi = rhi; + node->window = window(); + } + if (node->frame != m_frame) { + node->image = m_frame->image(); + QSGTexture *texture = window()->createTextureFromImage(node->image); + if (!texture) { + delete node; + return nullptr; + } + QSGTexture *previous = node->texture(); + node->setOwnsTexture(false); + node->setTexture(texture); + node->setOwnsTexture(true); + delete previous; + node->frame = m_frame; + qCDebug(trickplayRender) << "software/backend RGB fallback texture create" << m_frame->size; + } + node->setRect(boundingRect()); + // The backend may downscale an oversized atlas. Source rectangles are in + // texture pixels, whereas crop coordinates refer to the decoded atlas. + const QSize textureSize = node->texture()->textureSize(); + const qreal scaleX = qreal(textureSize.width()) / m_frame->size.width(); + const qreal scaleY = qreal(textureSize.height()) / m_frame->size.height(); + node->setSourceRect(QRectF(crop.x() * scaleX, crop.y() * scaleY, crop.width() * scaleX, crop.height() * scaleY)); + return node; +} +} // namespace Spool diff --git a/src/app/TrickplayPreviewItem.h b/src/app/TrickplayPreviewItem.h new file mode 100644 index 00000000..8f9ac8d9 --- /dev/null +++ b/src/app/TrickplayPreviewItem.h @@ -0,0 +1,47 @@ +#pragma once + +#include "TrickplayDecoder.h" +#include +#include +#include + +class QQuickImageResponse; + +namespace Spool { +class TrickplayPreviewItem : public QQuickItem { + Q_OBJECT + Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged) + Q_PROPERTY(QRectF crop READ crop WRITE setCrop NOTIFY cropChanged) + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) +public: + explicit TrickplayPreviewItem(QQuickItem *parent = nullptr); + ~TrickplayPreviewItem() override; + QUrl source() const + { + return m_source; + } + QRectF crop() const + { + return m_crop; + } + bool ready() const + { + return bool(m_frame); + } + void setSource(const QUrl& source); + void setCrop(const QRectF& crop); +signals: + void sourceChanged(); + void cropChanged(); + void readyChanged(); + +protected: + QSGNode *updatePaintNode(QSGNode *old, UpdatePaintNodeData *) override; + +private: + QUrl m_source; + QRectF m_crop; + QPointer m_response; + std::shared_ptr m_frame; +}; +} // namespace Spool diff --git a/src/app/TrickplayService.cpp b/src/app/TrickplayService.cpp new file mode 100644 index 00000000..60ed3a63 --- /dev/null +++ b/src/app/TrickplayService.cpp @@ -0,0 +1,632 @@ +#include "TrickplayService.h" +#include "../common/TlsTrust.h" +#include "../provider/ProviderLogging.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Spool { +namespace { + constexpr qsizetype MaximumBytes = 32 * 1024 * 1024; +} + +class TrickplayImageResponse final : public QQuickImageResponse { +public: + TrickplayImageResponse(TrickplayService *service, QString id) + : m_service(service) + { + QObject *context = service ? static_cast(service) : QCoreApplication::instance(); + if (context && thread() != context->thread()) + moveToThread(context->thread()); + const QPointer self(this); + QMetaObject::invokeMethod( + context, + [self, id = std::move(id)] { + if (!self) + return; + const auto parts = id.split('/'); + bool generationOk = false, indexOk = false; + const quint64 generation = parts.value(0).toULongLong(&generationOk); + const int index = parts.value(1).toInt(&indexOk); + if (!self->m_service || !generationOk || !indexOk || index < 0 + || generation != self->m_service->m_generation || self->m_cancelled) + self->finish({}, QStringLiteral("Stale preview request")); + else + self->m_service->request(index, self); + }, + Qt::QueuedConnection); + } + QQuickTextureFactory *textureFactory() const override + { + return m_image ? new TrickplayTextureFactory(m_image) : nullptr; + } + QString errorString() const override + { + return m_error; + } + void cancel() override + { + m_cancelled = true; + const QPointer self(this); + QObject *context = m_service ? static_cast(m_service.data()) : QCoreApplication::instance(); + QMetaObject::invokeMethod( + context, + [self] { + if (self) + self->finish({}, QStringLiteral("Cancelled")); + }, + Qt::QueuedConnection); + } + void finish(std::shared_ptr image, QString error) + { + if (m_finished.exchange(true)) + return; + m_image = m_cancelled ? nullptr : std::move(image); + m_error = m_cancelled ? QStringLiteral("Cancelled") : std::move(error); + emit finished(); + } + +private: + QPointer m_service; + std::shared_ptr m_image; + QString m_error; + std::atomic_bool m_cancelled = false; + std::atomic_bool m_finished = false; +}; + +TrickplayTextureFactory::TrickplayTextureFactory(std::shared_ptr frame) + : texture(std::move(frame)) +{ +} + +QSGTexture *TrickplayTextureFactory::createTexture(QQuickWindow *window) const +{ + return window->createTextureFromImage(image()); +} + +QSize TrickplayTextureFactory::textureSize() const +{ + return texture->size; +} +int TrickplayTextureFactory::textureByteCount() const +{ + return int(qint64(texture->size.width()) * texture->size.height() * 4); +} +QImage TrickplayTextureFactory::image() const +{ + return texture->image(); +} + +TrickplayService::TrickplayService(const ArtworkSource *source, TlsTrustController *trust, QObject *parent) + : QObject(parent) + , m_source(source) + , m_network(new QNetworkAccessManager(this)) +{ + // Independent connections, decode pool, and delivery: never queue behind + // posters or their rate-limited scene graph deliveries. + m_pool.setMaxThreadCount(1); + if (trust) + trust->attachNetworkAccessManager(m_network, QStringLiteral("Playback preview")); + m_cancelled = std::make_shared(false); +} + +TrickplayService::~TrickplayService() +{ + clear(); + m_pool.waitForDone(); +} + +void TrickplayService::clear() +{ + ++m_generation; + m_cancelled->store(true); + m_cancelled = std::make_shared(false); + if (m_decodeCancelled) + m_decodeCancelled->store(true); + abortFetch(); + m_pool.clear(); + m_decoding = false; + m_decodeIndex = m_pendingIndex = m_selectedIndex = -1; + m_prefetchAnchor = -1; + m_prefetchDirection = m_prefetchAttempts = 0; + m_direction = 1; + supersede(-1); + m_sheets.clear(); + m_images.clear(); + m_bif = {}; + m_info = {}; + emit changed(); +} + +void TrickplayService::setAccurateDecoding(bool accurate) +{ + if (m_accurateDecoding == accurate) + return; + m_accurateDecoding = accurate; + const TrickplayInfo info = m_info; + setSession(info, m_startSeconds); +} + +void TrickplayService::setEnabled(bool enabled) +{ + if (m_enabled == enabled) + return; + m_enabled = enabled; + const TrickplayInfo info = m_info; + setSession(info, m_startSeconds); +} + +void TrickplayService::setSession(const TrickplayInfo& info, double startSeconds) +{ + clear(); + m_info = info; + m_startSeconds = startSeconds; + if (providerLogEnabled(ProviderLogLevel::Trace)) { + const bool bif = info.format == QLatin1String("bif"); + const bool supported = bif || info.format.isEmpty() || info.format == QLatin1String("sprites"); + const bool missing = info.url.isEmpty() && info.urlTemplate.isEmpty(); + writeProviderLog(ProviderLogLevel::Trace, + QStringLiteral("preview session enabled=%1 availability=%2 format=%3 width=%4 height=%5 count=%6") + .arg(m_enabled) + .arg(missing ? QStringLiteral("missing") + : supported ? QStringLiteral("supported") + : QStringLiteral("unsupported")) + .arg(bif ? QStringLiteral("bif") + : supported ? QStringLiteral("sprites") + : QStringLiteral("unknown")) + .arg(info.width) + .arg(info.height) + .arg(info.thumbnailCount)); + } + if (!m_enabled) + return; + if (info.format == QLatin1String("bif") && !info.url.isEmpty()) + fetch(0); + else if (available()) { + const QVariantMap first = frame(startSeconds); + if (first.value("available").toBool()) + request(textureIndex(first.value("frameIndex").toInt()), nullptr); + } + emit changed(); +} + +bool TrickplayService::available() const +{ + if (!m_enabled) + return false; + if (m_info.format == QLatin1String("bif")) + return !m_info.url.isEmpty() && m_bif.count() > 0 && m_info.width > 0 && m_info.height > 0; + if (!m_info.format.isEmpty() && m_info.format != QLatin1String("sprites")) + return false; + return !m_info.urlTemplate.isEmpty() && m_info.intervalMs > 0 && m_info.width > 0 && m_info.height > 0 + && m_info.tileWidth > 0 && m_info.tileHeight > 0 && qint64(m_info.width) * m_info.height <= 1024 * 1024 + && qint64(m_info.width) * m_info.tileWidth <= 16384 && qint64(m_info.height) * m_info.tileHeight <= 16384 + && qint64(m_info.tileWidth) * m_info.tileHeight <= std::numeric_limits::max(); +} + +bool TrickplayService::residentSheet() const +{ + return m_info.format != QLatin1String("bif") + && qint64(m_info.width) * m_info.height * m_info.tileWidth * m_info.tileHeight * 4 + <= TrickplayDecodedByteBudget; +} + +int TrickplayService::textureIndex(int index) const +{ + if (!residentSheet()) + return index; + const int tiles = m_info.tileWidth * m_info.tileHeight; + return index / tiles * tiles; +} + +QString TrickplayService::resourceUrl(int index) const +{ + if (m_info.format == QLatin1String("bif")) + return m_info.url; + QString url = m_info.urlTemplate; + url.replace(QLatin1String("{index}"), QString::number(index / (m_info.tileWidth * m_info.tileHeight))); + return url; +} + +bool TrickplayService::authorize(int index) +{ + const auto resource = m_source ? m_source->resolveImage(QUrl(resourceUrl(index))) : ArtworkSource::ImageResource {}; + if (resource.url.isValid() && !resource.url.scheme().isEmpty()) + return true; + // Revocation also cancels speculative work and retires decoded credentials. + clear(); + return false; +} + +QVariantMap TrickplayService::frame(double seconds) +{ + QVariantMap result { { "available", false } }; + if (!available() || !std::isfinite(seconds)) + return result; + int index; + if (m_info.format == QLatin1String("bif")) + index = m_bif.frameAt(seconds); + else { + const double value = std::floor(std::max(0.0, seconds) * 1000 / m_info.intervalMs); + if (value > std::numeric_limits::max()) + return result; + index = int(value); + if (m_info.thumbnailCount > 0 && index >= m_info.thumbnailCount) + return result; + } + const QSize size + = m_info.format == QLatin1String("bif") ? m_bif.frameSize(index) : QSize(m_info.width, m_info.height); + if (index < 0 || !size.isValid() || qint64(size.width()) * size.height() > 1024 * 1024) + return result; + if (!authorize(index)) + return result; + select(index); + const bool resident = residentSheet(); + const int tile = resident ? index % (m_info.tileWidth * m_info.tileHeight) : 0; + return { { "available", true }, { "frameIndex", index }, + { "url", QStringLiteral("image://%1/%2/%3").arg(m_providerName).arg(m_generation).arg(textureIndex(index)) }, + { "width", size.width() }, { "height", size.height() }, + { "offsetX", resident ? -(tile % m_info.tileWidth) * size.width() : 0 }, + { "offsetY", resident ? -(tile / m_info.tileWidth) * size.height() : 0 }, + { "sheetWidth", resident ? size.width() * m_info.tileWidth : size.width() }, + { "sheetHeight", resident ? size.height() * m_info.tileHeight : size.height() } }; +} + +void TrickplayService::select(int index) +{ + if (m_selectedIndex >= 0 && index != m_selectedIndex) + m_direction = index > m_selectedIndex ? 1 : -1; + m_selectedIndex = index; + const int key = textureIndex(index); + const int anchor = m_info.format == QLatin1String("bif") ? index : index / (m_info.tileWidth * m_info.tileHeight); + if (anchor != m_prefetchAnchor || m_direction != m_prefetchDirection) { + m_prefetchAnchor = anchor; + m_prefetchDirection = m_direction; + m_prefetchAttempts = 0; + if (m_reply && m_fetchSpeculative && m_fetchIndex != key) + abortFetch(); + if (m_decoding && m_decodeSpeculative && m_decodeIndex != key) + m_decodeCancelled->store(true); + } + supersede(key); + if (m_reply && !m_fetchSpeculative && m_fetchIndex != key) + abortFetch(); + if (m_pendingIndex >= 0 && m_pendingIndex != key) + m_pendingIndex = -1; + prefetch(); +} + +QQuickImageResponse *TrickplayService::requestImageResponse(const QString& id) +{ + return new TrickplayImageResponse(this, id); +} + +void TrickplayService::supersede(int preserveIndex) +{ + for (auto it = m_waiters.begin(); it != m_waiters.end();) { + if (it.key() == preserveIndex) { + ++it; + continue; + } + for (const auto& waiter : std::as_const(it.value())) + if (waiter) + waiter->finish({}, QStringLiteral("Preview superseded")); + it = m_waiters.erase(it); + } +} + +void TrickplayService::request(int index, TrickplayImageResponse *response) +{ + if (!available() || index < 0 || index != textureIndex(index) + || (m_info.format == QLatin1String("bif") && index >= m_bif.count()) + || (m_info.format != QLatin1String("bif") && m_info.thumbnailCount > 0 && index >= m_info.thumbnailCount)) { + if (response) + response->finish({}, QStringLiteral("Invalid preview frame")); + return; + } + // Provider requests are queued independently of descriptor selection. + // A late job must not supersede the newer selection's live delivery. + if (m_selectedIndex < 0 || index != textureIndex(m_selectedIndex)) { + if (response) + response->finish({}, QStringLiteral("Preview superseded")); + return; + } + if (!authorize(index)) { + if (response) + response->finish({}, QStringLiteral("Preview account unavailable")); + return; + } + supersede(index); + if (const auto *image = m_images.object(index)) { + if (m_reply && !m_fetchSpeculative && m_fetchIndex != index) + abortFetch(); + m_pendingIndex = -1; + if (response) + response->finish(*image, {}); + prefetch(); + return; + } + if (m_reply) { + if (m_fetchIndex == index) + m_fetchSpeculative = false; + else + abortFetch(); + } + if (response) + m_waiters[index].append(response); + if (m_decoding) { + if (m_decodeIndex == index && !m_decodeCancelled->load()) { + m_decodeSpeculative = false; + return; + } + if (m_decodeSpeculative) + m_decodeCancelled->store(true); + m_pendingIndex = index; + return; + } + startDecode(index); +} + +void TrickplayService::abortFetch() +{ + if (m_reply) { + disconnect(m_reply, nullptr, this, nullptr); + m_reply->abort(); + m_reply->deleteLater(); + } + m_reply.clear(); + m_fetchIndex = -1; + m_fetchBytes.clear(); + m_fetchUrl.clear(); +} + +void TrickplayService::fetch(int index, bool speculative) +{ + if (!m_enabled) + return; + const QString scoped = resourceUrl(index); + if (m_reply && m_fetchUrl == scoped) { + if (!speculative) { + m_fetchSpeculative = false; + m_fetchIndex = index; + } + return; + } + abortFetch(); + const auto resource = m_source ? m_source->resolveImage(QUrl(scoped)) : ArtworkSource::ImageResource {}; + if (!resource.url.isValid() || resource.url.scheme().isEmpty()) { + clear(); + return; + } + QNetworkRequest request(resource.url); + request.setTransferTimeout(15000); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::SameOriginRedirectPolicy); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CookieLoadControlAttribute, QNetworkRequest::Manual); + request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); + for (const auto& line : resource.headers.split('\n')) { + const auto colon = line.indexOf(':'); + if (colon > 0) + request.setRawHeader(line.first(colon), line.mid(colon + 1).trimmed()); + } + m_fetchUrl = scoped; + m_fetchIndex = index; + m_fetchSpeculative = speculative; + QNetworkReply *reply = m_network->get(request); + m_reply = reply; + connect(reply, &QNetworkReply::readyRead, this, [this, reply] { + if (reply->bytesAvailable() > MaximumBytes - m_fetchBytes.size()) { + reply->abort(); + return; + } + m_fetchBytes += reply->readAll(); + }); + connect(reply, &QNetworkReply::finished, this, [this, reply] { + m_reply.clear(); + const int index = std::exchange(m_fetchIndex, -1); + const bool speculative = m_fetchSpeculative; + QString error = reply->error() == QNetworkReply::NoError ? QString() : reply->errorString(); + if (reply->bytesAvailable() > MaximumBytes - m_fetchBytes.size()) + error = QStringLiteral("Preview exceeds byte budget"); + else + m_fetchBytes += reply->readAll(); + reply->deleteLater(); + QByteArray bytes = std::move(m_fetchBytes); + m_fetchUrl.clear(); + if (!authorize(index)) + return; + if (!error.isEmpty()) { + if (providerLogEnabled(ProviderLogLevel::Trace)) + writeProviderLog(ProviderLogLevel::Trace, + QStringLiteral("preview fetch failed networkCode=%1 status=%2 frame=%3 speculative=%4") + .arg(int(reply->error())) + .arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()) + .arg(index) + .arg(speculative)); + complete(index, {}, error, speculative); + prefetch(); + return; + } + if (m_info.format == QLatin1String("bif")) { + if (!m_bif.parse(std::move(bytes), &error)) { + complete(index, {}, error, false); + return; + } + const int selected = m_bif.frameAt(m_startSeconds); + const QSize size = m_bif.frameSize(selected); + if (!size.isValid() || qint64(size.width()) * size.height() > 1024 * 1024) { + complete(index, {}, QStringLiteral("Invalid BIF frame dimensions"), false); + return; + } + m_info.width = size.width(); + m_info.height = size.height(); + select(selected); + startDecode(selected); + emit changed(); + } else { + const int sheet = index / (m_info.tileWidth * m_info.tileHeight); + m_sheets.insert(sheet, new QByteArray(bytes), int(bytes.size())); + if (speculative) { + const qint64 cost + = trickplayTextureCost(QSize(m_info.width * m_info.tileWidth, m_info.height * m_info.tileHeight)); + if (residentSheet() && !m_decoding && m_images.totalCost() + cost <= TrickplayDecodedByteBudget) + decode(index, std::move(bytes), true); + } else if (m_decoding) + m_pendingIndex = index; + else + decode(index, std::move(bytes)); + } + }); +} + +void TrickplayService::startDecode(int index, bool speculative) +{ + if (m_info.format == QLatin1String("bif")) { + qsizetype offset = 0, length = 0; + QByteArray backing = m_bif.frameBacking(index, &offset, &length); + decode(index, std::move(backing), speculative, offset, length); + } else { + const int sheet = index / (m_info.tileWidth * m_info.tileHeight); + if (const QByteArray *bytes = m_sheets.object(sheet)) + decode(index, *bytes, speculative); + else + fetch(index, speculative); + } +} + +void TrickplayService::decode(int index, QByteArray bytes, bool speculative, qsizetype offset, qsizetype length) +{ + m_decoding = true; + m_decodeSpeculative = speculative; + m_decodeIndex = index; + m_pendingIndex = -1; + m_decodeCancelled = std::make_shared(false); + const bool bif = m_info.format == QLatin1String("bif"); + const bool resident = residentSheet(); + const QSize expected + = bif ? m_bif.frameSize(index) : QSize(m_info.width * m_info.tileWidth, m_info.height * m_info.tileHeight); + QRect crop; + if (!bif) { + const int tile = index % (m_info.tileWidth * m_info.tileHeight); + crop = QRect((tile % m_info.tileWidth) * m_info.width, (tile / m_info.tileWidth) * m_info.height, m_info.width, + m_info.height); + } + const QSize outputSize = resident || bif ? expected : crop.size(); + const qint64 cost = trickplayTextureCost(outputSize); + // Reserve before allocating the foreground image, rather than temporarily + // retaining a full old cache alongside the new sheet. + if (!speculative && m_images.totalCost() + cost > TrickplayDecodedByteBudget) + m_images.clear(); + const quint64 generation = m_generation; + const auto cancelled = m_cancelled; + const auto decodeCancelled = m_decodeCancelled; + const bool accurate = m_accurateDecoding; + m_pool.start(QRunnable::create([this, generation, cancelled, decodeCancelled, index, crop, expected, resident, + accurate, offset, length, bytes = std::move(bytes)] { + QString error; + std::shared_ptr image; + if (!cancelled->load() && !decodeCancelled->load()) { + const QByteArray view + = QByteArray::fromRawData(bytes.constData() + offset, length < 0 ? bytes.size() : length); + image = decodeTrickplayTexture(view, crop, expected, resident, accurate, &error); + } + if (cancelled->load()) + return; + QMetaObject::invokeMethod( + this, + [this, generation, decodeCancelled, index, image = std::move(image), error]() mutable { + if (generation != m_generation) + return; + const bool speculative = m_decodeSpeculative; + m_decoding = false; + m_decodeIndex = -1; + if (!decodeCancelled->load() && authorize(index)) + complete(index, std::move(image), error, speculative); + image = {}; + if (generation != m_generation) + return; + const int pending = std::exchange(m_pendingIndex, -1); + if (pending >= 0) + request(pending, nullptr); + else + prefetch(); + }, + Qt::QueuedConnection); + })); +} + +void TrickplayService::prefetch() +{ + if (m_selectedIndex < 0 || !available() || m_reply || m_decoding || m_pendingIndex >= 0 + || !m_images.contains(textureIndex(m_selectedIndex))) + return; + const bool bif = m_info.format == QLatin1String("bif"); + const int limit = bif ? 2 : 1; + while (m_prefetchAttempts < limit) { + const qint64 neighbour = qint64(m_prefetchAnchor) + qint64(m_direction) * (++m_prefetchAttempts); + const qint64 index = bif ? neighbour : neighbour * m_info.tileWidth * m_info.tileHeight; + // Unknown sheet counts cannot safely speculate beyond the descriptor. + const int count = bif ? m_bif.count() : m_info.thumbnailCount; + if (index < 0 || count <= 0 || index >= count || index > std::numeric_limits::max()) + return; + const int key = textureIndex(int(index)); + if (m_images.contains(key)) + continue; + if (!authorize(key)) + return; + if (bif) { + const QSize size = m_bif.frameSize(key); + const qint64 cost = trickplayTextureCost(size); + if (size.isValid() && cost > 0 && cost <= 4 * 1024 * 1024 + && m_images.totalCost() + cost <= TrickplayDecodedByteBudget) + startDecode(key, true); + return; + } + const int sheet = int(neighbour); + if (m_sheets.contains(sheet)) { + const qint64 cost + = trickplayTextureCost(QSize(m_info.width * m_info.tileWidth, m_info.height * m_info.tileHeight)); + if (residentSheet() && m_images.totalCost() + cost <= TrickplayDecodedByteBudget) + startDecode(key, true); + } else + fetch(key, true); + return; + } +} + +void TrickplayService::complete( + int index, std::shared_ptr image, const QString& error, bool speculative) +{ + if (image) { + // Charge RGB-equivalent bytes: smaller planes do not expand the cache. + const qint64 cost = trickplayTextureCost(image->size); + if (!speculative || m_images.totalCost() + cost <= TrickplayDecodedByteBudget) + m_images.insert(index, new std::shared_ptr(image), int(cost)); + } else if (!error.isEmpty()) { + if (providerLogEnabled(ProviderLogLevel::Trace)) + writeProviderLog(ProviderLogLevel::Trace, + QStringLiteral("preview load failed frame=%1 speculative=%2").arg(index).arg(speculative)); + if (!speculative) + writeProviderLog(ProviderLogLevel::Warn, QStringLiteral("Preview frame unavailable")); + } + const auto waiters = m_waiters.take(index); + for (const auto& waiter : waiters) + if (waiter) + waiter->finish(image, error); +} + +QQuickImageResponse *TrickplayImageProvider::requestImageResponse(const QString& id, const QSize&) +{ + return m_service ? m_service->requestImageResponse(id) : new TrickplayImageResponse(nullptr, id); +} +} // namespace Spool diff --git a/src/app/TrickplayService.h b/src/app/TrickplayService.h new file mode 100644 index 00000000..70b9e1ec --- /dev/null +++ b/src/app/TrickplayService.h @@ -0,0 +1,111 @@ +#pragma once + +#include "../media/MediaTypes.h" +#include "../provider/ArtworkSource.h" +#include "TrickplayDecoder.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +namespace Spool { +class TlsTrustController; +class TrickplayImageResponse; + +class TrickplayTextureFactory final : public QQuickTextureFactory { +public: + explicit TrickplayTextureFactory(std::shared_ptr texture); + QSGTexture *createTexture(QQuickWindow *window) const override; + QSize textureSize() const override; + int textureByteCount() const override; + QImage image() const override; + const std::shared_ptr texture; +}; + +class TrickplayService final : public QObject { + Q_OBJECT +public: + TrickplayService(const ArtworkSource *source, TlsTrustController *trust, QObject *parent = nullptr); + ~TrickplayService() override; + void setSession(const TrickplayInfo& info, double startSeconds); + void setImageProviderName(QString name) + { + m_providerName = std::move(name); + } + void clear(); + void setAccurateDecoding(bool accurate); + void setEnabled(bool enabled); + bool available() const; + QVariantMap frame(double seconds); + QQuickImageResponse *requestImageResponse(const QString& id); +signals: + void changed(); + +private: + friend class TrickplayImageResponse; + bool residentSheet() const; + int textureIndex(int index) const; + QString resourceUrl(int index) const; + bool authorize(int index); + void select(int index); + void supersede(int preserveIndex); + void request(int index, TrickplayImageResponse *response); + void fetch(int index, bool speculative = false); + void abortFetch(); + void decode(int index, QByteArray backing, bool speculative = false, qsizetype offset = 0, qsizetype length = -1); + void startDecode(int index, bool speculative = false); + void prefetch(); + void complete(int index, std::shared_ptr image, const QString& error, bool speculative); + const ArtworkSource *m_source; + QNetworkAccessManager *m_network; + QPointer m_reply; + QThreadPool m_pool; + TrickplayInfo m_info; + BifSequence m_bif; + QCache m_sheets { 32 * 1024 * 1024 }; + QCache> m_images { int(TrickplayDecodedByteBudget) }; + QHash>> m_waiters; + std::shared_ptr m_cancelled; + std::shared_ptr m_decodeCancelled; + QString m_fetchUrl; + QByteArray m_fetchBytes; + QString m_providerName = QStringLiteral("trickplay"); + quint64 m_generation = 0; + bool m_decoding = false; + bool m_decodeSpeculative = false; + bool m_fetchSpeculative = false; + int m_decodeIndex = -1; + int m_fetchIndex = -1; + int m_pendingIndex = -1; + int m_selectedIndex = -1; + int m_direction = 1; + int m_prefetchAnchor = -1; + int m_prefetchDirection = 0; + int m_prefetchAttempts = 0; + double m_startSeconds = 0; + bool m_accurateDecoding = false; + bool m_enabled = true; +}; + +class TrickplayImageProvider final : public QQuickAsyncImageProvider { +public: + explicit TrickplayImageProvider(TrickplayService *service) + : m_service(service) + { + } + QQuickImageResponse *requestImageResponse(const QString& id, const QSize&) override; + +private: + QPointer m_service; +}; +} // namespace Spool diff --git a/src/app/UpdateManifest.cpp b/src/app/UpdateManifest.cpp index 25bd74e2..c0c9171e 100644 --- a/src/app/UpdateManifest.cpp +++ b/src/app/UpdateManifest.cpp @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -111,4 +111,4 @@ UpdateManifestResult selectUpdate( return { std::move(selected), {} }; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/UpdateManifest.h b/src/app/UpdateManifest.h index a0f3644d..3bbc7365 100644 --- a/src/app/UpdateManifest.h +++ b/src/app/UpdateManifest.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative { +namespace Spool { enum class UpdateChannel { Release, @@ -32,4 +32,4 @@ struct UpdateManifestResult { UpdateManifestResult selectUpdate( const QByteArray& manifest, int currentVersionCode, bool allowPrerelease, const QString& assetKey); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/UserItemStateController.cpp b/src/app/UserItemStateController.cpp index 7cc64762..25290033 100644 --- a/src/app/UserItemStateController.cpp +++ b/src/app/UserItemStateController.cpp @@ -1,6 +1,5 @@ #include "UserItemStateController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "BrowseSessionController.h" @@ -8,12 +7,12 @@ #include "HomeModelController.h" #include "SearchController.h" -namespace JellyfinNative { +namespace Spool { -UserItemStateController::UserItemStateController(JellyfinApiFacade *api, BrowseSessionController *currentItems, +UserItemStateController::UserItemStateController(UserItemStateSink *sink, BrowseSessionController *currentItems, HomeModelController *home, ContentModelController *content, SearchController *search, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(sink) , m_browse(currentItems) , m_home(home) , m_content(content) @@ -69,7 +68,7 @@ void UserItemStateController::applyPlayed(const QString& itemId, bool played) } void UserItemStateController::recordPlaybackStopped( - const MovieItem& item, const QString& itemId, qint64 positionTicks, bool completed) + const MovieItem& item, const QString& itemId, qint64 positionTicks, bool completed, const MovieItem& successor) { if (!completed) { applyResumeTicks(itemId, positionTicks); @@ -77,17 +76,23 @@ void UserItemStateController::recordPlaybackStopped( m_home->upsertResumeItem(item, positionTicks); return; } + if (m_home && item.id == itemId) + m_home->advanceNextUp(item, successor); applyPlayed(itemId, true); - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_api || !m_api->signedIn()) return; Async::runScoped( - this, m_api->setItemPlayed(itemId, true), []() {}, + this, m_api->setItemPlayed(itemId, true), + [this]() { + if (m_home) + m_home->refreshPlaybackRows(); + }, [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); } void UserItemStateController::setFavorite(const QString& itemId, bool favorite) { - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) return; applyFavorite(itemId, favorite); Async::runScoped( @@ -100,11 +105,15 @@ void UserItemStateController::setFavorite(const QString& itemId, bool favorite) void UserItemStateController::setPlayed(const QString& itemId, bool played) { - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) return; applyPlayed(itemId, played); Async::runScoped( - this, m_api->setItemPlayed(itemId, played), []() {}, + this, m_api->setItemPlayed(itemId, played), + [this]() { + if (m_home) + m_home->refreshPlaybackRows(); + }, [this, itemId, played](const std::exception_ptr& error) { applyPlayed(itemId, !played); emit errorOccurred(exceptionMessage(error)); @@ -113,7 +122,7 @@ void UserItemStateController::setPlayed(const QString& itemId, bool played) void UserItemStateController::clearProgress(const QString& itemId) { - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) return; applyResumeTicks(itemId, 0); applyPlayed(itemId, false); @@ -122,4 +131,4 @@ void UserItemStateController::clearProgress(const QString& itemId) [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/UserItemStateController.h b/src/app/UserItemStateController.h index 332d41f7..417b44d8 100644 --- a/src/app/UserItemStateController.h +++ b/src/app/UserItemStateController.h @@ -1,28 +1,30 @@ #pragma once +#include "../provider/UserItemStateSink.h" + #include #include -namespace JellyfinNative { +namespace Spool { class ContentModelController; class BrowseSessionController; class HomeModelController; class SearchController; -class JellyfinApiFacade; struct MovieItem; class UserItemStateController final : public QObject { Q_OBJECT public: - UserItemStateController(JellyfinApiFacade *api, BrowseSessionController *currentItems, HomeModelController *home, + UserItemStateController(UserItemStateSink *sink, BrowseSessionController *currentItems, HomeModelController *home, ContentModelController *content, SearchController *search, QObject *parent = nullptr); void applyResumeTicks(const QString& itemId, qint64 positionTicks); void applyFavorite(const QString& itemId, bool favorite); void applyPlayed(const QString& itemId, bool played); - void recordPlaybackStopped(const MovieItem& item, const QString& itemId, qint64 positionTicks, bool completed); + void recordPlaybackStopped( + const MovieItem& item, const QString& itemId, qint64 positionTicks, bool completed, const MovieItem& successor); Q_INVOKABLE void setFavorite(const QString& itemId, bool favorite); Q_INVOKABLE void setPlayed(const QString& itemId, bool played); Q_INVOKABLE void clearProgress(const QString& itemId); @@ -33,11 +35,11 @@ class UserItemStateController final : public QObject { void errorOccurred(const QString& message); private: - JellyfinApiFacade *m_api = nullptr; + UserItemStateSink *m_api = nullptr; BrowseSessionController *m_browse = nullptr; HomeModelController *m_home = nullptr; ContentModelController *m_content = nullptr; SearchController *m_search = nullptr; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/shaders/preview.vert b/src/app/shaders/preview.vert new file mode 100644 index 00000000..53d6c23e --- /dev/null +++ b/src/app/shaders/preview.vert @@ -0,0 +1,17 @@ +#version 440 +layout(location = 0) in vec4 qt_Vertex; +layout(location = 1) in vec2 qt_MultiTexCoord0; +layout(location = 0) out vec2 texCoord; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec4 options; + vec4 dimensions; + vec4 cropBounds; + vec4 chroma; +} ubuf; +void main() +{ + texCoord = qt_MultiTexCoord0; + gl_Position = ubuf.qt_Matrix * qt_Vertex; +} diff --git a/src/app/shaders/rgb.frag b/src/app/shaders/rgb.frag new file mode 100644 index 00000000..1d8d2523 --- /dev/null +++ b/src/app/shaders/rgb.frag @@ -0,0 +1,62 @@ +#version 440 +precision highp float; +layout(location = 0) in highp vec2 texCoord; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec4 options; + vec4 dimensions; + vec4 cropBounds; + vec4 chroma; +} ubuf; +layout(binding = 1) uniform sampler2D rgbTexture; +vec3 sourceColor(vec2 pixel) +{ + vec3 color = texture(rgbTexture, pixel * ubuf.dimensions.zw).rgb; + return mix(color, color.bgr, ubuf.options.y); +} + +// Exact normalized windowed sinc(x)*sinc(x/2), radius 2. Opposite-sign +// outer lobes cannot be bilinear-paired. Only the positive middle pair is +// combined: three samples per axis, nine per quad fragment. +vec4 lanczosWeights(float f) +{ + if (f < 0.00001) + return vec4(0.0, 1.0, 0.0, 0.0); + if (f > 0.99999) + return vec4(0.0, 0.0, 1.0, 0.0); + // The common sin(pi*f) factor cancels during normalization. + float sh = sin(1.570796326794897 * f); + float ch = cos(1.570796326794897 * f); + vec4 d = vec4(f + 1.0, f, 1.0 - f, 2.0 - f); + vec4 w = vec4(-ch, sh, ch, -sh) / (d * d); + return w / dot(w, vec4(1.0)); +} +void main() +{ + vec2 p = texCoord * ubuf.dimensions.xy - vec2(0.5); + vec2 base = floor(p) + vec2(0.5); + vec4 wx = lanczosWeights(fract(p.x)); + vec4 wy = lanczosWeights(fract(p.y)); + vec3 ox = vec3(-1.0, wx.z / (wx.y + wx.z), 2.0); + vec3 oy = vec3(-1.0, wy.z / (wy.y + wy.z), 2.0); + vec3 ax = vec3(wx.x, wx.y + wx.z, wx.w); + vec3 ay = vec3(wy.x, wy.y + wy.z, wy.w); + vec3 sum = vec3(0.0); + vec3 low = vec3(1.0), high = vec3(0.0); + for (int y = 0; y < 3; ++y) { + for (int x = 0; x < 3; ++x) { + // Clamp each source tap, not just the quad UV, to the selected + // tile's pixel centers. Filtering never samples adjacent tiles. + vec2 pixel = clamp(base + vec2(ox[x], oy[y]), ubuf.cropBounds.xy, ubuf.cropBounds.zw); + vec3 color = sourceColor(pixel); + sum += color * (ax[x] * ay[y]); + low = min(low, color); + high = max(high, color); + } + } + // Neighborhood envelope limits overshoot from Lanczos's negative lobes. + vec3 color = clamp(sum, low, high); + fragColor = vec4(clamp(color, vec3(0.0), vec3(1.0)) * ubuf.qt_Opacity, ubuf.qt_Opacity); +} diff --git a/src/app/shaders/yuv.frag b/src/app/shaders/yuv.frag new file mode 100644 index 00000000..9febf754 --- /dev/null +++ b/src/app/shaders/yuv.frag @@ -0,0 +1,72 @@ +#version 440 +precision highp float; +layout(location = 0) in highp vec2 texCoord; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec4 options; + vec4 dimensions; + vec4 cropBounds; + vec4 chroma; +} ubuf; +layout(binding = 1) uniform sampler2D yTexture; +layout(binding = 2) uniform sampler2D uTexture; +layout(binding = 3) uniform sampler2D vTexture; +float planeValue(vec4 value) { return mix(value.r, value.a, ubuf.options.x); } +vec3 sourceColor(vec2 pixel) +{ + float y = planeValue(texture(yTexture, pixel * ubuf.dimensions.zw)); + // JPEG chroma centers are halfway between paired luma centers. Rebuild + // chroma over the original JPEG, including across thumbnail boundaries, + // as a full-JPEG RGB decoder does. Odd dimensions use visible plane sizes. + vec2 c = clamp(pixel * 0.5 * ubuf.chroma.xy, ubuf.chroma.zw, vec2(1.0) - ubuf.chroma.zw); + float cb = planeValue(texture(uTexture, c)) - (128.0 / 255.0); + float cr = planeValue(texture(vTexture, c)) - (128.0 / 255.0); + return clamp(vec3(y + 1.402 * cr, y - 0.344136286 * cb - 0.714136286 * cr, + y + 1.772 * cb), vec3(0.0), vec3(1.0)); +} + +// Exact normalized windowed sinc(x)*sinc(x/2), radius 2. Opposite-sign +// outer lobes cannot be bilinear-paired. Only the positive middle pair is +// combined: three samples per axis, nine per quad fragment. +vec4 lanczosWeights(float f) +{ + if (f < 0.00001) + return vec4(0.0, 1.0, 0.0, 0.0); + if (f > 0.99999) + return vec4(0.0, 0.0, 1.0, 0.0); + // The common sin(pi*f) factor cancels during normalization. + float sh = sin(1.570796326794897 * f); + float ch = cos(1.570796326794897 * f); + vec4 d = vec4(f + 1.0, f, 1.0 - f, 2.0 - f); + vec4 w = vec4(-ch, sh, ch, -sh) / (d * d); + return w / dot(w, vec4(1.0)); +} +void main() +{ + vec2 p = texCoord * ubuf.dimensions.xy - vec2(0.5); + vec2 base = floor(p) + vec2(0.5); + vec4 wx = lanczosWeights(fract(p.x)); + vec4 wy = lanczosWeights(fract(p.y)); + vec3 ox = vec3(-1.0, wx.z / (wx.y + wx.z), 2.0); + vec3 oy = vec3(-1.0, wy.z / (wy.y + wy.z), 2.0); + vec3 ax = vec3(wx.x, wx.y + wx.z, wx.w); + vec3 ay = vec3(wy.x, wy.y + wy.z, wy.w); + vec3 sum = vec3(0.0); + vec3 low = vec3(1.0), high = vec3(0.0); + for (int y = 0; y < 3; ++y) { + for (int x = 0; x < 3; ++x) { + // Clamp each luma tap, not just the quad UV, to the selected + // tile's pixel centers. Chroma retains the JPEG reconstruction above. + vec2 pixel = clamp(base + vec2(ox[x], oy[y]), ubuf.cropBounds.xy, ubuf.cropBounds.zw); + vec3 color = sourceColor(pixel); + sum += color * (ax[x] * ay[y]); + low = min(low, color); + high = max(high, color); + } + } + // Neighborhood envelope limits overshoot from Lanczos's negative lobes. + vec3 color = clamp(sum, low, high); + fragColor = vec4(clamp(color, vec3(0.0), vec3(1.0)) * ubuf.qt_Opacity, ubuf.qt_Opacity); +} diff --git a/src/automation/LocalCommandServer.cpp b/src/automation/LocalCommandServer.cpp new file mode 100644 index 00000000..8fed540d --- /dev/null +++ b/src/automation/LocalCommandServer.cpp @@ -0,0 +1,621 @@ +#include "LocalCommandServer.h" +#include "LocalControlProtocol.h" +#include "app/AppController.h" +#include "app/DownloadManager.h" +#include "app/RouterController.h" +#include "app/SettingsSchema.h" +#include "common/AsyncTask.h" +#include "platform/NativeAppWindow.h" +#include "provider/SourceHub.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Spool { +namespace { + const QStringList SettingKeys { QStringLiteral("playback/seekPreviews"), + QStringLiteral("playback/accurateTrickplay"), QStringLiteral("playback/trickplayPreviewScalePercent"), + QStringLiteral("appearance/uiScalePercent"), QStringLiteral("playback/renderQuality") }; + QJsonObject itemJson(const MovieItem& item, int index) + { + return { { QStringLiteral("index"), index }, { QStringLiteral("id"), item.id }, + { QStringLiteral("title"), item.title }, { QStringLiteral("type"), item.itemType }, + { QStringLiteral("playable"), item.isPlayable() } }; + } +} + +LocalCommandServer::LocalCommandServer(AppController *app, RouterController *router, NativeAppWindow *window, + DownloadManager *downloads, SourceHub *sources) + : m_app(app) + , m_router(router) + , m_window(window) + , m_downloads(downloads) + , m_sources(sources) +{ + m_inputClock.start(); + m_server.setSocketOptions(QLocalServer::UserAccessOption); + m_server.setMaxPendingConnections(8); + connect(&m_server, &QLocalServer::newConnection, this, &LocalCommandServer::acceptConnections); +} +LocalCommandServer::~LocalCommandServer() +{ + stop(); +} + +bool LocalCommandServer::start(const QString& requestedInstance, QString *error) +{ + const QString directory = LocalControl::directory(error); + if (directory.isEmpty()) + return false; + m_instance = requestedInstance.isEmpty() ? QStringLiteral("%1-%2") + .arg(QCoreApplication::applicationPid()) + .arg(QUuid::createUuid().toString(QUuid::Id128).left(8)) + : requestedInstance; + if (!LocalControl::validInstance(m_instance)) { + *error = QStringLiteral("Instance must be 1-64 ASCII letters, digits, dots, underscores or hyphens"); + return false; + } + m_lock = std::make_unique(QDir(directory).filePath(m_instance + QStringLiteral(".lock"))); + if (!m_lock->tryLock(0)) { + *error = QStringLiteral("Instance identifier is already in use"); + return false; + } + const QString nonce = QUuid::createUuid().toString(QUuid::Id128); +#ifdef Q_OS_UNIX + const QString endpoint = QDir(directory).filePath(QStringLiteral("s-") + nonce); +#else + const QString endpoint = QStringLiteral("spool-control-") + nonce; +#endif + if (!m_server.listen(endpoint)) { + *error = QStringLiteral("Cannot listen on the private local socket"); + m_lock.reset(); + return false; + } + m_token = QUuid::createUuid().toString(QUuid::Id128) + QUuid::createUuid().toString(QUuid::Id128); + m_descriptorPath = QDir(directory).filePath(m_instance + QStringLiteral(".json")); + QSaveFile descriptor(m_descriptorPath); + if (!descriptor.open(QIODevice::WriteOnly) + || !descriptor.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner)) { + *error = QStringLiteral("Cannot create a private discovery file"); + stop(); + return false; + } + const QJsonObject data { { QStringLiteral("instance"), m_instance }, { QStringLiteral("endpoint"), endpoint }, + { QStringLiteral("token"), m_token }, { QStringLiteral("pid"), QCoreApplication::applicationPid() } }; + const QByteArray bytes = QJsonDocument(data).toJson(QJsonDocument::Compact); + if (descriptor.write(bytes) != bytes.size() || !descriptor.commit()) { + *error = QStringLiteral("Cannot publish instance discovery"); + stop(); + return false; + } + return true; +} + +void LocalCommandServer::stop() +{ + m_server.close(); + const auto connections = m_connections; + for (QLocalSocket *socket : connections) + socket->abort(); + if (!m_descriptorPath.isEmpty()) { + QFile::remove(m_descriptorPath); + m_descriptorPath.clear(); + } + m_lock.reset(); +} + +void LocalCommandServer::acceptConnections() +{ + while (m_server.hasPendingConnections()) { + QLocalSocket *socket = m_server.nextPendingConnection(); + socket->setParent(this); + if (m_connections.size() >= 8) { + socket->abort(); + socket->deleteLater(); + continue; + } + m_connections.insert(socket); + socket->setReadBufferSize(LocalControl::MaxRequestBytes + 1); + connect(socket, &QLocalSocket::disconnected, this, [this, socket] { + m_connections.remove(socket); + socket->deleteLater(); + }); + QTimer::singleShot(LocalControl::RequestTimeoutMs, socket, [this, socket] { + reject(socket, socket->property("requestId").toString(), QStringLiteral("timeout"), + QStringLiteral("Request did not complete before the server deadline")); + socket->abort(); + }); + auto buffer = std::make_shared(); + connect(socket, &QLocalSocket::readyRead, socket, [this, socket, buffer] { + if (socket->property("dispatched").toBool()) { + if (socket->bytesAvailable() > 0) + socket->abort(); + return; + } + *buffer += socket->readAll(); + if (buffer->size() > LocalControl::MaxRequestBytes) { + reject(socket, {}, QStringLiteral("request_too_large"), QStringLiteral("Request exceeds 64 KiB")); + return; + } + const qsizetype newline = buffer->indexOf('\n'); + if (newline < 0) + return; + socket->setProperty("dispatched", true); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(buffer->left(newline), &error); + if (newline != buffer->size() - 1 || error.error != QJsonParseError::NoError || !document.isObject()) { + reject(socket, {}, QStringLiteral("invalid_request"), + QStringLiteral("Expected one newline-framed JSON object")); + return; + } + const QJsonObject request = document.object(); + const QString id = request.value(QStringLiteral("id")).toString(); + socket->setProperty("requestId", id); + if (id.isEmpty() || id.size() > 128 || !request.value(QStringLiteral("args")).isObject() + || !request.value(QStringLiteral("command")).isString()) { + reject(socket, id, QStringLiteral("invalid_request"), + QStringLiteral("Missing or invalid id, command or args")); + return; + } + if (request.value(QStringLiteral("token")).toString() != m_token) { + reject(socket, id, QStringLiteral("unauthorized"), QStringLiteral("Invalid local capability")); + return; + } + dispatch(socket, request); + }); + } +} + +void LocalCommandServer::finish(QLocalSocket *socket, const QString& id, const QJsonObject& result) +{ + if (socket->property("finished").toBool()) + return; + socket->setProperty("finished", true); + QByteArray bytes = QJsonDocument(result).toJson(QJsonDocument::Compact) + '\n'; + if (bytes.size() > LocalControl::MaxResponseBytes) + bytes = QJsonDocument(LocalControl::failure(id, QStringLiteral("response_too_large"), + QStringLiteral("Result exceeds 1 MiB; request a smaller page"))) + .toJson(QJsonDocument::Compact) + + '\n'; + socket->write(bytes); + socket->disconnectFromServer(); +} +void LocalCommandServer::reject(QLocalSocket *socket, const QString& id, const QString& code, const QString& message) +{ + finish(socket, id, LocalControl::failure(id, code, message)); +} + +QJsonObject LocalCommandServer::state() const +{ + const auto player = m_app->player(); + // Deliberate allowlist: no URLs, provider properties, credentials, logs or arbitrary QML values. + return { { QStringLiteral("instance"), m_instance }, { QStringLiteral("pid"), QCoreApplication::applicationPid() }, + { QStringLiteral("initialized"), m_app->initialized() }, { QStringLiteral("route"), m_router->route() }, + { QStringLiteral("canBack"), m_router->canPop() }, { QStringLiteral("busy"), m_app->property("busy").toBool() }, + { QStringLiteral("appError"), !m_app->property("errorText").toString().isEmpty() }, + { QStringLiteral("browseLoading"), m_app->browse()->loadingMore() }, + { QStringLiteral("homeLoading"), m_app->home()->loading() }, + { QStringLiteral("visual"), QJsonObject::fromVariantMap(m_window->automationVisualState()) }, + { QStringLiteral("source"), QJsonObject::fromVariantMap(m_app->streamingQualitySource()) }, + { QStringLiteral("playback"), + QJsonObject { { QStringLiteral("active"), player->sessionActive() }, + { QStringLiteral("loaded"), player->fileLoaded() }, { QStringLiteral("paused"), player->paused() }, + { QStringLiteral("title"), player->title() }, { QStringLiteral("position"), player->positionSeconds() }, + { QStringLiteral("duration"), player->durationSeconds() }, + { QStringLiteral("failed"), !player->errorText().isEmpty() }, + { QStringLiteral("embeddedVideo"), player->embeddedVideoOutput() }, + { QStringLiteral("buffering"), player->buffering() } } }, + { QStringLiteral("window"), + QJsonObject { { QStringLiteral("width"), m_window->width() }, + { QStringLiteral("height"), m_window->height() }, { QStringLiteral("exposed"), m_window->isExposed() }, + { QStringLiteral("devicePixelRatio"), m_window->devicePixelRatio() } } } }; +} + +QJsonObject LocalCommandServer::items(const QJsonObject& args) const +{ + const QString kind = args.value(QStringLiteral("kind")).toString(QStringLiteral("browse")); + const int offset = args.value(QStringLiteral("offset")).toInt(0); + const int limit = args.value(QStringLiteral("limit")).toInt(100); + QJsonArray rows; + int count = 0; + if (kind == QStringLiteral("libraries")) { + const auto& libraries = m_app->libraries()->libraries(); + count = int(libraries.size()); + for (int index = offset; index < count && index - offset < limit; ++index) { + const auto& library = libraries[size_t(index)]; + rows.append(QJsonObject { { QStringLiteral("index"), index }, { QStringLiteral("id"), library.id }, + { QStringLiteral("title"), library.name }, { QStringLiteral("type"), library.collectionType } }); + } + } else { + MovieGridModel *model = kind == QStringLiteral("resume") ? m_app->home()->resumeItems() + : kind == QStringLiteral("next-up") ? m_app->home()->nextUpItems() + : m_app->browse()->items(); + count = model->count(); + for (int index = offset; index < count && index - offset < limit; ++index) + rows.append(itemJson(model->movieAt(index), index)); + } + return { { QStringLiteral("kind"), kind }, { QStringLiteral("offset"), offset }, { QStringLiteral("count"), count }, + { QStringLiteral("items"), rows }, + { QStringLiteral("hasMore"), kind == QStringLiteral("browse") && m_app->browse()->hasMore() } }; +} + +void LocalCommandServer::screenshot(QLocalSocket *socket, const QString& id, const QString& path) +{ + if (!m_window->isExposed() || m_window->width() <= 0 || m_window->height() <= 0) { + reject(socket, id, QStringLiteral("not_exposed"), + QStringLiteral("Window must be exposed to capture a rendered frame")); + return; + } + // Swap notification originates on the render thread. Readback and saving run on the GUI thread, after that frame. + connect( + m_window, &QQuickWindow::frameSwapped, socket, + [this, socket, id, path] { + if (socket->property("finished").toBool() || socket->state() != QLocalSocket::ConnectedState) + return; + const QImage image = m_window->grabWindow(); + if (image.isNull()) { + reject(socket, id, QStringLiteral("capture_failed"), + QStringLiteral("Qt framebuffer readback returned no image")); + return; + } + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) + || !file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner) || !image.save(&file, "PNG") + || !file.commit()) { + reject(socket, id, QStringLiteral("save_failed"), + QStringLiteral("Could not atomically save the PNG file")); + return; + } + const bool videoIncluded = !m_app->player()->sessionActive() + || m_app->player()->mediaKind() != QStringLiteral("video") || m_app->player()->embeddedVideoOutput(); + const QJsonObject result { { QStringLiteral("path"), path }, { QStringLiteral("width"), image.width() }, + { QStringLiteral("height"), image.height() }, { QStringLiteral("bytes"), QFileInfo(path).size() }, + { QStringLiteral("format"), QStringLiteral("PNG") }, { QStringLiteral("frameAwaited"), true }, + { QStringLiteral("videoIncluded"), videoIncluded }, + { QStringLiteral("capture"), QStringLiteral("QQuickWindow::grabWindow after frameSwapped") }, + { QStringLiteral("limitation"), + videoIncluded + ? QStringLiteral("Qt window framebuffer, including embedded scene-graph video; PNG is not a " + "calibrated HDR capture") + : QStringLiteral("Video is on a separate native plane; this captures the Qt UI only") }, + { QStringLiteral("state"), state() } }; + finish(socket, id, + { { QStringLiteral("id"), id }, { QStringLiteral("ok"), true }, { QStringLiteral("result"), result } }); + }, + Qt::ConnectionType(Qt::QueuedConnection | Qt::SingleShotConnection)); + m_window->requestUpdate(); +} + +void LocalCommandServer::dispatch(QLocalSocket *socket, const QJsonObject& request) +{ + const QString id = request.value(QStringLiteral("id")).toString(); + const QString command = request.value(QStringLiteral("command")).toString(); + const QJsonObject args = request.value(QStringLiteral("args")).toObject(); + const auto done = [this, socket, id](const QJsonObject& result) { + finish(socket, id, + { { QStringLiteral("id"), id }, { QStringLiteral("ok"), true }, { QStringLiteral("result"), result } }); + }; + const auto invalid = [this, socket, id](const QString& message) { + reject(socket, id, QStringLiteral("invalid_argument"), message); + }; + if (command == QStringLiteral("status") || command == QStringLiteral("state")) { + done(state()); + return; + } + if (!m_app->initialized()) { + reject(socket, id, QStringLiteral("not_ready"), QStringLiteral("Application initialization has not completed")); + return; + } + if (command == QStringLiteral("items")) { + const QString kind = args.value(QStringLiteral("kind")).toString(QStringLiteral("browse")); + if (!QStringList { QStringLiteral("browse"), QStringLiteral("libraries"), QStringLiteral("resume"), + QStringLiteral("next-up") } + .contains(kind) + || args.value(QStringLiteral("offset")).toInt(0) < 0 || args.value(QStringLiteral("limit")).toInt(100) < 1 + || args.value(QStringLiteral("limit")).toInt(100) > 500) { + invalid(QStringLiteral("Kind must be browse/libraries/resume/next-up; offset >= 0, limit 1..500")); + return; + } + done(items(args)); + } else if (command == QStringLiteral("downloads")) { + const QString action = args.value(QStringLiteral("action")).toString(QStringLiteral("list")); + if (action == QStringLiteral("list")) { + QJsonArray jobs; + for (const QVariant& value : m_downloads->jobs()) { + const QVariantMap row = value.toMap(); + QJsonObject job; + for (const auto& key : { "id", "itemId", "title", "state", "received", "total", "quality" }) + job.insert(QLatin1String(key), QJsonValue::fromVariant(row.value(QLatin1String(key)))); + job.insert(QStringLiteral("failed"), + row.value(QStringLiteral("state")).toString() == QStringLiteral("failed")); + jobs.append(job); + } + done({ { QStringLiteral("jobs"), jobs } }); + } else if (action == QStringLiteral("options") || action == QStringLiteral("start")) { + const QString itemId = args.value(QStringLiteral("itemId")).toString(); + const QVariantList options = m_sources->downloadOptions(itemId); + if (options.isEmpty()) { + invalid(QStringLiteral("Select an item from a download-capable account")); + return; + } + if (action == QStringLiteral("options")) { + QJsonArray rows; + for (qsizetype index = 0; index < options.size(); ++index) { + QJsonObject row = QJsonObject::fromVariantMap(options.at(index).toMap()); + row.insert(QStringLiteral("index"), int(index)); + rows.append(row); + } + done({ { QStringLiteral("options"), rows } }); + } else { + const int index = args.value(QStringLiteral("index")).toInt(-1); + if (index < 0 || index >= options.size()) { + invalid(QStringLiteral("Select an index from downloads options")); + return; + } + m_downloads->start(itemId, options.at(index).toMap()); + done({ { QStringLiteral("accepted"), true }, + { QStringLiteral("completion"), + QStringLiteral("Poll downloads list; provider choice may be required") } }); + } + } else if (action == QStringLiteral("cancel") || action == QStringLiteral("retry") + || action == QStringLiteral("remove") || action == QStringLiteral("play")) { + const QString jobId = args.value(QStringLiteral("jobId")).toString(); + bool found = false; + for (const QVariant& value : m_downloads->jobs()) { + if (value.toMap().value(QStringLiteral("id")).toString() == jobId) { + found = true; + break; + } + } + if (!found) { + invalid(QStringLiteral("Select a job ID from downloads list")); + return; + } + if (action == QStringLiteral("play")) { + const QString itemId = m_downloads->offlineItemId(jobId); + if (itemId.isEmpty()) { + invalid(QStringLiteral("Download is not available for offline playback")); + return; + } + m_app->playLocalItemId(itemId); + } else if (action == QStringLiteral("cancel")) { + m_downloads->cancel(jobId); + } else if (action == QStringLiteral("retry")) { + m_downloads->retry(jobId); + } else { + m_downloads->remove(jobId); + } + done({ { QStringLiteral("accepted"), true } }); + } else { + invalid(QStringLiteral("Unknown downloads action")); + } + } else if (command == QStringLiteral("qualities")) { + const QVariantList inventory = m_app->streamingQualityOptions(); + QJsonArray options; + for (qsizetype index = 0; index < inventory.size(); ++index) { + QJsonObject option = QJsonObject::fromVariantMap(inventory.at(index).toMap()); + option.insert(QStringLiteral("index"), int(index)); + options.append(option); + } + done({ { QStringLiteral("options"), options }, + { QStringLiteral("source"), QJsonObject::fromVariantMap(m_app->streamingQualitySource()) }, + { QStringLiteral("policy"), m_app->connectionSpeedDescription() }, { QStringLiteral("state"), state() } }); + } else if (command == QStringLiteral("quality")) { + const int index = args.value(QStringLiteral("index")).toInt(-1); + const QVariantList options = m_app->streamingQualityOptions(); + if (!m_app->player()->sessionActive() || index < 0 || index >= options.size()) { + invalid(QStringLiteral("Select an index from qualities while playback is active")); + return; + } + const QVariantMap row = options.at(index).toMap(); + m_app->selectStreamingQuality( + row.value(QStringLiteral("bitrate")).toLongLong(), row.value(QStringLiteral("height")).toInt()); + done({ { QStringLiteral("accepted"), true }, { QStringLiteral("option"), QJsonObject::fromVariantMap(row) }, + { QStringLiteral("completion"), + QStringLiteral("Stream renegotiation is asynchronous; poll status and qualities") } }); + } else if (command == QStringLiteral("screenshot")) { + const QString path = args.value(QStringLiteral("path")).toString(); + if (!QDir::isAbsolutePath(path) || path.size() > 4096) { + invalid(QStringLiteral("Screenshot requires an absolute output path")); + return; + } + screenshot(socket, id, path); + } else if (command == QStringLiteral("preview")) { + const double seconds = args.value(QStringLiteral("seconds")).toDouble(-1); + const double duration = m_app->player()->durationSeconds(); + if (!m_app->player()->sessionActive() || !m_app->player()->trickplayAvailable() || !std::isfinite(seconds) + || seconds < 0 || duration <= 0 || seconds > duration) { + invalid(QStringLiteral("Preview requires available trickplay and seconds within the active timeline")); + return; + } + const QVariantMap bar = m_window->automationVisualState().value(QStringLiteral("seekBar")).toMap(); + if (bar.isEmpty() || bar.value(QStringLiteral("width")).toDouble() <= 0) { + reject(socket, id, QStringLiteral("controls_hidden"), + QStringLiteral("Raise playback controls with key up, then retry preview")); + return; + } + const QPointF local(bar.value(QStringLiteral("x")).toDouble() + + qBound(0.001, seconds / duration, 0.999) * bar.value(QStringLiteral("width")).toDouble(), + bar.value(QStringLiteral("y")).toDouble() + bar.value(QStringLiteral("height")).toDouble() / 2.0); + const QPointF global(m_window->mapToGlobal(local.toPoint())); + QMouseEvent move(QEvent::MouseMove, local, global, Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(m_window, &move); + done({ { QStringLiteral("accepted"), true }, { QStringLiteral("seconds"), seconds }, + { QStringLiteral("completion"), + QStringLiteral( + "Actual timeline hover dispatched; poll state.visual.previewTexture.ready before screenshot") }, + { QStringLiteral("state"), state() } }); + } else if (command == QStringLiteral("pointer")) { + const QString action = args.value(QStringLiteral("action")).toString(); + const double x = args.value(QStringLiteral("x")).toDouble(-1); + const double y = args.value(QStringLiteral("y")).toDouble(-1); + if (!QStringList { QStringLiteral("move"), QStringLiteral("click"), QStringLiteral("press"), + QStringLiteral("release"), QStringLiteral("right-click") } + .contains(action) + || !std::isfinite(x) || !std::isfinite(y) || x < 0 || y < 0 || x >= m_window->width() + || y >= m_window->height()) { + invalid(QStringLiteral("Pointer action must be move/click/press/release/right-click within the window")); + return; + } + const QPointF local(x, y); + const QPointF global(m_window->mapToGlobal(local.toPoint())); + QMouseEvent move(QEvent::MouseMove, local, global, Qt::NoButton, m_pointerButtons, Qt::NoModifier); + move.setTimestamp(ulong(m_inputClock.elapsed())); + QCoreApplication::sendEvent(m_window, &move); + if (action == QStringLiteral("click") || action == QStringLiteral("right-click") + || action == QStringLiteral("press")) { + const auto button = action == QStringLiteral("right-click") ? Qt::RightButton : Qt::LeftButton; + if (m_pointerButtons != Qt::NoButton) { + invalid(QStringLiteral("Release the held pointer button before another press")); + return; + } + m_pointerButtons = button; + QMouseEvent press(QEvent::MouseButtonPress, local, global, button, m_pointerButtons, Qt::NoModifier); + press.setTimestamp(ulong(m_inputClock.elapsed())); + QCoreApplication::sendEvent(m_window, &press); + } + if (action == QStringLiteral("click") || action == QStringLiteral("right-click") + || action == QStringLiteral("release")) { + const auto button = m_pointerButtons.testFlag(Qt::RightButton) ? Qt::RightButton : Qt::LeftButton; + m_pointerButtons = Qt::NoButton; + QMouseEvent release(QEvent::MouseButtonRelease, local, global, button, m_pointerButtons, Qt::NoModifier); + release.setTimestamp(ulong(m_inputClock.elapsed())); + QCoreApplication::sendEvent(m_window, &release); + } + done(state()); + } else if (command == QStringLiteral("settings-get") || command == QStringLiteral("settings-set")) { + const QString key = args.value(QStringLiteral("key")).toString(); + if (!key.isEmpty() && !SettingKeys.contains(key)) { + invalid(QStringLiteral( + "Only seek preview enable/quality/scale, UI scale and render quality settings are exposed")); + return; + } + if (command == QStringLiteral("settings-get")) { + QJsonObject values; + for (const QString& allowed : SettingKeys) { + if (key.isEmpty() || key == allowed) + values.insert(allowed, QJsonValue::fromVariant(m_app->settings()->value(allowed))); + } + done({ { QStringLiteral("values"), values } }); + return; + } + const SettingSpec *spec = findSettingSpec(key); + const QVariant value = args.value(QStringLiteral("value")).toVariant(); + if (!spec || !spec->persisted || !settingSupportedOnPlatform(*spec) || !settingAcceptsValue(*spec, value)) { + invalid(QStringLiteral("Value rejected by the application's settings schema")); + return; + } + if (m_settingPending) { + reject(socket, id, QStringLiteral("busy"), QStringLiteral("A local setting write is still pending")); + return; + } + m_settingPending = true; + QObject *operation = new QObject(socket); + connect(operation, &QObject::destroyed, this, [this] { m_settingPending = false; }); + Async::runScoped( + operation, m_app->settings()->applyValues({ { key, value } }, ChangeOrigin::User), + [this, done, operation, key] { + done({ { QStringLiteral("key"), key }, + { QStringLiteral("value"), QJsonValue::fromVariant(m_app->settings()->value(key)) } }); + operation->deleteLater(); + }, + [this, socket, id, operation](const std::exception_ptr&) { + reject(socket, id, QStringLiteral("settings_failed"), + QStringLiteral("Application could not commit the setting")); + operation->deleteLater(); + }, + "local control setting"); + } else if (command == QStringLiteral("library")) { + if (!m_app->openLibraryById(args.value(QStringLiteral("id")).toString())) { + invalid(QStringLiteral("Library ID is not in the current qualified library inventory")); + return; + } + m_router->push(QStringLiteral("libraryGrid"), + { { QStringLiteral("libraryId"), args.value(QStringLiteral("id")).toString() } }); + done(state()); + } else if (command == QStringLiteral("load-more")) { + m_app->browse()->prefetchNextPage(); + done(state()); + } else if (command == QStringLiteral("navigate") || command == QStringLiteral("home")) { + const QString route = command == QStringLiteral("home") ? QStringLiteral("home") + : args.value(QStringLiteral("route")).toString(); + if (!QStringList { QStringLiteral("home"), QStringLiteral("search"), QStringLiteral("settings") }.contains( + route)) { + invalid(QStringLiteral("Navigation supports home, search or settings")); + return; + } + m_app->handleLocalControl( + { { QStringLiteral("command"), QStringLiteral("navigate") }, { QStringLiteral("to"), route } }); + done(state()); + } else if (command == QStringLiteral("back") || command == QStringLiteral("key")) { + QString name = command == QStringLiteral("back") ? QStringLiteral("back") + : args.value(QStringLiteral("name")).toString().toLower(); + if (name == QStringLiteral("ok")) + name = QStringLiteral("select"); + if (!QStringList { QStringLiteral("up"), QStringLiteral("down"), QStringLiteral("left"), + QStringLiteral("right"), QStringLiteral("select"), QStringLiteral("back"), QStringLiteral("space"), + QStringLiteral("home"), QStringLiteral("end"), QStringLiteral("pageup"), QStringLiteral("pagedown") } + .contains(name)) { + invalid(QStringLiteral("Unsupported navigation key")); + return; + } + m_window->bringToFront(); + // The actual app window is the target even if the WM denies activation to a background CLI. + const QHash keys { { QStringLiteral("up"), Qt::Key_Up }, { QStringLiteral("down"), Qt::Key_Down }, + { QStringLiteral("left"), Qt::Key_Left }, { QStringLiteral("right"), Qt::Key_Right }, + { QStringLiteral("select"), Qt::Key_Return }, { QStringLiteral("back"), Qt::Key_Back }, + { QStringLiteral("space"), Qt::Key_Space }, { QStringLiteral("home"), Qt::Key_Home }, + { QStringLiteral("end"), Qt::Key_End }, { QStringLiteral("pageup"), Qt::Key_PageUp }, + { QStringLiteral("pagedown"), Qt::Key_PageDown } }; + QKeyEvent press(QEvent::KeyPress, keys.value(name), Qt::NoModifier); + QKeyEvent release(QEvent::KeyRelease, keys.value(name), Qt::NoModifier); + QCoreApplication::sendEvent(m_window, &press); + QCoreApplication::sendEvent(m_window, &release); + done(state()); + } else if (command == QStringLiteral("play")) { + if (!m_app->handleLocalControl({ { QStringLiteral("command"), command }, + { QStringLiteral("itemId"), args.value(QStringLiteral("id")).toString() }, + { QStringLiteral("fromStart"), args.value(QStringLiteral("fromStart")).toBool() } })) { + invalid(QStringLiteral("Play requires an explicit account-qualified item ID from items")); + return; + } + done({ { QStringLiteral("accepted"), true }, + { QStringLiteral("completion"), QStringLiteral("Playback starts asynchronously; poll status") } }); + } else if (command == QStringLiteral("pause") || command == QStringLiteral("resume") + || command == QStringLiteral("stop") || command == QStringLiteral("seek")) { + if (!m_app->player()->sessionActive()) { + reject(socket, id, QStringLiteral("no_playback"), QStringLiteral("No active local playback session")); + return; + } + QVariantMap control { { QStringLiteral("command"), + command == QStringLiteral("resume") ? QStringLiteral("unpause") : command } }; + if (command == QStringLiteral("seek")) { + const double seconds = args.value(QStringLiteral("seconds")).toDouble(-1); + if (!std::isfinite(seconds) || seconds < 0 || seconds > m_app->player()->durationSeconds()) { + invalid(QStringLiteral("Seek seconds must be within the active timeline")); + return; + } + control.insert(QStringLiteral("positionTicks"), qint64(seconds * 10'000'000.0)); + } + m_app->handleLocalControl(control); + done(state()); + } else { + reject( + socket, id, QStringLiteral("unknown_command"), QStringLiteral("Unknown local command; see spoolet help")); + } +} +} diff --git a/src/automation/LocalCommandServer.h b/src/automation/LocalCommandServer.h new file mode 100644 index 00000000..38f9fc53 --- /dev/null +++ b/src/automation/LocalCommandServer.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class QLocalSocket; +class QLockFile; + +namespace Spool { +class AppController; +class DownloadManager; +class SourceHub; +class NativeAppWindow; +class RouterController; + +class LocalCommandServer final : public QObject { +public: + LocalCommandServer(AppController *app, RouterController *router, NativeAppWindow *window, + DownloadManager *downloads, SourceHub *sources); + ~LocalCommandServer() override; + bool start(const QString& instance, QString *error); + void stop(); + +private: + void acceptConnections(); + void dispatch(QLocalSocket *socket, const QJsonObject& request); + void finish(QLocalSocket *socket, const QString& id, const QJsonObject& result); + void reject(QLocalSocket *socket, const QString& id, const QString& code, const QString& message); + QJsonObject state() const; + QJsonObject items(const QJsonObject& args) const; + void screenshot(QLocalSocket *socket, const QString& id, const QString& path); + + AppController *m_app; + RouterController *m_router; + NativeAppWindow *m_window; + DownloadManager *m_downloads; + SourceHub *m_sources; + QLocalServer m_server; + QSet m_connections; + std::unique_ptr m_lock; + QString m_descriptorPath; + QString m_instance; + QString m_token; + bool m_settingPending = false; + Qt::MouseButtons m_pointerButtons = Qt::NoButton; + QElapsedTimer m_inputClock; +}; +} diff --git a/src/automation/LocalControlProtocol.cpp b/src/automation/LocalControlProtocol.cpp new file mode 100644 index 00000000..c19c5c63 --- /dev/null +++ b/src/automation/LocalControlProtocol.cpp @@ -0,0 +1,141 @@ +#include "LocalControlProtocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#include +#endif + +namespace Spool::LocalControl { +QString directory(QString *error) +{ +#ifdef Q_OS_UNIX + const QString base = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); +#else + const QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); +#endif + if (base.isEmpty()) { + *error = QStringLiteral("No user-local runtime directory is available"); + return {}; + } + const QString path = QDir(base).filePath(QStringLiteral("spool-control")); + if (!QFileInfo::exists(path) && !QDir().mkdir(path)) { + *error = QStringLiteral("Cannot create the local control directory"); + return {}; + } +#ifdef Q_OS_UNIX + struct stat info {}; + const QByteArray encoded = QFile::encodeName(path); + if (lstat(encoded.constData(), &info) != 0 || !S_ISDIR(info.st_mode) || info.st_uid != geteuid() + || chmod(encoded.constData(), 0700) != 0) { + *error = QStringLiteral("Local control directory is not a private owned directory"); + return {}; + } +#else + if (QFileInfo(path).isSymLink() || !QFileInfo(path).isDir()) { + *error = QStringLiteral("Local control directory is not a user directory"); + return {}; + } +#endif + return path; +} + +bool validInstance(const QString& instance) +{ + static const QRegularExpression expression(QStringLiteral("^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")); + return expression.match(instance).hasMatch(); +} + +bool privateFile(const QString& path) +{ +#ifdef Q_OS_UNIX + struct stat info {}; + const QByteArray encoded = QFile::encodeName(path); + return lstat(encoded.constData(), &info) == 0 && S_ISREG(info.st_mode) && info.st_uid == geteuid() + && (info.st_mode & 0077) == 0; +#else + return QFileInfo(path).isFile() && !QFileInfo(path).isSymLink(); +#endif +} + +QJsonObject readDescriptor(const QString& path) +{ + if (!privateFile(path)) + return {}; + QFile file(path); + if (!file.open(QIODevice::ReadOnly) || file.size() > 4096) + return {}; + const QJsonObject result = QJsonDocument::fromJson(file.readAll()).object(); + const QString instance = result.value(QStringLiteral("instance")).toString(); + const QString endpoint = result.value(QStringLiteral("endpoint")).toString(); + const QString token = result.value(QStringLiteral("token")).toString(); + if (!validInstance(instance) || token.size() != 64 || endpoint.isEmpty() || endpoint.size() > 256) + return {}; +#ifdef Q_OS_UNIX + if (QFileInfo(endpoint).absolutePath() != QFileInfo(path).absolutePath() + || !QFileInfo(endpoint).fileName().startsWith(QStringLiteral("s-"))) + return {}; +#else + if (!endpoint.startsWith(QStringLiteral("spool-control-")) || endpoint.contains(QLatin1Char('/')) + || endpoint.contains(QLatin1Char('\\'))) + return {}; +#endif + return result; +} + +QJsonObject failure(const QString& id, const QString& code, const QString& message) +{ + return { { QStringLiteral("id"), id }, { QStringLiteral("ok"), false }, + { QStringLiteral("error"), + QJsonObject { { QStringLiteral("code"), code }, { QStringLiteral("message"), message } } } }; +} + +QJsonObject request(const QJsonObject& descriptor, const QString& command, const QJsonObject& arguments, int timeoutMs) +{ + const QString id = QUuid::createUuid().toString(QUuid::WithoutBraces); + QJsonObject message { { QStringLiteral("id"), id }, { QStringLiteral("command"), command }, + { QStringLiteral("args"), arguments }, { QStringLiteral("token"), descriptor.value(QStringLiteral("token")) } }; + const QByteArray bytes = QJsonDocument(message).toJson(QJsonDocument::Compact) + '\n'; + if (bytes.size() > MaxRequestBytes) + return failure(id, QStringLiteral("request_too_large"), QStringLiteral("Request exceeds 64 KiB")); + QElapsedTimer elapsed; + elapsed.start(); + const auto remaining = [&] { return qMax(0, timeoutMs - int(elapsed.elapsed())); }; + QLocalSocket socket; + socket.setReadBufferSize(MaxResponseBytes + 1); + socket.connectToServer(descriptor.value(QStringLiteral("endpoint")).toString()); + if (!socket.waitForConnected(remaining())) + return failure(id, QStringLiteral("unavailable"), QStringLiteral("Instance is not reachable")); + if (socket.write(bytes) != bytes.size()) + return failure(id, QStringLiteral("write_failed"), QStringLiteral("Could not send command")); + while (socket.bytesToWrite() > 0) { + if (remaining() <= 0 || !socket.waitForBytesWritten(remaining())) + return failure(id, QStringLiteral("timeout"), QStringLiteral("Command write timed out")); + } + QByteArray reply; + while (true) { + reply += socket.readAll(); + if (reply.size() > MaxResponseBytes) + return failure(id, QStringLiteral("response_too_large"), QStringLiteral("Response exceeds 1 MiB")); + if (reply.contains('\n')) + break; + if (remaining() <= 0 || !socket.waitForReadyRead(remaining())) + return failure(id, QStringLiteral("timeout"), QStringLiteral("Instance did not complete command in time")); + } + QJsonParseError parseError; + const QJsonObject result = QJsonDocument::fromJson(reply.left(reply.indexOf('\n')), &parseError).object(); + if (parseError.error != QJsonParseError::NoError || result.value(QStringLiteral("id")).toString() != id + || !result.value(QStringLiteral("ok")).isBool()) + return failure( + id, QStringLiteral("invalid_response"), QStringLiteral("Invalid response or mismatched request ID")); + return result; +} +} diff --git a/src/automation/LocalControlProtocol.h b/src/automation/LocalControlProtocol.h new file mode 100644 index 00000000..090e9392 --- /dev/null +++ b/src/automation/LocalControlProtocol.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace Spool::LocalControl { +constexpr qsizetype MaxRequestBytes = 64 * 1024; +constexpr qsizetype MaxResponseBytes = 1024 * 1024; +constexpr int RequestTimeoutMs = 10'000; + +// Discovery files are private capabilities, not public command endpoints. +QString directory(QString *error); +bool validInstance(const QString& instance); +bool privateFile(const QString& path); +QJsonObject readDescriptor(const QString& path); +QJsonObject request(const QJsonObject& descriptor, const QString& command, const QJsonObject& arguments, + int timeoutMs = RequestTimeoutMs); +QJsonObject failure(const QString& id, const QString& code, const QString& message); +} diff --git a/src/automation/SpooletMain.cpp b/src/automation/SpooletMain.cpp new file mode 100644 index 00000000..1a1ef6c2 --- /dev/null +++ b/src/automation/SpooletMain.cpp @@ -0,0 +1,226 @@ +#include "LocalControlProtocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +int print(const QJsonObject& result) +{ + QTextStream(stdout) << QJsonDocument(result).toJson(QJsonDocument::Compact) << '\n'; + return result.value(QStringLiteral("ok")).toBool() ? 0 : 1; +} +int error(const QString& message) +{ + return print(Spool::LocalControl::failure({}, QStringLiteral("cli_error"), message)); +} +QJsonObject help() +{ + return { { QStringLiteral("ok"), true }, + { QStringLiteral("result"), + QJsonObject { + { QStringLiteral("usage"), QStringLiteral("spoolet [--instance ID] [--timeout MS] COMMAND [ARGS]") }, + { QStringLiteral("commands"), + QJsonArray { QStringLiteral("help | instances | status | state"), + QStringLiteral( + "navigate home|search|settings | home | back | key up|down|left|right|ok|back|space"), + QStringLiteral("items [browse|libraries|resume|next-up] [OFFSET] [LIMIT] | library " + "QUALIFIED_ID | load-more"), + QStringLiteral("play QUALIFIED_ID [--from-start] | pause | resume | stop | seek SECONDS"), + QStringLiteral("preview SECONDS (actual timeline hover; key up first if controls are hidden)"), + QStringLiteral("qualities | quality INDEX"), QStringLiteral("screenshot FILE.png"), + QStringLiteral("pointer move|click|press|release|right-click X Y (window logical coordinates)"), + QStringLiteral( + "downloads list | options ITEM_ID | start ITEM_ID INDEX | cancel|retry|remove|play JOB_ID"), + QStringLiteral("settings get [KEY] | settings set KEY JSON_VALUE") } }, + { QStringLiteral("notes"), + QStringLiteral( + "All stdout is JSON. Commands are bounded and never prompt. When multiple instances run, " + "--instance is required. Playback/navigation acceptance is not a promise that provider I/O has " + "completed: poll status. Screenshot waits for a new Qt frame, then atomically writes PNG; " + "separate native video planes are reported as excluded.") } } } }; +} +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("spoolet")); + QStringList arguments = app.arguments().mid(1); + QString instance; + int timeout = Spool::LocalControl::RequestTimeoutMs; + while (!arguments.isEmpty() && arguments.first().startsWith(QStringLiteral("--"))) { + const QString option = arguments.takeFirst(); + if (option == QStringLiteral("--help")) + return print(help()); + if ((option != QStringLiteral("--instance") && option != QStringLiteral("--timeout")) || arguments.isEmpty()) + return error(QStringLiteral("Expected --instance ID or --timeout MS; see help")); + const QString value = arguments.takeFirst(); + if (option == QStringLiteral("--instance")) { + if (!Spool::LocalControl::validInstance(value)) + return error(QStringLiteral("Invalid instance identifier")); + instance = value; + } else { + bool valid = false; + timeout = value.toInt(&valid); + if (!valid || timeout < 100 || timeout > 30'000) + return error(QStringLiteral("Timeout must be 100..30000 milliseconds")); + } + } + if (arguments.isEmpty() || arguments == QStringList { QStringLiteral("help") }) + return print(help()); + QString command = arguments.takeFirst(); + QJsonObject args; + const auto count = [&](int low, int high) { return arguments.size() >= low && arguments.size() <= high; }; + if (command == QStringLiteral("navigate") || command == QStringLiteral("key") + || command == QStringLiteral("library") || command == QStringLiteral("screenshot")) { + if (!count(1, 1)) + return error(QStringLiteral("Command requires one argument")); + const QString key = command == QStringLiteral("navigate") ? QStringLiteral("route") + : command == QStringLiteral("key") ? QStringLiteral("name") + : command == QStringLiteral("library") ? QStringLiteral("id") + : QStringLiteral("path"); + args.insert(key, + command == QStringLiteral("screenshot") ? QFileInfo(arguments.first()).absoluteFilePath() + : arguments.first()); + } else if (command == QStringLiteral("play")) { + if (!count(1, 2) || (arguments.size() == 2 && arguments.last() != QStringLiteral("--from-start"))) + return error(QStringLiteral("Usage: play QUALIFIED_ID [--from-start]")); + args.insert(QStringLiteral("id"), arguments.first()); + args.insert(QStringLiteral("fromStart"), arguments.size() == 2); + } else if (command == QStringLiteral("seek") || command == QStringLiteral("preview") + || command == QStringLiteral("quality")) { + if (!count(1, 1)) + return error(QStringLiteral("Command requires one numeric argument")); + bool valid = false; + const double value = arguments.first().toDouble(&valid); + if (!valid || !std::isfinite(value) || value < 0 + || (command == QStringLiteral("quality") && (value > 10000 || std::floor(value) != value))) + return error(QStringLiteral("Invalid nonnegative numeric argument")); + args.insert(command == QStringLiteral("quality") ? QStringLiteral("index") : QStringLiteral("seconds"), value); + } else if (command == QStringLiteral("pointer")) { + if (!count(3, 3) + || !QStringList { QStringLiteral("move"), QStringLiteral("click"), QStringLiteral("press"), + QStringLiteral("release"), QStringLiteral("right-click") } + .contains(arguments.first())) + return error(QStringLiteral("Usage: pointer move|click|press|release|right-click X Y")); + args.insert(QStringLiteral("action"), arguments.first()); + for (int index = 1; index <= 2; ++index) { + bool valid = false; + const double value = arguments.at(index).toDouble(&valid); + if (!valid || !std::isfinite(value) || value < 0) + return error(QStringLiteral("Pointer coordinates must be finite and nonnegative")); + args.insert(index == 1 ? QStringLiteral("x") : QStringLiteral("y"), value); + } + } else if (command == QStringLiteral("items")) { + if (!count(0, 3)) + return error(QStringLiteral("Usage: items [KIND] [OFFSET] [LIMIT]")); + if (!arguments.isEmpty()) + args.insert(QStringLiteral("kind"), arguments.first()); + for (int index = 1; index < arguments.size(); ++index) { + bool valid = false; + const int value = arguments.at(index).toInt(&valid); + if (!valid || value < 0) + return error(QStringLiteral("Item paging values must be nonnegative integers")); + args.insert(index == 1 ? QStringLiteral("offset") : QStringLiteral("limit"), value); + } + } else if (command == QStringLiteral("downloads")) { + if (arguments.isEmpty()) + return error(QStringLiteral("Usage: downloads list|options|start|cancel|retry|remove|play")); + const QString action = arguments.takeFirst(); + args.insert(QStringLiteral("action"), action); + if (action == QStringLiteral("list") && count(0, 0)) { + } else if (action == QStringLiteral("options") && count(1, 1)) { + args.insert(QStringLiteral("itemId"), arguments.first()); + } else if (action == QStringLiteral("start") && count(2, 2)) { + bool valid = false; + const int index = arguments.last().toInt(&valid); + if (!valid || index < 0 || index > 10000) + return error(QStringLiteral("Download option index must be a nonnegative integer")); + args.insert(QStringLiteral("itemId"), arguments.first()); + args.insert(QStringLiteral("index"), index); + } else if (QStringList { QStringLiteral("cancel"), QStringLiteral("retry"), QStringLiteral("remove"), + QStringLiteral("play") } + .contains(action) + && count(1, 1)) { + args.insert(QStringLiteral("jobId"), arguments.first()); + } else { + return error(QStringLiteral( + "Usage: downloads list | options ITEM_ID | start ITEM_ID INDEX | cancel|retry|remove|play JOB_ID")); + } + } else if (command == QStringLiteral("settings")) { + if (arguments.isEmpty()) + return error(QStringLiteral("Usage: settings get [KEY] or settings set KEY JSON_VALUE")); + const QString action = arguments.takeFirst(); + if (action == QStringLiteral("get") && count(0, 1)) { + command = QStringLiteral("settings-get"); + if (!arguments.isEmpty()) + args.insert(QStringLiteral("key"), arguments.first()); + } else if (action == QStringLiteral("set") && count(2, 2)) { + command = QStringLiteral("settings-set"); + args.insert(QStringLiteral("key"), arguments.first()); + QJsonParseError parseError; + const QJsonDocument value = QJsonDocument::fromJson( + (QStringLiteral("[") + arguments.last() + QLatin1Char(']')).toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !value.isArray() || value.array().size() != 1) + return error(QStringLiteral("Setting value must be valid JSON (strings need double quotes)")); + args.insert(QStringLiteral("value"), value.array().first()); + } else { + return error(QStringLiteral("Usage: settings get [KEY] or settings set KEY JSON_VALUE")); + } + } else if (!QStringList { QStringLiteral("instances"), QStringLiteral("status"), QStringLiteral("state"), + QStringLiteral("home"), QStringLiteral("back"), QStringLiteral("load-more"), QStringLiteral("pause"), + QStringLiteral("resume"), QStringLiteral("stop"), QStringLiteral("qualities") } + .contains(command) + || !arguments.isEmpty()) { + return error(QStringLiteral("Unknown command or extra arguments; see spoolet help")); + } + QString directoryError; + const QString directory = Spool::LocalControl::directory(&directoryError); + if (directory.isEmpty()) + return error(directoryError); + QList descriptors; + if (!instance.isEmpty()) { + const QJsonObject descriptor + = Spool::LocalControl::readDescriptor(QDir(directory).filePath(instance + QStringLiteral(".json"))); + if (descriptor.isEmpty()) + return error(QStringLiteral("Instance discovery is absent or unsafe")); + descriptors.append(descriptor); + } else { + const QStringList files = QDir(directory).entryList({ QStringLiteral("*.json") }, QDir::Files, QDir::Name); + if (files.size() > 64) + return error(QStringLiteral("Too many discovery entries; specify --instance explicitly")); + for (const QString& file : files) { + const QJsonObject descriptor = Spool::LocalControl::readDescriptor(QDir(directory).filePath(file)); + if (!descriptor.isEmpty()) + descriptors.append(descriptor); + } + } + if (command == QStringLiteral("instances") || instance.isEmpty()) { + QList live; + QJsonArray rows; + for (const QJsonObject& descriptor : descriptors) { + const QJsonObject reply + = Spool::LocalControl::request(descriptor, QStringLiteral("status"), {}, qMin(timeout, 500)); + if (reply.value(QStringLiteral("ok")).toBool()) { + live.append(descriptor); + rows.append(reply.value(QStringLiteral("result"))); + } + } + if (command == QStringLiteral("instances")) + return print({ { QStringLiteral("ok"), true }, + { QStringLiteral("result"), QJsonObject { { QStringLiteral("instances"), rows } } } }); + descriptors = live; + } + if (descriptors.isEmpty()) + return error(QStringLiteral("No running Spool instances found")); + if (descriptors.size() != 1) + return error(QStringLiteral("Multiple instances are running; use --instance ID")); + return print(Spool::LocalControl::request(descriptors.first(), command, args, timeout)); +} diff --git a/src/cache/DatabaseManager.cpp b/src/cache/DatabaseManager.cpp index 9d6e3cf5..fe263a11 100644 --- a/src/cache/DatabaseManager.cpp +++ b/src/cache/DatabaseManager.cpp @@ -20,9 +20,11 @@ #include #include +#include #include +#include -namespace JellyfinNative { +namespace Spool { class DatabaseWorker final : public QObject { Q_OBJECT @@ -32,6 +34,14 @@ class DatabaseWorker final : public QObject { { QElapsedTimer timer; timer.start(); + // A missing or incompatible Qt plugin is an environment failure, not + // evidence that the user's durable state is corrupt. + m_database = QSqlDatabase::addDatabase( + QStringLiteral("QSQLITE"), QStringLiteral("spool_native_state_") + m_connectionId); + if (!m_database.isValid()) { + qWarning() << "database: SQLite driver unavailable; local account data left unchanged"; + return false; + } const QFileInfo cacheInfo(cachePath); if (!QDir().mkpath(cacheInfo.absolutePath())) { qWarning() << "database: failed to create data directory for" << cachePath; @@ -43,7 +53,6 @@ class DatabaseWorker final : public QObject { const QString statePath = cacheInfo.dir().filePath(QStringLiteral("state.sqlite")); if (!openState(statePath)) { qWarning() << "database: invalid durable state; preserving and rebuilding" << statePath; - CredentialStore::clear(); if (!recoverState(statePath)) return false; m_recoveryNotice = QStringLiteral( @@ -108,10 +117,32 @@ class DatabaseWorker final : public QObject { query.prepare(QStringLiteral("INSERT INTO kv(key, value) VALUES(?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value")); query.addBindValue(key); - query.addBindValue(value); + const QString text = value.toString(); + query.addBindValue(text.isNull() ? QStringLiteral("") : text); query.exec(); } + void setValues(const QVariantMap& values) + { + if (values.isEmpty()) + return; + if (!m_database.transaction()) + throw std::runtime_error("Could not begin settings transaction"); + QSqlQuery query(m_database); + bool ok = query.prepare(QStringLiteral("INSERT INTO kv(key, value) VALUES(?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value")); + for (auto it = values.cbegin(); ok && it != values.cend(); ++it) { + query.bindValue(0, it.key()); + const QString text = it.value().toString(); + query.bindValue(1, text.isNull() ? QStringLiteral("") : text); + ok = query.exec(); + } + if (!ok || !m_database.commit()) { + m_database.rollback(); + throw std::runtime_error("Could not persist settings transaction"); + } + } + private: static bool quickCheck(QSqlDatabase& database) { @@ -154,7 +185,7 @@ class DatabaseWorker final : public QObject { bool openState(const QString& path) { - if (!ensureConnection(m_database, QStringLiteral("jellyfin_native_state"), path) + if (!ensureConnection(m_database, QStringLiteral("spool_native_state_") + m_connectionId, path) || !prepareConnection(m_database)) return false; secureDatabaseFiles(path); @@ -191,7 +222,7 @@ class DatabaseWorker final : public QObject { bool openCache(const QString& path) { - if (!ensureConnection(m_cacheDatabase, QStringLiteral("jellyfin_native_cache"), path) + if (!ensureConnection(m_cacheDatabase, QStringLiteral("spool_native_cache_") + m_connectionId, path) || !prepareConnection(m_cacheDatabase)) return false; secureDatabaseFiles(path); @@ -199,8 +230,20 @@ class DatabaseWorker final : public QObject { if (!query.exec(QStringLiteral("PRAGMA user_version")) || !query.next()) return false; const int existingVersion = query.value(0).toInt(); - if (existingVersion > 1) + if (existingVersion > 2) return false; + // Cached rows now preserve collection occurrence identity and continuations. + // Disposable data is rebuilt; durable settings/accounts live separately. + if (existingVersion == 1) { + if (!m_cacheDatabase.transaction()) + return false; + if (!query.exec(QStringLiteral("DROP TABLE IF EXISTS cache_entries")) + || !query.exec(QStringLiteral("DROP TABLE IF EXISTS home_payload")) + || !query.exec(QStringLiteral("PRAGMA user_version = 0")) || !m_cacheDatabase.commit()) { + m_cacheDatabase.rollback(); + return false; + } + } if (!query.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS cache_entries (" "namespace TEXT NOT NULL," "key TEXT NOT NULL," @@ -220,7 +263,7 @@ class DatabaseWorker final : public QObject { "payload BLOB NOT NULL," "saved_at INTEGER NOT NULL" ")")) - || !query.exec(QStringLiteral("PRAGMA user_version = 1"))) { + || !query.exec(QStringLiteral("PRAGMA user_version = 2"))) { qWarning() << "database: cache schema creation failed" << query.lastError().text(); return false; } @@ -323,12 +366,6 @@ class DatabaseWorker final : public QObject { qWarning() << "database: home payload write failed" << query.lastError().text(); } - void clearHomePayloads() - { - QSqlQuery query(m_cacheDatabase); - query.exec(QStringLiteral("DELETE FROM home_payload")); - } - int schemaVersion() { QSqlQuery query(m_cacheDatabase); @@ -439,108 +476,23 @@ class DatabaseWorker final : public QObject { removeCacheValue(nameSpace, key); } - std::vector accountProfiles() + // Sign-ins the native Jellyfin client saved, read once to carry them + // over to provider accounts. + QVariantList legacyAccounts() { - std::vector profiles; + QVariantList accounts; QSqlQuery query(m_database); if (!query.exec(QStringLiteral("SELECT profile_id, server_id, server_name, server_url, user_id, user_name, " - "avatar_tag, last_used_at, created_at, needs_authentication " - "FROM profiles ORDER BY last_used_at DESC, created_at ASC"))) { - qWarning() << "database: profile load failed" << query.lastError().text(); - return profiles; - } - while (query.next()) - profiles.push_back(accountProfileFromQuery(query)); - return profiles; - } - - std::optional activateAccountProfile(const QString& profileId) - { - if (profileId.isEmpty() || !m_database.transaction()) - return std::nullopt; - - QSqlQuery query(m_database); - query.prepare(QStringLiteral("SELECT profile_id, server_id, server_name, server_url, user_id, user_name, " - "avatar_tag, last_used_at, created_at, needs_authentication " - "FROM profiles WHERE profile_id = ?")); - query.addBindValue(profileId); - if (!query.exec() || !query.next()) { - m_database.rollback(); - return std::nullopt; + "last_used_at FROM profiles"))) + return accounts; + while (query.next()) { + accounts.append(QVariantMap { { QStringLiteral("serverId"), query.value(1) }, + { QStringLiteral("serverName"), query.value(2) }, { QStringLiteral("server"), query.value(3) }, + { QStringLiteral("userId"), query.value(4) }, { QStringLiteral("userName"), query.value(5) }, + { QStringLiteral("lastUsed"), query.value(6) }, + { QStringLiteral("token"), CredentialStore::load(query.value(0).toString()) } }); } - - AccountProfile profile = accountProfileFromQuery(query); - profile.lastUsedAt = QDateTime::currentMSecsSinceEpoch(); - QSqlQuery touch(m_database); - touch.prepare(QStringLiteral("UPDATE profiles SET last_used_at = ? WHERE profile_id = ?")); - touch.addBindValue(profile.lastUsedAt); - touch.addBindValue(profileId); - if (!touch.exec() || !m_database.commit()) { - m_database.rollback(); - return std::nullopt; - } - return profile; - } - - void upsertAccountProfile(const AccountProfile& profile) - { - const bool credentialSaved - = !profile.accessToken.isEmpty() && CredentialStore::save(profile.profileId, profile.accessToken); - if (!credentialSaved) - CredentialStore::remove(profile.profileId); - - QSqlQuery query(m_database); - query.prepare(QStringLiteral( - "INSERT INTO profiles(profile_id, server_id, server_name, server_url, user_id, user_name, " - "avatar_tag, last_used_at, created_at, needs_authentication) " - "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(profile_id) DO UPDATE SET " - "server_id = excluded.server_id, server_name = excluded.server_name, " - "server_url = excluded.server_url, user_id = excluded.user_id, user_name = excluded.user_name, " - "avatar_tag = excluded.avatar_tag, last_used_at = excluded.last_used_at, " - "needs_authentication = excluded.needs_authentication")); - const auto bindText - = [&query](const QString& value) { query.addBindValue(value.isNull() ? QStringLiteral("") : value); }; - bindText(profile.profileId); - bindText(profile.serverId); - bindText(profile.serverName); - bindText(profile.serverUrl); - bindText(profile.userId); - bindText(profile.userName); - bindText(profile.avatarTag); - query.addBindValue(profile.lastUsedAt); - query.addBindValue(profile.createdAt); - query.addBindValue(profile.needsAuthentication || !credentialSaved); - if (!query.exec()) - qWarning() << "database: profile upsert failed" << query.lastError().text(); - } - - void expireAccountProfile(const QString& profileId) - { - QSqlQuery query(m_database); - query.prepare(QStringLiteral("UPDATE profiles SET needs_authentication = 1 WHERE profile_id = ?")); - query.addBindValue(profileId); - if (!query.exec()) - qWarning() << "database: profile expiration failed" << query.lastError().text(); - CredentialStore::remove(profileId); - } - - void removeAccountProfile(const QString& profileId) - { - QSqlQuery query(m_database); - query.prepare(QStringLiteral("DELETE FROM profiles WHERE profile_id = ?")); - query.addBindValue(profileId); - if (!query.exec()) - qWarning() << "database: profile removal failed" << query.lastError().text(); - CredentialStore::remove(profileId); - } - - void clearAccountProfiles() - { - QSqlQuery query(m_database); - if (!query.exec(QStringLiteral("DELETE FROM profiles"))) - qWarning() << "database: profile clear failed" << query.lastError().text(); - CredentialStore::clear(); + return accounts; } void close() @@ -559,15 +511,8 @@ class DatabaseWorker final : public QObject { database = {}; QSqlDatabase::removeDatabase(connectionName); } - static AccountProfile accountProfileFromQuery(const QSqlQuery& query) - { - const QString profileId = query.value(0).toString(); - const QString accessToken = CredentialStore::load(profileId); - return { profileId, query.value(1).toString(), query.value(2).toString(), query.value(3).toString(), - query.value(4).toString(), query.value(5).toString(), accessToken, query.value(6).toString(), - query.value(7).toLongLong(), query.value(8).toLongLong(), - query.value(9).toBool() || accessToken.isEmpty() }; - } + inline static std::atomic s_nextConnectionId { 0 }; + const QString m_connectionId = QString::number(s_nextConnectionId.fetch_add(1, std::memory_order_relaxed)); QSqlDatabase m_database; QSqlDatabase m_cacheDatabase; QString m_recoveryNotice; @@ -592,7 +537,7 @@ namespace { }; if (!worker || !QMetaObject::invokeMethod(worker, finish, Qt::QueuedConnection)) finish(); - co_return co_await future; + co_return co_await qCoro(future).result(); } QString settingFromWorker(DatabaseWorker *worker, const QString& key, const QString& defaultValue) @@ -674,68 +619,12 @@ void DatabaseManager::shutdown() m_worker = nullptr; } -QCoro::Task DatabaseManager::loadLastServerUrlAsync() -{ - if (!co_await awaitInitialization()) - co_return QString(); - DatabaseWorker *worker = m_worker; - co_return co_await workerTask(worker, - [worker]() { return worker ? worker->value(QStringLiteral("login/serverUrl")).toString() : QString(); }); -} - -QCoro::Task DatabaseManager::loadLastUsernameAsync() +QCoro::Task DatabaseManager::loadLegacyAccountsAsync() { if (!co_await awaitInitialization()) - co_return QString(); + co_return QVariantList {}; DatabaseWorker *worker = m_worker; - co_return co_await workerTask( - worker, [worker]() { return worker ? worker->value(QStringLiteral("login/username")).toString() : QString(); }); -} - -void DatabaseManager::saveLoginHints(const QString& serverUrl, const QString& username) -{ - invokeOnWorkerAsync([this, serverUrl, username]() { - m_worker->setValue(QStringLiteral("login/serverUrl"), serverUrl); - m_worker->setValue(QStringLiteral("login/username"), username); - }); -} - -QCoro::Task> DatabaseManager::loadAccountProfilesAsync() -{ - if (!co_await awaitInitialization()) - co_return std::vector {}; - DatabaseWorker *worker = m_worker; - co_return co_await workerTask( - worker, [worker]() { return worker ? worker->accountProfiles() : std::vector {}; }); -} - -QCoro::Task> DatabaseManager::activateAccountProfileAsync(const QString& profileId) -{ - if (!co_await awaitInitialization()) - co_return std::nullopt; - DatabaseWorker *worker = m_worker; - co_return co_await workerTask( - worker, [worker, profileId]() { return worker ? worker->activateAccountProfile(profileId) : std::nullopt; }); -} - -void DatabaseManager::upsertAccountProfile(const AccountProfile& profile) -{ - invokeOnWorkerAsync([this, profile]() { m_worker->upsertAccountProfile(profile); }); -} - -void DatabaseManager::expireAccountProfile(const QString& profileId) -{ - invokeOnWorkerAsync([this, profileId]() { m_worker->expireAccountProfile(profileId); }); -} - -void DatabaseManager::removeAccountProfile(const QString& profileId) -{ - invokeOnWorkerAsync([this, profileId]() { m_worker->removeAccountProfile(profileId); }); -} - -void DatabaseManager::clearAccountProfiles() -{ - invokeOnWorkerAsync([this]() { m_worker->clearAccountProfiles(); }); + co_return co_await workerTask(worker, [worker]() { return worker ? worker->legacyAccounts() : QVariantList {}; }); } QCoro::Task DatabaseManager::loadDeviceIdAsync() @@ -752,34 +641,6 @@ void DatabaseManager::saveDeviceId(const QString& deviceId) invokeOnWorkerAsync([this, deviceId]() { m_worker->setValue(QStringLiteral("client/deviceId"), deviceId); }); } -QCoro::Task DatabaseManager::loadDiscoveredServersAsync() -{ - if (!co_await awaitInitialization()) - co_return QJsonArray(); - DatabaseWorker *worker = m_worker; - co_return co_await workerTask(worker, [worker]() { - if (!worker) - return QJsonArray(); - constexpr qint64 maxAgeMs = 7LL * 24 * 60 * 60 * 1000; - const QByteArray encoded = worker->cacheValue(QStringLiteral("discovery"), QStringLiteral("servers"), maxAgeMs); - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(encoded, &error); - if (error.error == QJsonParseError::NoError && document.isArray()) - return document.array(); - if (!encoded.isEmpty()) - worker->removeCacheValue(QStringLiteral("discovery"), QStringLiteral("servers")); - return QJsonArray(); - }); -} - -void DatabaseManager::saveDiscoveredServers(const QJsonArray& servers) -{ - const QByteArray encoded = QJsonDocument(servers).toJson(QJsonDocument::Compact); - constexpr qint64 ttlMs = 7LL * 24 * 60 * 60 * 1000; - saveCacheEntry(QStringLiteral("discovery"), QStringLiteral("servers"), encoded, ttlMs); - evictCacheEntries(512); -} - QCoro::Task DatabaseManager::loadHomePayloadAsync(const QString& key, int schemaVersion) { if (!co_await awaitInitialization()) @@ -799,11 +660,6 @@ void DatabaseManager::saveHomePayload(const QString& key, int schemaVersion, con [this, key, schemaVersion, payload]() { m_worker->setHomePayload(key, schemaVersion, payload); }); } -void DatabaseManager::invalidateHomePayloads() -{ - invokeOnWorkerAsync([this]() { m_worker->clearHomePayloads(); }); -} - QCoro::Task DatabaseManager::loadSettingAsync(const QString& key, const QString& defaultValue) { if (!co_await awaitInitialization()) @@ -833,8 +689,7 @@ QCoro::Task DatabaseManager::loadStartupStateAsync(const QStringLi co_return co_await workerTask(worker, [worker, keys, initialization]() { if (!worker || !initialization.result()) return StartupState {}; - return StartupState { worker->value(QStringLiteral("client/deviceId")).toString(), worker->values(keys), - worker->accountProfiles() }; + return StartupState { worker->value(QStringLiteral("client/deviceId")).toString(), worker->values(keys) }; }); } @@ -843,6 +698,19 @@ void DatabaseManager::saveSetting(const QString& key, const QString& value) invokeOnWorkerAsync([this, key, value]() { m_worker->setValue(key, value); }); } +QCoro::Task DatabaseManager::saveSettings(QVariantMap serializedValues) +{ + if (!co_await awaitInitialization()) + throw std::runtime_error("Settings database is unavailable"); + DatabaseWorker *worker = m_worker; + co_await workerTask(worker, [worker, values = std::move(serializedValues)] { + if (!worker) + throw std::runtime_error("Settings database is closed"); + worker->setValues(values); + return true; + }); +} + QCoro::Task DatabaseManager::schemaVersionAsync() { if (!co_await awaitInitialization()) @@ -879,6 +747,6 @@ void DatabaseManager::evictCacheEntries(int maximumEntries) invokeOnWorkerAsync([this, maximumEntries]() { m_worker->evictCacheEntries(maximumEntries); }); } -} // namespace JellyfinNative +} // namespace Spool #include "DatabaseManager.moc" diff --git a/src/cache/DatabaseManager.h b/src/cache/DatabaseManager.h index 02360d67..0969a7f5 100644 --- a/src/cache/DatabaseManager.h +++ b/src/cache/DatabaseManager.h @@ -12,21 +12,19 @@ #include #include -#include "../app/AccountProfile.h" -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include #include -namespace JellyfinNative { +namespace Spool { class DatabaseWorker; struct StartupState { QString deviceId; QVariantMap values; - std::vector profiles; }; class DatabaseManager final : public QObject { @@ -39,29 +37,20 @@ class DatabaseManager final : public QObject { bool initialize(const QString& databasePath); void shutdown(); - QCoro::Task loadLastServerUrlAsync(); - QCoro::Task loadLastUsernameAsync(); - void saveLoginHints(const QString& serverUrl, const QString& username); - - QCoro::Task> loadAccountProfilesAsync(); - QCoro::Task> activateAccountProfileAsync(const QString& profileId); - void upsertAccountProfile(const AccountProfile& profile); - void expireAccountProfile(const QString& profileId); - void removeAccountProfile(const QString& profileId); - void clearAccountProfiles(); + // Sign-ins saved by the native Jellyfin client, for the one-time move to + // provider accounts. + QCoro::Task loadLegacyAccountsAsync(); QCoro::Task loadDeviceIdAsync(); void saveDeviceId(const QString& deviceId); - QCoro::Task loadDiscoveredServersAsync(); - void saveDiscoveredServers(const QJsonArray& servers); QCoro::Task loadHomePayloadAsync(const QString& key, int schemaVersion); void saveHomePayload(const QString& key, int schemaVersion, const QJsonObject& payload); - void invalidateHomePayloads(); QCoro::Task loadSettingAsync(const QString& key, const QString& defaultValue = {}); QCoro::Task loadValuesAsync(const QStringList& keys); QCoro::Task loadStartupStateAsync(const QStringList& keys); void saveSetting(const QString& key, const QString& value); + QCoro::Task saveSettings(QVariantMap serializedValues); QCoro::Task schemaVersionAsync(); QCoro::Task loadCacheEntryAsync(const QString& nameSpace, const QString& key, qint64 maxAgeMs = -1); @@ -85,4 +74,4 @@ class DatabaseManager final : public QObject { QFuture m_initializationFuture; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/AsyncTask.h b/src/common/AsyncTask.h index b872966d..1ff0e63d 100644 --- a/src/common/AsyncTask.h +++ b/src/common/AsyncTask.h @@ -1,19 +1,46 @@ #pragma once -#include "JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "RequestGeneration.h" #include #include +#include #include +#include +#include #include #include #include #include -namespace JellyfinNative::Async { +namespace Spool::Async { + +// Runs `work` on `pool` (the global pool by default) and hands back a future +// a coroutine can await, for disk and CPU work that must stay off the GUI +// thread. A one-thread pool runs its work strictly in order. +template auto background(Work work, QThreadPool *pool = QThreadPool::globalInstance()) +{ + using Result = std::invoke_result_t; + auto promise = std::make_shared>(); + promise->start(); + QFuture future = promise->future(); + pool->start([promise, work = std::move(work)]() mutable { + try { + if constexpr (std::is_void_v) { + work(); + } else { + promise->addResult(work()); + } + } catch (...) { + promise->setException(std::current_exception()); + } + promise->finish(); + }); + return future; +} namespace detail { template @@ -92,4 +119,4 @@ void runLatest(Context *context, QCoro::Task task, const RequestGeneration& g } } -} // namespace JellyfinNative::Async +} // namespace Spool::Async diff --git a/src/common/LogRotation.h b/src/common/LogRotation.h index 84d9bffa..54398184 100644 --- a/src/common/LogRotation.h +++ b/src/common/LogRotation.h @@ -5,7 +5,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { // mkdir -p for the directory portion of a log path. The log directory lives // under /tmp on webOS and is wiped every boot, so every writer must be able @@ -18,7 +18,7 @@ inline void ensureParentDirectoryExists(const char *path) inline void makeWebOSSupportLogsReadable(const QString& current) { -#ifdef JELLYFIN_NATIVE_WEBOS +#ifdef SPOOL_WEBOS const QFileInfo info(current); QFile::setPermissions(info.absolutePath(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | QFileDevice::ReadGroup @@ -52,4 +52,4 @@ inline void rotateLogFile(const char *path) makeWebOSSupportLogsReadable(current); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/MetaJson.h b/src/common/MetaJson.h index 682d8599..c89c3c4a 100644 --- a/src/common/MetaJson.h +++ b/src/common/MetaJson.h @@ -1,6 +1,6 @@ #pragma once -#include "JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include @@ -11,7 +11,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { enum class MetaJsonKeyPolicy { CamelCase, @@ -50,7 +50,7 @@ namespace MetaJsonDetail { return object.value(QStringLiteral("ProviderIds")).toObject().value(QStringLiteral("Tmdb")); if (policy == MetaJsonKeyPolicy::PascalCase && key == QStringLiteral("Year")) return object.value(QStringLiteral("ProductionYear")); - return { }; + return {}; } inline QJsonArray stringListToJson(const QStringList& items) @@ -129,6 +129,8 @@ namespace MetaJsonDetail { return value.toDouble(); case QMetaType::QStringList: return stringListToJson(value.toStringList()); + case QMetaType::QVariantMap: + return QJsonObject::fromVariantMap(value.toMap()); default: break; } @@ -143,7 +145,7 @@ namespace MetaJsonDetail { return listToJson(qvariant_cast>(value), policy); qFatal("Unsupported MetaJson property type: %s", type.name()); - return { }; + return {}; } inline QVariant variantFromJson(const QJsonValue& value, QMetaType type, MetaJsonKeyPolicy policy) @@ -161,6 +163,8 @@ namespace MetaJsonDetail { return value.toDouble(); case QMetaType::QStringList: return stringListFromJson(value); + case QMetaType::QVariantMap: + return value.toObject().toVariantMap(); default: break; } @@ -175,7 +179,7 @@ namespace MetaJsonDetail { return QVariant::fromValue(listFromJson(value, policy)); qFatal("Unsupported MetaJson property type: %s", type.name()); - return { }; + return {}; } template QJsonObject metaToJson(const T& value, MetaJsonKeyPolicy policy) @@ -241,4 +245,4 @@ QList metaListFromJson(const QJsonArray& array, MetaJsonKeyPolicy policy = Me return MetaJsonDetail::listFromJson(array, policy); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/NetworkAddress.h b/src/common/NetworkAddress.h index 01ea00f4..c1df4ef4 100644 --- a/src/common/NetworkAddress.h +++ b/src/common/NetworkAddress.h @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { inline bool isPrivateNetworkAddress(const QHostAddress& address) { @@ -32,4 +32,4 @@ inline bool isLanHost(const QString& host) || !normalized.contains(QLatin1Char('.')); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/RequestGeneration.h b/src/common/RequestGeneration.h index 6c09ce79..ad8b0e0d 100644 --- a/src/common/RequestGeneration.h +++ b/src/common/RequestGeneration.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { class RequestGeneration final { public: @@ -29,4 +29,4 @@ class RequestGeneration final { Token m_value = 0; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/SeriesAudioSelection.cpp b/src/common/SeriesAudioSelection.cpp index 69863cef..f86b5494 100644 --- a/src/common/SeriesAudioSelection.cpp +++ b/src/common/SeriesAudioSelection.cpp @@ -1,7 +1,7 @@ #include "SeriesAudioSelection.h" #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -87,4 +87,4 @@ int matchingSeriesAudioStreamIndex(const QList& streams, const return -1; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/SeriesAudioSelection.h b/src/common/SeriesAudioSelection.h index 93663680..9fdb9357 100644 --- a/src/common/SeriesAudioSelection.h +++ b/src/common/SeriesAudioSelection.h @@ -1,8 +1,8 @@ #pragma once -#include "JellyfinTypes.h" +#include "../media/MediaTypes.h" -namespace JellyfinNative { +namespace Spool { struct SeriesAudioPreference { QString language; @@ -19,4 +19,4 @@ struct SeriesAudioPreference { SeriesAudioPreference seriesAudioPreferenceForSelection(const QList& streams, int selectedStreamIndex); int matchingSeriesAudioStreamIndex(const QList& streams, const SeriesAudioPreference& preference); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/TlsTrust.cpp b/src/common/TlsTrust.cpp index f4063079..005d5633 100644 --- a/src/common/TlsTrust.cpp +++ b/src/common/TlsTrust.cpp @@ -13,7 +13,7 @@ #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -125,9 +125,9 @@ QVariantList TlsTrustController::rememberedCertificates() const void TlsTrustController::attachNetworkAccessManager(QNetworkAccessManager *manager, QString source) { - if (!manager || manager->property("jellyfinTlsTrustAttached").toBool()) + if (!manager || manager->property("spoolTlsTrustAttached").toBool()) return; - manager->setProperty("jellyfinTlsTrustAttached", true); + manager->setProperty("spoolTlsTrustAttached", true); connect( manager, &QNetworkAccessManager::sslErrors, this, [this, source = std::move(source)]( @@ -466,4 +466,4 @@ void TlsTrustController::reloadRemembered() m_rememberedCertificates = std::move(entries); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/TlsTrust.h b/src/common/TlsTrust.h index afb3f78f..12bed6de 100644 --- a/src/common/TlsTrust.h +++ b/src/common/TlsTrust.h @@ -18,13 +18,13 @@ #include #if !QT_CONFIG(ssl) -#error "Spool for Jellyfin requires a Qt Network build with TLS support" +#error "Spool requires a Qt Network build with TLS support" #endif class QNetworkAccessManager; class QWebSocket; -namespace JellyfinNative { +namespace Spool { // Writes everything the app trusts -- the platform store Qt resolves, plus any // certificate the viewer chose to remember -- to a PEM file and returns its @@ -112,4 +112,4 @@ class TlsTrustController final : public QObject { QQueue m_queue; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/VariantUtils.cpp b/src/common/VariantUtils.cpp index 9ddd0133..a8c9d1fa 100644 --- a/src/common/VariantUtils.cpp +++ b/src/common/VariantUtils.cpp @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { QStringList stringListFromVariant(const QVariant& value) { @@ -36,4 +36,4 @@ QStringList stringListFromVariantMap(const QVariantMap& map, const QString& key) return stringListFromVariant(map.value(key)); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/VariantUtils.h b/src/common/VariantUtils.h index a56de714..0605ca16 100644 --- a/src/common/VariantUtils.h +++ b/src/common/VariantUtils.h @@ -5,9 +5,9 @@ #include #include -namespace JellyfinNative { +namespace Spool { QStringList stringListFromVariant(const QVariant& value); QStringList stringListFromVariantMap(const QVariantMap& map, const QString& key); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/Diagnostics.cpp b/src/diagnostics/Diagnostics.cpp index 8c71235f..3c0bb60e 100644 --- a/src/diagnostics/Diagnostics.cpp +++ b/src/diagnostics/Diagnostics.cpp @@ -1,6 +1,6 @@ #include "Diagnostics.h" -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include @@ -31,23 +31,23 @@ #include #endif -namespace JellyfinNative::Diagnostics { +namespace Spool::Diagnostics { namespace { -#ifdef JELLYFIN_DIAGNOSTICS +#ifdef SPOOL_DIAGNOSTICS constexpr bool kDiagnosticsEnabled = true; #else constexpr bool kDiagnosticsEnabled = false; #endif -#ifdef JELLYFIN_DIAGNOSTICS_STACKDUMP +#ifdef SPOOL_DIAGNOSTICS_STACKDUMP constexpr bool kStackDumpEnabled = true; #else constexpr bool kStackDumpEnabled = false; #endif -#ifdef JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG +#ifdef SPOOL_DIAGNOSTICS_ABORT_ON_HANG constexpr bool kAbortOnHang = true; #else constexpr bool kAbortOnHang = false; @@ -57,7 +57,7 @@ namespace { constexpr qint64 kShutdownWarnMs = 6000; bool diagnosticsEnabled() { - return kDiagnosticsEnabled && qEnvironmentVariableIntValue("JELLYFIN_NATIVE_DIAGNOSTICS") == 1; + return kDiagnosticsEnabled && qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS") == 1; } bool credentialKey(const QString& key) @@ -231,7 +231,7 @@ namespace { continue; const QString cmdline = QString::fromLocal8Bit(readSmallFile(QStringLiteral("/proc/%1/cmdline").arg(pid))) .replace(QLatin1Char('\0'), QLatin1Char(' ')); - if (!cmdline.contains(appId) && !cmdline.contains(QStringLiteral("jellyfin-native"))) + if (!cmdline.contains(appId) && !cmdline.contains(QStringLiteral("spool"))) continue; processes.append(procSnapshotObject(pid)); } @@ -358,7 +358,7 @@ QString supportReportPreview() QString saveSupportReport() { #ifdef Q_OS_ANDROID - QString directory = qEnvironmentVariable("JELLYFIN_NATIVE_LOG_DIR"); + QString directory = qEnvironmentVariable("SPOOL_LOG_DIR"); #else QString directory = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); #endif @@ -368,7 +368,7 @@ QString saveSupportReport() return {}; QFile::setPermissions(directory, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); - const QString path = QDir(directory).filePath(QStringLiteral("Spool-for-Jellyfin-Diagnostics-%1.json") + const QString path = QDir(directory).filePath(QStringLiteral("Spool-Diagnostics-%1.json") .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-hhmmss")))); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) @@ -437,7 +437,7 @@ void dumpDiagnostics(const QString& reason) { { QStringLiteral("reason"), safeReason }, { QStringLiteral("path"), procPath } }); if (kStackDumpEnabled) { -#ifdef Q_OS_UNIX +#if defined(Q_OS_UNIX) && QT_CONFIG(process) const QString output = state().root + QStringLiteral("/stackdump/%1-%2.gdb.txt").arg(stamp, safeReason); QStringList args; args << QStringLiteral("-batch") << QStringLiteral("-ex") << QStringLiteral("set pagination off") @@ -547,4 +547,4 @@ void NetworkRequest::finish(int statusCode, const QString& errorText) { QStringLiteral("error"), errorText }, { QStringLiteral("durationMs"), nowMs() - m_startedMs } }); } -} // namespace JellyfinNative::Diagnostics +} // namespace Spool::Diagnostics diff --git a/src/diagnostics/Diagnostics.h b/src/diagnostics/Diagnostics.h index 7a494cd7..e94c7b59 100644 --- a/src/diagnostics/Diagnostics.h +++ b/src/diagnostics/Diagnostics.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative::Diagnostics { +namespace Spool::Diagnostics { void initialize(const QString& appId, const QString& rootPath); void shutdown(); @@ -62,4 +62,4 @@ class NetworkRequest final { bool m_finished = false; }; -} // namespace JellyfinNative::Diagnostics +} // namespace Spool::Diagnostics diff --git a/src/diagnostics/InputLatencyMonitor.cpp b/src/diagnostics/InputLatencyMonitor.cpp index 7c788ca4..6f54ea86 100644 --- a/src/diagnostics/InputLatencyMonitor.cpp +++ b/src/diagnostics/InputLatencyMonitor.cpp @@ -18,7 +18,7 @@ #include #include -namespace JellyfinNative::Detail { +namespace Spool::Detail { namespace { @@ -576,9 +576,9 @@ quint64 InputLatencyTimeline::epoch() const return m_epoch.load(std::memory_order_acquire); } -} // namespace JellyfinNative::Detail +} // namespace Spool::Detail -namespace JellyfinNative { +namespace Spool { namespace { constexpr auto kEnabledSetting = "diagnostics/inputLatencyGuard"; @@ -619,7 +619,7 @@ InputLatencyMonitor::InputLatencyMonitor(QObject *parent) const bool persistedEnabled = QSettings().value(QLatin1String(kEnabledSetting), false).toBool(); m_overlayEnabled = QSettings().value(QLatin1String(kOverlayEnabledSetting), true).toBool(); - const bool environmentEnabled = qEnvironmentVariable("JELLYFIN_INPUT_LATENCY_DIAGNOSTICS") == QLatin1String("1"); + const bool environmentEnabled = qEnvironmentVariable("SPOOL_INPUT_LATENCY_DIAGNOSTICS") == QLatin1String("1"); m_timeline.setEnabled(persistedEnabled || environmentEnabled); } @@ -1157,4 +1157,4 @@ bool InputLatencyMonitor::canCaptureInput() const return true; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/InputLatencyMonitor.h b/src/diagnostics/InputLatencyMonitor.h index 8a150ad8..5b327997 100644 --- a/src/diagnostics/InputLatencyMonitor.h +++ b/src/diagnostics/InputLatencyMonitor.h @@ -17,7 +17,7 @@ class QEvent; class QQuickWindow; class QScreen; -namespace JellyfinNative { +namespace Spool { namespace Detail { @@ -338,4 +338,4 @@ class InputLatencyMonitor final : public QObject { std::atomic_bool m_hasPresentedFrame { false }; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/RenderBenchmark.cpp b/src/diagnostics/RenderBenchmark.cpp index 54ae5bca..08665e68 100644 --- a/src/diagnostics/RenderBenchmark.cpp +++ b/src/diagnostics/RenderBenchmark.cpp @@ -1,8 +1,7 @@ #include "RenderBenchmark.h" -#include "../app/AppController.h" -#include "../app/ArtworkService.h" #include "../app/RouterController.h" +#include "../models/LibraryListModel.h" #include "InputLatencyMonitor.h" #include @@ -11,18 +10,23 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include +#include + #include #include -namespace JellyfinNative { +namespace Spool { namespace { // Routes that stand up without a library behind them, so the same script runs @@ -40,13 +44,13 @@ namespace { } // namespace -RenderBenchmark *RenderBenchmark::createIfRequested( - AppController *app, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent) +RenderBenchmark *RenderBenchmark::createIfRequested(RenderBenchmarkHooks hooks, RouterController *router, + InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent) { const QString script = qEnvironmentVariable("SPOOL_BENCH").trimmed(); if (script.isEmpty()) return nullptr; - auto *benchmark = new RenderBenchmark(app, router, latency, window, parent); + auto *benchmark = new RenderBenchmark(std::move(hooks), router, latency, window, parent); // "library" walks one library's grid instead of the route set: open it, // then page down through it waiting for the artwork on each screen. That // is the part of this application that costs the most on a television and @@ -64,10 +68,10 @@ RenderBenchmark *RenderBenchmark::createIfRequested( return benchmark; } -RenderBenchmark::RenderBenchmark( - AppController *app, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent) +RenderBenchmark::RenderBenchmark(RenderBenchmarkHooks hooks, RouterController *router, InputLatencyMonitor *latency, + QQuickWindow *window, QObject *parent) : QObject(parent) - , m_app(app) + , m_hooks(std::move(hooks)) , m_router(router) , m_latency(latency) , m_window(window) @@ -91,12 +95,29 @@ RenderBenchmark::RenderBenchmark( }); m_pump = new QTimer(this); - m_pump->setInterval(8); + m_pumpIntervalMs = std::clamp(envInt("SPOOL_BENCH_PUMP_MS", 8), 0, 1000); + m_pump->setInterval(std::max(1, m_pumpIntervalMs)); connect(m_pump, &QTimer::timeout, this, [this] { if (m_window) m_window->requestUpdate(); }); + // One owned timer, stopped on success. Previously each step left an + // uncancelled singleShot behind, which could time out a later transition. + m_stepDeadline = new QTimer(this); + m_stepDeadline->setSingleShot(true); + connect(m_stepDeadline, &QTimer::timeout, this, [this] { + if (!m_awaitingSample) + return; + const QString route = m_script.value(m_position); + qWarning() << "render benchmark: route" << route << "never reported a frame"; + m_failures.append(QVariantMap { { QStringLiteral("route"), route }, { QStringLiteral("pass"), m_pass }, + { QStringLiteral("step"), m_position }, { QStringLiteral("reason"), QStringLiteral("route_timeout") } }); + m_awaitingSample = false; + m_pump->stop(); + QTimer::singleShot(0, this, [this] { step(); }); + }); + m_scrollGapTimer = new QChronoTimer(this); connect(m_scrollGapTimer, &QChronoTimer::timeout, this, [this] { const qint64 nowNs = m_scrollClock.nsecsElapsed(); @@ -112,8 +133,7 @@ RenderBenchmark::RenderBenchmark( connect(m_scrollPoll, &QTimer::timeout, this, [this] { if (!m_scrolling) return; - auto *artwork = m_app ? m_app->artwork() : nullptr; - const int outstanding = artwork ? artwork->outstandingRequests() : 0; + const int outstanding = m_hooks.outstandingArtworkRequests ? m_hooks.outstandingArtworkRequests() : 0; if (outstanding == 0 || m_scrollClock.elapsed() > m_stepTimeoutMs) finishScrollStep(); }); @@ -134,15 +154,15 @@ qint64 RenderBenchmark::budgetNs() const bool RenderBenchmark::openLibraryNamed(const QString& name) { - auto *libraries = m_app ? m_app->libraries() : nullptr; - if (!libraries) { + LibraryListModel *libraries = m_hooks.libraries; + if (!libraries || !m_hooks.openLibrary) { qWarning() << "render benchmark: no library list to search"; return false; } for (int index = 0; index < libraries->count(); ++index) { if (libraries->libraryAt(index).name.compare(name, Qt::CaseInsensitive) == 0) { qInfo() << "render benchmark: opening library" << name; - m_app->openLibrary(index); + m_hooks.openLibrary(index); return true; } } @@ -190,7 +210,8 @@ void RenderBenchmark::beginScrollWalk() qInfo() << "render benchmark: walking" << m_libraryName << (m_listMode ? "as a list" : "as a grid"); applyViewMode(); m_scrollPosition = 0; - m_pump->start(); + if (m_pumpIntervalMs > 0) + m_pump->start(); // Times how long the library has had to arrive, which is what the first // step's patience is measured against. m_scrollClock.start(); @@ -235,12 +256,8 @@ void RenderBenchmark::finishScrollStep() m_scrollGapTimer->stop(); QVariantMap sample; sample.insert(QStringLiteral("screen"), m_scrollPosition); - if (auto *artwork = m_app ? m_app->artwork() : nullptr) { - const auto totals = artwork->decodeTotals(); - sample.insert(QStringLiteral("decodeMsTotal"), static_cast(totals.decodeNs) / 1000000.0); - sample.insert(QStringLiteral("decodedPixelsTotal"), static_cast(totals.pixels)); - sample.insert(QStringLiteral("decodedImagesTotal"), totals.images); - } + if (m_hooks.artworkDecodeTotals) + sample.insert(m_hooks.artworkDecodeTotals()); sample.insert(QStringLiteral("settleMs"), static_cast(m_scrollClock.nsecsElapsed()) / 1000000.0); sample.insert(QStringLiteral("maxGapMs"), static_cast(m_scrollWorstGapNs) / 1000000.0); sample.insert(QStringLiteral("frameBudgetMs"), m_latency ? m_latency->frameBudgetMs() : 16.67); @@ -300,7 +317,7 @@ void RenderBenchmark::start() waiter->setInterval(50); const qint64 deadline = QDateTime::currentMSecsSinceEpoch() + m_stepTimeoutMs; connect(waiter, &QTimer::timeout, this, [this, waiter, deadline] { - auto *libraries = m_app ? m_app->libraries() : nullptr; + LibraryListModel *libraries = m_hooks.libraries; const bool ready = libraries && libraries->count() > 0; if (!ready && QDateTime::currentMSecsSinceEpoch() < deadline) return; @@ -344,34 +361,32 @@ void RenderBenchmark::step() return; } - if (m_forceCold) - m_app->onMemoryPressure(QStringLiteral("critical")); + if (m_forceCold && m_hooks.forceColdCaches) + m_hooks.forceColdCaches(); m_awaitingSample = true; m_stepTimer.start(); - m_pump->start(); + if (m_pumpIntervalMs > 0) + m_pump->start(); + m_stepDeadline->start(m_stepTimeoutMs); m_router->replace(route); if (m_window) m_window->requestUpdate(); - - // A route that never reports is a failure worth seeing rather than a - // hang: give up on it and carry on with the rest of the walk. - QTimer::singleShot(m_stepTimeoutMs, this, [this, route] { - if (!m_awaitingSample) - return; - qWarning() << "render benchmark: route" << route << "never reported a frame"; - m_awaitingSample = false; - m_pump->stop(); - QTimer::singleShot(0, this, [this] { step(); }); - }); } void RenderBenchmark::recordSample() { + QVariantMap metrics = m_latency->lastRouteMetrics(); + // A timed-out transition may publish late while a different route is now + // awaited. It must not stand in for the current step's result. + if (metrics.value(QStringLiteral("routeTo")).toString() != m_script.value(m_position)) + return; m_awaitingSample = false; + m_stepDeadline->stop(); m_pump->stop(); - QVariantMap metrics = m_latency->lastRouteMetrics(); if (!metrics.isEmpty() && m_pass >= 0) { + metrics.insert( + QStringLiteral("requestToSampleMs"), static_cast(m_stepTimer.nsecsElapsed()) / 1000000.0); metrics.insert(QStringLiteral("pass"), m_pass); metrics.insert(QStringLiteral("step"), m_position); m_samples.append(metrics); @@ -394,15 +409,43 @@ void RenderBenchmark::finish() samples.append(QJsonObject::fromVariantMap(sample.toMap())); QJsonObject report; + // Schema 2 includes synchronous route construction. Do not compare its + // route totals to schema-1 reports that started timing after construction. + report.insert(QStringLiteral("schemaVersion"), 2); + report.insert(QStringLiteral("measurementScope"), QStringLiteral("route-transition")); + report.insert(QStringLiteral("timingOrigin"), QStringLiteral("before-page-construction")); + report.insert(QStringLiteral("presentationObservable"), QStringLiteral("qt-frameSwapped")); + report.insert(QStringLiteral("providerPipelineMeasured"), false); + report.insert(QStringLiteral("providerId"), m_hooks.providerId); + report.insert(QStringLiteral("providerRuntime"), m_hooks.providerRuntime); + report.insert(QStringLiteral("qtVersion"), QString::fromLatin1(qVersion())); + report.insert(QStringLiteral("qpaPlatform"), QGuiApplication::platformName()); + report.insert(QStringLiteral("cpuArchitecture"), QSysInfo::currentCpuArchitecture()); + report.insert(QStringLiteral("buildAbi"), QSysInfo::buildAbi()); + report.insert(QStringLiteral("deviceLabel"), qEnvironmentVariable("SPOOL_BENCH_DEVICE_LABEL")); + report.insert(QStringLiteral("framePumpMs"), m_pumpIntervalMs); + report.insert(QStringLiteral("warmupPasses"), m_warmup); + report.insert(QStringLiteral("settleMs"), m_settleMs); + report.insert(QStringLiteral("stepTimeoutMs"), m_stepTimeoutMs); + report.insert(QStringLiteral("cacheMode"), + m_forceCold ? QStringLiteral("memory-pressure-eviction") : QStringLiteral("resident-after-warmup")); + report.insert(QStringLiteral("interpreterRequested"), qEnvironmentVariable("QV4_FORCE_INTERPRETER")); + report.insert(QStringLiteral("complete"), m_failures.isEmpty()); + report.insert(QStringLiteral("failures"), QJsonArray::fromVariantList(m_failures)); + if (m_window) { + report.insert(QStringLiteral("windowWidth"), m_window->width()); + report.insert(QStringLiteral("windowHeight"), m_window->height()); + report.insert(QStringLiteral("devicePixelRatio"), m_window->devicePixelRatio()); + report.insert(QStringLiteral("graphicsApi"), static_cast(m_window->rendererInterface()->graphicsApi())); + } report.insert(QStringLiteral("recordedAt"), QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); report.insert(QStringLiteral("script"), QJsonArray::fromStringList(m_script)); report.insert(QStringLiteral("iterations"), m_iterations); report.insert(QStringLiteral("cold"), m_forceCold); report.insert(QStringLiteral("frameBudgetMs"), m_latency ? m_latency->frameBudgetMs() : 16.67); - // Which paint path produced these numbers. It decides whether the frame - // gaps mean anything: the software backend rasterises on the CPU, so on a - // small runner a gap is mostly llvmpipe's throughput rather than anything - // this application did. + // Preserve the requested backend alongside the actual renderer API. + // Qt Quick's software adaptation is not the Mesa llvmpipe OpenGL driver; + // neither offscreen swap callbacks nor timer lateness prove display pacing. report.insert(QStringLiteral("quickBackend"), qEnvironmentVariable("QT_QUICK_BACKEND")); // What a frame-budget timer drifted by while the app was doing nothing. // A transition gap only means something when it is worse than this. @@ -419,19 +462,22 @@ void RenderBenchmark::finish() report.insert(QStringLiteral("listMode"), m_listMode); const QByteArray json = QJsonDocument(report).toJson(QJsonDocument::Indented); + bool written = true; if (m_outputPath.isEmpty()) { qInfo().noquote() << json; } else { QDir().mkpath(QFileInfo(m_outputPath).absolutePath()); QFile file(m_outputPath); if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - file.write(json); + written = file.write(json) == json.size() && file.flush(); qInfo() << "render benchmark: wrote" << m_samples.size() << "samples to" << m_outputPath; } else { + written = false; qWarning() << "render benchmark: could not write" << m_outputPath; } } - QCoreApplication::exit(m_samples.isEmpty() && m_scrollSamples.isEmpty() ? 1 : 0); + QCoreApplication::exit( + !written || !m_failures.isEmpty() || (m_samples.isEmpty() && m_scrollSamples.isEmpty()) ? 1 : 0); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/RenderBenchmark.h b/src/diagnostics/RenderBenchmark.h index 1f347b32..2fcafa24 100644 --- a/src/diagnostics/RenderBenchmark.h +++ b/src/diagnostics/RenderBenchmark.h @@ -7,14 +7,35 @@ #include #include #include +#include + +#include class QQuickWindow; class QQuickItem; -namespace JellyfinNative { +namespace Spool { class InputLatencyMonitor; +class LibraryListModel; class RouterController; -class AppController; + +// The handful of things the benchmark drives in the app, handed in so the +// diagnostics layer never names the composition root. +struct RenderBenchmarkHooks { + LibraryListModel *libraries = nullptr; + std::function openLibrary; + std::function outstandingArtworkRequests; + // decodeMsTotal, decodedPixelsTotal and decodedImagesTotal, as the + // artwork service counts them. + std::function artworkDecodeTotals; + // Identifies the implementation actually serving the UI, not merely a + // registered module. Update this when the JS application path is wired. + QString providerId; + QString providerRuntime; + // Requests application memory-pressure eviction. This does not flush Qt's + // disk/AOT caches, the OS page cache, or necessarily every resident page. + std::function forceColdCaches; +}; // Walks the app through a scripted set of route switches and writes down what // each one cost, so "does a page still appear in one frame" is a number in CI @@ -32,14 +53,14 @@ class RenderBenchmark final : public QObject { public: // Returns nullptr when SPOOL_BENCH is unset, which is every ordinary run. - static RenderBenchmark *createIfRequested(AppController *app, RouterController *router, + static RenderBenchmark *createIfRequested(RenderBenchmarkHooks hooks, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent); void start(); private: - RenderBenchmark(AppController *app, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, - QObject *parent); + RenderBenchmark(RenderBenchmarkHooks hooks, RouterController *router, InputLatencyMonitor *latency, + QQuickWindow *window, QObject *parent); void step(); void recordSample(); @@ -64,7 +85,7 @@ class RenderBenchmark final : public QObject { void finishIdleProbe(); void finish(); - AppController *m_app = nullptr; + RenderBenchmarkHooks m_hooks; RouterController *m_router = nullptr; InputLatencyMonitor *m_latency = nullptr; // Offscreen, nothing asks for a frame on its own: no compositor is @@ -73,6 +94,9 @@ class RenderBenchmark final : public QObject { // been swapped, so the harness has to keep asking for one. QQuickWindow *m_window = nullptr; QTimer *m_pump = nullptr; + QTimer *m_stepDeadline = nullptr; + int m_pumpIntervalMs = 8; + QVariantList m_failures; // The idle probe is the same kind of timer on the same interval as the // transition gap timer, so a pause that would show up as a dropped frame @@ -106,9 +130,8 @@ class RenderBenchmark final : public QObject { // How long to sit still between steps. Doubles as the idle-probe window, // so the noise floor is sampled over the same span a transition occupies. int m_settleMs = 120; - // Drop every cached page before each step, so the walk measures what a - // route costs to build rather than what it costs to reveal. This is the - // case a television lives in, where memory keeps nothing resident. + // Request memory-pressure eviction before each step. Record actual + // cache-hit classification separately; this is not a fully cold process. bool m_forceCold = false; int m_position = -1; @@ -118,4 +141,4 @@ class RenderBenchmark final : public QObject { QElapsedTimer m_stepTimer; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/SystemPerformanceMonitor.cpp b/src/diagnostics/SystemPerformanceMonitor.cpp index 582f1017..9bfb5538 100644 --- a/src/diagnostics/SystemPerformanceMonitor.cpp +++ b/src/diagnostics/SystemPerformanceMonitor.cpp @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { SystemPerformanceMonitor::SystemPerformanceMonitor(QObject *parent) : QObject(parent) @@ -60,4 +60,4 @@ void SystemPerformanceMonitor::sample() emit metricsChanged(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/SystemPerformanceMonitor.h b/src/diagnostics/SystemPerformanceMonitor.h index 32446370..34b5c80d 100644 --- a/src/diagnostics/SystemPerformanceMonitor.h +++ b/src/diagnostics/SystemPerformanceMonitor.h @@ -7,7 +7,7 @@ #include -namespace JellyfinNative { +namespace Spool { class SystemPerformanceMonitor final : public QObject { Q_OBJECT @@ -137,4 +137,4 @@ class SystemPerformanceMonitor final : public QObject { qint64 m_systemTotalBytes = 0; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/discovery/DiscoveryController.cpp b/src/discovery/DiscoveryController.cpp deleted file mode 100644 index 77c55a0e..00000000 --- a/src/discovery/DiscoveryController.cpp +++ /dev/null @@ -1,704 +0,0 @@ -#include "DiscoveryController.h" -#include "../common/NetworkAddress.h" -#include "../common/TlsTrust.h" - -#include "../diagnostics/Diagnostics.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr quint16 kDiscoveryPort = 7359; - constexpr auto kDiscoveryPayload = "who is JellyfinServer?"; - constexpr int kHttpProbePort = 8096; - constexpr int kHttpProbeTimeoutMs = 600; - constexpr int kManualProbeTimeoutMs = 3000; - constexpr int kHttpProbeConcurrency = 8; - constexpr int kInitialHttpFallbackDelayMs = 1500; - constexpr int kMaxAutomaticHttpProbeTargets = 254; - // Sweeping neighbours is the expensive half of discovery, so only the two - // best-ranked networks get one. A shared budget instead let whichever - // interface the kernel happened to list first — routinely a docker bridge - // or a VPN tunnel — spend the whole allowance before the LAN was reached. - constexpr int kMaxScannedNetworks = 2; - constexpr int kUnicastSweepBatch = 24; - constexpr int kUnicastSweepIntervalMs = 15; - - // Names that belong to container bridges, hypervisors and VPN tunnels. - // A Jellyfin server is very unlikely to live behind one, and each costs a - // full subnet sweep, so they sort behind real network interfaces. - bool isSyntheticInterface(const QString& name) - { - static const QStringList prefixes - = { QStringLiteral("docker"), QStringLiteral("br-"), QStringLiteral("virbr"), QStringLiteral("veth"), - QStringLiteral("vmnet"), QStringLiteral("vboxnet"), QStringLiteral("tun"), QStringLiteral("tap"), - QStringLiteral("utun"), QStringLiteral("wg"), QStringLiteral("zt"), QStringLiteral("tailscale"), - QStringLiteral("podman"), QStringLiteral("cni"), QStringLiteral("flannel"), QStringLiteral("kube") }; - for (const QString& prefix : prefixes) { - if (name.startsWith(prefix, Qt::CaseInsensitive)) - return true; - } - return false; - } - - int scanPriority(const QNetworkInterface& iface, const QNetworkAddressEntry& entry) - { - int score = 0; - if (isSyntheticInterface(iface.name())) - score += 100; - if (iface.flags() & QNetworkInterface::IsPointToPoint) - score += 50; - // A /16 is a container bridge far more often than it is a home LAN. - if (entry.prefixLength() > 0 && entry.prefixLength() < 24) - score += 10; - return score; - } - - struct ScanNetwork { - QHostAddress ip; - QHostAddress netmask; - int priority = 0; - }; - - QList rankedScanNetworks() - { - QList networks; - const auto interfaces = QNetworkInterface::allInterfaces(); - for (const QNetworkInterface& iface : interfaces) { - if (!(iface.flags() & QNetworkInterface::IsUp) || !(iface.flags() & QNetworkInterface::IsRunning) - || (iface.flags() & QNetworkInterface::IsLoopBack)) { - continue; - } - - for (const QNetworkAddressEntry& entry : iface.addressEntries()) { - if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol || entry.netmask().isNull()) - continue; - networks.push_back({ entry.ip(), entry.netmask(), scanPriority(iface, entry) }); - } - } - std::stable_sort(networks.begin(), networks.end(), - [](const ScanNetwork& lhs, const ScanNetwork& rhs) { return lhs.priority < rhs.priority; }); - return networks; - } - - QUrl normalizedServerUrl(QUrl url) - { - url.setFragment({}); - url.setQuery({}); - QString path = url.path(); - while (path.endsWith(QLatin1Char('/')) && path.size() > 1) - path.chop(1); - if (path == QStringLiteral("/")) - path.clear(); - url.setPath(path); - return url; - } - - QUrl publicInfoUrl(QUrl serverUrl) - { - QString path = serverUrl.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - serverUrl.setPath(path + QStringLiteral("/System/Info/Public")); - return serverUrl; - } - - QUrl serverUrlFromPublicInfoUrl(QUrl publicInfo) - { - static const QString suffix = QStringLiteral("/System/Info/Public"); - QString path = publicInfo.path(); - if (path.endsWith(suffix)) - path.chop(suffix.size()); - publicInfo.setPath(path); - return normalizedServerUrl(publicInfo); - } - - void appendUniqueUrl(QList *urls, QSet *seen, const QUrl& candidate) - { - const QUrl normalized = normalizedServerUrl(candidate); - if (!normalized.isValid() || normalized.host().isEmpty() - || (normalized.scheme() != QStringLiteral("http") && normalized.scheme() != QStringLiteral("https"))) { - return; - } - const QString key = normalized.toString(QUrl::FullyEncoded); - if (seen->contains(key)) - return; - seen->insert(key); - urls->push_back(normalized); - } - - // Parses what the user typed the way the probe does: a bare host gains an - // http:// prefix so QUrl can split host from port and path. - QUrl parseTypedAddress(const QString& input, bool *explicitUrl) - { - QString value = input.trimmed(); - while (value.endsWith(QLatin1Char('/'))) - value.chop(1); - if (value.isEmpty()) - return {}; - - static const QRegularExpression explicitScheme( - QStringLiteral(R"(^[a-z][a-z0-9+.-]*://)"), QRegularExpression::CaseInsensitiveOption); - const bool scheme = explicitScheme.match(value).hasMatch(); - if (explicitUrl) - *explicitUrl = scheme; - const QUrl parsed(scheme ? value : QStringLiteral("http://") + value); - if (!parsed.isValid() || parsed.host().isEmpty()) - return {}; - return normalizedServerUrl(parsed); - } - - // Servers behind container networking announce the address they see - // themselves on, which is routable only inside that network. The datagram - // reached us from somewhere we can actually talk to, so that wins. - QString reachableServerAddress(const QString& advertised, const QHostAddress& sender) - { - QUrl url(advertised); - QHostAddress advertisedHost; - if (!url.isValid() || url.host().isEmpty() || sender.isNull()) - return advertised; - // Only literals are second-guessed. A name may resolve perfectly well - // for us even when it names something the server reached differently. - if (!advertisedHost.setAddress(url.host())) - return advertised; - if (advertisedHost.isEqual(sender, QHostAddress::TolerantConversion)) - return advertised; - - url.setHost(sender.toString()); - return normalizedServerUrl(url).toString(QUrl::FullyEncoded); - } - - void appendSubnetTargets(QList *targets, QSet *seen, quint32 network, quint32 broadcast, - quint32 ownAddress, int maxTargets) - { - if (broadcast <= network + 1 || targets->size() >= maxTargets) - return; - - const auto append = [&](quint32 candidate) { - if (targets->size() >= maxTargets || candidate <= network || candidate >= broadcast - || candidate == ownAddress || seen->contains(candidate)) { - return; - } - seen->insert(candidate); - targets->push_back(QHostAddress(candidate)); - }; - - append(network + 1); - append(broadcast - 1); - if (ownAddress > network + 1) - append(ownAddress - 1); - if (ownAddress + 1 < broadcast) - append(ownAddress + 1); - for (quint32 candidate = network + 1; candidate < broadcast && targets->size() < maxTargets; ++candidate) - append(candidate); - } - -} - -DiscoveryController::DiscoveryController(TlsTrustController *tlsTrust, QObject *parent) - : QObject(parent) -{ - if (tlsTrust) { - tlsTrust->attachNetworkAccessManager(&m_http, QStringLiteral("Server discovery")); - connect( - &m_http, &QNetworkAccessManager::sslErrors, this, [this](QNetworkReply *reply, const QList&) { - if (reply == m_serverProbeReply) - m_tlsRetryInput = m_serverProbeInput; - }); - connect( - tlsTrust, &TlsTrustController::decisionResolved, this, [this](const QString& source, bool trusted, bool) { - if (source != QStringLiteral("Server discovery") || m_tlsRetryInput.isEmpty()) - return; - const QString input = std::exchange(m_tlsRetryInput, {}); - if (trusted) - probeServer(input); - }); - } - connect(&m_socket, &QUdpSocket::readyRead, this, &DiscoveryController::handlePendingDatagrams); - connect(&m_rescanTimer, &QTimer::timeout, this, &DiscoveryController::sendProbe); - m_rescanTimer.setInterval(15000); - connect(&m_unicastSweepTimer, &QTimer::timeout, this, &DiscoveryController::pumpUnicastSweep); - m_unicastSweepTimer.setInterval(kUnicastSweepIntervalMs); -} - -DiscoveryController::~DiscoveryController() -{ - stop(); -} - -bool DiscoveryController::active() const -{ - return m_active; -} - -bool DiscoveryController::serverProbeActive() const -{ - return m_serverProbeReply || !m_serverProbeCandidates.isEmpty(); -} - -QList DiscoveryController::serverProbeCandidates(const QString& input) -{ - bool explicitUrl = false; - const QUrl parsed = parseTypedAddress(input, &explicitUrl); - if (parsed.isEmpty()) - return {}; - - const QString host = parsed.host(); - const bool lan = isLanHost(host); - const int suppliedPort = parsed.port(-1); - QList result; - QSet seen; - - const auto add = [&](const QString& scheme, int port) { - QUrl candidate = parsed; - candidate.setScheme(scheme); - candidate.setPort(port); - appendUniqueUrl(&result, &seen, candidate); - }; - - if (explicitUrl) { - appendUniqueUrl(&result, &seen, parsed); - if (parsed.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0) - add(QStringLiteral("http"), suppliedPort > 0 && suppliedPort != 443 ? suppliedPort : kHttpProbePort); - else if (parsed.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) == 0 && suppliedPort < 0) - add(QStringLiteral("http"), kHttpProbePort); - return result; - } - - if (lan) { - add(QStringLiteral("http"), suppliedPort > 0 ? suppliedPort : kHttpProbePort); - add(QStringLiteral("https"), suppliedPort); - if (suppliedPort < 0) - add(QStringLiteral("http"), -1); - } else { - add(QStringLiteral("https"), suppliedPort); - add(QStringLiteral("http"), suppliedPort > 0 ? suppliedPort : kHttpProbePort); - if (suppliedPort < 0) - add(QStringLiteral("http"), -1); - } - return result; -} - -bool DiscoveryController::looksLikeServerAddress(const QString& input) -{ - QString typed = input.trimmed(); - while (typed.endsWith(QLatin1Char('/'))) - typed.chop(1); - // The login screen probes while the user types, so anything that reads as - // mid-keystroke — a dangling label, hyphen or port separator — waits. - if (typed.endsWith(QLatin1Char('.')) || typed.endsWith(QLatin1Char('-')) || typed.endsWith(QLatin1Char(':'))) - return false; - - const QUrl parsed = parseTypedAddress(typed, nullptr); - if (parsed.isEmpty() || serverProbeCandidates(typed).isEmpty()) - return false; - - // An explicit port, an IP literal, localhost or a plausible public suffix - // are the shapes that can already resolve to a host. - const QString host = parsed.host(); - if (parsed.port(-1) > 0 || !QHostAddress(host).isNull()) - return true; - if (host.compare(QStringLiteral("localhost"), Qt::CaseInsensitive) == 0) - return true; - const qsizetype lastDot = host.lastIndexOf(QLatin1Char('.')); - return lastDot > 0 && host.size() - lastDot - 1 >= 2; -} - -QList DiscoveryController::httpFallbackTargets( - const QHostAddress& address, const QHostAddress& netmask, int maxTargets) -{ - QList targets; - if (maxTargets <= 0 || address.protocol() != QAbstractSocket::IPv4Protocol || !isPrivateNetworkAddress(address)) - return targets; - - const quint32 own = address.toIPv4Address(); - const quint32 mask = netmask.protocol() == QAbstractSocket::IPv4Protocol ? netmask.toIPv4Address() : 0xffffff00u; - QSet seen; - - const quint32 local24 = own & 0xffffff00u; - appendSubnetTargets(&targets, &seen, local24, local24 | 0xffu, own, maxTargets); - - const quint32 configuredNetwork = own & mask; - const quint32 configuredBroadcast = configuredNetwork | ~mask; - const quint64 configuredHosts = static_cast(configuredBroadcast) - configuredNetwork - 1; - if (configuredHosts > 0 && configuredHosts <= static_cast(maxTargets)) { - appendSubnetTargets(&targets, &seen, configuredNetwork, configuredBroadcast, own, maxTargets); - } else { - if (local24 >= 0x100u) - appendSubnetTargets(&targets, &seen, local24 - 0x100u, local24 - 1u, own, maxTargets); - if (local24 <= 0xfffffe00u) - appendSubnetTargets(&targets, &seen, local24 + 0x100u, local24 + 0x1ffu, own, maxTargets); - } - return targets; -} - -DiscoveredServer DiscoveryController::serverFromPublicInfo( - const QByteArray& payload, const QUrl& serverUrl, QString *version) -{ - const QJsonDocument document = QJsonDocument::fromJson(payload); - if (!document.isObject()) - return {}; - - const QJsonObject object = document.object(); - const QString id = object.value(QStringLiteral("Id")).toString().trimmed(); - QString name = object.value(QStringLiteral("ServerName")).toString().trimmed(); - if (name.isEmpty()) - name = object.value(QStringLiteral("Name")).toString().trimmed(); - if (id.isEmpty() || name.isEmpty()) - return {}; - if (version) - *version = object.value(QStringLiteral("Version")).toString().trimmed(); - return { id, name, normalizedServerUrl(serverUrl).toString(QUrl::FullyEncoded) }; -} - -void DiscoveryController::start() -{ - Diagnostics::Task task(QStringLiteral("discovery_start")); - if (m_active) - return; - - m_active = true; - const bool udpReady = ensureSocket(); - qInfo() << "discovery active" << m_active << "udpReady=" << udpReady << "boundPort" << m_socket.localPort() - << "error" << (udpReady ? QStringLiteral("none") : m_socket.errorString()); - emit activeChanged(); - - if (udpReady) - sendProbe(); - m_rescanTimer.start(); - QTimer::singleShot(kInitialHttpFallbackDelayMs, this, &DiscoveryController::startHttpFallbackScan); -} - -void DiscoveryController::stop() -{ - Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("discovery_stop"), - { { QStringLiteral("active"), m_active }, { QStringLiteral("inFlightHttpProbes"), m_inFlightHttpProbes } }); - cancelServerProbe(); - if (!m_active) - return; - - m_active = false; - m_rescanTimer.stop(); - m_unicastSweepTimer.stop(); - m_unicastSweepQueue.clear(); - m_socket.close(); - m_httpProbeQueue.clear(); - m_enqueuedHttpProbeTargets.clear(); - const auto replies = m_httpProbeReplies; - for (QNetworkReply *reply : replies) { - if (!reply) - continue; - reply->disconnect(this); - reply->abort(); - reply->deleteLater(); - } - m_httpProbeReplies.clear(); - m_inFlightHttpProbes = 0; - emit activeChanged(); -} - -void DiscoveryController::sendProbe() -{ - if (!m_active || m_socket.state() != QAbstractSocket::BoundState) - return; - - m_socket.writeDatagram(QByteArray(kDiscoveryPayload), QHostAddress::Broadcast, kDiscoveryPort); - qInfo() << "discovery broadcast probe sent"; - - // Docker forwards a published UDP port for unicast only, so a server in a - // container on this machine never sees the broadcasts above. - m_socket.writeDatagram(QByteArray(kDiscoveryPayload), QHostAddress::LocalHost, kDiscoveryPort); - - const auto interfaces = QNetworkInterface::allInterfaces(); - for (const QNetworkInterface& iface : interfaces) { - if (!(iface.flags() & QNetworkInterface::IsUp) || !(iface.flags() & QNetworkInterface::IsRunning) - || (iface.flags() & QNetworkInterface::IsLoopBack)) { - continue; - } - - for (const QNetworkAddressEntry& entry : iface.addressEntries()) { - if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol || entry.broadcast().isNull()) - continue; - - m_socket.writeDatagram(QByteArray(kDiscoveryPayload), entry.broadcast(), kDiscoveryPort); - qInfo() << "discovery interface probe sent"; - } - } - - if (!m_foundAnyServer) - sendUnicastSweep(); -} - -void DiscoveryController::sendUnicastSweep() -{ - // Container runtimes forward a published UDP port for unicast traffic - // only, so a server in one never sees the broadcasts above. Asking each - // neighbour directly costs a few hundred small datagrams — far less than - // the HTTP sweep — and the replies are ordinary unicast answers, which a - // stateful firewall lets back in without a rule of its own. - if (!m_unicastSweepQueue.isEmpty()) - return; - - const auto networks = rankedScanNetworks(); - int swept = 0; - for (const ScanNetwork& network : networks) { - if (swept >= kMaxScannedNetworks) - break; - ++swept; - const auto targets = httpFallbackTargets(network.ip, network.netmask, kMaxAutomaticHttpProbeTargets); - for (const QHostAddress& target : targets) - m_unicastSweepQueue.enqueue(target); - } - - qInfo() << "discovery unicast sweep queued" << m_unicastSweepQueue.size() << "targets across" << swept - << "networks"; - m_unicastSweepTimer.start(); - pumpUnicastSweep(); -} - -void DiscoveryController::pumpUnicastSweep() -{ - // The socket send buffer cannot take a few hundred datagrams in one go; - // past it writeDatagram simply fails and those neighbours are never asked. - // Anything that does not fit stays at the head of the queue for next tick. - int sent = 0; - while (!m_unicastSweepQueue.isEmpty() && sent < kUnicastSweepBatch) { - if (m_socket.writeDatagram(QByteArray(kDiscoveryPayload), m_unicastSweepQueue.head(), kDiscoveryPort) <= 0) - return; - m_unicastSweepQueue.dequeue(); - ++sent; - } - - if (m_unicastSweepQueue.isEmpty()) { - m_unicastSweepTimer.stop(); - qInfo() << "discovery unicast sweep drained"; - } -} - -void DiscoveryController::handlePendingDatagrams() -{ - while (m_socket.hasPendingDatagrams()) { - const QNetworkDatagram datagram = m_socket.receiveDatagram(); - if (datagram.data() == QByteArray(kDiscoveryPayload)) - continue; - - qInfo() << "discovery datagram from" << datagram.senderAddress().toString() << "bytes" - << datagram.data().size(); - const auto document = QJsonDocument::fromJson(datagram.data()); - if (!document.isObject()) { - qWarning() << "discovery datagram was not a JSON object"; - continue; - } - const auto object = document.object(); - if (!object.contains(QStringLiteral("Id")) || !object.contains(QStringLiteral("Name")) - || !object.contains(QStringLiteral("Address"))) { - qWarning() << "discovery datagram missing Id/Name/Address keys" << object.keys(); - continue; - } - - const QString advertised = object.value(QStringLiteral("Address")).toString(); - const QString address = reachableServerAddress(advertised, datagram.senderAddress()); - if (address != advertised) - qInfo() << "discovery rewrote unroutable advertised address to the datagram sender"; - qInfo() << "discovery reply accepted"; - m_foundAnyServer = true; - emit serverDiscovered({ - object.value(QStringLiteral("Id")).toString(), - object.value(QStringLiteral("Name")).toString(), - address, - }); - } -} - -void DiscoveryController::startHttpFallbackScan() -{ - Diagnostics::Task task(QStringLiteral("discovery_http_fallback_scan")); - if (!m_active || m_inFlightHttpProbes > 0 || !m_httpProbeQueue.isEmpty()) - return; - - const auto networks = rankedScanNetworks(); - - // A server published on this machine — a container forwarding 8096, or a - // plain local install — only answers on loopback or on one of this host's - // own addresses, and a neighbour sweep skips both by design. - enqueueHttpProbeTarget(QHostAddress(QHostAddress::LocalHost)); - for (const ScanNetwork& network : networks) - enqueueHttpProbeTarget(network.ip); - - int scanned = 0; - for (const ScanNetwork& network : networks) { - if (scanned >= kMaxScannedNetworks) - break; - ++scanned; - const auto targets = httpFallbackTargets(network.ip, network.netmask, kMaxAutomaticHttpProbeTargets); - for (const QHostAddress& target : targets) - enqueueHttpProbeTarget(target); - } - - qInfo() << "http discovery queued" << m_httpProbeQueue.size() << "targets across" << scanned << "networks"; - pumpHttpProbeQueue(); -} - -bool DiscoveryController::ensureSocket() -{ - if (m_socket.state() == QAbstractSocket::BoundState) - return true; - - return m_socket.bind( - QHostAddress::AnyIPv4, kDiscoveryPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); -} - -void DiscoveryController::enqueueHttpProbeTarget(const QHostAddress& address) -{ - const QString key = address.toString(); - if (m_enqueuedHttpProbeTargets.contains(key)) - return; - - m_enqueuedHttpProbeTargets.insert(key); - m_httpProbeQueue.enqueue(address); -} - -void DiscoveryController::pumpHttpProbeQueue() -{ - while (m_inFlightHttpProbes < kHttpProbeConcurrency && !m_httpProbeQueue.isEmpty()) { - const QHostAddress address = m_httpProbeQueue.dequeue(); - const QString host = address.toString(); - const QString serverUrl = QStringLiteral("http://%1:%2").arg(host).arg(kHttpProbePort); - - QNetworkRequest request(QUrl(serverUrl + QStringLiteral("/System/Info/Public"))); - request.setTransferTimeout(kHttpProbeTimeoutMs); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("JellyfinNativeDiscovery/0.1.0")); - - QNetworkReply *reply = m_http.get(request); - m_httpProbeReplies.insert(reply); - ++m_inFlightHttpProbes; - Diagnostics::logEvent(QStringLiteral("network"), QStringLiteral("discovery_probe_begin"), - { { QStringLiteral("host"), host }, { QStringLiteral("inFlight"), m_inFlightHttpProbes } }); - - connect(reply, &QNetworkReply::finished, this, [this, reply, serverUrl, host]() { - m_httpProbeReplies.remove(reply); - if (reply->error() == QNetworkReply::NoError) { - const QByteArray payload = reply->readAll(); - qInfo() << "http discovery reply accepted"; - handleHttpProbeResult(serverUrl, payload); - } - - reply->deleteLater(); - m_inFlightHttpProbes = qMax(0, m_inFlightHttpProbes - 1); - m_enqueuedHttpProbeTargets.remove(host); - Diagnostics::logEvent(QStringLiteral("network"), QStringLiteral("discovery_probe_end"), - { { QStringLiteral("host"), host }, { QStringLiteral("inFlight"), m_inFlightHttpProbes } }); - pumpHttpProbeQueue(); - }); - } -} - -void DiscoveryController::handleHttpProbeResult(const QString& serverUrl, const QByteArray& payload) -{ - const DiscoveredServer server = serverFromPublicInfo(payload, QUrl(serverUrl), nullptr); - if (server.id.isEmpty()) - return; - emit serverDiscovered(server); -} - -void DiscoveryController::probeServer(const QString& input) -{ - cancelServerProbe(); - m_serverProbeInput = input.trimmed(); - const QList candidates = serverProbeCandidates(m_serverProbeInput); - for (const QUrl& candidate : candidates) - m_serverProbeCandidates.enqueue(candidate); - if (m_serverProbeCandidates.isEmpty()) { - emit serverProbeFailed(m_serverProbeInput, QStringLiteral("Enter a valid Jellyfin server address.")); - return; - } - emit serverProbeActiveChanged(); - startNextServerProbe(); -} - -void DiscoveryController::cancelServerProbe() -{ - const bool wasActive = serverProbeActive(); - m_serverProbeCandidates.clear(); - m_serverProbeInput.clear(); - if (m_serverProbeReply) { - m_serverProbeReply->disconnect(this); - m_serverProbeReply->abort(); - m_serverProbeReply->deleteLater(); - m_serverProbeReply = nullptr; - } - if (wasActive) - emit serverProbeActiveChanged(); -} - -void DiscoveryController::startNextServerProbe() -{ - if (m_serverProbeReply || m_serverProbeCandidates.isEmpty()) { - if (!m_serverProbeReply && m_serverProbeCandidates.isEmpty()) - finishServerProbe(true); - return; - } - - const QUrl serverUrl = m_serverProbeCandidates.dequeue(); - QNetworkRequest request(publicInfoUrl(serverUrl)); - request.setTransferTimeout(kManualProbeTimeoutMs); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - request.setHeader(QNetworkRequest::UserAgentHeader, - QCoreApplication::applicationName() + QLatin1Char('/') + QCoreApplication::applicationVersion()); - - QNetworkReply *reply = m_http.get(request); - m_serverProbeReply = reply; - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - if (m_serverProbeReply != reply) - return; - m_serverProbeReply = nullptr; - const QByteArray payload = reply->error() == QNetworkReply::NoError ? reply->readAll() : QByteArray {}; - reply->deleteLater(); - - QString version; - const DiscoveredServer server - = serverFromPublicInfo(payload, serverUrlFromPublicInfoUrl(reply->url()), &version); - if (!server.id.isEmpty()) { - const QString input = m_serverProbeInput; - const bool plainHttp = QUrl(server.address).scheme() == QStringLiteral("http"); - m_serverProbeCandidates.clear(); - m_serverProbeInput.clear(); - emit serverProbeActiveChanged(); - // Deliberately not announced as a discovered server: the screen - // already shows this result next to the address that produced it, - // and listing it again reads as two servers. It joins the list - // when the user actually picks it. - emit serverProbeSucceeded(input, server, version, plainHttp); - return; - } - startNextServerProbe(); - }); -} - -void DiscoveryController::finishServerProbe(bool notifyFailure) -{ - const QString input = m_serverProbeInput; - const bool wasActive = serverProbeActive() || !input.isEmpty(); - m_serverProbeCandidates.clear(); - m_serverProbeInput.clear(); - if (wasActive) - emit serverProbeActiveChanged(); - if (notifyFailure) - emit serverProbeFailed(input, QStringLiteral("No Jellyfin server responded at that address.")); -} - -} // namespace JellyfinNative diff --git a/src/discovery/DiscoveryController.h b/src/discovery/DiscoveryController.h deleted file mode 100644 index 30aeb28d..00000000 --- a/src/discovery/DiscoveryController.h +++ /dev/null @@ -1,83 +0,0 @@ -#pragma once - -#include "../common/JellyfinTypes.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace JellyfinNative { - -class TlsTrustController; -class DiscoveryController final : public QObject { - Q_OBJECT - Q_PROPERTY(bool active READ active NOTIFY activeChanged) - Q_PROPERTY(bool serverProbeActive READ serverProbeActive NOTIFY serverProbeActiveChanged) - -public: - explicit DiscoveryController(TlsTrustController *tlsTrust, QObject *parent = nullptr); - ~DiscoveryController() override; - - bool active() const; - bool serverProbeActive() const; - - static QList serverProbeCandidates(const QString& input); - // True once the typed text names an address worth probing on its own, so - // the login screen can connect as you type instead of waiting for Enter. - Q_INVOKABLE static bool looksLikeServerAddress(const QString& input); - static QList httpFallbackTargets( - const QHostAddress& address, const QHostAddress& netmask, int maxTargets = 254); - static DiscoveredServer serverFromPublicInfo(const QByteArray& payload, const QUrl& serverUrl, QString *version); - - Q_INVOKABLE void start(); - Q_INVOKABLE void stop(); - Q_INVOKABLE void probeServer(const QString& input); - Q_INVOKABLE void cancelServerProbe(); - -signals: - void activeChanged(); - void serverProbeActiveChanged(); - void serverDiscovered(const JellyfinNative::DiscoveredServer& server); - void serverProbeSucceeded( - const QString& input, const JellyfinNative::DiscoveredServer& server, const QString& version, bool plainHttp); - void serverProbeFailed(const QString& input, const QString& message); - -private slots: - void sendProbe(); - void handlePendingDatagrams(); - void startHttpFallbackScan(); - -private: - bool ensureSocket(); - void sendUnicastSweep(); - void pumpUnicastSweep(); - void enqueueHttpProbeTarget(const QHostAddress& address); - void pumpHttpProbeQueue(); - void handleHttpProbeResult(const QString& serverUrl, const QByteArray& payload); - void startNextServerProbe(); - void finishServerProbe(bool notifyFailure); - - QUdpSocket m_socket; - QTimer m_rescanTimer; - QTimer m_unicastSweepTimer; - QQueue m_unicastSweepQueue; - QNetworkAccessManager m_http; - QQueue m_httpProbeQueue; - QSet m_enqueuedHttpProbeTargets; - QSet m_httpProbeReplies; - QQueue m_serverProbeCandidates; - QNetworkReply *m_serverProbeReply = nullptr; - QString m_serverProbeInput; - QString m_tlsRetryInput; - int m_inFlightHttpProbes = 0; - bool m_active = false; - bool m_foundAnyServer = false; -}; - -} // namespace JellyfinNative diff --git a/src/main.cpp b/src/main.cpp index b5d3404a..37d14070 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,22 +1,31 @@ -#include "api/JellyfinApiFacade.h" #include "app/AppController.h" #include "app/ArtworkImageProvider.h" +#include "app/ArtworkService.h" +#include "app/CollectionEditingController.h" #include "app/CpuTopology.h" #include "app/GraphicsStartup.h" +#include "app/GroupPlaybackController.h" #include "app/LocalizationManager.h" #include "app/MemoryBudget.h" +#include "app/RemoteTargetsController.h" #include "app/RouterController.h" -#include "app/SessionController.h" +#include "app/SettingsSyncController.h" +#include "app/TrickplayPreviewItem.h" +#include "app/TrickplayService.h" #include "app/UserItemStateController.h" #include "cache/DatabaseManager.h" -#include "common/JellyfinTypes.h" +#include "common/AsyncTask.h" #include "common/LogRotation.h" #include "common/TlsTrust.h" +#if !defined(SPOOL_ANDROID) && !defined(SPOOL_WEBOS) && !defined(SPOOL_APPLE_MOBILE) +#include "automation/LocalCommandServer.h" +#endif +#include "app/DownloadManager.h" #include "diagnostics/Diagnostics.h" #include "diagnostics/InputLatencyMonitor.h" #include "diagnostics/RenderBenchmark.h" #include "diagnostics/SystemPerformanceMonitor.h" -#include "discovery/DiscoveryController.h" +#include "media/MediaTypes.h" #include "platform/NativeAppWindow.h" #include "platform/PlatformApplicationServices.h" #include "platform/PlatformCapabilities.h" @@ -27,16 +36,26 @@ #include "platform/ScreenSaverInhibitor.h" #include "player/MpvVideoItem.h" #include "player/PlayerController.h" -#if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) +#include "provider/ProviderCapabilities.h" +#include "provider/ProviderQmlCache.h" +#include "provider/ProviderRegistry.h" +#include "provider/ProviderStore.h" +#include "provider/SourceHub.h" +#include "providers/local/LocalProvider.h" +#if defined(SPOOL_ANDROID) || defined(SPOOL_WEBOS) #include "platform/UpdateController.h" #endif #if defined(SPOOL_ANDROID) #include #endif +#ifdef Q_OS_TVOS +#include "platform/tvos/TvOSRemoteInput.h" +#endif #include #include #include +#include #include #include #include @@ -47,8 +66,10 @@ #include #include #include +#include +#include -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) #include "platform/webos/WebOSDeviceName.h" #endif #include @@ -65,8 +86,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -77,6 +100,7 @@ #include #include #include +#include #ifdef Q_OS_WIN #include #endif @@ -89,6 +113,23 @@ namespace { +// A start-up option: `--name value` or `--name=value` on the command line, +// else the environment variable, else the fallback. +QString optionValue( + const QStringList& arguments, const QString& option, const char *environmentVariable, const QString& fallback = {}) +{ + for (qsizetype index = 1; index < arguments.size(); ++index) { + const QString& argument = arguments.at(index); + if (argument == option && index + 1 < arguments.size()) + return arguments.at(index + 1); + if (argument.startsWith(option + QLatin1Char('='))) + return argument.mid(option.size() + 1); + } + if (qEnvironmentVariableIsSet(environmentVariable)) + return QString::fromLocal8Bit(qgetenv(environmentVariable)); + return fallback; +} + // The window's stock incubation controller advances async QML construction // ~5 ms per frame, so a page whose creation costs ~200 ms of CPU takes // 500-700 ms of wall time to instantiate. A timer-driven controller with a @@ -119,7 +160,7 @@ class BoostedIncubationController final : public QObject, public QQmlIncubationC }; constexpr auto kAppId = "com.sachk.spool"; -constexpr auto kAppVersion = JELLYFIN_VERSION; +constexpr auto kAppVersion = SPOOL_VERSION; FILE *g_logFile = nullptr; QByteArray g_logPath; @@ -151,14 +192,14 @@ void writeStandardError(const QByteArray& line) FILE *openRotatedLogFile(const QByteArray& path) { - JellyfinNative::rotateLogFile(path.constData()); + Spool::rotateLogFile(path.constData()); return fopen(path.constData(), "w"); } FILE *openAppLogFile(const QString& appRootPath) { - const QByteArray fileName = QFile::encodeName(JellyfinNative::appLogFileName()); - for (const QString& directory : JellyfinNative::appLogDirectories(appRootPath)) { + const QByteArray fileName = QFile::encodeName(Spool::appLogFileName()); + for (const QString& directory : Spool::appLogDirectories(appRootPath)) { if (directory.isEmpty()) continue; QDir().mkpath(directory); @@ -168,17 +209,58 @@ FILE *openAppLogFile(const QString& appRootPath) if (FILE *file = openRotatedLogFile(path)) { QFile::setPermissions(QString::fromLocal8Bit(path), QFileDevice::ReadOwner | QFileDevice::WriteOwner); g_logPath = path; - qputenv("JELLYFIN_NATIVE_LOG_DIR", encodedDirectory); + qputenv("SPOOL_LOG_DIR", encodedDirectory); return file; } } return nullptr; } +// Through 0.8 the app was "Spool for Jellyfin" from "spool-jellyfin". Its data, +// cache and settings follow it to the new name once, so the rename signs +// nobody out. Android keys storage to the package and has nothing to move. +// Graphics startup reads the settings store, so this runs before it. +void setIdentity() +{ +#ifdef Q_OS_ANDROID + QCoreApplication::setOrganizationName(QStringLiteral("spool")); + QCoreApplication::setApplicationName(QStringLiteral("Spool")); +#else + const QStandardPaths::StandardLocation locations[] = { QStandardPaths::AppDataLocation, + QStandardPaths::AppLocalDataLocation, QStandardPaths::CacheLocation, QStandardPaths::AppConfigLocation }; + QCoreApplication::setOrganizationName(QStringLiteral("spool-jellyfin")); + QCoreApplication::setApplicationName(QStringLiteral("Spool for Jellyfin")); + QStringList previous; + for (const auto location : locations) + previous.append(QStandardPaths::writableLocation(location)); + const QSettings previousSettings; + const QStringList keys = previousSettings.allKeys(); + QCoreApplication::setOrganizationName(QStringLiteral("spool")); + QCoreApplication::setApplicationName(QStringLiteral("Spool")); + for (size_t index = 0; index < std::size(locations); ++index) { + const QString current = QStandardPaths::writableLocation(locations[index]); + const QString old = previous.at(qsizetype(index)); + if (old != current && QFileInfo::exists(old) && !QFileInfo::exists(current)) { + QDir().mkpath(QFileInfo(current).absolutePath()); + if (QDir().rename(old, current)) + QDir().rmdir(QFileInfo(old).absolutePath()); + } + } + QSettings settings; + if (settings.allKeys().isEmpty()) { + for (const QString& key : keys) + settings.setValue(key, previousSettings.value(key)); + } +#endif +} + void configurePersistentStartupCaches(const QString& cacheRoot) { const QString qtShaderCache = QDir(cacheRoot).filePath(QStringLiteral("qtshadercache")); - const QString qmlDiskCache = QDir(cacheRoot).filePath(QStringLiteral("qmlcache")); + // Resource timestamps are fixed in reproducible builds, so compiled QML + // and provider modules are only valid for this exact version and bundle. + const QString qmlDiskCache = QDir(cacheRoot).filePath(QStringLiteral("qmlcache-") + QString::fromLatin1(kAppVersion) + + QLatin1Char('-') + QStringLiteral(SPOOL_PROVIDER_BUNDLE_DIGEST)); QDir().mkpath(qtShaderCache); QDir().mkpath(qmlDiskCache); @@ -224,7 +306,7 @@ void logLine(const char *fmt, ...) void qtMessageHandler(QtMsgType type, const QMessageLogContext& context, const QString& message) { - if (type == QtWarningMsg && !qEnvironmentVariableIsSet("JELLYFIN_NATIVE_VERBOSE_QT")) { + if (type == QtWarningMsg && !qEnvironmentVariableIsSet("SPOOL_VERBOSE_QT")) { const QString category = context.category ? QString::fromLatin1(context.category) : QString(); if (message.startsWith(QStringLiteral("Detected locale \"C\"")) || (category == QStringLiteral("qt.qpa.wayland") @@ -251,7 +333,7 @@ void qtMessageHandler(QtMsgType type, const QMessageLogContext& context, const Q break; } - const QByteArray local = JellyfinNative::sanitizedLogMessage(message).toLocal8Bit(); + const QByteArray local = Spool::sanitizedLogMessage(message).toLocal8Bit(); if (context.category && context.category[0]) logLine("[qt:%s] %s: %s", level, context.category, local.constData()); else @@ -267,7 +349,7 @@ void logQmlWarnings(const QList& warnings) logLine("[qml] %s", qPrintable(warning.toString())); } -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) && !defined(SPOOL_APPLE_MOBILE) QIcon applicationIcon(bool playerSelected) { const QString variant = playerSelected ? QStringLiteral("spool-film") : QStringLiteral("spool"); @@ -284,7 +366,7 @@ bool registerBundledFonts(const QString& appRootPath) "PTRootUI-Variable.ttf", "MaterialIcons-Regular.ttf", }; - const QDir fontsDirectory(JellyfinNative::bundledFontsPath(appRootPath)); + const QDir fontsDirectory(Spool::bundledFontsPath(appRootPath)); for (const char *fileName : fontFiles) { const QString path = fontsDirectory.filePath(QString::fromLatin1(fileName)); if (!QFileInfo::exists(path)) { @@ -311,7 +393,7 @@ double splashCoreWidthDp() // this has to make the same choice the resource system just made or Qt's // first frame lands on different pixels than the frame it replaces. return static_cast( - JellyfinNative::platformCapabilities().isTV ? SPOOL_SPLASH_CORE_WIDTH_DP_TV : SPOOL_SPLASH_CORE_WIDTH_DP_PHONE); + Spool::platformCapabilities().isTV ? SPOOL_SPLASH_CORE_WIDTH_DP_TV : SPOOL_SPLASH_CORE_WIDTH_DP_PHONE); #else return 0.0; #endif @@ -321,7 +403,7 @@ double splashCoreWidthDp() // everywhere else it is in the binary. QUrl splashImageUrl(const QString& appRootPath) { -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) return QUrl::fromLocalFile(QDir(appRootPath).filePath(QStringLiteral("splash-core.png"))); #else Q_UNUSED(appRootPath); @@ -383,20 +465,22 @@ int main(int argc, char **argv) #ifdef Q_OS_UNIX umask(S_IRWXG | S_IRWXO); #endif - const JellyfinNative::ProcessStartupTiming processStartupTiming = JellyfinNative::captureProcessStartupTiming(); + const Spool::ProcessStartupTiming processStartupTiming = Spool::captureProcessStartupTiming(); g_startupTimer.start(); QElapsedTimer& startupTimer = g_startupTimer; bool launchTest = false; for (int i = 1; i < argc; ++i) { if (strcmp(argv[i], "--version") == 0 || strcmp(argv[i], "-v") == 0) { - printf("Spool for Jellyfin %s\n", kAppVersion); + printf("Spool %s\n", kAppVersion); return 0; } if (strcmp(argv[i], "--launch-test") == 0) launchTest = true; + if (strcmp(argv[i], "--unredacted-urls") == 0) + Spool::setDiagnosticUrlsUnredacted(true); } - const QString appRootPath = JellyfinNative::resolveAppRoot(argv[0]); + const QString appRootPath = Spool::resolveAppRoot(argv[0]); if (appRootPath.isEmpty()) return 1; @@ -406,6 +490,9 @@ int main(int argc, char **argv) static_cast(processStartupTiming.execToMainMs), processStartupTiming.staticInitializationMs); if (!g_logPath.isEmpty()) logLine("log file: %s", g_logPath.constData()); + if (Spool::diagnosticUrlsUnredacted()) + logLine("WARNING: --unredacted-urls exposes full URLs and URL credentials in local logs. " + "Keep these logs private; do not share them without redacting secrets."); // libmpv parses option strings (and many internal numeric values) with the // C locale assumption — under any other LC_NUMERIC playback fails to start @@ -422,12 +509,12 @@ int main(int argc, char **argv) qputenv("LC_CTYPE", QByteArrayLiteral("en_US.UTF-8")); } - if (!JellyfinNative::configurePlatformEnvironment(appRootPath)) + if (!Spool::configurePlatformEnvironment(appRootPath)) return 1; if (!qgetenv("QSG_RENDER_LOOP").isEmpty()) logLine("QSG_RENDER_LOOP=%s", qgetenv("QSG_RENDER_LOOP").constData()); - const QString cachePath = JellyfinNative::startupCacheRoot(appRootPath); + const QString cachePath = Spool::startupCacheRoot(appRootPath); configurePersistentStartupCaches(cachePath); logLine("app root: %s", qPrintable(appRootPath)); @@ -442,18 +529,17 @@ int main(int argc, char **argv) // Graphics startup reads the persisted backend before QGuiApplication. // Establish the settings-store identity first; these setters are static. - QCoreApplication::setOrganizationName(QStringLiteral("spool-jellyfin")); - QCoreApplication::setApplicationName(QStringLiteral("Spool for Jellyfin")); + setIdentity(); QCoreApplication::setApplicationVersion(QString::fromLatin1(kAppVersion)); - const auto graphicsApi = JellyfinNative::GraphicsStartup::configureBeforeApplication(launchTest); + const auto graphicsApi = Spool::GraphicsStartup::configureBeforeApplication(launchTest); logLine("startup: constructing QGuiApplication"); QGuiApplication app(argc, argv); // The name, version and organisation are set above, before the settings // store is read. - app.setApplicationDisplayName(QStringLiteral("Spool for Jellyfin")); - JellyfinNative::TerminationSignalHandler terminationSignals(app); + app.setApplicationDisplayName(QStringLiteral("Spool")); + Spool::TerminationSignalHandler terminationSignals(app); logLine("startup: QGuiApplication constructed"); QString autoplayItemId; @@ -461,21 +547,16 @@ int main(int argc, char **argv) const QString arg = QString::fromLocal8Bit(argv[i]); if (arg == QStringLiteral("--play") && i + 1 < argc) { autoplayItemId = QString::fromLocal8Bit(argv[++i]); - } else if (!arg.startsWith('-') && arg.length() >= 16) { - autoplayItemId = arg; } } - if (autoplayItemId.isEmpty()) { - autoplayItemId = QString::fromLocal8Bit(qgetenv("SPOOL_PLAY_ITEM")); - } - const QByteArray hdrRequest = JellyfinNative::GraphicsStartup::prepareBeforeWindow(graphicsApi, launchTest); + const QByteArray hdrRequest = Spool::GraphicsStartup::prepareBeforeWindow(graphicsApi, launchTest); // Native-window and scene-graph setup stay on the GUI thread. Put a surface // on screen now so later startup work can overlap its first frame. - JellyfinNative::InputLatencyMonitor inputLatencyMonitor; - JellyfinNative::NativeAppWindow window(QString::fromLatin1(kAppId)); - JellyfinNative::GraphicsStartup::attachToWindow(window, hdrRequest); + Spool::InputLatencyMonitor inputLatencyMonitor; + Spool::NativeAppWindow window(QString::fromLatin1(kAppId)); + Spool::GraphicsStartup::attachToWindow(window, hdrRequest); window.rootContext()->setContextProperty(QStringLiteral("startupNativeWindow"), &window); // The launch screen. Everything that draws it -- the frame put up before // the shell exists, the shell's own overlay, the slow-start page -- reads @@ -488,7 +569,10 @@ int main(int argc, char **argv) QStringLiteral("startupSplashCoreWidthFraction"), static_cast(SPOOL_SPLASH_CORE_WIDTH_FRACTION)); window.rootContext()->setContextProperty(QStringLiteral("startupSplashCoreWidthDp"), splashCoreWidthDp()); window.rootContext()->setContextProperty(QStringLiteral("startupSplashPixelsPerDp"), splashPixelsPerDp()); - JellyfinNative::configurePlatformWindow(window); + Spool::configurePlatformWindow(window); +#ifdef Q_OS_TVOS + Spool::installTvOSRemoteInput(window); +#endif inputLatencyMonitor.attachWindow(&window); window.setInputLatencyMonitor(&inputLatencyMonitor); const auto directSingleShot = static_cast(Qt::DirectConnection | Qt::SingleShotConnection); @@ -526,38 +610,56 @@ int main(int argc, char **argv) if (!registerBundledFonts(appRootPath)) return 1; -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) && !defined(SPOOL_APPLE_MOBILE) const QIcon defaultApplicationIcon = applicationIcon(false); const QIcon playerApplicationIcon = applicationIcon(true); app.setWindowIcon(defaultApplicationIcon); app.setDesktopFileName(QStringLiteral("com.sachk.spool")); #endif - const auto& capabilities = JellyfinNative::platformCapabilities(); + const auto& capabilities = Spool::platformCapabilities(); - const QString diagnosticsRoot = qEnvironmentVariableIsSet("JELLYFIN_DIAGNOSTICS_DIR") - ? QString::fromLocal8Bit(qgetenv("JELLYFIN_DIAGNOSTICS_DIR")) + const QString diagnosticsRoot = qEnvironmentVariableIsSet("SPOOL_DIAGNOSTICS_DIR") + ? QString::fromLocal8Bit(qgetenv("SPOOL_DIAGNOSTICS_DIR")) : QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/diagnostics"); - JellyfinNative::Diagnostics::initialize(QString::fromLatin1(kAppId), diagnosticsRoot); - JellyfinNative::Diagnostics::EventLoopWatchdog eventLoopWatchdog(&app); + Spool::Diagnostics::initialize(QString::fromLatin1(kAppId), diagnosticsRoot); + Spool::Diagnostics::EventLoopWatchdog eventLoopWatchdog(&app); const QStringList arguments = app.arguments(); + for (int i = 1; i < arguments.size(); ++i) { + const QString& arg = arguments.at(i); + if (arg.startsWith(QLatin1Char('{')) && arg.endsWith(QLatin1Char('}'))) { + const QJsonDocument doc = QJsonDocument::fromJson(arg.toUtf8()); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + const QJsonObject params = obj.contains(QStringLiteral("parameters")) + ? obj.value(QStringLiteral("parameters")).toObject() + : (obj.contains(QStringLiteral("params")) ? obj.value(QStringLiteral("params")).toObject() : obj); + for (auto it = params.begin(); it != params.end(); ++it) { + const QString key = it.key(); + const QString val = it.value().toVariant().toString(); + if (!val.isEmpty()) + qputenv(key.toUtf8(), val.toUtf8()); + } + } + } + } if (arguments.contains(QStringLiteral("--diagnose-and-exit")) || arguments.contains(QStringLiteral("--dump-diagnostics"))) { - JellyfinNative::Diagnostics::dumpDiagnostics(QStringLiteral("command-line")); - JellyfinNative::Diagnostics::shutdown(); + Spool::Diagnostics::dumpDiagnostics(QStringLiteral("command-line")); + Spool::Diagnostics::shutdown(); return 0; } - if (qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_BLOCK_GUI_MS") > 0) { - const int blockMs = qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_BLOCK_GUI_MS"); + if (qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_BLOCK_GUI_MS") > 0) { + const int blockMs = qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_BLOCK_GUI_MS"); QTimer::singleShot(1000, &app, [blockMs]() { - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_begin"), + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_begin"), { { QStringLiteral("durationMs"), blockMs } }); QThread::msleep(static_cast(blockMs)); - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_end")); + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_end")); }); } - const JellyfinNative::MemoryBudget memoryBudget = JellyfinNative::MemoryBudget::detect(); + const Spool::MemoryBudget memoryBudget = Spool::MemoryBudget::detect(); window.setSystemMemoryBytes(memoryBudget.memTotalBytes); logLine("memory budget: memTotal=%lld networkDisk=%lld qmlImageDisk=%lld artworkBytes=%d demuxer=%s/%s", static_cast(memoryBudget.memTotalBytes), static_cast(memoryBudget.networkDiskCacheBytes), @@ -572,92 +674,166 @@ int main(int argc, char **argv) diskCache->setMaximumCacheSize(memoryBudget.networkDiskCacheBytes); networkAccessManager->setCache(diskCache); QObject *platformUpdateController = nullptr; -#if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_ANDROID) || defined(SPOOL_WEBOS) // QML binds before the shell exists. Android waits for its settings; // the webOS experiment starts its unconditional check in the event loop. - auto updateController = std::make_unique(networkAccessManager, cachePath); + auto updateController = std::make_unique(networkAccessManager, cachePath); platformUpdateController = updateController.get(); #endif - JellyfinNative::DatabaseManager database; - QObject::connect( - &database, &JellyfinNative::DatabaseManager::initializationFailed, &app, [&app](const QString& message) { - logLine("database initialization failed: %s", qPrintable(message)); - app.exit(1); - }); - JellyfinNative::SystemPerformanceMonitor systemPerformanceMonitor; - systemPerformanceMonitor.setAudioDecodeCpuTimeProvider( - [] { return JellyfinNative::platformAudioDecodeCpuTimeNs(); }); + Spool::DatabaseManager database; + QObject::connect(&database, &Spool::DatabaseManager::initializationFailed, &app, [&app](const QString& message) { + logLine("database initialization failed: %s", qPrintable(message)); + app.exit(1); + }); + Spool::SystemPerformanceMonitor systemPerformanceMonitor; + systemPerformanceMonitor.setAudioDecodeCpuTimeProvider([] { return Spool::platformAudioDecodeCpuTimeNs(); }); // Start the SQLite worker before constructing the controllers. Device // identity and session reads are awaited after the first frame. { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("database_initialize")); - const QString databasePath = JellyfinNative::persistentDataRoot() + QStringLiteral("/cache.sqlite"); + Spool::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("database_initialize")); + const QString databasePath = Spool::persistentDataRoot() + QStringLiteral("/cache.sqlite"); if (!database.initialize(databasePath)) return 1; } - JellyfinNative::TlsTrustController tlsTrust; - auto discovery = std::make_unique(&tlsTrust); - auto api = std::make_unique(networkAccessManager, &tlsTrust); - api->setDeviceIdentity({}, capabilities.deviceName, QString::fromLatin1(kAppVersion)); -#if defined(JELLYFIN_NATIVE_WEBOS) - // webOS reports the name the owner gave the set only on request, and - // again whenever they change it. Marshal it onto the API's thread, since - // the luna callback does not belong to us. - JellyfinNative::requestWebOSDeviceName([apiPointer = api.get()](const QString& name) { - QMetaObject::invokeMethod( - apiPointer, [apiPointer, name]() { apiPointer->setDeviceName(name); }, Qt::QueuedConnection); - }); + Spool::TlsTrustController tlsTrust; + // Accounts outlive the player and the app controller, which hold their + // parts by pointer, so the registry and the hub come first. + // Chosen by SPOOL_PROVIDER_SOURCES at configure time, which rejects + // anything else; an unknown name would fall to the narrowest. + const Spool::ProviderSources providerSources + = Spool::providerSourcesFromName(QString::fromLatin1(SPOOL_PROVIDER_SOURCES)) + .value_or(Spool::ProviderSources::Bundled); + logLine("providers: sources=%s", SPOOL_PROVIDER_SOURCES); + Spool::ProviderRegistry providers(&database); + if (providerSources != Spool::ProviderSources::Bundled) + providers.setInstallDirectory(QDir(Spool::persistentDataRoot()).filePath(QStringLiteral("providers"))); + providers.loadModules(); +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) && !defined(SPOOL_APPLE_MOBILE) + { + Spool::ProviderManifest folder; + folder.id = QStringLiteral("spool.local"); + folder.name = QStringLiteral("This computer"); + folder.summary = QStringLiteral("Combine selected video and music folders into a library"); + folder.publisher = QStringLiteral("Spool"); + folder.version = QString::fromLatin1(kAppVersion); + folder.capabilities = { QStringLiteral("search"), QStringLiteral("userState") }; + folder.ui = { { QStringLiteral("login"), QStringLiteral("LocalFolders.qml") }, + { QStringLiteral("settings"), QStringLiteral("LocalFolders.qml") } }; + providers.addNativeModule( + folder, + [](const QString& accountId, const QVariantMap& configuration, QObject *parent) -> Spool::Provider * { + return new Spool::LocalProvider( + accountId, configuration.value(QStringLiteral("folders")).toStringList(), parent); + }, + QUrl(QStringLiteral("qrc:/qt/qml/Spool/qml/pages/"))); + } #endif - JellyfinNative::configurePlatformPlaybackCapabilities(*api, app); + Spool::SourceHub hub(&providers); + Spool::DownloadManager downloads(&hub, Spool::persistentDataRoot(), &tlsTrust); + std::unique_ptr offlineLibrary; + const auto refreshOfflineLibrary = [&] { + hub.removeSource(QStringLiteral("spool-downloads")); + offlineLibrary + = std::make_unique(QStringLiteral("spool-downloads"), QStringList {}, nullptr, + downloads.libraryFiles(), QDir(Spool::persistentDataRoot()).filePath(QStringLiteral("offline-state"))); + hub.addSource(offlineLibrary.get()); + }; + QObject::connect(&downloads, &Spool::DownloadManager::libraryChanged, &app, refreshOfflineLibrary); + refreshOfflineLibrary(); + Spool::CollectionEditingController collectionEditing(&hub); + Spool::ProviderCapabilities providerCapabilities; + QObject::connect(&hub, &Spool::Provider::capabilitiesChanged, &providerCapabilities, + [&hub, &providerCapabilities] { providerCapabilities.setFlags(hub.capabilities()); }); + Spool::configurePlatformPlaybackCapabilities( + [&hub](const QStringList& codecs, bool restrict) { hub.setVideoCodecs(codecs, restrict); }, hub); + // Only an open build may be pointed at another store; a curated one is + // curated by this one. + const QString officialStore = QStringLiteral("https://spool-player.github.io/spool-providers/"); + Spool::ProviderStore store(&providers, &database, networkAccessManager, + QUrl(providerSources == Spool::ProviderSources::Open + ? optionValue(arguments, QStringLiteral("--provider-store"), "SPOOL_PROVIDER_STORE", officialStore) + : officialStore), + providerSources); - const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); + const Spool::CpuTopology cpuTopology = Spool::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, cpuTopology.physicalCores, cpuTopology.smtDetected ? "true" : "false", qPrintable(cpuTopology.source), cpuTopology.artworkDecodeThreads); - auto artworkService = std::make_unique( - qmlImageCachePath + QStringLiteral("/artwork"), memoryBudget.qmlImageDiskCacheBytes, - memoryBudget.artworkByteCacheBytes, cpuTopology.artworkDecodeThreads, &tlsTrust); + auto artworkService = std::make_unique(qmlImageCachePath + QStringLiteral("/artwork"), + memoryBudget.qmlImageDiskCacheBytes, memoryBudget.artworkByteCacheBytes, cpuTopology.artworkDecodeThreads, + &tlsTrust); artworkService->setUiWidth(window.width()); + artworkService->setSource(hub.artwork()); + Spool::TrickplayService trickplay(hub.artwork(), &tlsTrust); + Spool::TrickplayService remoteTrickplay(hub.artwork(), &tlsTrust); - auto player = std::make_unique( - &window, api.get(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); + auto player = std::make_unique( + &window, hub.playback(), &tlsTrust, Spool::bundledFontsPath(appRootPath)); + player->setTrickplayService(&trickplay); player->setDemuxerBudget(memoryBudget.mpvDemuxerMaxBytes, memoryBudget.mpvDemuxerMaxBackBytes); - JellyfinNative::ScreenSaverInhibitor screenSaverInhibitor; + Spool::ScreenSaverInhibitor screenSaverInhibitor; const auto updateScreenSaver = [&screenSaverInhibitor, player = player.get()] { - screenSaverInhibitor.setInhibited(JellyfinNative::screenSaverShouldBeInhibited( - player && player->sessionActive(), player && player->paused())); + screenSaverInhibitor.setInhibited( + Spool::screenSaverShouldBeInhibited(player && player->sessionActive(), player && player->paused())); }; - QObject::connect(player.get(), &JellyfinNative::PlayerController::playbackStateChanged, &app, updateScreenSaver); - QObject::connect(player.get(), &JellyfinNative::PlayerController::sessionActiveChanged, &app, updateScreenSaver); -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) + QObject::connect(player.get(), &Spool::PlayerController::playbackStateChanged, &app, updateScreenSaver); + QObject::connect(player.get(), &Spool::PlayerController::sessionActiveChanged, &app, updateScreenSaver); +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) && !defined(SPOOL_APPLE_MOBILE) const auto updateApplicationIcon = [&app, &window, player = player.get(), &defaultApplicationIcon, &playerApplicationIcon] { const QIcon& icon = player->sessionActive() ? playerApplicationIcon : defaultApplicationIcon; app.setWindowIcon(icon); window.setIcon(icon); }; - QObject::connect( - player.get(), &JellyfinNative::PlayerController::sessionActiveChanged, &app, updateApplicationIcon); + QObject::connect(player.get(), &Spool::PlayerController::sessionActiveChanged, &app, updateApplicationIcon); updateApplicationIcon(); #endif - auto controller = std::make_unique( - &database, discovery.get(), api.get(), artworkService.get(), player.get(), &tlsTrust); -#if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) + auto controller = std::make_unique(&database, &hub, artworkService.get(), player.get()); + Spool::SettingsSyncController settingsSync(controller->settings(), &database, &providers); + Spool::RemoteTargetsController remoteTargets(&hub, &providers, controller->group()); + remoteTargets.setTrickplayService(&remoteTrickplay); + controller->attachRemoteTargets(&remoteTargets); + QObject::connect(&providers, &Spool::ProviderRegistry::accountIdentityRevoked, controller.get(), + &Spool::AppController::revokeAccountIdentity); + controller->attachSettingsSync(&settingsSync); + controller->settings()->attachSync(&settingsSync); + const auto updateTrickplayDecoding = [&hub, &trickplay, &remoteTrickplay, settings = controller->settings()] { + const bool accurate = settings->value(QStringLiteral("playback/accurateTrickplay")).toBool(); + const bool enabled = settings->value(QStringLiteral("playback/seekPreviews")).toBool(); + hub.setVideoPreviewsEnabled(enabled); + trickplay.setEnabled(enabled); + remoteTrickplay.setEnabled(enabled); + trickplay.setAccurateDecoding(accurate); + remoteTrickplay.setAccurateDecoding(accurate); + }; + QObject::connect( + controller->settings(), &Spool::SettingsController::settingsValuesChanged, &app, updateTrickplayDecoding); + updateTrickplayDecoding(); + controller->settings()->attachInputLatency(&inputLatencyMonitor); + settingsSync.setForeground(app.applicationState() == Qt::ApplicationActive); + QObject::connect(&app, &QGuiApplication::applicationStateChanged, &settingsSync, + [&settingsSync](Qt::ApplicationState state) { settingsSync.setForeground(state == Qt::ApplicationActive); }); + QObject::connect(controller->settings(), &Spool::SettingsController::playbackPreferencesChanged, &hub, + &Spool::SourceHub::setPlaybackPreferences); + QObject::connect( + &providers, &Spool::ProviderRegistry::problem, controller.get(), &Spool::AppController::toastMessage); + QObject::connect(&store, &Spool::ProviderStore::problem, controller.get(), &Spool::AppController::toastMessage); +#if defined(SPOOL_ANDROID) || defined(SPOOL_WEBOS) // Settings own the update preference; platform installers own installation. - QObject::connect(controller->settings(), &JellyfinNative::SettingsController::automaticUpdatesChanged, + QObject::connect(controller->settings(), &Spool::SettingsController::automaticUpdatesChanged, updateController.get(), [updater = updateController.get()](bool enabled) { updater->setAutomaticUpdatesEnabled(enabled); }); #endif - QObject::connect(controller.get(), &JellyfinNative::AppController::clearLogsRequested, &app, [appRootPath]() { + QObject::connect(controller.get(), &Spool::AppController::clearLogsRequested, &app, [appRootPath]() { const std::lock_guard lock(g_logMutex); if (g_logFile) { fclose(g_logFile); g_logFile = nullptr; } - const QByteArray fileName = QFile::encodeName(JellyfinNative::appLogFileName()); - for (const QString& directory : JellyfinNative::appLogDirectories(appRootPath)) { + const QByteArray fileName = QFile::encodeName(Spool::appLogFileName()); + for (const QString& directory : Spool::appLogDirectories(appRootPath)) { if (directory.isEmpty()) continue; const QString path = QDir(directory).filePath(QString::fromUtf8(fileName)); @@ -671,7 +847,7 @@ int main(int argc, char **argv) // Tear down here so the mpv render-context handoff completes immediately; // aboutToQuit is too late because the window no longer produces frames. QObject::connect( - &window, &JellyfinNative::NativeAppWindow::closeRequested, controller.get(), + &window, &Spool::NativeAppWindow::closeRequested, controller.get(), [controller = controller.get()]() { logLine("window close requested: stopping controllers"); controller->shutdown(); @@ -688,23 +864,23 @@ int main(int argc, char **argv) // against null pointers and emit a flood of "Cannot read property // 'X' of null" warnings during the unwind. QObject::connect(&app, &QCoreApplication::aboutToQuit, &app, [controller = controller.get(), &window]() { - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("shutting_down")); - JellyfinNative::Diagnostics::Phase shutdownPhase(QStringLiteral("shutdown"), QStringLiteral("aboutToQuit")); + Spool::Diagnostics::setInstanceState(QStringLiteral("shutting_down")); + Spool::Diagnostics::Phase shutdownPhase(QStringLiteral("shutdown"), QStringLiteral("aboutToQuit")); logLine("aboutToQuit: stopping controllers"); - if (qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_SHUTDOWN_HANG_MS") > 0) { - const int hangMs = qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_SHUTDOWN_HANG_MS"); - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_begin"), + if (qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_SHUTDOWN_HANG_MS") > 0) { + const int hangMs = qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_SHUTDOWN_HANG_MS"); + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_begin"), { { QStringLiteral("durationMs"), hangMs } }); QThread::msleep(static_cast(hangMs)); - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_end")); + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_end")); } { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("controller_shutdown")); + Spool::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("controller_shutdown")); controller->shutdown(); } logLine("aboutToQuit: clearing QML source"); { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("clear_qml_source")); + Spool::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("clear_qml_source")); window.setSource(QUrl()); } logLine("aboutToQuit: QML source cleared"); @@ -712,30 +888,128 @@ int main(int argc, char **argv) // Parented to the engine so it outlives every incubator and dies with it. window.engine()->setIncubationController(new BoostedIncubationController(window.engine())); + // Warm all QML in selected provider packages after the first frame. Low-priority, + // asynchronous compilation retains code only, never live provider screens. + auto *providerQmlCache = new Spool::ProviderQmlCache(window.engine()); + const auto warmProviderComponents = [providerQmlCache, &providers] { + QList roots; + for (const QString& id : providers.moduleIds()) { + const Spool::ProviderModule *module = providers.module(id); + if (!module->native) + roots.append(module->root); + } + providerQmlCache->setPackages(roots); + }; + warmProviderComponents(); + QObject::connect(&providers, &Spool::ProviderRegistry::modulesChanged, providerQmlCache, warmProviderComponents); + QObject::connect(providerQmlCache, &Spool::ProviderQmlCache::finished, &app, + [providerQmlCache] { logLine("provider QML: warmed %d components", providerQmlCache->retainedCount()); }); + QObject::connect( + &window, &QQuickWindow::frameSwapped, providerQmlCache, [providerQmlCache] { providerQmlCache->start(); }, + Qt::SingleShotConnection); + QObject::connect(controller.get(), &Spool::AppController::aggressiveMemoryPressure, providerQmlCache, + &Spool::ProviderQmlCache::clear); + QObject::connect(&app, &QCoreApplication::aboutToQuit, providerQmlCache, &Spool::ProviderQmlCache::clear); + window.engine()->addImageProvider(QStringLiteral("artwork"), new Spool::ArtworkImageProvider(artworkService.get())); + window.engine()->addImageProvider(QStringLiteral("trickplay"), new Spool::TrickplayImageProvider(&trickplay)); window.engine()->addImageProvider( - QStringLiteral("artwork"), new JellyfinNative::ArtworkImageProvider(artworkService.get())); + QStringLiteral("remote-trickplay"), new Spool::TrickplayImageProvider(&remoteTrickplay)); window.engine()->addImageProvider(QStringLiteral("mpv-overlay"), window.createOverlayImageProvider()); -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) window.engine()->addImportPath(appRootPath + QStringLiteral("/qt-qml")); #endif QObject::connect(window.engine(), &QQmlEngine::warnings, &logQmlWarnings); QObject::connect(&window, &QQuickView::statusChanged, [](QQuickView::Status status) { logLine("view status changed: %d", static_cast(status)); }); - auto localization = std::make_unique(); + auto localization = std::make_unique(); localization->attachToEngine(window.engine()); - if (api) { - api->setAcceptLanguage(localization->bcp47Locale()); - QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, api.get(), - [api = api.get(), loc = localization.get()]() { api->setAcceptLanguage(loc->bcp47Locale()); }); - } - auto router = std::make_unique(); - JellyfinNative::PlatformApplicationServices platformServices(app, window, *controller, *router); + controller->settings()->attachLocalization(localization.get()); + // Providers start once the device identity they present is known: in + // parallel, off the GUI thread, after the first frame is on its way. + QObject::connect(controller.get(), &Spool::AppController::deviceIdentityReady, &providers, + [&providers, &store, &capabilities, &tlsTrust, loc = localization.get(), settings = controller->settings()]( + const QString& deviceId) { + providers.setRuntimeEnvironment( + { { QStringLiteral("id"), deviceId }, { QStringLiteral("name"), capabilities.deviceName }, + { QStringLiteral("app"), QStringLiteral("Spool") }, + { QStringLiteral("version"), QString::fromLatin1(kAppVersion) }, + { QStringLiteral("platform"), QSysInfo::productType() }, + { QStringLiteral("locale"), loc->bcp47Locale() } }, + { [&tlsTrust](QNetworkAccessManager *network) { + tlsTrust.attachNetworkAccessManager(network, QStringLiteral("Provider")); + }, + [&tlsTrust](QWebSocket *socket, QUrl url) { + tlsTrust.attachWebSocket(socket, [url] { return url; }, QStringLiteral("Provider")); + } }); + Spool::Async::runScoped( + &providers, providers.restore(), [] { }, [](const std::exception_ptr&) { }, "provider restore"); + // Checked once the launch has settled, so it never competes with + // the first page for the network. + if (store.storeAvailable()) { + QTimer::singleShot(8000, &store, [&store, settings] { + store.checkForUpdates(settings->value(QStringLiteral("providers/updates")).toString()); + }); + } + }); + auto router = std::make_unique(QStringLiteral("home")); + QTimer identityRouteReset; + identityRouteReset.setSingleShot(true); + bool identityRoutePending = false; + QObject::connect(&providers, &Spool::ProviderRegistry::accountIdentityRevoked, &identityRouteReset, + [&identityRouteReset, &identityRoutePending](const QString&) { + identityRoutePending = true; + identityRouteReset.start(0); + }); + QObject::connect(&providers, &Spool::ProviderRegistry::sourceStarted, &identityRouteReset, + [&identityRouteReset, &identityRoutePending](Spool::Provider *) { + if (identityRoutePending) + identityRouteReset.start(0); + }); + QObject::connect(&identityRouteReset, &QTimer::timeout, router.get(), + [&providers, &identityRoutePending, router = router.get()] { + bool active = false; + for (const QVariant& value : providers.accounts()) { + const QVariantMap account = value.toMap(); + active |= account.value(QStringLiteral("enabled")).toBool() + && account.value(QStringLiteral("connectionState")).toString() == QStringLiteral("active"); + } + router->reset(active ? QStringLiteral("home") : QStringLiteral("accounts")); + identityRoutePending = !active; + }); + Spool::ApplicationHooks applicationHooks; + applicationHooks.player = player.get(); + applicationHooks.settings = controller->settings(); + applicationHooks.playQueue = controller->playQueue(); + applicationHooks.artwork = controller->artwork(); + applicationHooks.memoryPressure + = [controller = controller.get()](const QString& level) { controller->onMemoryPressure(level); }; + applicationHooks.playbackTransition + = [controller = controller.get()] { return controller->property("playbackTransition").toBool(); }; + applicationHooks.stopPlayback = [controller = controller.get()] { controller->stopPlayback(); }; + applicationHooks.playNext = [controller = controller.get()] { controller->playQueueNext(); }; + applicationHooks.playPrevious = [controller = controller.get()] { controller->playQueuePrevious(); }; + QObject::connect(controller.get(), &Spool::AppController::playbackTransitionChanged, &applicationHooks, + &Spool::ApplicationHooks::playbackTransitionChanged); + QObject::connect(controller.get(), &Spool::AppController::aggressiveMemoryPressure, &applicationHooks, + &Spool::ApplicationHooks::aggressiveMemoryPressure); + QObject::connect(controller.get(), &Spool::AppController::diagnosticsReportSaved, &applicationHooks, + &Spool::ApplicationHooks::diagnosticsReportSaved); + QObject::connect(&applicationHooks, &Spool::ApplicationHooks::toastRequested, controller.get(), + &Spool::AppController::toastMessage); + QObject::connect( + &downloads, &Spool::DownloadManager::toastRequested, controller.get(), &Spool::AppController::toastMessage); + Spool::PlatformApplicationServices platformServices(app, window, applicationHooks, *router); platformServices.start(); QQmlPropertyMap *platformInfo = QQmlPropertyMap::create(&app); platformInfo->insert(QStringLiteral("isTV"), capabilities.isTV); platformInfo->insert(QStringLiteral("isWebOS"), capabilities.isWebOS); platformInfo->insert(QStringLiteral("isAndroid"), capabilities.isAndroid); platformInfo->insert(QStringLiteral("isMobile"), capabilities.isMobile); +#ifdef Q_OS_TVOS + platformInfo->insert(QStringLiteral("isTVOS"), true); +#else + platformInfo->insert(QStringLiteral("isTVOS"), false); +#endif #ifdef TOUCHSCREEN platformInfo->insert(QStringLiteral("touchscreen"), !capabilities.isTV); #else @@ -759,32 +1033,35 @@ int main(int argc, char **argv) QStringLiteral("splashCoreWidthFraction"), static_cast(SPOOL_SPLASH_CORE_WIDTH_FRACTION)); platformInfo->insert(QStringLiteral("splashCoreAspect"), static_cast(SPOOL_SPLASH_CORE_ASPECT)); platformInfo->insert(QStringLiteral("splashImageUrl"), splashImageUrl(appRootPath)); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "App", controller.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Art", artworkService.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Browse", controller->browse()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Home", controller->home()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Content", controller->content()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "ItemState", controller->itemState()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Search", controller->search()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Libraries", controller->libraries()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "DiscoveredServers", controller->discoveredServers()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Discovery", discovery.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "TlsTrust", &tlsTrust); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Session", controller->session()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "QuickConnect", controller->quickConnect()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Settings", controller->settings()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Player", controller->player()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "PlayQueue", controller->playQueue()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "SyncPlay", controller->syncPlay()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "RemoteControl", controller->remoteControl()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Management", controller->management()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Router", router.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "NativeWindow", &window); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "InputLatency", &inputLatencyMonitor); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "SystemPerformance", &systemPerformanceMonitor); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "I18n", localization.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Platform", platformInfo); - qmlRegisterType("JellyfinWebOS", 1, 0, "MpvVideoItem"); + qmlRegisterSingletonInstance("Spool", 1, 0, "App", controller.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "ProviderCapabilities", &providerCapabilities); + qmlRegisterSingletonInstance("Spool", 1, 0, "Providers", &providers); + qmlRegisterSingletonInstance("Spool", 1, 0, "Downloads", &downloads); + qmlRegisterSingletonInstance("Spool", 1, 0, "Sources", &hub); + qmlRegisterSingletonInstance("Spool", 1, 0, "CollectionEditing", &collectionEditing); + qmlRegisterSingletonInstance("Spool", 1, 0, "SettingsSync", &settingsSync); + qmlRegisterSingletonInstance("Spool", 1, 0, "RemoteTargets", &remoteTargets); + qmlRegisterSingletonInstance("Spool", 1, 0, "Store", &store); + qmlRegisterSingletonInstance("Spool", 1, 0, "Group", controller->group()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Art", artworkService.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Browse", controller->browse()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Home", controller->home()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Content", controller->content()); + qmlRegisterSingletonInstance("Spool", 1, 0, "ItemState", controller->itemState()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Search", controller->search()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Libraries", controller->libraries()); + qmlRegisterSingletonInstance("Spool", 1, 0, "TlsTrust", &tlsTrust); + qmlRegisterSingletonInstance("Spool", 1, 0, "Settings", controller->settings()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Player", controller->player()); + qmlRegisterSingletonInstance("Spool", 1, 0, "PlayQueue", controller->playQueue()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Router", router.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "NativeWindow", &window); + qmlRegisterSingletonInstance("Spool", 1, 0, "InputLatency", &inputLatencyMonitor); + qmlRegisterSingletonInstance("Spool", 1, 0, "SystemPerformance", &systemPerformanceMonitor); + qmlRegisterSingletonInstance("Spool", 1, 0, "I18n", localization.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Platform", platformInfo); + qmlRegisterType("Spool", 1, 0, "MpvVideoItem"); + qmlRegisterType("Spool", 1, 0, "TrickplayPreviewItem"); // Start asynchronous device, settings, account, and discovery reads before // QML construction. A sole saved account is resolved before routing begins. controller->initialize(); @@ -793,10 +1070,10 @@ int main(int argc, char **argv) &app, &QCoreApplication::aboutToQuit, router.get(), [router = router.get()] { router->markCleanShutdown(); }); { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("load_qml")); + Spool::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("load_qml")); // Load through the module registry so AOT QML units are used and // constrained devices do not retain compiler buffers. - window.loadFromModule("JellyfinWebOS", "Main"); + window.loadFromModule("Spool", "Main"); } if (window.status() == QQuickView::Error) { logQmlWarnings(window.errors()); @@ -847,19 +1124,35 @@ int main(int argc, char **argv) // as it always does and is then walked through a set of route switches // with what each one cost written out, so page-switch cost is a number in // CI rather than an impression. - if (auto *benchmark = JellyfinNative::RenderBenchmark::createIfRequested( - controller.get(), router.get(), &inputLatencyMonitor, &window, &app)) { - if (controller->initialized()) { + Spool::RenderBenchmarkHooks benchmarkHooks; + benchmarkHooks.providerId = QStringList(providers.moduleIds()).join(QLatin1Char(',')); + benchmarkHooks.providerRuntime = QStringLiteral("js"); + benchmarkHooks.libraries = controller->libraries(); + benchmarkHooks.openLibrary = [controller = controller.get()](int index) { controller->openLibrary(index); }; + benchmarkHooks.outstandingArtworkRequests + = [artwork = artworkService.get()] { return artwork->outstandingRequests(); }; + benchmarkHooks.artworkDecodeTotals = [artwork = artworkService.get()] { + const auto totals = artwork->decodeTotals(); + return QVariantMap { { QStringLiteral("decodeMsTotal"), static_cast(totals.decodeNs) / 1000000.0 }, + { QStringLiteral("decodedPixelsTotal"), static_cast(totals.pixels) }, + { QStringLiteral("decodedImagesTotal"), totals.images } }; + }; + benchmarkHooks.forceColdCaches + = [controller = controller.get()] { controller->onMemoryPressure(QStringLiteral("critical")); }; + if (auto *benchmark = Spool::RenderBenchmark::createIfRequested( + std::move(benchmarkHooks), router.get(), &inputLatencyMonitor, &window, &app)) { + // The shell picks its first route once accounts are restored; a walk + // that starts before then races that choice. + const auto started = std::make_shared(false); + const auto begin = [benchmark, started, controller = controller.get(), &providers] { + if (*started || !controller->initialized() || !providers.restored()) + return; + *started = true; benchmark->start(); - } else { - QObject::connect( - controller.get(), &JellyfinNative::AppController::initializedChanged, benchmark, - [benchmark, controller = controller.get()] { - if (controller->initialized()) - benchmark->start(); - }, - Qt::SingleShotConnection); - } + }; + QObject::connect(controller.get(), &Spool::AppController::initializedChanged, benchmark, begin); + QObject::connect(&providers, &Spool::ProviderRegistry::restoredChanged, benchmark, begin, Qt::QueuedConnection); + begin(); } if (launchTest) { @@ -899,12 +1192,12 @@ int main(int argc, char **argv) logLine("launch test: invalid shell geometry content=%.1fx%.1f nav=%.1f@%.1f " "route=%.1fx%.1f@%.1f", contentWidth, contentHeight, navHeight, navY, routeWidth, routeHeight, routeY); - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("launch_test_invalid_shell")); + Spool::Diagnostics::setInstanceState(QStringLiteral("launch_test_invalid_shell")); app.exit(1); return; } logLine("launch test: application UI rendered"); - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("launch_test_rendered")); + Spool::Diagnostics::setInstanceState(QStringLiteral("launch_test_rendered")); app.exit(0); }, Qt::QueuedConnection); @@ -917,27 +1210,43 @@ int main(int argc, char **argv) window.requestUpdate(); } if (!autoplayItemId.isEmpty()) { - QObject::connect(controller.get(), &JellyfinNative::AppController::initializedChanged, controller.get(), + QObject::connect(controller.get(), &Spool::AppController::initializedChanged, controller.get(), [autoplayItemId, c = controller.get()]() { if (c->initialized()) { logLine("startup: autoplay requested for item %s", qPrintable(autoplayItemId)); - QTimer::singleShot(300, c, [autoplayItemId, c]() { c->playItemId(autoplayItemId); }); + QTimer::singleShot(300, c, [autoplayItemId, c]() { c->playLocalItemId(autoplayItemId); }); } }); } QTimer::singleShot(1000, router.get(), [router = router.get()] { router->beginSession(false); }); +#if !defined(SPOOL_ANDROID) && !defined(SPOOL_WEBOS) && !defined(SPOOL_APPLE_MOBILE) + Spool::LocalCommandServer localCommands(controller.get(), router.get(), &window, &downloads, &hub); + if (!app.arguments().contains(QStringLiteral("--no-local-control"))) { + QString error; + const QString instance = optionValue(app.arguments(), QStringLiteral("--instance"), "SPOOL_INSTANCE"); + if (!localCommands.start(instance, &error)) { + logLine("local control unavailable: %s", qPrintable(error)); + // An explicitly named launch must not silently target a different instance. + if (!instance.isEmpty()) + return 1; + } + } + QObject::connect(&app, &QCoreApplication::aboutToQuit, &localCommands, &Spool::LocalCommandServer::stop); + QObject::connect( + &window, &Spool::NativeAppWindow::closeRequested, &localCommands, &Spool::LocalCommandServer::stop); +#endif QTimer::singleShot(0, &window, [&startupTimer]() { logLine("startup: first event-loop turn at %lld ms", static_cast(startupTimer.elapsed())); - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("running")); + Spool::Diagnostics::setInstanceState(QStringLiteral("running")); }); logLine("startup: entering event loop at %lld ms", static_cast(startupTimer.elapsed())); const int exitCode = app.exec(); logLine("app.exec returned: %d", exitCode); - JellyfinNative::Diagnostics::setInstanceState( + Spool::Diagnostics::setInstanceState( QStringLiteral("app_exec_returned"), { { QStringLiteral("exitCode"), exitCode } }); - JellyfinNative::Diagnostics::shutdown(); + Spool::Diagnostics::shutdown(); return exitCode; } diff --git a/src/common/JellyfinTypes.cpp b/src/media/MediaTypes.cpp similarity index 81% rename from src/common/JellyfinTypes.cpp rename to src/media/MediaTypes.cpp index 5c755b72..eb40c84e 100644 --- a/src/common/JellyfinTypes.cpp +++ b/src/media/MediaTypes.cpp @@ -1,6 +1,6 @@ -#include "JellyfinTypes.h" +#include "MediaTypes.h" +#include "../common/MetaJson.h" #include "../platform/PlatformSettingsPolicy.h" -#include "MetaJson.h" #include #include @@ -9,13 +9,15 @@ #include #include +#include #include -namespace JellyfinNative { +namespace Spool { namespace { constexpr qint64 kTicksPerSecond = 10000000; + std::atomic_bool g_diagnosticUrlsUnredacted { false }; bool streamLanguagesMatch(const QString& requested, const QString& available) { @@ -373,45 +375,104 @@ QString normalizedAudioOutputMode(const QString& mode) return normalizedPlatformAudioOutputMode(mode); } +namespace { + + QString redactedCredentialText(QString url) + { + static const QRegularExpression credentialQuery( + QStringLiteral("((?:[?&]|%26)(?:secret|code|password|pw|pin|api[_-]?key|(?:access|refresh)[_-]?token|token|" + "x-(?:emby|plex)-token)" + "(?:=|%3d))[^&\\s]+"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression credentialField( + QStringLiteral("((?:\\\"?(?:secret|code|password|pw|pin|api[_-]?key|(?:access|refresh)[_-]?token|token|x-(?" + ":emby|plex)-token)\\\"?)" + "\\s*[:=]\\s*)(?:\\\"[^\\\"]*\\\"|'[^']*'|[^\\\",}\\s]+)"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression credentialHeader( + QStringLiteral( + R"(((?")); + url.replace(credentialQuery, QStringLiteral("\\1")); + url.replace(credentialField, QStringLiteral("\\1")); + return url; + } + + QString sanitizedLogText(QString message) + { + message = redactedCredentialText(std::move(message)); + static const QRegularExpression personalField( + QStringLiteral( + "((?:\\\"?(?:title|episode[_-]?name|library[_-]?name|user[_-]?name|server[_-]?url|address|" + "item[_-]?id|profile[_-]?id|cache[_-]?key|device[_-]?id)\\\"?)\\s*[:=]\\s*\\\"?)[^\\\",}\\s]+"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression url( + QStringLiteral("\\b(?:https?|wss?)://[^\\s\\\"'<>]+|\\bmagnet:\\?[^\\s\\\"'<>]+"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression ipv4( + QStringLiteral("(?")); + message.replace(url, QStringLiteral("")); + message.replace(ipv4, QStringLiteral("")); + message.replace(bracketedIpv6, QStringLiteral("")); + message.replace(stableId, QStringLiteral("")); + return message; + } + +} // namespace + +void setDiagnosticUrlsUnredacted(bool enabled) +{ + g_diagnosticUrlsUnredacted.store(enabled, std::memory_order_relaxed); +} + +bool diagnosticUrlsUnredacted() +{ + return g_diagnosticUrlsUnredacted.load(std::memory_order_relaxed); +} + QString sanitizedDiagnosticUrl(QString url, qsizetype maxLength) { - static const QRegularExpression credentialQuery( - QStringLiteral("((?:[?&]|%26)(?:secret|code|password|pw|api[_-]?key|access[_-]?token|token|x-emby-token)" - "(?:=|%3d))[^&%\\s]+"), - QRegularExpression::CaseInsensitiveOption); - static const QRegularExpression credentialField( - QStringLiteral("((?:\\\"?(?:secret|code|password|pw|api[_-]?key|access[_-]?token|token|x-emby-token)\\\"?)" - "\\s*[:=]\\s*\\\"?)[^\\\",}\\s]+"), - QRegularExpression::CaseInsensitiveOption); - static const QRegularExpression authorizationHeader( - QStringLiteral("((?:Authorization|X-Emby-Token)\\s*[:=]\\s*)[^,\\r\\n}]+"), - QRegularExpression::CaseInsensitiveOption); - url.replace(credentialQuery, QStringLiteral("\\1")); - url.replace(credentialField, QStringLiteral("\\1")); - url.replace(authorizationHeader, QStringLiteral("\\1")); + if (diagnosticUrlsUnredacted() + && (url.startsWith(QLatin1String("https://")) || url.startsWith(QLatin1String("http://")))) + return url; + url = redactedCredentialText(std::move(url)); return maxLength >= 0 ? url.left(maxLength) : url; } -QString sanitizedLogMessage(QString message) +QString sanitizedLogMessage(QString message, bool allowDiagnosticUrls) { - message = sanitizedDiagnosticUrl(std::move(message)); - static const QRegularExpression personalField( - QStringLiteral("((?:\\\"?(?:title|episode[_-]?name|library[_-]?name|user[_-]?name|server[_-]?url|address|" - "item[_-]?id|profile[_-]?id|cache[_-]?key|device[_-]?id)\\\"?)\\s*[:=]\\s*\\\"?)[^\\\",}\\s]+"), - QRegularExpression::CaseInsensitiveOption); + if (!allowDiagnosticUrls || !diagnosticUrlsUnredacted()) + return sanitizedLogText(std::move(message)); + + // Preserve URLs verbatim, including encoded credentials/profile parameters; + // unrelated password/header fields and personal data remain redacted. static const QRegularExpression url( QStringLiteral("\\bhttps?://[^\\s\\\"'<>]+"), QRegularExpression::CaseInsensitiveOption); - static const QRegularExpression ipv4( - QStringLiteral("(?")); - message.replace(url, QStringLiteral("")); - message.replace(ipv4, QStringLiteral("")); - message.replace(bracketedIpv6, QStringLiteral("")); - message.replace(stableId, QStringLiteral("")); - return message; + auto matches = url.globalMatch(message); + if (!matches.hasNext()) + return sanitizedLogText(std::move(message)); + QString result; + result.reserve(message.size()); + qsizetype offset = 0; + while (matches.hasNext()) { + const auto match = matches.next(); + result += sanitizedLogText(message.mid(offset, match.capturedStart() - offset)); + result += match.capturedView(); + offset = match.capturedEnd(); + } + result += sanitizedLogText(message.mid(offset)); + return result; } QUrl serverUrlWithPath(const QString& serverUrl, const QStringList& segments) @@ -614,4 +675,4 @@ qint64 normalizedResumeTicks(qint64 resumeTicks, qint64 runtimeTicks) return isMeaningfulResumePosition(resumeTicks, runtimeTicks) ? resumeTicks : 0; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/JellyfinTypes.h b/src/media/MediaTypes.h similarity index 86% rename from src/common/JellyfinTypes.h rename to src/media/MediaTypes.h index 2e8fc918..e97b4d62 100644 --- a/src/common/JellyfinTypes.h +++ b/src/media/MediaTypes.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -11,21 +12,10 @@ #include #include +#include #include -namespace JellyfinNative { - -struct DiscoveredServer { - Q_GADGET - Q_PROPERTY(QString id MEMBER id) - Q_PROPERTY(QString name MEMBER name) - Q_PROPERTY(QString address MEMBER address) - -public: - QString id; - QString name; - QString address; -}; +namespace Spool { struct LibraryItem { Q_GADGET @@ -140,6 +130,8 @@ struct MediaStreamInfo { QString colorTransfer; QString colorSpace; QString aspectRatio; + // An external subtitle file, fetched from the stream's own origin. + QString deliveryUrl; int width = 0; int height = 0; double frameRate = 0.0; @@ -166,7 +158,7 @@ struct MediaSourceInfo { Q_PROPERTY(qint64 size MEMBER size) Q_PROPERTY(int bitRate MEMBER bitRate) Q_PROPERTY(qint64 runtimeTicks MEMBER runtimeTicks) - Q_PROPERTY(QList streams MEMBER streams) + Q_PROPERTY(QList streams MEMBER streams) public: QString id; @@ -205,6 +197,7 @@ struct MovieItem { Q_PROPERTY(QString itemType MEMBER itemType) Q_PROPERTY(QString imdbId MEMBER imdbId) Q_PROPERTY(QString tmdbId MEMBER tmdbId) + Q_PROPERTY(QVariantMap externalIds MEMBER externalIds) Q_PROPERTY(QString playlistItemId MEMBER playlistItemId) Q_PROPERTY(QString locationType MEMBER locationType) Q_PROPERTY(bool isVirtualItem MEMBER isVirtualItem) @@ -235,6 +228,8 @@ struct MovieItem { Q_PROPERTY(QString logoTag MEMBER logoTag) Q_PROPERTY(QString bannerTag MEMBER bannerTag) Q_PROPERTY(QString thumbTag MEMBER thumbTag) + Q_PROPERTY(QString backdropItemId MEMBER backdropItemId) + Q_PROPERTY(QString thumbItemId MEMBER thumbItemId) Q_PROPERTY(QStringList genres MEMBER genres) Q_PROPERTY(QStringList tags MEMBER tags) Q_PROPERTY(QStringList studios MEMBER studios) @@ -245,9 +240,9 @@ struct MovieItem { Q_PROPERTY(QString premiereDate MEMBER premiereDate) Q_PROPERTY(QString endDate MEMBER endDate) Q_PROPERTY(QString status MEMBER status) - Q_PROPERTY(QList people MEMBER people) - Q_PROPERTY(QList mediaSources MEMBER mediaSources) - Q_PROPERTY(QList externalUrls MEMBER externalUrls) + Q_PROPERTY(QList people MEMBER people) + Q_PROPERTY(QList mediaSources MEMBER mediaSources) + Q_PROPERTY(QList externalUrls MEMBER externalUrls) public: QString id; @@ -258,6 +253,7 @@ struct MovieItem { QString itemType; QString imdbId; QString tmdbId; + QVariantMap externalIds; QString playlistItemId; QString locationType; bool isVirtualItem = false; @@ -287,6 +283,9 @@ struct MovieItem { QString logoTag; QString bannerTag; QString thumbTag; + // Empty means the image belongs to this item; otherwise its opaque owner ID. + QString backdropItemId; + QString thumbItemId; QStringList genres; QStringList tags; QStringList studios; @@ -319,13 +318,6 @@ QString itemEpisodeCode(const MovieItem& item); bool isGenericEpisodeTitle(const MovieItem& item); int episodicPlaybackStartIndex(const std::vector& episodes); -struct AuthSession { - QString userId; - QString userName; - QString accessToken; - QString serverId; -}; - struct MediaSegment { Q_GADGET Q_PROPERTY(QString id MEMBER id) @@ -340,8 +332,7 @@ struct MediaSegment { qint64 endTicks = 0; }; -// Per-width trickplay manifest matching Jellyfin's -// item.Trickplay[mediaSourceId][width] structure. +// A per-playback sprite manifest or a whole-sequence BIF index. struct TrickplayInfo { int width = 0; int height = 0; @@ -350,6 +341,10 @@ struct TrickplayInfo { int thumbnailCount = 0; int intervalMs = 0; int bandwidth = 0; + QString urlTemplate; // sprite sheet URL, with {index} + QString format; // "bif" for a whole sequence, empty for sheets + QString url; + QByteArray headers; // native-only credential snapshot }; struct SubtitlePreferences { @@ -393,6 +388,8 @@ struct PagedMovieItems { int totalRecordCount = 0; int startIndex = 0; int limit = 0; + std::optional nextCursor; + bool exhausted = true; }; struct PlaybackQueueItem { @@ -410,6 +407,10 @@ struct PlaybackSession { QString playMethod = QStringLiteral("DirectPlay"); QString container; qint64 startTimeTicks = 0; + qint64 timelineOriginTicks = 0; // source position represented by media time-pos zero + qint64 sourceBitrate = 0; + int sourceWidth = 0; + int sourceHeight = 0; qint64 runtimeTicks = 0; QList mediaStreams; std::vector segments; @@ -423,24 +424,26 @@ struct PlaybackSession { QString exceptionMessage(const std::exception_ptr& exception); QString normalizedAudioOutputMode(const QString& mode); +void setDiagnosticUrlsUnredacted(bool enabled); +bool diagnosticUrlsUnredacted(); QString sanitizedDiagnosticUrl(QString url, qsizetype maxLength = -1); -QString sanitizedLogMessage(QString message); +// Provider diagnostics pass false: URL disclosure never applies to provider text. +QString sanitizedLogMessage(QString message, bool allowDiagnosticUrls = true); QUrl serverUrlWithPath(const QString& serverUrl, const QStringList& segments); bool isMeaningfulResumePosition(qint64 resumeTicks, qint64 runtimeTicks); qint64 normalizedResumeTicks(qint64 resumeTicks, qint64 runtimeTicks); QVariantMap formatMediaInfo(const MovieItem& item, const QString& preferredAudioLanguage); -} // namespace JellyfinNative - -Q_DECLARE_METATYPE(JellyfinNative::DiscoveredServer) -Q_DECLARE_METATYPE(JellyfinNative::LibraryItem) -Q_DECLARE_METATYPE(JellyfinNative::PersonItem) -Q_DECLARE_METATYPE(JellyfinNative::MediaStreamInfo) -Q_DECLARE_METATYPE(JellyfinNative::MediaSourceInfo) -Q_DECLARE_METATYPE(JellyfinNative::ExternalUrlInfo) -Q_DECLARE_METATYPE(JellyfinNative::MovieItem) -Q_DECLARE_METATYPE(JellyfinNative::MediaSegment) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(QList) +} // namespace Spool + +Q_DECLARE_METATYPE(Spool::LibraryItem) +Q_DECLARE_METATYPE(Spool::PersonItem) +Q_DECLARE_METATYPE(Spool::MediaStreamInfo) +Q_DECLARE_METATYPE(Spool::MediaSourceInfo) +Q_DECLARE_METATYPE(Spool::ExternalUrlInfo) +Q_DECLARE_METATYPE(Spool::MovieItem) +Q_DECLARE_METATYPE(Spool::MediaSegment) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(QList) diff --git a/src/models/DiscoveredServerModel.cpp b/src/models/DiscoveredServerModel.cpp deleted file mode 100644 index 57735f4f..00000000 --- a/src/models/DiscoveredServerModel.cpp +++ /dev/null @@ -1,112 +0,0 @@ -#include "DiscoveredServerModel.h" - -namespace JellyfinNative { - -DiscoveredServerModel::DiscoveredServerModel(QObject *parent) - : QAbstractListModel(parent) -{ -} - -int DiscoveredServerModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - return static_cast(m_servers.size()); -} - -QVariant DiscoveredServerModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) - return {}; - - const auto& server = m_servers[static_cast(index.row())]; - switch (role) { - case IdRole: - return server.id; - case NameRole: - return server.name; - case AddressRole: - return server.address; - case OnlineRole: - return m_onlineServers.contains(server.id) || m_onlineServers.contains(server.address); - default: - return {}; - } -} - -QHash DiscoveredServerModel::roleNames() const -{ - return { - { IdRole, "serverId" }, - { NameRole, "name" }, - { AddressRole, "address" }, - { OnlineRole, "online" }, - }; -} - -void DiscoveredServerModel::setServers(const std::vector& servers, bool online) -{ - beginResetModel(); - m_servers.clear(); - m_onlineServers.clear(); - for (const DiscoveredServer& server : servers) { - // An entry with no address cannot be connected to, so it would only - // ever draw as a nameless row the user can select and get nothing from. - if (server.address.isEmpty()) - continue; - m_servers.push_back(server); - if (online) { - m_onlineServers.insert(server.id); - m_onlineServers.insert(server.address); - } - } - endResetModel(); -} - -void DiscoveredServerModel::upsertServer(const DiscoveredServer& server, bool online) -{ - if (server.address.isEmpty()) - return; - if (online) { - m_onlineServers.insert(server.id); - m_onlineServers.insert(server.address); - } - for (int i = 0; i < rowCount(); ++i) { - auto& existing = m_servers[static_cast(i)]; - if (existing.id == server.id || existing.address == server.address) { - DiscoveredServer updated = server; - if ((updated.id.isEmpty() || updated.id == updated.address) && !existing.id.isEmpty()) - updated.id = existing.id; - existing = updated; - emit dataChanged(index(i, 0), index(i, 0)); - return; - } - } - - const int newRow = rowCount(); - beginInsertRows({}, newRow, newRow); - m_servers.push_back(server); - endInsertRows(); -} - -void DiscoveredServerModel::clear() -{ - beginResetModel(); - m_servers.clear(); - m_onlineServers.clear(); - endResetModel(); -} - -DiscoveredServer DiscoveredServerModel::serverAt(int index) const -{ - if (index < 0 || index >= rowCount()) - return {}; - return m_servers[static_cast(index)]; -} - -std::vector DiscoveredServerModel::servers() const -{ - return m_servers; -} - -} // namespace JellyfinNative diff --git a/src/models/DiscoveredServerModel.h b/src/models/DiscoveredServerModel.h deleted file mode 100644 index 878d4f4b..00000000 --- a/src/models/DiscoveredServerModel.h +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include "../common/JellyfinTypes.h" - -#include -#include - -#include - -namespace JellyfinNative { - -class DiscoveredServerModel final : public QAbstractListModel { - Q_OBJECT - -public: - enum Roles { - IdRole = Qt::UserRole + 1, - NameRole, - AddressRole, - OnlineRole, - }; - - explicit DiscoveredServerModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role) const override; - QHash roleNames() const override; - - void setServers(const std::vector& servers, bool online = true); - void upsertServer(const DiscoveredServer& server, bool online = true); - void clear(); - DiscoveredServer serverAt(int index) const; - std::vector servers() const; - -private: - std::vector m_servers; - QSet m_onlineServers; -}; - -} // namespace JellyfinNative diff --git a/src/models/LibraryListModel.cpp b/src/models/LibraryListModel.cpp index e5329ca3..784a232e 100644 --- a/src/models/LibraryListModel.cpp +++ b/src/models/LibraryListModel.cpp @@ -1,17 +1,29 @@ #include "LibraryListModel.h" -namespace JellyfinNative { +#include +#include +#include + +#include + +namespace { +constexpr auto libraryOrderKey = "home/libraryOrder"; +constexpr auto hiddenLibrariesKey = "home/hiddenLibraries"; +} + +namespace Spool { LibraryListModel::LibraryListModel(QObject *parent) : QAbstractListModel(parent) { + m_hiddenIds = QSettings().value(QLatin1String(hiddenLibrariesKey)).toStringList(); } int LibraryListModel::rowCount(const QModelIndex& parent) const { if (parent.isValid()) return 0; - return static_cast(m_libraries.size()); + return static_cast(m_visibleIndices.size()); } int LibraryListModel::count() const @@ -24,7 +36,7 @@ QVariant LibraryListModel::data(const QModelIndex& index, int role) const if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) return {}; - const auto& library = m_libraries[static_cast(index.row())]; + const auto& library = m_libraries[m_visibleIndices[static_cast(index.row())]]; switch (role) { case IdRole: return library.id; @@ -57,7 +69,7 @@ QVariantMap LibraryListModel::get(int index) const if (index < 0 || index >= rowCount()) return {}; - const auto& library = m_libraries[static_cast(index)]; + const auto& library = m_libraries[m_visibleIndices[static_cast(index)]]; return { { QStringLiteral("libraryId"), library.id }, { QStringLiteral("name"), library.name }, @@ -72,9 +84,124 @@ void LibraryListModel::setLibraries(const std::vector& libraries) const int oldCount = rowCount(); beginResetModel(); m_libraries = libraries; + const QStringList order = QSettings().value(QLatin1String(libraryOrderKey)).toStringList(); + QHash positions; + positions.reserve(order.size()); + for (qsizetype i = 0; i < order.size(); ++i) + positions.insert(order[i], i); + std::stable_sort(m_libraries.begin(), m_libraries.end(), [&positions, &order](const auto& a, const auto& b) { + return positions.value(a.id, order.size()) < positions.value(b.id, order.size()); + }); + rebuildVisibleIndices(); endResetModel(); if (oldCount != rowCount()) emit countChanged(); + emit hiddenLibrariesChanged(); +} + +bool LibraryListModel::moveLibrary(int from, int to) +{ + if (from < 0 || from >= rowCount() || to < 0 || to >= rowCount() || from == to) + return false; + beginMoveRows({}, from, from, {}, to > from ? to + 1 : to); + // Reorder only visible slots; hidden libraries retain their positions. + if (from < to) { + for (int row = from; row < to; ++row) + std::swap(m_libraries[m_visibleIndices[static_cast(row)]], + m_libraries[m_visibleIndices[static_cast(row + 1)]]); + } else { + for (int row = from; row > to; --row) + std::swap(m_libraries[m_visibleIndices[static_cast(row)]], + m_libraries[m_visibleIndices[static_cast(row - 1)]]); + } + endMoveRows(); + saveOrder(); + return true; +} + +void LibraryListModel::saveOrder() const +{ + // Retain slots for temporarily disconnected accounts, including hidden + // libraries, so reconnecting cannot lose their order. + QSettings settings; + QStringList order = settings.value(QLatin1String(libraryOrderKey)).toStringList(); + QSet connected; + for (const auto& library : m_libraries) + connected.insert(library.id); + size_t next = 0; + for (QString& id : order) { + if (connected.contains(id)) + id = m_libraries[next++].id; + } + while (next < m_libraries.size()) + order.append(m_libraries[next++].id); + settings.setValue(QLatin1String(libraryOrderKey), order); +} + +void LibraryListModel::rebuildVisibleIndices() +{ + m_visibleIndices.clear(); + m_visibleIndices.reserve(m_libraries.size()); + for (size_t index = 0; index < m_libraries.size(); ++index) { + if (!isHidden(m_libraries[index].id)) + m_visibleIndices.push_back(index); + } +} + +bool LibraryListModel::isHidden(const QString& libraryId) const +{ + return m_hiddenIds.contains(libraryId); +} + +QVariantList LibraryListModel::hiddenLibraries() const +{ + QVariantList result; + for (const auto& library : m_libraries) { + if (isHidden(library.id)) + result.append( + QVariantMap { { QStringLiteral("libraryId"), library.id }, { QStringLiteral("name"), library.name } }); + } + return result; +} + +bool LibraryListModel::hideLibrary(const QString& libraryId) +{ + for (int row = 0; row < count(); ++row) { + if (m_libraries[m_visibleIndices[static_cast(row)]].id != libraryId) + continue; + // Snapshot the full order before hiding, even if it was never moved. + saveOrder(); + beginRemoveRows({}, row, row); + m_hiddenIds.append(libraryId); + m_visibleIndices.erase(m_visibleIndices.begin() + row); + endRemoveRows(); + QSettings().setValue(QLatin1String(hiddenLibrariesKey), m_hiddenIds); + emit countChanged(); + emit hiddenLibrariesChanged(); + return true; + } + return false; +} + +bool LibraryListModel::showLibrary(const QString& libraryId) +{ + if (!isHidden(libraryId)) + return false; + const auto found = std::find_if( + m_libraries.begin(), m_libraries.end(), [&libraryId](const auto& library) { return library.id == libraryId; }); + if (found == m_libraries.end()) + return false; + const size_t sourceIndex = static_cast(found - m_libraries.begin()); + const auto position = std::lower_bound(m_visibleIndices.begin(), m_visibleIndices.end(), sourceIndex); + const int row = static_cast(position - m_visibleIndices.begin()); + beginInsertRows({}, row, row); + m_hiddenIds.removeAll(libraryId); + m_visibleIndices.insert(position, sourceIndex); + endInsertRows(); + QSettings().setValue(QLatin1String(hiddenLibrariesKey), m_hiddenIds); + emit countChanged(); + emit hiddenLibrariesChanged(); + return true; } void LibraryListModel::clear() @@ -82,16 +209,25 @@ void LibraryListModel::clear() const int oldCount = rowCount(); beginResetModel(); m_libraries.clear(); + m_visibleIndices.clear(); endResetModel(); if (oldCount != 0) emit countChanged(); + emit hiddenLibrariesChanged(); } LibraryItem LibraryListModel::libraryAt(int index) const { if (index < 0 || index >= rowCount()) return {}; - return m_libraries[static_cast(index)]; + return m_libraries[m_visibleIndices[static_cast(index)]]; +} + +LibraryItem LibraryListModel::libraryById(const QString& libraryId) const +{ + const auto found = std::find_if( + m_libraries.begin(), m_libraries.end(), [&libraryId](const auto& library) { return library.id == libraryId; }); + return found == m_libraries.end() ? LibraryItem {} : *found; } const std::vector& LibraryListModel::libraries() const @@ -99,4 +235,4 @@ const std::vector& LibraryListModel::libraries() const return m_libraries; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/models/LibraryListModel.h b/src/models/LibraryListModel.h index db773461..4da1c6f9 100644 --- a/src/models/LibraryListModel.h +++ b/src/models/LibraryListModel.h @@ -1,16 +1,19 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include +#include +#include #include -namespace JellyfinNative { +namespace Spool { class LibraryListModel final : public QAbstractListModel { Q_OBJECT Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList hiddenLibraries READ hiddenLibraries NOTIFY hiddenLibrariesChanged) public: enum Roles { @@ -29,17 +32,29 @@ class LibraryListModel final : public QAbstractListModel { QHash roleNames() const override; Q_INVOKABLE QVariantMap get(int index) const; + Q_INVOKABLE bool moveLibrary(int from, int to); + Q_INVOKABLE bool hideLibrary(const QString& libraryId); + Q_INVOKABLE bool showLibrary(const QString& libraryId); + Q_INVOKABLE bool isHidden(const QString& libraryId) const; + QVariantList hiddenLibraries() const; void setLibraries(const std::vector& libraries); void clear(); LibraryItem libraryAt(int index) const; + LibraryItem libraryById(const QString& libraryId) const; + // Full catalog, including hidden libraries: refreshes must not discard data. const std::vector& libraries() const; signals: void countChanged(); + void hiddenLibrariesChanged(); private: std::vector m_libraries; + std::vector m_visibleIndices; + QStringList m_hiddenIds; + void rebuildVisibleIndices(); + void saveOrder() const; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/models/MovieGridModel.cpp b/src/models/MovieGridModel.cpp index 19f34fd4..60c3906d 100644 --- a/src/models/MovieGridModel.cpp +++ b/src/models/MovieGridModel.cpp @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -242,4 +242,4 @@ bool MovieGridModel::removeUnresumable() return removed; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/models/MovieGridModel.h b/src/models/MovieGridModel.h index 02f87dd9..a8a648d7 100644 --- a/src/models/MovieGridModel.h +++ b/src/models/MovieGridModel.h @@ -1,12 +1,12 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include -namespace JellyfinNative { +namespace Spool { class MovieGridModel final : public QAbstractListModel { Q_OBJECT @@ -46,4 +46,4 @@ class MovieGridModel final : public QAbstractListModel { std::vector m_movies; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/AndroidDownloadStorage.cpp b/src/platform/AndroidDownloadStorage.cpp new file mode 100644 index 00000000..acb2995f --- /dev/null +++ b/src/platform/AndroidDownloadStorage.cpp @@ -0,0 +1,235 @@ +#include "AndroidDownloadStorage.h" + +#ifdef Q_OS_ANDROID +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace Spool { + +#ifdef Q_OS_ANDROID +namespace { + constexpr jint folderRequestCode = 0x5350; + constexpr jint readWriteGrant = 0x00000003; + constexpr jint persistableGrant = 0x00000040; + constexpr jint prefixGrant = 0x00000080; + constexpr auto documentsClass = "android/provider/DocumentsContract"; + + QJniObject androidUri(const QUrl& url) + { + if (!url.isValid() || url.scheme() != QLatin1String("content")) + return {}; + const QJniObject text = QJniObject::fromString(url.toString(QUrl::FullyEncoded)); + return QJniObject::callStaticObjectMethod( + "android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", text.object()); + } + + QUrl documentUrl(const QJniObject& uri) + { + return uri.isValid() ? QUrl(uri.toString()) : QUrl {}; + } + + QJniObject contentResolver() + { + const QJniObject context = QNativeInterface::QAndroidApplication::context(); + return context.isValid() ? context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;") + : QJniObject {}; + } + + // QJniObject's void calls clear Java exceptions. Use JNI here so a refused + // persisted grant or failed launch is reported instead of treated as success. + bool persistGrant(const QJniObject& resolver, const QJniObject& uri, jint flags) + { + QJniEnvironment env; + const jmethodID method + = env->GetMethodID(resolver.objectClass(), "takePersistableUriPermission", "(Landroid/net/Uri;I)V"); + if (env.checkAndClearExceptions() || !method) + return false; + env->CallVoidMethod(resolver.object(), method, uri.object(), flags); + return !env.checkAndClearExceptions(); + } +} + +class AndroidDownloadStorage::ResultReceiver final : public QtAndroidPrivate::ActivityResultListener { +public: + explicit ResultReceiver(AndroidDownloadStorage *owner) + : m_owner(owner) + { + QtAndroidPrivate::registerActivityResultListener(this); + } + + ~ResultReceiver() override + { + QtAndroidPrivate::unregisterActivityResultListener(this); + } + + bool handleActivityResult(jint requestCode, jint resultCode, jobject data) override + { + if (requestCode != folderRequestCode) + return false; + const QJniObject intent(data); + QMetaObject::invokeMethod( + m_owner, + [owner = m_owner, resultCode, intent]() { + owner->m_pickerOpen = false; + if (resultCode == 0) // Activity.RESULT_CANCELED + return; + if (resultCode != -1 || !intent.isValid()) { // Activity.RESULT_OK + emit owner->problem(QStringLiteral("The download folder could not be selected.")); + return; + } + const QJniObject uri = intent.callObjectMethod("getData", "()Landroid/net/Uri;"); + const QUrl tree = documentUrl(uri); + const jint grants = intent.callMethod("getFlags", "()I") & readWriteGrant; + const QJniObject resolver = contentResolver(); + if (!AndroidDownloadStorage::isTree(tree) || grants != readWriteGrant || !resolver.isValid() + || !persistGrant(resolver, uri, grants)) { + emit owner->problem( + QStringLiteral("The selected folder did not provide persistent read and write access.")); + return; + } + emit owner->folderSelected(tree); + }, + Qt::QueuedConnection); + return true; + } + +private: + AndroidDownloadStorage *m_owner; +}; +#endif + +AndroidDownloadStorage::AndroidDownloadStorage(QObject *parent) + : QObject(parent) +{ +#ifdef Q_OS_ANDROID + m_receiver = std::make_unique(this); +#endif +} + +AndroidDownloadStorage::~AndroidDownloadStorage() = default; + +void AndroidDownloadStorage::chooseFolder() +{ +#ifdef Q_OS_ANDROID + if (m_pickerOpen) + return; + m_pickerOpen = true; + QPointer owner(this); + QNativeInterface::QAndroidApplication::runOnAndroidMainThread([owner]() { + if (!owner) + return; + const QJniObject activity = QtAndroidPrivate::activity(); + const QJniObject action = QJniObject::fromString(QStringLiteral("android.intent.action.OPEN_DOCUMENT_TREE")); + const QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V", action.object()); + if (intent.isValid()) + intent.callObjectMethod( + "addFlags", "(I)Landroid/content/Intent;", readWriteGrant | persistableGrant | prefixGrant); + bool launched = false; + if (activity.isValid() && intent.isValid()) { + QJniEnvironment env; + const jmethodID method + = env->GetMethodID(activity.objectClass(), "startActivityForResult", "(Landroid/content/Intent;I)V"); + if (!env.checkAndClearExceptions() && method) { + env->CallVoidMethod(activity.object(), method, intent.object(), folderRequestCode); + launched = !env.checkAndClearExceptions(); + } + } + if (!launched && owner) { + QMetaObject::invokeMethod( + owner.data(), + [owner]() { + if (!owner) + return; + owner->m_pickerOpen = false; + emit owner->problem(QStringLiteral("The system download folder picker is unavailable.")); + }, + Qt::QueuedConnection); + } + }); +#else + emit problem(QStringLiteral("The Android download folder picker is not supported on this platform.")); +#endif +} + +bool AndroidDownloadStorage::isTree(const QUrl& url) +{ +#ifdef Q_OS_ANDROID + const QJniObject uri = androidUri(url); + return uri.isValid() + && QJniObject::callStaticMethod( + documentsClass, "isTreeUri", "(Landroid/net/Uri;)Z", uri.object()); +#else + Q_UNUSED(url) + return false; +#endif +} + +QUrl AndroidDownloadStorage::createFile(const QUrl& tree, const QString& name) +{ +#ifdef Q_OS_ANDROID + if (!isTree(tree) || name.isEmpty()) + return {}; + const QJniObject resolver = contentResolver(); + if (!resolver.isValid()) + return {}; + const QJniObject uri = androidUri(tree); + const QJniObject id = QJniObject::callStaticObjectMethod( + documentsClass, "getTreeDocumentId", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()); + if (!id.isValid()) + return {}; + const QJniObject parent = QJniObject::callStaticObjectMethod(documentsClass, "buildDocumentUriUsingTree", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", uri.object(), id.object()); + if (!parent.isValid()) + return {}; + const QJniObject mime + = QJniObject::fromString(QMimeDatabase().mimeTypeForFile(name, QMimeDatabase::MatchExtension).name()); + const QJniObject displayName = QJniObject::fromString(name); + return documentUrl(QJniObject::callStaticObjectMethod(documentsClass, "createDocument", + "(Landroid/content/ContentResolver;Landroid/net/Uri;Ljava/lang/String;Ljava/lang/String;)Landroid/net/Uri;", + resolver.object(), parent.object(), mime.object(), displayName.object())); +#else + Q_UNUSED(tree) + Q_UNUSED(name) + return {}; +#endif +} + +QUrl AndroidDownloadStorage::renameFile(const QUrl& document, const QString& name) +{ +#ifdef Q_OS_ANDROID + const QJniObject uri = androidUri(document); + const QJniObject resolver = contentResolver(); + if (!uri.isValid() || !resolver.isValid() || name.isEmpty()) + return {}; + const QJniObject displayName = QJniObject::fromString(name); + return documentUrl(QJniObject::callStaticObjectMethod(documentsClass, "renameDocument", + "(Landroid/content/ContentResolver;Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", + resolver.object(), uri.object(), displayName.object())); +#else + Q_UNUSED(document) + Q_UNUSED(name) + return {}; +#endif +} + +bool AndroidDownloadStorage::removeFile(const QUrl& document) +{ +#ifdef Q_OS_ANDROID + const QJniObject uri = androidUri(document); + const QJniObject resolver = contentResolver(); + return uri.isValid() && resolver.isValid() + && QJniObject::callStaticMethod(documentsClass, "deleteDocument", + "(Landroid/content/ContentResolver;Landroid/net/Uri;)Z", resolver.object(), uri.object()); +#else + Q_UNUSED(document) + return false; +#endif +} + +} // namespace Spool diff --git a/src/platform/AndroidDownloadStorage.h b/src/platform/AndroidDownloadStorage.h new file mode 100644 index 00000000..bf9a805d --- /dev/null +++ b/src/platform/AndroidDownloadStorage.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include + +namespace Spool { + +class AndroidDownloadStorage final : public QObject { + Q_OBJECT + +public: + explicit AndroidDownloadStorage(QObject *parent = nullptr); + ~AndroidDownloadStorage() override; + + Q_INVOKABLE void chooseFolder(); + + static bool isTree(const QUrl& url); + static QUrl createFile(const QUrl& tree, const QString& name); + // The returned URI can differ from the original. Close the file before renaming. + static QUrl renameFile(const QUrl& document, const QString& name); + static bool removeFile(const QUrl& document); + +signals: + void folderSelected(const QUrl& tree); + void problem(const QString& message); + +private: +#ifdef Q_OS_ANDROID + class ResultReceiver; + std::unique_ptr m_receiver; + bool m_pickerOpen = false; +#endif +}; + +} // namespace Spool diff --git a/src/platform/CredentialStore.h b/src/platform/CredentialStore.h index 610dd17c..641c4eb3 100644 --- a/src/platform/CredentialStore.h +++ b/src/platform/CredentialStore.h @@ -2,11 +2,11 @@ #include -namespace JellyfinNative::CredentialStore { +namespace Spool::CredentialStore { QString load(const QString& profileId); bool save(const QString& profileId, const QString& accessToken); void remove(const QString& profileId); void clear(); -} // namespace JellyfinNative::CredentialStore +} // namespace Spool::CredentialStore diff --git a/src/platform/MpvConfigPolicy.h b/src/platform/MpvConfigPolicy.h index 7893f8b0..41a621a3 100644 --- a/src/platform/MpvConfigPolicy.h +++ b/src/platform/MpvConfigPolicy.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct MpvConfigPolicy { enum class Mode { @@ -20,4 +20,4 @@ struct MpvConfigPolicy { MpvConfigPolicy validatedPlatformMpvConfigPolicy(const QString& mode, const QString& directory); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/NativeAppWindow.h b/src/platform/NativeAppWindow.h index 8f7a96ed..46375fee 100644 --- a/src/platform/NativeAppWindow.h +++ b/src/platform/NativeAppWindow.h @@ -3,11 +3,13 @@ #include #include #include +#include #include +#include #include -namespace JellyfinNative { +namespace Spool { class InputLatencyMonitor; class NativeAppWindow final : public QQuickView { @@ -103,6 +105,52 @@ class NativeAppWindow final : public QQuickView { void clearOverlay(); QQuickImageProvider *createOverlayImageProvider(); QImage copyOverlayImage() const; + // Fixed public visual-state allowlist, not an arbitrary QObject/QML inspector. + QVariantMap automationVisualState() const + { + QVariantMap result; + int remaining = 4096; + const auto visit = [&result, &remaining](auto&& self, QQuickItem *item, int depth) -> void { + if (!item || depth > 128 || --remaining < 0) + return; + const QString name = item->objectName(); + const bool preview = name == QStringLiteral("playerTrickplayPreview"); + const bool texture = name == QStringLiteral("playerTrickplayTexture"); + const bool seekBar = name == QStringLiteral("playerSeekBar"); + const bool libraryRow = name == QStringLiteral("libraryMediaRow"); + const bool libraryMenu = name == QStringLiteral("libraryContextMenuPanel"); + const bool showHidden = name == QStringLiteral("showHiddenLibrariesButton"); + if ((preview || texture || seekBar || libraryRow || libraryMenu || showHidden) && item->isVisible()) { + const QRectF geometry = item->mapRectToScene(item->boundingRect()); + QVariantMap state { { QStringLiteral("x"), geometry.x() }, { QStringLiteral("y"), geometry.y() }, + { QStringLiteral("width"), geometry.width() }, { QStringLiteral("height"), geometry.height() } }; + if (preview) { + state.insert(QStringLiteral("active"), item->property("active").toBool()); + state.insert(QStringLiteral("seconds"), item->property("previewSeconds").toDouble()); + state.insert(QStringLiteral("selectionPending"), item->property("selectionPending").toBool()); + } + if (texture) + state.insert(QStringLiteral("ready"), item->property("ready").toBool()); + if (libraryRow) { + state.insert(QStringLiteral("moveMode"), item->property("moveMode").toBool()); + state.insert(QStringLiteral("currentIndex"), item->property("currentIndex").toInt()); + state.insert(QStringLiteral("contentX"), item->property("contentX").toDouble()); + } + const QString key = preview ? QStringLiteral("preview") + : texture ? QStringLiteral("previewTexture") + : libraryRow ? QStringLiteral("libraryRow") + : libraryMenu ? QStringLiteral("libraryMenu") + : showHidden ? QStringLiteral("showHiddenLibraries") + : QStringLiteral("seekBar"); + result.insert(key, state); + } + const auto children = item->childItems(); + for (QQuickItem *child : children) + self(self, child, depth + 1); + }; + visit(visit, rootObject(), 0); + return result; + } signals: void closeRequested(); @@ -168,4 +216,4 @@ class NativeAppWindow final : public QQuickView { bool m_immersive = false; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformApplicationServices.h b/src/platform/PlatformApplicationServices.h index 63698d9c..f3ffb401 100644 --- a/src/platform/PlatformApplicationServices.h +++ b/src/platform/PlatformApplicationServices.h @@ -1,19 +1,56 @@ #pragma once +#include +#include + +#include #include class QGuiApplication; -namespace JellyfinNative { +namespace Spool { -class AppController; +class ArtworkService; class NativeAppWindow; +class PlayQueueController; +class PlayerController; class RouterController; +class SettingsController; + +// What the platform layer needs from the application above it, handed down +// at startup so nothing under src/platform names the composition root. The +// signals are forwarded from the app; the callback runs on the GUI thread. +class ApplicationHooks final : public QObject { + Q_OBJECT + +public: + using QObject::QObject; + + PlayerController *player = nullptr; + SettingsController *settings = nullptr; + PlayQueueController *playQueue = nullptr; + ArtworkService *artwork = nullptr; + // A platform memory warning at the given level ("low" or "critical"). + std::function memoryPressure; + // Local playback, for the system media controls. + std::function playbackTransition; + std::function stopPlayback; + std::function playNext; + std::function playPrevious; + +signals: + // The app is between one queue item and the next. + void playbackTransitionChanged(); + // The app has shed what it can; the window may drop its resources too. + void aggressiveMemoryPressure(); + void diagnosticsReportSaved(const QString& path); + void toastRequested(const QString& message); +}; class PlatformApplicationServices final { public: PlatformApplicationServices( - QGuiApplication& application, NativeAppWindow& window, AppController& controller, RouterController& router); + QGuiApplication& application, NativeAppWindow& window, ApplicationHooks& hooks, RouterController& router); ~PlatformApplicationServices(); PlatformApplicationServices(const PlatformApplicationServices&) = delete; @@ -26,4 +63,4 @@ class PlatformApplicationServices final { std::unique_ptr m_platform; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformCapabilities.h b/src/platform/PlatformCapabilities.h index 4a1dc73d..9bfaf8fb 100644 --- a/src/platform/PlatformCapabilities.h +++ b/src/platform/PlatformCapabilities.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct PlatformCapabilities { QString deviceName; @@ -23,4 +23,4 @@ struct PlatformCapabilities { const PlatformCapabilities& platformCapabilities(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformDisplayOutput.h b/src/platform/PlatformDisplayOutput.h index 98e8ceb7..860bf681 100644 --- a/src/platform/PlatformDisplayOutput.h +++ b/src/platform/PlatformDisplayOutput.h @@ -1,13 +1,18 @@ #pragma once class QQuickWindow; +class QScreen; -namespace JellyfinNative { +namespace Spool { struct DisplayOutputCapabilities; namespace PlatformDisplayOutput { + // GUI-thread only; unknown state is SDR. This is queried before creating + // a swapchain, independently of the GPU's supported surface formats. + bool desktopHdrEnabled(QScreen *screen); + // Render-thread only. Reads Qt's selected format; Wayland PASS_THROUGH // ownership is inferred from Qt's selection rule, not queried from the swapchain. DisplayOutputCapabilities probe(QQuickWindow *window); @@ -17,4 +22,4 @@ namespace PlatformDisplayOutput { } // namespace PlatformDisplayOutput -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformPaths.h b/src/platform/PlatformPaths.h index 6fb4704f..72e4e6b3 100644 --- a/src/platform/PlatformPaths.h +++ b/src/platform/PlatformPaths.h @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { QString resolveAppRoot(const char *argv0); QString bundledFontsPath(const QString& appRootPath); @@ -12,4 +12,4 @@ QString persistentDataRoot(); QStringList appLogDirectories(const QString& appRootPath); QString appLogFileName(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformPerformanceSampler.h b/src/platform/PlatformPerformanceSampler.h index 1aac9433..c06116d5 100644 --- a/src/platform/PlatformPerformanceSampler.h +++ b/src/platform/PlatformPerformanceSampler.h @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct PlatformPerformanceSample { // Whether this process's own CPU time could be read. On Android that is @@ -48,4 +48,4 @@ class PlatformPerformanceSampler final { std::unique_ptr m_platform; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformPlaybackRuntime.h b/src/platform/PlatformPlaybackRuntime.h index d6c552fb..dc9ab5a8 100644 --- a/src/platform/PlatformPlaybackRuntime.h +++ b/src/platform/PlatformPlaybackRuntime.h @@ -1,14 +1,21 @@ #pragma once +#include #include -class QObject; +#include -namespace JellyfinNative { +class QObject; -class JellyfinApiFacade; +namespace Spool { qint64 platformAudioDecodeCpuTimeNs(); -void configurePlatformPlaybackCapabilities(JellyfinApiFacade& api, QObject& callbackContext); -} // namespace JellyfinNative +// Hands the platform's decodable video codecs to whoever negotiates +// playback. The platform may call the applier more than once: webOS reports +// a software set immediately and the hardware set once its probe answers, +// marshalled onto callbackContext's thread. +using VideoCodecCapabilityApplier = std::function; +void configurePlatformPlaybackCapabilities(VideoCodecCapabilityApplier apply, QObject& callbackContext); + +} // namespace Spool diff --git a/src/platform/PlatformPlaybackSurface.h b/src/platform/PlatformPlaybackSurface.h index 7dafa8d9..1ca27b4c 100644 --- a/src/platform/PlatformPlaybackSurface.h +++ b/src/platform/PlatformPlaybackSurface.h @@ -9,7 +9,7 @@ class QObject; struct mpv_handle; -namespace JellyfinNative { +namespace Spool { class NativeAppWindow; struct PlaybackSession; @@ -41,4 +41,4 @@ void platformAudioTrackChanged(int index); // where the scene graph already letterboxes. void platformVideoSizeChanged(int width, int height); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformProcess.h b/src/platform/PlatformProcess.h index dc02811e..55bac91b 100644 --- a/src/platform/PlatformProcess.h +++ b/src/platform/PlatformProcess.h @@ -6,7 +6,7 @@ class QCoreApplication; -namespace JellyfinNative { +namespace Spool { struct ProcessStartupTiming { qint64 execToMainMs = -1; @@ -28,4 +28,4 @@ class TerminationSignalHandler final { std::unique_ptr m_platform; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformRemoteMediaSession.h b/src/platform/PlatformRemoteMediaSession.h index aa954615..79c8588a 100644 --- a/src/platform/PlatformRemoteMediaSession.h +++ b/src/platform/PlatformRemoteMediaSession.h @@ -5,7 +5,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct RemoteMediaSessionState { QString title; @@ -44,4 +44,4 @@ class PlatformRemoteMediaSession : public QObject { std::unique_ptr createPlatformRemoteMediaSession(QObject *parent = nullptr); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformSettingsPolicy.h b/src/platform/PlatformSettingsPolicy.h index e0205121..1c7c05d6 100644 --- a/src/platform/PlatformSettingsPolicy.h +++ b/src/platform/PlatformSettingsPolicy.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct SettingChoice; @@ -40,11 +40,10 @@ bool platformSupportsDirectVideoOutput(); // to it after a viewer has watched the first few seconds stutter. const char *platformDefaultVideoOutput(); bool platformUsesPerOutputAudioDelay(); -bool platformDefaultCastButtonEnabled(); bool platformDefaultRemoteControlTargetEnabled(); QString normalizedPlatformAudioRoute(const QString& output); QString platformAudioRouteDisplayName(const QString& output); QString platformAudioDelayStorageKey(const QString& output); int platformAutomaticAudioDelayMs(const QString& output, int displayLatencyMs, int outputLatencyMs); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformStartup.h b/src/platform/PlatformStartup.h index e40f6fad..ada61537 100644 --- a/src/platform/PlatformStartup.h +++ b/src/platform/PlatformStartup.h @@ -4,7 +4,7 @@ class QString; -namespace JellyfinNative { +namespace Spool { class NativeAppWindow; @@ -12,4 +12,4 @@ bool configurePlatformEnvironment(const QString& appRootPath); QSurfaceFormat platformSurfaceFormat(); void configurePlatformWindow(NativeAppWindow& window); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformSystemProbes.h b/src/platform/PlatformSystemProbes.h index 2c6b792a..bf7cfbb5 100644 --- a/src/platform/PlatformSystemProbes.h +++ b/src/platform/PlatformSystemProbes.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct PlatformCpuProbe { int physicalCores = 0; @@ -30,4 +30,4 @@ qint64 effectiveLinuxMemoryBytes( const QByteArray& meminfo, const QByteArray& cgroupV2Limit, const QByteArray& cgroupV1Limit); QString platformProcessMemoryDiagnostics(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/ScreenSaverInhibitor.h b/src/platform/ScreenSaverInhibitor.h index 5195ad2f..bb102274 100644 --- a/src/platform/ScreenSaverInhibitor.h +++ b/src/platform/ScreenSaverInhibitor.h @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { class ScreenSaverBackend { public: @@ -35,4 +35,4 @@ class ScreenSaverInhibitor final { bool screenSaverShouldBeInhibited(bool mediaSessionActive, bool paused, bool slideshowAdvancing = false); QByteArray webOsScreenSaverResponsePayload(const QByteArray& requestPayload); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/UpdateController.cpp b/src/platform/UpdateController.cpp index 6584d0a5..2e5d9342 100644 --- a/src/platform/UpdateController.cpp +++ b/src/platform/UpdateController.cpp @@ -2,7 +2,7 @@ #if defined(SPOOL_ANDROID) #include "android/AndroidUpdateInstaller.h" -#elif defined(JELLYFIN_NATIVE_WEBOS) +#elif defined(SPOOL_WEBOS) #include "webos/WebOSUpdateInstaller.h" #include #endif @@ -18,7 +18,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -37,7 +37,7 @@ namespace { #if defined(SPOOL_ANDROID) return SPOOL_ANDROID_VERSION_CODE; #else - const QVersionNumber version = QVersionNumber::fromString(QStringLiteral(JELLYFIN_VERSION)); + const QVersionNumber version = QVersionNumber::fromString(QStringLiteral(SPOOL_VERSION)); return version.majorVersion() * 100000000 + version.minorVersion() * 100000 + version.microVersion() * 100 + 99; #endif } @@ -60,9 +60,9 @@ namespace { void configureRequest(QNetworkRequest& request, int timeout) { #if defined(SPOOL_ANDROID) - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-Android/%1").arg(JELLYFIN_VERSION)); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-Android/%1").arg(SPOOL_VERSION)); #else - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-webOS/%1").arg(JELLYFIN_VERSION)); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-webOS/%1").arg(SPOOL_VERSION)); #endif request.setTransferTimeout(timeout); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::UserVerifiedRedirectPolicy); @@ -105,7 +105,7 @@ UpdateController::UpdateController(QNetworkAccessManager *network, QString cache m_waitingForPermission = false; setStage(AndroidUpdateInstaller::canRequestPackageInstalls() ? Stage::Ready : Stage::PermissionRequired); }); -#elif defined(JELLYFIN_NATIVE_WEBOS) +#elif defined(SPOOL_WEBOS) m_installer = new WebOSUpdateInstaller(this); connect(m_installer, &WebOSUpdateInstaller::statusChanged, this, [this](const QString& message) { qInfo() << "update: webOS installer:" << message; }); @@ -305,7 +305,7 @@ void UpdateController::offerRelease() QString UpdateController::packagePath() const { -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) return m_packageDirectory ? m_packageDirectory->filePath(QStringLiteral("spool-update.ipk")) : QString(); #else return QDir(m_cacheRoot).filePath(QStringLiteral("updates/spool-update.apk")); @@ -324,7 +324,7 @@ void UpdateController::decline() QFile::remove(packagePath()); m_packageReady = false; } -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) m_packageDirectory.reset(); #endif m_errorText.clear(); @@ -339,7 +339,7 @@ void UpdateController::download() resetDownload(); m_packageReady = false; m_errorText.clear(); -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) // Only this application's freshly created directory is made traversable. // /tmp avoids permissions on app-cache ancestors owned by another service. m_packageDirectory = std::make_unique(QStringLiteral("/tmp/spool-update-XXXXXX")); @@ -431,7 +431,7 @@ void UpdateController::finishDownload() m_output.reset(); m_hash.reset(); -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) constexpr auto packagePermissions = QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadGroup | QFileDevice::ReadOther; if (!QFile::setPermissions(packagePath(), packagePermissions)) { @@ -468,7 +468,7 @@ void UpdateController::install() } if (!AndroidUpdateInstaller::install(packagePath())) fail(QStringLiteral("Android could not open the package installer.")); -#elif defined(JELLYFIN_NATIVE_WEBOS) +#elif defined(SPOOL_WEBOS) if (m_installer->isRunning()) return; // The service may terminate this process to replace the application. Do not @@ -537,4 +537,4 @@ void UpdateController::resetDownload() m_hash.reset(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/UpdateController.h b/src/platform/UpdateController.h index 58c4f83f..0328a054 100644 --- a/src/platform/UpdateController.h +++ b/src/platform/UpdateController.h @@ -16,9 +16,9 @@ class QNetworkReply; class QSaveFile; class QTemporaryDir; -namespace JellyfinNative { +namespace Spool { -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) class WebOSUpdateInstaller; #endif @@ -113,10 +113,10 @@ class UpdateController final : public QObject { qint64 m_lastSpeedBytes = 0; qint64 m_lastSpeedMs = 0; bool m_packageReady = false; -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) WebOSUpdateInstaller *m_installer = nullptr; std::unique_ptr m_packageDirectory; #endif }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/android/AndroidApplicationServices.cpp b/src/platform/android/AndroidApplicationServices.cpp index c812a296..770bb457 100644 --- a/src/platform/android/AndroidApplicationServices.cpp +++ b/src/platform/android/AndroidApplicationServices.cpp @@ -2,30 +2,29 @@ #include "AndroidLocalMediaSession.h" -#include "app/AppController.h" #include "player/PlayerController.h" #include #include -namespace JellyfinNative { +namespace Spool { struct PlatformApplicationServices::PlatformData { - explicit PlatformData(AppController& controller) - : controller(&controller) - , player(controller.player()) - , mediaSession(controller) + explicit PlatformData(ApplicationHooks& applicationHooks) + : hooks(&applicationHooks) + , player(applicationHooks.player) + , mediaSession(applicationHooks) { } - AppController *controller = nullptr; + ApplicationHooks *hooks = nullptr; PlayerController *player = nullptr; AndroidLocalMediaSession mediaSession; }; PlatformApplicationServices::PlatformApplicationServices( - QGuiApplication&, NativeAppWindow&, AppController& controller, RouterController&) - : m_platform(std::make_unique(controller)) + QGuiApplication&, NativeAppWindow&, ApplicationHooks& hooks, RouterController&) + : m_platform(std::make_unique(hooks)) { } @@ -51,15 +50,15 @@ void PlatformApplicationServices::start() }); QObject::connect( - platform->controller, &AppController::diagnosticsReportSaved, qGuiApp, [platform](const QString& reportPath) { + platform->hooks, &ApplicationHooks::diagnosticsReportSaved, qGuiApp, [platform](const QString& reportPath) { const QJniObject context = QNativeInterface::QAndroidApplication::context(); const QJniObject path = QJniObject::fromString(reportPath); const jboolean opened = QJniObject::callStaticMethod("com/sachk/spool/AndroidUpdateBridge", "shareDiagnostics", "(Landroid/content/Context;Ljava/lang/String;)Z", context.object(), path.object()); if (!opened) - emit platform->controller->toastMessage(QStringLiteral("Could not open Android’s share menu.")); + emit platform->hooks->toastRequested(QStringLiteral("Could not open Android’s share menu.")); }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/android/AndroidLocalMediaSession.cpp b/src/platform/android/AndroidLocalMediaSession.cpp index f85b1e25..74f8f660 100644 --- a/src/platform/android/AndroidLocalMediaSession.cpp +++ b/src/platform/android/AndroidLocalMediaSession.cpp @@ -1,7 +1,7 @@ #include "AndroidLocalMediaSession.h" -#include "app/AppController.h" #include "app/ArtworkService.h" +#include "platform/PlatformApplicationServices.h" #include "player/PlayQueueController.h" #include "player/PlayerController.h" @@ -13,20 +13,20 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { // Only accessed on the Qt application thread, including JNI dispatch below. AndroidLocalMediaSession *localMediaSession = nullptr; constexpr auto serviceClass = "com/sachk/spool/LocalMediaPlaybackService"; } -AndroidLocalMediaSession::AndroidLocalMediaSession(AppController& controller) - : m_controller(controller) - , m_player(*controller.player()) - , m_queue(*controller.playQueue()) +AndroidLocalMediaSession::AndroidLocalMediaSession(ApplicationHooks& hooks) + : m_hooks(hooks) + , m_player(*hooks.player) + , m_queue(*hooks.playQueue) { localMediaSession = this; - // End-of-file first clears PlayerController, then AppController negotiates the + // End-of-file first clears PlayerController, then the app negotiates the // successor. Observe the settled transition, not that intermediate empty player. connect(&m_player, &PlayerController::sessionActiveChanged, this, &AndroidLocalMediaSession::update, Qt::QueuedConnection); @@ -38,7 +38,7 @@ AndroidLocalMediaSession::AndroidLocalMediaSession(AppController& controller) Qt::QueuedConnection); connect( &m_queue, &PlayQueueController::queueChanged, this, &AndroidLocalMediaSession::update, Qt::QueuedConnection); - connect(&controller, &AppController::playbackTransitionChanged, this, &AndroidLocalMediaSession::update, + connect(&hooks, &ApplicationHooks::playbackTransitionChanged, this, &AndroidLocalMediaSession::update, Qt::QueuedConnection); connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &AndroidLocalMediaSession::clear); update(); @@ -54,8 +54,7 @@ void AndroidLocalMediaSession::update() { const bool active = m_player.sessionActive(); const MovieItem item = m_queue.currentItem(); - const bool continuing - = m_active && m_controller.property("playbackTransition").toBool() && item.itemType == QStringLiteral("Audio"); + const bool continuing = m_active && m_hooks.playbackTransition() && item.itemType == QStringLiteral("Audio"); if ((!active && !continuing) || (active && m_player.mediaKind() != QStringLiteral("audio"))) { clear(); return; @@ -81,7 +80,7 @@ void AndroidLocalMediaSession::update() static_cast(active ? !m_player.paused() : !m_transitionPaused.value_or(false)), static_cast(!active || m_player.buffering()), static_cast(m_player.effectivePlaybackSpeed()), static_cast(m_queue.canGoNext()), static_cast(m_queue.canGoPrevious())); - if (auto *artwork = m_controller.artwork()) + if (auto *artwork = m_hooks.artwork) updateArtwork(artwork->itemUrl(item, false, 512)); } @@ -100,8 +99,8 @@ void AndroidLocalMediaSession::updateArtwork(const QString& url) QJniObject::callStaticMethod(serviceClass, "setArtwork", "([B)V", static_cast(nullptr)); if (url.isEmpty()) return; - auto *response = m_controller.artwork()->requestImageResponse( - QString::fromLatin1(QUrl::toPercentEncoding(url)), QSize(512, 512)); + auto *response + = m_hooks.artwork->requestImageResponse(QString::fromLatin1(QUrl::toPercentEncoding(url)), QSize(512, 512)); m_artworkResponse = response; connect(response, &QQuickImageResponse::finished, this, [this, response]() { response->deleteLater(); @@ -166,14 +165,14 @@ void AndroidLocalMediaSession::control(int action, qint64 value) setPaused(m_player.sessionActive() ? !m_player.paused() : !m_transitionPaused.value_or(false)); break; case 3: - m_controller.stopPlayback(); + m_hooks.stopPlayback(); clear(); break; case 4: - m_controller.playQueueNext(); + m_hooks.playNext(); break; case 5: - m_controller.playQueuePrevious(); + m_hooks.playPrevious(); break; case 6: m_player.seek(static_cast(value) / 1000.0); @@ -196,10 +195,10 @@ void dispatchAndroidLocalMediaControl(int action, qint64 value) } } -} // namespace JellyfinNative +} // namespace Spool extern "C" JNIEXPORT void JNICALL Java_com_sachk_spool_LocalMediaPlaybackService_nativeControl( JNIEnv *, jclass, jint action, jlong value) { - JellyfinNative::dispatchAndroidLocalMediaControl(static_cast(action), static_cast(value)); + Spool::dispatchAndroidLocalMediaControl(static_cast(action), static_cast(value)); } diff --git a/src/platform/android/AndroidLocalMediaSession.h b/src/platform/android/AndroidLocalMediaSession.h index b4e9ae54..5dac4824 100644 --- a/src/platform/android/AndroidLocalMediaSession.h +++ b/src/platform/android/AndroidLocalMediaSession.h @@ -7,16 +7,16 @@ class QQuickImageResponse; #include -namespace JellyfinNative { +namespace Spool { -class AppController; +class ApplicationHooks; class PlayerController; class PlayQueueController; // Android owns the system controls; mpv remains the sole playback engine. class AndroidLocalMediaSession final : public QObject { public: - explicit AndroidLocalMediaSession(AppController& controller); + explicit AndroidLocalMediaSession(ApplicationHooks& hooks); ~AndroidLocalMediaSession() override; void control(int action, qint64 value); @@ -27,7 +27,7 @@ class AndroidLocalMediaSession final : public QObject { void setPaused(bool paused); void updateArtwork(const QString& url); - AppController& m_controller; + ApplicationHooks& m_hooks; PlayerController& m_player; PlayQueueController& m_queue; bool m_active = false; @@ -36,4 +36,4 @@ class AndroidLocalMediaSession final : public QObject { QPointer m_artworkResponse; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/android/AndroidNativeAppWindow.cpp b/src/platform/android/AndroidNativeAppWindow.cpp index 42ab619c..03776955 100644 --- a/src/platform/android/AndroidNativeAppWindow.cpp +++ b/src/platform/android/AndroidNativeAppWindow.cpp @@ -7,7 +7,7 @@ #include