diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 636342f5..aa7677f9 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -33,6 +33,11 @@ on: required: true default: false type: boolean + build_ios: + description: "Build unsigned iOS device IPA and smoke the simulator app" + required: true + default: true + type: boolean build_tvos: description: "Build unsigned Apple TV device IPA and simulator app" required: true @@ -362,6 +367,29 @@ jobs: nix develop .#native -c python tools/run-tests.py \ --build-dir build/linux-release/app --workers "$(nproc)" + # The GUI phase measures real cold/prepared controller setup on every + # run. Keep its history longer than the large transient test artifacts. + - name: Report player setup latency + if: ${{ always() }} + run: | + mkdir -p dist/player-setup-benchmark + if [[ -f build/linux-release/app/player-setup-benchmark.json ]]; then + cp build/linux-release/app/player-setup-benchmark.json dist/player-setup-benchmark/timings.json + fi + status=0 + python3 tools/report-player-setup.py dist/player-setup-benchmark/timings.json \ + --markdown dist/player-setup-benchmark/summary.md || status=$? + cat dist/player-setup-benchmark/summary.md >> "$GITHUB_STEP_SUMMARY" + exit "$status" + + - name: Retain player setup history + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: internal-player-setup-benchmark + path: dist/player-setup-benchmark + retention-days: 90 + - name: Retain internal native phase results after failures if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 @@ -757,6 +785,14 @@ jobs: ~/Library/Caches/ccache key: ${{ steps.macos-build-cache.outputs.cache-primary-key }} + ios: + name: iOS + needs: [lint, toolchain] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_ios }} + uses: ./.github/workflows/ios-review.yml + with: + release: ${{ inputs.release || false }} + tvos: name: tvOS (${{ matrix.sdk }} arm64) runs-on: macos-15 diff --git a/.github/workflows/ios-review.yml b/.github/workflows/ios-review.yml new file mode 100644 index 00000000..77ff63e2 --- /dev/null +++ b/.github/workflows/ios-review.yml @@ -0,0 +1,114 @@ +name: iOS device IPA and simulator smoke +on: + workflow_dispatch: + inputs: + release: + description: "Use release artifact retention; the device IPA remains unsigned" + type: boolean + default: false + workflow_call: + inputs: + release: + description: "Use release artifact retention; the device IPA remains unsigned" + type: boolean + default: false + push: + branches: [feature/ios] +permissions: + contents: read +jobs: + native: + name: iOS (${{ matrix.sdk }} ${{ matrix.arch }}) + runs-on: macos-15-intel + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - sdk: iphonesimulator + arch: x86_64 + - sdk: iphoneos + arch: arm64 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2 + with: + persist-credentials: false + submodules: recursive + - name: Validate immutable package version + shell: bash + run: | + source tools/lib/build-common.sh + version="$(read_project_version "$GITHUB_WORKSPACE")" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + [[ "$GITHUB_REF_NAME" == "v$version" ]] || { + echo "error: release tag must match VERSION ($version)" >&2 + exit 1 + } + fi + echo "Building source $GITHUB_SHA with bundle/package version $version" + - name: Install native build tools + run: brew install cmake ninja meson pkg-config bash python imagemagick nasm gpatch + - name: Install pinned Qt host and iOS kits + shell: bash + run: | + python3 -m venv "$RUNNER_TEMP/aqt" + "$RUNNER_TEMP/aqt/bin/pip" install aqtinstall==3.3.0 + qt_version="$(python3 -c 'import json; print(json.load(open("tools/manifests/toolchain.json"))["qt"]["version"])')" + "$RUNNER_TEMP/aqt/bin/aqt" install-qt mac desktop "$qt_version" clang_64 -O "$RUNNER_TEMP/Qt" -m qtshadertools qtwebsockets qtimageformats qttasktree + "$RUNNER_TEMP/aqt/bin/aqt" install-qt mac ios "$qt_version" ios -O "$RUNNER_TEMP/Qt" -m qtshadertools qtwebsockets qtimageformats qttasktree + echo "QT_HOST_PATH=$RUNNER_TEMP/Qt/$qt_version/macos" >> "$GITHUB_ENV" + echo "QT_TARGET_PREFIX=$RUNNER_TEMP/Qt/$qt_version/ios" >> "$GITHUB_ENV" + - name: Build native iOS application + shell: bash + env: + APPLE_SDK: ${{ matrix.sdk }} + APPLE_ARCH: ${{ matrix.arch }} + APPLE_CODE_SIGNING_ALLOWED: 'NO' + # The published IPA is unsigned, for sideloading, so it is the open + # build (all providers, provider downloads); the simulator smoke + # starts the same flavour. + SPOOL_APPLE_APP_STORE: 'OFF' + BUILD_JOBS: '4' + run: bash tools/build-ios.sh + - name: Smoke native startup on simulator + if: matrix.sdk == 'iphonesimulator' + shell: bash + run: | + device="$(xcrun simctl list devices available --json | python3 -c 'import json,sys; print(next(d["udid"] for ds in json.load(sys.stdin)["devices"].values() for d in ds if d["name"].startswith("iPhone")))')" + xcrun simctl boot "$device" + xcrun simctl bootstatus "$device" -b + bundle="build/apple/iphonesimulator-x86_64/app/Release-iphonesimulator/Spool.app" + xcrun simctl install "$device" "$bundle" + xcrun simctl launch --console "$device" com.sachk.spool --launch-test + - name: Package audited unsigned device IPA + if: matrix.sdk == 'iphoneos' + shell: bash + run: bash tools/package-ios.sh build/apple/iphoneos-arm64/app/Release-iphoneos/Spool.app dist/ios + - name: Upload only the unsigned device release package + if: matrix.sdk == 'iphoneos' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: spool-ios-device-arm64 + path: dist/ios/Spool-*-iOS-arm64.ipa + if-no-files-found: error + retention-days: ${{ inputs.release && 1 || 7 }} + - name: Archive simulator bundle preserving executable permissions + if: matrix.sdk == 'iphonesimulator' + shell: bash + run: | + tar -czf "$RUNNER_TEMP/ios-simulator-x86_64.tar.gz" \ + -C build/apple/iphonesimulator-x86_64/app/Release-iphonesimulator Spool.app + - name: Keep simulator build internal + if: matrix.sdk == 'iphonesimulator' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: internal-ios-simulator-x86_64 + path: ${{ runner.temp }}/ios-simulator-x86_64.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Document unsigned artifact boundary + if: matrix.sdk == 'iphoneos' + shell: bash + run: | + echo '### Unsigned iOS device IPA' >> "$GITHUB_STEP_SUMMARY" + echo 'The audited arm64 device bundle has matching VERSION metadata and a closed bundled Mach-O dependency graph. It is unsigned and unprovisioned: re-sign with your own Apple identity and provisioning profile before installation. Device playback, App Store approval and TestFlight distribution are not verified by this build.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0eb8bca2..926fbdbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,6 +138,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 'iPhone and iPad — sideload with SideStore or AltStore' '*-iOS-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' @@ -169,10 +170,15 @@ jobs: echo 'The macOS DMG is **not Developer ID signed or notarized**.' fi echo - echo 'Release CI requires the complete Linux traditional and real OpenGL/Vulkan GUI suites, Windows OpenGL tests, and the Android phone journey. Full macOS, tvOS simulator, and Android TV native suites remain available manually but do not gate this release; their platform builds and package audits are retained.' + echo 'Release CI requires the complete Linux traditional and real OpenGL/Vulkan GUI suites, ASan/UBSan package consumers, and all platform builds and package audits. Windows, Android phone/TV, macOS native, and tvOS simulator runtime suites remain available manually but do not gate this release. Successful packages do not certify physical-device playback.' 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)." + if compgen -G 'release-assets/*-iOS-arm64.ipa' >/dev/null; then + echo + echo 'The iPhone/iPad IPA is **unsigned**, for sideloading: SideStore or AltStore re-sign it with your own free Apple ID. Add the source `https://spool-player.github.io/spool/altstore.json` once and updates arrive there. It is not distributed through the App Store or TestFlight; simulator startup does not certify device playback.' + echo "See [installing on iPhone and iPad](https://github.com/$REPO/blob/$TAG/docs/install/ios.md)." + fi } >release-notes.md - name: Publish GitHub prerelease env: @@ -250,6 +256,21 @@ jobs: test -f release-assets/spool-universal.apk ! jq -e '[.releases[].assets[].url] | any(endswith("spool-universal.apk"))' \ update-pages/updates/android.json >/dev/null + - name: Publish the SideStore/AltStore source + env: + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + version="${TAG#v}" + ipa="release-assets/Spool-$version-iOS-arm64.ipa" + test -f "$ipa" + cp app/icons/png/spool/1024.png update-pages/altstore-icon.png + python3 tools/apple/altstore-source.py "$ipa" \ + --download-url "https://github.com/$REPO/releases/download/$TAG/$(basename "$ipa")" \ + --icon-url "https://spool-player.github.io/spool/altstore-icon.png" \ + --date "$(date -u +%Y-%m-%d)" --notes generated-notes.md >update-pages/altstore.json + jq -e '.apps[0].versions[0].version == "'"$version"'"' update-pages/altstore.json - name: Include exact release manifest in Pages run: cp release-assets/website-release.json update-pages/updates/latest.json - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 diff --git a/AGENTS.md b/AGENTS.md index 8df19123..33ae88ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,7 @@ compatibility shims for older provider/host builds. - 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. - For a requested deployment, use this exact build-and-install command from the repository root: - `TV_HOST=root@tv.local; ./build-ipk.sh && nix develop -c bash tools/webos/verify-device.sh --no-launch --host "$TV_HOST" ./build/com.sachk.spool_0.3.0_arm.ipk` + `TV_HOST=root@tv.local; ./build-ipk.sh && nix develop -c bash tools/webos/verify-device.sh --no-launch --host "$TV_HOST" "./build/com.sachk.spool_$(cat VERSION)_arm.ipk"` `build-ipk.sh` defaults to the complete fresh `all` pipeline. Never invoke the `app`, `stage`, or `package` phases separately. - The packaged IPK is the only supported way to update the TV app. Never copy binaries or libraries to the TV manually, mutate the installed application tree, or substitute hand-written CMake/staging/install steps. - If webOS diagnosis is requested, read current logs first and make one targeted change — no speculative deploy loops. diff --git a/CMakeLists.txt b/CMakeLists.txt index bab44ae9..1fe2c947 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ endif() project(SpoolWebOS VERSION "${SPOOL_VERSION}" LANGUAGES C CXX) set(SPOOL_APPLE_MOBILE OFF) -if(CMAKE_SYSTEM_NAME STREQUAL "tvOS") +if(IOS OR CMAKE_SYSTEM_NAME STREQUAL "tvOS") set(SPOOL_APPLE_MOBILE ON) endif() @@ -23,6 +23,9 @@ if(CMAKE_CROSSCOMPILING AND NOT ANDROID AND NOT SPOOL_APPLE_MOBILE) endif() option(SPOOL_WEBOS "Build the webOS Starfish/native-surface client" ${SPOOL_WEBOS_DEFAULT}) option(TOUCHSCREEN "Enable mobile touchscreen interactions (runtime-disabled on TVs)" ${ANDROID}) +if(IOS) + set(TOUCHSCREEN ON) +endif() 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) @@ -226,6 +229,8 @@ set(SPOOL_SHELL_SOURCES src/app/ArtworkImageProvider.cpp src/app/ArtworkImageProvider.h src/app/ArtworkPrefetcher.h + src/app/ArtworkConcurrency.cpp + src/app/ArtworkConcurrency.h src/app/ArtworkService.cpp src/app/ArtworkService.h src/app/TrickplayDecoder.cpp @@ -234,6 +239,8 @@ set(SPOOL_SHELL_SOURCES src/app/TrickplayService.h src/app/TrickplayPreviewItem.cpp src/app/TrickplayPreviewItem.h + src/app/BrowseIndex.cpp + src/app/BrowseIndex.h src/app/BrowseSessionController.cpp src/app/BrowseSessionController.h src/app/ContentModelController.cpp @@ -264,6 +271,7 @@ set(SPOOL_SHELL_SOURCES src/app/LibraryQuery.h src/app/MemoryBudget.cpp src/app/MemoryBudget.h + src/app/PlaybackStopPolicy.h src/app/RouterController.cpp src/app/RouterController.h src/app/SettingsController.cpp @@ -564,6 +572,7 @@ qt_add_qml_module(spool qml/browse/ListBrowseView.qml qml/browse/RowStackView.qml qml/pages/HomePage.qml + qml/pages/HomeProviderPicker.qml qml/pages/ItemDetailsPage.qml qml/pages/CollectionEditorPage.qml qml/pages/LocalFolders.qml @@ -618,6 +627,7 @@ qt_add_qml_module(spool qml/primitives/MetadataChip.qml qml/primitives/MenuListView.qml qml/primitives/ListScrollBar.qml + qml/primitives/AlphabetRail.qml qml/primitives/MenuRow.qml qml/primitives/SecondaryText.qml qml/primitives/NavGrid.qml @@ -633,6 +643,7 @@ qt_add_qml_module(spool qml/primitives/SettingRow.qml qml/primitives/SliderRow.qml qml/primitives/Surface.qml + qml/primitives/SwitchIndicator.qml qml/primitives/TechMetadataLine.qml qml/primitives/TechnicalDetailsBar.qml qml/primitives/TextFieldRow.qml @@ -858,6 +869,8 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) set(SPOOL_TESTS_app browse-session-controller tests/app/BrowseSessionControllerTest.cpp + browse-sparse-listing tests/app/BrowseSparseListingTest.cpp + artwork-concurrency tests/app/ArtworkConcurrencyTest.cpp settings-schema tests/app/SettingsSchemaTest.cpp settings-controller tests/app/SettingsControllerTest.cpp settings-sync-controller tests/app/SettingsSyncControllerTest.cpp @@ -1115,6 +1128,8 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/PackageAuditTest.py" "${CMAKE_CURRENT_SOURCE_DIR}/tools/package-audit.py" ) + add_test(NAME webos-tooling + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/WebosToolingTest.py") endif() add_test(NAME module-seam COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tools/check-module-seam.sh" @@ -1131,6 +1146,15 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/RenderBenchmarkReportTest.py" "${CMAKE_CURRENT_SOURCE_DIR}/tools/compare-render-benchmark.py" ) + add_test(NAME altstore-source + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/AltStoreSourceTest.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/apple/altstore-source.py" + ) + add_test(NAME player-setup-report + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/PlayerSetupReportTest.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/report-player-setup.py") add_test(NAME test-driver-ordering COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/TestDriverTest.py") @@ -1138,7 +1162,7 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) # desktop controller consumer therefore belongs to the serial GPU phase. set(SPOOL_E2E_SOURCES tests/TestRunner.cpp tests/QmlTestRunner.cpp tests/ExtensionIntegration.h tests/ArtworkIntegration.h tests/player/MpvVideoItemTest.cpp tests/player/MpvFullscreenTest.cpp - tests/player/PlayerControllerTest.cpp + tests/player/PlayerControllerTest.cpp tests/player/PlayerSetupBenchmark.cpp tests/e2e/AppJourneyTest.cpp tests/e2e/JellyfinFixture.cpp) if(APPLE AND NOT SPOOL_APPLE_MOBILE) set_source_files_properties(tests/player/MpvFullscreenTest.cpp PROPERTIES LANGUAGE OBJCXX) @@ -1149,7 +1173,7 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/native-spoolet-path.txt" CONTENT "$\n") target_include_directories(spool-e2e-tests PRIVATE tests src "${CMAKE_CURRENT_BINARY_DIR}/generated") - target_compile_definitions(spool-e2e-tests PRIVATE TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" + target_compile_definitions(spool-e2e-tests PRIVATE SPOOL_E2E_BINARY=1 TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" SPOOL_E2E_APP="$" SPOOL_E2E_SPOOLET="$") if(SPOOL_TEST_RENDER_BACKEND STREQUAL "opengl") target_compile_definitions(spool-e2e-tests PRIVATE SPOOL_TEST_OPENGL_ONLY=1) @@ -1169,6 +1193,7 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) VERBATIM) set(SPOOL_QML_TESTS route-policy tests/qml/tst_RoutePolicy.qml + row-stack-view tests/qml/tst_RowStackView.qml key-router tests/qml/tst_KeyRouter.qml player-overlay-input tests/qml/tst_PlayerOverlayInput.qml popup-menu-input tests/qml/tst_PopupMenuInput.qml @@ -1253,16 +1278,61 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) endforeach() file(APPEND "${qml_registry}" "}\n") target_sources(spool-e2e-tests PRIVATE "${qml_registry}") - add_test(NAME gui-e2e-native COMMAND spool-e2e-tests --run-all --workers 1 --timeout-ms 600000 --results "${CMAKE_CURRENT_BINARY_DIR}/e2e-results.json") + # On a Linux host the GUI selectors always get the private Weston/Xvfb + # session, so a bare `ctest` cannot open windows on the developer's + # desktop (or be disturbed by them using it). + if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ANDROID) + set(SPOOL_E2E_SESSION bash "${CMAKE_CURRENT_SOURCE_DIR}/tools/test-gpu-session.sh") + else() + set(SPOOL_E2E_SESSION "") + endif() + add_test(NAME gui-e2e-native COMMAND ${SPOOL_E2E_SESSION} $ --run-all --workers 1 --timeout-ms 600000 --results "${CMAKE_CURRENT_BINARY_DIR}/e2e-results.json") set_tests_properties(gui-e2e-native PROPERTIES LABELS e2e ENVIRONMENT "${SPOOL_TEST_ENV}" TIMEOUT 0) + add_test(NAME player-setup-performance + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/report-player-setup.py" + "${CMAKE_CURRENT_BINARY_DIR}/player-setup-benchmark.json") + set_tests_properties(player-setup-performance PROPERTIES LABELS e2e DEPENDS gui-e2e-native) + # Exercise the event-thread lifecycle with a controllable client API. + # Keep this separate from the core targets so the fake symbols cannot + # interpose on the real libmpv used by the other player tests. + find_package(Threads REQUIRED) + add_executable(mpv-lifecycle-tests + tests/player/MpvLifecycleTest.cpp + src/player/MpvLifecycle.cpp) + target_include_directories(mpv-lifecycle-tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${MPV_ACTIVE_SOURCE_DIR}" + "${MPV_ACTIVE_SOURCE_DIR}/include") + # Native builds normally obtain installed headers from the libmpv + # target. Import those paths without linking its real client symbols. + foreach(mpv_headers_target IN ITEMS PkgConfig::MPV MPV::MPV) + if(TARGET ${mpv_headers_target}) + target_include_directories(mpv-lifecycle-tests SYSTEM PRIVATE + "$") + endif() + endforeach() + target_link_libraries(mpv-lifecycle-tests PRIVATE Qt6::Core Threads::Threads) + set_target_properties(mpv-lifecycle-tests PROPERTIES + AUTOMOC OFF + AUTOUIC OFF + AUTORCC OFF) + add_test(NAME mpv-lifecycle COMMAND mpv-lifecycle-tests) + set_tests_properties(mpv-lifecycle PROPERTIES LABELS e2e ENVIRONMENT "${SPOOL_TEST_ENV}" TIMEOUT 0) foreach(test_target IN ITEMS spool-tests spool-e2e-tests) set_target_properties(${test_target} PROPERTIES INTERPROCEDURAL_OPTIMIZATION OFF INTERPROCEDURAL_OPTIMIZATION_RELEASE OFF INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL OFF) endforeach() set_tests_properties(provider-sdk-tool local-provider-overrides ffmpeg-capability-generator module-seam - network-shaper render-benchmark-report test-driver-ordering PROPERTIES LABELS traditional) + network-shaper render-benchmark-report altstore-source player-setup-report test-driver-ordering PROPERTIES LABELS traditional) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - set_tests_properties(package-audit PROPERTIES LABELS traditional) + set_tests_properties(package-audit webos-tooling PROPERTIES LABELS traditional) + # Exercise the actual curl scheduler/counters, not just its exposed + # option values. Meson tests are disabled in packaged native builds. + find_library(SPOOL_CURL_TEST_MPV NAMES mpv HINTS ${MPV_LIBRARY_DIRS} REQUIRED) + add_test(NAME curl-transport + COMMAND "${Python3_EXECUTABLE}" "${MPV_ACTIVE_SOURCE_DIR}/test/curl_transport.py" + --libmpv "${SPOOL_CURL_TEST_MPV}" -v) + set_tests_properties(curl-transport PROPERTIES LABELS traditional TIMEOUT 180) endif() endif() if(SPOOL_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") diff --git a/README.md b/README.md index 1ad8c67c..0efc9d26 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,32 @@ 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. +Search uses all enabled, authorized watching profiles regardless of the Home +provider filter, never the combined permissions of an adult and child profile +saved on the same server. + +### Settings and navigation + +Settings opens a category index with common controls and a direct **Zoom** +shortcut. Search matches setting names, descriptions and aliases; choosing a +result opens its category and surrounding controls without changing values. +Back restores the query, selection and scroll position. **Ctrl+F** searches +Settings when that page owns input; text composition and modal editors keep +their existing ownership. + +Mouse scrolling and keyboard navigation can coexist. If the selected card is +offscreen, the first direction selects the top-left usable visible card without +scrolling back. That complete held gesture is consumed until physical release; +the next press navigates normally. **Move** is explicit in the library menu: +moving keeps the library selected and the viewport stable until **Done**. + +Home's provider chooser switches between **All providers** and one provider +without changing enabled accounts, watching profiles or global search. The +choice is saved on this device. Installed-provider pages show version, +installation source, update controls and provider-owned settings. Jellyfin and +Emby account settings expose server-backed audio/subtitle preferences where +the current user is allowed to change them; failed writes retain the draft. +Watching-profile choosers show the provider's logo and installed version. ### Desktop file drops @@ -81,7 +105,8 @@ shows identity, versions, declared capabilities, network origins and changes; self-supplied publisher names and byte digests are not authentication. Multiple packages require individual consent. Mixed package/media drops ask which operation to perform before installing or playing anything. Drops wait -while another editor or approval dialog owns input. +while another editor or approval dialog owns input. **Ctrl+O** also respects +those dialogs instead of opening a second picker. ### Offline downloads @@ -734,6 +759,69 @@ 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. +## iOS + +iOS builds produce an unsigned ARM64 `.ipa` when that platform succeeds in CI. +Installation requires your own Apple signing identity and provisioning profile; +the download is not App Store- or TestFlight-distributed. Simulator startup is +not proof of device playback. + +Native builds require macOS, Xcode, GNU patch (`brew install gpatch`) and matching +host/iOS Qt kits at the version in `tools/manifests/toolchain.json`. +Set `QT_HOST_PATH` and `QT_TARGET_PREFIX`, then run `tools/build-ios.sh`; it +builds the patched host QML import scanner before configuring the app. +`APPLE_SDK=iphonesimulator` selects the simulator; the default is `iphoneos`. +The device architecture defaults to `arm64`, the official Qt simulator kit to +`x86_64`. Apple Silicon Macs require Rosetta for this simulator kit; the review +workflow uses an Intel macOS runner. A custom source-built Qt kit can select +`APPLE_ARCH=arm64` for the simulator. The deployment baseline is iOS 17, +matching [Qt 6.11's supported configuration](https://doc.qt.io/qt-6.11/ios.html). +The shared +`tools/apple/build-dependencies.sh` verifies existing dependency pins and builds +static FFmpeg, curl/OpenSSL, subtitle libraries, libplacebo, mpv and QCoro for +the selected SDK. Qt and application code use OpenGL ES; no desktop AppKit, +IOKit, dynamic provider loader or desktop command socket is included. + +App Store review builds force bundled-only provider code and include only pins +whose catalogue-derived `appleAppStore` flag is exactly `true`. Provider checkout +overrides and downloaded provider updates are not permitted. Account sign-in +still uses the bundled provider screens and system browser. + +Tokens use device-only Keychain items, retained for background audio after the +first unlock. Media data stays in the sandbox; caches/logs are disposable and +application data is excluded from backup. Video pauses when hidden, while +audio uses the system playback session and lock-screen/AirPods transport +commands. Interruptions and disconnected audio routes pause playback. +Video remains full-bleed; touch controls respect the device safe areas. + +Local-network access is prompted by iOS when connecting to a user-selected LAN +server. If access was denied, enable **Settings → Spool → Local Network**. +UDP discovery explicitly reports `discovery_unavailable`: this bundle does not +request Apple's restricted multicast entitlement. Manual addresses and +consented HTTP discovery remain available. Plain-HTTP connections to +user-owned servers require the included ATS exception and an App Review +explanation. No camera, microphone, photo-library or broad-storage permission +is requested. Diagnostics use the native share sheet. + +To export a signed archive, set `APPLE_ARCHIVE=1`, `APPLE_DEVELOPMENT_TEAM`, and +`APPLE_EXPORT_OPTIONS` (your Xcode ExportOptions.plist); unsigned simulator +builds are the default verification path. The privacy manifest declares local +preferences, file metadata, free disk space and monotonic timing APIs; App +Store submission still requires the developer's privacy/legal declarations, +signing, entitlement review and device playback/route/lifecycle testing. + +Package an unsigned device bundle with: + +```sh +bash tools/package-ios.sh \ + build/apple/iphoneos-arm64/app/Release-iphoneos/Spool.app dist/ios +``` + +The packager verifies the embedded version and actual iOS ARM64 Mach-O closure; +it does not sign the app. CI keeps the simulator archive internal and publishes +only `spool-ios-device-arm64`. The reusable iOS workflow also accepts manual +dispatch on `big-refactor`. + ## Android development The Android toolchain is pinned to SDK 36, Build Tools 36.0.0 and NDK @@ -875,6 +963,10 @@ has rejected a visibly rendered Resume control. All platform builds, package audits and signing checks remain required; successful artifacts do not imply those platforms' native tests passed. +Native incremental builds validate referenced immutable Nix store items rather +than treating CMake's generated object-path suffixes as missing dependencies. +Real garbage-collected dependencies still invalidate the cached build. + Build and run the same complete host suite as CI with `nix run .#tests`, or run already-built targets with: @@ -952,6 +1044,10 @@ and native foreground activation while the product still uses Wayland; a seatless headless compositor cannot exercise foreground-only behavior. Neither harness connects to the user's display. +Repeatable isolated native profiling uses the same private graphics session. +See [profiling workflows](docs/debug/memory-profiling.md) for benchmark captures, +machine-readable summaries and the limits of software-renderer baselines. + The isolated Linux CPU driver package includes a bounded Mesa Wayland WSI clock-domain correction. Automatic FIFO targets remain in the swapchain's clock internally, but the existing upstream conversion helper translates diff --git a/VERSION b/VERSION index ac39a106..2003b639 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.9.2 diff --git a/app/android-common/src/com/sachk/spool/AndroidUpdateBridge.java b/app/android-common/src/com/sachk/spool/AndroidUpdateBridge.java index 7b50b5e2..e3de9a0b 100644 --- a/app/android-common/src/com/sachk/spool/AndroidUpdateBridge.java +++ b/app/android-common/src/com/sachk/spool/AndroidUpdateBridge.java @@ -36,7 +36,7 @@ public static boolean openInstallSettings(Context context) return false; Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + context.getPackageName())); - return startResolvedActivity(context, intent); + return startActivity(context, intent); } public static boolean installApk(Context context, String path) @@ -52,7 +52,7 @@ public static boolean installApk(Context context, String path) intent.setDataAndType(uri, APK_MIME_TYPE); intent.setClipData(ClipData.newRawUri("Spool update", uri)); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - return startResolvedActivity(context, intent); + return startActivity(context, intent); } catch (RuntimeException exception) { Log.e(TAG, "Could not launch APK installer", exception); return false; @@ -82,9 +82,7 @@ public static boolean shareDiagnostics(Context context, String reportPath) intent.putExtra(Intent.EXTRA_SUBJECT, "Spool diagnostics"); intent.setClipData(ClipData.newRawUri("Spool diagnostics", uri)); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (intent.resolveActivity(context.getPackageManager()) == null) - return false; - return startResolvedActivity(context, Intent.createChooser(intent, "Share diagnostics")); + return startActivity(context, Intent.createChooser(intent, "Share diagnostics")); } catch (IOException | RuntimeException exception) { Log.e(TAG, "Could not share diagnostics", exception); return false; @@ -125,11 +123,11 @@ private static void addDirectoryToZip(File root, File directory, ZipOutputStream } } - private static boolean startResolvedActivity(Context context, Intent intent) + private static boolean startActivity(Context context, Intent intent) { try { - if (intent.resolveActivity(context.getPackageManager()) == null) - return false; + // Package visibility can hide a valid handler from resolveActivity. + // Attempt the launch and let Android report whether it is available. if (!(context instanceof Activity)) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); diff --git a/app/com.sachk.spool.metainfo.xml b/app/com.sachk.spool.metainfo.xml index 4478572d..80be6265 100644 --- a/app/com.sachk.spool.metainfo.xml +++ b/app/com.sachk.spool.metainfo.xml @@ -21,6 +21,12 @@ Spool + +

Browse very large libraries smoothly: only the rows on screen are loaded, an alphabet index jumps to any letter, and typing finds a title the way a file browser does. Artwork downloads adapt to each server's capacity. Adding an account is one step, with no provider versions or update prompts, Plex servers can be added from an existing sign-in, and a new sign-in opens straight into its library. The iPhone build installs through SideStore or AltStore. Fixes held navigation stalling while rows load, speed tests losing their measurements, and prepared playback ignoring a paused start.

+
+ +

Improve retained-page navigation, remote focus recovery, provider lifecycle safety, and playback controls. Add repeatable isolated performance profiling and extend real-application end-to-end journeys. Clean up webOS packaging and require explicit launch for device verification. Bundle updated providers using the source-only socket host contract.

+

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.

diff --git a/app/ios/Info.plist.in b/app/ios/Info.plist.in new file mode 100644 index 00000000..c2566534 --- /dev/null +++ b/app/ios/Info.plist.in @@ -0,0 +1,19 @@ + + + +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 +UILaunchScreen +UIBackgroundModesaudio +UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +UISupportedInterfaceOrientations~ipadUIInterfaceOrientationPortraitUIInterfaceOrientationPortraitUpsideDownUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +NSLocalNetworkUsageDescriptionSpool connects to media servers on your home network so you can browse and play your library. +NSAppTransportSecurityNSAllowsLocalNetworkingNSAllowsArbitraryLoads +ITSAppUsesNonExemptEncryption + diff --git a/app/ios/PrivacyInfo.xcprivacy b/app/ios/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..04c13f0b --- /dev/null +++ b/app/ios/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 68bf6997..b7fde36a 100755 --- a/build-ipk.sh +++ b/build-ipk.sh @@ -3,12 +3,33 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +usage() { + cat < 1 )); then + usage >&2 + exit 2 +fi +PHASE="${1:-all}" +case "$PHASE" in + -h|--help) usage; exit 0 ;; + all|app|stage|package) ;; + *) usage >&2; exit 2 ;; +esac + +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 + DEFAULT_SDK_ROOT="$WORKSPACE_ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot" +fi +export WEBOS_SDK_ROOT="${WEBOS_SDK_ROOT:-$DEFAULT_SDK_ROOT}" 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 - DEFAULT_SDK_ROOT="$WORKSPACE_ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot" - fi cd "$ROOT" exec nix develop "$ROOT" -c env \ SPOOL_BUILD_IPK_ENTERED_NIX=1 \ @@ -17,6 +38,10 @@ if [[ -z "${IN_NIX_SHELL:-}" && "${SPOOL_BUILD_IPK_ENTERED_NIX:-0}" != "1" ]]; t fi WEBOS_TOOLS_ROOT="${WEBOS_TOOLS_ROOT:-$ROOT/tools/webos-native}" +if [[ "$PHASE" == "all" ]]; then + exec "$WEBOS_TOOLS_ROOT/build-webos.sh" all +fi + # shellcheck source=tools/lib/build-common.sh source "$ROOT/tools/lib/build-common.sh" # shellcheck source=tools/lib/manifest-sources.sh @@ -24,10 +49,6 @@ source "$ROOT/tools/lib/manifest-sources.sh" # shellcheck source=tools/webos-native/nixos-sdk-compat.sh source "$ROOT/tools/webos-native/nixos-sdk-compat.sh" -if (( $# == 0 )); then - exec "$ROOT/tools/webos-native/build-webos.sh" all -fi -PHASE="$1" DO_BUILD=0 DO_STAGE=0 DO_PACKAGE=0 @@ -35,15 +56,6 @@ case "$PHASE" in app) DO_BUILD=1 ;; stage) DO_STAGE=1 ;; package) DO_PACKAGE=1 ;; - all) - DO_BUILD=1 - DO_STAGE=1 - DO_PACKAGE=1 - ;; - *) - echo "usage: $0 [app|stage|package|all]" >&2 - exit 2 - ;; esac SDK_ROOT="${WEBOS_SDK_ROOT:-$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot}" @@ -53,7 +65,11 @@ MPV_SRC="${MPV_SRC:-$ROOT/mpv}" MPV_BUILD="${MPV_BUILD:-$MPV_SRC/build/webos-libmpv}" WEBOS_CROSS_FILE="${WEBOS_CROSS_FILE:-$ROOT/build/webos-thirdparty-build/webos.cross.ini}" FFMPEG_CAPABILITY_MANIFEST="${FFMPEG_CAPABILITY_MANIFEST:-$ROOT/tools/manifests/ffmpeg-capabilities.json}" -QT6_HOST_PREFIX="${QT6_HOST_PREFIX:-$ROOT/build/qt6-611-host-install}" +QT_MANIFEST="${QT_MANIFEST:-$ROOT/tools/manifests/qt-webos-6.11.json}" +QT_VERSION="$(manifest_qt_field "$QT_MANIFEST" qtVersion)" +QT_BUILD_TAG="${QT_VERSION%.*}" +QT_BUILD_TAG="${QT_BUILD_TAG//./}" +QT6_HOST_PREFIX="${QT6_HOST_PREFIX:-$ROOT/build/qt6-$QT_BUILD_TAG-host-install}" APP_SOURCE_DIR="$ROOT/app" BUILD_DIR="$ROOT/build" CMAKE_BUILD_DIR="${WEBOS_APP_BUILD_DIR:-$ROOT/build/webos/app}" @@ -80,7 +96,7 @@ else fi -QT6_PREFIX="${QT6_PREFIX:-$ROOT/build/qt6-611-target-static-install}" +QT6_PREFIX="${QT6_PREFIX:-$ROOT/build/qt6-$QT_BUILD_TAG-target-static-install}" if (( DO_BUILD || DO_STAGE )); then WEBOS_SDK_ROOT="$SDK_ROOT" "$WEBOS_TOOLS_ROOT/restore-toolchain.sh" ensure_webos_sdk_host_tools "$SDK_ROOT" @@ -111,12 +127,6 @@ if (( DO_BUILD || DO_STAGE )); then exit 1 fi - #QT_GUI_CONFIG="$QT6_PREFIX/include/QtGui/qtgui-config.h" - #if ! grep -q '^#define QT_FEATURE_accessibility 1$' "$QT_GUI_CONFIG" 2>/dev/null; then - # echo "error: Qt at $QT6_PREFIX was built without accessibility support." >&2 - # echo " Rerun: bash $WEBOS_TOOLS_ROOT/build-qt6-611.sh" >&2 - # exit 1 - #fi if [[ ! -f "$QT6_PREFIX/lib/cmake/QCoro6/QCoro6Config.cmake" ]]; then echo "error: target QCoro is missing from $QT6_PREFIX." >&2 @@ -280,7 +290,7 @@ cp -f "$APP_SOURCE_DIR/icons/png/spool/130.png" "$APP_DIR/large-icon.png" # frame appinfo.json names, and the same block on its own for Qt to draw. SPLASH_DIR="${SPOOL_SPLASH_DIR:-$CMAKE_BUILD_DIR/splash/webos}" [[ -f "$SPLASH_DIR/splash.png" ]] || { - echo "error: launch screen missing at $SPLASH_DIR; run '$0 app' first" >&2 + echo "error: launch screen missing at $SPLASH_DIR; rerun ./build-ipk.sh" >&2 exit 1 } cp -f "$SPLASH_DIR/splash.png" "$APP_DIR/splash.png" @@ -295,7 +305,7 @@ cp -f "$ROOT/app/notices/OPEN_SOURCE_NOTICES.txt" "$ROOT/LICENSE" \ PATCHELF_BIN="$(command -v patchelf)" [[ -x "$INSTALL_DIR/bin/spool" ]] || { - echo "error: app install missing; run '$0 app' first" >&2 + echo "error: app install missing; rerun ./build-ipk.sh" >&2 exit 1 } cp -f "$INSTALL_DIR/bin/spool" "$STAGE_BIN/spool" @@ -415,10 +425,11 @@ fi if (( DO_PACKAGE )); then [[ -f "$APP_DIR/appinfo.json" && -x "$STAGE_BIN/spool" ]] || { - echo "error: staged webOS app missing; run '$0 stage' first" >&2 + echo "error: staged webOS app missing; rerun ./build-ipk.sh" >&2 exit 1 } -rm -f "$BUILD_DIR"/*.ipk +APP_ID="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["id"])' "$APP_DIR/appinfo.json")" +rm -f "$BUILD_DIR/${APP_ID}_"*.ipk # Plain ares-package output, no maintainer scripts. Hardware decode LS2 access # comes from appinstalld's rolegen (/usr/share/rolegen/templates/NDK.*), which # generates media-client roles for every native dev app from appinfo's "main" diff --git a/cmake/platform/AppleMobile.cmake b/cmake/platform/AppleMobile.cmake index 149c139f..0f1fad0a 100644 --- a/cmake/platform/AppleMobile.cmake +++ b/cmake/platform/AppleMobile.cmake @@ -1,6 +1,9 @@ function(spool_resolve_apple_mobile_dependencies) - if(NOT SPOOL_APPLE_APP_STORE) - message(FATAL_ERROR "Apple mobile builds require SPOOL_APPLE_APP_STORE=ON") + # App Store review limits what a build may bundle and download. An + # unsigned build for sideloading never goes through review, so it may be + # an ordinary open build; tvOS and signed builds keep the store's rules. + if(NOT SPOOL_APPLE_APP_STORE AND NOT IOS) + message(FATAL_ERROR "Apple TV builds require SPOOL_APPLE_APP_STORE=ON") endif() set(PKG_CONFIG_ARGN --static) pkg_check_modules(MPV REQUIRED IMPORTED_TARGET GLOBAL mpv) diff --git a/cmake/platform/Common.cmake b/cmake/platform/Common.cmake index 94ba4f90..97238119 100644 --- a/cmake/platform/Common.cmake +++ b/cmake/platform/Common.cmake @@ -4,11 +4,14 @@ 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}/IOS.cmake) include(${CMAKE_CURRENT_LIST_DIR}/Windows.cmake) include(${CMAKE_CURRENT_LIST_DIR}/WebOS.cmake) macro(spool_resolve_platform_dependencies) - if(ANDROID) + if(IOS) + spool_resolve_ios_dependencies() + elseif(ANDROID) spool_resolve_android_dependencies() elseif(SPOOL_WEBOS) spool_resolve_webos_dependencies() @@ -24,6 +27,10 @@ macro(spool_resolve_platform_dependencies) endmacro() function(spool_configure_platform_targets native_target core_target) + if(IOS) + spool_configure_ios_targets(${native_target} ${core_target}) + return() + endif() if(ANDROID) spool_configure_android_targets(${native_target} ${core_target}) return() diff --git a/cmake/platform/IOS.cmake b/cmake/platform/IOS.cmake new file mode 100644 index 00000000..744cd1ed --- /dev/null +++ b/cmake/platform/IOS.cmake @@ -0,0 +1,24 @@ +include(${CMAKE_CURRENT_LIST_DIR}/AppleMobile.cmake) +function(spool_resolve_ios_dependencies) + spool_resolve_apple_mobile_dependencies() +endfunction() + +function(spool_configure_ios_targets native_target core_target) + spool_configure_apple_mobile_targets(${native_target} ${core_target}) + target_compile_definitions(${native_target} PRIVATE TOUCHSCREEN) + set_target_properties(${native_target} PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/app/ios/Info.plist.in" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.sachk.spool" + MACOSX_BUNDLE_BUNDLE_NAME "Spool" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" + XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon" + ) + set_source_files_properties(app/ios/PrivacyInfo.xcprivacy PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(${native_target} PRIVATE app/ios/PrivacyInfo.xcprivacy) + if(SPOOL_IOS_ASSETS) + target_sources(${native_target} PRIVATE "${SPOOL_IOS_ASSETS}") + endif() +endfunction() diff --git a/cmake/platform/WebOS.cmake b/cmake/platform/WebOS.cmake index 7bf8b542..2f63c66c 100644 --- a/cmake/platform/WebOS.cmake +++ b/cmake/platform/WebOS.cmake @@ -175,5 +175,7 @@ function(spool_import_static_webos_plugins native_target) Qt6::QSQLiteDriverPlugin Qt6::QJpegPlugin Qt6::QWebpPlugin + # Provider marks, such as Jellyfin's, are SVG. + Qt6::QSvgPlugin ) endfunction() diff --git a/docs/debug/lifecycle-diagnostics.md b/docs/debug/lifecycle-diagnostics.md index 4054ce6c..214d5050 100644 --- a/docs/debug/lifecycle-diagnostics.md +++ b/docs/debug/lifecycle-diagnostics.md @@ -18,9 +18,52 @@ Generated files: webOS helpers: -- `tools/webos/diagnose.sh root@tv.local` -- `tools/webos/collect-diagnostics-bundle.sh root@tv.local` -- `tools/webos/kill-stale.sh root@tv.local` +- `tools/webos/diagnose.sh root@tv.local` prints allowlisted instance metadata, + never raw logs, process command lines, or diagnostic paths. +- `tools/webos/collect-diagnostics-bundle.sh root@tv.local` streams a read-only + archive to the desktop. It contains `ps.txt` and a nested `logs.tar.gz`; no + temporary directories or archives are created on the TV. Treat logs as private. +- `tools/webos/kill-stale.sh root@tv.local` explicitly terminates processes whose + executable belongs to this app bundle, rechecking before forced termination. + +These commands require an explicit target, validate `APP_ID`, and use +noninteractive SSH with connection/keepalive limits and a per-command +`WEBOS_COMMAND_TIMEOUT` (30 seconds by default). They never restart services, +change app data ownership, or patch installed files. Do not run device helpers +unless device diagnosis/deployment was explicitly requested. + +### Package and verification modes + +`./build-ipk.sh` and `./build-ipk.sh all` both run the complete pipeline: +verified source fetch, host Qt, target Qt, dependencies, fresh app configure, +staging/audits, then packaging. Phase entry points exist for CI orchestration; +do not use them as shortcuts when deploying. Help and invalid arguments never +enter Nix, restore a toolchain, download sources, or touch a device. + +`tools/webos/verify-device.sh --host root@tv.local` installs the newest matching +IPK by modification time and verifies exact app registration. Its default is +install-only; `--no-launch` makes that intent explicit. `--no-install` reads +registration without requiring a package. Only `--launch` launches the app, +collects logs, and captures a screenshot; `--skip-screenshot` omits that capture. +LS2 service errors, missing registration and failed capture fail the command. +Installation subscriptions are bounded by `--install-timeout` (120 seconds). +Screenshot files created in `/tmp` are removed after retrieval. + +### Device render benchmark + +Build `tools/webos/package-heaptrack-profile.sh --benchmark-only` and explicitly +install its IPK from `build/webos-profile` before running +`tools/webos/bench-device.sh --host root@tv.local`. The ordinary heaptrack +profiling IPK also supports benchmarks. Instrumentation is added to a separate +local staging copy, never the canonical release stage or installed application. + +The benchmark explicitly closes and launches only Spool, supplies data through +a temporary `/tmp` configuration read by the packaged shim, retrieves and +validates the report, then removes its temporary files and closes Spool. It +never rewrites `bin/spool`, changes app data ownership, wakes the panel, or +switches TV inputs. The panel must already be active; a sleeping panel or +incomplete/invalid report fails the measurement. `--wait` bounds report polling +(240 seconds by default), and each transport command is independently bounded. Simulation knobs: @@ -73,3 +116,8 @@ nix develop .#native -c bash tools/test-gpu-session.sh \ 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. + +For repeatable whole-app route timing instead of fullscreen-specific timings, +see [native route profiling](memory-profiling.md#repeatable-native-route-timings). +That driver reuses the private display, isolates app state for every repeat and +retains validated JSON plus aggregate summaries without raw application logs. diff --git a/docs/debug/memory-profiling.md b/docs/debug/memory-profiling.md index a9e63270..a676802f 100644 --- a/docs/debug/memory-profiling.md +++ b/docs/debug/memory-profiling.md @@ -1,4 +1,108 @@ -# Memory profiling (heaptrack) +# Performance and memory profiling + +## Repeatable native route timings + +Use `tools/profile-native.py` for low-overhead measurements of the **real app's** +route construction, GUI CPU time, Qt swap callbacks and frame-budget timer +lateness. It drives the existing `RenderBenchmark`/`InputLatencyMonitor`, not a +second timing implementation. No instrumentation runs in normal app launches. +`spoolet` is a navigation/control CLI; it does not currently expose a performance +sampling command. + +Build the same optimized binary for every comparison: + +```sh +nix run .#build +nix develop .#native -c python3 tools/profile-native.py \ + --app build/linux-release/install/bin/spool \ + --output build/profiles/before +``` + +The driver is Linux-only and always uses the existing private Xvfb + Weston +display with pinned Mesa llvmpipe/OpenGL, never your desktop or a TV. Every run +has a fresh empty profile, private session bus and temporary caches. Inherited +Spool data/cache/credential/benchmark overrides are discarded. The runner does +not attach to an existing instance or restore accounts. This workload measures +empty-shell route transitions, **not** provider response processing, populated +library scrolling, playback, physical GPU speed, panel presentation or heap/RSS. +The native development shell supplies the pinned graphics environment and +display tools; running outside it fails with an actionable error. + +By default it performs three fresh app/display runs in each mode, with one +unmeasured warmup walk and four measured walks per run: + +- `warm`: app pages remain resident after warmup. +- `eviction`: the existing benchmark requests application memory-pressure + eviction before transitions. This is **not** a cold OS page cache, Qt disk/AOT + cache or newly started process for every transition. + +The output directory must be new; existing results are never overwritten. +Artifacts are private (directory `0700`, files `0600`): + +- `warm-01.json`, `eviction-01.json`, etc.: the app's actual schema-2 reports. +- `summary.json`: completion/failure status, SHA-256 of the measured executable, + workload configuration, rendering environment, per-route medians/sample + counts/worst timer lateness and each run's idle timer noise floor. +- `summary.md`: median/min/max overall time across fresh runs. An overall time + is the **sum of per-route median wall times within one run**; startup and idle + settle delays are not included. Range across repeats exposes shared-machine + variability, not a statistical confidence interval. + +No raw application logs, support bundles, settings, account stores, environment +dumps or screenshots are retained or printed. Temporary state is removed on +success, failure and cancellation. Each run has a total `--timeout` (default +120 seconds, including display startup) plus `--step-timeout-ms` (default 8000). +Ctrl-C/SIGTERM stops only that run's owned app/compositor process group, with +a bounded three-second grace before forced termination. Missing/invalid metrics, +missing route steps, incomplete reports, changing rendering environments and +nonzero app exits fail the command; `summary.json` remains `complete: false`. +No failure report should be treated as a performance result. + +After building the changed revision, repeat with a **different** output +directory. Compare matching per-run reports on the same idle machine: + +```sh +nix develop .#native -c python3 tools/profile-native.py \ + --app build/linux-release/install/bin/spool --output build/profiles/after +python3 tools/compare-render-benchmark.py build/profiles/after/warm-01.json \ + --baseline build/profiles/before/warm-01.json --require-comparable \ + --markdown build/profiles/after/warm-comparison.md +``` + +`--require-comparable` refuses absent baselines or differences in workload, +Qt/provider runtime, timing origin or rendering environment, even with +`--warn-only`. Without it, the report helper explicitly labels an absent or +incompatible baseline and prints measurements without deltas (useful for CI's +first run). Missing/non-finite metrics and empty/incomplete current or baseline +reports always fail. Avoid conclusions from one pair of runs: examine all +repeats and idle noise. Timer lateness is **not** dropped frames; swap callbacks +are **not** display presentation. + +### Small isolated smoke + +After a native release build, this exercises both modes without user state: + +```sh +nix develop .#native -c python3 tools/profile-native.py \ + --app build/linux-release/install/bin/spool \ + --output build/profiles/smoke --repeats 1 --iterations 1 --timeout 90 +python3 tools/compare-render-benchmark.py build/profiles/smoke/warm-01.json +``` + +Expect exit zero, `summary.json` with `complete: true`, one report per mode and +nonempty measured route samples. A separate invocation with a new output +directory and `--timeout 0.1` must exit nonzero with `complete: false`, not leave +an app/compositor running or reuse the successful smoke's artifacts. For a +cancellation smoke, start the normal command with another new output directory, +press Ctrl-C, and inspect only its `summary.json` completion/failure fields. + +For allocation call stacks instead of route timings, use heaptrack below. +For native symbol-based CPU/allocation investigations use the existing +`linux-dev` CMake preset (`RelWithDebInfo`, development features enabled); do +not compare it directly to `linux-release` (`Release`, development features +disabled). Neither preset alone records a profile. + +## Heap allocation profiling (heaptrack) We profile the native app's heap with **heaptrack**: every allocation is recorded with its call stack on the TV, then analysed on the desktop with `heaptrack_gui` @@ -33,46 +137,71 @@ for breaking down a resident heap footprint. Heaptrack is opt-in and is not included in normal packages. Build and install a profiling package with an explicit TV host: +Run device commands only with explicit permission for that TV. Installation is +install-only; arming a capture does not launch the app. A profiling session must +be launched separately by the user, and the default capture closes that app to +flush the trace. + ```sh TV_HOST=root@tv.local BUNDLE_HEAPTRACK=1 ./build-ipk.sh nix develop -c bash tools/webos/verify-device.sh --no-launch \ - --host "$TV_HOST" ./build/com.sachk.spool_0.3.0_arm.ipk + --host "$TV_HOST" "./build/com.sachk.spool_$(cat VERSION)_arm.ipk" ``` This builds the recorder into `build/webos-heaptrack/install/...` and bundles it in the IPK. It is built from the same source as the desktop nixpkgs heaptrack, so the trace format matches. +The dedicated `tools/webos/package-heaptrack-profile.sh` helper creates its +profiling package from a temporary staging copy, preserving the canonical +release stage. Its `--benchmark-only` mode omits heaptrack and packages only the +benchmark-aware launch shim; that package supports route benchmarks, **not** +heap allocation recording. Helper artifacts remain under `build/webos-profile`. + ## Capture a session -Run the orchestrator with the TV host. It arms one-shot profiling, blocks until -the app relaunches under the preload, waits for playback to start, records, -finalises a clean trace, pulls and symbolises it, then opens `heaptrack_gui`: +Run the orchestrator with the TV host. It arms one-shot profiling, waits (up to +`--wait-timeout`, default 600 seconds) for a separately requested app launch under +the preload, waits for playback, records, finalises a trace, pulls and symbolises +it, then writes a text summary. No desktop GUI opens by default: ```sh tools/webos/profile-memory.sh --host root@tv.local --duration 60 ``` Useful options: `--no-stop` (leave the app running; partial-but-live trace), -`--no-gui`, and `--duration SEC`. Set `WAIT_PLAYBACK=0` to record from launch -instead of from the first play event, or `PLAY_WAIT=SEC` to change that wait. +`--out DIR`, `--wait-timeout SEC`, `--duration SEC`, and `--gui` (explicitly +open `heaptrack_gui`; `--no-gui` remains accepted). Set `WAIT_PLAYBACK=0` to record +from launch instead of from the first play event, or `PLAY_WAIT=SEC` (default +240) to change the playback wait. If no play event is seen before that deadline, +the current helper records anyway: do not label that trace a measured +playback-start allocation profile without independently confirming playback. Output lands in `build/memory/heaptrack//`: `heaptrack.spool.gz` (open in `heaptrack_gui`), `summary.txt` (`heaptrack_print`), `maps.txt`, and `smaps_rollup.txt` (RSS breakdown). +Unlike the isolated native timing artifacts, these traces, mapping paths, +symbolisation logs and collected module files are private device evidence. +Keep them local; share only the minimum filtered numeric summary or redacted +call stacks needed for diagnosis, never unfiltered app logs/support bundles. + ## Reading the result for the +350 MB question - If heaptrack's **peak heap** ≈ the RSS growth, the flame graph names the subsystem holding it (ffmpeg per-track demux state, Starfish buffer pools, Qt image cache, PGS subtitle bitmaps). -- If heaptrack's tracked **leaked/heap** is much smaller than the RSS growth in - `smaps_rollup.txt`, the gap is **glibc allocator retention** (freed but not - returned to the OS) — i.e. arena fragmentation. The app currently runs with no - `MALLOC_ARENA_MAX`; setting it (or calling `malloc_trim`) is the fix in that case. +- If heaptrack's tracked heap is much smaller than the RSS growth in + `smaps_rollup.txt`, investigate allocator retention/fragmentation **and** + memory heaptrack cannot see (mapped files, driver/video allocations and other + non-heap mappings). RSS minus tracked heap is not by itself proof of glibc + retention. Compare mapping categories and allocation lifetimes before trying + allocator tuning such as `MALLOC_ARENA_MAX`. ## The older coarse tool -`tools/webos/capture-memory.sh` still streams process-level RSS/PSS/swap over time -to a CSV+SVG. It answers "how much" but not "what" — use heaptrack for the breakdown. +`tools/webos/capture-memory.sh` streams process-level RSS/PSS/swap over time to +a CSV+SVG. It defaults to a bounded `--duration 300` seconds, rejects a zero +duration, and bounds the wait for the target process with `--wait-timeout 600` +seconds. It answers "how much" but not "what" — use heaptrack for the breakdown. diff --git a/docs/features/related-media.md b/docs/features/related-media.md new file mode 100644 index 00000000..bccf3d7c --- /dev/null +++ b/docs/features/related-media.md @@ -0,0 +1,20 @@ +# Trailers and extras + +Movie, series, season, and episode details show **Trailers** and **Extras** when +the media source has related media for that item. Select a card to play it; +hold the remote's select button or open the item menu to queue it, view its +media information, or update its watched/favourite state. + +These shelves come from the provider's related-media catalog contract +(`trailers` and `extras` kinds), keeping server endpoints inside the provider. +They contain separately indexed media items and use the normal authenticated +playback path, including stream negotiation, subtitles, remote targets, and +resume reporting. Watching an extra records progress on that extra, leaving +the main feature's progress intact. Nothing plays automatically before a movie. + +Empty shelves are hidden. If either optional request fails, the main feature +and any other successfully loaded rows remain usable. Navigation to another +item or switching accounts invalidates outstanding shelf results. + +This feature covers media indexed by the source. Remote trailer web links, +theme-video backgrounds, and a cinema pre-roll mode are separate additions. diff --git a/docs/install/ios.md b/docs/install/ios.md new file mode 100644 index 00000000..10021832 --- /dev/null +++ b/docs/install/ios.md @@ -0,0 +1,51 @@ +# Installing Spool on iPhone and iPad + +Spool is not on the App Store. Each release publishes an unsigned iOS app +(`Spool--iOS-arm64.ipa`) that SideStore or AltStore installs by signing +it with your own Apple ID. A free Apple ID is enough. + +Spool needs iOS 17 or later. + +## With SideStore or AltStore + +1. Install [SideStore](https://sidestore.io) or [AltStore](https://altstore.io) + on the device, following their own setup guide. +2. In SideStore or AltStore, open **Sources**, choose **Add**, and enter: + + ``` + https://spool-player.github.io/spool/altstore.json + ``` + +3. Open the Spool source and install Spool. + +Updates appear in the same place whenever a new version is released. + +## Installing a downloaded IPA by hand + +Download `Spool--iOS-arm64.ipa` from the +[releases page](https://github.com/spool-player/spool/releases), open it with +SideStore or AltStore (**My Apps → +**), and sign in with your Apple ID when asked. +This route does not update itself; the source above does. + +## What a free Apple ID means + +- Apps signed with a free Apple ID stop opening after **7 days** unless they are + refreshed. SideStore and AltStore refresh them in the background; open them + now and then if Spool asks to be refreshed. +- A free Apple ID can have **3** sideloaded apps installed at once, SideStore + or AltStore included. +- Spool asks for no special entitlements, so nothing is lost by signing it this + way. Your accounts and settings stay on the device across refreshes and updates. + +## Building and signing it yourself + +With a Mac, Xcode and a paid developer account you can build a signed copy: + +``` +APPLE_DEVELOPMENT_TEAM= APPLE_ARCHIVE=1 \ +APPLE_EXPORT_OPTIONS=/path/to/ExportOptions.plist bash tools/build-ios.sh +``` + +`SPOOL_APPLE_APP_STORE=OFF` builds the same open flavour the release IPA uses +(every bundled provider, and providers from links); the default is the App Store +flavour, which bundles only App Store-approved providers. diff --git a/docs/playback-controls.md b/docs/playback-controls.md new file mode 100644 index 00000000..e12545b6 --- /dev/null +++ b/docs/playback-controls.md @@ -0,0 +1,56 @@ +# Local repeat controls + +Open **Playback settings → Repeat** while local audio or video is playing: + +| Mode | At the end of an item | Explicit Next / Previous | +| --- | --- | --- | +| Off | Advance normally; episodes can continue into fetched successors. | Move within the queue. | +| Repeat queue | Advance through the current playback order, wrapping at the end. | Wrap at either end. | +| Repeat current item | Restart the current item from the beginning. | Move within the queue, bypassing repeat-one. | + +Repeat queue follows the queue's playback order, including shuffle and manual +reordering. A one-item queue can repeat. Clearing the queue resets repeat to Off; +replacing it keeps the chosen mode. The setting lasts for this app session and is +reset on logout. + +Remote repeat commands and the local menu change the same state. Playback +start/progress reports publish that state along with the current queue and +playback order, so a remote controller can reflect local changes. + +A playback group owns its queue policy. Joining a group clears local repeat and +disables the local Repeat menu; group playback reports omit the local repeat and +shuffle fields. Leaving the group starts with local repeat Off. This control does +not claim to change the group's repeat mode. Native playlist playback keeps its +own player-side traversal and ignores queue repeat. + +The queue model owns the single repeat state and the wrap decisions. + +## Sleep timer + +Open **Playback settings → Sleep timer** during local audio or video playback. +Choose **After current item** or **15, 30, 45, 60, 90, or 120 minutes**. Choose +**Off** to cancel. The playback settings row shows the remaining duration. + +- A duration counts elapsed time from when it is selected. Pausing, buffering, + changing items, and restarting a stream for quality or codec changes do not + restart the countdown. Selecting another duration starts a new countdown. +- After current item stops once when that item completes, before repeat or + automatic queue/episode advance. Quality and codec restarts of the same item + keep it armed. Starting a different item cancels it. +- Only one request can be active. Selecting after-current replaces a duration, + and selecting a duration replaces after-current. +- Stopping playback explicitly clears the request, including stop actions from + the player overlay, a remote controller, or platform media controls. A stop + also invalidates pending item, album, episode, and stream negotiations, so + their responses cannot resume stopped playback. +- Joining a playback group, changing profile, logging out, and quitting clear + the request. The local timer is unavailable in a group and is never sent to a + remote target. + +The request is owned by the application, so navigating away from or rebuilding +the player overlay does not discard it. It is held only for the current process +and uses a monotonic elapsed clock, independent of playback position and wall +clock adjustments. It does not shut down or suspend the device. If the app event +loop is delayed, playback stops on the first callback that observes the elapsed +deadline, including a stream negotiation callback; device suspension and process +termination remain platform lifecycle behavior. diff --git a/docs/provider-parity-verification.md b/docs/provider-parity-verification.md index 5a708746..a358dabc 100644 --- a/docs/provider-parity-verification.md +++ b/docs/provider-parity-verification.md @@ -116,6 +116,43 @@ and 3840×2160, at 100% and 150% zoom, in confirmed, pending, error and opted-ou 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. +## Real application journey coverage + +`app-journey` launches the built Spool product and drives its existing `spoolet` +interface, rendered labels and normal pointer/remote input. The shipped Jellyfin +provider talks to a private authenticated loopback server; the fixture supplies +protocol responses and records requests, not navigation or playback decisions. +The journey extends login, settings, download/offline playback and stale-progress +coverage with these transitions: + +| Transition | Observable proof | Regression caught | +| --- | --- | --- | +| Played filter → empty library → Reset → remote OK | Server sees `IsPlayed`; browse becomes empty, then restores both items; Back closes only the menu and OK opens the movie | Filter lost in provider translation, stale results, or focus stranded after model replacement | +| Movie → related episode → Back → movie → Back → library → remote OK | Real episode details are fetched; distinct episode/movie headings establish restored identity and remote selection | Retained details rebound to the child, skipped parent route, or lost browse selection | +| Repeat current item → actual media EOF | New playback negotiation and server start receipt at zero with `RepeatOne`, followed by decoded red-over-blue beginning frames | Menu changes only visual state, repeat fails to restart, or restart incorrectly resumes old progress | +| Timed sleep → After current item with repeat still enabled → EOF | Remaining-time and after-current labels; end-position stop receipt, watched mutation, and no further negotiation/playback during a bounded observation window | Timer replacement fails or repeat takes precedence over the requested stop | + +Run the already-built journey in an isolated Linux GPU session: + +```sh +nix develop .#native -c bash tools/test-gpu-session.sh \ + build/linux-release-local-providers/app/spool-e2e-tests --child app-journey +``` + +Build `spool-e2e-tests` (which depends on the matching `spool` and `spoolet`) +first. The native shell supplies the pinned display/Mesa runtime; FFmpeg and +Tesseract with English data must also be available. Each wait has a deadline; +EOF cases seek the paused, finite 90-second fixture near its end rather than +waiting for full playback or altering the product clock. Existing Android/tvOS +adapters run the same journey against driver-owned isolated emulators/simulators; +the test does not accept a user device. Do not substitute Qt offscreen/minimal or +the Qt Quick software renderer for this GPU journey. + +Failures retain private screenshots, allowlisted fixture counters and app logs in +the reported artifact directory. Do not publish raw logs or control descriptors. +These cases are executable coverage, not a claim of a completed platform run; +record actual execution results separately. + ## Limits Loopback and protocol fixtures verify host behavior and request contracts; they do diff --git a/flake.lock b/flake.lock index 2dd87166..d84f8470 100644 --- a/flake.lock +++ b/flake.lock @@ -20,10 +20,10 @@ "mpv-src": { "flake": false, "locked": { - "lastModified": 1791400971, - "narHash": "sha256-+B8gcOlvKEPAxnPgGwg6EpCwBAcPMq9DzRYkLLTMIrI=", - "rev": "4d4217d80139c977b2d555e59e4d56ee40456e8f", - "revCount": 55612, + "lastModified": 1792585892, + "narHash": "sha256-mIoCdxXIXGwQAmxfYZbXN4u+YNr6cl0gTsEQQ2IQ9Uc=", + "rev": "693f4158cbc9c5b3edae377493e0db6cd7502d34", + "revCount": 55613, "type": "git", "url": "file:./mpv" }, diff --git a/mpv b/mpv index 4d4217d8..693f4158 160000 --- a/mpv +++ b/mpv @@ -1 +1 @@ -Subproject commit 4d4217d80139c977b2d555e59e4d56ee40456e8f +Subproject commit 693f4158cbc9c5b3edae377493e0db6cd7502d34 diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 3905f42f..c0fde6a0 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = spool-bin pkgdesc = Fast media player for desktop and TV, built on Qt Quick and mpv - pkgver = 0.9.0 + pkgver = 0.9.2 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.9.0 + provides = spool=0.9.2 conflicts = spool options = !strip options = !debug options = !lto - source = https://github.com/spool-player/spool/releases/download/v0.9.0/Spool-0.9.0-linux-x86_64.tar.zst + source = https://github.com/spool-player/spool/releases/download/v0.9.2/Spool-0.9.2-linux-x86_64.tar.zst sha256sums = SKIP pkgname = spool-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 272d18b2..c108874b 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Sacha Korban pkgname=spool-bin -pkgver=0.9.0 +pkgver=0.9.2 pkgrel=1 pkgdesc='Fast media player for desktop and TV, built on Qt Quick and mpv' arch=('x86_64') diff --git a/providers/bundled/example.open-movies-1.1.1.tar.zst b/providers/bundled/example.open-movies-1.1.1.tar.zst deleted file mode 100644 index 1a6aacb1..00000000 Binary files a/providers/bundled/example.open-movies-1.1.1.tar.zst and /dev/null differ diff --git a/providers/bundled/example.open-movies-1.1.2.szo b/providers/bundled/example.open-movies-1.1.2.szo new file mode 100644 index 00000000..92a73888 Binary files /dev/null and b/providers/bundled/example.open-movies-1.1.2.szo differ diff --git a/providers/bundled/spool.emby-0.1.10.szo b/providers/bundled/spool.emby-0.1.10.szo new file mode 100644 index 00000000..cbaef29f Binary files /dev/null and b/providers/bundled/spool.emby-0.1.10.szo differ diff --git a/providers/bundled/spool.emby-0.1.8.szo b/providers/bundled/spool.emby-0.1.8.szo deleted file mode 100644 index 042e291a..00000000 Binary files a/providers/bundled/spool.emby-0.1.8.szo and /dev/null differ diff --git a/providers/bundled/spool.jellyfin-0.2.13.szo b/providers/bundled/spool.jellyfin-0.2.13.szo deleted file mode 100644 index 203b8fb4..00000000 Binary files a/providers/bundled/spool.jellyfin-0.2.13.szo and /dev/null differ diff --git a/providers/bundled/spool.jellyfin-0.2.15.szo b/providers/bundled/spool.jellyfin-0.2.15.szo new file mode 100644 index 00000000..0a23e13b Binary files /dev/null and b/providers/bundled/spool.jellyfin-0.2.15.szo differ diff --git a/providers/bundled/spool.plex-0.1.12.szo b/providers/bundled/spool.plex-0.1.12.szo new file mode 100644 index 00000000..e104e5f8 Binary files /dev/null and b/providers/bundled/spool.plex-0.1.12.szo differ diff --git a/providers/bundled/spool.plex-0.1.9.tar.zst b/providers/bundled/spool.plex-0.1.9.tar.zst deleted file mode 100644 index 69776376..00000000 Binary files a/providers/bundled/spool.plex-0.1.9.tar.zst and /dev/null differ diff --git a/providers/bundled/spool.stremio-0.1.2.tar.zst b/providers/bundled/spool.stremio-0.1.2.tar.zst deleted file mode 100644 index 67597612..00000000 Binary files a/providers/bundled/spool.stremio-0.1.2.tar.zst and /dev/null differ diff --git a/providers/bundled/spool.stremio-0.1.3.szo b/providers/bundled/spool.stremio-0.1.3.szo new file mode 100644 index 00000000..c1abfe8e Binary files /dev/null and b/providers/bundled/spool.stremio-0.1.3.szo differ diff --git a/providers/lock.json b/providers/lock.json index 5aa7dde9..b5cffdf2 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -4,37 +4,37 @@ { "id": "spool.jellyfin", "appleAppStore": true, - "version": "0.2.13", - "archive": "bundled/spool.jellyfin-0.2.13.szo", - "sha256": "4f1331f17657dd6ff1ffb1c396149ac00c4393ac02dea44a185901111f07a897" + "version": "0.2.15", + "archive": "bundled/spool.jellyfin-0.2.15.szo", + "sha256": "65efc71022a12f02caf19560c53f6404d2e49037c4b8bf29a6a73dcd233823a6" }, { "id": "spool.emby", "appleAppStore": true, - "version": "0.1.8", - "archive": "bundled/spool.emby-0.1.8.szo", - "sha256": "14f9a36a8e7317b2cf51ed1ebcea3d74e346f2897ac18cec6e8eddf7aa290ea8" + "version": "0.1.10", + "archive": "bundled/spool.emby-0.1.10.szo", + "sha256": "ee2ab45c03e1dd94011fcaefddae6030baf1c46e2554d2974a69f79227363a6b" }, { "id": "spool.plex", "appleAppStore": true, - "version": "0.1.9", - "archive": "bundled/spool.plex-0.1.9.tar.zst", - "sha256": "d7d4b8412d45b3ae04151ec1c6e32680736c6443783ce4d8de168ad9dc89679a" + "version": "0.1.12", + "archive": "bundled/spool.plex-0.1.12.szo", + "sha256": "b2219089f0726d74e824a5fdf89315f6993749b4d0c81ad42bb5d67d3b2b53b6" }, { "id": "spool.stremio", "appleAppStore": false, - "version": "0.1.2", - "archive": "bundled/spool.stremio-0.1.2.tar.zst", - "sha256": "b403491c4cd9129fbaef1a4f381a87357bdac98edb5c4d04087365dd13e7d05c" + "version": "0.1.3", + "archive": "bundled/spool.stremio-0.1.3.szo", + "sha256": "b438e9acb27690ab14cfb72baa40fc9cea299d7a4e12282ac78fdb430efc9351" }, { "id": "example.open-movies", "appleAppStore": true, - "version": "1.1.1", - "archive": "bundled/example.open-movies-1.1.1.tar.zst", - "sha256": "20fe53ea93eeea93b87ca324f4e04464301267e48d3cf717ed59e92586586ab8" + "version": "1.1.2", + "archive": "bundled/example.open-movies-1.1.2.szo", + "sha256": "e9d7f10328309c2810049c86aa8a4f0a70fd24bc91dcad666fe6a2d52f819c7f" } ] } diff --git a/qml/Main.qml b/qml/Main.qml index 4f515455..0e294c3f 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -15,6 +15,7 @@ FocusScope { } AppShell { + objectName: "appShell" anchors.fill: parent } } diff --git a/qml/browse/RowStackView.qml b/qml/browse/RowStackView.qml index 22669976..7705ee5b 100644 --- a/qml/browse/RowStackView.qml +++ b/qml/browse/RowStackView.qml @@ -17,12 +17,16 @@ 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, moveItem, contextMenu, headerAction, headerActionText } +// contextSource, moveItem, contextMenu, headerAction, headerActionText, +// reserveWhenEmpty, reserveWhileLoading } FocusScope { id: root property var shell property var sections: [] + // Loading changes row reservation without replacing the section model, + // which would discard every row's delegates and horizontal position. + property bool loading: false property string contextReturnRoute: "" property bool navigationFocusVisible: true // Set on the first row so the shell can time the first frame it draws. @@ -40,6 +44,12 @@ FocusScope { property bool pointerNavigationPending: false property var pendingScrollController: null + readonly property bool directionRelease: true + property var recoveryGesture: ({ + "key": 0 + }) + onVisibleChanged: if (!visible) + recoveryGesture.key = 0 signal activated(var section, int index, var item) signal contextRequested(var section, int index, var item, var anchor) @@ -51,6 +61,49 @@ FocusScope { readonly property alias contentY: sectionList.contentY readonly property int sectionCount: sections ? sections.length : 0 + property var sectionObjects: [] + property bool sectionObjectsReady: false + onSectionsChanged: if (sectionObjectsReady) + updateSectionObjects() + Component.onCompleted: { + sectionObjectsReady = true + updateSectionObjects() + } + + Component { + id: sectionObjectComponent + QtObject { + property var descriptor + } + } + + // Re-publishing a card model is not replacing its section. Keep descriptor + // objects (and therefore row delegates) when the section topology is stable. + function updateSectionObjects() { + // sectionsChanged precedes reevaluation of dependent bindings in some + // QML delivery orders. Reconcile the published payload, not sectionCount. + const nextCount = sections ? sections.length : 0 + let sameSections = sectionObjects.length === nextCount + for (let index = 0; sameSections && index < nextCount; ++index) { + const previous = sectionObjects[index].descriptor + const next = sections[index] + sameSections = previous.key === next.key && previous.contextSource === next.contextSource + } + if (sameSections) { + for (let index = 0; index < nextCount; ++index) + sectionObjects[index].descriptor = sections[index] + return + } + const previousObjects = sectionObjects + const nextObjects = [] + for (let index = 0; index < nextCount; ++index) + nextObjects.push(sectionObjectComponent.createObject(root, { + "descriptor": sections[index] + })) + sectionObjects = nextObjects + for (let index = 0; index < previousObjects.length; ++index) + previousObjects[index].destroy() + } function sectionAt(index) { return index >= 0 && index < sectionCount ? sections[index] : null @@ -214,13 +267,25 @@ FocusScope { } function routeKey(key, phase, repeat) { - if (phase !== "release" && InputKeys.isDirection(key) && pointerNavigationPending) - return recoverPointerNavigation() const row = currentRow() + if (phase === "release" && InputKeys.isDirection(key)) { + InputKeys.consumeRecoveryGesture(recoveryGesture, key, phase, repeat, false) + if (row) + row.routeKey(key, phase, repeat) + return true + } if (!row) return false if (row.moveMode) return row.routeKey(key, phase, repeat) + if (InputKeys.isDirection(key)) { + if (InputKeys.consumeRecoveryGesture(recoveryGesture, key, phase, repeat, false)) + return true + if (pointerNavigationPending || !row.selectionVisiblyUsable(sectionList)) { + InputKeys.consumeRecoveryGesture(recoveryGesture, key, phase, repeat, true) + return recoverPointerNavigation() + } + } if (key === Qt.Key_Up || key === Qt.Key_Down) return moveSection(key === Qt.Key_Down ? 1 : -1) return row.routeKey(key, phase, repeat) @@ -239,6 +304,8 @@ FocusScope { onActiveFocusChanged: { clearPendingPointerNavigation() + if (!activeFocus) + recoveryGesture.key = 0 if (activeFocus && Metrics.keyboardFocusActive) { navigationFocusVisible = true Qt.callLater(focusCurrentSection) @@ -249,7 +316,7 @@ FocusScope { id: sectionList anchors.fill: parent - model: root.sections + model: root.sectionObjects header: root.header footer: root.footer spacing: root.rowSpacing @@ -277,25 +344,27 @@ FocusScope { required property int index required property var modelData + readonly property var sectionDescriptor: modelData.descriptor width: sectionList.width - title: String(modelData.title || "") - model: modelData.model + title: String(sectionDescriptor.title || "") + model: sectionDescriptor.model shell: root.shell - cardKind: String(modelData.kind || "poster") - useSeriesPoster: Boolean(modelData.useSeriesPoster) - preferEpisodeTitle: Boolean(modelData.preferEpisodeTitle) - enabledRow: modelData.enabled === undefined ? true : Boolean(modelData.enabled) - reserveWhenEmpty: Boolean(modelData.reserveWhenEmpty) - loading: Boolean(modelData.loading) - emptyText: String(modelData.emptyText || "") - headerBadge: modelData.headerBadge || null - cardBadge: modelData.cardBadge || null + cardKind: String(sectionDescriptor.kind || "poster") + useSeriesPoster: Boolean(sectionDescriptor.useSeriesPoster) + preferEpisodeTitle: Boolean(sectionDescriptor.preferEpisodeTitle) + enabledRow: sectionDescriptor.enabled === undefined ? true : Boolean(sectionDescriptor.enabled) + reserveWhenEmpty: Boolean(sectionDescriptor.reserveWhenEmpty) || (root.loading && Boolean( + sectionDescriptor.reserveWhileLoading)) + loading: Boolean(sectionDescriptor.loading) + emptyText: String(sectionDescriptor.emptyText || "") + headerBadge: sectionDescriptor.headerBadge || null + cardBadge: sectionDescriptor.cardBadge || null focusVisible: root.navigationFocusVisible - moveItem: modelData.moveItem || null - contextMenu: modelData.contextMenu || null - headerAction: modelData.headerAction || null - headerActionText: String(modelData.headerActionText || "") + moveItem: sectionDescriptor.moveItem || null + contextMenu: sectionDescriptor.contextMenu || null + headerAction: sectionDescriptor.headerAction || null + headerActionText: String(sectionDescriptor.headerActionText || "") onCountChanged: Qt.callLater(root.repair) onHasHeaderActionChanged: Qt.callLater(root.repair) // This view is already inset by its host; use its usable width. @@ -305,7 +374,7 @@ FocusScope { cardGap: Metrics.gapPx wheelFlickable: sectionList atomicPopulate: root.measureFirstRow && index === 0 - itemContextSource: String(modelData.contextSource || modelData.key || "") + itemContextSource: String(sectionDescriptor.contextSource || sectionDescriptor.key || "") itemContextReturnRoute: root.contextReturnRoute onVerticalWheelScrolled: controller => root.beginPointerNavigation(controller) @@ -317,7 +386,7 @@ FocusScope { root.firstRowReady = true onActivated: (itemIndex, item) => { root.currentSection = index - root.activated(modelData, itemIndex, item) + root.activated(sectionDescriptor, itemIndex, item) } } } diff --git a/qml/pages/AccountsPage.qml b/qml/pages/AccountsPage.qml index 99987d6f..40c672c3 100644 --- a/qml/pages/AccountsPage.qml +++ b/qml/pages/AccountsPage.qml @@ -318,11 +318,6 @@ FocusScope { if (tiles.length) rows.push(tiles) } - for (let i = 0; i < providerRepeater.count; ++i) { - const provider = providerRepeater.itemAt(i) - if (provider) - rows.push([provider]) - } rows.push([addServerButton]) return rows } @@ -608,7 +603,9 @@ FocusScope { AppText { Layout.fillWidth: true Layout.topMargin: Metrics.scaled(12) - text: root.startupMode || root.onboardingId.length > 0 ? "Who's watching?" : "Profiles & servers" + text: root.onboardingId.length > 0 ? "Signing in…" : root.startupMode ? "Who's watching?" : + "Profiles & servers" + font.pixelSize: Metrics.titleSizePx font.weight: Font.DemiBold horizontalAlignment: Text.AlignHCenter @@ -616,7 +613,7 @@ FocusScope { AppText { Layout.fillWidth: true - text: root.onboardingId.length > 0 ? "Select your watching profile, then choose how Spool starts." : + text: root.onboardingId.length > 0 ? "Your library opens as soon as the server answers." : root.startupMode ? "Choose a profile for each server that asks. Other servers are already open." : "One person watches on each server at a time. Independent servers appear on Home together." @@ -664,7 +661,8 @@ FocusScope { spacing: Metrics.scaled(12) function headerStops() { - return [providerSettingsButton, startupButton, addProfileButton].filter(item => item.visible) + return [providerSettingsButton, startupButton, addProfileButton, + addServerButtonInSection].filter(item => item.visible) } function tileStops() { const tiles = [] @@ -717,7 +715,7 @@ FocusScope { root.lastFocus = focusKey kind: "flat" iconName: "settings" - text: "Provider settings" + text: "Settings" onClicked: { root.focusStop(providerSettingsButton) root.shell.pushRoute("providerDetails", { @@ -753,6 +751,25 @@ FocusScope { sectionItem.modelData.people[0].accounts[0].id)) } } + ActionButton { + id: addServerButtonInSection + readonly property string focusKey: "addServer:" + sectionItem.modelData.key + readonly property var installedModule: root.moduleFor(sectionItem.modelData.moduleId) + onActiveFocusChanged: if (activeFocus) + root.lastFocus = focusKey + visible: Boolean(installedModule && installedModule.needsAccount) + kind: "flat" + iconName: "dns" + text: "Add a server" + onClicked: { + root.focusStop(addServerButtonInSection) + const context = Providers.beginSetup(sectionItem.modelData.moduleId, + sectionItem.modelData.people[0].accounts[0].id, + "addServer") + if (context) + root.shell.openProviderScreen(context) + } + } } } @@ -781,7 +798,6 @@ FocusScope { providerId: modelData.moduleId providerName: installedModule ? installedModule.name : "" providerIcon: installedModule ? installedModule.iconUrl : "" - providerVersion: installedModule ? installedModule.version : "" errorText: root.errorOf(modelData) detail: { const label = root.stateLabel(tile.profileState) @@ -814,43 +830,6 @@ FocusScope { } } - AppText { - Layout.fillWidth: true - visible: !root.startupMode && root.onboardingId.length === 0 - text: "Installed providers" - font.pixelSize: Metrics.bodySizePx + Metrics.scaled(4) - font.weight: Font.DemiBold - } - - Repeater { - id: providerRepeater - model: !root.startupMode && root.onboardingId.length === 0 ? Providers.modules : [] - delegate: ServerCard { - id: moduleCard - required property var modelData - readonly property string focusKey: "module:" + modelData.id - Layout.fillWidth: true - title: modelData.name - serverAddress: "Installed provider version " + modelData.version - providerName: modelData.name - providerId: modelData.id - providerIcon: modelData.iconUrl - providerVersion: modelData.version - focused: activeFocus && Metrics.keyboardFocusActive - onActiveFocusChanged: if (activeFocus) - root.lastFocus = focusKey - function clicked() { - accepted() - } - onAccepted: { - root.focusStop(moduleCard) - root.shell.pushRoute("providerDetails", { - moduleId: modelData.id - }) - } - } - } - ActionButton { id: addServerButton readonly property string focusKey: "addServer" @@ -860,7 +839,7 @@ FocusScope { Layout.topMargin: Metrics.scaled(8) kind: "secondary" iconName: "add" - text: "Add a server or service" + text: "Add an account" onClicked: root.shell.pushRoute("addProvider") } } diff --git a/qml/pages/AddProviderPage.qml b/qml/pages/AddProviderPage.qml index 9c5a43e6..420c635e 100644 --- a/qml/pages/AddProviderPage.qml +++ b/qml/pages/AddProviderPage.qml @@ -5,10 +5,11 @@ 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. +// Adding an account: pick the service your media is on and sign in. Whether +// that service's provider still has to be downloaded, or updated first, is +// Spool's business, not the viewer's -- choosing a tile does whatever it +// takes and then opens its sign-in. Spool's own services come first, then +// the community's; a provider shared as a link is one quiet button below. FocusScope { id: root @@ -17,157 +18,134 @@ FocusScope { 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" - }) + // "grid" or "link": where remote focus is. + property string focusArea: "grid" + property string problemText: "" + + readonly property bool firstAccount: !Providers.hasAccounts + readonly property var entries: { + const out = Store.official.map(entry => Object.assign({}, entry, { + "community": false + })) + if (Store.storeAvailable) + out.push(...Store.community.map(entry => Object.assign({}, entry, { + "community": true + }))) return out } + readonly property bool linkAvailable: Store.storeAvailable && Store.linksAllowed - // Headers come and go as the store loads; stay on the same entry. + // The store loads in pieces; stay on the same service as it does. 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 + onEntriesChanged: { + const index = entries.findIndex(entry => entry.id === currentId) + tiles.currentIndex = index >= 0 ? index : Math.min(Math.max(0, tiles.currentIndex), entries.length - 1) } focus: true Component.onCompleted: { Store.refresh(true) - list.currentIndex = step(-1, 1) - InputKeys.focus(list) + tiles.currentIndex = 0 + InputKeys.focus(tiles) } function stateOf(entry) { - if (entry.kind === "link") - return Store.busy.url ? "busy" : "link" - if (entry.busy) + if (!entry) + return "" + if (entry.busy || pendingSetup === entry.id) return "busy" if (!entry.compatible) - return entry.installed ? "installed" : "incompatible" - if (entry.updateAvailable) - return "update" - return entry.installed ? "installed" : "get" + return entry.installed ? "ready" : "unavailable" + return "ready" + } + + function accountCount(entry) { + return Providers.accounts.filter(account => account.moduleId === entry.id).length + } + + function detailText(entry) { + switch (stateOf(entry)) { + case "busy": + return "Getting ready…" + case "unavailable": + return "Needs a newer version of Spool" + } + const accounts = accountCount(entry) + if (accounts > 0) + return accounts === 1 ? "1 account added · Add another" : accounts + " accounts added · Add another" + return entry.summary || "" } function beginSetup(id) { pendingSetup = "" - const context = Providers.beginSetup(id) + // A service already signed in to starts from that sign-in, the way a + // Plex app lists the account's other servers; a provider that cannot + // reuse it just asks again. + const signedIn = Providers.accounts.find(account => account.moduleId === id) + const context = signedIn ? Providers.beginSetup(id, signedIn.id, "addServer") : Providers.beginSetup(id) if (context) shell.openProviderScreen(context) } - function activateRow(index) { - const entry = rows[index] - if (!entry) + // One action whatever the provider's state: get it current, then sign in. + function activateEntry(index) { + const entry = entries[index] + if (!entry || stateOf(entry) !== "ready") return - switch (stateOf(entry)) { - case "link": - linkEditing = true - Qt.callLater(() => { - const item = list.itemAtIndex(index) - if (item) - item.focusField() - }) - break - case "update": + problemText = "" + if (!entry.installed) { + pendingSetup = entry.id + Store.install(entry.id) + } else if (entry.updateAvailable && entry.compatible) { pendingSetup = entry.id Store.update(entry.id) - break - case "installed": + } else { beginSetup(entry.id) - break - case "get": - pendingSetup = entry.id - Store.install(entry.id) - break } } + function openLink() { + linkEditing = true + focusArea = "link" + Qt.callLater(() => linkField.focusField()) + } + function submitLink(text) { const url = String(text || "").trim() if (url.length === 0) return pendingSetup = "*" Store.addFromUrl(url) + closeLink() + } + + function closeLink() { linkEditing = false - InputKeys.focus(list) + focusArea = "grid" + InputKeys.focus(tiles) } function openMenu(index) { - const entry = rows[index] - if (!entry || !entry.installed) + const entry = entries[index] + if (!entry || !entry.installed || entry.removable === false || entry.bundled) 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") + if (index === 0) + activateEntry(entries.findIndex(candidate => candidate.id === entry.id)) + else if (index === 1) removingEntry = entry } function closeMenu() { menuLoader.active = false - InputKeys.focus(list) + InputKeys.focus(tiles) } function activate() { @@ -175,17 +153,22 @@ FocusScope { return removeConfirmation.item.activate() if (menuLoader.item) return menuLoader.item.activate() - activateRow(list.currentIndex) + if (focusArea === "link") { + if (!linkEditing) + openLink() + return + } + activateEntry(tiles.currentIndex) } function longPress() { - return openMenu(list.currentIndex) + return focusArea === "grid" && openMenu(tiles.currentIndex) } function back() { if (removingEntry) { removingEntry = null - InputKeys.focus(list) + InputKeys.focus(tiles) return true } if (menuLoader.item) { @@ -193,8 +176,7 @@ FocusScope { return true } if (linkEditing) { - linkEditing = false - InputKeys.focus(list) + closeLink() return true } return false @@ -209,12 +191,32 @@ FocusScope { 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) + const columns = Math.max(1, tiles.columns) + if (focusArea === "link") { + if (key === Qt.Key_Up) { + focusArea = "grid" + InputKeys.focus(tiles) + } + return InputKeys.isDirection(key) + } + const index = tiles.currentIndex + if (key === Qt.Key_Left && index % columns > 0) + tiles.currentIndex = index - 1 + else if (key === Qt.Key_Right && index % columns < columns - 1 && index + 1 < entries.length) + tiles.currentIndex = index + 1 + else if (key === Qt.Key_Up && index >= columns) + tiles.currentIndex = index - columns + else if (key === Qt.Key_Down) { + if (index + columns < entries.length) + tiles.currentIndex = index + columns + else if (Math.floor(index / columns) < Math.floor((entries.length - 1) / columns)) + tiles.currentIndex = entries.length - 1 + else if (linkAvailable) { + focusArea = "link" + linkButton.forceActiveFocus() + } + } else if (key === Qt.Key_Menu || key === Qt.Key_M) + return openMenu(index) else return InputKeys.isDirection(key) return true @@ -226,8 +228,9 @@ FocusScope { if (root.pendingSetup === id || root.pendingSetup === "*") root.beginSetup(id) } - function onProblem() { + function onProblem(message) { root.pendingSetup = "" + root.problemText = message } } @@ -236,17 +239,19 @@ FocusScope { 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)) + Flickable { + id: flick + anchors.fill: parent + contentWidth: width + contentHeight: column.implicitHeight + Metrics.pageMarginPx * 2 + boundsBehavior: Flickable.StopAtBounds + clip: true ColumnLayout { - id: header + id: column + x: (flick.width - width) / 2 y: Math.max(Metrics.pageMarginPx, Metrics.scaled(56)) - width: parent.width + width: Math.min(flick.width - Metrics.pageMarginPx * 2, Metrics.scaled(1120)) spacing: Metrics.scaled(10) RowLayout { @@ -254,7 +259,7 @@ FocusScope { spacing: Metrics.scaled(14) IconButton { - visible: Providers.hasAccounts + visible: !root.firstAccount iconName: "arrow_back" accessibleName: "Back" onClicked: Router.pop("accounts") @@ -262,148 +267,127 @@ FocusScope { AppText { Layout.fillWidth: true - text: "Add a provider" + text: root.firstAccount ? "Welcome to Spool" : "Add an account" font.pixelSize: Metrics.scaled(44) font.weight: Font.DemiBold elide: Text.ElideRight } + + BusySpinner { + Layout.preferredWidth: Metrics.scaled(24) + Layout.preferredHeight: Metrics.scaled(24) + running: Store.loading + visible: running + } } AppText { Layout.fillWidth: true - text: "Choose where your media comes from. You can add as many as you like." + text: root.firstAccount ? "Where's your media? Choose the service it's on and sign in." : + "Choose a service and sign in. 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 + visible: Store.error.length > 0 && root.entries.length === 0 text: Store.error color: Theme.errorText font.pixelSize: Metrics.bodySizePx wrapMode: Text.Wrap } - } - ListView { - id: list - anchors.top: header.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 - } + GridView { + id: tiles - delegate: Item { - id: slot - required property int index - required property var modelData - readonly property bool isHeader: modelData.kind === "header" + readonly property real minimumTile: Metrics.scaled(250) + readonly property int columns: Math.max(1, Math.floor(width / minimumTile)) - function focusField() { - linkField.focusRow() + Layout.fillWidth: true + Layout.topMargin: Metrics.scaled(26) + Layout.preferredHeight: Math.ceil(count / columns) * cellHeight + interactive: false + focus: true + keyNavigationEnabled: false + highlightMoveDuration: 0 + model: root.entries + cellWidth: Math.floor(width / columns) + cellHeight: Metrics.scaled(212) + onCurrentIndexChanged: { + root.currentId = root.entries[currentIndex] ? String(root.entries[currentIndex].id || "") : "" + const item = currentItem + if (!item || !activeFocus) + return + const bounds = item.mapToItem(flick.contentItem, 0, 0, item.width, item.height) + if (bounds.y < flick.contentY) + flick.contentY = Math.max(0, bounds.y - Metrics.scaled(24)) + else if (bounds.y + bounds.height > flick.contentY + flick.height) + flick.contentY = bounds.y + bounds.height + Metrics.scaled(24) - flick.height } - width: list.width - height: isHeader ? Metrics.scaled(index === 0 ? 32 : 56) : row.height + delegate: Item { + id: slot - 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) - } + required property int index + required property var modelData + readonly property string serviceState: root.stateOf(modelData) + readonly property bool current: GridView.isCurrentItem && Metrics.keyboardFocusActive + && tiles.activeFocus && root.focusArea === "grid" - BusySpinner { - anchors.right: parent.right - anchors.bottom: parent.bottom - width: Metrics.scaled(18) - height: width - running: slot.isHeader && Boolean(slot.modelData.loading) - visible: running - } + width: tiles.cellWidth + height: tiles.cellHeight - 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 { + Rectangle { 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 + anchors.margins: Metrics.scaled(8) + radius: Theme.radiusPanel + color: slot.current ? Theme.focusedFill : hover.hovered ? Theme.bgHover : Theme.bgRaised + border.width: slot.current ? Theme.focusBorderWidth : Theme.hoverBorderWidth + border.color: slot.current ? Theme.accent : Theme.border + opacity: slot.serviceState === "unavailable" ? 0.55 : 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.scaled(20) + spacing: Metrics.scaled(8) + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.scaled(12) + ProviderIcon { - source: row.modelData.iconUrl || "" - name: row.modelData.name || "" - seed: row.modelData.id || "" + Layout.preferredWidth: Metrics.scaled(64) + Layout.preferredHeight: Metrics.scaled(64) + source: slot.modelData.iconUrl || "" + name: slot.modelData.name || "" + seed: slot.modelData.id || "" // The folders provider is built in and has no package icon. - symbol: row.modelData.id === "spool.local" ? "computer" : "" + symbol: slot.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 - } + Item { + Layout.fillWidth: true + } + BusySpinner { + Layout.preferredWidth: Metrics.scaled(26) + Layout.preferredHeight: Metrics.scaled(26) + Layout.alignment: Qt.AlignTop + running: slot.serviceState === "busy" + visible: running + } + MaterialIcon { + Layout.alignment: Qt.AlignTop + visible: root.accountCount(slot.modelData) > 0 && slot.serviceState === "ready" + name: "check_circle" + iconSize: Metrics.scaled(24) + iconColor: Theme.accent } } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(4) AppText { Layout.fillWidth: true - text: row.isLink ? "Add from a link" : row.modelData.name || "" + Layout.topMargin: Metrics.scaled(4) + text: slot.modelData.name || "" font.pixelSize: Metrics.scaled(24) font.weight: Font.DemiBold elide: Text.ElideRight @@ -412,101 +396,108 @@ FocusScope { 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 + Layout.fillHeight: true + text: root.detailText(slot.modelData) + color: slot.serviceState === "unavailable" ? Theme.textMuted : Theme.textSecondary font.pixelSize: Metrics.scaled(17) + wrapMode: Text.WordWrap elide: Text.ElideRight - maximumLineCount: 1 + maximumLineCount: 2 + verticalAlignment: Text.AlignTop } - } - - // 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 + // Not made by Spool: said once, plainly, without + // turning the choice into a lecture. AppText { - id: chipText - anchors.centerIn: parent - text: ({ - "get": "Install", - "update": "Update", - "installed": "Add account", - "link": "Paste link" - })[row.state] || "" - font.pixelSize: Metrics.scaled(17) + visible: slot.modelData.community === true + text: "Community" + color: Theme.textMuted + font.pixelSize: Metrics.scaled(14) font.weight: Font.DemiBold + font.letterSpacing: Metrics.scaled(1) } } - 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) + HoverHandler { + id: hover } - 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) + TapHandler { + acceptedButtons: Qt.LeftButton | Qt.RightButton + onTapped: (point, button) => { + tiles.currentIndex = slot.index + root.focusArea = "grid" + if (button === Qt.RightButton) + root.openMenu(slot.index) + else + root.activateEntry(slot.index) } + onLongPressed: root.openMenu(slot.index) } } + } + } - HoverHandler { - id: hover - enabled: !(row.isLink && root.linkEditing) - } + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Metrics.scaled(12) + visible: root.pendingSetup === "*" || root.problemText.length > 0 + spacing: Metrics.scaled(10) + BusySpinner { + Layout.preferredWidth: Metrics.scaled(20) + Layout.preferredHeight: Metrics.scaled(20) + running: root.pendingSetup === "*" + visible: running + } + AppText { + text: root.pendingSetup === "*" ? "Getting the provider from that link…" : root.problemText + color: root.pendingSetup === "*" ? Theme.textSecondary : Theme.errorText + font.pixelSize: Metrics.bodySizePx + wrapMode: Text.Wrap + } + } - 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) + ActionButton { + id: linkButton + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Metrics.scaled(18) + visible: root.linkAvailable && !root.linkEditing + kind: "flat" + iconName: "link" + text: "Have a link to a provider?" + onClicked: root.openLink() + } + + ColumnLayout { + Layout.fillWidth: true + Layout.maximumWidth: Metrics.scaled(720) + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Metrics.scaled(18) + visible: 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.closeLink() + } + ActionButton { + text: "Add" + kind: "primary" + enabled: linkField.text.trim().length > 0 + onClicked: root.submitLink(linkField.text) } } } @@ -519,9 +510,9 @@ FocusScope { active: false sourceComponent: OptionPickerDialog { visible: true - anchorItem: list.currentItem + anchorItem: tiles.currentItem title: root.menuEntry ? root.menuEntry.name : "" - options: root.menuActions.map(action => action.label) + options: ["Add an account", "Remove from this device"] onSelected: index => root.choose(index) onDismissed: root.closeMenu() } @@ -532,19 +523,18 @@ FocusScope { 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" + title: "Remove " + root.removingEntry.name + "?" + message: "Its accounts are signed out on this device. Nothing on your server is deleted, and you can add it again any time." + confirmText: "Remove" destructive: true onAccepted: { Store.uninstall(root.removingEntry.id) root.removingEntry = null - InputKeys.focus(list) + InputKeys.focus(tiles) } onDismissed: { root.removingEntry = null - InputKeys.focus(list) + InputKeys.focus(tiles) } } } diff --git a/qml/pages/HomePage.qml b/qml/pages/HomePage.qml index 58398c63..51d784cb 100644 --- a/qml/pages/HomePage.qml +++ b/qml/pages/HomePage.qml @@ -12,19 +12,26 @@ FocusScope { property var shell property var uiTransitionToken: 0 + readonly property bool directionRelease: rows.directionRelease // An all-hidden library row has management controls instead of cards. readonly property bool contentReady: rows.firstRowReady || !Providers.hasAccounts || (homeLibraries.length === 0 && !Home.loading) property bool providerChooserOpen: false + readonly property bool modalVisible: providerChooserOpen property int libraryRevision: 0 - readonly property var providerChoices: Home.providerChoices - readonly property var selectedProvider: providerChoices.find(choice => String(choice.id) === Home.providerId) || { - name: "All providers" - } + // What the provider button says Home is showing right now. + readonly property string providerSummary: { + const choices = Home.providerChoices + const shown = choices.filter(choice => choice.shown) + if (!Home.providerFilterActive || shown.length === choices.length) + return "All providers" + return shown.length === 1 ? String(shown[0].name) : shown.length + " of " + choices.length + " providers" + } readonly property var homeLibraries: { // Read the signals' properties so scoped identity/visibility changes // rebuild the array without mutating the global Libraries model. - const providerId = Home.providerId + const hiddenProviders = Home.hiddenProviderIds + const filterActive = Home.providerFilterActive const revision = libraryRevision const accounts = Providers.accounts const hidden = Libraries.hiddenLibraries @@ -37,7 +44,8 @@ FocusScope { return result } readonly property var homeHiddenLibraries: { - const providerId = Home.providerId + const hiddenProviders = Home.hiddenProviderIds + const filterActive = Home.providerFilterActive const accounts = Providers.accounts return Libraries.hiddenLibraries.filter(library => Home.includesItem(String(library.libraryId || ""))) } @@ -62,41 +70,8 @@ FocusScope { InputKeys.focus(providerButton) } - function chooseProvider(index) { - const choice = providerChoices[index] - if (!choice) - return - closeProviderChooser() - Home.selectProvider(String(choice.id || "")) - } - function openProviderChooser() { providerChooserOpen = true - providerGrid.currentIndex = Math.max(0, providerChoices.findIndex(choice => String(choice.id) - === Home.providerId)) - Qt.callLater(() => InputKeys.focus(providerGrid)) - } - - function recoverProviderFocus() { - const candidate = InputKeys.topLeftVisibleCandidate(providerGrid, providerGrid) - // Ask the same viewport helper about the selected delegate as well; - // do not give this chooser a separate visibility rule. - const selected = InputKeys.topLeftVisibleCandidate({ - count: providerGrid.currentItem ? 1 : 0, - width: providerGrid.width, - height: providerGrid.height, - itemAtIndex: index => providerGrid.currentItem, - mapToItem: (clip, x, y, width, height) - => providerGrid.mapToItem(clip, x, y, width, - height) - }, providerGrid) - const usable = providerGrid.activeFocus && selected && (selected.fullyVisible || selected.visibleFraction - >= InputKeys.focusRecoveryVisibleThreshold) - if (usable) - return false - if (candidate) - InputKeys.focusIndexWithoutScrolling(providerGrid, candidate.index) - return true } focus: true @@ -142,14 +117,14 @@ FocusScope { "title": "Continue Watching", "model": Home.resumeItems, "kind": "landscape", - "reserveWhenEmpty": Home.loading + "reserveWhileLoading": true }, { "key": "nextUpItems", "title": "Next Up", "model": Home.nextUpItems, "kind": "landscape", - "reserveWhenEmpty": Home.loading && (Home.latestLibraryRows || []).some(row => row.collectionType - === "tvshows") + "reserveWhileLoading": (Home.latestLibraryRows || []).some(row => row.collectionType + === "tvshows") }) const latest = Home.latestLibraryRows || [] for (let index = 0; index < latest.length; ++index) { @@ -161,7 +136,7 @@ FocusScope { "model": row && row.model ? row.model : null, "kind": row && row.kind ? row.kind : "poster", "enabled": !Libraries.isHidden(String(row && row.libraryId || "")), - "reserveWhenEmpty": Home.loading, + "reserveWhileLoading": true, "headerBadge": multiple && row ? root.sourceBadge(row.libraryId, false) : null, "useSeriesPoster": true, "preferEpisodeTitle": true, @@ -202,19 +177,7 @@ FocusScope { if (providerChooserOpen) { if (InputKeys.isBack(key, false, false)) return false - if (chooserCancelButton.activeFocus) { - if (phase === "press" && key === Qt.Key_Up) - InputKeys.focus(providerGrid) - return InputKeys.isDirection(key) || InputKeys.isAccept(key) - } - if (phase === "press" && (InputKeys.isDirection(key) || InputKeys.isAccept(key)) && recoverProviderFocus()) - return true - if (phase === "press" && !repeat && key === Qt.Key_Down && providerGrid.currentIndex - + providerGrid.columnCount() >= providerGrid.count) { - InputKeys.focus(chooserCancelButton) - return true - } - return providerGrid.routeKey(key, phase, repeat) || InputKeys.isAccept(key) + return providerPicker.item ? providerPicker.item.routeKey(key, phase, repeat) : true } if (providerButton.activeFocus || allProvidersButton.activeFocus) { if (phase === "press" && key === Qt.Key_Down) @@ -243,12 +206,8 @@ FocusScope { function activate() { if (providerChooserOpen) { - if (chooserCancelButton.activeFocus) { - closeProviderChooser() - return - } - if (!recoverProviderFocus()) - providerGrid.activate() + if (providerPicker.item) + providerPicker.item.activate() return } if (providerButton.activeFocus) { @@ -256,7 +215,7 @@ FocusScope { return } if (allProvidersButton.activeFocus) { - Home.selectProvider("") + Home.showAllProviders() return } if (!rows.activeFocus) { @@ -320,17 +279,17 @@ FocusScope { ActionButton { id: providerButton objectName: "homeProviderChooserButton" - text: String(root.selectedProvider.name) + (root.selectedProvider.version ? " · " - + root.selectedProvider.version : - "") + " ▾" - Accessible.description: "Choose providers for Home only. Search and playback remain unchanged." + iconName: "filter_list" + text: root.providerSummary + Accessible.name: "Providers on Home: " + root.providerSummary + Accessible.description: "Turn providers on or off for Home only. Search and playback remain unchanged." onClicked: root.openProviderChooser() } ActionButton { id: allProvidersButton visible: Home.providerScopeMessage.length > 0 - text: "All providers" - onClicked: Home.selectProvider("") + text: "Show all" + onClicked: Home.showAllProviders() } } SecondaryText { @@ -356,6 +315,7 @@ FocusScope { anchors.topMargin: Metrics.scaled(8) shell: root.shell sections: root.buildSections() + loading: Home.loading contextReturnRoute: "home" measureFirstRow: true focus: true @@ -366,74 +326,14 @@ FocusScope { InputLatency.mark(root.uiTransitionToken, "first_delegate") } - FocusScope { - id: providerChooser + Loader { + id: providerPicker anchors.fill: parent - visible: root.providerChooserOpen + active: root.providerChooserOpen z: 200 - MouseArea { - anchors.fill: parent - onClicked: root.closeProviderChooser() - } - Surface { - anchors.centerIn: parent - width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(760)) - height: Math.min(parent.height - Metrics.pageMarginPx * 2, Metrics.scaled(560)) - MouseArea { - anchors.fill: parent - } - ColumnLayout { - anchors.fill: parent - anchors.margins: Metrics.scaled(20) - spacing: Metrics.scaled(12) - AppText { - text: "Providers on Home" - font.pixelSize: Metrics.titleSizePx - } - SecondaryText { - text: "Only Home is filtered. Search, profiles and playback are unchanged." - Layout.fillWidth: true - wrapMode: Text.WordWrap - } - NavGrid { - id: providerGrid - objectName: "homeProviderChooserGrid" - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - shell: root.shell - model: root.providerChoices - cellWidth: Metrics.scaled(172) - cellHeight: Metrics.scaled(204) - onAccepted: index => root.chooseProvider(index) - delegate: ProfileTile { - required property int index - required property var modelData - tileSize: Metrics.scaled(152) - username: String(modelData.name) - detail: String(modelData.id) === Home.providerId ? "Selected for Home" : "" - providerName: String(modelData.name) - providerId: String(modelData.id || "") - providerIcon: modelData.iconUrl || "" - providerVersion: String(modelData.version || "") - focused: providerGrid.activeFocus && providerGrid.currentIndex === index - Accessible.description: String(modelData.name) + (modelData.version - ? ", installed provider version " - + modelData.version : "") - onAccepted: { - providerGrid.currentIndex = index - InputKeys.focus(providerGrid) - root.chooseProvider(index) - } - } - } - ActionButton { - id: chooserCancelButton - text: "Cancel" - Layout.alignment: Qt.AlignRight - onClicked: root.closeProviderChooser() - } - } + sourceComponent: HomeProviderPicker { + anchorItem: providerButton + onDismissed: root.closeProviderChooser() } } } diff --git a/qml/pages/HomeProviderPicker.qml b/qml/pages/HomeProviderPicker.qml new file mode 100644 index 00000000..34fefc75 --- /dev/null +++ b/qml/pages/HomeProviderPicker.qml @@ -0,0 +1,308 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +// Which providers fill Home, as a multi-select popover beside whatever opened +// it. Every provider is a switch and the popover stays open while they are +// flipped. Home needs one provider that can fill it, so the last such switch +// is held on and says why rather than silently refusing. +FocusScope { + id: root + + property Item anchorItem: null + property bool placementReady: false + readonly property var choices: Home.providerChoices + readonly property var entries: [ + { + "all": true + } + ].concat(choices) + readonly property bool allShown: Home.hiddenProviderIds.length === 0 + readonly property real edgeMargin: Metrics.scaled(12) + readonly property real rowHeight: Math.max(Metrics.touchTargetPx, Metrics.scaled(64)) + readonly property real panelPadding: Metrics.scaled(8) + readonly property real panelWidth: Math.min(width - edgeMargin * 2, Metrics.scaled(520)) + readonly property real panelHeight: Math.min(height - edgeMargin * 2, header.implicitHeight + entries.length + * rowHeight + panelPadding * 3) + + signal dismissed + + anchors.fill: parent + focus: true + z: 200 + + function detail(entry) { + if (entry.all) + return allShown ? "Every provider is on" : "Turn every provider back on" + if (entry.required) + return "Home needs at least one provider" + const profiles = Number(entry.profiles || 0) + if (profiles <= 0) + return "No signed-in profile" + return profiles === 1 ? "1 profile" : profiles + " profiles" + } + + function choose(index) { + const entry = entries[index] + if (!entry) + return + if (entry.all) + Home.showAllProviders() + else if (!entry.required) + Home.setProviderShown(String(entry.id), !entry.shown) + } + + function present() { + placementReady = false + if (!visible || width <= 0 || height <= 0) + return + Qt.callLater(function () { + if (!root.anchorItem) { + panel.x = (root.width - root.panelWidth) / 2 + panel.y = (root.height - root.panelHeight) / 2 + } else { + const anchor = root.anchorItem.mapToItem(root, 0, 0) + const gap = Metrics.scaled(6) + const below = anchor.y + root.anchorItem.height + gap + const above = anchor.y - root.panelHeight - gap + const fitsBelow = below + root.panelHeight <= root.height - root.edgeMargin + panel.x = Math.max(root.edgeMargin, Math.min(root.width - root.panelWidth - root.edgeMargin, anchor.x + + root.anchorItem.width - root.panelWidth)) + panel.y = Math.max(root.edgeMargin, Math.min(root.height - root.panelHeight - root.edgeMargin, fitsBelow + || above < root.edgeMargin ? below : above)) + } + root.placementReady = true + list.currentIndex = Math.max(0, list.currentIndex) + InputKeys.focus(list) + }) + } + + function routeKey(key, phase, repeat) { + if (InputKeys.isBack(key, false, false)) { + if (phase === "press" && !repeat) + dismissed() + return phase === "press" + } + if (InputKeys.isDirection(key)) { + if (phase === "press" && key === Qt.Key_Up) + list.moveSelection(-1) + else if (phase === "press" && key === Qt.Key_Down) + list.moveSelection(1) + return true + } + return InputKeys.isAccept(key) + } + + function activate() { + list.activate() + } + + function back() { + dismissed() + return true + } + + Component.onCompleted: present() + onVisibleChanged: present() + onWidthChanged: present() + onHeightChanged: present() + + MouseArea { + anchors.fill: parent + enabled: root.placementReady + onClicked: root.dismissed() + } + + // A cheap lift off the page; a blurred shadow costs too much on a TV. + Rectangle { + x: panel.x - Metrics.scaled(2) + y: panel.y + Metrics.scaled(6) + width: panel.width + Metrics.scaled(4) + height: panel.height + radius: panel.radius + Metrics.scaled(2) + color: "#66000000" + visible: panel.visible + opacity: panel.opacity + } + + PopupMenuPanel { + id: panel + objectName: "homeProviderPicker" + width: root.panelWidth + open: root.visible && root.placementReady + openHeight: root.panelHeight + baseColor: Theme.floatingPanel + border.color: Theme.borderStrong + opacity: open ? 1 : 0 + + Behavior on opacity { + enabled: !Theme.reducedMotion + NumberAnimation { + duration: 110 + easing.type: Easing.OutCubic + } + } + + ColumnLayout { + id: header + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: Metrics.scaled(18) + anchors.rightMargin: Metrics.scaled(18) + anchors.topMargin: Metrics.scaled(16) + spacing: Metrics.scaled(4) + + AppText { + Layout.fillWidth: true + text: "Providers on Home" + font.pixelSize: Metrics.bodySizePx + Metrics.scaled(2) + font.weight: Font.DemiBold + Accessible.role: Accessible.Heading + Accessible.name: text + } + SecondaryText { + Layout.fillWidth: true + Layout.bottomMargin: Metrics.scaled(6) + text: "Search and playback still use every provider." + color: Theme.textMuted + wrapMode: Text.WordWrap + } + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.border + } + } + + MenuListView { + id: list + objectName: "homeProviderPickerList" + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.bottom: parent.bottom + anchors.margins: root.panelPadding + // A count, not the array: rows then outlive a toggle, so the + // switch animates and the selection stays where it was. + model: root.entries.length + dismissOnHorizontal: false + entryProvider: index => root.entries[index] + onAccepted: index => root.choose(index) + onDismissed: root.dismissed() + + delegate: Item { + id: row + + required property int index + readonly property var modelData: root.entries[index] || ({}) + readonly property bool highlighted: list.activeFocus && list.currentIndex === index + readonly property bool on: modelData.all ? root.allShown : Boolean(modelData.shown) + + width: list.width + height: root.rowHeight + Accessible.role: modelData.all ? Accessible.Button : Accessible.CheckBox + Accessible.name: modelData.all ? "All providers" : String(modelData.name) + Accessible.description: root.detail(modelData) + Accessible.checkable: !modelData.all + Accessible.checked: on + Accessible.onPressAction: root.choose(index) + Accessible.onToggleAction: root.choose(index) + + Rectangle { + anchors.fill: parent + anchors.topMargin: Metrics.scaled(2) + anchors.bottomMargin: Metrics.scaled(2) + radius: Theme.radiusLarge + color: row.highlighted ? Theme.focusedFill : rowHover.hovered ? Theme.bgHover : "transparent" + border.width: row.highlighted ? Theme.focusBorderWidth : 0 + border.color: Theme.accent + antialiasing: true + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.scaled(12) + anchors.rightMargin: Metrics.scaled(14) + spacing: Metrics.scaled(14) + + Rectangle { + visible: row.modelData.all + Layout.preferredWidth: Metrics.scaled(36) + Layout.preferredHeight: Metrics.scaled(36) + radius: Math.round(width * 0.22) + color: Theme.accentPanel + MaterialIcon { + anchors.centerIn: parent + name: "apps" + iconSize: Metrics.scaled(22) + iconColor: Theme.accent + } + } + ProviderIcon { + visible: !row.modelData.all + Layout.preferredWidth: Metrics.scaled(36) + Layout.preferredHeight: Metrics.scaled(36) + source: row.modelData.iconUrl || "" + name: String(row.modelData.name || "") + seed: String(row.modelData.id || "") + opacity: row.on ? 1 : 0.55 + } + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.scaled(2) + AppText { + Layout.fillWidth: true + text: row.modelData.all ? "All providers" : String(row.modelData.name) + color: row.on || row.highlighted ? Theme.textPrimary : Theme.textSecondary + font.pixelSize: Metrics.bodySizePx + font.weight: Font.Medium + maximumLineCount: 1 + elide: Text.ElideRight + } + SecondaryText { + Layout.fillWidth: true + text: root.detail(row.modelData) + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + maximumLineCount: 1 + elide: Text.ElideRight + } + } + MaterialIcon { + visible: row.modelData.all + Layout.preferredWidth: Metrics.scaled(30) + name: "check" + iconSize: Metrics.scaled(24) + iconColor: Theme.accent + opacity: root.allShown ? 1 : 0 + } + SwitchIndicator { + visible: !row.modelData.all + Layout.alignment: Qt.AlignVCenter + checked: row.on + focused: row.highlighted + animate: true + opacity: row.modelData.required ? 0.6 : 1 + } + } + + HoverHandler { + id: rowHover + onHoveredChanged: if (hovered) + list.currentIndex = row.index + } + TapHandler { + gesturePolicy: TapHandler.DragThreshold + onTapped: { + list.currentIndex = row.index + root.choose(row.index) + } + } + } + } + } +} diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index a7f7da00..c36a174f 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -12,6 +12,8 @@ FocusScope { property var shell readonly property var contextRow: detailRowsLoader.item ? detailRowsLoader.item.contextRow : null readonly property var peopleRow: detailRowsLoader.item ? detailRowsLoader.item.peopleRow : null + readonly property var trailersRow: detailRowsLoader.item ? detailRowsLoader.item.trailersRow : null + readonly property var extrasRow: detailRowsLoader.item ? detailRowsLoader.item.extrasRow : null readonly property var similarRow: detailRowsLoader.item ? detailRowsLoader.item.similarRow : null readonly property var seasonPickerList: seasonPickerPanel.menuList readonly property var routeContext: RoutePolicy.detailsContext(shell ? shell.routeArgs : ({}), Browse.items) @@ -28,9 +30,29 @@ FocusScope { || item.title || item.seriesName || "Selected item") - readonly property string seriesTitle: item.seriesName || (typeText === "Series" ? titleText : "") + // The route's copy can know the series before the full details do; a + // line that is on screen must not vanish while they load. + readonly property string seriesTitle: item.seriesName || routeItem.seriesName || (typeText === "Series" ? titleText : + "") readonly property string seasonTitleText: seasonTitle() - readonly property string seriesIdText: item.seriesId || "" + readonly property string seriesIdText: item.seriesId || routeItem.seriesId || "" + readonly property string overviewText: item.overview || routeItem.overview || "" + // First load of an item with nothing cached: hold the space its overview + // and credits will take, so they fill in rather than push the page. + readonly property bool detailsPending: Content.detailItemBusy && !fullDetailItem.movieId + readonly property bool detailsSettled: !Content.detailItemBusy && Boolean(fullDetailItem.movieId) + // How much space to hold comes from the last page of the same account + // and type to finish loading: servers differ (Plex seasons carry no + // summary, Jellyfin films list four kinds of credit), and a guess that + // is wrong either way moves the page twice instead of not at all. + readonly property string layoutKey: String(routeItem.movieId || "").split(":")[0] + ":" + typeText + readonly property var expectedLayout: (shell && shell.detailLayoutMemory[layoutKey]) || defaultLayout(typeText) + // From the data, never from the slots' children: an Item that starts out + // invisible makes its children report invisible too, so a slot sized by + // them could never grow past zero. + readonly property bool overviewPlaceholderShown: detailsPending && expectedLayout.overview && overviewText.length + === 0 + readonly property bool creditsPlaceholderShown: detailsPending && expectedLayout.credits > 0 readonly property string seasonIdText: typeText === "Season" ? String(item.movieId || "") : String(item.seasonId || "") @@ -64,7 +86,7 @@ FocusScope { && seriesIdText.length > 0 && seasonIdText.length > 0 readonly property bool canPlayAlbum: albumDetail && contextCount > 0 - readonly property bool showPrimaryAction: canPlayEpisodicContainer || canPlayAlbum || (selectedIndex >= 0 + readonly property bool showPrimaryAction: canPlayEpisodicContainer || canPlayAlbum || (Boolean(item.movieId) && canPlay) readonly property bool hasProgress: Number(item.resumeTicks || 0) > 0 && Number(item.runtimeTicks || 0) > 0 @@ -90,7 +112,8 @@ FocusScope { if (!fullDetailItem.movieId || !mediaInfoAvailable) return null const smart = String(Settings.values["audio/trackMode"] || "Default") === "Smart" - return Content.detailMediaInfo(smart ? String(Settings.values["subtitles/language"] || "") : "") + const language = Settings.values["audio/language"] || Settings.values["subtitles/language"] || "" + return Content.detailMediaInfo(smart ? String(language) : "") } readonly property real copyWidth: showSideArt ? Math.min(width * 0.56, Metrics.scaled(940)) : width - contentMargin * 2 @@ -98,6 +121,8 @@ FocusScope { readonly property int contextCount: Content.detailSeasons ? Content.detailSeasons.count : 0 readonly property int seasonOptionCount: Content.detailSeasonOptions ? Content.detailSeasonOptions.count : 0 readonly property int similarCount: Content.detailSimilarItems ? Content.detailSimilarItems.count : 0 + readonly property int trailerCount: Content.detailTrailers ? Content.detailTrailers.count : 0 + readonly property int extraCount: Content.detailExtras ? Content.detailExtras.count : 0 readonly property bool contextPosterCards: typeText === "Series" || typeText === "BoxSet" readonly property bool contextItemsPossible: albumDetail || contextPosterCards || ((typeText === "Episode" || typeText === "Season") @@ -112,16 +137,17 @@ FocusScope { readonly property var metadataPeople: fullDetailItem.people && fullDetailItem.people.length > 0 ? fullDetailItem.people : (item.people || []) readonly property var people: metadataPeople - readonly property bool reservePeopleRow: loadingDetailRows && !fullDetailItem.movieId && (typeText === "Movie" - || typeText === "Series" - || typeText === "Episode" - || typeText === "Season") + // Seasons rarely carry their own cast, so reserving a row for one only + // drew a heading that then vanished. + readonly property bool reservePeopleRow: detailsPending && expectedLayout.people readonly property bool showPeopleRow: people.length > 0 || reservePeopleRow readonly property var genreList: fullDetailItem.genres && fullDetailItem.genres.length > 0 ? fullDetailItem.genres : (item.genres || []) readonly property var studioList: fullDetailItem.studios && fullDetailItem.studios.length > 0 ? fullDetailItem.studios : (item.studios || []) - readonly property var metadataRows: buildMetadataRows() + // Partial genres and studios from the route copy would be reshuffled when + // the credits arrive above them; the placeholder holds the space instead. + readonly property var metadataRows: detailsPending ? [] : buildMetadataRows() readonly property bool showMetadataPanel: metadataRows.length > 0 readonly property bool showSeriesLink: (typeText === "Episode" || typeText === "Season") && seriesIdText.length > 0 && seriesTitle.length > 0 @@ -171,7 +197,7 @@ FocusScope { root.focusOverflow(root.overflowIndex) }) } - function onExtensionSupportChanged(accountId) { + function onCapabilitySupportChanged(accountId) { if (root.overflowOpen) root.loadProviderActions() } @@ -197,7 +223,10 @@ FocusScope { property bool enabledButton: true signal activated - width: primary ? Math.min(Math.max(implicitWidth, 190), 320) : Math.min(Math.max(implicitWidth, 132), 230) + width: Math.min(actionRow.width, primary ? Math.min(Math.max(implicitWidth, Metrics.scaled(190)), Metrics.scaled( + 320)) : Math.min(Math.max(implicitWidth, Metrics.scaled( + 132)), Metrics.scaled( + 230))) text: label kind: primary ? "primary" : "secondary" enabled: enabledButton @@ -236,23 +265,27 @@ FocusScope { signal activated visible: label.length > 0 - implicitWidth: linkColumn.implicitWidth + implicitWidth: linkText.implicitWidth + linkIcon.implicitWidth + linkRow.spacing implicitHeight: linkColumn.implicitHeight + Layout.maximumWidth: root.copyWidth focus: true Column { id: linkColumn anchors.left: parent.left + anchors.right: parent.right anchors.top: parent.top spacing: 2 Row { id: linkRow + width: parent.width spacing: 8 AppText { id: linkText anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, linkRow.width - linkIcon.width - linkRow.spacing) text: link.label color: link.activeFocus || hover.hovered ? Theme.textPrimary : Theme.textSecondary font.pixelSize: root.detailTitlePx @@ -262,6 +295,7 @@ FocusScope { } MaterialIcon { + id: linkIcon anchors.verticalCenter: linkText.verticalCenter name: link.dropdown ? "expand_more" : "chevron_right" iconSize: Math.max(20, Math.round(root.detailTitlePx * 0.45)) @@ -410,7 +444,7 @@ FocusScope { width: parent ? parent.width : implicitWidth height: implicitHeight - implicitHeight: visible ? block.implicitHeight : 0 + implicitHeight: hasRows ? block.implicitHeight : 0 visible: hasRows focus: true onRowsChanged: normalizeSelection() @@ -495,6 +529,68 @@ FocusScope { scheduleActiveRouteRefresh() } + onVisibleChanged: { + // The player's browse sheet reuses Content's detail rows while this + // resident page is hidden. Reclaim them when the page returns. + loadedDetailKey = "" + if (visible) + scheduleActiveRouteRefresh() + } + + function defaultLayout(type) { + // Seasons on every server tried carried no summary or credits of their own. + switch (type) { + case "Movie": + return { + "overview": true, + "credits": 4, + "people": true + } + case "Series": + return { + "overview": true, + "credits": 3, + "people": true + } + case "Episode": + return { + "overview": true, + "credits": 2, + "people": true + } + default: + return { + "overview": false, + "credits": 0, + "people": false + } + } + } + + onDetailsSettledChanged: if (detailsSettled) + Qt.callLater(rememberLayout) + function rememberLayout() { + if (!shell || !detailsSettled) + return + // Credits vary from item to item. Holding too much leaves room in the + // panel, too little moves the page, so lean on the larger recent count + // and let it shrink one row per load. + const credits = compactEpisodicDetail ? Math.min(2, metadataRows.length) : metadataRows.length + const previous = shell.detailLayoutMemory[layoutKey] + shell.rememberDetailLayout(layoutKey, { + "overview": overviewText.length > 0, + "credits": Math.max(credits, previous ? previous.credits - 1 : 0), + "people": people.length > 0 + }) + } + + onFullDetailItemChanged: { + if (fullDetailItem.movieId) { + syncUserState() + Qt.callLater(root.refreshDetailRows) + } + } + onRouteActiveChanged: { if (routeActive) enterRoute(false) @@ -513,10 +609,45 @@ FocusScope { } } function currentMediaItem() { + if (focusZone === "trailers" && trailersRow && trailersRow.currentIndex >= 0) + return Content.detailTrailers.get(trailersRow.currentIndex) + if (focusZone === "extras" && extrasRow && extrasRow.currentIndex >= 0) + return Content.detailExtras.get(extrasRow.currentIndex) return item } + // Every navigation that lands here is a fresh entry, even to the item + // already shown: playback hands back the page it started from, and focus + // belongs on its primary action again. Deferred so the route's bindings + // have settled first. + Connections { + target: Router + function onRouteChanged() { + Qt.callLater(function () { + if (!root.routeActive) + return + root.seasonPickerOpen = false + root.overflowOpen = false + root.enterRoute(true) + }) + } + } + + // The route's copy of the item is rebuilt whenever its source row + // changes, often with the same item in it. Only a different item resets + // focus and refetches; anything else would blank the page mid-load. + property string enteredItemId: "" + // Space a first load reserved for this item's overview and credits; the + // slots keep it so a smaller answer leaves room rather than a jump. + property real heldOverviewHeight: 0 + property real heldCreditsHeight: 0 onRouteItemChanged: { + const id = String(routeItem.movieId || "") + if (id === enteredItemId) + return + enteredItemId = id + heldOverviewHeight = 0 + heldCreditsHeight = 0 seasonPickerOpen = false overflowOpen = false if (routeActive) @@ -530,12 +661,12 @@ FocusScope { } function scheduleActiveRouteRefresh() { - if (!routeActive || routeRefreshScheduled) + if (!routeActive || !visible || routeRefreshScheduled) return routeRefreshScheduled = true Qt.callLater(function () { root.routeRefreshScheduled = false - if (!root.routeActive) + if (!root.routeActive || !root.visible) return root.refreshDetailRows() root.refreshItemDetail() @@ -548,10 +679,6 @@ FocusScope { root.updateDetailCounts() root.rebuildSeasonEntries() } - function onDetailItemChanged() { - if (root.fullDetailItem.movieId) - root.syncUserState() - } } Connections { @@ -627,6 +754,18 @@ FocusScope { if (similarRow) similarRow.currentIndex = similarCount > 0 ? Math.max(0, Math.min(similarRow.currentIndex, similarCount - 1)) : 0 + if (trailersRow) + trailersRow.currentIndex = trailerCount > 0 ? Math.max(0, Math.min(trailersRow.currentIndex, trailerCount + - 1)) : -1 + if (extrasRow) + extrasRow.currentIndex = extraCount > 0 ? Math.max(0, Math.min(extrasRow.currentIndex, extraCount - 1)) : -1 + if (routeActive && ((focusZone === "trailers" && trailerCount === 0) || (focusZone === "extras" && extraCount + === 0))) + focusDefaultAction() + // Position before the episodes are first drawn: a deferred call let + // one frame show the row from episode 1 and then jump to the one to + // play. The deferred call stays for a row not laid out yet. + positionContextRow() Qt.callLater(positionContextRow) } @@ -641,6 +780,8 @@ FocusScope { } function refreshDetailRows() { + if (!routeActive || !visible) + return const itemId = item.movieId || "" const key = itemId + ":" + typeText + ":" + seriesIdText + ":" + seasonIdText if (key === loadedDetailKey) @@ -651,6 +792,10 @@ FocusScope { contextRow.currentIndex = 0 if (similarRow) similarRow.currentIndex = 0 + if (trailersRow) + trailersRow.currentIndex = 0 + if (extrasRow) + extrasRow.currentIndex = 0 Content.loadDetailRows(itemId, typeText, seriesIdText, seasonIdText) } @@ -741,6 +886,10 @@ FocusScope { zones.push("context") if (showPeopleRow) zones.push("people") + if (trailerCount > 0) + zones.push("trailers") + if (extraCount > 0) + zones.push("extras") if (showSimilarRow) zones.push("similar") return zones @@ -755,12 +904,16 @@ FocusScope { return actionRow if (zone === "metadata") return metadataPanel - if (zone === "context" || zone === "people" || zone === "similar") + if (zone === "context" || zone === "people" || zone === "trailers" || zone === "extras" || zone === "similar") detailRowsLoader.forced = true if (zone === "context") return contextRow if (zone === "people") return peopleRow + if (zone === "trailers") + return trailersRow + if (zone === "extras") + return extrasRow return similarRow } @@ -777,6 +930,10 @@ FocusScope { contextRow.focusList() else if (zone === "people") peopleRow.focusList() + else if (zone === "trailers") + trailersRow.focusList() + else if (zone === "extras") + extrasRow.focusList() else if (zone === "similar") similarRow.focusList() else @@ -809,16 +966,9 @@ FocusScope { } function orderedActions() { - const actions = [] - if (showPrimaryAction) - actions.push(primaryAction) - if (showPrimaryAction && hasProgress) - actions.push(restartAction) - actions.push(playedAction) - actions.push(favoriteAction) - if (showContextPlaybackActions || mediaInfoAvailable || showLetterboxdAction || showExternalActions) - actions.push(menuAction) - return actions + return [primaryAction, restartAction, playedAction, favoriteAction, menuAction].filter(function (action) { + return action.visible + }) } function focusActionIndex(index) { @@ -828,6 +978,7 @@ FocusScope { focusZone = "actions" actionIndex = Math.max(0, Math.min(index, actions.length - 1)) InputKeys.focus(actions[actionIndex]) + InputKeys.positionChild(detailsFlick, actions[actionIndex]) return true } @@ -835,6 +986,31 @@ FocusScope { return focusActionIndex(actionIndex + delta) } + function focusActionVertically(delta) { + const actions = orderedActions() + const current = actions[actionIndex] + if (!current) + return false + let next = -1 + let nearestRow = Infinity + let nearestColumn = Infinity + const center = current.x + current.width / 2 + for (let index = 0; index < actions.length; ++index) { + const candidate = actions[index] + const rowDistance = (candidate.y - current.y) * delta + if (rowDistance <= 1) + continue + const columnDistance = Math.abs(candidate.x + candidate.width / 2 - center) + if (rowDistance < nearestRow - 1 || (Math.abs(rowDistance - nearestRow) <= 1 && columnDistance + < nearestColumn)) { + next = index + nearestRow = rowDistance + nearestColumn = columnDistance + } + } + return next >= 0 && focusActionIndex(next) + } + function activatePrimary(fromStart) { if (App.busy) return @@ -847,9 +1023,16 @@ FocusScope { App.playEpisodicContainer(seriesId, typeText === "Season" ? seasonIdText : "") return } - if (selectedIndex < 0 || !canPlay) + const itemId = String(item.movieId || "") + if (itemId.length <= 0 || !canPlay) return - App.playFromModel(itemModel, selectedIndex, fromStart === true) + // The model can change after this page was opened. Resolve the ID + // again at activation, and fetch it directly if it has left the model. + const index = RoutePolicy.modelIndexForItemId(itemModel, itemId, selectedIndex) + if (index >= 0) + App.playFromModel(itemModel, index, fromStart === true) + else + App.playItemId(itemId, fromStart === true) } function playDetailContext(shuffled) { @@ -862,15 +1045,13 @@ FocusScope { function toggleFavorite() { if (!item.movieId) return - favoriteState = !favoriteState - ItemState.setFavorite(item.movieId, favoriteState) + ItemState.setFavorite(item.movieId, !favoriteState) } function togglePlayed() { if (!item.movieId) return - playedState = !playedState - ItemState.setPlayed(item.movieId, playedState) + ItemState.setPlayed(item.movieId, !playedState) } function overflowOptions() { @@ -964,7 +1145,10 @@ FocusScope { return if (typeText === "Series") { if (shell) - shell.openDetailsAt(Content.detailSeasons, index, "season", detailsReturnRoute) + shell.openDetailsAt(Content.detailSeasons, index, "season", detailsReturnRoute, { + "seriesId": String(item.movieId || ""), + "seriesName": titleText + }) return } if (albumDetail) { @@ -975,6 +1159,11 @@ FocusScope { shell.openDetailsAt(Content.detailSeasons, index, "context", detailsReturnRoute) } + function playRelatedMedia(model, index) { + if (!App.busy && model && index >= 0 && index < model.count) + App.playFromModel(model, index) + } + function openSimilarItem(index) { if (index >= 0 && String(Content.detailSimilarItems.get(index).itemType || "") === "Audio") { App.playFromModel(Content.detailSimilarItems, index) @@ -1031,6 +1220,8 @@ FocusScope { focusOverflow(0) return true } + if (focusZone === "actions" && focusActionVertically(key === Qt.Key_Down ? 1 : -1)) + return true return moveFocusZone(key === Qt.Key_Down ? 1 : -1) } if (focusZone === "actions") { @@ -1063,6 +1254,10 @@ FocusScope { contextRow.activate() } else if (focusZone === "people") { peopleRow.activate() + } else if (focusZone === "trailers") { + trailersRow.activate() + } else if (focusZone === "extras") { + extrasRow.activate() } else if (focusZone === "similar") { similarRow.activate() } else if (focusZone === "overflow") { @@ -1101,6 +1296,10 @@ FocusScope { return contextRow.longPress() if (focusZone === "similar") return similarRow.longPress() + if (focusZone === "trailers") + return trailersRow.longPress() + if (focusZone === "extras") + return extrasRow.longPress() return focusZone === "actions" && shell ? shell.openItemMenu(item, orderedActions()[actionIndex], { "deferBackdropDismissal": true, "containerId": actionContainerId, @@ -1276,13 +1475,30 @@ FocusScope { color: Theme.bg } + // The backdrop's address changes as the route copy gives way to the + // full item, and a fresh Image is blank until it decodes, so the art + // blinked off for a frame. The last ready backdrop stays up underneath + // until its replacement is ready. + Image { + id: heldBackdrop + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + visible: root.backgroundArt.length > 0 && backdrop.status !== Image.Ready + opacity: status === Image.Ready ? 0.34 : 0 + asynchronous: true + cache: true + } + Image { + id: backdrop anchors.fill: parent source: root.backgroundArt fillMode: Image.PreserveAspectCrop opacity: status === Image.Ready ? 0.34 : 0 asynchronous: true cache: true + onStatusChanged: if (status === Image.Ready) + heldBackdrop.source = source } Rectangle { @@ -1402,23 +1618,78 @@ FocusScope { metadata: root.metadataLine() } - AppText { + // The overview, or the space held for it. The slot keeps the + // tallest height it has held for this item, so a shorter or + // missing overview leaves room instead of pulling the + // actions up under the eye. + Item { + id: overviewSlot + readonly property real contentHeight: root.overviewText.length > 0 + ? overviewLabel.implicitHeight : + root.overviewPlaceholderShown + ? overviewProbe.implicitHeight : 0 Layout.fillWidth: true Layout.topMargin: root.compactEpisodicDetail ? 2 : 8 - visible: Boolean(root.item.overview && root.item.overview.length > 0) - text: root.item.overview || "" - color: Theme.textSecondary - wrapMode: Text.Wrap - font.pixelSize: Metrics.bodySizePx + 1 - lineHeight: 1.18 - maximumLineCount: root.compactEpisodicDetail ? (root.typeText === "Season" ? 3 : 4) : 5 - elide: Text.ElideRight + Layout.preferredHeight: Math.max(contentHeight, root.heldOverviewHeight) + visible: Layout.preferredHeight > 0 + onContentHeightChanged: if (root.overviewPlaceholderShown) + root.heldOverviewHeight = Math.max(root.heldOverviewHeight, + contentHeight) + + AppText { + id: overviewLabel + width: parent.width + visible: root.overviewText.length > 0 + text: root.overviewText + color: Theme.textSecondary + wrapMode: Text.Wrap + font.pixelSize: Metrics.bodySizePx + 1 + lineHeight: 1.18 + maximumLineCount: root.compactEpisodicDetail ? (root.typeText === "Season" ? 3 : 4) : 5 + elide: Text.ElideRight + } + + // Same face and line height as the overview, so the bars + // take exactly the lines they stand in for. + AppText { + id: overviewProbe + visible: false + text: root.compactEpisodicDetail ? "A\nA" : "A\nA\nA" + font.pixelSize: Metrics.bodySizePx + 1 + lineHeight: 1.18 + } + + Item { + id: overviewPlaceholder + width: parent.width + height: overviewProbe.implicitHeight + visible: root.overviewPlaceholderShown + Accessible.ignored: true + + Repeater { + model: root.compactEpisodicDetail ? 2 : 3 + delegate: Rectangle { + required property int index + readonly property real line: overviewPlaceholder.height / ( + root.compactEpisodicDetail ? 2 : 3) + y: index * line + line * 0.18 + width: overviewPlaceholder.width * (index === (root.compactEpisodicDetail ? 1 : 2) + ? 0.62 : 0.96) + height: line * 0.64 + radius: Theme.radiusSmall + color: Theme.bgHover + opacity: 0.8 + } + } + } } - Row { + Flow { id: actionRow + Layout.fillWidth: true + Layout.minimumWidth: 0 Layout.topMargin: root.compactEpisodicDetail ? 8 : 18 - spacing: 10 + spacing: Metrics.scaled(10) DetailAction { id: primaryAction @@ -1449,7 +1720,7 @@ FocusScope { iconName: root.playedState ? "visibility" : "visibility_off" label: root.playedState ? "Mark unwatched" : "Mark watched" checked: root.playedState - enabledButton: root.selectedIndex >= 0 + enabledButton: Boolean(root.item.movieId) onActivated: root.togglePlayed() } @@ -1458,7 +1729,7 @@ FocusScope { iconName: root.favoriteState ? "favorite" : "favorite_border" label: root.favoriteState ? "Remove favourite" : "Add favourite" checked: root.favoriteState - enabledButton: root.selectedIndex >= 0 + enabledButton: Boolean(root.item.movieId) onActivated: root.toggleFavorite() } @@ -1468,7 +1739,7 @@ FocusScope { label: "More" checked: root.overflowOpen visible: String(root.item.movieId || "").length > 0 - enabledButton: root.selectedIndex >= 0 + enabledButton: Boolean(root.item.movieId) onActivated: root.toggleOverflow() } } @@ -1481,14 +1752,72 @@ FocusScope { font.pixelSize: Metrics.metaSizePx } - MetadataPanel { - id: metadataPanel + // The credits panel, or the space held for it; like the + // overview slot it never gives back height it has held. + Item { + id: creditsSlot + readonly property real contentHeight: metadataPanel.hasRows ? metadataPanel.implicitHeight : + root.creditsPlaceholderShown + ? metadataPlaceholder.height : 0 Layout.fillWidth: true Layout.topMargin: root.compactEpisodicDetail ? 12 : 28 - rows: root.compactEpisodicDetail ? root.metadataRows.slice(0, 2) : root.metadataRows - onActivated: (kind, value) => root.activateMetadata(kind, value) - onLeaveUp: root.focusActionRow() - onLeaveDown: root.focusFirstMediaRow() + Layout.preferredHeight: Math.max(contentHeight, root.heldCreditsHeight) + visible: Layout.preferredHeight > 0 + onContentHeightChanged: if (root.creditsPlaceholderShown) + root.heldCreditsHeight = Math.max(root.heldCreditsHeight, + contentHeight) + + Surface { + id: metadataPlaceholder + width: parent.width + readonly property int rowCount: Math.max(1, root.expectedLayout.credits) + // Measured, not guessed: a few pixels per row adds up + // to a visible nudge when four credit rows arrive. + readonly property int rowHeight: chipProbe.implicitHeight + height: rowCount * rowHeight + (rowCount - 1) * 12 + 34 + visible: root.creditsPlaceholderShown + baseColor: Theme.floatingPanel + radius: Theme.radiusPanel + Accessible.ignored: true + + MetadataChip { + id: chipProbe + visible: false + text: "Ag" + } + + Column { + anchors.fill: parent + anchors.margins: 17 + spacing: 12 + Repeater { + model: metadataPlaceholder.rowCount + delegate: Row { + spacing: 16 + Rectangle { + width: 72 + height: metadataPlaceholder.rowHeight + radius: Theme.radiusSmall + color: Theme.bgHover + } + Rectangle { + width: Metrics.scaled(240) + height: metadataPlaceholder.rowHeight + radius: Theme.radiusSmall + color: Theme.bgHover + } + } + } + } + } + + MetadataPanel { + id: metadataPanel + rows: root.compactEpisodicDetail ? root.metadataRows.slice(0, 2) : root.metadataRows + onActivated: (kind, value) => root.activateMetadata(kind, value) + onLeaveUp: root.focusActionRow() + onLeaveDown: root.focusFirstMediaRow() + } } } @@ -1556,7 +1885,8 @@ FocusScope { property bool forced: false readonly property int estimatedRowHeight: Math.round(root.rowPosterWidth * 1.5) + Metrics.scaled(94) readonly property int estimatedRows: Number(root.showContextRow || root.reserveContextRow) + Number( - root.showPeopleRow) + Number(root.showSimilarRow) + root.showPeopleRow) + Number(root.showSimilarRow) + Number( + root.trailerCount > 0) + Number(root.extraCount > 0) active: forced || detailsFlick.contentY + detailsFlick.height * 1.5 >= y // zoneTarget() needs the rows to exist synchronously when it // forces them for focus; flipping asynchronous off completes @@ -1568,18 +1898,27 @@ FocusScope { id: detailRowsArea property alias contextRow: contextRowItem property alias peopleRow: peopleRowItem + property alias trailersRow: trailersRowItem + property alias extrasRow: extrasRowItem property alias similarRow: similarRowItem readonly property int rowSpacing: Metrics.sectionGapPx readonly property int visibleRowCount: (contextRowItem.visible ? 1 : 0) + (peopleRowItem.visible ? 1 : 0) + ( - similarRowItem.visible ? 1 : 0) + similarRowItem.visible ? 1 : 0) + ( + trailersRowItem.visible ? 1 : 0) + ( + extrasRowItem.visible ? 1 : 0) readonly property int rowsHeight: contextRowItem.height + peopleRowItem.height + + trailersRowItem.height + extrasRowItem.height + similarRowItem.height + Math.max(0, visibleRowCount - 1) * rowSpacing readonly property int contextY: 0 readonly property int peopleY: contextY + (contextRowItem.visible ? contextRowItem.height + rowSpacing : 0) - readonly property int similarY: peopleY + (peopleRowItem.visible ? peopleRowItem.height + rowSpacing : + readonly property int trailersY: peopleY + (peopleRowItem.visible ? peopleRowItem.height + rowSpacing : + 0) + readonly property int extrasY: trailersY + (trailersRowItem.visible ? trailersRowItem.height + rowSpacing : + 0) + readonly property int similarY: extrasY + (extrasRowItem.visible ? extrasRowItem.height + rowSpacing : 0) implicitHeight: rowsHeight + root.contentMargin @@ -1630,6 +1969,48 @@ FocusScope { onActivated: (index, person) => root.openPerson(person) } + MediaRow { + id: trailersRowItem + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root.contentMargin + anchors.rightMargin: root.contentMargin + y: detailRowsArea.trailersY + title: "Trailers" + model: Content.detailTrailers + shell: root.shell + cardKind: "landscape" + cardWidth: root.rowLandscapeWidth + cardGap: root.rowGap + enabledRow: root.trailerCount > 0 + onActiveFocusChanged: { + if (activeFocus) + root.focusZone = "trailers" + } + onActivated: index => root.playRelatedMedia(Content.detailTrailers, index) + } + + MediaRow { + id: extrasRowItem + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root.contentMargin + anchors.rightMargin: root.contentMargin + y: detailRowsArea.extrasY + title: "Extras" + model: Content.detailExtras + shell: root.shell + cardKind: "landscape" + cardWidth: root.rowLandscapeWidth + cardGap: root.rowGap + enabledRow: root.extraCount > 0 + onActiveFocusChanged: { + if (activeFocus) + root.focusZone = "extras" + } + onActivated: index => root.playRelatedMedia(Content.detailExtras, index) + } + MediaRow { id: similarRowItem anchors.left: parent.left @@ -1788,7 +2169,7 @@ FocusScope { checkedFor: function (entry) { return entry && String(entry.seasonId || "") === root.currentSeasonId } - onCurrentIndexChanged: root.seasonPickerIndex = currentIndex + onMoved: index => root.seasonPickerIndex = index onDismissed: root.closeSeasonPicker() onAccepted: entry => root.selectSeason(entry) } diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index 84ea25c1..67009a31 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -30,6 +30,7 @@ FocusScope { property var filterEntries: [] property var libraryEntries: [] property string typeAheadBuffer: "" + property bool typeAheadMissed: false readonly property bool directionRelease: true readonly property int alphabetFeedbackIndex: { if (grid.heldKey && grid.currentIndex >= 0) @@ -39,8 +40,36 @@ FocusScope { readonly property string currentAlphabetLabel: { if (!Browse.items || alphabetFeedbackIndex < 0 || alphabetFeedbackIndex >= Browse.items.count) return "#" + // By name the controller knows the letter even of rows not loaded yet. + if (Browse.alphabetical) + return Browse.alphabetRevision >= 0 ? Browse.labelAt(alphabetFeedbackIndex) : "#" return LibraryNavigation.sectionLabel(currentSortBy(), Browse.items.get(alphabetFeedbackIndex)) } + // The letter rail: for a name-sorted list long enough to need one, whose + // length is known (every row held, or a sparse listing of all of them). + // More than a couple of screens: worth telling where in it the view is. + readonly property bool longList: grid.contentHeight > grid.height * 2.5 + // Space for the rail is kept whenever one could appear, so it showing up + // never reflows the grid under the viewer. + readonly property bool alphabetRailReserved: Boolean(Browse.alphabetical && grid.count >= 60) + readonly property bool alphabetRail: alphabetRailReserved && Boolean(Browse.sparse || !Browse.hasMore) && longList + + readonly property var alphabetLetters: alphabetRail && Browse.alphabetRevision >= 0 ? Browse.alphabet : [] + // Moving faster than a few screens a second, by fling, wheel or scroll + // bar: nothing passing is seen long enough to be worth a request the + // server would only finish after the view has moved on. + property bool scrollingFast: false + property double lastScrollAt: 0 + property real lastScrollY: 0 + // Remote focus has moved off the grid onto the rail. + property bool railActive: false + property int railIndex: -1 + readonly property int viewLetterIndex: { + const label = currentAlphabetLabel + return alphabetLetters.findIndex(letter => letter.label === label) + } + onAlphabetRailChanged: if (!alphabetRail) + railActive = false readonly property var libraryList: libraryPanel.menuList readonly property var sortList: sortPanel.menuList readonly property var filterList: filterPanel.menuList @@ -76,10 +105,15 @@ FocusScope { property bool navigationFocusVisible: true property bool pointerNavigationPending: false property var pendingScrollController: null + property var recoveryGesture: ({ + "key": 0 + }) Component.onCompleted: if (activeFocus) InputKeys.focus(grid) onActiveFocusChanged: { clearPendingPointerNavigation() + if (!activeFocus) + recoveryGesture.key = 0 if (activeFocus) InputKeys.focus(grid) } @@ -153,6 +187,19 @@ FocusScope { return Libraries.rowCount() } + function checkpointRoute() { + if (!visible || !shell || shell.route !== "libraryGrid") + return + const libraryId = String(Browse.libraryId || "") + const routeLibraryId = String(shell.routeArgs.libraryId || "") + if (routeLibraryId.length > 0 && routeLibraryId !== libraryId) + return + Router.checkpoint({ + libraryId: libraryId, + focusIndex: Math.max(0, grid.currentIndex) + }) + } + function currentLibraryModelIndex() { const currentId = String(Browse.libraryId || "") const count = libraryCount() @@ -580,29 +627,14 @@ FocusScope { function selectTypeAheadMatch(query, includeCurrent) { const count = Browse.items ? Browse.items.count : 0 - if (count <= 0 || query.length <= 0) + const index = LibraryNavigation.typeAheadIndex(count, grid.currentIndex, query, includeCurrent, + root.typeAheadTitle) + if (index < 0) return false - const start = Math.max(0, grid.currentIndex) - const firstOffset = includeCurrent ? 0 : 1 - for (let offset = firstOffset; offset < firstOffset + count; ++offset) { - const index = (start + offset) % count - if (typeAheadTitle(index).indexOf(query) === 0) { - grid.currentIndex = index - grid.ensureCurrentVisible() - grid.requestMoreIfNeeded() - return true - } - } - for (let offset = firstOffset; offset < firstOffset + count; ++offset) { - const index = (start + offset) % count - if (typeAheadTitle(index).indexOf(query) >= 0) { - grid.currentIndex = index - grid.ensureCurrentVisible() - grid.requestMoreIfNeeded() - return true - } - } - return false + grid.currentIndex = index + grid.ensureCurrentVisible() + grid.requestMoreIfNeeded() + return true } function typeAhead(text) { @@ -612,18 +644,94 @@ FocusScope { const code = character.charCodeAt(0) if (code < 32 || code === 127 || character === "/" || (character === " " && typeAheadBuffer.length <= 0)) return false + railActive = false const previous = typeAheadBuffer const repeatedSingle = previous.length === 1 && previous === character + // Pressing one letter again walks through that letter's titles. + if (repeatedSingle && Browse.alphabetical && grid.currentIndex + 1 < grid.count && Browse.labelAt( + grid.currentIndex + 1).toLocaleLowerCase() === character) { + jumpToRow(grid.currentIndex + 1) + typeAheadReset.restart() + return true + } + // By name the list is a dictionary: each key refines the prefix and + // the controller finds it, loading only the rows it has to. + if (Browse.seekPrefix(previous + character)) { + typeAheadBuffer = previous + character + typeAheadMissed = false + typeAheadReset.restart() + return true + } const candidate = repeatedSingle ? character : previous + character typeAheadBuffer = candidate - if (!selectTypeAheadMatch(candidate, previous.length > 0 && !repeatedSingle) && candidate.length > 1) { - typeAheadBuffer = character - selectTypeAheadMatch(character, false) + typeAheadMissed = false + if (!selectTypeAheadMatch(candidate, previous.length > 0 && !repeatedSingle)) { + if (candidate.length > 1 && selectTypeAheadMatch(character, false)) + typeAheadBuffer = character + else + typeAheadMissed = true } + // A miss in a list not sorted by name can still be found by search. + typeAheadReset.interval = typeAheadMissed ? 4000 : 1250 typeAheadReset.restart() return true } + function searchTypedText() { + const text = typeAheadBuffer + typeAheadBuffer = "" + typeAheadMissed = false + typeAheadReset.stop() + if (!hasShell() || text.length <= 0) + return + Search.setQuery(text) + shell.pushRoute("search") + } + + function jumpToRow(row) { + if (grid.count <= 0) + return + const target = Math.max(0, Math.min(grid.count - 1, row)) + grid.currentIndex = target + grid.positionViewAtIndex(target, GridView.Beginning) + grid.requestMoreIfNeeded() + } + + function jumpToLetter(index) { + if (index < 0 || index >= alphabetLetters.length) + return + railIndex = index + jumpToRow(alphabetLetters[index].row) + } + + // Channel up/down, page up/down: the letter before or after this one. + function stepLetter(delta) { + if (!alphabetRail || alphabetLetters.length <= 0) + return false + const from = viewLetterIndex >= 0 ? viewLetterIndex : 0 + const next = Math.max(0, Math.min(alphabetLetters.length - 1, from + delta)) + // Already inside a letter, page up returns to its first title. + if (delta < 0 && grid.currentIndex > alphabetLetters[from].row) + jumpToLetter(from) + else + jumpToLetter(next) + return true + } + + function enterRail() { + if (!alphabetRail) + return false + railIndex = Math.max(0, viewLetterIndex) + railActive = true + return true + } + + function leaveRail() { + railActive = false + InputKeys.focus(grid) + grid.ensureCurrentVisible() + } + function activateLibraryIndex(index) { const entry = libraryEntries[index] if (!entry) @@ -679,6 +787,12 @@ FocusScope { filterEntries = buildFilterEntries() } + function clearFilters() { + savedIndex = 0 + gridReveal.reset() + Browse.clearFilters() + } + function focusToolbar() { InputKeys.focus(libraryButton) } @@ -731,11 +845,15 @@ FocusScope { 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"] || "") : "") + const language = Settings.values["audio/language"] || Settings.values["subtitles/language"] || "" + return Browse.mediaInfoFor(grid.currentIndex, smart ? String(language) : "") } Connections { target: Browse + function onSeekMoved(row, settled) { + root.jumpToRow(row) + } function onChanged() { if (root.filtersOpen) root.filterEntries = root.buildFilterEntries() @@ -808,10 +926,40 @@ FocusScope { } function routeKey(key, phase, repeat) { - if (phase === "release" && InputKeys.isDirection(key)) + if (railActive) { + if (phase === "release") + return InputKeys.isDirection(key) + if (key === Qt.Key_Up || key === Qt.Key_Down) { + jumpToLetter(Math.max(0, Math.min(alphabetLetters.length - 1, railIndex + (key === Qt.Key_Up ? -1 : + 1)))) + + return true + } + if (key === Qt.Key_Left) { + leaveRail() + return true + } + return InputKeys.isDirection(key) + } + if (phase === "press" && grid.activeFocus && (key === Qt.Key_PageUp || key === Qt.Key_PageDown)) + return stepLetter(key === Qt.Key_PageUp ? -1 : 1) + // Right from the last column steps onto the letter rail. + if (phase === "press" && !repeat && key === Qt.Key_Right && grid.activeFocus && alphabetRail + && grid.currentIndex >= 0 && (grid.currentIndex % root.columns === root.columns - 1 + || grid.currentIndex === grid.count - 1) && !pointerNavigationPending) + return enterRail() + if (phase === "release" && InputKeys.isDirection(key)) { + InputKeys.consumeRecoveryGesture(recoveryGesture, key, phase, repeat, false) return grid.routeKey(key, phase, repeat) - if (phase !== "release" && InputKeys.isDirection(key) && grid.activeFocus && pointerNavigationPending) - return applyPendingPointerNavigation() + } + if (InputKeys.isDirection(key) && grid.activeFocus) { + if (InputKeys.consumeRecoveryGesture(recoveryGesture, key, phase, repeat, false)) + return true + if (pointerNavigationPending || !InputKeys.selectionVisiblyUsable(grid, root)) { + InputKeys.consumeRecoveryGesture(recoveryGesture, key, phase, repeat, true) + return applyPendingPointerNavigation() + } + } if (libraryList && libraryList.activeFocus) return libraryList.routeKey(key, phase, repeat) if (sortList && sortList.activeFocus) @@ -878,6 +1026,14 @@ FocusScope { } function activate() { + if (railActive) { + leaveRail() + return + } + if (typeAheadMissed && typeAheadBuffer.length > 0 && grid.activeFocus) { + searchTypedText() + return + } if (libraryList && libraryList.activeFocus) libraryList.activate() else if (sortList && sortList.activeFocus) @@ -893,7 +1049,7 @@ FocusScope { else if (filterButton.activeFocus) openFilterMenu() else if (clearFiltersButton.activeFocus) - Browse.clearFilters() + clearFilters() else grid.activate() } @@ -913,8 +1069,14 @@ FocusScope { } function back() { + if (railActive) { + leaveRail() + return true + } if (typeAheadBuffer.length > 0) { typeAheadBuffer = "" + typeAheadMissed = false + Browse.cancelSeek() typeAheadReset.stop() return true } @@ -1054,11 +1216,13 @@ FocusScope { iconName: "close" label: "Clear" visible: !root.isFixedBrowseView && root.activeFilterCount > 0 && root.lane !== "compact" - onActivated: { - root.savedIndex = 0 - gridReveal.reset() - Browse.clearFilters() - } + // Clearing removes this button. Focus left on it stayed on an + // invisible item: no ring, arrows fell through to scrolling, + // and Enter only cleared again, so a remote could no longer + // open anything. + onVisibleChanged: if (!visible && activeFocus) + InputKeys.focus(grid) + onActivated: root.clearFilters() } } @@ -1186,7 +1350,8 @@ FocusScope { boundsBehavior: Flickable.StopAtBounds model: Browse.items leftMargin: focusPadding - rightMargin: focusPadding + Math.max(Metrics.scaled(10), 10) + Metrics.scaled(6) + rightMargin: focusPadding + Math.max(Metrics.scaled(10), 10) + Metrics.scaled(6) + ( + root.alphabetRailReserved ? letterRail.restingWidth : 0) // 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 @@ -1211,8 +1376,19 @@ FocusScope { requestMoreIfNeeded() } onContentYChanged: { + const now = Date.now() + const elapsed = now - root.lastScrollAt + if (elapsed > 0 && elapsed < 200 && Math.abs(contentY - root.lastScrollY) * 1000 / elapsed > height + * 3) + root.scrollingFast = true + root.lastScrollAt = now + root.lastScrollY = contentY + if (root.scrollingFast) + scrollSettle.restart() requestPageIfNeeded() artworkWindowDebounce.restart() + if (Browse.sparse && !grid.racing && !root.scrollingFast && !artworkWindowThrottle.running) + artworkWindowThrottle.start() alphabetFeedback.restart() } onCurrentIndexChanged: { @@ -1246,6 +1422,26 @@ FocusScope { minimumSize: 0.04 onScrolled: root.beginPointerNavigation(null) } + AlphabetRail { + id: letterRail + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: libraryScrollBar.left + anchors.rightMargin: -Math.max(Metrics.scaled(10), 10) + z: 5 + visible: root.alphabetRail + letters: root.alphabetLetters + active: root.railActive + currentIndex: root.railActive ? root.railIndex : root.viewLetterIndex + interactive: Platform.hasPointer + onLetterChosen: (index, final) => { + root.beginPointerNavigation(null) + root.jumpToLetter(index) + } + } + // Where a fast hold lets go is where loading starts. + onRacingChanged: if (!racing) + requestMoreIfNeeded() onEdgeUp: root.focusToolbar() onAccepted: root.activateCurrent() onHoldStarted: Browse.prefetchNextPage() @@ -1319,15 +1515,30 @@ FocusScope { } } + Timer { + id: scrollSettle + interval: 120 + repeat: false + onTriggered: { + root.scrollingFast = false + grid.requestMoreIfNeeded() + } + } + + // While the view keeps moving, a sparse listing still loads + // what is under it a few times a second. + Timer { + id: artworkWindowThrottle + interval: 350 + 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) - }) + onTriggered: root.checkpointRoute() } Timer { @@ -1351,8 +1562,10 @@ FocusScope { readonly property real midY: height / 2 readonly property real desiredY: libraryScrollBar.handleCenterY - height / 2 + // With the letter rail it stands clear of the rail, open or not. anchors.right: libraryScrollBar.right - anchors.rightMargin: libraryScrollBar.visualWidth + anchors.rightMargin: libraryScrollBar.visualWidth + (root.alphabetRail ? letterRail.restingWidth : + 0) y: Math.max(0, Math.min(grid.height - height, desiredY)) width: Metrics.scaled(52) + neckWidth height: Metrics.scaled(48) @@ -1360,7 +1573,10 @@ FocusScope { // 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 + // Only for lists long enough that the position needs + // telling, and never beside the rail's own bubble. + opacity: root.longList && !letterRail.engaged && (alphabetFeedback.running + || libraryScrollBar.pressed) ? 1 : 0 visible: opacity > 0 preferredRendererType: Shape.CurveRenderer z: 21 @@ -1454,6 +1670,7 @@ FocusScope { id: gridDelegate required property int index + required property bool loaded property bool artworkPooled: false GridView.onPooled: artworkPooled = true GridView.onReused: artworkPooled = false @@ -1464,8 +1681,8 @@ FocusScope { kind: root.squareArtwork ? "square" : "poster" preferEpisodeTitle: false useSeriesPoster: true - focused: Metrics.keyboardFocusActive && root.navigationFocusVisible && grid.activeFocus - && gridDelegate.GridView.isCurrentItem + focused: Metrics.keyboardFocusActive && root.navigationFocusVisible && grid.activeFocus && + !root.railActive && gridDelegate.GridView.isCurrentItem artworkVisible: true // Buffered delegates must not enter the render queue: // scrolling cancels old requests and loads the viewport @@ -1473,14 +1690,44 @@ FocusScope { // 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 + artworkDeferred: grid.racing || root.scrollingFast + artworkEnabled: loaded && !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() + + // A row not fetched yet: the letter it is under, so a + // fling across an unloaded stretch still reads as A..Z. + Rectangle { + width: parent.width + height: parent.artHeight + radius: Theme.radiusMedium + color: Theme.bgRaised + border.width: 1 + border.color: Theme.border + opacity: gridDelegate.loaded ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.reducedMotion ? 0 : 160 + } + } + + AppText { + anchors.centerIn: parent + text: !gridDelegate.loaded && Browse.alphabetical && Browse.alphabetRevision >= 0 + ? Browse.labelAt(gridDelegate.index) : "" + font.pixelSize: Math.round(parent.width * 0.42) + font.weight: Font.Bold + color: Theme.textMuted + opacity: 0.35 + } + } } } @@ -1491,6 +1738,7 @@ FocusScope { id: listRow required property int index + required property bool loaded required property string displayTitle required property string displaySubtitle @@ -1506,7 +1754,10 @@ FocusScope { anchors.right: rowSubtitle.left anchors.rightMargin: Metrics.scaled(16) anchors.verticalCenter: parent.verticalCenter - text: listRow.displayTitle + text: listRow.loaded ? listRow.displayTitle : Browse.alphabetical + && Browse.alphabetRevision >= 0 ? Browse.labelAt(listRow.index) + + "…" : "…" + opacity: listRow.loaded ? 1 : 0.4 font.pixelSize: Metrics.bodySizePx + Metrics.scaled(2) font.weight: listRow.GridView.isCurrentItem ? Font.DemiBold : Font.Medium color: listRow.GridView.isCurrentItem ? Theme.textPrimary : Theme.textSecondary @@ -1540,13 +1791,16 @@ FocusScope { 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" + border.color: root.navigationFocusVisible && ((Metrics.keyboardFocusActive && grid.activeFocus + && !root.railActive) || (root.listMode + && Metrics.coarsePointer + && root.touchPreviewItemId.length + > 0 && root.touchPreviewItemId + === String( + root.paneItem + ? root.paneItem.movieId : + ""))) ? Theme.accent : + "transparent" } } @@ -1571,21 +1825,40 @@ FocusScope { anchors.top: parent.top anchors.rightMargin: Metrics.pageMarginPx anchors.topMargin: root.contentTopMargin + 54 - width: typeAheadText.implicitWidth + 30 + width: typeAheadRow.implicitWidth + 30 height: 40 radius: height / 2 visible: root.typeAheadBuffer.length > 0 color: Theme.floatingPanel border.width: 1 - border.color: Theme.accent + border.color: root.typeAheadMissed ? Theme.border : Theme.accent z: 30 - AppText { - id: typeAheadText + Row { + id: typeAheadRow anchors.centerIn: parent - text: root.typeAheadBuffer - font.pixelSize: Metrics.bodySizePx - font.weight: Font.DemiBold + spacing: Metrics.scaled(10) + + AppText { + anchors.verticalCenter: parent.verticalCenter + text: root.typeAheadBuffer + font.pixelSize: Metrics.bodySizePx + font.weight: Font.DemiBold + } + // Finding a title in rows not loaded yet takes a fetch or two. + BusySpinner { + anchors.verticalCenter: parent.verticalCenter + width: Metrics.scaled(18) + height: width + running: Boolean(Browse.seeking) + visible: running + } + SecondaryText { + anchors.verticalCenter: parent.verticalCenter + visible: root.typeAheadMissed + text: Platform.isTV ? "OK to search" : "Enter to search" + font.pixelSize: Metrics.metaSizePx + } } } @@ -1593,7 +1866,10 @@ FocusScope { id: typeAheadReset interval: 1250 repeat: false - onTriggered: root.typeAheadBuffer = "" + onTriggered: { + root.typeAheadBuffer = "" + root.typeAheadMissed = false + } } // Kodi-style single-line media info for the focused item. @@ -1633,7 +1909,7 @@ FocusScope { checkedFor: function (entry) { return entry && String(entry.libraryId) === String(Browse.libraryId || "") } - onCurrentIndexChanged: root.libraryIndex = currentIndex + onMoved: index => root.libraryIndex = index onDismissed: root.closeMenus() onAccepted: entry => root.activateLibraryIndex(entry.index) } @@ -1656,7 +1932,7 @@ FocusScope { entry && entry.value === root.currentSortBy() } - onCurrentIndexChanged: root.sortIndex = currentIndex + onMoved: index => root.sortIndex = index onDismissed: root.closeMenus() onAccepted: entry => root.activateSortEntry(entry) } @@ -1678,12 +1954,9 @@ FocusScope { selectionStyle: "check" resetVisible: true resetEnabled: root.activeFilterCount > 0 - onCurrentIndexChanged: root.filterIndex = currentIndex + onMoved: index => root.filterIndex = index onDismissed: root.closeMenus() onAccepted: entry => root.activateFilterEntry(entry) - onReset: { - root.savedIndex = 0 - Browse.clearFilters() - } + onReset: root.clearFilters() } } diff --git a/qml/pages/LibraryNavigation.js b/qml/pages/LibraryNavigation.js index 56367223..c68eecb1 100644 --- a/qml/pages/LibraryNavigation.js +++ b/qml/pages/LibraryNavigation.js @@ -1,5 +1,24 @@ .pragma library +// Prefer a prefix anywhere in traversal order to an earlier substring match, +// but fetch/case-fold each native model item at most once per keystroke. +function typeAheadIndex(count, currentIndex, query, includeCurrent, titleAt) { + if (count <= 0 || query.length <= 0) + return -1 + const start = Math.max(0, currentIndex) + const firstOffset = includeCurrent ? 0 : 1 + let partialMatch = -1 + for (let offset = firstOffset; offset < firstOffset + count; ++offset) { + const index = (start + offset) % count + const match = titleAt(index).indexOf(query) + if (match === 0) + return index + if (match > 0 && partialMatch < 0) + partialMatch = index + } + return partialMatch +} + function value(item, key) { if (!item) return undefined diff --git a/qml/pages/PersonDetailsPage.qml b/qml/pages/PersonDetailsPage.qml index 4b923f50..cd45ecce 100644 --- a/qml/pages/PersonDetailsPage.qml +++ b/qml/pages/PersonDetailsPage.qml @@ -6,12 +6,16 @@ import "../theme" import "../primitives" import "../browse" import "../shell/ItemActivation.js" as ItemActivation +import "../shell/RoutePolicy.js" as RoutePolicy FocusScope { id: root property var shell - readonly property var person: shell ? shell.personItem : ({}) + readonly property bool directionRelease: rows.directionRelease + readonly property var person: RoutePolicy.personContext(shell ? shell.routeArgs : ({})) + readonly property bool routeActive: Boolean(shell && shell.route === "personDetails") + property bool routeRefreshScheduled: false // The controller supplies title/model/kind per credit row; the page adds // the presentation the row kind implies. A person's episode credits name // the episode, where Continue Watching names the series. @@ -37,8 +41,24 @@ FocusScope { readonly property bool contentReady: !Content.personItemsBusy focus: true - Component.onCompleted: rows.reset() + Component.onCompleted: { + rows.reset() + schedulePersonRefresh() + } onSectionsChanged: rows.reset() + onPersonChanged: schedulePersonRefresh() + onRouteActiveChanged: schedulePersonRefresh() + + function schedulePersonRefresh() { + if (!routeActive || routeRefreshScheduled) + return + routeRefreshScheduled = true + Qt.callLater(function () { + root.routeRefreshScheduled = false + if (root.routeActive) + Content.loadPersonItems(String(root.person.id || "")) + }) + } function routeKey(key, phase, repeat) { return rows.routeKey(key, phase, repeat) @@ -85,7 +105,7 @@ FocusScope { onEdgeUp: { positionAtBeginning() if (root.shell) - root.shell.focusNavBar() + root.shell.focusNavBar() } onActivated: (section, index, item) => root.openAt(section, index, item) diff --git a/qml/pages/PlayerOverlayChrome.qml b/qml/pages/PlayerOverlayChrome.qml index 27e22d43..893b37c2 100644 --- a/qml/pages/PlayerOverlayChrome.qml +++ b/qml/pages/PlayerOverlayChrome.qml @@ -404,14 +404,21 @@ Item { // its bindings from ever running otherwise. Loader { id: syncPlayMenu - anchors.right: parent.right - anchors.bottom: hud.top - anchors.rightMargin: root.dp(52) - anchors.bottomMargin: root.dp(18) - width: root.dp(420) + readonly property real edgeMargin: root.dp(16) + readonly property real spaceAboveHud: Math.max(0, hud.y - root.dp(18) - edgeMargin) + // Stay above the transport when there is room for a heading and row. + // Very short windows let the menu cover the HUD so its list stays usable. + readonly property real menuMaximumHeight: Math.min(Metrics.scaled(520), spaceAboveHud >= Metrics.controlHeightPx + * 2 ? spaceAboveHud : Math.max(0, root.height - edgeMargin + * 2)) + + width: Math.max(0, Math.min(root.dp(420), root.width - edgeMargin * 2)) + x: Math.max(edgeMargin, root.width - width - root.dp(52)) + y: Math.max(edgeMargin, hud.y - height - root.dp(18)) z: 55 active: root.overlay.syncPlay !== null sourceComponent: Shell.GroupMenu { + maximumHeight: syncPlayMenu.menuMaximumHeight onRequestClose: root.overlay.closeMenu() } } diff --git a/qml/pages/PlayerOverlayInput.qml b/qml/pages/PlayerOverlayInput.qml index a455100f..57066fb5 100644 --- a/qml/pages/PlayerOverlayInput.qml +++ b/qml/pages/PlayerOverlayInput.qml @@ -84,7 +84,8 @@ Item { function handleControlsKey(key) { if (key === Qt.Key_Up) { - overlay.focusZone = overlay.focusZone === "actions" ? "timeline" : "back" + overlay.focusZone = overlay.focusZone === "actions" ? "timeline" : overlay.focusZone === "timeline" + && overlay.hasActiveSegment ? "skip" : "back" overlay.showControls(overlay.focusZone) return true } @@ -95,7 +96,8 @@ Item { } if (overlay.focusZone === "timeline") overlay.actionIndex = overlay.pauseActionIndex - overlay.focusZone = overlay.focusZone === "back" ? "timeline" : "actions" + overlay.focusZone = overlay.focusZone === "back" ? (overlay.hasActiveSegment ? "skip" : "timeline") : + overlay.focusZone === "skip" ? "timeline" : "actions" overlay.showControls(overlay.focusZone) return true } @@ -163,7 +165,7 @@ Item { overlay.player.enableSubtitles() break case "skipSegment": - overlay.player.skipActiveSegment() + overlay.skipActiveSegment() break case "queuePrevious": overlay.playPrevious() @@ -202,8 +204,8 @@ Item { } if (overlay.controlsVisible && handleControlsKey(key)) return true - if (key === Qt.Key_T && overlay.hasPlayer && overlay.player.activeSegmentType.length > 0) { - overlay.player.skipActiveSegment() + if (key === Qt.Key_T && overlay.hasActiveSegment) { + overlay.skipActiveSegment() return true } if (key === Qt.Key_F && overlay.hasPlayer) { diff --git a/qml/pages/PlayerOverlayPage.qml b/qml/pages/PlayerOverlayPage.qml index d871098f..4cb398f2 100644 --- a/qml/pages/PlayerOverlayPage.qml +++ b/qml/pages/PlayerOverlayPage.qml @@ -55,6 +55,8 @@ FocusScope { readonly property int actionTargetSize: Math.max(Metrics.touchTargetPx, dp(audioOnly ? 54 : 68)) readonly property var audioSyncSteps: [1, 5, 10, 100] readonly property bool audioSelectable: hasPlayer && player.audioTracks.length > 1 + readonly property bool hasActiveSegment: hasPlayer && player.activeSegmentType.length > 0 + && player.activeSegmentEndSeconds > 0 // Nothing hides over a now playing stage, and the controls that only make // sense against a picture are left out of it. readonly property bool audioOnly: hasPlayer && player.mediaKind === "audio" @@ -154,6 +156,15 @@ FocusScope { "action": "quality", "label": "Quality" }) + values.push({ + "action": "repeat", + "label": "Repeat", + "enabled": !syncPlayActive + }, { + "action": "sleep", + "label": "Sleep timer", + "enabled": !syncPlayActive + }) if (!audioOnly) values.push({ "action": "subtitleSettings", @@ -188,6 +199,56 @@ FocusScope { return qualityOptions if (menuKind === "debug") return debugOptions + if (menuKind === "repeat") + return [ + { + "label": "Off", + "mode": "RepeatNone" + }, + { + "label": "Repeat queue", + "mode": "RepeatAll" + }, + { + "label": "Repeat current item", + "mode": "RepeatOne" + } + ] + if (menuKind === "sleep") + return [ + { + "label": "Off", + "minutes": 0 + }, + { + "label": "After current item", + "minutes": -1 + }, + { + "label": "15 minutes", + "minutes": 15 + }, + { + "label": "30 minutes", + "minutes": 30 + }, + { + "label": "45 minutes", + "minutes": 45 + }, + { + "label": "60 minutes", + "minutes": 60 + }, + { + "label": "90 minutes", + "minutes": 90 + }, + { + "label": "120 minutes", + "minutes": 120 + } + ] return [] } @@ -407,6 +468,10 @@ FocusScope { return "Audio" if (menuKind === "quality") return "Quality" + if (menuKind === "repeat") + return "Repeat" + if (menuKind === "sleep") + return "Sleep timer" return "Playback settings" } @@ -416,7 +481,7 @@ FocusScope { } function menuLabel(item) { - if (menuKind === "quality" || menuKind === "debug") + if (menuKind === "quality" || menuKind === "debug" || menuKind === "repeat" || menuKind === "sleep") return String(item && item.label || "") return String(item) } @@ -428,13 +493,35 @@ FocusScope { function menuDetail(index) { 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" && syncPlayActive ? "Disabled by SyncPlay" : "" + const action = debugAction(index) + // Playback speed is the one row a SyncPlay group takes away; repeat + // belongs to the group's queue policy as well. + if ((action === "speed" || action === "repeat") && syncPlayActive) + return "Disabled by SyncPlay" + if (action === "repeat") + return playQueue.repeatMode === "RepeatAll" ? "Queue" : playQueue.repeatMode === "RepeatOne" + ? "Current item" : "Off" + if (action === "sleep") { + if (syncPlayActive) + return "Unavailable in SyncPlay" + if (App.sleepTimerMode === "afterCurrent") + return "After current item" + if (App.sleepTimerMode === "timed") + return formatClock(App.sleepTimerRemainingSeconds) + " remaining" + return "Off" + } + return "" } function menuItemSelected(index) { if (menuKind === "quality") return Boolean(qualityOptions[index] && qualityOptions[index].selected) + if (menuKind === "repeat") + return Boolean(menuOptions[index] && menuOptions[index].mode === playQueue.repeatMode) + if (menuKind === "sleep") { + const minutes = App.sleepTimerMode === "afterCurrent" ? -1 : App.sleepTimerMinutes + return Boolean(menuOptions[index] && menuOptions[index].minutes === minutes) + } if (!hasPlayer) return false if (menuKind === "subtitles") @@ -453,7 +540,11 @@ FocusScope { audioSyncVisible = false subtitleSettingsVisible = false controlsVisible = true - const initialIndex = kind === "audio" && hasPlayer ? player.selectedAudioIndex : 0 + let initialIndex = 0 + if (hasPlayer && kind === "audio") + initialIndex = player.selectedAudioIndex + else if (hasPlayer && kind === "subtitles") + initialIndex = player.selectedSubtitleIndex chrome.resetMenu(Math.max(0, initialIndex)) autohide.stop() } @@ -490,7 +581,19 @@ FocusScope { player.selectSubtitle(index) else if (kind === "audio" && hasPlayer && player.audioTracks.length > 0) player.selectAudio(index) - else if (kind === "quality") { + else if (kind === "repeat") { + const option = menuOptions[index] + if (option && !syncPlayActive) + App.setRepeatMode(option.mode) + } else if (kind === "sleep") { + const option = menuOptions[index] + if (!option || syncPlayActive) + return + if (option.minutes === -1) + App.stopAfterCurrentItem() + else + App.setSleepTimerMinutes(option.minutes) + } else if (kind === "quality") { const option = qualityOptions[index] if (!option) return @@ -505,6 +608,11 @@ FocusScope { openMenu("quality") return } + if (action === "repeat" || action === "sleep") { + if (!syncPlayActive) + openMenu(action) + return + } if (action === "subtitleSettings") { openSubtitleSettings() return @@ -779,6 +887,14 @@ FocusScope { player.seek(clampSeconds(seconds)) } + function skipActiveSegment() { + if (!hasActiveSegment) + return false + seekTo(player.activeSegmentEndSeconds) + showControls("timeline") + return true + } + function seekRelative(seconds) { if (syncPlayActive) syncPlay.requestRelativeSeek(seconds) @@ -845,14 +961,6 @@ FocusScope { } function back() { - if (touchscreenControls) - return stopPlayback("overlay-back") - if (desktopControlsAvailable && NativeWindow.fullScreen) { - toggleFullScreen() - return true - } - if (desktopControlsAvailable) - return stopPlayback("overlay-back") // Back abandons a scrub — a held seek included — rather than leaving // playback, so read it before the input hand-back clears it. const wasScrubbing = scrubbing @@ -863,10 +971,8 @@ FocusScope { autohide.stop() return true } - if (subtitleSettingsVisible) { - closeSubtitleSettings() - return true - } + if (subtitleSettingsVisible) + return subtitleSettings.back() // The panel answers first: while a row is picked up, Back puts it back // rather than closing out from under the move. if (browsePanelVisible) @@ -882,6 +988,15 @@ FocusScope { return true } + if (touchscreenControls) + return stopPlayback("overlay-back") + if (desktopControlsAvailable && NativeWindow.fullScreen) { + toggleFullScreen() + return true + } + if (desktopControlsAvailable) + return stopPlayback("overlay-back") + if (controlsVisible) return hideControls() return stopPlayback("overlay-back") @@ -946,6 +1061,10 @@ FocusScope { stopPlayback("overlay-back") return } + if (focusZone === "skip") { + skipActiveSegment() + return + } if (focusZone === "timeline") { if (!commitScrub() && hasPlayer) togglePlayback() @@ -980,6 +1099,17 @@ FocusScope { } onScrubbingChanged: if (!scrubbing) maybeRestartAutohide() + onHasActiveSegmentChanged: if (!hasActiveSegment && focusZone === "skip") + focusZone = "timeline" + + Connections { + target: syncPlay + + function onGroupChanged() { + if (syncPlay.enabled && (overlay.menuKind === "repeat" || overlay.menuKind === "sleep")) + overlay.closeMenu() + } + } Connections { target: player @@ -1059,6 +1189,10 @@ FocusScope { PlayerOverlayChrome { id: chrome anchors.fill: parent + anchors.leftMargin: overlay.touchscreenControls ? overlay.SafeArea.margins.left : 0 + anchors.rightMargin: overlay.touchscreenControls ? overlay.SafeArea.margins.right : 0 + anchors.topMargin: overlay.touchscreenControls ? overlay.SafeArea.margins.top : 0 + anchors.bottomMargin: overlay.touchscreenControls ? overlay.SafeArea.margins.bottom : 0 overlay: parent } } diff --git a/qml/pages/PlayerQueuePanel.qml b/qml/pages/PlayerQueuePanel.qml index 6fb23a7d..801fff10 100644 --- a/qml/pages/PlayerQueuePanel.qml +++ b/qml/pages/PlayerQueuePanel.qml @@ -206,6 +206,8 @@ FocusScope { } function routeKey(key, phase, repeat) { + if (InputKeys.isBack(key)) + return phase === "press" && (repeat || back()) if (phase === "release") return true diff --git a/qml/pages/PlayerSkipSegmentCard.qml b/qml/pages/PlayerSkipSegmentCard.qml index 7eff8d5f..a12fb9d4 100644 --- a/qml/pages/PlayerSkipSegmentCard.qml +++ b/qml/pages/PlayerSkipSegmentCard.qml @@ -8,6 +8,7 @@ Rectangle { required property var overlay readonly property string segmentType: overlay.hasPlayer ? overlay.player.activeSegmentType : "" + readonly property bool focused: overlay.isControlsActive() && overlay.focusZone === "skip" readonly property string label: { if (segmentType === "Intro") return "Skip intro" @@ -27,10 +28,10 @@ Rectangle { width: overlay.dp(220) height: overlay.dp(60) radius: Theme.radiusPanel - color: skipHover.hovered ? Qt.lighter(Theme.accentDim, 1.2) : Theme.accentDim - border.width: 1 + color: focused || skipHover.hovered ? Qt.lighter(Theme.accentDim, 1.2) : Theme.accentDim + border.width: focused ? Theme.focusBorderWidth : 1 border.color: Theme.accent - visible: segmentType.length > 0 && overlay.controlsVisible + visible: overlay.hasActiveSegment && overlay.controlsVisible z: 30 RowLayout { @@ -52,7 +53,9 @@ Rectangle { font.weight: Font.DemiBold } AppText { - text: "T" + text: root.overlay.smartTvPlatform ? (root.focused ? "OK" : "") : root.overlay.touchscreenControls ? "" : + "T" + visible: text.length > 0 color: Theme.textSecondary font.pixelSize: root.overlay.dp(13) } @@ -63,7 +66,6 @@ Rectangle { } TapHandler { - onTapped: if (root.overlay.hasPlayer) - root.overlay.player.skipActiveSegment() + onTapped: root.overlay.skipActiveSegment() } } diff --git a/qml/pages/PlayerTransportBar.qml b/qml/pages/PlayerTransportBar.qml index 2522a391..ef50b483 100644 --- a/qml/pages/PlayerTransportBar.qml +++ b/qml/pages/PlayerTransportBar.qml @@ -134,7 +134,6 @@ RowLayout { id: volumeControls readonly property bool showSlider: Settings.values["playback/showVolumeSlider"] !== false - property real lastAudibleVolume: 100 visible: root.overlay.desktopControlsAvailable Layout.minimumWidth: visible ? root.overlay.dp(showSlider ? 254 : 28) : 0 @@ -143,7 +142,8 @@ RowLayout { spacing: root.overlay.dp(10) MaterialIcon { - name: root.overlay.hasPlayer && root.overlay.player.volume === 0 ? "volume_off" : "volume_up" + name: root.overlay.hasPlayer && (root.overlay.player.muted || root.overlay.player.volume === 0) + ? "volume_off" : "volume_up" iconColor: muteHover.hovered ? Theme.textPrimary : Theme.textSecondary iconSize: root.overlay.dp(38) @@ -152,17 +152,8 @@ RowLayout { } TapHandler { - onTapped: { - if (!root.overlay.hasPlayer) - return - const volume = Number(root.overlay.player.volume) - if (volume > 0) { - volumeControls.lastAudibleVolume = volume - root.overlay.player.setVolume(0) - } else { - root.overlay.player.setVolume(Math.max(1, volumeControls.lastAudibleVolume)) - } - } + onTapped: if (root.overlay.hasPlayer) + root.overlay.player.toggleMuted() } } diff --git a/qml/pages/ProviderDetailsPage.qml b/qml/pages/ProviderDetailsPage.qml index a9ddc0a7..5bb16160 100644 --- a/qml/pages/ProviderDetailsPage.qml +++ b/qml/pages/ProviderDetailsPage.qml @@ -185,61 +185,26 @@ FocusScope { wrapMode: Text.WordWrap } } - AppText { - Layout.fillWidth: true - visible: Boolean(root.installedModule) - text: root.installedModule ? "Installed provider version " + root.installedModule.version : "" - font.weight: Font.DemiBold - wrapMode: Text.WordWrap - } - SecondaryText { - Layout.fillWidth: true - visible: Boolean(root.installedModule) - text: root.provenanceText + "\nProvider ID: " + root.moduleId - wrapMode: Text.WordWrap - } - SecondaryText { - Layout.fillWidth: true - visible: Boolean(root.installedModule && root.installedModule.publisher) - text: root.installedModule ? "Declared publisher: " + root.installedModule.publisher : "" - wrapMode: Text.WordWrap - } SecondaryText { Layout.fillWidth: true visible: Boolean(root.installedModule && root.installedModule.summary) text: root.installedModule ? root.installedModule.summary : "" wrapMode: Text.WordWrap } - AppText { - Layout.fillWidth: true - visible: Boolean(root.installedModule) - text: root.busy ? "Updating provider…" : root.updateEntry ? "Update available: " - + root.updateEntry.version : - root.provenance === "native" - ? "Updated with Spool" : root.provenance - === "file" - ? "File updates require a new inspection and explicit trust." : - !Store.storeAvailable - ? "Provider downloads are disabled in this build." : - Store.loading - ? "Checking provider catalog…" : - Store.error.length > 0 - ? Store.error : - "No newer provider version listed." - wrapMode: Text.WordWrap - } Flow { Layout.fillWidth: true spacing: Metrics.scaled(12) ActionButton { id: updateButton readonly property string focusKey: "update" + // Updates arrive on their own unless the viewer chose + // otherwise in advanced settings. visible: Boolean(root.installedModule && root.updateEntry && Store.storeAvailable && root.provenance - !== "file") + !== "file") && Settings.value("providers/updates") !== "auto" enabled: !root.busy kind: "primary" iconName: "system_update" - text: root.updateEntry ? "Update to " + root.updateEntry.version : "Update" + text: root.busy ? "Updating…" : "Update now" onClicked: { root.focusControl(updateButton) Store.update(root.moduleId) @@ -265,7 +230,7 @@ FocusScope { visible: Boolean(root.installedModule && root.installedModule.removable) enabled: !root.busy kind: "flat" - text: "Remove provider" + text: "Remove from this device" onClicked: { root.focusControl(removeButton) root.removingModule = true @@ -274,14 +239,14 @@ FocusScope { } AppText { Layout.fillWidth: true - text: "Accounts & servers" + text: "Accounts" font.pixelSize: Metrics.bodySizePx + Metrics.scaled(4) font.weight: Font.DemiBold } SecondaryText { Layout.fillWidth: true - text: root.accounts.length === 0 ? "No saved accounts. Add an account to configure this provider." : - "Settings belong to each account. A locked profile must be opened deliberately before its settings are available." + text: root.accounts.length === 0 ? "No accounts yet." : + "Each account has its own settings. A profile with a PIN has to be opened first." wrapMode: Text.WordWrap } Repeater { @@ -320,7 +285,7 @@ FocusScope { SecondaryText { Layout.fillWidth: true visible: !accountRow.modelData.hasSettings - text: "This provider does not offer a separate account settings screen." + text: "No settings for this account." wrapMode: Text.WordWrap } AppText { @@ -413,6 +378,22 @@ FocusScope { wrapMode: Text.WordWrap Accessible.role: Accessible.AlertMessage } + // For support and the curious: what exactly is installed, kept + // out of the way of everyone else. + SecondaryText { + Layout.fillWidth: true + Layout.topMargin: Metrics.scaled(24) + visible: Boolean(root.installedModule) + text: root.installedModule ? [root.installedModule.name + " " + root.installedModule.version, root.provenanceText, + root.installedModule.publisher ? "by " + root.installedModule.publisher : + "", root.moduleId].filter(part + => part.length + > 0).join( + " · ") : "" + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + wrapMode: Text.WordWrap + } } } Loader { diff --git a/qml/pages/ProviderScreenPage.qml b/qml/pages/ProviderScreenPage.qml index 7d563530..c6f2de6e 100644 --- a/qml/pages/ProviderScreenPage.qml +++ b/qml/pages/ProviderScreenPage.qml @@ -59,7 +59,8 @@ FocusScope { } SecondaryText { Layout.fillWidth: true - text: page.installedModule ? "Installed provider version " + page.installedModule.version : "" + visible: text.length > 0 + text: page.installedModule ? String(page.installedModule.summary || "") : "" elide: Text.ElideRight } } diff --git a/qml/pages/SearchPage.qml b/qml/pages/SearchPage.qml index 123e53d2..3b6af56a 100644 --- a/qml/pages/SearchPage.qml +++ b/qml/pages/SearchPage.qml @@ -11,6 +11,7 @@ FocusScope { id: root property var shell + readonly property bool directionRelease: results.directionRelease readonly property var search: Search readonly property string query: search ? search.query : "" readonly property int resultCount: search ? search.resultCount : 0 @@ -18,6 +19,7 @@ FocusScope { readonly property bool searchBusy: search ? search.busy : false readonly property bool suggestionsBusy: search ? search.suggestionsBusy : false readonly property bool showSuggestions: query.length < 2 && suggestionCount > 0 + readonly property bool showSearchFailure: query.length >= 2 && Boolean(search && search.failed) && !searchBusy readonly property var resultSections: [ { "key": "movies", @@ -101,6 +103,18 @@ FocusScope { } function routeKey(key, phase, repeat) { + if (retryButton.activeFocus) { + if (key === Qt.Key_Up && phase !== "release") + field.focusField() + return InputKeys.isDirection(key) + } + if (phase === "release" && InputKeys.isDirection(key)) { + if (results.activeFocus) + results.routeKey(key, phase, repeat) + else if (suggestionsRow.activeFocus) + suggestionsRow.routeKey(key, phase, repeat) + return true + } if (suggestionsRow.activeFocus) { if (key === Qt.Key_Up) { field.focusField() @@ -121,6 +135,10 @@ FocusScope { } if (key !== Qt.Key_Down || !(field.activeFocus || field.editing)) return false + if (showSearchFailure) { + InputKeys.focus(retryButton) + return true + } if (query.length >= 2) return results.focusPreferred(preferredKind) return showSuggestions && suggestionsRow.focusList() @@ -137,6 +155,10 @@ FocusScope { } function activate() { + if (retryButton.activeFocus) { + retrySearch() + return + } if (field.activeFocus && !field.editing) { field.activate() return @@ -159,12 +181,18 @@ FocusScope { } function back() { - if (!suggestionsRow.activeFocus && !results.activeFocus) + if (!suggestionsRow.activeFocus && !results.activeFocus && !retryButton.activeFocus) return false field.focusField() return true } + function retrySearch() { + field.focusField() + if (search) + search.submit() + } + readonly property real keyboardInset: { if (!Qt.inputMethod.visible) return 0 @@ -248,7 +276,7 @@ FocusScope { Layout.fillWidth: true Layout.fillHeight: true - visible: root.query.length >= 2 + visible: root.query.length >= 2 && !root.showSearchFailure sections: root.resultSections shell: root.shell contextReturnRoute: "search" @@ -278,5 +306,45 @@ FocusScope { } } } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.showSearchFailure + spacing: Metrics.scaled(14) + + Item { + Layout.fillHeight: true + } + + AppText { + Layout.fillWidth: true + text: "Couldn't search" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + SecondaryText { + Layout.fillWidth: true + text: "Check your connection to the server, then try again." + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + ActionButton { + id: retryButton + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: parent.width + text: "Try again" + iconName: "refresh" + onClicked: root.retrySearch() + } + + Item { + Layout.fillHeight: true + } + } } } diff --git a/qml/pages/SettingsNavigation.js b/qml/pages/SettingsNavigation.js index aae80743..b364c585 100644 --- a/qml/pages/SettingsNavigation.js +++ b/qml/pages/SettingsNavigation.js @@ -25,6 +25,17 @@ function rowAvailable(row, platform, hdrPlayback, valueForKey) { return true } +function rowsDependOnSetting(rowsByKey, key) { + if (!key) + return false + for (const rowKey in rowsByKey) { + const row = rowsByKey[rowKey] + if (row && row.dependsOnKey === key) + return true + } + return false +} + // Flatten hand-authored sections into a MenuListView model. resolve(key) // returns the spec to show for a key, or a falsy value to leave it out; a // section whose rows all drop out takes its header with it. Header entries are @@ -189,22 +200,9 @@ function matchesSearch(row, query, choices) { // Recover in the viewport that the pointer left behind, never scroll back to // an old anchor. InputKeys owns candidate preference and visibility threshold. function recoverVisibleSelection(view, clipItem, inputKeys) { - const selected = view.itemAtIndex(view.currentIndex) + if (inputKeys.selectionVisiblyUsable(view, clipItem)) + return false const candidate = inputKeys.topLeftVisibleCandidate(view, clipItem) - if (view.activeFocus && selected) { - const rect = selected.mapToItem(clipItem, 0, 0, selected.width, selected.height) - const viewport = view.mapToItem(clipItem, 0, 0, view.width, view.height) - const width = Math.max(0, Math.min(rect.x + rect.width, viewport.x + viewport.width, clipItem.width) - - Math.max(rect.x, viewport.x, 0)) - const height = Math.max(0, Math.min(rect.y + rect.height, viewport.y + viewport.height, clipItem.height) - - Math.max(rect.y, viewport.y, 0)) - if (width * height / Math.max(1, rect.width * rect.height) >= inputKeys.focusRecoveryVisibleThreshold) - return false - // At large zoom a row may exceed the whole viewport. InputKeys' partial - // candidate fallback still makes the visible part of that row usable. - if (candidate && candidate.index === view.currentIndex && rect.height > view.height && height > 0) - return false - } if (candidate) inputKeys.focusIndexWithoutScrolling(view, candidate.index) return true @@ -213,17 +211,11 @@ function recoverVisibleSelection(view, clipItem, inputKeys) { // A recovery press is not an edit gesture. Qt may send synthetic releases // between repeats; only the physical release permits the next action. function consumeRecoveryGesture(view, clipItem, inputKeys, gesture, key, phase, repeat) { - if (phase === "release") { - if (!repeat && gesture.key === key) - gesture.key = 0 - return true - } - if (gesture.key === key) + if (inputKeys.consumeRecoveryGesture(gesture, key, phase, repeat, false)) return true if (!recoverVisibleSelection(view, clipItem, inputKeys)) return false - gesture.key = key - return true + return inputKeys.consumeRecoveryGesture(gesture, key, phase, repeat, true) } var subtitleSections = [ diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index 684de970..432216c5 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -31,7 +31,9 @@ FocusScope { ? SettingsNavigation.categoryTitle(categoryId) : "Settings" readonly property bool modalVisible: certificateManagerVisible || choiceDialogVisible || diagnosticsExportVisible - || mpvFolderDialogVisible + || mpvFolderDialogVisible || homeProvidersVisible + property bool homeProvidersVisible: false + property Item homeProvidersAnchor: null property bool mpvFolderDialogVisible: false onModalVisibleChanged: if (!modalVisible && rowsBuilt) Qt.callLater(function () { @@ -55,7 +57,7 @@ FocusScope { } } - function restoreLocation(saved) { + function restoreLocation(saved, takeFocus) { if (!saved) return finishRowEdit() @@ -65,7 +67,7 @@ FocusScope { searchQuery = saved.query expandedGroups = saved.expanded revealedRowKey = saved.revealedRowKey - reconcileSettingsRows(rebuildVisibleRows(), saved.rowKey, true) + reconcileSettingsRows(rebuildVisibleRows(), saved.rowKey, takeFocus !== false) // Incremental reconciliation can move ListView's content origin while // retaining the result delegate. Raw contentY belongs to the old model. settingsList.contentY = settingsList.originY + saved.scrollOffset @@ -74,13 +76,32 @@ FocusScope { function openSearch() { if (modalVisible || editingKey.length || inputCompositionActive()) return false - if (pageMode !== "search") + searchField.focusField() + return true + } + + // The field is always on screen; what it holds decides the mode. The + // first character leaves the page it was typed over for results, and + // emptying it puts that page back without taking the caret away. + function setSearchQuery(text) { + if (!text.length) { + if (pageMode !== "search") + return + const saved = priorSearchLocation + priorSearchLocation = null + searchResultLocation = null + restoreLocation(saved, false) + return + } + if (pageMode !== "search") { + finishRowEdit() + navigationMode = "row" priorSearchLocation = location() + pageMode = "search" + } searchResultLocation = null - pageMode = "search" + searchQuery = text refreshSettingsFilter(true) - Qt.callLater(searchField.focusField) - return true } function openCategory(id, rowKey) { @@ -198,6 +219,8 @@ FocusScope { onDownloadsAvailableChanged: refreshSettingsFilter(false) function rowAvailable(row) { + if (row.key === "providers/updates" && !Store.storeAvailable) + return false if (row.key === "action/connectionSpeed" && !ProviderCapabilities.speedTest) return false if (row.key === "action/downloads") @@ -279,7 +302,6 @@ FocusScope { } } if (pageMode === "index") { - visibleRows.push(descriptor("action/searchSettings", -2, false, false)) visibleRows.push(descriptor("action/zoom", -1, false, false)) for (let index = 0; index < SettingsNavigation.categories.length; ++index) visibleRows.push(descriptor("action/category/" + SettingsNavigation.categories[index].id, index, false, @@ -318,12 +340,6 @@ FocusScope { "type": "action", "destination": "category" } - rowMap["action/searchSettings"] = { - "key": "action/searchSettings", - "title": "Search settings", - "description": "Find any setting by name, value or keyword", - "type": "index" - } rowMap["action/zoom"] = { "key": "action/zoom", "title": "Interface scale (Zoom)", @@ -503,11 +519,15 @@ FocusScope { return SettingsSync.summary if (row.key === "action/connectionSpeed") return App.connectionSpeedDescription + if (row.key === "artwork/downloads" && Art.downloadStatus.length > 0) + return row.description + " · Now " + Art.downloadStatus 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 === "home/hiddenProviderIds") + return "Choose which providers fill Home" if (row.key === "action/accounts") { const count = Providers.accounts.length return count === 1 ? "1 account" : count + " accounts" @@ -524,16 +544,18 @@ FocusScope { // Choice labels may carry a "%1" placeholder for the user's preferred // language, e.g. "Smart (English when available)". - function preferredLanguageWord() { + function preferredLanguageWord(audio) { const labels = Settings.subtitleLanguageOptions - const index = Settings.subtitleLanguageIndex + // An empty audio preference follows the subtitle language, as the player does. + const index = audio && Settings.audioLanguageIndex > 0 ? Settings.audioLanguageIndex : + Settings.subtitleLanguageIndex if (index <= 0 || index >= labels.length) return "your language" - return String(labels[index]).split(" ")[0] + return String(labels[index]) } - function substitutedLabels(labels) { - const word = preferredLanguageWord() + function substitutedLabels(labels, audio) { + const word = preferredLanguageWord(audio) const result = [] for (let index = 0; index < labels.length; ++index) result.push(String(labels[index]).replace("%1", word)) @@ -568,6 +590,12 @@ FocusScope { if (row.key === "action/clearLatencyStatistics" || row.key === "action/clearLogs") return "Clear" + if (row.key === "home/hiddenProviderIds") { + const choices = Home.providerChoices + const shown = choices.filter(choice => choice.shown).length + return !Home.providerFilterActive || shown === choices.length ? "All" : shown + " of " + choices.length + } + if (row.key === "about/version") return "v" + Qt.application.version if (row.key === "about/locale") @@ -576,28 +604,27 @@ FocusScope { } function rowOptions(row) { - if (row.key === "home/providerId") - return Home.providerChoices.map(function (choice) { - return choice.name + (choice.version ? " · " + choice.version : "") - }) if (row.key === "i18n/locale") { const result = [] for (let index = 0; index < I18n.availableLocales.length; ++index) result.push(I18n.displayNameFor(I18n.availableLocales[index])) return result } - if (row.key === "subtitles/language" || row.key === "audio/language") + if (row.key === "subtitles/language") return Settings.subtitleLanguageOptions + if (row.key === "audio/language") { + const languages = Settings.subtitleLanguageOptions + const result = [] + for (let index = 0; index < languages.length; ++index) + result.push(index === 0 ? "Use subtitle language" : languages[index]) + return result + } if (row.key === "subtitles/mode" || row.key === "audio/trackMode") - return substitutedLabels(row.choiceLabels || []) + return substitutedLabels(row.choiceLabels || [], row.key === "audio/trackMode") return row.choiceLabels || [] } function rowChoiceValues(row) { - if (row.key === "home/providerId") - return Home.providerChoices.map(function (choice) { - return choice.id - }) if (row.key === "i18n/locale") return I18n.availableLocales if (row.key === "subtitles/language" || row.key === "audio/language") @@ -678,9 +705,7 @@ FocusScope { return selectRow(index, false) if (row.type === "index") { - if (row.key === "action/searchSettings") - openSearch() - else if (row.key === "action/zoom") + if (row.key === "action/zoom") openCategory("appearance", "appearance/uiScalePercent") else openCategory(row.categoryId, "") @@ -728,7 +753,10 @@ FocusScope { shell.openDownloads("", settingsList, true) else if (row.key === "action/manageCertificates") certificateManagerVisible = true - else if (row.key === "action/clearLatencyStatistics") + else if (row.key === "home/hiddenProviderIds") { + homeProvidersAnchor = rowControlAt(index) + homeProvidersVisible = true + } else if (row.key === "action/clearLatencyStatistics") InputLatency.clearStatistics() else if (row.key === "action/clearLogs") App.clearLogs() @@ -806,9 +834,26 @@ FocusScope { Qt.callLater(choiceDialog.completePresentation) } + function closeHomeProviders() { + homeProvidersVisible = false + homeProvidersAnchor = null + Qt.callLater(function () { + selectRow(currentIndex, true) + }) + } + function back() { + if (homeProvidersVisible) { + closeHomeProviders() + return true + } if (diagnosticsExportVisible) { - diagnosticsExportLoader.item.back() + if (diagnosticsExportLoader.item) + diagnosticsExportLoader.item.back() + else { + diagnosticsExportVisible = false + InputKeys.focus(settingsList) + } return true } if (certificateManagerVisible) { @@ -857,33 +902,39 @@ FocusScope { const focused = root.Window.window ? root.Window.window.activeFocusItem : null if (inputCompositionActive()) return false - if (searchButton.activeFocus || backButton.activeFocus) { + if (backButton.activeFocus) { if (action === "activate") - return searchButton.activeFocus ? openSearch() : back() + return back() if (action === "down") { - InputKeys.focus(settingsList) - return true - } - if (action === "left" && backButton.visible) { - InputKeys.focus(backButton) - return true - } - if (action === "right") { - InputKeys.focus(searchButton) + searchField.focusRow() return true } if (action === "up" && shell) { shell.focusNavBar() return true } - return false + return action === "left" || action === "right" } - if (pageMode === "search" && (searchField.activeFocus || searchField.editing)) { + if (searchField.activeFocus || searchField.editing) { + // Resting on the row (TV): Select opens the keyboard. Typing in + // it: Select or Down hands the results to the D-pad. + if (action === "activate" && !searchField.editing) { + searchField.focusField() + return true + } if (action === "down" || action === "activate") { Qt.inputMethod.hide() InputKeys.focus(settingsList) return true } + if (action === "up") { + Qt.inputMethod.hide() + if (backButton.visible) + InputKeys.focus(backButton) + else if (shell) + shell.focusNavBar() + return true + } return false } if (InputKeys.isTextInputItem(focused) && navigationMode !== "value-editing") @@ -958,6 +1009,11 @@ FocusScope { repeat) if (certificateManagerVisible) return certificateManagerLoader.item.routeKey(key, phase, repeat) + if (homeProvidersVisible) { + if (InputKeys.isBack(key, false, false)) + return false + return homeProvidersLoader.item ? homeProvidersLoader.item.routeKey(key, phase, repeat) : true + } if (diagnosticsExportVisible) return diagnosticsExportLoader.item.routeKey(key, phase, repeat) if (mpvFolderDialogVisible) @@ -980,8 +1036,14 @@ FocusScope { } function activate() { + if (homeProvidersVisible) { + if (homeProvidersLoader.item) + homeProvidersLoader.item.activate() + return + } if (diagnosticsExportVisible) { - diagnosticsExportLoader.item.activate() + if (diagnosticsExportLoader.item) + diagnosticsExportLoader.item.activate() return } if (mpvFolderDialogVisible) @@ -1051,7 +1113,10 @@ FocusScope { target: Settings function onSettingChanged(key) { - root.refreshSettingsFilter(false) + // Value bindings update independently. Only availability changes + // need to reconcile the rows and force another list layout. + if (SettingsNavigation.rowsDependOnSetting(root.rowsByKey, key)) + root.refreshSettingsFilter(false) } } Connections { @@ -1080,42 +1145,41 @@ FocusScope { anchors.horizontalCenter: parent.horizontalCenter width: settingsList.width spacing: Metrics.scaled(10) - AppText { - width: parent.width - text: root.pageTitle - font.pixelSize: Metrics.titleSizePx - wrapMode: Text.Wrap - Accessible.name: text - Accessible.role: Accessible.Heading - } Row { width: parent.width - spacing: Metrics.scaled(12) - ActionButton { + spacing: Metrics.scaled(6) + IconButton { id: backButton - text: "Back" + anchors.verticalCenter: parent.verticalCenter visible: root.pageMode !== "index" - width: Math.min(Metrics.scaled(100), Math.max(0, (parent.width - parent.spacing) / 2)) + iconName: "arrow_back" + accessibleName: "Back" onClicked: root.back() } - ActionButton { - id: searchButton - text: "Search" - width: Math.min(Metrics.scaled(120), Math.max(0, (parent.width - parent.spacing) / 2)) - onClicked: root.openSearch() + AppText { + id: titleText + anchors.verticalCenter: parent.verticalCenter + width: parent.width - (backButton.visible ? backButton.width + parent.spacing : 0) + text: root.pageTitle + font.pixelSize: Metrics.titleSizePx + wrapMode: Text.Wrap + Accessible.name: text + Accessible.role: Accessible.Heading } } TextFieldRow { id: searchField width: parent.width - visible: root.pageMode === "search" - label: "Search settings" - placeholderText: "Name, value or keyword (for example zoom)" - text: root.searchQuery - onTextEdited: text => { - root.searchQuery = text - root.refreshSettingsFilter(true) - } + implicitHeight: Metrics.scaled(56) + iconName: "search" + accessibleName: "Search settings" + placeholderText: "Search settings by name, value or keyword" + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + enterKeyType: Qt.EnterKeySearch + // A search result opened in context is not a search page; the + // query comes back with the results on Back. + text: root.pageMode === "search" ? root.searchQuery : "" + onTextEdited: text => root.setSearchQuery(text) onAccepted: { Qt.inputMethod.hide() InputKeys.focus(settingsList) @@ -1161,12 +1225,7 @@ FocusScope { currentIndex).rowKey : "" } onAccepted: index => root.routeAction("activate") - onEdgeUp: { - if (root.pageMode === "search") - searchField.focusRow() - else - InputKeys.focus(searchButton) - } + onEdgeUp: searchField.focusRow() delegate: Column { id: settingsDelegate required property int index @@ -1223,7 +1282,7 @@ FocusScope { id: settingRow readonly property var row: parent ? parent.row : null readonly property int rowIndex: parent ? parent.rowIndex : -1 - readonly property bool isSubmenu: row && row.type === "submenu" + readonly property bool isSubmenu: Boolean(row && row.type === "submenu") width: parent ? parent.width : settingsList.width focus: false focusPolicy: Qt.NoFocus @@ -1232,10 +1291,10 @@ FocusScope { description: row ? root.rowDescription(row) : "" valueText: row ? root.rowValueText(row) : "" valueTextVisible: !isSubmenu - pointerActivationEnabled: row && (root.pageMode !== "category" || row.type === "action" || row.type - === "index" || isSubmenu || row.dependsOnKey || (row.destination - && row.destination - !== "category")) + pointerActivationEnabled: Boolean(row && (root.pageMode !== "category" || row.type === "action" || row.type + === "index" || isSubmenu || row.dependsOnKey || (row.destination + && row.destination + !== "category"))) trailing: [ MaterialIcon { visible: settingRow.isSubmenu @@ -1290,6 +1349,7 @@ FocusScope { } options: row ? root.rowOptions(row) : [] currentIndex: row ? root.rowCurrentIndex(row) : 0 + expanded: root.choiceDialogVisible && root.choiceDialogRow === row onSelected: (index, value) => root.setRowChoice(row, index) } } @@ -1471,9 +1531,9 @@ FocusScope { active: root.diagnosticsExportVisible z: 200 sourceComponent: ConfirmationDialog { - title: Platform.isAndroid ? "Share diagnostics?" : "Save diagnostics report?" + title: Platform.isMobile ? "Share diagnostics?" : "Save diagnostics report?" message: root.diagnosticsExportPreview - confirmText: Platform.isAndroid ? "Share" : "Save" + confirmText: Platform.isMobile ? "Share" : "Save" onAccepted: { App.saveDiagnosticsReport() root.diagnosticsExportVisible = false @@ -1501,6 +1561,17 @@ FocusScope { } } + Loader { + id: homeProvidersLoader + anchors.fill: parent + active: root.homeProvidersVisible + z: 200 + sourceComponent: HomeProviderPicker { + anchorItem: root.homeProvidersAnchor + onDismissed: root.closeHomeProviders() + } + } + Loader { id: choiceDialogLoader anchors.fill: parent diff --git a/qml/pages/SubtitleSettingsPanel.qml b/qml/pages/SubtitleSettingsPanel.qml index bb056639..1549174d 100644 --- a/qml/pages/SubtitleSettingsPanel.qml +++ b/qml/pages/SubtitleSettingsPanel.qml @@ -138,7 +138,7 @@ FocusScope { if (spec.key === "subtitles/mode") { const options = settingsController.subtitleLanguageOptions const index = settingsController.subtitleLanguageIndex - const word = index > 0 && index < options.length ? String(options[index]).split(" ")[0] : "your language" + const word = index > 0 && index < options.length ? String(options[index]) : "your language" const result = [] for (let i = 0; i < spec.choiceLabels.length; ++i) result.push(String(spec.choiceLabels[i]).replace("%1", word)) @@ -739,6 +739,7 @@ FocusScope { description: root.rowDescription(spec, rowFocus) options: spec ? root.choiceLabels(spec) : [] currentIndex: spec ? root.currentChoice(spec) : 0 + expanded: root.choiceVisible && root.choiceRow === spec onOpened: { root.focusRow(rowIndex) root.activateRow(rowIndex) diff --git a/qml/primitives/ActionButton.qml b/qml/primitives/ActionButton.qml index 7e558f40..509cea62 100644 --- a/qml/primitives/ActionButton.qml +++ b/qml/primitives/ActionButton.qml @@ -18,6 +18,9 @@ T.Control { focusPolicy: Metrics.keyboardFocusActive ? Qt.StrongFocus : Qt.NoFocus Accessible.role: Accessible.Button Accessible.name: text + Accessible.focusable: enabled + Accessible.focused: activeFocus + Accessible.pressed: tap.pressed Accessible.onPressAction: if (enabled) clicked() @@ -51,20 +54,29 @@ T.Control { contentItem: Item { clip: true - Row { + Item { id: buttonContent anchors.centerIn: parent - spacing: Metrics.scaled(8) + implicitWidth: buttonLabel.implicitWidth + (buttonIcon.visible ? buttonIcon.width + Metrics.scaled(8) : 0) + width: Math.min(implicitWidth, Math.max(0, parent.width - Metrics.scaled(34))) + height: Math.max(buttonLabel.implicitHeight, buttonIcon.visible ? buttonIcon.height : 0) MaterialIcon { + id: buttonIcon + anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter visible: root.iconName.length > 0 name: root.iconName iconSize: Metrics.bodySizePx + 6 iconColor: root.foreground + Accessible.ignored: true } AppText { + id: buttonLabel + anchors.left: buttonIcon.visible ? buttonIcon.right : parent.left + anchors.leftMargin: buttonIcon.visible ? Metrics.scaled(8) : 0 + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.text color: root.foreground @@ -74,6 +86,7 @@ T.Control { Font.Medium elide: Text.ElideRight maximumLineCount: 1 + Accessible.ignored: true } } } diff --git a/qml/primitives/AlphabetRail.qml b/qml/primitives/AlphabetRail.qml new file mode 100644 index 00000000..c504cfd4 --- /dev/null +++ b/qml/primitives/AlphabetRail.qml @@ -0,0 +1,178 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../theme" + +// An index beside a name-sorted list, as a phone's contacts have: the +// letters evenly down the side, the one the view is in marked, and a large +// bubble naming the letter under the pointer. Pointing, dragging or the +// remote moves through it; letters that do not fit become dots between the +// ones that do, so the column never overlaps itself on a small window. +Item { + id: root + + // [{label, row, exact}] in list order. + property var letters: [] + // The letter the view is in, or under remote focus; marked. + property int currentIndex: -1 + // Remote focus is in the rail. + property bool active: false + property bool interactive: true + readonly property bool pointing: pointer.containsMouse || pointer.pressed + readonly property bool engaged: active || pointing + readonly property real restingWidth: Metrics.scaled(24) + readonly property real letterSize: Metrics.scaled(13) + readonly property real inset: Metrics.scaled(6) + readonly property real spacing: letters.length > 0 ? (height - inset * 2) / letters.length : height + // Every `stride`-th letter is drawn; the rest are dots. + readonly property int stride: Math.max(1, Math.ceil(letterSize * 1.3 / Math.max(1, spacing))) + readonly property int hoverIndex: pointing && pointer.mouseY >= 0 ? indexAt(pointer.mouseY) : -1 + // The letter the bubble names: under the pointer, else the remote's. + readonly property int bubbleIndex: hoverIndex >= 0 ? hoverIndex : active ? currentIndex : -1 + // Chosen by pointer: `final` on release, so dragging previews and lifting + // commits, the way an index scrubber on a phone does. + signal letterChosen(int index, bool final) + + width: restingWidth + + function centerOf(index) { + return inset + (index + 0.5) * spacing + } + + function indexAt(y) { + if (letters.length <= 0) + return -1 + return Math.max(0, Math.min(letters.length - 1, Math.floor((y - inset) / Math.max(1, spacing)))) + } + + // A quiet pill behind the column while it is in use, so it reads as a + // control rather than as stray letters. + Rectangle { + anchors.fill: parent + radius: width / 2 + color: Theme.floatingPanel + border.width: root.active ? Theme.focusBorderWidth : 1 + border.color: root.active ? Theme.accent : Theme.border + opacity: root.engaged ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.reducedMotion ? 0 : 120 + } + } + } + + Repeater { + model: root.letters + + delegate: Item { + id: letter + + required property int index + required property var modelData + readonly property bool current: index === root.currentIndex + readonly property bool hovered: index === root.hoverIndex + readonly property bool shown: current || hovered || index % root.stride === 0 || index + === root.letters.length - 1 + + width: root.width + height: root.spacing + y: root.centerOf(index) - height / 2 + + Rectangle { + anchors.centerIn: parent + width: Math.min(root.width - Metrics.scaled(4), root.letterSize + Metrics.scaled(8)) + height: width + radius: width / 2 + color: Theme.accent + visible: letter.current + } + + AppText { + anchors.centerIn: parent + visible: letter.shown + text: letter.modelData.label + font.pixelSize: root.letterSize + font.weight: letter.current || letter.hovered ? Font.Bold : Font.DemiBold + color: letter.current || letter.hovered || root.engaged ? Theme.textPrimary : Theme.textMuted + // A position only estimated so far is drawn a little quieter. + opacity: letter.current || root.engaged || letter.modelData.exact ? 1 : 0.7 + } + + Rectangle { + anchors.centerIn: parent + visible: !letter.shown + width: Metrics.scaled(3) + height: width + radius: width / 2 + color: Theme.textMuted + } + } + } + + // The letter under the pointer, large, beside the column. + Rectangle { + id: bubble + readonly property real size: Metrics.scaled(64) + width: size + height: size + radius: Metrics.scaled(14) + x: -size - Metrics.scaled(12) + y: root.bubbleIndex >= 0 ? Math.max(0, Math.min(root.height - size, root.centerOf(root.bubbleIndex) - size / 2)) : + 0 + color: Theme.floatingPanel + border.width: Math.max(2, Metrics.scaled(2)) + border.color: Theme.accent + opacity: root.bubbleIndex >= 0 ? 1 : 0 + visible: opacity > 0 + z: 3 + + Behavior on opacity { + NumberAnimation { + duration: Theme.reducedMotion ? 0 : 100 + } + } + + AppText { + anchors.centerIn: parent + text: root.bubbleIndex >= 0 && root.bubbleIndex < root.letters.length + ? root.letters[root.bubbleIndex].label : "" + font.pixelSize: Metrics.scaled(32) + font.weight: Font.Bold + color: Theme.textPrimary + } + } + + MouseArea { + id: pointer + + property int lastIndex: -1 + + anchors.fill: parent + enabled: root.interactive && root.letters.length > 0 + hoverEnabled: true + preventStealing: true + acceptedButtons: Qt.LeftButton + cursorShape: Qt.PointingHandCursor + + function choose(y, final) { + const index = root.indexAt(y) + if (index < 0) + return + if (index !== lastIndex || final) + root.letterChosen(index, final) + lastIndex = index + } + + onPressed: mouse => { + lastIndex = -1 + choose(mouse.y, false) + } + onPositionChanged: mouse => { + if (pressed) + choose(mouse.y, false) + } + onReleased: mouse => choose(mouse.y, true) + } +} diff --git a/qml/primitives/ConfirmationDialog.qml b/qml/primitives/ConfirmationDialog.qml index 77ddc268..85a77ead 100644 --- a/qml/primitives/ConfirmationDialog.qml +++ b/qml/primitives/ConfirmationDialog.qml @@ -27,9 +27,9 @@ FocusScope { function routeKey(key, phase, repeat) { if (InputKeys.isBack(key, false, false)) { - if (phase === "release") + if (phase === "press" && !repeat) dismissed() - return true + return phase === "press" } if (!InputKeys.isDirection(key)) return InputKeys.isAccept(key) @@ -64,6 +64,7 @@ FocusScope { color: "#99000000" TapHandler { + gesturePolicy: TapHandler.ReleaseWithinBounds onTapped: root.dismissed() } } @@ -75,6 +76,8 @@ FocusScope { elevated: true baseColor: Theme.floatingPanel + PopupShield {} + ColumnLayout { id: content anchors.fill: parent diff --git a/qml/primitives/IconButton.qml b/qml/primitives/IconButton.qml index 93fc4377..bd364cee 100644 --- a/qml/primitives/IconButton.qml +++ b/qml/primitives/IconButton.qml @@ -31,6 +31,19 @@ T.Control { height: width focusPolicy: Metrics.keyboardFocusActive && root.focusOnClick ? Qt.StrongFocus : Qt.NoFocus + Accessible.role: Accessible.Button + Accessible.name: root.accessibleName || root.iconText + Accessible.focusable: root.enabled + Accessible.focused: root.activeFocus + Accessible.pressed: tap.pressed + Accessible.onPressAction: { + if (root.enabled) { + if (root.focusOnClick) + InputKeys.focus(root) + root.clicked() + } + } + background: Rectangle { radius: Theme.radiusSmall color: root.chromeless ? (tap.pressed ? Theme.bgHover : "transparent") : tap.pressed ? Theme.bgHover : root.railStyle @@ -68,6 +81,7 @@ T.Control { iconSize: Math.round(Math.min(root.width, root.height, Metrics.scaled(44)) * root.iconRatio) iconColor: Metrics.keyboardFocusActive && root.activeFocus || root.selected ? Theme.accent : Theme.textSecondary + Accessible.ignored: true } AppText { @@ -80,6 +94,7 @@ T.Control { Theme.textSecondary horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + Accessible.ignored: true } } diff --git a/qml/primitives/ImageCard.qml b/qml/primitives/ImageCard.qml index 1ed27715..009e4231 100644 --- a/qml/primitives/ImageCard.qml +++ b/qml/primitives/ImageCard.qml @@ -11,7 +11,23 @@ Item { property color fallbackTint: "transparent" property bool artworkVisible: true property bool artworkEnabled: true + // Hold off starting a load -- the view is racing past -- while keeping + // whatever this card already shows. A recycled card for another item + // shows its blank frame rather than the previous item's picture. + property bool artworkDeferred: false property bool bordered: true + readonly property string wantedSource: artworkEnabled && imageUrl.length > 0 ? artworkSource(imageUrl) : "" + property string appliedSource: "" + + function syncSource() { + if (wantedSource.length === 0 || !artworkDeferred || appliedSource === wantedSource) + appliedSource = wantedSource + else + appliedSource = "" + } + onWantedSourceChanged: syncSource() + onArtworkDeferredChanged: syncSource() + Component.onCompleted: syncSource() readonly property bool artworkReady: !artworkEnabled || imageUrl.length === 0 || artwork.status === Image.Ready || artwork.status === Image.Error @@ -60,7 +76,7 @@ Item { id: artwork anchors.fill: parent anchors.margins: frame.border.width - source: root.artworkEnabled && root.imageUrl.length > 0 ? root.artworkSource(root.imageUrl) : "" + source: root.appliedSource fillMode: Image.PreserveAspectCrop asynchronous: true // Never display the previous delegate's artwork after its source diff --git a/qml/primitives/InputKeys.qml b/qml/primitives/InputKeys.qml index 75b7c4fe..42e923da 100644 --- a/qml/primitives/InputKeys.qml +++ b/qml/primitives/InputKeys.qml @@ -98,6 +98,41 @@ QtObject { return view.currentIndex === index } + function selectionVisiblyUsable(view, clipItem) { + const selected = view.itemAtIndex(view.currentIndex) + if (!view.activeFocus || !selected) + return false + const rect = selected.mapToItem(clipItem, 0, 0, selected.width, selected.height) + const viewport = view.mapToItem(clipItem, 0, 0, view.width, view.height) + const width = Math.max(0, Math.min(rect.x + rect.width, viewport.x + viewport.width, clipItem.width) - Math.max( + rect.x, viewport.x, 0)) + const height = Math.max(0, Math.min(rect.y + rect.height, viewport.y + viewport.height, clipItem.height) - Math.max( + rect.y, viewport.y, 0)) + if (width * height / Math.max(1, rect.width * rect.height) >= focusRecoveryVisibleThreshold) + return true + // Large zoom can make one control exceed the viewport. Keep the + // existing preferred partial-candidate fallback usable in that case. + if ((rect.height > view.height || rect.width > view.width) && width > 0 && height > 0) { + const candidate = topLeftVisibleCandidate(view, clipItem) + return Boolean(candidate && candidate.index === view.currentIndex) + } + return false + } + + function consumeRecoveryGesture(gesture, key, phase, repeat, recovered) { + if (phase === "release") { + if (!repeat && gesture.key === key) + gesture.key = 0 + return true + } + if (gesture.key === key) + return true + if (!recovered) + return false + gesture.key = key + return true + } + function isAccept(key, includeSpace) { return key === Qt.Key_Return || key === Qt.Key_Enter || key === Qt.Key_Select || (includeSpace !== false && key === Qt.Key_Space) diff --git a/qml/primitives/LazyMenuPanel.qml b/qml/primitives/LazyMenuPanel.qml index a98c0c86..de8870c5 100644 --- a/qml/primitives/LazyMenuPanel.qml +++ b/qml/primitives/LazyMenuPanel.qml @@ -9,6 +9,9 @@ Loader { property bool open: false property var model: [] + // The owner's row, normally bound to the owner's own property. The panel + // never writes it, so that binding survives; the person's moves arrive + // through moved() instead. property int currentIndex: 0 property Item edgeEscapeItem: null property string title: "" @@ -20,7 +23,9 @@ Loader { property bool resetVisible: false property bool resetEnabled: false readonly property var menuList: item ? item.menuList : null + property bool rebuilding: false + signal moved(int index) signal accepted(var entry, int index) signal dismissed signal reset @@ -28,6 +33,27 @@ Loader { active: open height: active ? maximumHeight : 0 + // A new model resets a ListView to its first row. Owners rebuild their + // entries on every toggle and expand, so swap the model without telling + // them and put the list back on their row. + function syncModel() { + if (!menuList) + return + rebuilding = true + menuList.model = model + rebuilding = false + syncCurrent() + } + + function syncCurrent() { + if (menuList && menuList.currentIndex !== currentIndex) + menuList.currentIndex = Math.min(currentIndex, menuList.count - 1) + } + + onLoaded: syncModel() + onModelChanged: syncModel() + onCurrentIndexChanged: syncCurrent() + sourceComponent: PopupMenuPanel { id: panel property alias menuList: list @@ -63,11 +89,10 @@ Loader { id: list Layout.fillWidth: true Layout.fillHeight: true - model: root.model entryProvider: index => root.model[index] - currentIndex: root.currentIndex edgeEscapeItem: root.edgeEscapeItem - onCurrentIndexChanged: root.currentIndex = currentIndex + onCurrentIndexChanged: if (!root.rebuilding) + root.moved(currentIndex) onDismissed: root.dismissed() onAccepted: index => root.accepted(root.model[index], index) diff --git a/qml/primitives/MediaItemCard.qml b/qml/primitives/MediaItemCard.qml index fcb0ec56..3045257b 100644 --- a/qml/primitives/MediaItemCard.qml +++ b/qml/primitives/MediaItemCard.qml @@ -22,6 +22,8 @@ Item { property real progress: -1 property bool artworkVisible: true property bool artworkEnabled: true + // Keep what is shown but start nothing new; see ImageCard. + property bool artworkDeferred: false // Where the item comes from, shown over its artwork when set. property url badgeIcon property string badgeText: "" @@ -111,6 +113,7 @@ Item { bordered: false imageUrl: root.imageSource() artworkEnabled: root.artworkEnabled + artworkDeferred: root.artworkDeferred fallbackText: root.fallbackText() fallbackIcon: root.fallbackIcon.length > 0 ? root.fallbackIcon : root.posterKind ? "movie" : "" fallbackTint: root.fallbackTint diff --git a/qml/primitives/MediaRow.qml b/qml/primitives/MediaRow.qml index 028071fa..d2fef089 100644 --- a/qml/primitives/MediaRow.qml +++ b/qml/primitives/MediaRow.qml @@ -40,6 +40,7 @@ FocusScope { readonly property bool hasHeaderAction: typeof headerAction === "function" property var contextMenu: null property bool moveMode: false + property bool publishingMove: false property bool dragActive: false property int dragSourceIndex: -1 property int dragTargetIndex: -1 @@ -50,7 +51,6 @@ FocusScope { 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 readonly property int count: modelCount() @@ -75,27 +75,57 @@ FocusScope { visible: rowVisible onVisibleChanged: if (!visible && (moveMode || dragActive)) finishMove() - onActiveFocusChanged: if (!activeFocus && (moveMode || dragActive)) + onActiveFocusChanged: if (!activeFocus && !publishingMove && (moveMode || dragActive)) finishMove() focus: true - Component.onCompleted: resetPresentation() + // Row stacks recycle whole rows, so one row can go from a JS array of + // libraries to an item model of episodes. Qt's card pool keeps cards + // built for the old kind of model and hands them to the new one, which + // reads null role data and can crash inside the delegate model. When the + // kind changes, assign the model with reuse off: that drains the pool and + // destroys the released cards instead of recycling them. + property string boundModelKind: "" + function modelKind(value) { + if (!value) + return "" + return Array.isArray(value) ? "list" : value.rowCount !== undefined ? "item" : "other" + } + function bindModel() { + const kind = modelKind(root.model) + if (kind === boundModelKind) { + listView.model = root.model + return + } + boundModelKind = kind + listView.reuseItems = false + listView.model = root.model + listView.reuseItems = true + } + + Component.onCompleted: { + bindModel() + resetPresentation() + } onAtomicPopulateChanged: Qt.callLater(resetPresentation) onModelChanged: { - ++modelRevision - if (moveMode || dragActive) + bindModel() + if (!publishingMove && (moveMode || dragActive)) finishMove() - Qt.callLater(resetPresentation) + if (!publishingMove) + Qt.callLater(resetPresentation) } onCountChanged: { - ++modelRevision - currentIndex = count > 0 ? Math.max(0, Math.min(currentIndex, count - 1)) : -1 - if (moveMode || dragActive) - finishMove() + if (!publishingMove) { + 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) + if (!publishingMove) + Qt.callLater(resetPresentation) } onCurrentIndexChanged: syncViewCurrentIndex() onReorderEnabledChanged: if (!reorderEnabled && (moveMode || dragActive)) @@ -116,33 +146,20 @@ FocusScope { target: root.model && root.model.rowCount !== undefined ? root.model : null ignoreUnknownSignals: true - function onDataChanged() { - ++root.modelRevision - } function onModelReset() { - ++root.modelRevision - root.finishMove() - root.currentIndex = root.count > 0 ? Math.max(0, Math.min(root.currentIndex, root.count - 1)) : -1 + if (!root.publishingMove) { + 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 - } - function onRowsMoved() { - ++root.modelRevision - } - function onRowsRemoved() { - ++root.modelRevision - } } function modelCount() { return ModelAccess.count(model) } - // revision is unused but must stay in the signature: bindings pass - // modelRevision so a model change re-evaluates them. - function itemAt(index, revision) { + function itemAt(index) { return ModelAccess.at(model, index) } @@ -162,6 +179,15 @@ FocusScope { return InputKeys.topLeftVisibleCandidate(listView, outerViewport) } + function selectionVisiblyUsable(outerViewport) { + if (headerButton.activeFocus) + return true + for (let index = 0; index < carouselButtons.children.length; ++index) + if (carouselButtons.children[index].activeFocus) + return true + return InputKeys.selectionVisiblyUsable(listView, outerViewport) + } + function focusIndexWithoutScrolling(index) { if (!InputKeys.focusIndexWithoutScrolling(listView, index)) return false @@ -281,12 +307,16 @@ FocusScope { "deferBackdropDismissal": Boolean(deferBackdropDismissal) })) } - function beginMoveById(libraryId) { - for (let index = 0; index < count; ++index) { + function libraryIndex(libraryId) { + for (let index = 0; index < count; ++index) if (String(itemAt(index).libraryId || "") === libraryId) - return beginMove(index) - } - return false + return index + return -1 + } + + function beginMoveById(libraryId) { + const index = libraryIndex(libraryId) + return index >= 0 && beginMove(index) } function beginMove(index) { @@ -312,14 +342,27 @@ FocusScope { 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 + const libraryId = String(itemAt(from).libraryId || "") + const scrollOffset = listView.contentX - listView.originX + publishingMove = true + try { + if (from !== to && !moveItem(from, to)) + return false + const movedIndex = libraryIndex(libraryId) + if (movedIndex < 0) { + finishMove() + return false + } + currentIndex = movedIndex + syncViewCurrentIndex() + listView.forceLayout() + listView.contentX = listView.originX + scrollOffset + listView.positionViewAtIndex(movedIndex, ListView.Contain) + focusList() + return true + } finally { + publishingMove = false + } } function moveSelected(direction) { @@ -387,27 +430,46 @@ FocusScope { id: card required property int index - readonly property var cardData: root.itemAt(index, root.modelRevision) - readonly property var cardItem: root.cardKind === "library" ? (cardData.item || ({})) : cardData + required property var modelData + // Qt supplies a live role object for our native models, and the + // element itself for a QVariantList/JS array. Read the item role + // directly so one changed row does not re-fetch every card. + readonly property var cardData: modelData || ({}) + readonly property var cardItem: { + // Bindings can evaluate before cardData's own binding lands + // during delegate construction; treat that as empty. + const data = cardData || ({}) + const itemRole = data.item + return libraryCard ? (itemRole || ({})) : itemRole !== undefined ? itemRole : data + } readonly property bool libraryCard: root.cardKind === "library" readonly property bool personCard: root.cardKind === "person" - readonly property var badge: root.cardBadge ? root.cardBadge(cardData) : null + readonly property var badge: root.cardBadge ? root.cardBadge(cardData || ({})) : null + + // The same ordering hazard as cardItem: a binding below can run + // before cardData's does, while it is still null. Reading a role + // off null failed the binding and left, say, a library card with + // no name until something else made it re-evaluate. + function role(name) { + const data = cardData || modelData + return data ? data[name] : undefined + } width: root.cardWidth height: listView.height shell: libraryCard || personCard ? null : root.shell kind: libraryCard ? "landscape" : personCard ? "poster" : root.cardKind item: cardItem || ({}) - titleOverride: libraryCard ? String(cardData.name || "") : personCard ? String(cardData.name || "") : "" - subtitleOverride: libraryCard ? String(badge && badge.detail || cardData.collectionType || "") : personCard - ? String(cardData.role || cardData.type || "") : "" + titleOverride: libraryCard || personCard ? String(role("name") || "") : "" + subtitleOverride: libraryCard ? String(badge && badge.detail || role("collectionType") || "") : personCard + ? String(role("role") || role("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" : "" - fallbackTint: libraryCard ? Theme.libraryTint(cardData.name) : "transparent" + fallbackIcon: libraryCard ? Theme.libraryIcon(role("collectionType")) : personCard ? "person" : "" + fallbackTint: libraryCard ? Theme.libraryTint(role("name")) : "transparent" useSeriesPoster: root.useSeriesPoster preferEpisodeTitle: root.preferEpisodeTitle focused: root.keyboardFocusActive && root.focusVisible && card.index === listView.currentIndex @@ -580,7 +642,7 @@ FocusScope { leftMargin: root.focusPadding rightMargin: root.allowTrailingSpace ? Math.max(root.focusPadding, width - root.cardWidth) : root.focusPadding reuseItems: true - model: root.model + // Assigned by bindModel(), never bound: see boundModelKind. delegate: cardDelegate highlightFollowsCurrentItem: true diff --git a/qml/primitives/MenuListView.qml b/qml/primitives/MenuListView.qml index c0934551..bdc2d5b8 100644 --- a/qml/primitives/MenuListView.qml +++ b/qml/primitives/MenuListView.qml @@ -46,7 +46,7 @@ ListView { function firstEnabled(start, step) { const direction = step < 0 ? -1 : 1 - for (let index = Math.max(0, Math.min(count - 1, start)); index >= 0 && index < count; index += direction) + for (let index = start; index >= 0 && index < count; index += direction) if (isRowEnabled(index)) return index return -1 diff --git a/qml/primitives/NavGrid.qml b/qml/primitives/NavGrid.qml index ba0109eb..aa82d70f 100644 --- a/qml/primitives/NavGrid.qml +++ b/qml/primitives/NavGrid.qml @@ -17,6 +17,16 @@ GridView { property int holdReleaseTimeout: 220 property int holdInitialReleaseTimeout: 800 property bool holdRepeatSeen: false + // Wall-clock time the release watchdog was last armed, and how long a + // hold has been carried through stalls without hearing the key again. + property double holdWatchdogArmedAt: 0 + property double holdStallGraceMs: 0 + readonly property int holdStallGraceLimit: 1500 + // Items per second a held key is moving at right now. Past a screenful + // or two a second nothing on the way can be seen, so a view can skip the + // artwork and loading for what it is only passing through. + property real holdRate: 0 + readonly property bool racing: heldKey !== 0 && holdRate >= 120 property int holdDelay: 500 property int holdCruiseDuration: 700 property int holdRampDuration: 1700 @@ -75,6 +85,8 @@ GridView { heldKey = 0 holdRepeatSeen = false holdAccumulator = 0 + holdStallGraceMs = 0 + holdRate = 0 } function nowMs() { @@ -88,16 +100,39 @@ GridView { holdRepeatSeen = false holdAccumulator = 0 holdTimer.start() + armReleaseWatchdog() + } + + function armReleaseWatchdog() { + holdWatchdogArmedAt = Date.now() holdReleaseWatchdog.restart() } + // Silence from the key ends a hold -- unless the silence was this app's + // own. A view busy building rows (a large library being raced through + // while its pages arrive) can block the event loop past the watchdog's + // deadline with the next repeat already queued behind it; the watchdog + // then fires late, first, and reads a stall as a release. A late firing + // gets the key another interval to speak, up to a bound so a release the + // platform never sends still ends the hold. + function releaseWatchdogExpired() { + const lateMs = Date.now() - holdWatchdogArmedAt - holdReleaseWatchdog.interval + if (heldKey && lateMs > 40 && holdStallGraceMs < holdStallGraceLimit) { + holdStallGraceMs += lateMs + holdReleaseWatchdog.interval + armReleaseWatchdog() + return + } + stopAccelerating() + } + function confirmHold() { if (!holdRepeatSeen) { holdRepeatSeen = true lastHoldTickAt = nowMs() holdStarted(heldKey) } - holdReleaseWatchdog.restart() + holdStallGraceMs = 0 + armReleaseWatchdog() } function heldDelta() { @@ -130,6 +165,7 @@ GridView { const frameMs = Math.min(50, Math.max(0, now - lastHoldTickAt)) lastHoldTickAt = now const rate = accelerationRate(heldMs) + holdRate = rate if (rate <= 0) return holdAccumulator += rate * frameMs / 1000 @@ -230,6 +266,6 @@ GridView { id: holdReleaseWatchdog interval: root.holdRepeatSeen ? root.holdReleaseTimeout : root.holdInitialReleaseTimeout repeat: false - onTriggered: root.stopAccelerating() + onTriggered: root.releaseWatchdogExpired() } } diff --git a/qml/primitives/OptionPickerDialog.qml b/qml/primitives/OptionPickerDialog.qml index 4f5aef75..80fa44d2 100644 --- a/qml/primitives/OptionPickerDialog.qml +++ b/qml/primitives/OptionPickerDialog.qml @@ -21,9 +21,12 @@ FocusScope { ? anchorItem.width * 0.78 : metrics.scaled( 460)))) - readonly property real panelHeight: Math.min(height - edgeMargin * 2, Math.max(rowHeight + metrics.scaled(16), Math.min( - options.length, 8) * rowHeight - + metrics.scaled(16))) + readonly property real listPadding: metrics.scaled(6) + readonly property real headerHeight: title.length > 0 ? titleLabel.implicitHeight + metrics.scaled(22) : 0 + readonly property real panelHeight: Math.min(height - edgeMargin * 2, headerHeight + Math.max(1, Math.min(options.length, + 8)) * rowHeight + + listPadding * 2) + readonly property bool scrolls: options.length * rowHeight > optionList.height + 1 signal selected(int index) signal dismissed @@ -69,6 +72,9 @@ FocusScope { if (deficit > 1 && !spaceRequested) { spaceRequested = true spaceBelowRequired(deficit) + // A host may scroll to make room, but hosts without a scroll + // handler still need the clamped/above-anchor placement below. + Qt.callLater(completePresentation) return false } @@ -82,9 +88,11 @@ FocusScope { function routeKey(key, phase, repeat) { if (inputKeys.isBack(key, false, false)) { - if (phase === "release") + if (phase === "press" && !repeat) dismissed() - return true + // KeyRouter swallows the release of a claimed Back press. An + // isolated release can fall through to back() instead. + return phase === "press" } if (inputKeys.isDirection(key)) { if (phase === "press" && key === Qt.Key_Up) @@ -108,6 +116,8 @@ FocusScope { Component.onCompleted: schedulePresentation() onVisibleChanged: schedulePresentation() onAnchorItemChanged: schedulePresentation() + onWidthChanged: schedulePresentation() + onHeightChanged: schedulePresentation() MouseArea { anchors.fill: parent @@ -115,6 +125,18 @@ FocusScope { onClicked: root.dismissed() } + // A cheap lift off the page; a blurred shadow costs too much on a TV. + Rectangle { + x: menuPanel.x - root.metrics.scaled(2) + y: menuPanel.y + root.metrics.scaled(6) + width: menuPanel.width + root.metrics.scaled(4) + height: menuPanel.height + radius: menuPanel.radius + root.metrics.scaled(2) + color: "#66000000" + visible: menuPanel.visible + opacity: menuPanel.opacity + } + PopupMenuPanel { id: menuPanel objectName: "optionPickerPanel" @@ -122,12 +144,53 @@ FocusScope { open: root.visible && root.placementReady openHeight: root.panelHeight baseColor: Theme.floatingPanel + border.color: Theme.borderStrong + opacity: open ? 1 : 0 + + Behavior on opacity { + enabled: !Theme.reducedMotion + NumberAnimation { + duration: 110 + easing.type: Easing.OutCubic + } + } + + AppText { + id: titleLabel + visible: root.title.length > 0 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: root.metrics.scaled(16) + anchors.rightMargin: root.metrics.scaled(16) + anchors.topMargin: root.metrics.scaled(12) + text: root.title + color: Theme.textMuted + font.pixelSize: root.metrics.metaSizePx + font.weight: Font.DemiBold + maximumLineCount: 1 + elide: Text.ElideRight + Accessible.role: Accessible.Heading + Accessible.name: text + } + + Rectangle { + visible: titleLabel.visible + anchors.left: parent.left + anchors.right: parent.right + y: root.headerHeight - 1 + height: 1 + color: Theme.border + } MenuListView { id: optionList objectName: "optionPickerList" anchors.fill: parent - anchors.margins: root.metrics.scaled(8) + anchors.topMargin: root.headerHeight + root.listPadding + anchors.bottomMargin: root.listPadding + anchors.leftMargin: root.listPadding + anchors.rightMargin: root.listPadding + (root.scrolls ? scrollBar.visualWidth + root.metrics.scaled(6) : 0) model: root.options currentIndex: root.currentIndex onDismissed: root.dismissed() @@ -141,10 +204,20 @@ FocusScope { minimumRowHeight: root.metrics.controlHeightPx label: String(modelData) checked: index === root.currentIndex + checkIconName: "check" highlighted: optionList.activeFocus && optionList.currentIndex === index onHovered: optionList.currentIndex = index onActivated: root.selected(index) } } + + ListScrollBar { + id: scrollBar + visible: root.scrolls + anchors.top: optionList.top + anchors.bottom: optionList.bottom + anchors.right: parent.right + flickable: optionList + } } } diff --git a/qml/primitives/SelectRow.qml b/qml/primitives/SelectRow.qml index 2fb52384..b972a4f2 100644 --- a/qml/primitives/SelectRow.qml +++ b/qml/primitives/SelectRow.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Layouts import "../theme" SettingRow { @@ -6,6 +7,8 @@ SettingRow { property var options: [] property int currentIndex: 0 + // Set while this row's option list is open, so the field reads as open. + property bool expanded: false readonly property string selectedText: options.length > 0 ? String(options[Math.max(0, Math.min(options.length - 1, currentIndex))]) : "" @@ -25,49 +28,69 @@ SettingRow { onClicked: opened() - Rectangle { - readonly property int sidePadding: Metrics.scaled(16) + trailing: Rectangle { + id: field + readonly property int sidePadding: Metrics.scaled(14) - anchors.right: parent.right - anchors.rightMargin: Metrics.scaled(14) - anchors.verticalCenter: parent.verticalCenter - // Hug the value instead of always reserving the widest pill: short - // answers like "Blue" should not sit in a field of empty chrome. - width: Math.max(Metrics.scaled(120), Math.min(valueLabel.implicitWidth + dropdownIcon.width + sidePadding * 2 - + Metrics.scaled(10), Math.max(Metrics.scaled(180), root.width - * 0.46))) - height: Math.max(Metrics.scaled(40), Metrics.controlHeightPx) - radius: Theme.radiusMedium - color: root.rowFocus ? Theme.accentPanel : Theme.bgRaised - border.width: root.rowFocus ? Theme.focusBorderWidth : Theme.hoverBorderWidth - border.color: root.rowFocus ? Theme.accent : Theme.borderStrong + Layout.alignment: Qt.AlignVCenter + // Hug the value instead of always reserving the widest field: short + // answers like "Blue" should not sit in a field of empty chrome. The + // row layout reserves this space so long labels cannot run under it. + Layout.preferredWidth: Math.min(root.availableWidth * 0.5, Math.max(Metrics.scaled(140), Math.min(valueLabel.implicitWidth + + chevron.width + + sidePadding + * 2 + Metrics.scaled( + 12), Math.max( + Metrics.scaled( + 180), root.width + * 0.46)))) + Layout.preferredHeight: Math.max(Metrics.scaled(42), Metrics.controlHeightPx) + radius: Theme.radiusLarge + color: root.rowFocus || root.expanded ? Theme.inputFillActive : Theme.inputFill + border.width: root.rowFocus || root.expanded ? Theme.focusBorderWidth : Theme.hoverBorderWidth + border.color: root.rowFocus || root.expanded ? Theme.accent : fieldHover.hovered ? Theme.inputBorderHover : + Theme.inputBorder antialiasing: true + HoverHandler { + id: fieldHover + } + AppText { id: valueLabel anchors.left: parent.left - anchors.right: dropdownIcon.left + anchors.right: chevron.left anchors.top: parent.top anchors.bottom: parent.bottom anchors.leftMargin: parent.sidePadding - anchors.rightMargin: Metrics.scaled(10) + anchors.rightMargin: Metrics.scaled(8) text: root.selectedText color: Theme.textPrimary - font.pixelSize: Metrics.metaSizePx - font.weight: Font.DemiBold + font.pixelSize: Metrics.metaSizePx + Metrics.scaled(1) + font.weight: Font.Medium horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } MaterialIcon { - id: dropdownIcon + id: chevron anchors.right: parent.right - anchors.rightMargin: parent.sidePadding - Metrics.scaled(2) + anchors.rightMargin: parent.sidePadding - Metrics.scaled(4) anchors.verticalCenter: parent.verticalCenter name: "expand_more" iconSize: Math.max(20, Metrics.iconSizePx) - iconColor: root.options.length > 0 ? Theme.textPrimary : Theme.textDisabled + iconColor: root.options.length === 0 ? Theme.textDisabled : root.rowFocus || root.expanded ? Theme.accent : + Theme.textSecondary + rotation: root.expanded ? 180 : 0 + + Behavior on rotation { + enabled: !Theme.reducedMotion + NumberAnimation { + duration: 140 + easing.type: Easing.OutCubic + } + } } } } diff --git a/qml/primitives/ServerCard.qml b/qml/primitives/ServerCard.qml index 043261e6..11583fa1 100644 --- a/qml/primitives/ServerCard.qml +++ b/qml/primitives/ServerCard.qml @@ -12,7 +12,9 @@ FocusScope { property string providerName: "" property string providerId: "" property url providerIcon - property string providerVersion: "" + property string iconName: "dns" + // A short state shown at the end of the row, such as "Added". + property string badge: "" readonly property int brandingHeight: providerName.length > 0 ? Metrics.scaled(30) : 0 signal accepted @@ -25,10 +27,7 @@ FocusScope { focusPolicy: Qt.StrongFocus Accessible.role: Accessible.Button Accessible.name: title - Accessible.description: [serverAddress, providerName.length > 0 ? providerName + (providerVersion.length > 0 ? ", installed provider version " - + providerVersion : - "") : ""].filter( - part => part.length > 0).join(". ") + Accessible.description: [serverAddress, badge, providerName].filter(part => part.length > 0).join(". ") Accessible.onPressAction: root.accepted() Rectangle { @@ -49,7 +48,7 @@ FocusScope { anchors.bottomMargin: root.brandingHeight MaterialIcon { - name: "dns" + name: root.iconName iconSize: Metrics.scaled(22) iconColor: root.focused ? Theme.accent : Theme.textMuted } @@ -77,6 +76,25 @@ FocusScope { elide: Text.ElideRight } } + + Rectangle { + visible: root.badge.length > 0 + Layout.preferredHeight: Metrics.scaled(28) + Layout.preferredWidth: badgeText.implicitWidth + Metrics.scaled(22) + radius: height / 2 + color: Theme.bgPanel + border.width: 1 + border.color: Theme.border + + AppText { + id: badgeText + anchors.centerIn: parent + text: root.badge + color: Theme.textSecondary + font.pixelSize: Metrics.metaSizePx + font.weight: Font.DemiBold + } + } } ProviderIcon { @@ -92,20 +110,6 @@ FocusScope { seed: root.providerId Accessible.ignored: true } - SecondaryText { - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.rightMargin: Metrics.scaled(14) - anchors.bottomMargin: Metrics.scaled(6) - width: Math.max(0, parent.width - Metrics.scaled(60)) - visible: root.providerVersion.length > 0 - text: root.providerVersion - horizontalAlignment: Text.AlignRight - font.pixelSize: Metrics.metaSizePx - elide: Text.ElideRight - Accessible.ignored: true - } - TapHandler { onTapped: root.accepted() } diff --git a/qml/primitives/SliderRow.qml b/qml/primitives/SliderRow.qml index b7846328..2a002105 100644 --- a/qml/primitives/SliderRow.qml +++ b/qml/primitives/SliderRow.qml @@ -102,9 +102,12 @@ SettingRow { Layout.preferredHeight: Math.max(Metrics.scaled(34), Math.round(Metrics.controlHeightPx * 0.78)) Layout.alignment: Qt.AlignVCenter radius: Theme.radiusMedium - color: Theme.bgRaised + // Only drawn as a field where it is one; on a TV it is a readout. + color: valueField.readOnly ? Theme.bgRaised : valueField.activeFocus ? Theme.inputFillActive : + Theme.inputFill + border.width: valueField.activeFocus ? Theme.focusBorderWidth : Theme.hoverBorderWidth - border.color: valueField.activeFocus ? Theme.accent : Theme.border + border.color: valueField.activeFocus ? Theme.accent : valueField.readOnly ? Theme.border : Theme.inputBorder antialiasing: true AppText { diff --git a/qml/primitives/SwitchIndicator.qml b/qml/primitives/SwitchIndicator.qml new file mode 100644 index 00000000..b97d3b17 --- /dev/null +++ b/qml/primitives/SwitchIndicator.qml @@ -0,0 +1,40 @@ +import QtQuick +import "../theme" + +// The on/off track drawn beside a setting. Focus outlines it in the text +// colour, which stays visible against an accent-filled track; off draws an +// input-strength edge so the empty track still reads as a control. +Rectangle { + id: track + + property bool checked: false + property bool focused: false + property bool animate: false + readonly property int inset: Math.max(2, Metrics.scaled(3)) + + implicitWidth: Metrics.scaled(52) + implicitHeight: Metrics.scaled(30) + radius: height / 2 + color: checked ? Theme.accent : Theme.inputFill + border.width: focused ? Theme.focusBorderWidth : Theme.hoverBorderWidth + border.color: focused ? Theme.textPrimary : checked ? Theme.accent : Theme.inputBorder + antialiasing: true + + Rectangle { + y: (parent.height - height) / 2 + x: track.checked ? parent.width - width - track.inset : track.inset + width: parent.height - track.inset * 2 + height: width + radius: width / 2 + color: track.checked ? Theme.accentText : Theme.textSecondary + antialiasing: true + + Behavior on x { + enabled: track.animate && !Theme.reducedMotion + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + } +} diff --git a/qml/primitives/TextFieldRow.qml b/qml/primitives/TextFieldRow.qml index 59746fd4..3939124c 100644 --- a/qml/primitives/TextFieldRow.qml +++ b/qml/primitives/TextFieldRow.qml @@ -16,6 +16,8 @@ T.Control { readonly property bool secret: echoMode === TextInput.Password || echoMode === TextInput.PasswordEchoOnEdit property int enterKeyType: Qt.EnterKeyDefault property string label: "" + // A leading glyph, e.g. "search", that says what the field is for. + property string iconName: "" property string accessibleName: label.length > 0 ? label : placeholderText readonly property bool editing: field.activeFocus readonly property bool masked: secret && !secretVisible @@ -78,25 +80,43 @@ T.Control { return true } + readonly property real textInset: icon.visible ? icon.x + icon.width + Metrics.scaled(10) : Metrics.scaled(15) + + HoverHandler { + id: hover + } + background: Rectangle { radius: Theme.radiusMedium - color: row.editing ? Theme.bgRaised : Theme.bgPanel - border.width: (row.activeFocus || row.editing) ? Theme.focusBorderWidth : 1 - border.color: (row.activeFocus || row.editing) ? Theme.accent : Theme.border + color: row.editing ? Theme.inputFillActive : Theme.inputFill + border.width: (row.activeFocus || row.editing) ? Theme.focusBorderWidth : Math.max(1, Metrics.scaled(2)) + border.color: (row.activeFocus || row.editing) ? Theme.accent : hover.hovered ? Theme.inputBorderHover : + Theme.inputBorder SecondaryText { anchors.left: parent.left anchors.top: parent.top - anchors.leftMargin: Metrics.scaled(16) + anchors.leftMargin: row.textInset + Metrics.scaled(1) anchors.topMargin: Metrics.scaled(7) visible: row.label.length > 0 text: row.label - color: Theme.textMuted + color: Theme.textSecondary font.pixelSize: Metrics.metaSizePx + Metrics.scaled(2) font.weight: Font.Medium } } + MaterialIcon { + id: icon + x: Metrics.scaled(14) + anchors.verticalCenter: parent.verticalCenter + visible: row.iconName.length > 0 + name: row.iconName + iconSize: Math.round(Metrics.bodySizePx * 1.4) + iconColor: row.editing || row.activeFocus ? Theme.textPrimary : Theme.textSecondary + Accessible.ignored: true + } + T.TextField { id: field Accessible.name: row.accessibleName @@ -105,12 +125,14 @@ T.Control { | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase : 0) EnterKey.type: row.enterKeyType anchors.fill: parent - anchors.leftMargin: Metrics.scaled(15) + anchors.leftMargin: row.textInset 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 {} color: Theme.textPrimary + selectedTextColor: Theme.textPrimary + selectionColor: Theme.accentDim // The UI face has no U+25CF, the mask character Qt asks for by // default, so masked text would fall back to a stranger's glyph or a // blank box. U+2022 is in the face; bold and spaced it draws the row @@ -165,7 +187,7 @@ T.Control { anchors.fill: parent visible: field.displayText.length === 0 && field.placeholderText.length > 0 text: field.placeholderText - color: Theme.textDisabled + color: Theme.textMuted font.pixelSize: Metrics.bodySizePx + Metrics.scaled(2) verticalAlignment: Text.AlignVCenter elide: Text.ElideRight diff --git a/qml/primitives/ToggleRow.qml b/qml/primitives/ToggleRow.qml index 3c81fb53..a2b6f195 100644 --- a/qml/primitives/ToggleRow.qml +++ b/qml/primitives/ToggleRow.qml @@ -25,36 +25,11 @@ SettingRow { onClicked: toggle() trailing: [ - Rectangle { - id: track - readonly property int inset: Math.max(2, Metrics.scaled(3)) - + SwitchIndicator { Layout.alignment: Qt.AlignVCenter - implicitWidth: Metrics.scaled(52) - implicitHeight: Metrics.scaled(30) - radius: height / 2 - color: root.checked ? Theme.accent : Theme.bgRaised - border.width: root.rowFocus ? Theme.focusBorderWidth : Theme.hoverBorderWidth - border.color: root.rowFocus ? Theme.textPrimary : root.checked ? Theme.accent : Theme.borderStrong - antialiasing: true - - Rectangle { - y: (parent.height - height) / 2 - x: root.checked ? parent.width - width - track.inset : track.inset - width: parent.height - track.inset * 2 - height: width - radius: width / 2 - color: root.checked ? Theme.accentText : Theme.textSecondary - antialiasing: true - - Behavior on x { - enabled: root.animateChange && !Theme.reducedMotion - NumberAnimation { - duration: 120 - easing.type: Easing.OutCubic - } - } - } + checked: root.checked + focused: root.rowFocus + animate: root.animateChange } ] diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 8180d561..70c2e875 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -1,4 +1,5 @@ import QtQuick +import QtCore as Persist import QtQuick.Layouts import "../theme" import "../primitives" @@ -123,8 +124,8 @@ KeyRouter { } } - // Qt's logical pixel on Android is exactly an Android dp, and Android has - // already decided what a dp should be for the panel in front of it: a + // Android dp and iOS points are distance-corrected logical pixels. Android + // has already decided what a dp should be for the panel in front of it: a // handset reports a few hundred dp across because it is held at arm's // length, and a television reports about 960x540 because it is watched // from across a room. Scoring that television viewport at 1.0 is what @@ -135,7 +136,7 @@ KeyRouter { Binding { target: Metrics property: "baselinePx" - value: Platform.isAndroid ? 720 : 1440 + value: Platform.isAndroid || Platform.isMobile ? 720 : 1440 restoreMode: Binding.RestoreNone } @@ -215,7 +216,6 @@ KeyRouter { 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) @@ -393,12 +393,33 @@ KeyRouter { root.applyInitializedRoute() } function onAccountSetupStarted(accountId) { + // The accounts page shows the new profile connecting, and keeps + // any failure beside it; success needs nothing from the viewer. + root.linkingAccountId = accountId Router.reset("accounts", { "onboarding": accountId }) root.navigationTarget = routeStack InputKeys.focus(routeStack) } + // A new sign-in opens straight into its library. The profile just + // signed in to is what Spool opens with from now on, unless this + // server was already set to ask who is watching; both are changed on + // the accounts page, not asked about after every sign-in. + function onAccountSelectionFinished(accountId, selected) { + if (accountId !== root.linkingAccountId) + return + root.linkingAccountId = "" + if (!selected) + return + const account = Providers.accounts.find(entry => entry.id === accountId) + if (!account || String(account.startupMode || "") !== "ask") + Providers.setStartupChoice(accountId, "always") + Providers.finishOnboarding(accountId) + root.goHome() + if (account) + toast.show("Signed in" + (account.detail ? " to " + account.detail : "")) + } function onComponentRequested(context) { root.providerOverlay = context } @@ -417,6 +438,9 @@ KeyRouter { if (root.networkConsentPending) Providers.resolveNetworkConsent(String(root.networkConsent.id), false) } + // The account whose sign-in has just finished and is still connecting. + property string linkingAccountId: "" + function showToastAction(message, actionText, callback) { toast.showAction(message, actionText, callback) } @@ -456,17 +480,8 @@ KeyRouter { } } else if (root.route === "personDetails") { const personId = String(args.personId || "") - if (personId.length <= 0) { + if (personId.length <= 0) Router.reset("home") - } else { - personItem = { - id: personId, - name: String(args.personName || "Person"), - role: String(args.personRole || ""), - type: String(args.personType || "Person") - } - Content.loadPersonItems(personItem.id) - } } Router.finishRecovery() return true @@ -614,11 +629,58 @@ KeyRouter { ? request.source : "", request ? request.focusIndex : 0) } - function openDetailsAt(model, index, source, returnRoute) { + function openDetailsAt(model, index, source, returnRoute, parent) { const nextModel = model || (Browse.items) - return commitDetailsRoute(RoutePolicy.detailsRouteAt(nextModel, index, source, returnRoute, route), source, + return commitDetailsRoute(RoutePolicy.detailsRouteAt(nextModel, index, source, returnRoute, route, parent), source, index) + } + // Details pages remember, per account and item type, how much overview + // and credit space their last fully loaded page took, so the next first + // load holds the right space. Kept here and on disk, so it outlives a + // trimmed page and a restart; keys are opaque account prefixes and types. + readonly property var detailLayoutMemory: { + try { + return JSON.parse(detailLayoutStore.memory) + } catch (error) { + return ({}) + } + } + function rememberDetailLayout(key, layout) { + const memory = Object.assign({}, detailLayoutMemory) + if (JSON.stringify(memory[key]) === JSON.stringify(layout)) + return + memory[key] = layout + detailLayoutStore.memory = JSON.stringify(memory) + } + Persist.Settings { + id: detailLayoutStore + category: "detailLayout" + property string memory: "{}" + } + + // spoolet open: select an item the way a person would, so automation + // exercises the same route, model and snapshot as a real selection. + function automationOpen(source, index) { + const page = activePage + const onDetails = route === "itemDetails" && page + if (source === "context" && onDetails && page.openContextItem) { + if (index < 0 || index >= Content.detailSeasons.count) + return false + page.openContextItem(index) + return true + } + if (source === "similar" && onDetails && page.openSimilarItem) { + if (index < 0 || index >= Content.detailSimilarItems.count) + return false + page.openSimilarItem(index) + return true + } + const model = source === "browse" ? Browse.items : source === "resume" ? Home.resumeItems : source === "next-up" + ? Home.nextUpItems : null + if (!model || index < 0 || index >= model.count) + return false + return openDetailsAt(model, index, source === "browse" ? "movies" : source, route) } function openSeriesDetails(seriesId, seriesName, returnRoute) { @@ -927,22 +989,20 @@ KeyRouter { } function openPerson(person) { - personItem = person || ({}) - const personId = String(personItem.id || "") + const personId = String(person && person.id || "") if (personId.length <= 0) return false - if (Content) - Content.loadPersonItems(personId) - if (route === "personDetails") { - InputKeys.focus(routeStack) - return true - } - pushRoute("personDetails", { - personId: personId, - personName: String(personItem.name || "Person"), - personRole: String(personItem.role || ""), - personType: String(personItem.type || "Person") - }) + const args = { + personId: personId, + personName: String(person.name || "Person"), + personRole: String(person.role || ""), + personType: String(person.type || "Person"), + personImageTag: String(person.imageTag || "") + } + if (route === "personDetails" && String(routeArgs.personId || "") === personId) + replaceRoute("personDetails", args) + else + pushRoute("personDetails", args) return true } @@ -1124,7 +1184,7 @@ KeyRouter { TopBar { id: navBar - anchors.rightMargin: openFilesButton.visible ? openFilesButton.width + Metrics.scaled(14) : 0 + trailingCells: openFilesButton.visible ? 1 : 0 objectName: "shellNavigationBar" anchors.left: parent.left anchors.right: parent.right @@ -1158,9 +1218,9 @@ KeyRouter { IconButton { id: openFilesButton objectName: "shellOpenFiles" - anchors.right: parent.right - anchors.rightMargin: Metrics.scaled(14) + x: navBar.x + navBar.trailingCellCenterX - width / 2 y: navBar.y + (navBar.height - height) / 2 + railStyle: true visible: root.desktopFilesAvailable && navBar.visible && !root.playerHoldsScreen enabled: !root.fileInputBlocked && !(root.fileDropDialog && root.fileDropDialog.opened) iconName: "folder_open" @@ -1230,11 +1290,25 @@ KeyRouter { DropArea { id: desktopDropArea objectName: "shellDesktopDropArea" + property var enteredUrls: [] + function copyUrls(urls) { + return Array.from(urls, url => String(url)) + } + onEntered: drag => { + // Native MIME data can expire before drop. Own values, never the event wrapper. + const urls = copyUrls(drag.urls) + enteredUrls = drag.hasUrls ? urls : [] + } + onExited: enteredUrls = [] anchors.fill: parent enabled: root.desktopFilesAvailable onDropped: drop => { - if (drop.hasUrls && root.fileDropDialog) { - root.fileDropDialog.submitUrls(drop.urls) + // Read the native getter once while this event is alive; do no work until drop. + const freshUrls = copyUrls(drop.urls) + const urls = drop.hasUrls ? (freshUrls.length > 0 ? freshUrls : enteredUrls.slice()) : [] + enteredUrls = [] + if (urls.length > 0 && root.fileDropDialog) { + root.fileDropDialog.submitUrls(urls) drop.acceptProposedAction() } } diff --git a/qml/shell/DiagnosticsOverlay.qml b/qml/shell/DiagnosticsOverlay.qml index dc1e568c..27985a3a 100644 --- a/qml/shell/DiagnosticsOverlay.qml +++ b/qml/shell/DiagnosticsOverlay.qml @@ -12,6 +12,12 @@ Item { // singleton to report on. readonly property var syncPlay: ProviderCapabilities.groupPlayback ? Group : null readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false + // Rows with nothing behind them are left out rather than shown as zeros: + // a column of 0.00 reads as a measurement, not an absence. + readonly property bool playbackActive: Player.sessionActive + readonly property bool latencyRecorded: InputLatency.enabled + + Component.onCompleted: SystemPerformance.observe(root) function formatBytes(bytes) { const value = Math.max(0, Number(bytes || 0)) @@ -75,14 +81,22 @@ Item { SystemPerformance.processCpuPercent) } SecondaryText { + visible: !root.playbackActive + text: "Playback idle" + color: Theme.textMuted + } + SecondaryText { + visible: root.playbackActive text: "mpv total " + root.cpu(SystemPerformance.mpvCpuPercent) + " video decode " + root.cpu( SystemPerformance.videoDecodeCpuPercent) } SecondaryText { + visible: root.playbackActive text: "audio decode " + root.cpu(SystemPerformance.audioDecodeCpuPercent) + " output " + root.cpu( SystemPerformance.audioOutputCpuPercent) } SecondaryText { + visible: root.playbackActive text: "Dropped frames decoder " + Player.decoderDroppedFrames + " output " + Player.outputDroppedFrames + " late " + Player.delayedFrames } @@ -90,6 +104,7 @@ Item { // to audio does not drop frames when it cannot keep up; it runs // slow, and only the rate shows it. SecondaryText { + visible: root.playbackActive text: "Frame rate " + Player.outputFps.toFixed(2) + " of " + Player.containerFps.toFixed(2) + " fps" } SecondaryText { @@ -107,10 +122,19 @@ Item { SystemPerformance.systemAvailableBytes) } SecondaryText { + visible: !root.latencyRecorded + Layout.maximumWidth: Metrics.scaled(396) + text: "Input latency not recorded · turn on Record input latency" + color: Theme.textMuted + elide: Text.ElideRight + } + SecondaryText { + visible: root.latencyRecorded text: "Input " + InputLatency.lastLatencyMs.toFixed(2) + " ms worst " + InputLatency.worstLatencyMs.toFixed( 2) + " ms budget " + InputLatency.frameBudgetMs.toFixed(2) + " ms" } SecondaryText { + visible: root.latencyRecorded text: "Frames late " + InputLatency.lateCount + " missed " + InputLatency.missedFrameCount + " samples " + InputLatency.sampleCount } @@ -143,6 +167,7 @@ Item { elide: Text.ElideRight } SecondaryText { + visible: root.latencyRecorded Layout.maximumWidth: Metrics.scaled(396) text: "Stage " + InputLatency.lastStage + (InputLatency.lastRouteSample.length > 0 ? " · " + InputLatency.lastRouteSample : diff --git a/qml/shell/GroupMenu.qml b/qml/shell/GroupMenu.qml index a9cc2239..fb3ce797 100644 --- a/qml/shell/GroupMenu.qml +++ b/qml/shell/GroupMenu.qml @@ -13,11 +13,13 @@ FocusScope { property bool menuOpen: false property var entries: [] property int currentIndex: 0 + // The host bounds the menu to its viewport: 0 means natural height. + property real maximumHeight: 0 signal requestClose visible: menuOpen implicitHeight: panel.implicitHeight - height: implicitHeight + height: maximumHeight > 0 ? Math.min(implicitHeight, maximumHeight) : implicitHeight function isActionable(entry) { return entry && (entry.kind === "join" || entry.kind === "create" || entry.kind === "leave") @@ -157,11 +159,8 @@ FocusScope { Surface { id: panel - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top + anchors.fill: parent implicitHeight: contentColumn.implicitHeight + 20 - height: implicitHeight elevated: true baseColor: Theme.bgRaised clip: true @@ -170,9 +169,7 @@ FocusScope { ColumnLayout { id: contentColumn - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top + anchors.fill: parent anchors.margins: 10 spacing: 6 @@ -188,6 +185,8 @@ FocusScope { MenuListView { id: list Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 Layout.preferredHeight: contentHeight interactive: false model: menu.entries diff --git a/qml/shell/ItemContextMenu.qml b/qml/shell/ItemContextMenu.qml index bd7048d5..4eae747f 100644 --- a/qml/shell/ItemContextMenu.qml +++ b/qml/shell/ItemContextMenu.qml @@ -49,7 +49,7 @@ FocusScope { === "resumeItems" readonly property bool partialEpisode: itemType === "Episode" && hasProgress && !playedState readonly property bool actionable: itemId.length > 0 - readonly property bool queueable: actionable && item && item.playable !== false + readonly property bool queueable: Boolean(actionable && item && item.playable !== false) readonly property string currentViewKind: String(Browse.viewKind || "") readonly property bool inPlaylist: currentViewKind === "playlist" readonly property bool inCollection: currentViewKind === "boxset" || currentViewKind === "collection" @@ -86,7 +86,7 @@ FocusScope { root.menuIndex = Math.max(0, index) Qt.callLater(root.positionMenu) } - function onExtensionSupportChanged(accountId) { + function onCapabilitySupportChanged(accountId) { if (root.opened && !root.libraryContext) root.loadProviderActions() } @@ -403,17 +403,13 @@ FocusScope { } else if (action === "addQueue") { App.addToQueueFromItem(item) } else if (action === "played") { - playedState = !playedState - ItemState.setPlayed(itemId, playedState) + ItemState.setPlayed(itemId, !playedState) } else if (action === "unwatched") { - playedState = false ItemState.clearProgress(itemId) } else if (action === "clear") { - playedState = false ItemState.clearProgress(itemId) } else if (action === "favorite") { - favoriteState = !favoriteState - ItemState.setFavorite(itemId, favoriteState) + ItemState.setFavorite(itemId, !favoriteState) } else if (action === "download") { shell.openDownloads(itemId, anchorItem) } else if (action === "collectionEditor") { diff --git a/qml/shell/MediaInfoOverlay.qml b/qml/shell/MediaInfoOverlay.qml index 46b88bc3..7e8a4fc0 100644 --- a/qml/shell/MediaInfoOverlay.qml +++ b/qml/shell/MediaInfoOverlay.qml @@ -26,14 +26,14 @@ OverlayDialog { onDismissed: closeOverlay() onVisibleChanged: if (visible) { - currentActionIndex = closeActionIndex - Qt.callLater(ensureFocus) - Qt.callLater(refreshItemDetail) - } + currentActionIndex = closeActionIndex + Qt.callLater(ensureFocus) + Qt.callLater(refreshItemDetail) + } onItemChanged: if (visible) - Qt.callLater(refreshItemDetail) + Qt.callLater(refreshItemDetail) onActiveFocusChanged: if (visible && !activeFocus) - InputKeys.focus(root) + InputKeys.focus(root) component Pair: ColumnLayout { property string label: "" @@ -104,7 +104,7 @@ OverlayDialog { const units = ["B", "KB", "MB", "GB", "TB"] let unit = 0 while (value >= 1024 && unit < units.length - 1) { - value /= 1024 + value /= 1024; ++unit } return value.toFixed(unit >= 3 ? 2 : unit === 0 ? 0 : 1) + " " + units[unit] @@ -225,17 +225,36 @@ OverlayDialog { function activateCurrent() { closeOverlay() } + function scrollInfo(delta) { + infoWheel.stopScrolling() + infoFlick.cancelFlick() + const first = infoFlick.originY + const last = first + Math.max(0, infoFlick.contentHeight - infoFlick.height) + infoFlick.contentY = Math.max(first, Math.min(last, infoFlick.contentY + delta)) + } function routeKey(key, phase, repeat) { if (phase === "release" && key === Qt.Key_I) { closeOverlay() return true } - if (key === Qt.Key_Up || key === Qt.Key_Left) { - focusAction(currentActionIndex - 1) + const scrollKey = InputKeys.isVertical(key) || key === Qt.Key_PageUp || key === Qt.Key_PageDown || key + === Qt.Key_Home || key === Qt.Key_End + if (phase === "release") + return scrollKey || InputKeys.isHorizontal(key) + if (scrollKey) { + const step = Math.max(Metrics.controlHeightPx, Math.round(infoFlick.height * 0.15)) + const page = Math.round(infoFlick.height * 0.85) + const delta = key === Qt.Key_Up ? -step : key === Qt.Key_Down ? step : key === Qt.Key_PageUp ? -page : key + === Qt.Key_PageDown + ? page : key + === Qt.Key_Home + ? -infoFlick.contentHeight : + infoFlick.contentHeight + scrollInfo(delta) return true } - if (key === Qt.Key_Down || key === Qt.Key_Right) { - focusAction(currentActionIndex + 1) + if (InputKeys.isHorizontal(key)) { + focusAction(closeActionIndex) return true } return false @@ -277,12 +296,13 @@ OverlayDialog { focus: root.currentActionIndex === root.closeActionIndex onClicked: root.closeOverlay() onActiveFocusChanged: if (activeFocus) - root.currentActionIndex = root.closeActionIndex + root.currentActionIndex = root.closeActionIndex } } Flickable { id: infoFlick + objectName: "mediaInfoScrollView" Layout.fillWidth: true Layout.fillHeight: true @@ -291,12 +311,23 @@ OverlayDialog { boundsBehavior: Flickable.StopAtBounds clip: true FastWheelHandler { + id: infoWheel + flickable: infoFlick + } + + ListScrollBar { + id: infoScrollBar + parent: infoFlick flickable: infoFlick + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + z: 1 } ColumnLayout { id: infoColumn - width: parent.width + width: Math.max(0, infoFlick.width - (infoScrollBar.visible ? infoScrollBar.width + Metrics.scaled(8) : 0)) spacing: 18 EmptyPlaceholder { diff --git a/qml/shell/ProviderInstallDialog.qml b/qml/shell/ProviderInstallDialog.qml index 7836437a..23f47b4b 100644 --- a/qml/shell/ProviderInstallDialog.qml +++ b/qml/shell/ProviderInstallDialog.qml @@ -9,8 +9,10 @@ OverlayDialog { id: root required property var transfers property bool suspendLocalTransfers: false - readonly property var visibleTransfers: suspendLocalTransfers ? transfers.filter(transfer => - !transfer.operationToken) : transfers + // Only installs the viewer started from a file wait on this dialog; store + // installs show on their tile and updates happen quietly. + readonly property var visibleTransfers: suspendLocalTransfers ? [] : transfers.filter(transfer => Boolean( + transfer.operationToken)) visible: visibleTransfers.length > 0 focus: visible preferredWidth: 620 diff --git a/qml/shell/ProviderLinkScreen.qml b/qml/shell/ProviderLinkScreen.qml index 34bf0162..bda81bd0 100644 --- a/qml/shell/ProviderLinkScreen.qml +++ b/qml/shell/ProviderLinkScreen.qml @@ -116,10 +116,12 @@ FocusScope { width: ListView.view.width title: modelData.title || modelData.name || "" serverAddress: modelData.address || "" + iconName: modelData.icon || "dns" + badge: modelData.badge || "" + opacity: modelData.badge ? 0.75 : 1 providerName: root.installedModule ? root.installedModule.name : "" providerId: root.provider ? root.provider.moduleId : "" providerIcon: root.installedModule ? root.installedModule.iconUrl : "" - providerVersion: root.installedModule ? root.installedModule.version : "" enabled: !root.busy onAccepted: { choicesList.currentIndex = index diff --git a/qml/shell/RememberedCertificatesDialog.qml b/qml/shell/RememberedCertificatesDialog.qml index de14892e..15e5afcf 100644 --- a/qml/shell/RememberedCertificatesDialog.qml +++ b/qml/shell/RememberedCertificatesDialog.qml @@ -14,6 +14,7 @@ FocusScope { function routeKey(key, phase, repeat) { if (root.inputKeys.isBack(key, false, false)) { + // Back claims both phases; only the release dismisses. if (phase === "release") dismissed() return true @@ -105,7 +106,7 @@ FocusScope { width: ListView.view.width height: details.implicitHeight + Metrics.scaled(24) elevated: ListView.isCurrentItem - baseColor: ListView.isCurrentItem ? Theme.focusFill : Theme.bgElevated + baseColor: ListView.isCurrentItem ? Theme.focusedFill : Theme.bgRaised TapHandler { onTapped: { diff --git a/qml/shell/RoutePolicy.js b/qml/shell/RoutePolicy.js index dde9c2ca..f45d04ac 100644 --- a/qml/shell/RoutePolicy.js +++ b/qml/shell/RoutePolicy.js @@ -8,8 +8,21 @@ function itemTypeFor(item) { return item ? String(item.itemType || item.type || "") : "" } +function personContext(args) { + const routeArgs = args || ({}) + return { + id: String(routeArgs.personId || ""), + name: String(routeArgs.personName || "Person"), + role: String(routeArgs.personRole || ""), + type: String(routeArgs.personType || "Person"), + imageTag: String(routeArgs.personImageTag || "") + } +} + function modelCount(model) { - return model && model.rowCount ? model.rowCount() : 0 + if (!model) + return 0 + return model.count !== undefined ? Number(model.count) : model.rowCount ? model.rowCount() : 0 } function modelItem(model, index) { @@ -24,6 +37,9 @@ function modelIndexForItemId(model, itemId, fallbackIndex) { if (itemIdFor(model.get(i)) === id) return i } + // A route's identity survives its source model being refreshed or + // reused by another page. Never turn it into a different item. + return -1 } return count > 0 ? Math.max(0, Math.min(Number(fallbackIndex || 0), count - 1)) : -1 } @@ -32,22 +48,30 @@ function detailsContext(args, fallbackModel) { const routeArgs = args || ({}) const model = routeArgs.model || fallbackModel const index = modelIndexForItemId(model, routeArgs.itemId, routeArgs.focusIndex) + const id = String(routeArgs.itemId || "") let item = modelItem(model, index) - let effectiveIndex = index - if (itemIdFor(item).length <= 0 && String(routeArgs.itemId || "").length > 0) { - item = { - movieId: String(routeArgs.itemId), - itemType: String(routeArgs.itemType || "Video"), - title: String(routeArgs.title || "Selected item"), - seriesId: String(routeArgs.seriesId || ""), - seasonId: String(routeArgs.seasonId || ""), - playable: true - } - effectiveIndex = 0 + if (itemIdFor(item).length <= 0 && id.length > 0) { + // The row this was opened from can be refilled by the page itself -- + // a series' seasons become the season's episodes. Fall back to the + // copy taken at navigation, not a bare title, so nothing on the page + // disappears while the full details load. + const snapshot = routeArgs.snapshot + item = snapshot && itemIdFor(snapshot) === id ? snapshot : { + movieId: id, + itemType: String(routeArgs.itemType || "Video"), + title: String(routeArgs.title || "Selected item"), + seriesId: String(routeArgs.seriesId || ""), + seasonId: String(routeArgs.seasonId || ""), + playable: true + } } + if (!item.seriesName && String(routeArgs.seriesName || "").length > 0) + item = Object.assign({}, item, { + seriesName: String(routeArgs.seriesName) + }) return { model: model, - index: effectiveIndex, + index: index, item: item, source: String(routeArgs.source || "movies"), returnRoute: String(routeArgs.returnRoute || "libraryGrid") @@ -73,7 +97,9 @@ function normalizeDetailsRoute(request, fallbackModel, currentRoute) { title: request && request.title ? String(request.title) : String(fallbackItem.title || fallbackItem.seriesName || ""), seriesId: request && request.seriesId ? String(request.seriesId) : String(fallbackItem.seriesId || ""), - seasonId: request && request.seasonId ? String(request.seasonId) : String(fallbackItem.seasonId || "") + seriesName: request && request.seriesName ? String(request.seriesName) : String(fallbackItem.seriesName || ""), + seasonId: request && request.seasonId ? String(request.seasonId) : String(fallbackItem.seasonId || ""), + snapshot: itemIdFor(fallbackItem) === itemId ? fallbackItem : null } } @@ -96,7 +122,9 @@ function detailsNavigationMode(currentRoute, currentArgs, nextArgs, source) { return currentId.length > 0 && currentId === nextId ? "replace" : "push" } -function detailsRouteAt(model, index, source, returnRoute, currentRoute) { +// `parent` names what the item belongs to when its own row may not say, as a +// series page does for its seasons. +function detailsRouteAt(model, index, source, returnRoute, currentRoute, parent) { const focusIndex = Math.max(0, Number(index || 0)) const item = modelItem(model, focusIndex) return normalizeDetailsRoute({ @@ -104,7 +132,8 @@ function detailsRouteAt(model, index, source, returnRoute, currentRoute) { itemId: itemIdFor(item), itemType: itemTypeFor(item), title: String(item.title || item.seriesName || ""), - seriesId: String(item.seriesId || ""), + seriesId: String(item.seriesId || (parent && parent.seriesId) || ""), + seriesName: String(item.seriesName || (parent && parent.seriesName) || ""), seasonId: String(item.seasonId || ""), source: source || "movies", returnRoute: returnRoute || currentRoute || "libraryGrid", diff --git a/qml/shell/ServerLogin.qml b/qml/shell/ServerLogin.qml index 8ee3b842..415419a7 100644 --- a/qml/shell/ServerLogin.qml +++ b/qml/shell/ServerLogin.qml @@ -117,12 +117,24 @@ FocusScope { validAddress = !!result.servers.length }, () => {}) } + property bool autoChosen: false function mergeServers(found) { const merged = servers.slice() for (const entry of found) { - const index = merged.findIndex(row => row.id === entry.id) - if (index < 0) + // Only nonempty IDs establish identity; ID-less entries match by + // address, so unrelated servers never collapse into one row. + const index = merged.findIndex(row => (entry.id && row.id === entry.id) || (!!entry.address && row.address + === entry.address)) + if (index < 0) { merged.push(entry) + continue + } + const row = merged[index] + // Address matching must retain a server ID learned by an earlier probe. + if ((!entry.id || entry.id === entry.address) && row.id) + merged[index] = Object.assign({}, entry, { + id: row.id + }) else merged[index] = entry } @@ -135,8 +147,17 @@ FocusScope { const stamp = discoveryGeneration provider.request("discover").then(result => { discovering = false - if (stamp === discoveryGeneration && !closed) - mergeServers(result.servers || []) + if (stamp !== discoveryGeneration || closed) + return + mergeServers(result.servers || []) + // One server answering at home is almost always the one; open it + // unless the viewer has started typing another. Only once, so + // Back from its sign-in stays on the server list. + if (!autoChosen && servers.length === 1 && address.text.length === 0 && step === "server" && !busy + && servers[0].address) { + autoChosen = true + connect(String(servers[0].address)) + } }, () => { discovering = false }) @@ -350,7 +371,9 @@ FocusScope { } Component.onCompleted: { const context = provider && provider.arguments ? provider.arguments.setupContext : null - if (context && context.accountId && setupContextOperation.length > 0) { + // Adding another server starts blank; adding a profile or signing in + // again starts from the saved one. + if (context && context.accountId && context.purpose !== "addServer" && setupContextOperation.length > 0) { provider.request(setupContextOperation).then(result => { if (!closed && result.server) { address.text = String(result.server) @@ -511,7 +534,6 @@ FocusScope { providerName: root.installedModule ? root.installedModule.name : "" providerId: root.provider ? root.provider.moduleId : "" providerIcon: root.installedModule ? root.installedModule.iconUrl : "" - providerVersion: root.installedModule ? root.installedModule.version : "" badgeIcon: modelData.hasPassword ? "lock" : "" onAccepted: { usernameField.text = modelData.name diff --git a/qml/shell/TlsTrustDialog.qml b/qml/shell/TlsTrustDialog.qml index 0b75a88d..4fbd9162 100644 --- a/qml/shell/TlsTrustDialog.qml +++ b/qml/shell/TlsTrustDialog.qml @@ -14,6 +14,7 @@ FocusScope { function routeKey(key, phase, repeat) { if (root.inputKeys.isBack(key, false, false)) { + // Back claims both phases; only the release dismisses. if (phase === "release") root.trustController.cancel() return true diff --git a/qml/shell/TopBar.qml b/qml/shell/TopBar.qml index 600005f6..1892884d 100644 --- a/qml/shell/TopBar.qml +++ b/qml/shell/TopBar.qml @@ -32,6 +32,10 @@ FocusScope { // 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) + // A cell held empty at the trailing end for a control the shell places + // itself (Open files), so it keeps the rail's spacing and background. + property int trailingCells: 0 + readonly property real trailingCellCenterX: width - Metrics.scaled(14) - trailingCells * railCellWidth / 2 // Index space: the rail's buttons, then the group button when shown. function lastIndex() { @@ -276,6 +280,9 @@ FocusScope { iconName: "groups" accessibleName: "Watch together" railStyle: true + // The glyph's figures sit inside a wide, short box, so at the + // rail's ratio it read smaller than its neighbours. + iconRatio: 0.62 selected: root.groupMenuOpen onClicked: root.groupMenuOpen ? root.closeGroupMenu(false) : root.openGroupMenu() @@ -294,6 +301,12 @@ FocusScope { } } } + + Item { + visible: root.trailingCells > 0 + Layout.preferredWidth: root.trailingCells * root.railCellWidth + Layout.fillHeight: true + } } Loader { diff --git a/qml/shell/UpdateDialog.qml b/qml/shell/UpdateDialog.qml index 160424a1..e1acd35b 100644 --- a/qml/shell/UpdateDialog.qml +++ b/qml/shell/UpdateDialog.qml @@ -105,9 +105,9 @@ FocusScope { function routeKey(key, phase, repeat) { if (InputKeys.isBack(key, false, false)) { - if (phase === "release") + if (phase === "press" && !repeat) back() - return true + return phase === "press" } if (!InputKeys.isDirection(key)) return InputKeys.isAccept(key) @@ -161,6 +161,8 @@ FocusScope { color: Theme.overlayScrimStrong } + PopupShield {} + Surface { anchors.centerIn: parent width: Math.min(parent.width - Metrics.scaled(40), Metrics.scaled(680)) diff --git a/qml/theme/Metrics.qml b/qml/theme/Metrics.qml index 4c46c037..f7d5fbda 100644 --- a/qml/theme/Metrics.qml +++ b/qml/theme/Metrics.qml @@ -52,10 +52,10 @@ QtObject { // Its unit is whatever Qt calls a logical pixel, and that is not the same // thing everywhere: on a desktop it is roughly a 96dpi pixel, so the // default is sqrt(1920 * 1080), the viewport this file was drawn against. - // On Android it is exactly an Android dp, which is a different and better - // yardstick -- the platform has already accounted for how far away the - // panel is held -- so the shell substitutes Android's own. Set by - // AppShell; see the binding there for why. + // Android dp and iOS points are already distance-corrected units, so the + // shell substitutes their mobile yardstick. Android TV shares the dp + // baseline; tvOS reports desktop-shaped points and retains the TV one. + // Set by AppShell; see the binding there for why. property real baselinePx: 1440 readonly property real viewportRatio: Math.sqrt(Math.max(1, viewportWidth) * Math.max(1, viewportHeight)) / baselinePx diff --git a/qml/theme/Theme.qml b/qml/theme/Theme.qml index 55be63d9..de31ac00 100644 --- a/qml/theme/Theme.qml +++ b/qml/theme/Theme.qml @@ -10,6 +10,15 @@ QtObject { readonly property color border: "#2A2A2A" readonly property color borderStrong: "#3A3A3A" + // Text entry has to read as a place to type from across a room. The panel + // greys sit within a few steps of the page and of each other, so a field + // drawn in them vanished on both; these hold a 3:1 edge against the page + // and the panel greys and keep the hint text above 4.5:1. + readonly property color inputFill: "#262626" + readonly property color inputFillActive: "#2E2E2E" + readonly property color inputBorder: "#737373" + readonly property color inputBorderHover: "#909090" + readonly property color textPrimary: "#EDEDED" readonly property color textSecondary: "#C6C6C6" readonly property color textMuted: "#9C9C9C" diff --git a/sdk/README.md b/sdk/README.md index c844775b..3e9fe730 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -325,6 +325,14 @@ 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`. +A `browse` listing that can start at any row says so with `seekable: true` on +its pages and an exact `total`. Spool then fetches only the rows on screen, +asking with `offset` (a row number) in place of `cursor`, so a library of tens +of thousands of titles never has to be walked from the top. Optional +`browseIndex` answers how a `SortName` listing divides by first character; with +it the letter rail is exact, without it Spool estimates and corrects itself as +rows arrive. + `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 diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts index 91c8744a..50fa886b 100644 --- a/sdk/provider.d.ts +++ b/sdk/provider.d.ts @@ -81,11 +81,11 @@ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; 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. + * The host surface shared by sources and operations, under different scopes: + * source services outlive any one operation; operation requests are cancelled + * with their call. */ -export interface SourceHost { +export interface ProviderHost { device: Device; /** Frozen manifest declarations mapped to true; not account authorization. */ readonly capabilities: Capabilities; @@ -95,16 +95,27 @@ export interface SourceHost { 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; + /** Push to Spool: see Events. */ + emit(type: K, payload: Events[K]): void; +} + +/** + * 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 extends ProviderHost { /** 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 { +/** + * Given to each operation; its requests are cancelled with it. Sockets are + * source-level services only: operations cannot open them. + */ +export interface OperationHost extends ProviderHost { /** 0–10000 ms. */ delay(milliseconds: number): Promise; /** Requires discovery declaration. UDP broadcast; replies within `timeout` ms (100–5000). */ @@ -140,8 +151,12 @@ export type CreateSource = (configuration: Record, host: SourceHo /** 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 interface Page { items: Item[]; cursor: string | null; total?: number | null; exhausted: boolean; + /** `browse` only: this listing is positional. Spool may then ask for any row with `offset`, and `total` is exact. */ + seekable?: boolean } +export interface PageArgs { cursor?: string; limit: number; + /** Row to start at, sent instead of `cursor` only to a listing whose first page said `seekable: true`. */ + offset?: number } export type SortBy = 'SortName' | 'Random' | 'CommunityRating' | 'CriticRating' | 'DateCreated' | 'DateLastContentAdded' | 'OfficialRating' | 'PremiereDate' | 'PlayCount' | 'Runtime' | 'DatePlayed' | string; @@ -382,6 +397,11 @@ export interface Source extends CatalogueOperations, PreferenceOperations, Appli latest?: Operation; similar?: Operation; personItems?: Operation; + /** How a `SortName` listing divides by leading character, so a long library can show and jump to letters + * before those rows are loaded. Takes the same arguments as `browse` without paging; `entries` run in + * ascending order with '#' first for anything before A. Optional: Spool estimates without it. */ + browseIndex?: Operation<{ parentId?: string; collectionType?: string; genre?: string; studio?: string; + filters?: BrowseFilters }, { entries: { label: string; count: number }[] }>; /** `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. */ @@ -406,7 +426,14 @@ export interface Source extends CatalogueOperations, PreferenceOperations, Appli queue?: PlaybackQueueSnapshot; queueIndex?: number }, {}>; favorite?: Operation<{ itemId: string; value: boolean }, {}>; + /** Marking unplayed also clears the resume position on the server. */ played?: Operation<{ itemId: string; value: boolean }, {}>; + /** + * Saves the resume position. `"0"` clears it on the server, taking the item out of the server's + * continue-watching list, and must leave its played state unchanged. Spool's Clear progress and + * Continue Watching's Mark unwatched send exactly this; a server that ignores a zero position + * needs another way to reset it. + */ progress?: Operation<{ itemId: string; positionTicks: string }, {}>; /** Manifest `actions` are run here; `pick` shows the picker, then runs again with its result merged in. */ diff --git a/sdk/spool-provider.py b/sdk/spool-provider.py index 3acbbb3a..8c032377 100755 --- a/sdk/spool-provider.py +++ b/sdk/spool-provider.py @@ -20,12 +20,17 @@ import subprocess import sys import tarfile +import tempfile from typing import NoReturn MAX_ARCHIVE = 16 * 1024 * 1024 MAX_EXPANDED = 32 * 1024 * 1024 MAX_FILE = 8 * 1024 * 1024 MAX_FILES = 512 +# Bound archive metadata as well as file payloads. Allow directory headers and +# ustar record padding without letting ignored members expand without a limit. +MAX_MEMBERS = 2 * MAX_FILES +MAX_TAR = MAX_EXPANDED + MAX_MEMBERS * 1024 + tarfile.RECORDSIZE 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", @@ -80,17 +85,22 @@ def validate(files: dict[str, bytes]) -> dict: 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: + summary = manifest.get("summary", "") + if not isinstance(summary, str): + fail("manifest.summary must be a string") + if len(manifest["name"]) > 64 or len(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 "icon" in manifest and not isinstance(manifest["icon"], str): + fail("manifest.icon must be a string") 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"): + if not isinstance(path, str) or path not in files or not path.endswith(".qml"): fail(f"manifest.ui.{role} names a missing QML file") capabilities = manifest.get("capabilities") if not isinstance(capabilities, list) or len(capabilities) > len(CAPABILITIES): @@ -102,8 +112,11 @@ def validate(files: dict[str, bytes]) -> dict: if capability in declared: fail(f"duplicate capability: {capability}") declared.add(capability) - for origin in manifest.get("origins", []): - if origin != "*" and not re.fullmatch(r"https?://[^/\s]+", origin): + origins = manifest.get("origins", []) + if not isinstance(origins, list): + fail("manifest.origins must be an array of strings") + for origin in origins: + if not isinstance(origin, str) or (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"): @@ -125,41 +138,93 @@ def compress(data: bytes) -> bytes: capture_output=True, check=True).stdout +def read_bounded(stream, maximum: int, description: str) -> bytes: + data = stream.read(maximum + 1) + if len(data) > maximum: + fail(f"{description} exceeds its size limit") + return data + + def decompress(data: bytes) -> bytes: try: from compression import zstd - return zstd.decompress(data) except ImportError: - pass + zstd = None + if zstd is not None: + try: + with zstd.open(io.BytesIO(data), "rb", options={zstd.DecompressionParameter.window_log_max: 26}) as stream: + return read_bounded(stream, MAX_TAR, "expanded archive") + except (zstd.ZstdError, EOFError) as error: + fail(f"invalid zstd archive: {error}") 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 + # A file-backed stdin avoids deadlock between a full compressed-input pipe + # and the bounded output reader. Never collect unlimited subprocess output. + with tempfile.TemporaryFile() as source: + source.write(data) + source.seek(0) + with subprocess.Popen(["zstd", "-d", "-q", "-c", "--memory=64MB"], stdin=source, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as decoder: + try: + expanded = read_bounded(decoder.stdout, MAX_TAR, "expanded archive") + if decoder.wait() != 0: + fail("invalid zstd archive") + return expanded + finally: + if decoder.poll() is None: + decoder.kill() + decoder.wait() 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") + with path.open("rb") as source: + data = read_bounded(source, MAX_ARCHIVE, "archive") files = {} + expanded_size = 0 + folded = set() with tarfile.open(fileobj=io.BytesIO(decompress(data)), mode="r:") as archive: - for member in archive.getmembers(): + for count, member in enumerate(archive, 1): + if count > MAX_MEMBERS: + fail("archive has too many members") if member.isdir(): continue - if not member.isfile(): + if not member.isfile() or member.issparse(): fail(f"links and special files are not allowed: {member.name}") - files[member.name.removeprefix("./")] = archive.extractfile(member).read() + name = member.name.removeprefix("./") + if not valid_path(name): + fail(f"forbidden path: {name}") + if name.casefold() in folded: + fail(f"duplicate or case-colliding path: {name}") + if member.size < 0 or member.size > MAX_FILE: + fail(f"oversized file: {name}") + expanded_size += member.size + if len(files) >= MAX_FILES or expanded_size > MAX_EXPANDED: + fail("package is too large") + folded.add(name.casefold()) + with archive.extractfile(member) as stream: + files[name] = stream.read(member.size) + if len(files[name]) != member.size: + fail(f"truncated file: {name}") return validate(files), files def collect(source: pathlib.Path) -> dict[str, bytes]: files = {} + expanded_size = 0 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 path.is_symlink(): + fail(f"symlinks are not allowed: {path}") + for entry in [path] if path.is_file() else 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() + if len(files) >= MAX_FILES: + fail("package has too many files") + with entry.open("rb") as stream: + data = read_bounded(stream, min(MAX_FILE, MAX_EXPANDED - expanded_size), str(entry)) + expanded_size += len(data) + files[entry.relative_to(source).as_posix()] = data return files @@ -181,7 +246,8 @@ def build(source: pathlib.Path, output: pathlib.Path | None) -> pathlib.Path: def feed(path: pathlib.Path, url: str) -> dict: manifest, _ = read(path) - data = path.read_bytes() + with path.open("rb") as source: + data = read_bounded(source, MAX_ARCHIVE, "archive") entry = {key: manifest[key] for key in ("format", "id", "name", "version", "summary", "publisher", "homepage") if manifest.get(key)} entry.update(url=url, size=len(data), sha256=hashlib.sha256(data).hexdigest()) diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index d4218e8d..4ff29388 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -186,6 +187,12 @@ AppController::AppController( 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_sleepTimerClock.start(); + m_sleepTimer.setInterval(1000); + connect(&m_sleepTimer, &QTimer::timeout, this, [this]() { + if (!stopIfSleepTimerExpired()) + emit sleepTimerChanged(); + }); connect(m_player, &PlayerController::watchedPersistenceRequested, m_itemState, &UserItemStateController::persistPlaybackWatched); connect(m_itemState, &UserItemStateController::playedChanged, m_playQueue, &PlayQueueController::updatePlayed); @@ -227,19 +234,42 @@ AppController::AppController( } playQueueCurrent(false); }); + connect(m_playQueue, &PlayQueueController::queueChanged, this, [this]() { + m_player->setQueueState( + m_playQueue->nowPlayingQueue(), inGroup() ? QString() : m_playQueue->repeatMode(), m_playQueue->shuffled()); + }); + connect(m_group, &GroupPlaybackController::groupChanged, this, [this]() { + // The server owns group repetition; local modes must not leak out of it. + if (inGroup()) { + cancelSleepTimer(); + m_playQueue->setRepeatMode(QStringLiteral("RepeatNone")); + } + m_player->setQueueState( + m_playQueue->nowPlayingQueue(), inGroup() ? QString() : m_playQueue->repeatMode(), m_playQueue->shuffled()); + }); connect(m_browse, &BrowseSessionController::reloadRequested, this, [this]() { beginBrowse(); }); connect(m_browse, &BrowseSessionController::moreItemsRequested, this, &AppController::loadMoreCurrentItems); + connect(m_browse, &BrowseSessionController::blockRequested, this, &AppController::loadBrowseBlock); + connect(m_browse, &BrowseSessionController::indexRequested, this, &AppController::loadBrowseIndex); connect(m_database, &DatabaseManager::recoveryNotice, this, &AppController::showToast); { connect(m_group, &GroupPlayback::queuePlaybackRequested, this, [this](qint64 positionTicks) { + QPointer guard(this); MovieItem item = m_playQueue->currentItem(); if (item.id.isEmpty()) return; + const auto generation = cancelPendingPlaybackRequests(); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + return; item.resumeTicks = std::max(0, positionTicks); if (m_player->sessionActive() && m_activePlaybackItem.id != item.id) m_player->stopWithReason(QStringLiteral("syncplay-group-switch")); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + return; m_activePlaybackItem = item; setBusy(true, QStringLiteral("Joining the group…")); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + return; Async::runScoped( this, startPlayback(item, true), []() {}, [this](const std::exception_ptr& error) { @@ -288,12 +318,13 @@ AppController::AppController( }); connect(m_provider, &Provider::sessionEnded, this, &AppController::resetApplicationState); - connect(m_player, &PlayerController::stopRequested, this, [this] { - m_playbackLoadGeneration.invalidate(); - m_playQueue->cancelEpisodeSuccessors(); - cancelEpisodicPlaybackSelection(); - setPlaybackTransition(false); - setBusy(false); + connect(m_player, &PlayerController::stopRequested, this, [this](const QString& reason) { + // The group-switch callback already retired the old App request and + // owns the new handoff. Other Stops must retire every pending lookup. + if (reason == QStringLiteral("syncplay-group-switch")) + return; + cancelSleepTimer(); + cancelPendingPlaybackRequests(true); }); connect(m_player, &PlayerController::playbackStopped, this, &AppController::handlePlaybackStopped); connect(m_player, &PlayerController::playbackSeekOutsideStream, this, @@ -389,7 +420,7 @@ AppController::AppController( [this](const QString& itemId, qint64 positionTicks, const QString&, bool retryableCodecFailure, int audioStreamIndex, int subtitleStreamIndex) { const bool syncPlayActive = inGroup(); - if (itemId.isEmpty() || itemId != m_activePlaybackItem.id) + if (itemId.isEmpty() || itemId != m_activePlaybackItem.id || m_playbackLookupPending) return; // A quality the server cannot deliver should cost the viewer the // quality, not the thing they were watching. @@ -498,9 +529,17 @@ QCoro::Task AppController::initializeAsync() void AppController::resetApplicationState() { - m_remotePlaybackRequestGeneration.invalidate(); + cancelSleepTimer(); + m_playQueue->setRepeatMode(QStringLiteral("RepeatNone")); + cancelPendingPlaybackRequests(true); resetVisibleModels(); + m_playQueue->clear(); m_activePlaybackItem = {}; + m_activePlaybackStreams.clear(); + m_activeAudioStreamIndex = -1; + m_activeSubtitleStreamIndex = -1; + m_qualityFallbackBitrate = -1; + m_qualityFallbackHeight = 0; } void AppController::resetVisibleModels() @@ -513,6 +552,7 @@ void AppController::resetVisibleModels() m_home->reset(); m_content->reset(); m_search->reset(); + m_itemState->reset(); m_libraryLoadGeneration.invalidate(); m_libraryListGeneration.invalidate(); m_browse->reset(); @@ -590,10 +630,14 @@ void AppController::fetchPlayItem(const QString& itemId, bool fromStart, PlayDes { if (itemId.isEmpty() || !m_catalog) return; + cancelPendingPlaybackRequests(); + const auto generation = m_playbackLoadGeneration.current(); + m_playbackLookupPending = true; setBusy(true, QStringLiteral("Loading item for playback…")); - Async::runScoped( - this, m_catalog->fetchItemDetails(itemId), + Async::runLatest( + this, m_catalog->fetchItemDetails(itemId), m_playbackLoadGeneration, generation, [this, fromStart, destination](const MovieItem& item) { + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -603,6 +647,7 @@ void AppController::fetchPlayItem(const QString& itemId, bool fromStart, PlayDes showToast(QStringLiteral("Item not found.")); }, [this, destination](const std::exception_ptr& error) { + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -688,7 +733,9 @@ void AppController::playAlbumFrom(const MovieItem& track, bool fromStart, PlayDe return; } - const quint64 generation = ++m_albumQueueGeneration; + cancelPendingPlaybackRequests(); + const quint64 generation = m_albumQueueGeneration; + m_playbackLookupPending = true; setBusy(true, QStringLiteral("Loading album…")); // The same descriptor ContentModelController uses for album children, so // there is one definition of what an album contains. @@ -697,6 +744,7 @@ void AppController::playAlbumFrom(const MovieItem& track, bool fromStart, PlayDe [this, generation, track, fromStart, destination](const PagedMovieItems& page) { if (generation != m_albumQueueGeneration) return; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -712,6 +760,7 @@ void AppController::playAlbumFrom(const MovieItem& track, bool fromStart, PlayDe [this, generation, track, fromStart, destination](const std::exception_ptr&) { if (generation != m_albumQueueGeneration) return; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -721,6 +770,30 @@ void AppController::playAlbumFrom(const MovieItem& track, bool fromStart, PlayDe }); } +RequestGeneration::Token AppController::cancelPendingPlaybackRequests(bool cancelGroup) +{ + const QPointer guard(this); + // Item details, album/episode expansion and negotiation are stages of the + // same play request. A newer request or Stop retires every earlier stage. + const auto generation = m_playbackLoadGeneration.next(); + ++m_albumQueueGeneration; + ++m_episodeQueueGeneration; + m_episodeQueuePending = false; + m_playbackLookupPending = false; + m_playQueue->cancelEpisodeSuccessors(); + m_remotePlaybackRequestGeneration.invalidate(); + m_syncPlayQueueRequestGeneration.invalidate(); + if (cancelGroup) + m_group->cancelPendingPlayback(); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + return generation; + setPlaybackTransition(false); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + return generation; + setBusy(false); + return generation; +} + void AppController::stopPlayback() { m_player->stop(); @@ -740,11 +813,7 @@ void AppController::playLocalFiles(const QList& urls, bool append) showToast(QStringLiteral("Leave the playback group before opening local files.")); return; } - m_playbackLoadGeneration.invalidate(); - m_playQueue->cancelEpisodeSuccessors(); - cancelEpisodicPlaybackSelection(); - setBusy(false); - setPlaybackTransition(false); + cancelPendingPlaybackRequests(); if (m_player->sessionActive()) m_player->stopWithReason(QStringLiteral("local-file-switch")); m_activePlaybackItem = {}; @@ -780,6 +849,7 @@ void AppController::playLocalQueueNext(PlayDestination destination) } if (!m_playQueue->next()) return; + cancelPendingPlaybackRequests(); playQueueCurrent(false); } @@ -808,6 +878,7 @@ void AppController::playLocalQueuePrevious(PlayDestination destination) } if (!m_playQueue->previous()) return; + cancelPendingPlaybackRequests(); playQueueCurrent(true); } @@ -823,19 +894,92 @@ void AppController::playQueueItem(int index) } // 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()) + if (index == m_playQueue->currentIndex() && m_player->sessionActive()) { + cancelPendingPlaybackRequests(); return; + } if (inGroup()) { // Jumping the group, not just this client. + cancelPendingPlaybackRequests(); m_group->requestPlayItem(queueEntryId(index)); return; } if (!m_playQueue->playAt(index)) return; + cancelPendingPlaybackRequests(); playQueueCurrent(false); } +void AppController::setRepeatMode(const QString& mode) +{ + if (!inGroup()) + m_playQueue->setRepeatMode(mode); +} + +QString AppController::sleepTimerMode() const +{ + switch (m_playbackStopPolicy.mode()) { + case PlaybackStopPolicy::Mode::AfterCurrent: + return QStringLiteral("afterCurrent"); + case PlaybackStopPolicy::Mode::Timed: + return QStringLiteral("timed"); + case PlaybackStopPolicy::Mode::Off: + return QStringLiteral("off"); + } + return QStringLiteral("off"); +} + +int AppController::sleepTimerRemainingSeconds() const +{ + return m_playbackStopPolicy.remainingSeconds(m_sleepTimerClock.elapsed()); +} + +void AppController::setSleepTimerMinutes(int minutes) +{ + if (minutes == 0) { + cancelSleepTimer(); + return; + } + if (minutes < 0) { + stopAfterCurrentItem(); + return; + } + if (inGroup() || !m_player->sessionActive() + || !m_playbackStopPolicy.setDuration(minutes, m_sleepTimerClock.elapsed())) + return; + m_sleepTimer.start(); + emit sleepTimerChanged(); +} + +void AppController::stopAfterCurrentItem() +{ + if (inGroup() || !m_player->sessionActive() + || !m_playbackStopPolicy.setAfterCurrent(m_activePlaybackItem.id.toStdString())) + return; + m_sleepTimer.stop(); + emit sleepTimerChanged(); +} + +void AppController::cancelSleepTimer() +{ + if (!m_playbackStopPolicy.cancel()) + return; + m_sleepTimer.stop(); + emit sleepTimerChanged(); +} + +bool AppController::stopIfSleepTimerExpired() +{ + if (!m_playbackStopPolicy.takeExpired(m_sleepTimerClock.elapsed())) + return false; + m_sleepTimer.stop(); + emit sleepTimerChanged(); + stopPlayback(); + showToast(QStringLiteral("Sleep timer finished.")); + return true; +} + // 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. @@ -881,9 +1025,10 @@ void AppController::removeQueueItem(int index) // has no player to act on it — the row that was playing is gone, so // something has to take its place or stop. if (m_playQueue->currentIndex() < 0) { - m_player->stopWithReason(QStringLiteral("queue-cleared")); + stopPlayback(); return; } + cancelPendingPlaybackRequests(); playQueueCurrent(false); } @@ -936,9 +1081,47 @@ void AppController::loadMoreCurrentItems() m_libraryLoadGeneration, loadGeneration, onDone, onError); } +void AppController::loadBrowseBlock(int start) +{ + const BrowseDescriptor descriptor = m_browse->descriptor(); + if (!descriptor.isValid() || !m_catalog->signedIn()) { + m_browse->blockFailed(start); + return; + } + const RequestGeneration::Token loadGeneration = m_libraryLoadGeneration.current(); + const QString cacheKey = m_browse->cacheKey(); + const QVariantMap query = descriptor.kind == BrowseKind::Library ? m_browse->query() : QVariantMap {}; + Async::runLatest( + this, m_catalog->fetchBrowsePage(descriptor, start, m_browse->blockSize(), query, std::nullopt), + m_libraryLoadGeneration, loadGeneration, + [this, start, cacheKey](const PagedMovieItems& page) { m_browse->applyBlock(start, page, cacheKey); }, + [this, start](const std::exception_ptr& error) { + qWarning() << "browse block failed" << start << exceptionMessage(error); + m_browse->blockFailed(start); + }); +} + +void AppController::loadBrowseIndex() +{ + const BrowseDescriptor descriptor = m_browse->descriptor(); + if (!descriptor.isValid() || !m_catalog->signedIn()) + return; + const RequestGeneration::Token loadGeneration = m_libraryLoadGeneration.current(); + const QString cacheKey = m_browse->cacheKey(); + QVariantMap query = descriptor.kind == BrowseKind::Library ? m_browse->query() : QVariantMap {}; + Async::runLatest( + this, m_catalog->fetchBrowseIndex(descriptor, std::move(query)), m_libraryLoadGeneration, loadGeneration, + [this, cacheKey](const std::vector& entries) { m_browse->setExactIndex(entries, cacheKey); }, + [this, cacheKey](const std::exception_ptr& error) { + qWarning() << "browse index failed" << exceptionMessage(error); + m_browse->setExactIndex({}, cacheKey); + }); +} + void AppController::playQueuedItems( const std::vector& items, int startIndex, bool fromStart, PlayDestination destination) { + cancelPendingPlaybackRequests(); if (dispatchRemotePlay(destination, items, startIndex, fromStart)) return; if (!m_playQueue->playNow(items, startIndex)) { @@ -1009,12 +1192,12 @@ void AppController::playEpisodicContainer(const QString& seriesId, const QString { if (seriesId.isEmpty()) return; - if (m_episodeQueuePending) - return; + cancelPendingPlaybackRequests(); m_episodeQueuePending = true; + m_playbackLookupPending = true; const auto destination = userPlayDestination(); - const quint64 generation = ++m_episodeQueueGeneration; + const quint64 generation = m_episodeQueueGeneration; setBusy(true, seasonId.isEmpty() ? QStringLiteral("Finding the next episode…") : QStringLiteral("Finding the next episode in this season…")); @@ -1024,6 +1207,7 @@ void AppController::playEpisodicContainer(const QString& seriesId, const QString if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -1045,6 +1229,7 @@ void AppController::playEpisodicContainer(const QString& seriesId, const QString if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -1059,6 +1244,7 @@ void AppController::cancelEpisodicPlaybackSelection() return; ++m_episodeQueueGeneration; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); } @@ -1068,6 +1254,7 @@ void AppController::playQueuedItem(const MovieItem& item, bool fromStart, PlayDe playEpisodeWithContext(item, 0, fromStart, destination); return; } + cancelPendingPlaybackRequests(); if (dispatchRemotePlay(destination, std::span(&item, 1), 0, fromStart)) return; if (!m_playQueue->playNow(item)) { @@ -1086,8 +1273,10 @@ void AppController::playEpisodeWithContext( return; } - const quint64 generation = ++m_episodeQueueGeneration; + cancelPendingPlaybackRequests(); + const quint64 generation = m_episodeQueueGeneration; m_episodeQueuePending = true; + m_playbackLookupPending = true; setBusy(true, direction == 0 ? QStringLiteral("Loading episode queue…") : QStringLiteral("Finding episode…")); Async::runScoped( this, m_catalog->fetchEpisodes(episode.seriesId), @@ -1095,6 +1284,7 @@ void AppController::playEpisodeWithContext( if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -1148,6 +1338,7 @@ void AppController::playEpisodeWithContext( if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -1232,21 +1423,39 @@ void AppController::playQueueCurrent(bool fromStart, std::optional expli QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPaused, bool forceTranscode, int audioStreamIndex, int subtitleStreamIndex, bool restartActive) { + if (m_shuttingDown || stopIfSleepTimerExpired()) + co_return; + m_playQueue->cancelEpisodeSuccessors(); Diagnostics::Task task(QStringLiteral("playback_negotiate"), { { QStringLiteral("itemId"), playItem.id }, { QStringLiteral("title"), playItem.title }, { QStringLiteral("type"), playItem.itemType } }); + const QPointer guard(this); const RequestGeneration::Token generation = m_playbackLoadGeneration.next(); if (!forceTranscode) m_codecFallbackAttempted = false; - PlaybackSession session = co_await m_playback->resolvePlayback(playItem, forceTranscode); - if (!m_playbackLoadGeneration.isCurrent(generation)) + PlaybackSession session; + try { + session = co_await m_playback->resolvePlayback(playItem, forceTranscode); + } catch (const std::exception&) { + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + co_return; + throw; + } + if (!guard || !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_catalog->libraryScopeKey(), playItem.seriesId)); - if (!m_playbackLoadGeneration.isCurrent(generation)) + QByteArray storedSelection; + try { + storedSelection + = co_await m_database->loadCacheEntryAsync(QString::fromLatin1(kSeriesTrackSelectionNamespace), + seriesTrackSelectionKey(m_catalog->libraryScopeKey(), playItem.seriesId)); + } catch (const std::exception&) { + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + co_return; + throw; + } + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) co_return; SeriesAudioPreference storedAudioPreference; int storedSubtitleStreamIndex = -1; @@ -1291,10 +1500,12 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau } if (!restartActive && !forceTranscode) { co_await m_settings->applyDeferredTrackDefaults(); - if (!m_playbackLoadGeneration.isCurrent(generation)) + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) co_return; } m_activePlaybackStreams = session.mediaStreams; + session.repeatMode = inGroup() ? QString() : m_playQueue->repeatMode(); + session.shuffled = m_playQueue->shuffled(); m_activeMediaSourceId = session.mediaSourceId; m_activeSourceBitrate = session.sourceBitrate; m_activeSourceHeight = session.sourceHeight; @@ -1302,12 +1513,25 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau m_activePlayMethod = session.playMethod; const int fileAudioDelayMs = restartActive ? m_player->fileAudioDelayMs() : 0; const int subtitleDelayMs = restartActive ? m_player->subtitleDelayMs() : 0; + // Network replies may run before the timer event after a blocked loop. + if (!guard || !m_playbackLoadGeneration.isCurrent(generation) || stopIfSleepTimerExpired()) + co_return; + if (m_playbackStopPolicy.playbackStarting(playItem.id.toStdString())) + emit sleepTimerChanged(); setBusy(false); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + co_return; m_player->play(session, startPaused); m_playingAccountId = m_provider->accountOf(session.itemId); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + co_return; if (restartActive) { m_player->setFileAudioDelayMs(fileAudioDelayMs); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + co_return; m_player->setSubtitleDelayMs(subtitleDelayMs); + if (!guard || !m_playbackLoadGeneration.isCurrent(generation)) + co_return; } const QString itemId = playItem.id; @@ -1348,6 +1572,7 @@ void AppController::shutdown() return; m_shuttingDown = true; qInfo() << "app: shutdown requested"; + cancelSleepTimer(); m_player->prepareForShutdown(); m_prefetch->stop(); m_provider->shutdown(); @@ -1646,7 +1871,7 @@ void AppController::selectStreamingQuality(qint64 bitrate, int height) // 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()) + if (m_playbackLookupPending || !m_player->sessionActive() || m_activePlaybackItem.id.isEmpty()) return; const qint64 positionTicks = static_cast(m_player->positionSeconds() * 10'000'000.0); const MovieItem resumeItem = PlaybackFailurePolicy::retryItem(m_activePlaybackItem, positionTicks); @@ -1676,23 +1901,23 @@ void AppController::handlePlaybackStopped( : MovieItem {}; m_itemState->recordPlaybackStopped(m_activePlaybackItem, itemId, positionTicks, watched, successor, reportId); m_playQueue->updateResumeTicks(itemId, watched ? 0 : positionTicks); - if (!reachedEnd || m_activePlaybackItem.id != itemId || inGroup()) { + if (!reachedEnd || m_activePlaybackItem.id != itemId || m_playbackLookupPending || inGroup()) { setPlaybackTransition(false); return; } - bool continueQueue = false; - if (m_repeatMode == QStringLiteral("RepeatOne")) { - continueQueue = m_playQueue->currentIndex() >= 0; - } else { - continueQueue = m_playQueue->next(); - if (!continueQueue && m_repeatMode == QStringLiteral("RepeatAll") && m_playQueue->rowCount() > 0) - continueQueue = m_playQueue->playAt(0); + if (stopIfSleepTimerExpired()) + return; + if (m_playbackStopPolicy.takeCompleted(m_activePlaybackItem.id.toStdString())) { + emit sleepTimerChanged(); + stopPlayback(); + return; } + const bool continueQueue = m_playQueue->advanceAfterCompletion(); if (continueQueue) { // Hold the surface across the gap while the next (or repeated) item is // negotiated and decoded. setPlaybackTransition(true); - playQueueCurrent(m_repeatMode == QStringLiteral("RepeatOne")); + playQueueCurrent(m_playQueue->repeatMode() == QStringLiteral("RepeatOne")); } else { setPlaybackTransition(true); m_playQueue->enqueueEpisodeSuccessors(m_activePlaybackItem); diff --git a/src/app/AppController.h b/src/app/AppController.h index 632b8ee2..e15e7f85 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -10,14 +10,17 @@ #include "BrowseSessionController.h" #include "ContentModelController.h" #include "HomeModelController.h" +#include "PlaybackStopPolicy.h" #include "RemoteTargetsController.h" #include "SearchController.h" #include "SettingsController.h" #include +#include #include #include #include +#include #include #include @@ -54,6 +57,10 @@ class AppController final : public QObject { Q_PROPERTY(bool initialized READ initialized NOTIFY initializedChanged) Q_PROPERTY(QString connectionSpeedDescription READ connectionSpeedDescription NOTIFY streamingQualityChanged) + Q_PROPERTY(QString sleepTimerMode READ sleepTimerMode NOTIFY sleepTimerChanged) + Q_PROPERTY(int sleepTimerMinutes READ sleepTimerMinutes NOTIFY sleepTimerChanged) + Q_PROPERTY(int sleepTimerRemainingSeconds READ sleepTimerRemainingSeconds NOTIFY sleepTimerChanged) + public: AppController(DatabaseManager *database, SourceHub *provider, ArtworkService *artwork, PlayerController *player, QObject *parent = nullptr); @@ -133,6 +140,15 @@ class AppController final : public QObject { Q_INVOKABLE void playQueueNext(); Q_INVOKABLE void playQueuePrevious(); Q_INVOKABLE void playQueueItem(int index); + Q_INVOKABLE void setRepeatMode(const QString& mode); + QString sleepTimerMode() const; + int sleepTimerMinutes() const + { + return m_playbackStopPolicy.durationMinutes(); + } + int sleepTimerRemainingSeconds() const; + Q_INVOKABLE void setSleepTimerMinutes(int minutes); + Q_INVOKABLE void stopAfterCurrentItem(); // The queue panel edits through these rather than reaching for the // PlayQueue singleton, so a SyncPlay group cannot be desynchronised by a // drag that never passed a guard. A reorder gesture previews locally on @@ -169,6 +185,7 @@ class AppController final : public QObject { signals: void busyChanged(); void playbackTransitionChanged(); + void sleepTimerChanged(); void streamingQualityChanged(); void errorTextChanged(); void initializedChanged(); @@ -192,6 +209,8 @@ class AppController final : public QObject { void resetVisibleModels(); void loadLibraries(); void loadMoreCurrentItems(); + void loadBrowseBlock(int start); + void loadBrowseIndex(); void refreshHomeRows() { m_home->refresh(m_libraries.libraries()); @@ -210,6 +229,12 @@ class AppController final : public QObject { bool fromStart, const QString& mode = QStringLiteral("now"), std::optional positionTicks = std::nullopt); void fetchPlayItem(const QString& itemId, bool fromStart, PlayDestination destination); + // One play request spans item details, album/episode expansion and + // negotiation. A newer request or an explicit Stop retires every earlier + // stage and its callbacks. + RequestGeneration::Token cancelPendingPlaybackRequests(bool cancelGroup = false); + void cancelSleepTimer(); + bool stopIfSleepTimerExpired(); void playQueuedItems( const std::vector& items, int startIndex, bool fromStart, PlayDestination destination); bool modelIsOrderedList(MovieGridModel *model) const; @@ -277,10 +302,15 @@ class AppController final : public QObject { bool m_busy = false; bool m_playbackTransition = false; quint64 m_playbackTransitionGeneration = 0; + // A lookup accepted on the user's behalf is still the current intent; + // completion notifications must not treat its target as stale. + bool m_playbackLookupPending = false; + PlaybackStopPolicy m_playbackStopPolicy; + QElapsedTimer m_sleepTimerClock; + QTimer m_sleepTimer; bool m_initialized = false; QString m_busyText; QString m_errorText; - QString m_repeatMode = QStringLiteral("RepeatNone"); RequestGeneration m_libraryLoadGeneration; RequestGeneration m_libraryListGeneration; RequestGeneration m_playbackLoadGeneration; diff --git a/src/app/AppControllerRemote.cpp b/src/app/AppControllerRemote.cpp index f2c9edbd..ddc9275d 100644 --- a/src/app/AppControllerRemote.cpp +++ b/src/app/AppControllerRemote.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -172,7 +173,7 @@ void AppController::handleRemoteCommand(const QString& accountId, const QVariant const QString mode = command.value(QStringLiteral("mode")).toString(); if (mode == QStringLiteral("RepeatNone") || mode == QStringLiteral("RepeatAll") || mode == QStringLiteral("RepeatOne")) - m_repeatMode = mode; + setRepeatMode(mode); } else if (name == QStringLiteral("shuffle")) { m_playQueue->setShuffled(command.value(QStringLiteral("value")).toBool()); } else if (name == QStringLiteral("quality")) { @@ -239,40 +240,69 @@ void AppController::playRemoteItems(const QString& accountId, const QVariantMap& 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()) + // A broken list must not play a wrong subset. + if (itemIds.isEmpty() || itemIds.contains(QString())) return; const QString mode = command.value(QStringLiteral("mode"), QStringLiteral("now")).toString(); + const bool replacesPlayback = mode != QStringLiteral("next") && mode != QStringLiteral("last"); 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) { + cancelPendingPlaybackRequests(); + if (replacesPlayback) + m_playbackLookupPending = true; + const RequestGeneration::Token generation = m_remotePlaybackRequestGeneration.current(); + if (replacesPlayback) + setBusy(true, QStringLiteral("Starting playback…")); + Async::runLatest( + this, m_catalog->fetchItemsByIds(itemIds), m_remotePlaybackRequestGeneration, generation, + [this, generation, itemIds, mode, requestedIndex, startTicks, replacesPlayback](std::vector items) { if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) return; - setBusy(false); - if (items.empty()) { + if (replacesPlayback) + m_playbackLookupPending = false; + // Preserve the requested order and the identity of the requested + // row even when some items could not be fetched. + QHash itemsById; + itemsById.reserve(static_cast(items.size())); + for (const MovieItem& item : items) { + if (!itemsById.contains(item.id)) + itemsById.insert(item.id, &item); + } + std::vector ordered; + int resolvedIndex = -1; + for (qsizetype i = 0; i < itemIds.size(); ++i) { + const auto found = itemsById.constFind(itemIds.at(i)); + if (found == itemsById.cend()) + continue; + if (i == requestedIndex) + resolvedIndex = static_cast(ordered.size()); + ordered.push_back(**found); + } + if (ordered.empty() || (replacesPlayback && resolvedIndex < 0)) { + if (replacesPlayback) + setBusy(false); showToast(QStringLiteral("That item isn't available here.")); return; } - if (mode == QStringLiteral("next") || mode == QStringLiteral("last")) { - m_playQueue->addToQueue(items, mode == QStringLiteral("next")); + if (!replacesPlayback) { + m_playQueue->addToQueue(ordered, 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)) + setBusy(false); + ordered[size_t(resolvedIndex)].resumeTicks = std::max(0, startTicks); + if (!m_playQueue->playNow(ordered, resolvedIndex)) return; if (mode == QStringLiteral("shuffle")) m_playQueue->setShuffled(true); startQueuedPlayback(startTicks <= 0, std::max(0, startTicks)); }, - [this, generation](const std::exception_ptr& error) { + [this, generation, replacesPlayback](const std::exception_ptr& error) { if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) return; - setBusy(false); + if (replacesPlayback) { + m_playbackLookupPending = false; + setBusy(false); + } showToast(exceptionMessage(error)); }, "remote playback"); diff --git a/src/app/ArtworkConcurrency.cpp b/src/app/ArtworkConcurrency.cpp new file mode 100644 index 00000000..5441c3b6 --- /dev/null +++ b/src/app/ArtworkConcurrency.cpp @@ -0,0 +1,91 @@ +#include "ArtworkConcurrency.h" + +#include +#include + +namespace Spool { + +namespace { + + // Open a little more than the server works on at once: enough that it + // never waits on the network between requests, not so much that a scroll + // leaves it a queue of posters nobody will see. + constexpr double kHeadroom = 1.1; + // Where a measured round trip may start the limit. Higher is earned. + constexpr double kSeedCeiling = 8.0; + // Fetches per bucket of the windowed minimum: the fastest is remembered + // for between one and two buckets. + constexpr int kBucketSamples = 60; + constexpr double kRecentWeight = 0.25; + constexpr double kParallelismWeight = 0.2; + // How far each sample moves the limit toward its new target. + constexpr double kStep = 0.3; + constexpr int kWarmupSamples = 4; + + double minimum(double a, double b) + { + return a < 0 ? b : b < 0 ? a : std::min(a, b); + } + +} // namespace + +void ArtworkConcurrency::setFixed(int limit) +{ + m_fixed = limit > 0 ? std::clamp(limit, 1, kMaximum) : 0; +} + +int ArtworkConcurrency::limit() const +{ + if (!automatic()) + return m_fixed; + return std::clamp(static_cast(std::lround(m_limit)), kMinimum, kMaximum); +} + +double ArtworkConcurrency::fastestMs() const +{ + return minimum(m_fastestCurrent, m_fastestPrevious); +} + +void ArtworkConcurrency::seed(double roundTripMs) +{ + if (!(roundTripMs >= 0)) + return; + m_roundTripMs = roundTripMs; + // Each 10 ms of round trip is worth another request in flight; a LAN + // starts where a fixed client would, a distant server well above it. + if (m_samples < kWarmupSamples) + m_limit = std::clamp(kDefault + roundTripMs / 10.0, static_cast(kDefault), kSeedCeiling); +} + +void ArtworkConcurrency::record(double durationMs, int inFlight) +{ + if (!(durationMs >= 0) || inFlight < 1) + return; + ++m_samples; + m_fastestCurrent = minimum(m_fastestCurrent, durationMs); + if (++m_bucketSamples >= kBucketSamples) { + m_fastestPrevious = m_fastestCurrent; + m_fastestCurrent = -1; + m_bucketSamples = 0; + } + m_recentMs = m_recentMs < 0 ? durationMs : m_recentMs + (durationMs - m_recentMs) * kRecentWeight; + + // Little's law: of `inFlight` open, the server was really working on + // this many, given how much longer than unqueued this fetch took. + const double parallel = inFlight * fastestMs() / std::max(1.0, durationMs); + m_parallelism = m_parallelism < 0 ? parallel : m_parallelism + (parallel - m_parallelism) * kParallelismWeight; + if (m_samples < kWarmupSamples) + return; + + // One more than that, so a server keeping up with everything sent is + // always offered another request rather than rounding back to the same. + double target = std::max(static_cast(kMinimum), m_parallelism * kHeadroom + 1.0); + // Growing is only evidence-based while the limit is what holds requests + // back; a half-empty pipe says nothing about whether more would help. + if (target > m_limit && inFlight < limit()) + target = m_limit; + m_limit = std::clamp( + m_limit + (target - m_limit) * kStep, static_cast(kMinimum), static_cast(kMaximum)); +} + +} // namespace Spool diff --git a/src/app/ArtworkConcurrency.h b/src/app/ArtworkConcurrency.h new file mode 100644 index 00000000..e5891946 --- /dev/null +++ b/src/app/ArtworkConcurrency.h @@ -0,0 +1,74 @@ +#pragma once + +namespace Spool { + +// How many artwork downloads to have open at once against one server. +// +// What matters is how soon a screenful of posters arrives, which is +// throughput, not how fast any single one comes back. Every server has some +// number of requests it really works on at once -- one per image-scaler +// thread, say -- and requests beyond that wait in its queue. Little's law +// gives that number from the fetches themselves: a fetch that took twice +// the unqueued time while eight were open was sharing the server with about +// four others. Keeping a little more than that open keeps the server busy +// without piling up a queue: a scroll cancels requests, but the server still +// does their work, and that backlog is what the next screen waits behind. +// +// On a server that is only far away, every fetch takes the round trip and no +// longer, so the estimate is simply "everything open" and the limit grows, +// while the limit is what holds the screen back, until it covers the screen. +// The unqueued time is the fastest fetch over the last hundred or so; every +// burst starts with a few fetches that had the server to themselves, so no +// probing is needed to find it, and the window lets a network change through. +class ArtworkConcurrency final { +public: + static constexpr int kMinimum = 2; + static constexpr int kMaximum = 32; + // What a fixed setting and an unmeasured server start from. + static constexpr int kDefault = 6; + + // A positive value pins the limit; zero adapts. + void setFixed(int limit); + bool automatic() const + { + return m_fixed <= 0; + } + int limit() const; + + // A round trip estimated from the first fetches: where to start. Only + // the opening fetches can move it; after that the samples decide. + void seed(double roundTripMs); + // A fetch that went to the network finished after `durationMs`, with as + // many as `inFlight` fetches (itself included) open to this server while + // it ran. + void record(double durationMs, int inFlight); + + double roundTripMs() const + { + return m_roundTripMs; + } + double fastestMs() const; + double recentMs() const + { + return m_recentMs; + } + // Requests the server is estimated to work on at once. + double serverParallelism() const + { + return m_parallelism; + } + +private: + int m_fixed = 0; + double m_limit = kDefault; + double m_roundTripMs = -1; + double m_recentMs = -1; + double m_parallelism = -1; + // Windowed minimum: the fastest of this bucket and the one before. + double m_fastestCurrent = -1; + double m_fastestPrevious = -1; + int m_bucketSamples = 0; + int m_samples = 0; +}; + +} // namespace Spool diff --git a/src/app/ArtworkService.cpp b/src/app/ArtworkService.cpp index 8c8b15c1..7d1d4cbd 100644 --- a/src/app/ArtworkService.cpp +++ b/src/app/ArtworkService.cpp @@ -1,6 +1,7 @@ #include "ArtworkService.h" #include "../common/TlsTrust.h" #include "../platform/PlatformSettingsPolicy.h" +#include "ArtworkConcurrency.h" #include "ArtworkImageProvider.h" #include "LocalThumbnail.h" @@ -9,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -33,7 +35,6 @@ namespace Spool { namespace { - constexpr int kRenderConcurrency = 6; // Each completed response becomes a texture upload in the next sync pass; // batches of 50+ posters land together, so cap completions per 16 ms tick // to keep individual frames short while a batch trickles in. @@ -106,6 +107,21 @@ namespace { return url.toString(QUrl::FullyEncoded); } + QString originOf(const QUrl& url) + { + return url.toString(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment | QUrl::RemoveUserInfo); + } + + // Qt opens at most six HTTP/1.1 connections to a host unless told + // otherwise, which would quietly cap the adaptive limit. Connections are + // opened only as requests need them, so a high ceiling costs nothing. + void allowConnections(QNetworkRequest& request) + { + QHttp1Configuration http1; + http1.setNumberOfConnectionsPerHost(ArtworkConcurrency::kMaximum); + request.setHttp1Configuration(http1); + } + } // namespace class ArtworkFetchWorker final : public QObject { @@ -170,6 +186,32 @@ class ArtworkFetchWorker final : public QObject { } } + // Zero adapts to each server; anything else is the user's fixed number. + void setDownloadLimit(int fixed) + { + m_fixedLimit = fixed; + for (Origin& origin : m_origins) + origin.control.setFixed(fixed); + publishStatus(); + drainRender(); + } + + QString downloadStatus() const + { + QStringList parts; + for (auto it = m_origins.cbegin(); it != m_origins.cend(); ++it) { + const ArtworkConcurrency& control = it->control; + parts.push_back(QStringLiteral("%1 at once%2%3") + .arg(control.limit()) + .arg(control.roundTripMs() >= 0 + ? QStringLiteral(", about %1 ms away").arg(qRound(control.roundTripMs())) + : QString()) + .arg(control.recentMs() >= 0 ? QStringLiteral(", %1 ms per image").arg(qRound(control.recentMs())) + : QString())); + } + return parts.join(QStringLiteral("; ")); + } + void cancelAll() { m_renderQueue.clear(); @@ -190,6 +232,72 @@ class ArtworkFetchWorker final : public QObject { QByteArray headers; }; + struct Origin { + ArtworkConcurrency control; + int inFlight = 0; + // Per open fetch, the most fetches open to this server at once + // while it ran: what it shared the server with. + QHash peakOpen; + // Early round trips: time to first byte of the first fetches. + int seedSamples = 0; + double fastestFirstByteMs = -1; + int lastLoggedLimit = 0; + }; + + Origin& originFor(const QUrl& url) + { + const QString key = originOf(url); + auto found = m_origins.find(key); + if (found == m_origins.end()) { + found = m_origins.insert(key, Origin {}); + found->control.setFixed(m_fixedLimit); + } + return *found; + } + + // The ping comes from the first real fetches rather than a request of + // its own, so the server sees nothing it was not asked for. Those first + // fetches each open a connection, so their first byte arrives after the + // TCP and TLS handshakes too -- two to three round trips in all. + void noteFirstByte(Origin& origin, double firstByteMs) + { + if (origin.seedSamples >= 3 || firstByteMs < 0) + return; + ++origin.seedSamples; + origin.fastestFirstByteMs + = origin.fastestFirstByteMs < 0 ? firstByteMs : std::min(origin.fastestFirstByteMs, firstByteMs); + const double roundTripMs = origin.fastestFirstByteMs / 2.5; + origin.control.seed(roundTripMs); + if (origin.seedSamples == 1) + qInfo() << "artwork: server round trip about" << qRound(roundTripMs) << "ms, starting at" + << origin.control.limit() << "downloads at once"; + publishStatus(); + } + + void noteLimit(Origin& origin) + { + const int limit = origin.control.limit(); + if (std::abs(limit - origin.lastLoggedLimit) < 3) + return; + origin.lastLoggedLimit = limit; + qInfo() << "artwork: downloads at once" << limit << "fastest" << qRound(origin.control.fastestMs()) + << "ms recent" << qRound(origin.control.recentMs()) << "ms server parallelism" + << QString::number(origin.control.serverParallelism(), 'f', 1); + publishStatus(); + } + + void publishStatus() + { + QPointer service = m_service; + QMetaObject::invokeMethod( + m_service, + [service, status = downloadStatus()]() mutable { + if (service) + service->setDownloadStatus(std::move(status)); + }, + Qt::QueuedConnection); + } + void ensureNetwork() { if (m_network) @@ -209,6 +317,7 @@ class ArtworkFetchWorker final : public QObject { QNetworkRequest cachedRequest(const QUrl& url, const QByteArray& headers = {}) const { QNetworkRequest request(url); + allowConnections(request); if (headers.isEmpty()) { request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache); return request; @@ -232,17 +341,48 @@ class ArtworkFetchWorker final : public QObject { void drainRender() { ensureNetwork(); - while (m_network && m_renderReplies.size() < kRenderConcurrency && !m_renderQueue.isEmpty()) { - const RenderRequest request = m_renderQueue.dequeue(); + if (!m_network) + return; + // Oldest first, skipping requests whose server is at its limit so a + // slow server never holds up another one's posters. + for (qsizetype index = 0; index < m_renderQueue.size();) { + Origin& origin = originFor(m_renderQueue.at(index).url); + if (origin.inFlight >= origin.control.limit()) { + ++index; + continue; + } + const RenderRequest request = m_renderQueue.takeAt(index); + const QString originKey = originOf(request.url); + ++origin.inFlight; + origin.peakOpen.insert(request.requestId, 0); + for (int& peak : origin.peakOpen) + peak = std::max(peak, origin.inFlight); const qint64 fetchStartedNs = monotonicNs(); QNetworkReply *reply = m_network->get(cachedRequest(request.url, request.headers)); m_renderReplies.insert(request.requestId, reply); const QString key = request.key; + auto firstByteNs = std::make_shared(-1); + connect(reply, &QNetworkReply::metaDataChanged, this, [firstByteNs, fetchStartedNs] { + if (*firstByteNs < 0) + *firstByteNs = monotonicNs() - fetchStartedNs; + }); connect(reply, &QNetworkReply::finished, this, - [this, reply, requestId = request.requestId, key, queuedNs = request.queuedNs, fetchStartedNs]() { + [this, reply, requestId = request.requestId, key, queuedNs = request.queuedNs, fetchStartedNs, + originKey, firstByteNs]() { m_renderReplies.remove(requestId); const bool diskCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool(); const qint64 finishedNs = monotonicNs(); + if (const auto origin = m_origins.find(originKey); origin != m_origins.end()) { + const int sharedWith = origin->peakOpen.take(requestId); + origin->inFlight = std::max(0, origin->inFlight - 1); + // Cache hits and failures say nothing about the network. + if (!diskCache && reply->error() == QNetworkReply::NoError) { + if (origin->control.automatic()) + noteFirstByte(*origin, static_cast(*firstByteNs) / 1e6); + origin->control.record(static_cast(finishedNs - fetchStartedNs) / 1e6, sharedWith); + noteLimit(*origin); + } + } finishReply(reply, [this, requestId, key, diskCache, queuedNs, fetchStartedNs, finishedNs]( QByteArray bytes, QString error) { @@ -261,7 +401,12 @@ class ArtworkFetchWorker final : public QObject { ensureNetwork(); if (!m_network || !m_renderQueue.isEmpty() || !m_renderReplies.isEmpty()) return; - while (m_prefetchReplies.size() < m_prefetchMaxConcurrent && !m_prefetchQueue.isEmpty()) { + // Speculative fetches get half of what the screen would, and only + // while it is not waiting on anything. + const int prefetchLimit = m_prefetchQueue.isEmpty() + ? 0 + : std::max(2, m_origins.value(originOf(m_prefetchQueue.head())).control.limit() / 2); + while (m_prefetchReplies.size() < prefetchLimit && !m_prefetchQueue.isEmpty()) { const QUrl url = m_prefetchQueue.dequeue(); const QString key = cacheKeyForUrl(url); QNetworkReply *reply = m_network->get(cachedRequest(url)); @@ -332,7 +477,8 @@ class ArtworkFetchWorker final : public QObject { QQueue m_prefetchQueue; QSet m_prefetchSeen; QSet m_prefetchReplies; - int m_prefetchMaxConcurrent = 3; + QHash m_origins; + int m_fixedLimit = 0; }; class ArtworkImageResponse final : public QQuickImageResponse { @@ -710,6 +856,19 @@ int ArtworkService::outstandingRequests() const return static_cast(m_responses.size()) + static_cast(m_pendingDeliveries.size()); } +void ArtworkService::setDownloadLimit(int fixed) +{ + invokeWorker([fixed](ArtworkFetchWorker *worker) { worker->setDownloadLimit(fixed); }); +} + +void ArtworkService::setDownloadStatus(QString status) +{ + if (status == m_downloadStatus) + return; + m_downloadStatus = std::move(status); + emit downloadStatusChanged(); +} + void ArtworkService::cancelPrefetches() { invokeWorker([](ArtworkFetchWorker *worker) { worker->cancelPrefetches(); }); diff --git a/src/app/ArtworkService.h b/src/app/ArtworkService.h index 86ea2971..057d1478 100644 --- a/src/app/ArtworkService.h +++ b/src/app/ArtworkService.h @@ -46,6 +46,7 @@ class ArtworkImageResponse; class ArtworkService final : public QObject, public ArtworkPrefetcher { Q_OBJECT + Q_PROPERTY(QString downloadStatus READ downloadStatus NOTIFY downloadStatusChanged) public: ArtworkService(QString cacheDirectory, qint64 networkCacheBytes, int byteCacheBytes, int decodeThreads, TlsTrustController *tlsTrust, QObject *parent = nullptr); @@ -68,6 +69,15 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { void prefetch(const QStringList& urls) override; void cancelPrefetches() override; void releaseMemory(bool aggressive); + // Artwork downloads open at once per server: zero adapts to the server's + // latency and load, a positive number is fixed. + void setDownloadLimit(int fixed); + // What the adaptive limit has settled on, per server, for settings. + QString downloadStatus() const + { + return m_downloadStatus; + } + void setDownloadStatus(QString status); // 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 @@ -146,6 +156,10 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { QTimer m_deliveryTimer; int m_deliveredThisTick = 0; int m_nextRequestId = 1; + QString m_downloadStatus; + +signals: + void downloadStatusChanged(); }; } // namespace Spool diff --git a/src/app/BrowseIndex.cpp b/src/app/BrowseIndex.cpp new file mode 100644 index 00000000..d693fb69 --- /dev/null +++ b/src/app/BrowseIndex.cpp @@ -0,0 +1,257 @@ +#include "BrowseIndex.h" + +#include +#include +#include + +namespace Spool { + +namespace { + + constexpr int kLetters = 27; + + // How leading letters divide a title list once articles are dropped, as a + // share of the whole: the starting guess for a letter nobody has loaded. + // S is the giant; Q and X barely register. + constexpr std::array kPrior { 2.5, 6.5, 6.5, 5.5, 6.0, 3.0, 3.5, 3.5, 5.0, 3.0, 1.8, 2.0, 4.5, + 6.5, 2.5, 1.8, 4.0, 0.3, 4.0, 10.0, 6.0, 1.0, 1.3, 4.0, 0.3, 0.8, 0.8 }; + + QString estimateLabel(int index) + { + return index == 0 ? QStringLiteral("#") : QString(QChar(u'A' + index - 1)); + } + + int estimateIndex(const QString& label) + { + if (label == QLatin1String("#")) + return 0; + if (label.size() == 1 && label.at(0) >= u'A' && label.at(0) <= u'Z') + return label.at(0).unicode() - u'A' + 1; + return -1; + } + + // "#" before every letter, letters in code-point order otherwise. + bool labelBefore(const QString& left, const QString& right) + { + if (left == right) + return false; + if (left == QLatin1String("#")) + return true; + if (right == QLatin1String("#")) + return false; + return left < right; + } + +} // namespace + +QString BrowseIndex::labelFor(const QString& sortName) +{ + for (const QChar character : sortName) { + if (character.isSpace()) + continue; + const QString base = QString(character).normalized(QString::NormalizationForm_D); + const QChar letter = base.isEmpty() ? character : base.at(0); + if (!letter.isLetter()) + return QStringLiteral("#"); + return QString(letter).toUpper(); + } + return QStringLiteral("#"); +} + +QString BrowseIndex::sortKey(const QString& text) +{ + const QString decomposed = text.normalized(QString::NormalizationForm_D); + QString key; + key.reserve(decomposed.size()); + for (const QChar character : decomposed) { + if (character.category() != QChar::Mark_NonSpacing) + key.append(character); + } + return key.toCaseFolded().simplified(); +} + +void BrowseIndex::reset(int count, bool descending) +{ + m_count = std::max(0, count); + m_descending = descending; + m_exactLabels.clear(); + m_exactCounts.clear(); + m_firstSeen.assign(kLetters, -1); + m_lastSeen.assign(kLetters, -1); + m_dirty = true; +} + +void BrowseIndex::setExact(const std::vector& entries) +{ + m_exactLabels.clear(); + m_exactCounts.clear(); + for (const BrowseIndexEntry& entry : entries) { + if (entry.count <= 0 || entry.label.isEmpty()) + continue; + const QString label = labelFor(entry.label); + // Servers may split accented letters out; they share a letter here. + if (!m_exactLabels.isEmpty() && m_exactLabels.last() == label) { + m_exactCounts.back() += entry.count; + continue; + } + m_exactLabels.push_back(label); + m_exactCounts.push_back(entry.count); + } + m_dirty = true; +} + +void BrowseIndex::observe(int row, const QString& sortName) +{ + if (row < 0 || row >= m_count || m_firstSeen.empty()) + return; + const int index = estimateIndex(labelFor(sortName)); + if (index < 0) + return; + int& first = m_firstSeen[static_cast(index)]; + int& last = m_lastSeen[static_cast(index)]; + if (first >= 0 && first <= row && last >= row) + return; + first = first < 0 ? row : std::min(first, row); + last = std::max(last, row); + if (!exact()) + m_dirty = true; +} + +const std::vector& BrowseIndex::letters() const +{ + if (m_dirty) + rebuild(); + return m_letters; +} + +void BrowseIndex::rebuild() const +{ + m_dirty = false; + m_letters.clear(); + if (m_count <= 0) + return; + + if (exact()) { + const qsizetype size = m_exactLabels.size(); + long long sum = 0; + for (const int count : m_exactCounts) + sum += count; + // Counts that disagree with the listing (a library changing under + // us) are scaled to fit rather than trusted row for row. + const double scale = sum > 0 ? static_cast(m_count) / static_cast(sum) : 1.0; + const bool matches = sum == m_count; + long long cumulative = 0; + for (qsizetype position = 0; position < size; ++position) { + const qsizetype ascending = m_descending ? size - 1 - position : position; + const int row = std::min(m_count - 1, static_cast(std::llround(cumulative * scale))); + m_letters.push_back({ m_exactLabels.at(ascending), row, matches }); + cumulative += m_exactCounts[static_cast(ascending)]; + } + return; + } + + // Estimate: everything in listing order. + std::array weight {}; + std::array first {}; + std::array last {}; + for (int position = 0; position < kLetters; ++position) { + const int ascending = m_descending ? kLetters - 1 - position : position; + weight[position] = kPrior[ascending]; + first[position] = m_firstSeen[static_cast(ascending)]; + last[position] = m_lastSeen[static_cast(ascending)]; + } + int previous = 0; + for (int k = 0; k < kLetters; ++k) { + // The nearest pinned rows either side of where letter k begins. + int lo = -1; + int a = -1; + for (int j = 0; j < k; ++j) { + if (last[j] > lo) { + lo = last[j]; + a = j; + } + } + int hi = m_count; + int b = kLetters; + for (int j = k; j < kLetters; ++j) { + if (first[j] >= 0 && first[j] < hi) { + hi = first[j]; + b = j; + } + } + int row = 0; + bool exactRow = false; + if (hi <= lo + 1) { + row = hi; + exactRow = hi == lo + 1; + } else { + const double startShare = a >= 0 ? weight[a] / 2 : 0.0; + const double endShare = b < kLetters ? weight[b] / 2 : 0.0; + double before = startShare; + for (int j = a + 1; j < k; ++j) + before += weight[j]; + double total = before + endShare; + for (int j = std::max(a + 1, k); j < b; ++j) + total += weight[j]; + const double share = total > 0 ? before / total : 0.0; + row = lo + 1 + static_cast(std::lround(share * (hi - lo - 1))); + } + row = std::clamp(std::max(row, previous), 0, m_count); + previous = row; + m_letters.push_back( + { estimateLabel(m_descending ? kLetters - 1 - k : k), std::min(row, m_count - 1), exactRow }); + } +} + +int BrowseIndex::labelIndex(const QString& label) const +{ + const auto& all = letters(); + for (size_t i = 0; i < all.size(); ++i) { + if (all[i].label == label) + return static_cast(i); + } + return -1; +} + +QString BrowseIndex::labelAt(int row) const +{ + const auto& all = letters(); + if (all.empty()) + return QStringLiteral("#"); + // The last letter starting at or before the row: of several letters + // starting on one row, all but the last are empty. + const auto after = std::upper_bound( + all.begin(), all.end(), row, [](int value, const Letter& letter) { return value < letter.row; }); + return after == all.begin() ? all.front().label : std::prev(after)->label; +} + +int BrowseIndex::rowForLabel(const QString& label) const +{ + const auto& all = letters(); + if (all.empty()) + return -1; + const int found = labelIndex(label); + if (found >= 0) + return all[static_cast(found)].row; + for (const Letter& letter : all) { + const bool after = m_descending ? labelBefore(letter.label, label) : labelBefore(label, letter.label); + if (after) + return letter.row; + } + return m_count - 1; +} + +std::pair BrowseIndex::spanForLabel(const QString& label) const +{ + const auto& all = letters(); + const int start = rowForLabel(label); + if (start < 0) + return { 0, m_count }; + const int found = labelIndex(label); + if (found < 0) + return { start, start }; + const size_t next = static_cast(found) + 1; + return { start, next < all.size() ? std::max(start, all[next].row) : m_count }; +} + +} // namespace Spool diff --git a/src/app/BrowseIndex.h b/src/app/BrowseIndex.h new file mode 100644 index 00000000..895b16e1 --- /dev/null +++ b/src/app/BrowseIndex.h @@ -0,0 +1,81 @@ +#pragma once + +#include "../media/MediaTypes.h" + +#include +#include + +#include +#include + +namespace Spool { + +// Where each leading letter starts in a name-sorted listing that may be +// mostly unloaded. With the server's counts the answer is exact. Without +// them it starts from how film and series titles usually divide across the +// alphabet and corrects itself from every row that arrives: a row whose +// letter is known pins that letter's span, and the unknown stretches between +// pinned rows are shared out by the same prior. A jump to an estimated +// letter therefore loads the rows that make the next estimate better. +class BrowseIndex final { +public: + struct Letter { + QString label; + int row = 0; + bool exact = false; + }; + + // A fresh listing of `count` rows, ascending or descending by name. + void reset(int count, bool descending); + int count() const + { + return m_count; + } + bool descending() const + { + return m_descending; + } + // The server's counts per leading character, ascending ("#" first). + void setExact(const std::vector& entries); + bool exact() const + { + return !m_exactLabels.isEmpty(); + } + // A loaded row's sort name. Rows may be observed in any order and more + // than once; what was learnt outlives the row being unloaded again. + void observe(int row, const QString& sortName); + + // Letters in listing order with the row each starts at. Letters with no + // rows are left out when exact and kept (zero width) when estimated. + const std::vector& letters() const; + // The letter a row is under, known or estimated. + QString labelAt(int row) const; + // First row of a letter, or of the nearest letter after it in listing + // order when it has none; -1 when the letter is unknown to the index. + int rowForLabel(const QString& label) const; + // The rows [first, end) a letter spans, estimated or exact. + std::pair spanForLabel(const QString& label) const; + + // "#" for anything that does not start with a letter; letters are + // upper-cased with accents removed, so "Émilie" files under "E". + static QString labelFor(const QString& sortName); + // Lower-cased, accent-free comparison form of a sort name or typed text. + static QString sortKey(const QString& text); + +private: + void rebuild() const; + int labelIndex(const QString& label) const; + + int m_count = 0; + bool m_descending = false; + // Ascending labels and the rows of each, from the server. + QStringList m_exactLabels; + std::vector m_exactCounts; + // Per ascending estimate label: first and last observed row, or -1. + std::vector m_firstSeen; + std::vector m_lastSeen; + mutable bool m_dirty = true; + mutable std::vector m_letters; +}; + +} // namespace Spool diff --git a/src/app/BrowseSessionController.cpp b/src/app/BrowseSessionController.cpp index 61caa853..2dc81fc7 100644 --- a/src/app/BrowseSessionController.cpp +++ b/src/app/BrowseSessionController.cpp @@ -7,14 +7,110 @@ #include #include #include +#include #include namespace Spool { +namespace { + + // Block fetches in flight at once. Providers allow eight operations per + // account; leaving room keeps artwork, details and search responsive. + constexpr int kMaxPendingBlocks = 3; + // Guesses a type-to-seek makes before settling on the nearest row. + constexpr int kMaxSeekSteps = 10; + + bool supportsSparse(BrowseKind kind) + { + return kind == BrowseKind::Library || kind == BrowseKind::Genre || kind == BrowseKind::Studio; + } + + QString itemSortName(const MovieItem& item) + { + return item.sortName.isEmpty() ? item.title : item.sortName; + } + + // Where a folded sort key falls in [0, 1) by its first few characters: + // the ruler a seek uses to guess between two rows it already knows. + double keyPosition(const QString& key) + { + double position = 0; + double scale = 1; + for (int i = 0; i < std::min(4, key.size()); ++i) { + const char16_t c = key.at(i).unicode(); + const int value = c < u'a' ? 0 : c > u'z' ? 27 : c - u'a' + 1; + scale /= 28; + position += value * scale; + } + return position; + } + +} // namespace + BrowseSessionController::BrowseSessionController(LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) , m_prefetch(prefetch) { + // Lead blocks wait for scrolling to pause; a fling across a library must + // not queue a fetch for every block it passes. + m_blockTimer.setSingleShot(true); + m_blockTimer.setInterval(90); + connect(&m_blockTimer, &QTimer::timeout, this, &BrowseSessionController::pumpBlocks); +} + +bool BrowseSessionController::alphabetical() const +{ + if (!supportsSparse(m_descriptor.kind)) + return false; + const QString sortBy = m_query.value(QStringLiteral("sortBy")).toString(); + return sortBy.isEmpty() || sortBy == QLatin1String("SortName"); +} + +void BrowseSessionController::noteAlphabetChanged() +{ + ++m_alphabetRevision; + emit alphabetChanged(); +} + +bool BrowseSessionController::sortDescending() const +{ + return m_query.value(QStringLiteral("sortOrder")).toString() == QLatin1String("Descending"); +} + +QVariantList BrowseSessionController::alphabet() const +{ + QVariantList result; + if (!alphabetical() || m_items.rowCount() <= 0) + return result; + const auto& letters = m_index.letters(); + result.reserve(static_cast(letters.size())); + for (size_t i = 0; i < letters.size(); ++i) { + const auto& letter = letters[i]; + const int end = i + 1 < letters.size() ? letters[i + 1].row : m_items.rowCount(); + // A letter known to be empty has nowhere to jump to. + if (end <= letter.row && letter.exact) + continue; + result.push_back(QVariantMap { { QStringLiteral("label"), letter.label }, { QStringLiteral("row"), letter.row }, + { QStringLiteral("exact"), letter.exact } }); + } + return result; +} + +QString BrowseSessionController::labelAt(int row) const +{ + if (m_items.isLoaded(row)) + return BrowseIndex::labelFor(itemSortName(m_items.movieAt(row))); + return m_index.labelAt(row); +} + +int BrowseSessionController::rowForLabel(const QString& label) const +{ + return m_index.rowForLabel(label); +} + +void BrowseSessionController::setLoadedRowBudget(int rows) +{ + m_loadedRowBudget = std::max(400, rows); } int BrowseSessionController::filterActiveCount() const @@ -45,6 +141,7 @@ int BrowseSessionController::applyCachedPage(const QString& cacheKey) void BrowseSessionController::clear() { + resetSparseState(); m_items.clear(); } @@ -69,9 +166,46 @@ void BrowseSessionController::resetPaging(const QString& cacheKey) m_pageSize = 0; m_nextCursor.reset(); m_seenCursors.clear(); + resetSparseState(); emit pagingChanged(); } +void BrowseSessionController::resetSparseState() +{ + m_blockTimer.stop(); + m_loadedBlocks.clear(); + m_pendingBlocks.clear(); + m_wantedBlocks.clear(); + m_viewFirst = 0; + m_viewLast = 0; + m_index.reset(0, false); + cancelSeek(); + noteAlphabetChanged(); +} + +void BrowseSessionController::observeRows(int first, const std::vector& items) +{ + if (!alphabetical()) + return; + for (size_t offset = 0; offset < items.size(); ++offset) + m_index.observe(first + static_cast(offset), itemSortName(items[offset])); +} + +void BrowseSessionController::beginSparseListing(const PagedMovieItems& page) +{ + resetSparseState(); + m_blockSize = std::max(1, page.limit > 0 ? page.limit : static_cast(page.items.size())); + m_items.beginSparse(page.totalRecordCount); + m_items.setRows(page.startIndex, page.items); + m_loadedBlocks[page.startIndex - page.startIndex % m_blockSize] = ++m_useClock; + m_index.reset(page.totalRecordCount, sortDescending()); + observeRows(page.startIndex, page.items); + qInfo() << "browse page: sparse listing" << "rows=" << page.totalRecordCount << "block=" << m_blockSize; + noteAlphabetChanged(); + if (alphabetical()) + emit indexRequested(); +} + void BrowseSessionController::setPage(const PagedMovieItems& page, const QString& cacheKey, bool append) { QElapsedTimer applyTimer; @@ -88,6 +222,30 @@ void BrowseSessionController::setPage(const PagedMovieItems& page, const QString if (!page.exhausted) m_seenCursors.insert(*page.nextCursor); m_nextCursor = page.exhausted ? std::nullopt : page.nextCursor; + + const bool sparseListing = !append && page.seekable && page.startIndex == 0 && supportsSparse(m_descriptor.kind) + && page.totalRecordCount > static_cast(page.items.size()); + if (sparseListing) { + // A refresh of the same listing keeps the rows already loaded. + if (m_items.isSparse() && m_items.rowCount() == page.totalRecordCount && m_cacheKey == cacheKey) { + m_items.setRows(0, page.items); + observeRows(0, page.items); + } else { + beginSparseListing(page); + } + m_cacheKey = cacheKey; + m_nextStartIndex = page.totalRecordCount; + m_totalCount = page.totalRecordCount; + if (page.limit > 0) + m_pageSize = page.limit; + m_hasMore = false; + m_loadingMore = false; + if (m_prefetch) + m_prefetch->prefetchPosters(page.items); + emit pagingChanged(); + return; + } + if (append) m_items.appendMovies(page.items); else @@ -106,12 +264,287 @@ void BrowseSessionController::setPage(const PagedMovieItems& page, const QString m_pageSize = page.limit; m_hasMore = !page.exhausted; m_loadingMore = false; + // A whole listing in hand is its own exact index. + if (!append || m_index.count() != m_items.rowCount()) { + m_index.reset(m_items.rowCount(), sortDescending()); + observeRows(0, m_items.movies()); + } else { + observeRows(page.startIndex, page.items); + } + noteAlphabetChanged(); if (m_prefetch) m_prefetch->prefetchPosters(page.items); emit pagingChanged(); } +void BrowseSessionController::applyBlock(int start, const PagedMovieItems& page, const QString& cacheKey) +{ + m_pendingBlocks.erase(std::remove(m_pendingBlocks.begin(), m_pendingBlocks.end(), start), m_pendingBlocks.end()); + if (!m_items.isSparse() || cacheKey != m_cacheKey) + return; + if (page.totalRecordCount > 0 && page.totalRecordCount != m_items.rowCount()) { + // The library changed underneath; start the listing over from here. + qInfo() << "browse page: sparse listing changed size" << m_items.rowCount() << "->" << page.totalRecordCount; + PagedMovieItems restart = page; + restart.startIndex = start; + beginSparseListing(restart); + m_totalCount = page.totalRecordCount; + m_nextStartIndex = page.totalRecordCount; + emit pagingChanged(); + return; + } + m_items.setRows(start, page.items); + m_loadedBlocks[start] = ++m_useClock; + if (!m_index.exact()) { + observeRows(start, page.items); + noteAlphabetChanged(); + } + evictBlocks(); + const bool viewportPending = std::any_of(m_pendingBlocks.begin(), m_pendingBlocks.end(), + [this](int block) { return block + m_blockSize > m_viewFirst && block <= m_viewLast; }); + if (m_loadingMore != viewportPending) { + m_loadingMore = viewportPending; + emit pagingChanged(); + } + if (!m_seekKey.isEmpty() && m_seekWaitingFor == start) + stepSeek(); + pumpBlocks(); +} + +void BrowseSessionController::blockFailed(int start) +{ + m_pendingBlocks.erase(std::remove(m_pendingBlocks.begin(), m_pendingBlocks.end(), start), m_pendingBlocks.end()); + if (!m_seekKey.isEmpty() && m_seekWaitingFor == start) + cancelSeek(); + if (m_loadingMore && m_pendingBlocks.empty()) { + m_loadingMore = false; + emit pagingChanged(); + } +} + +void BrowseSessionController::setExactIndex(const std::vector& entries, const QString& cacheKey) +{ + if (cacheKey != m_cacheKey || !alphabetical()) + return; + if (!entries.empty()) { + m_index.setExact(entries); + noteAlphabetChanged(); + return; + } + // No counts from the server: the far end of the listing is the anchor + // that makes the estimate worth having. + if (m_items.isSparse()) { + const int last = (m_items.rowCount() - 1) / m_blockSize * m_blockSize; + if (!m_loadedBlocks.contains(last)) { + m_wantedBlocks.push_back(last); + pumpBlocks(); + } + } +} + +void BrowseSessionController::scheduleBlocks() +{ + const int count = m_items.rowCount(); + if (!m_items.isSparse() || count <= 0) + return; + const int firstBlock = std::clamp(m_viewFirst, 0, count - 1) / m_blockSize; + const int lastBlock = std::clamp(m_viewLast, 0, count - 1) / m_blockSize; + const int center = (firstBlock + lastBlock) / 2; + std::deque wanted; + auto want = [&](int block) { + const int start = block * m_blockSize; + if (block < 0 || start >= count || m_loadedBlocks.contains(start)) + return; + if (std::find(wanted.begin(), wanted.end(), start) == wanted.end()) + wanted.push_back(start); + }; + // On screen from the middle out, then one block either side. + for (int distance = 0; center - distance >= firstBlock || center + distance <= lastBlock; ++distance) { + if (center + distance <= lastBlock) + want(center + distance); + if (distance > 0 && center - distance >= firstBlock) + want(center - distance); + } + want(lastBlock + 1); + want(firstBlock - 1); + // A pending seek's block and the far-end anchor stay wanted. + for (const int start : m_wantedBlocks) { + if (start == m_seekWaitingFor || start == (count - 1) / m_blockSize * m_blockSize) + want(start / m_blockSize); + } + m_wantedBlocks = std::move(wanted); + for (int block = firstBlock; block <= lastBlock; ++block) { + if (const auto loaded = m_loadedBlocks.find(block * m_blockSize); loaded != m_loadedBlocks.end()) + loaded->second = ++m_useClock; + } + const bool viewportMissing = std::any_of(m_wantedBlocks.begin(), m_wantedBlocks.end(), + [&](int start) { return start / m_blockSize >= firstBlock && start / m_blockSize <= lastBlock; }); + if (viewportMissing) + pumpBlocks(); + else + m_blockTimer.start(); +} + +void BrowseSessionController::pumpBlocks() +{ + while (static_cast(m_pendingBlocks.size()) < kMaxPendingBlocks && !m_wantedBlocks.empty()) { + const int start = m_wantedBlocks.front(); + m_wantedBlocks.pop_front(); + if (m_loadedBlocks.contains(start) + || std::find(m_pendingBlocks.begin(), m_pendingBlocks.end(), start) != m_pendingBlocks.end()) + continue; + m_pendingBlocks.push_back(start); + if (start + m_blockSize > m_viewFirst && start <= m_viewLast && !m_loadingMore) { + m_loadingMore = true; + emit pagingChanged(); + } + emit blockRequested(start); + } +} + +void BrowseSessionController::evictBlocks() +{ + const int budget = std::max(4, m_loadedRowBudget / m_blockSize); + const int keepFirst = m_viewFirst - 2 * m_blockSize; + const int keepLast = m_viewLast + 2 * m_blockSize; + const int center = (m_viewFirst + m_viewLast) / 2; + while (static_cast(m_loadedBlocks.size()) > budget) { + auto victim = m_loadedBlocks.end(); + int victimDistance = -1; + for (auto it = m_loadedBlocks.begin(); it != m_loadedBlocks.end(); ++it) { + const int start = it->first; + // The opening block is the warm cache's page; keep it. + if (start == 0 || (start + m_blockSize > keepFirst && start <= keepLast)) + continue; + const int distance = std::abs(start + m_blockSize / 2 - center); + if (distance > victimDistance) { + victim = it; + victimDistance = distance; + } + } + if (victim == m_loadedBlocks.end()) + return; + m_items.unloadRows(victim->first, victim->first + m_blockSize - 1); + m_loadedBlocks.erase(victim); + } +} + +bool BrowseSessionController::seekPrefix(const QString& text) +{ + if (!alphabetical() || (!m_items.isSparse() && m_hasMore)) + return false; + const QString key = BrowseIndex::sortKey(text); + if (key.isEmpty() || m_items.rowCount() <= 0) + return false; + m_seekKey = key; + m_seekSteps = 0; + m_seekWaitingFor = -1; + emit seekingChanged(); + stepSeek(); + return true; +} + +void BrowseSessionController::cancelSeek() +{ + const bool was = !m_seekKey.isEmpty(); + m_seekKey.clear(); + m_seekWaitingFor = -1; + m_seekSteps = 0; + if (was) + emit seekingChanged(); +} + +void BrowseSessionController::stepSeek() +{ + if (m_seekKey.isEmpty()) + return; + const QString target = m_seekKey; + const bool descending = sortDescending(); + const int count = m_items.rowCount(); + // Rows at or after where the target's run begins, in listing order. + auto atOrAfter + = [&](const QString& key) { return descending ? (key < target || key.startsWith(target)) : key >= target; }; + + // The tightest bracket the loaded rows give. + int lo = -1; + int hi = count; + QString loKey; + QString hiKey; + const auto& loaded = m_items.movies(); + for (int slot = 0; slot < static_cast(loaded.size()); ++slot) { + const int row = m_items.rowOfSlot(slot); + const QString key = BrowseIndex::sortKey(itemSortName(loaded[static_cast(slot)])); + if (atOrAfter(key)) { + if (row < hi) { + hi = row; + hiKey = key; + } + } else if (row > lo) { + lo = row; + loKey = key; + } + } + + const auto settle = [this, count](int row) { + const int settled = std::clamp(row, 0, count - 1); + cancelSeek(); + emit seekMoved(settled, true); + }; + // Adjacent, or crossed where the server collates differently from us. + if (hi <= lo + 1) { + settle(hi); + return; + } + if (++m_seekSteps > kMaxSeekSteps) { + settle(hi); + return; + } + + // Narrow by the letter's span, then interpolate on the key's characters. + const double targetPosition = keyPosition(target); + const double ascendingStart = keyPosition(target.left(1)); + const auto [letterStart, letterEnd] = m_index.spanForLabel(BrowseIndex::labelFor(target)); + struct Bound { + int row; + double position; + }; + Bound low { lo + 1, lo >= 0 ? keyPosition(loKey) : (descending ? 1.0 : 0.0) }; + Bound high { hi, hi < count ? keyPosition(hiKey) : (descending ? 0.0 : 1.0) }; + const double letterLowPosition = descending ? ascendingStart + 1.0 / 28 : ascendingStart; + const double letterHighPosition = descending ? ascendingStart : ascendingStart + 1.0 / 28; + if (letterStart > low.row && letterStart < high.row) + low = { letterStart, letterLowPosition }; + if (letterEnd < high.row && letterEnd > low.row) + high = { letterEnd, letterHighPosition }; + double share = 0.5; + if (std::abs(high.position - low.position) > 1e-12) + share = std::clamp((targetPosition - low.position) / (high.position - low.position), 0.0, 1.0); + if (target.size() == 1 && letterStart > lo && letterStart < hi) + share = 0; + const int guess = std::clamp( + static_cast(std::lround(low.row + share * (high.row - low.row))), lo + 1, std::max(lo + 1, hi - 1)); + const int block = guess / m_blockSize * m_blockSize; + // The view follows the guess, so that is where rows must stay loaded. + const int span = std::max(0, m_viewLast - m_viewFirst); + m_viewFirst = guess; + m_viewLast = std::min(count - 1, guess + span); + emit seekMoved(guess, false); + if (!m_items.isSparse() || m_loadedBlocks.contains(block)) { + settle(guess); + return; + } + m_seekWaitingFor = block; + m_wantedBlocks.erase(std::remove(m_wantedBlocks.begin(), m_wantedBlocks.end(), block), m_wantedBlocks.end()); + m_wantedBlocks.push_front(block); + if (std::find(m_pendingBlocks.begin(), m_pendingBlocks.end(), block) == m_pendingBlocks.end()) { + // A seek is what the viewer is waiting on; it goes ahead of the queue. + m_pendingBlocks.push_back(block); + m_wantedBlocks.pop_front(); + emit blockRequested(block); + } +} + void BrowseSessionController::setLoadingMore(bool loading) { if (m_loadingMore == loading) @@ -124,6 +557,16 @@ void BrowseSessionController::prefetchVisibleRange(int firstIndex, int lastIndex { if (firstIndex < 0 || lastIndex < firstIndex || m_items.rowCount() <= 0) return; + if (m_items.isSparse()) { + m_viewFirst = firstIndex; + m_viewLast = lastIndex; + scheduleBlocks(); + if (m_prefetch) { + const std::vector visible = m_items.loadedRange(firstIndex, lastIndex + 16); + m_prefetch->prefetchPosters(visible, 0, static_cast(visible.size())); + } + return; + } if (m_prefetch) m_prefetch->prefetchPosters(m_items.movies(), firstIndex, lastIndex - firstIndex + 1); prefetchPageForIndex(lastIndex); @@ -131,7 +574,7 @@ void BrowseSessionController::prefetchVisibleRange(int firstIndex, int lastIndex void BrowseSessionController::prefetchPageForIndex(int lastIndex) { - if (m_pageSize <= 0 || lastIndex < 0) + if (m_pageSize <= 0 || lastIndex < 0 || m_items.isSparse()) return; // Maintain one and a half pages ahead of the visible tail. Waiting until // the highlight enters the newest page leaves too little network headroom diff --git a/src/app/BrowseSessionController.h b/src/app/BrowseSessionController.h index b49e6baf..2faf8ef1 100644 --- a/src/app/BrowseSessionController.h +++ b/src/app/BrowseSessionController.h @@ -2,13 +2,18 @@ #include "../media/MediaTypes.h" #include "../models/MovieGridModel.h" +#include "BrowseIndex.h" #include #include #include #include +#include +#include #include +#include +#include #include namespace Spool { @@ -29,6 +34,16 @@ class BrowseSessionController final : public QObject { Q_PROPERTY(QVariantMap query READ query NOTIFY changed) Q_PROPERTY(QVariantMap filterOptions READ filterOptions NOTIFY changed) Q_PROPERTY(int filterActiveCount READ filterActiveCount NOTIFY changed) + // Only the rows near the viewport are held; the rest are placeholders. + Q_PROPERTY(bool sparse READ sparse NOTIFY pagingChanged) + // Sorted by name, so letters mean something and typing can seek. + Q_PROPERTY(bool alphabetical READ alphabetical NOTIFY changed) + // [{label, row, exact}] in listing order, for the letter rail. + Q_PROPERTY(QVariantList alphabet READ alphabet NOTIFY alphabetChanged) + Q_PROPERTY(bool alphabetExact READ alphabetExact NOTIFY alphabetChanged) + // Bumps whenever letter positions may have moved; cheap to bind to. + Q_PROPERTY(int alphabetRevision READ alphabetRevision NOTIFY alphabetChanged) + Q_PROPERTY(bool seeking READ seeking NOTIFY seekingChanged) public: explicit BrowseSessionController(LibraryPrefetchController *prefetch, QObject *parent = nullptr); @@ -103,6 +118,37 @@ class BrowseSessionController final : public QObject { return m_filterOptions; } int filterActiveCount() const; + bool sparse() const + { + return m_items.isSparse(); + } + bool alphabetical() const; + QVariantList alphabet() const; + bool alphabetExact() const + { + return m_index.exact(); + } + int alphabetRevision() const + { + return m_alphabetRevision; + } + bool seeking() const + { + return !m_seekKey.isEmpty(); + } + int blockSize() const + { + return m_blockSize; + } + // The letter a row is under, known or estimated: what a placeholder and + // the scrub callout show before the row has loaded. + Q_INVOKABLE QString labelAt(int row) const; + Q_INVOKABLE int rowForLabel(const QString& label) const; + // Moves to the first title starting with `text`, as a file browser does, + // loading only the rows it needs to find it. False when this listing + // cannot seek by name; the page then searches what is loaded. + Q_INVOKABLE bool seekPrefix(const QString& text); + Q_INVOKABLE void cancelSeek(); BrowseDescriptor descriptor() const { return m_descriptor; @@ -117,6 +163,12 @@ class BrowseSessionController final : public QObject { void resetPaging(const QString& cacheKey = {}); void setPage(const PagedMovieItems& page, const QString& cacheKey, bool append); void setLoadingMore(bool loading); + // A requested block of a sparse listing arrived, or could not be had. + void applyBlock(int start, const PagedMovieItems& page, const QString& cacheKey); + void blockFailed(int start); + void setExactIndex(const std::vector& entries, const QString& cacheKey); + // Most rows a sparse listing keeps loaded at once. + void setLoadedRowBudget(int rows); Q_INVOKABLE void prefetchVisibleRange(int firstIndex, int lastIndex); Q_INVOKABLE void prefetchPageForIndex(int lastIndex); Q_INVOKABLE void prefetchNextPage(); @@ -141,10 +193,26 @@ class BrowseSessionController final : public QObject { void changed(); void reloadRequested(); void moreItemsRequested(); + // Fetch rows [start, start + blockSize) of the current listing. + void blockRequested(int start); + void indexRequested(); + void alphabetChanged(); + void seekingChanged(); + // A seek moved to `row`; `settled` once it is the answer, not a guess. + void seekMoved(int row, bool settled); private: void clearBrowseIdentity(); void requestQuery(QVariantMap query); + void beginSparseListing(const PagedMovieItems& page); + void resetSparseState(); + void observeRows(int first, const std::vector& items); + void scheduleBlocks(); + void pumpBlocks(); + void evictBlocks(); + void stepSeek(); + bool sortDescending() const; + void noteAlphabetChanged(); LibraryPrefetchController *m_prefetch = nullptr; MovieGridModel m_items; @@ -167,6 +235,23 @@ class BrowseSessionController final : public QObject { QHash m_libraryQueries; QVariantMap m_query; QVariantMap m_filterOptions; + + // Sparse listings: blocks are aligned runs of m_blockSize rows. + int m_blockSize = 100; + int m_loadedRowBudget = 3000; + int m_viewFirst = 0; + int m_viewLast = 0; + std::map m_loadedBlocks; // start row -> last use + std::vector m_pendingBlocks; + std::deque m_wantedBlocks; + quint64 m_useClock = 0; + QTimer m_blockTimer; + BrowseIndex m_index; + // Type-to-seek in progress: the folded prefix and how many guesses so far. + QString m_seekKey; + int m_seekSteps = 0; + int m_seekWaitingFor = -1; + int m_alphabetRevision = 0; }; } // namespace Spool diff --git a/src/app/ContentModelController.cpp b/src/app/ContentModelController.cpp index 0e90353c..40ee7aaf 100644 --- a/src/app/ContentModelController.cpp +++ b/src/app/ContentModelController.cpp @@ -50,6 +50,8 @@ void ContentModelController::loadDetailRows( m_detailSeasons.clear(); m_detailSeasonOptions.clear(); m_detailSimilarItems.clear(); + m_detailTrailers.clear(); + m_detailExtras.clear(); if (itemId.isEmpty() || !m_api || !m_api->signedIn()) { emit detailRowsChanged(); @@ -63,8 +65,10 @@ void ContentModelController::loadDetailRows( const bool loadBoxSet = itemType == QStringLiteral("BoxSet"); const bool loadAlbum = itemType == QStringLiteral("MusicAlbum"); const bool loadContext = loadSeasons || loadEpisodes || loadBoxSet || loadAlbum; + const bool loadRelatedMedia = itemType == QStringLiteral("Movie") || itemType == QStringLiteral("Series") + || itemType == QStringLiteral("Season") || itemType == QStringLiteral("Episode"); m_detailRowsBusy = true; - m_detailRowsPending = 1 + (loadContext ? 1 : 0) + (loadSeasonOptions ? 1 : 0); + m_detailRowsPending = 1 + (loadContext ? 1 : 0) + (loadSeasonOptions ? 1 : 0) + (loadRelatedMedia ? 2 : 0); emit detailRowsChanged(); qInfo() << "detail rows: loading" << itemType << itemId << "context=" @@ -166,6 +170,27 @@ void ContentModelController::loadDetailRows( }); } + if (loadRelatedMedia) { + for (const auto& [kind, model] : + { std::pair { QStringLiteral("trailers"), &m_detailTrailers }, + std::pair { QStringLiteral("extras"), &m_detailExtras } }) { + Async::runLatest( + this, m_api->fetchRelatedMedia(itemId, kind), m_detailRowsGeneration, generation, + [this, generation, model](const std::vector& items) { + model->setMovies(items); + m_prefetch->prefetchPosters(items); + emit detailRowsChanged(); + finishDetailRowLoad(generation); + }, + [this, generation](const std::exception_ptr& error) { + // An unavailable optional shelf must not hide the main + // item, its episodes, or other successful detail rows. + qWarning() << "detail rows: related media fetch failed" << exceptionMessage(error); + finishDetailRowLoad(generation); + }); + } + } + Async::runLatest( this, m_api->fetchSimilarItems(itemId), m_detailRowsGeneration, generation, [this, generation, itemId](const std::vector& items) { @@ -184,24 +209,40 @@ void ContentModelController::loadDetailRows( void ContentModelController::loadItemDetail(const QString& itemId) { const RequestGeneration::Token generation = m_detailItemGeneration.next(); - m_detailItem = {}; if (itemId.isEmpty() || !m_api || !m_api->signedIn()) { + m_detailItem = {}; + m_detailItemBusy = false; emit detailItemChanged(); return; } + // Refreshing the item on screen keeps it there: blanking it until the + // answer arrives made the page drop to its sparse route copy and back. + // Another item starts from whatever was last fetched for it. + if (m_detailItem.id != itemId) { + m_detailItem = m_detailCache.value(itemId); + if (!m_detailItem.id.isEmpty()) + m_api->applyLocalPlaybackState(m_detailItem); + } + m_detailItemBusy = true; emit detailItemChanged(); Async::runLatest( this, m_api->fetchItemDetails(itemId), m_detailItemGeneration, generation, [this](MovieItem item) { m_api->applyLocalPlaybackState(item); + if (m_detailCache.size() >= 48) + m_detailCache.clear(); + m_detailCache.insert(item.id, item); m_detailItem = std::move(item); + m_detailItemBusy = false; emit detailItemChanged(); }, - [this](const std::exception_ptr& error) { - m_detailItem = {}; + [this, itemId](const std::exception_ptr& error) { + if (m_detailItem.id != itemId) + m_detailItem = {}; + m_detailItemBusy = false; emit detailItemChanged(); emit errorOccurred(exceptionMessage(error)); }); @@ -374,6 +415,8 @@ void ContentModelController::updateResumeTicks(const QString& itemId, qint64 pos m_detailSeasons.updateResumeTicks(itemId, positionTicks); m_detailSeasonOptions.updateResumeTicks(itemId, positionTicks); m_detailSimilarItems.updateResumeTicks(itemId, positionTicks); + m_detailTrailers.updateResumeTicks(itemId, positionTicks); + m_detailExtras.updateResumeTicks(itemId, positionTicks); for (PersonItemSection& section : m_personItemSections) section.model->updateResumeTicks(itemId, positionTicks); } @@ -384,10 +427,14 @@ void ContentModelController::updateFavorite(const QString& itemId, bool favorite m_detailItem.favorite = favorite; emit detailItemChanged(); } + if (const auto cached = m_detailCache.find(itemId); cached != m_detailCache.end()) + cached->favorite = favorite; m_linkedItems.updateFavorite(itemId, favorite); m_detailSeasons.updateFavorite(itemId, favorite); m_detailSeasonOptions.updateFavorite(itemId, favorite); m_detailSimilarItems.updateFavorite(itemId, favorite); + m_detailTrailers.updateFavorite(itemId, favorite); + m_detailExtras.updateFavorite(itemId, favorite); for (PersonItemSection& section : m_personItemSections) section.model->updateFavorite(itemId, favorite); } @@ -407,6 +454,8 @@ void ContentModelController::updatePlayed(const QString& itemId, bool played) m_detailSeasons.updatePlayed(itemId, played); m_detailSeasonOptions.updatePlayed(itemId, played); m_detailSimilarItems.updatePlayed(itemId, played); + m_detailTrailers.updatePlayed(itemId, played); + m_detailExtras.updatePlayed(itemId, played); for (PersonItemSection& section : m_personItemSections) section.model->updatePlayed(itemId, played); } @@ -420,11 +469,15 @@ void ContentModelController::reset() m_detailSeasons.clear(); m_detailSeasonOptions.clear(); m_detailSimilarItems.clear(); + m_detailTrailers.clear(); + m_detailExtras.clear(); clearPersonItems(); m_linkedItems.clear(); if (m_api) m_api->clearLocalPlaybackState(); m_detailItem = {}; + m_detailCache.clear(); + m_detailItemBusy = false; m_detailRowsBusy = false; m_detailContextInitialIndex = 0; m_detailRowsPending = 0; diff --git a/src/app/ContentModelController.h b/src/app/ContentModelController.h index f987bb2d..696b1cd7 100644 --- a/src/app/ContentModelController.h +++ b/src/app/ContentModelController.h @@ -4,6 +4,7 @@ #include "../models/MovieGridModel.h" #include "../provider/Catalog.h" +#include #include #include #include @@ -22,9 +23,12 @@ class ContentModelController final : public QObject { 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 *detailTrailers READ detailTrailers CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *detailExtras READ detailExtras 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(bool detailItemBusy READ detailItemBusy NOTIFY detailItemChanged) Q_PROPERTY(int detailContextInitialIndex READ detailContextInitialIndex NOTIFY detailRowsChanged) Q_PROPERTY(bool personItemsBusy READ personItemsBusy NOTIFY personItemsChanged) @@ -43,6 +47,14 @@ class ContentModelController final : public QObject { { return &m_detailSimilarItems; } + MovieGridModel *detailTrailers() + { + return &m_detailTrailers; + } + MovieGridModel *detailExtras() + { + return &m_detailExtras; + } QVariantList personItemRows() const; MovieGridModel *linkedItems() { @@ -52,6 +64,10 @@ class ContentModelController final : public QObject { { return m_detailRowsBusy; } + bool detailItemBusy() const + { + return m_detailItemBusy; + } int detailContextInitialIndex() const { return m_detailContextInitialIndex; @@ -103,9 +119,15 @@ class ContentModelController final : public QObject { MovieGridModel m_detailSeasons; MovieGridModel m_detailSeasonOptions; MovieGridModel m_detailSimilarItems; + MovieGridModel m_detailTrailers; + MovieGridModel m_detailExtras; std::vector m_personItemSections; MovieGridModel m_linkedItems; MovieItem m_detailItem; + // Recently fetched details, shown at once when an item is reopened while + // the fresh copy loads. Small: a browsing session revisits a handful. + QHash m_detailCache; + bool m_detailItemBusy = false; bool m_detailRowsBusy = false; int m_detailContextInitialIndex = 0; RequestGeneration m_detailRowsGeneration; diff --git a/src/app/DownloadManager.cpp b/src/app/DownloadManager.cpp index d9c96cf4..12003392 100644 --- a/src/app/DownloadManager.cpp +++ b/src/app/DownloadManager.cpp @@ -114,6 +114,12 @@ DownloadManager::DownloadManager(SourceHub *sources, QString dataRoot, TlsTrustC trust->attachNetworkAccessManager(m_network, QStringLiteral("Download")); m_destination = defaultDestination(); restore(); + // First connection wins: snapshots are stale before any QML dependent + // re-reads the inventory on this notification. + connect(this, &DownloadManager::changed, this, [this] { + m_jobsSnapshotValid = false; + m_libraryFilesSnapshotValid = false; + }); connect(m_androidStorage, &AndroidDownloadStorage::folderSelected, this, &DownloadManager::setDestination); connect(m_androidStorage, &AndroidDownloadStorage::problem, this, [this](const QString& problem) { m_problem = problem; @@ -133,6 +139,9 @@ DownloadManager::~DownloadManager() { disconnect(m_sources, nullptr, this, nullptr); for (const auto& job : m_jobs) { + // An acquired plan releases exactly once: moving it here empties it + // for any later completion path, and only the provider cleans up. + release(m_sources, std::move(job->plan)).then([] { }); if (job->stopped) continue; job->stopped = true; @@ -155,6 +164,8 @@ DownloadManager::~DownloadManager() } QVariantList DownloadManager::jobs() const { + if (m_jobsSnapshotValid) + return m_jobsSnapshot; QVariantList rows; rows.reserve(m_order.size()); for (const auto& id : m_order) { @@ -165,11 +176,15 @@ QVariantList DownloadManager::jobs() const { QStringLiteral("total"), job->total }, { QStringLiteral("quality"), job->option.value(QStringLiteral("label")) } }); } + m_jobsSnapshot = rows; + m_jobsSnapshotValid = true; return rows; } QVariantList DownloadManager::libraryFiles() const { + if (m_libraryFilesSnapshotValid) + return m_libraryFilesSnapshot; QVariantList files; for (const auto& id : m_order) { const auto job = m_jobs.value(id); @@ -177,6 +192,8 @@ QVariantList DownloadManager::libraryFiles() const files.push_back(QVariantMap { { QStringLiteral("path"), job->path }, { QStringLiteral("metadata"), job->metadata }, { QStringLiteral("size"), job->received } }); } + m_libraryFilesSnapshot = files; + m_libraryFilesSnapshotValid = true; return files; } void DownloadManager::setEnabled(bool enabled) @@ -288,10 +305,13 @@ QCoro::Task DownloadManager::prepare(std::shared_ptr job) if (item.mediaSources.size() == 1) request.variantId = item.mediaSources.front().id; DownloadPlan plan = co_await m_sources->negotiateDownload(request, job->id); - if (!guard) + if (!guard) { + // The manager died while negotiating; the plan is still acquired. + release(m_sources, std::move(plan)).then([] { }); co_return; + } if (job->stopped) { - co_await release(std::move(plan)); + co_await release(m_sources, std::move(plan)); co_return; } // Only known local-playable file containers can be committed to the library. @@ -330,11 +350,11 @@ QCoro::Task DownloadManager::prepare(std::shared_ptr job) } transfer(job, std::move(plan)); } -QCoro::Task DownloadManager::release(DownloadPlan plan) +QCoro::Task DownloadManager::release(SourceHub *sources, DownloadPlan plan) { try { - if (!plan.cleanup.isEmpty()) - co_await m_sources->releaseDownload(std::move(plan.cleanup)); + if (sources && !plan.cleanup.isEmpty()) + co_await sources->releaseDownload(std::move(plan.cleanup)); } catch (const std::exception&) { // Cleanup cannot change an already committed file or expose provider errors. } @@ -483,7 +503,7 @@ void DownloadManager::finish(const std::shared_ptr& job, const QString& err job->temporary.clear(); } job->state = job->error.isEmpty() ? QStringLiteral("complete") : QStringLiteral("failed"); - release(std::move(job->plan)).then([] { }); + release(m_sources, std::move(job->plan)).then([] { }); if (!persist() && job->state == QStringLiteral("complete")) { if (job->path.startsWith(QStringLiteral("content://"))) AndroidDownloadStorage::removeFile(QUrl(job->path)); diff --git a/src/app/DownloadManager.h b/src/app/DownloadManager.h index a2df1c42..b8943df1 100644 --- a/src/app/DownloadManager.h +++ b/src/app/DownloadManager.h @@ -29,6 +29,8 @@ class DownloadManager final : public QObject { DownloadManager( SourceHub *sources, QString dataRoot, TlsTrustController *trust = nullptr, QObject *parent = nullptr); ~DownloadManager() override; + // Inventory rows are a snapshot of the last change: constructed once per + // change (not per progress-driven status read) and shared implicitly. QVariantList jobs() const; QString destination() const { @@ -80,7 +82,9 @@ class DownloadManager final : public QObject { private: struct Job; QCoro::Task prepare(std::shared_ptr job); - QCoro::Task release(DownloadPlan plan); + // Releasing an acquired plan must not depend on the manager's lifetime: + // the provider hub owns the acquisition and its normal release path. + static QCoro::Task release(SourceHub *sources, 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 = {}); @@ -95,6 +99,10 @@ class DownloadManager final : public QObject { AndroidDownloadStorage *m_androidStorage; QHash> m_jobs; QStringList m_order; + mutable QVariantList m_jobsSnapshot; + mutable QVariantList m_libraryFilesSnapshot; + mutable bool m_jobsSnapshotValid = false; + mutable bool m_libraryFilesSnapshotValid = false; #if defined(SPOOL_WEBOS) bool m_enabled = false; #else diff --git a/src/app/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp index e2014980..9866d620 100644 --- a/src/app/GroupPlaybackController.cpp +++ b/src/app/GroupPlaybackController.cpp @@ -7,6 +7,8 @@ #include #include +#include +#include #include #include @@ -241,6 +243,46 @@ void GroupPlaybackController::cancelPendingUnpause() setWaitingForGroupPlayback(false); } +void GroupPlaybackController::cancelPendingPlayback() +{ + const QPointer self(this); + const quint64 generation = ++m_queueGeneration; + ++m_playbackCancellationGeneration; + m_queueLoading = false; + m_waitingForPlaybackStart = false; + m_queueHandoff.cancel(); + m_seekResume.cancel(); + m_unpauseRequestPending = false; + m_commandTimer.stop(); + m_commandDue = false; + m_lastCommandKey.clear(); + m_scheduledCommand.clear(); + m_scheduledEntryId.clear(); + m_scheduledPositionTicks = 0; + m_scheduledServerTimeMs = 0; + m_bufferingDebounceTimer.stop(); + m_playerStateKnown = false; + m_suppressSeekBufferingUntilMs = 0; + m_speedCorrectionTimer.stop(); + const bool wasCorrectingSpeed = m_speedCorrectionActive; + m_speedCorrectionActive = false; + m_syncCorrectionAttempts = 0; + m_lastCorrectionAtMs = 0; + + // Clear the complete stopped state before notifying observers: a status + // listener may immediately request another group item. + const bool statusChanged + = m_waitingForGroupPlayback || m_playbackDiffValid || m_syncMethod != QStringLiteral("None"); + m_waitingForGroupPlayback = false; + m_playbackDiffValid = false; + m_playbackDiffMs = 0.0; + m_syncMethod = QStringLiteral("None"); + if (wasCorrectingSpeed && m_player) + m_player->clearSyncPlaybackSpeed(); + if (self && generation == m_queueGeneration && statusChanged) + emit syncStatusChanged(); +} + void GroupPlaybackController::requestTogglePause() { if (!enabled() || !m_player->sessionActive()) @@ -258,11 +300,22 @@ void GroupPlaybackController::requestSeek(double positionSeconds) { if (!enabled() || !std::isfinite(positionSeconds)) return; + if (m_account.isEmpty()) + return; m_seekResume.arm(!m_player->paused()); - send(QStringLiteral("seek"), - { { QStringLiteral("positionTicks"), - QString::number(qint64(std::max(0.0, positionSeconds) * kTicksPerSecond)) } }, - "Seek"); + const quint64 generation = m_playbackCancellationGeneration; + QVariantMap arguments { { QStringLiteral("action"), QStringLiteral("seek") }, + { QStringLiteral("positionTicks"), + QString::number(qint64(std::max(0.0, positionSeconds) * kTicksPerSecond)) } }; + Async::runScoped( + this, m_hub->call(m_account, QStringLiteral("groupSend"), arguments), [](QVariantMap) {}, + [this, generation](const std::exception_ptr& error) { + if (generation != m_playbackCancellationGeneration) + return; + m_seekResume.cancel(); + emit errorText(QStringLiteral("Seek: %1").arg(exceptionMessage(error))); + }, + "group seek"); } void GroupPlaybackController::requestRelativeSeek(double deltaSeconds) @@ -438,35 +491,43 @@ void GroupPlaybackController::applyQueue(const QVariantMap& event) // 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"); - }; + const auto hydrate + = [this, self = QPointer(this), generation, itemIds, entryIds, playingIndex] { + Async::runScoped( + this, m_hub->fetchItemsByIds(itemIds), + [this, self, generation, itemIds, entryIds, playingIndex](const std::vector& fetched) { + if (generation != m_queueGeneration) + return; + std::vector ordered; + int resolvedIndex = -1; + QHash itemsById; + itemsById.reserve(static_cast(fetched.size())); + for (const MovieItem& item : fetched) { + if (!itemsById.contains(item.id)) + itemsById.insert(item.id, &item); + } + for (int i = 0; i < itemIds.size(); ++i) { + const auto found = itemsById.constFind(itemIds.at(i)); + if (found == itemsById.cend()) + 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 (!self || generation != m_queueGeneration) + return; + 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; @@ -479,8 +540,8 @@ void GroupPlaybackController::applyQueue(const QVariantMap& event) setWaitingForGroupPlayback(true); Async::runScoped( this, m_hub->fetchItemsByIds({ selectedItemId }), - [this, generation, selectedItemId, entry = m_entryId, requestedTicks, hydrate]( - const std::vector& fetched) { + [this, self = QPointer(this), generation, selectedItemId, entry = m_entryId, + requestedTicks, hydrate](const std::vector& fetched) { if (generation != m_queueGeneration) return; m_queueLoading = false; @@ -489,7 +550,12 @@ void GroupPlaybackController::applyQueue(const QVariantMap& event) MovieItem item = selected == fetched.cend() ? MovieItem {} : *selected; item.playlistItemId = entry; m_playQueue->setShuffled(false); - if (item.id.isEmpty() || !m_playQueue->playNow(item)) { + if (!self || generation != m_queueGeneration) + return; + const bool queued = !item.id.isEmpty() && m_playQueue->playNow(item); + if (!self || generation != m_queueGeneration) + return; + if (!queued) { setWaitingForGroupPlayback(false); emit errorText(QStringLiteral("The group's current item isn't available here.")); return; @@ -501,6 +567,8 @@ void GroupPlaybackController::applyQueue(const QVariantMap& event) { QStringLiteral("positionTicks"), QString::number(std::max(0, requestedTicks)) }, { QStringLiteral("entryId"), m_entryId }, { QStringLiteral("at"), serverNowMs() } }); emit queuePlaybackRequested(requestedTicks); + if (!self || generation != m_queueGeneration) + return; hydrate(); }, [this, generation](const std::exception_ptr& error) { @@ -537,6 +605,7 @@ void GroupPlaybackController::clearGroup() m_queueHandoff.cancel(); m_seekResume.cancel(); ++m_queueGeneration; + ++m_playbackCancellationGeneration; m_queueLoading = false; m_waitingForPlaybackStart = false; m_commandDue = false; @@ -600,7 +669,11 @@ void GroupPlaybackController::executeScheduledCommand() finishSpeedCorrection(); m_player->setPaused(true); m_player->seek(targetSeconds); - QTimer::singleShot(250, this, [this] { sendPlayerBufferingState(true); }); + const quint64 generation = m_playbackCancellationGeneration; + QTimer::singleShot(250, this, [this, generation] { + if (generation == m_playbackCancellationGeneration) + sendPlayerBufferingState(true); + }); } else if (command == QStringLiteral("stop")) { m_seekResume.cancel(); finishSpeedCorrection(); @@ -723,14 +796,21 @@ void GroupPlaybackController::requestGroupUnpause() { if (!enabled() || m_unpauseRequestPending) return; + + const QPointer self(this); + const quint64 generation = m_playbackCancellationGeneration; m_unpauseRequestPending = true; setWaitingForGroupPlayback(true); + if (!self || generation != m_playbackCancellationGeneration || !m_unpauseRequestPending) + return; Async::runScoped( this, m_hub->call( m_account, QStringLiteral("groupSend"), { { QStringLiteral("action"), QStringLiteral("unpause") } }), [](QVariantMap) {}, - [this](const std::exception_ptr& error) { + [this, generation](const std::exception_ptr& error) { + if (generation != m_playbackCancellationGeneration) + return; m_unpauseRequestPending = false; setWaitingForGroupPlayback(false); emit errorText(exceptionMessage(error)); diff --git a/src/app/GroupPlaybackController.h b/src/app/GroupPlaybackController.h index f7c1c516..55ffaa70 100644 --- a/src/app/GroupPlaybackController.h +++ b/src/app/GroupPlaybackController.h @@ -195,6 +195,7 @@ class GroupPlaybackController final : public GroupPlayback { void requestPlayItem(const QString& entryId) override; void requestUnpauseWhenReady() override; void cancelPendingUnpause() override; + void cancelPendingPlayback() override; QCoro::Task publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) override; // A `group` event from the account hosting the group. @@ -257,6 +258,9 @@ class GroupPlaybackController final : public GroupPlayback { qint64 m_lastQueueUpdateMs = 0; double m_playbackDiffMs = 0.0; quint64 m_queueGeneration = 0; + // Queue hydration can refresh without canceling an in-flight seek or + // unpause. Only Stop or leaving the group retires those callbacks. + quint64 m_playbackCancellationGeneration = 0; int m_greedyTimeSyncRemaining = 0; GroupQueueHandoff m_queueHandoff; GroupSeekResume m_seekResume; diff --git a/src/app/HomeModelController.cpp b/src/app/HomeModelController.cpp index 857168ea..b34ddccc 100644 --- a/src/app/HomeModelController.cpp +++ b/src/app/HomeModelController.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -160,6 +161,15 @@ namespace { return items; } + constexpr auto kHiddenProvidersKey = "home/hiddenProviderIds"; + + QStringList parseProviderIds(const QString& value) + { + QStringList ids = value.split(QLatin1Char(','), Qt::SkipEmptyParts); + ids.sort(); + ids.removeDuplicates(); + return ids; + } } // namespace HomeModelController::HomeModelController( @@ -178,32 +188,78 @@ HomeModelController::HomeModelController( QVariantList HomeModelController::providerChoices() const { - return m_sources ? m_sources->homeProviderChoices() : QVariantList {}; + if (!m_sources) + return {}; + QVariantList choices = m_sources->homeProviderChoices(); + int shownWithProfiles = 0; + for (QVariant& value : choices) { + QVariantMap choice = value.toMap(); + const bool shown = !m_hiddenProviderIds.contains(choice.value(QStringLiteral("id")).toString()); + choice.insert(QStringLiteral("shown"), shown); + if (shown && choice.value(QStringLiteral("profiles")).toInt() > 0) + ++shownWithProfiles; + value = choice; + } + for (QVariant& value : choices) { + QVariantMap choice = value.toMap(); + choice.insert(QStringLiteral("required"), + shownWithProfiles == 1 && choice.value(QStringLiteral("shown")).toBool() + && choice.value(QStringLiteral("profiles")).toInt() > 0); + value = choice; + } + return choices; } void HomeModelController::attachSettings(SettingsController *settings) { m_settings = settings; connect(settings, &SettingsController::settingChanged, this, [this](const QString& key) { - if (key == QStringLiteral("home/providerId")) - setPreferredProviderId(m_settings->value(key).toString()); + if (key == QLatin1String(kHiddenProvidersKey)) + setHiddenProviderIds(parseProviderIds(m_settings->value(key).toString())); }); - setPreferredProviderId(settings->value(QStringLiteral("home/providerId")).toString()); + setHiddenProviderIds(parseProviderIds(settings->value(QLatin1String(kHiddenProvidersKey)).toString())); +} + +bool HomeModelController::setProviderShown(const QString& moduleId, bool shown) +{ + if (moduleId.isEmpty() || shown != m_hiddenProviderIds.contains(moduleId)) + return true; + if (!shown) { + for (const QVariant& value : providerChoices()) { + const QVariantMap choice = value.toMap(); + if (choice.value(QStringLiteral("id")).toString() == moduleId + && choice.value(QStringLiteral("required")).toBool()) + return false; + } + } + QStringList next = m_hiddenProviderIds; + if (shown) + next.removeAll(moduleId); + else + next.push_back(moduleId); + storeHiddenProviderIds(next); + return true; } -void HomeModelController::selectProvider(const QString& moduleId) +void HomeModelController::showAllProviders() { + storeHiddenProviderIds({}); +} + +void HomeModelController::storeHiddenProviderIds(const QStringList& moduleIds) +{ + const QString value = parseProviderIds(moduleIds.join(QLatin1Char(','))).join(QLatin1Char(',')); if (m_settings) - m_settings->setValue(QStringLiteral("home/providerId"), moduleId); + m_settings->setValue(QLatin1String(kHiddenProvidersKey), value); else - setPreferredProviderId(moduleId); + setHiddenProviderIds(parseProviderIds(value)); } -void HomeModelController::setPreferredProviderId(const QString& moduleId) +void HomeModelController::setHiddenProviderIds(QStringList moduleIds) { - if (m_preferredProviderId == moduleId) + if (m_hiddenProviderIds == moduleIds) return; - m_preferredProviderId = moduleId; + m_hiddenProviderIds = std::move(moduleIds); updateProviderScope(); } @@ -214,14 +270,14 @@ bool HomeModelController::includesItem(const QString& scopedId) const void HomeModelController::updateProviderScope() { - const QString previousModuleId = m_homeQuery.moduleId; + const QStringList previousHidden = m_homeQuery.hiddenModuleIds; if (m_sources) { - const QVariantMap status = m_sources->homeProviderStatus(m_preferredProviderId); - m_homeQuery.moduleId = status.value(QStringLiteral("moduleId")).toString(); + const QVariantMap status = m_sources->homeProviderStatus(m_hiddenProviderIds); + m_homeQuery.hiddenModuleIds = status.value(QStringLiteral("hiddenModuleIds")).toStringList(); m_providerScopeMessage = status.value(QStringLiteral("message")).toString(); } const QString accountScopeKey = m_sources ? m_sources->homeScopeKey(m_homeQuery) : QString(); - if (previousModuleId == m_homeQuery.moduleId && accountScopeKey == m_providerAccountScopeKey) { + if (previousHidden == m_homeQuery.hiddenModuleIds && accountScopeKey == m_providerAccountScopeKey) { // Removal/start/stop and metadata updates can describe the same // authorized scope repeatedly. Publish chooser/status changes without // restarting its in-flight feeds or exhausting provider admission. @@ -321,9 +377,13 @@ QCoro::Task HomeModelController::loadCachedPayloadAsync() co_return; const RequestGeneration::Token generation = m_generation.current(); const auto playbackGeneration = m_playbackRowsGeneration.current(); + const auto cacheGeneration = m_cacheGeneration.current(); const QJsonObject payload = co_await m_database->loadHomePayloadAsync(key, kHomePayloadSchemaVersion); + // A warm payload must never replace published network results, and a + // scope change retires reads started under the old account. if (!m_generation.isCurrent(generation) || !m_playbackRowsGeneration.isCurrent(playbackGeneration) - || key != payloadCacheKey() || m_loaded) + || !m_cacheGeneration.isCurrent(cacheGeneration) || m_networkResultsPublished || key != payloadCacheKey() + || m_loaded) co_return; if (applyCachedPayload(payload)) qInfo() << "home: warm payload cache applied" << key; @@ -383,6 +443,7 @@ void HomeModelController::refresh(const std::vector& libraries) QCoro::Task HomeModelController::refreshAsync( std::vector libraries, RequestGeneration::Token generation) { + const QPointer guard(this); const auto playbackGeneration = m_playbackRowsGeneration.current(); std::vector latestLibraries; latestLibraries.reserve(libraries.size()); @@ -398,51 +459,89 @@ QCoro::Task HomeModelController::refreshAsync( for (const LibraryItem& library : latestLibraries) latestTasks.push_back(fetchLatestLibraryItems(library)); + bool complete = true; + bool resumeLoaded = false; std::vector resumeItems; try { resumeItems = co_await resumeTask; + resumeLoaded = true; qInfo() << "home: resume items" << resumeItems.size(); } catch (const std::exception&) { + complete = false; qWarning() << "home: resume fetch failed"; } - if (!m_generation.isCurrent(generation)) + if (!guard || !m_generation.isCurrent(generation)) co_return; + bool nextUpLoaded = false; std::vector nextUpItems; try { nextUpItems = co_await nextUpTask; + nextUpLoaded = true; qInfo() << "home: next-up items" << nextUpItems.size(); } catch (const std::exception&) { + complete = false; qWarning() << "home: next-up fetch failed"; } - if (!m_generation.isCurrent(generation)) + if (!guard || !m_generation.isCurrent(generation)) co_return; + if (resumeLoaded || nextUpLoaded) { + m_networkResultsPublished = true; + m_cacheGeneration.invalidate(); + } + // A failed shelf is not an empty shelf: retain its warm rows. + if (!resumeLoaded) + resumeItems = m_resumeItems.movies(); + if (!nextUpLoaded) + nextUpItems = m_nextUpItems.movies(); if (m_playbackRowsGeneration.isCurrent(playbackGeneration)) { reconcilePlaybackRows(resumeItems, nextUpItems); - m_resumeItems.setMovies(resumeItems); - m_nextUpItems.setMovies(nextUpItems); + if (resumeLoaded) + m_resumeItems.setMovies(resumeItems); + if (nextUpLoaded) + m_nextUpItems.setMovies(nextUpItems); } std::vector latestSections; latestSections.reserve(latestLibraries.size()); + std::vector failedLatestOrders; for (int order = 0; order < static_cast(latestLibraries.size()); ++order) { const LibraryItem& library = latestLibraries[static_cast(order)]; try { std::vector items = co_await latestTasks[static_cast(order)]; + if (!guard || !m_generation.isCurrent(generation)) + co_return; + m_networkResultsPublished = true; + m_cacheGeneration.invalidate(); qInfo() << "home: latest items" << items.size(); if (!items.empty()) latestSections.push_back({ order, library, std::move(items) }); } catch (const std::exception&) { + complete = false; qWarning() << "home: latest fetch failed"; + failedLatestOrders.push_back(order); } - if (!m_generation.isCurrent(generation)) + if (!guard || !m_generation.isCurrent(generation)) co_return; } + // A pending disk read can finish between failed shelf requests. Resolve + // fallbacks only after all awaits so those newly available rows survive. + for (int order : failedLatestOrders) { + const LibraryItem& library = latestLibraries[static_cast(order)]; + const auto cached = std::find_if(m_latestLibrarySections.cbegin(), m_latestLibrarySections.cend(), + [&library](const LatestLibrarySection& section) { return section.library.id == library.id; }); + if (cached != m_latestLibrarySections.cend() && cached->model && !cached->model->movies().empty()) + latestSections.push_back({ order, library, cached->model->movies() }); + } + m_refreshInFlight = false; - m_loaded = true; - saveCachedPayload(payloadFromSections(m_resumeItems.movies(), m_nextUpItems.movies(), latestSections)); + // A failed shelf must remain retryable and must not persist a partial + // result as durable truth. + m_loaded = complete; + if (complete) + saveCachedPayload(payloadFromSections(m_resumeItems.movies(), m_nextUpItems.movies(), latestSections)); const bool latestRowsChanged = updateLatestLibraryRows(std::move(latestSections)); emit loadingChanged(); @@ -457,12 +556,13 @@ QCoro::Task HomeModelController::refreshAsync( : LibraryPrefetchController::ImageKind::Poster); } m_prefetch->schedule(libraries, m_recentLibraryIds); - if (latestRowsChanged || !m_latestLibrarySections.empty()) + if (latestRowsChanged) emit latestLibraryRowsChanged(); } QCoro::Task> HomeModelController::fetchLatestLibraryItems(LibraryItem library) { + const QPointer guard(this); constexpr int kTargetItems = 20; constexpr int kMaximumRawItems = 200; constexpr int kInitialTvRawItems = 120; @@ -470,7 +570,7 @@ QCoro::Task> HomeModelController::fetchLatestLibraryItems int limit = library.collectionType == QStringLiteral("tvshows") ? kInitialTvRawItems : kTargetItems; while (limit <= kMaximumRawItems) { std::vector rawItems = co_await m_api->fetchLatestItems(library.id, limit); - if (!includesItem(library.id)) + if (!guard || !includesItem(library.id)) co_return std::vector {}; std::erase_if(rawItems, [this](const MovieItem& item) { return !includesItem(item.id); }); const int rawCount = static_cast(rawItems.size()); @@ -677,6 +777,8 @@ QCoro::Task HomeModelController::refreshPlaybackRowsAsync(RequestGeneratio auto nextUp = co_await nextUpTask; if (!m_playbackRowsGeneration.isCurrent(generation)) co_return; + m_networkResultsPublished = true; + m_cacheGeneration.invalidate(); reconcilePlaybackRows(resume, nextUp); m_resumeItems.setMovies(std::move(resume)); m_nextUpItems.setMovies(std::move(nextUp)); @@ -694,7 +796,9 @@ void HomeModelController::invalidate(const std::function& m_playbackRowsGeneration.invalidate(); m_refreshInFlight = false; m_loaded = false; - m_prefetch->stop(); + m_networkResultsPublished = false; + m_cacheGeneration.invalidate(); + m_prefetch->reset(); if (isAvailable) { m_locallyPlayed.removeIf([&isAvailable](const QString& id) { return !isAvailable(id); }); for (auto it = m_optimisticNextUp.begin(); it != m_optimisticNextUp.end();) { @@ -738,7 +842,9 @@ void HomeModelController::reset() m_optimisticNextUp.clear(); m_refreshInFlight = false; m_loaded = false; - m_prefetch->stop(); + m_networkResultsPublished = false; + m_cacheGeneration.invalidate(); + m_prefetch->reset(); m_resumeItems.clear(); m_nextUpItems.clear(); m_recentLibraryIds.clear(); diff --git a/src/app/HomeModelController.h b/src/app/HomeModelController.h index f986fab2..dd031f2a 100644 --- a/src/app/HomeModelController.h +++ b/src/app/HomeModelController.h @@ -30,8 +30,10 @@ class HomeModelController final : public QObject { Q_PROPERTY(Spool::MovieGridModel *nextUpItems READ nextUpItems CONSTANT) Q_PROPERTY(QVariantList latestLibraryRows READ latestLibraryRows NOTIFY latestLibraryRowsChanged) Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) - Q_PROPERTY(QString preferredProviderId READ preferredProviderId NOTIFY providerScopeChanged) - Q_PROPERTY(QString providerId READ providerId NOTIFY providerScopeChanged) + // The providers someone turned off for Home, and whether Home is honouring + // that right now: it shows everything when nothing that is left on can. + Q_PROPERTY(QStringList hiddenProviderIds READ hiddenProviderIds NOTIFY providerScopeChanged) + Q_PROPERTY(bool providerFilterActive READ providerFilterActive NOTIFY providerScopeChanged) Q_PROPERTY(QString providerScopeMessage READ providerScopeMessage NOTIFY providerScopeChanged) Q_PROPERTY(QVariantList providerChoices READ providerChoices NOTIFY providerScopeChanged) @@ -53,13 +55,13 @@ class HomeModelController final : public QObject { return m_refreshInFlight; } - QString preferredProviderId() const + QStringList hiddenProviderIds() const { - return m_preferredProviderId; + return m_hiddenProviderIds; } - QString providerId() const + bool providerFilterActive() const { - return m_homeQuery.moduleId; + return !m_homeQuery.hiddenModuleIds.isEmpty(); } QString providerScopeMessage() const { @@ -68,7 +70,9 @@ class HomeModelController final : public QObject { QVariantList providerChoices() const; void attachSettings(SettingsController *settings); Q_INVOKABLE bool includesItem(const QString& scopedId) const; - Q_INVOKABLE void selectProvider(const QString& moduleId); + // Refuses to turn off the last provider that can fill Home. + Q_INVOKABLE bool setProviderShown(const QString& moduleId, bool shown); + Q_INVOKABLE void showAllProviders(); bool applyCachedPayload(const QJsonObject& payload); void loadCachedPayload(); void refresh(const std::vector& libraries); @@ -110,7 +114,8 @@ class HomeModelController final : public QObject { void reconcilePlaybackRows(std::vector& resume, std::vector& nextUp); void updateProviderScope(); - void setPreferredProviderId(const QString& moduleId); + void setHiddenProviderIds(QStringList moduleIds); + void storeHiddenProviderIds(const QStringList& moduleIds); QCoro::Task> fetchResumeItems(); QCoro::Task> fetchNextUpEpisodes(); DatabaseManager *m_database = nullptr; @@ -119,7 +124,7 @@ class HomeModelController final : public QObject { SourceHub *m_sources = nullptr; SettingsController *m_settings = nullptr; SourceHub::HomeQuery m_homeQuery; - QString m_preferredProviderId; + QStringList m_hiddenProviderIds; QString m_providerScopeMessage; QString m_providerAccountScopeKey; std::vector m_allLibraries; @@ -128,10 +133,12 @@ class HomeModelController final : public QObject { std::vector m_latestLibrarySections; RequestGeneration m_generation; RequestGeneration m_playbackRowsGeneration; + RequestGeneration m_cacheGeneration; QSet m_locallyPlayed; QHash m_optimisticNextUp; bool m_refreshInFlight = false; bool m_loaded = false; + bool m_networkResultsPublished = false; QStringList m_recentLibraryIds; }; diff --git a/src/app/LibraryPrefetchController.cpp b/src/app/LibraryPrefetchController.cpp index 6da22d10..4f00c2e3 100644 --- a/src/app/LibraryPrefetchController.cpp +++ b/src/app/LibraryPrefetchController.cpp @@ -37,6 +37,16 @@ void LibraryPrefetchController::stop() m_active = false; } +// A scope change (account or library availability) drops the stored pages; +// merely navigating away only stops the queue via stop(). +void LibraryPrefetchController::reset() +{ + stop(); + m_pages.clear(); + m_pageStoredAtMs.clear(); + m_cachedKeys.clear(); +} + void LibraryPrefetchController::schedule(const std::vector& libraries, const QStringList& recentLibraryIds) { stop(); diff --git a/src/app/LibraryPrefetchController.h b/src/app/LibraryPrefetchController.h index 4968cbd6..e8648d54 100644 --- a/src/app/LibraryPrefetchController.h +++ b/src/app/LibraryPrefetchController.h @@ -26,6 +26,7 @@ class LibraryPrefetchController final : public QObject { LibraryPrefetchController(Catalog *catalog, ArtworkPrefetcher *artwork = nullptr, QObject *parent = nullptr); void stop(); + void reset(); void schedule(const std::vector& libraries, const QStringList& recentLibraryIds); std::optional cachedPage(const QString& cacheKey) const; void storePage(const QString& cacheKey, const PagedMovieItems& page); diff --git a/src/app/PlaybackStopPolicy.h b/src/app/PlaybackStopPolicy.h new file mode 100644 index 00000000..b502b9e3 --- /dev/null +++ b/src/app/PlaybackStopPolicy.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +namespace Spool { + +// One local, process-lifetime stop request. The host supplies monotonic time so +// pauses, item transitions, and a delayed event loop never restart a duration. +class PlaybackStopPolicy final { +public: + enum class Mode { Off, AfterCurrent, Timed }; + + Mode mode() const + { + return m_mode; + } + int durationMinutes() const + { + return m_durationMinutes; + } + + int remainingSeconds(std::int64_t nowMs) const + { + if (m_mode != Mode::Timed) + return 0; + return static_cast((std::max(0, m_deadlineMs - nowMs) + 999) / 1000); + } + + bool setDuration(int minutes, std::int64_t nowMs) + { + if (minutes < 1 || minutes > 120) + return false; + cancel(); + m_mode = Mode::Timed; + m_durationMinutes = minutes; + m_deadlineMs = nowMs + static_cast(minutes) * 60'000; + return true; + } + + bool setAfterCurrent(const std::string& itemId) + { + if (itemId.empty()) + return false; + cancel(); + m_mode = Mode::AfterCurrent; + m_itemId = itemId; + return true; + } + + // Restarting the same item for quality/track changes keeps the request. + // Deliberately starting another item cancels only after-current mode. + bool playbackStarting(const std::string& itemId) + { + return m_mode == Mode::AfterCurrent && itemId != m_itemId && cancel(); + } + + bool takeCompleted(const std::string& itemId) + { + return m_mode == Mode::AfterCurrent && itemId == m_itemId && cancel(); + } + + bool takeExpired(std::int64_t nowMs) + { + return m_mode == Mode::Timed && nowMs >= m_deadlineMs && cancel(); + } + + // Used for explicit stops, group joins, logout/profile changes, and exit. + bool cancel() + { + const bool wasActive = m_mode != Mode::Off; + m_mode = Mode::Off; + m_durationMinutes = 0; + m_deadlineMs = 0; + m_itemId.clear(); + return wasActive; + } + +private: + Mode m_mode = Mode::Off; + int m_durationMinutes = 0; + std::int64_t m_deadlineMs = 0; + std::string m_itemId; +}; + +} // namespace Spool diff --git a/src/app/RemoteTargetsController.cpp b/src/app/RemoteTargetsController.cpp index dfb29840..163d8945 100644 --- a/src/app/RemoteTargetsController.cpp +++ b/src/app/RemoteTargetsController.cpp @@ -131,7 +131,7 @@ RemoteTargetsController::RemoteTargetsController( 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(); + const qint64 maximum = m_runtime.toLongLong() > 0 ? m_runtime.toLongLong() : std::numeric_limits::max(); qint64 ticks = std::clamp(m_position.toLongLong(), qint64(0), maximum); if (delta > 0) ticks = delta > (maximum - ticks) / 10000 ? maximum : ticks + delta * 10000; @@ -161,6 +161,15 @@ QVariantMap RemoteTargetsController::state() const return result; } +qint64 RemoteTargetsController::predictedPositionTicks() const +{ + const qint64 base = m_position.toLongLong(); + if (m_state.value("state").toString() != QStringLiteral("playing")) + return base; + const qint64 elapsedMs = std::max(0, m_clock.elapsed() - m_positionAt); + return base + static_cast(elapsedMs * 10000.0 * m_state.value("rate", 1.0).toDouble()); +} + bool RemoteTargetsController::isCurrent(const Selection& selection) const { return selection.generation == m_selection.generation && selection.targetId == m_selection.targetId @@ -560,6 +569,11 @@ 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; + // Until the target acknowledges our pause/unpause/stop, its polls describe + // the old running clock; the captured position must survive them. + const bool transportPending + = m_optimistic.contains("state") && response.value("state") != m_optimistic.value("state"); + bool positionOwned = false; 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(); @@ -572,9 +586,13 @@ void RemoteTargetsController::applyState(QVariantMap response) = std::abs(static_cast(response.value(it.key()).toString().toLongLong()) - expected) <= 25000000; } + if (it.key() == "positionTicks" && transportPending) + confirmed = false; if (confirmed && !m_ackSequence) it = m_optimistic.erase(it); else { + if (it.key() == "positionTicks") + positionOwned = true; response.insert(it.key(), it.value()); ++it; } @@ -588,6 +606,8 @@ void RemoteTargetsController::applyState(QVariantMap response) const bool positionChangedValue = position != m_position || runtime != m_runtime; m_position = position; m_runtime = runtime; + if (!positionOwned && !position.isEmpty()) + m_positionAt = now; // A genuine acknowledgement is transport state, not a media model change. response.remove("commandSequence"); const QString revision = response.value("queueRevision").toString(); @@ -622,9 +642,14 @@ void RemoteTargetsController::beginOptimistic(const QVariantMap& command) { const auto name = command.value("action").toString(); m_ackSequence.reset(); - if (name == "pause" || name == "unpause" || name == "stop") + if (name == "pause" || name == "unpause" || name == "stop") { + // Capture the current position before the transport changes state: a + // stale poll must neither rewind it nor restart an optimistic + // unpause's clock. An optimistic seek target is already exact. + if (!m_position.isEmpty()) + m_optimistic.insert("positionTicks", m_position); m_optimistic.insert("state", name == "pause" ? "paused" : name == "unpause" ? "playing" : "stopped"); - else if (name == "seek") + } else if (name == "seek") m_optimistic.insert("positionTicks", command.value("positionTicks")); else if (name == "volume") m_optimistic.insert("volume", command.value("value")); @@ -634,8 +659,10 @@ void RemoteTargetsController::beginOptimistic(const QVariantMap& command) m_optimistic.insert("shuffled", command.value("value")); else if (name == "repeat") m_optimistic.insert("repeatMode", command.value("mode")); - if (name == "seek") + if (name == "seek" || name == "pause" || name == "unpause" || name == "stop") { m_optimisticAt = m_clock.elapsed(); + m_positionAt = m_optimisticAt; + } m_optimisticUntil = m_clock.elapsed() + 8000; auto snapshot = state(); for (auto it = m_optimistic.cbegin(); it != m_optimistic.cend(); ++it) @@ -733,6 +760,18 @@ QCoro::Task RemoteTargetsController::command(Selection selected, QVariantM } void RemoteTargetsController::send(QVariantMap value) { + if (value.value("action").toString() == QStringLiteral("seek")) { + bool ok = false; + qint64 ticks = value.value("positionTicks").toString().toLongLong(&ok); + if (!ok) + return; + ticks = std::max(0, ticks); + // An unknown runtime must not clamp a positive seek to zero. + const qint64 runtime = m_runtime.toLongLong(); + if (runtime > 0) + ticks = std::min(ticks, runtime); + value.insert("positionTicks", QString::number(ticks)); + } Async::runScoped( this, command(m_selection, std::move(value)), [](bool) {}, [this](const std::exception_ptr&) { setProblem(tr("The remote command could not be sent.")); }, @@ -883,7 +922,7 @@ void RemoteTargetsController::updateMediaSession() 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.positionMs = predictedPositionTicks() / 10000; session.durationMs = m_runtime.toLongLong() / 10000; session.playing = m_state.value("state") == "playing"; session.playbackRate = m_state.value("rate", 1.0).toDouble(); diff --git a/src/app/RemoteTargetsController.h b/src/app/RemoteTargetsController.h index e7845222..a8360b50 100644 --- a/src/app/RemoteTargetsController.h +++ b/src/app/RemoteTargetsController.h @@ -64,6 +64,10 @@ class RemoteTargetsController final : public QObject { return m_target; } QVariantMap state() const; + // The running clock for media-session consumers: the last accepted + // position advanced by elapsed time while the target plays. Zero is a + // real position, not a frozen clock. + qint64 predictedPositionTicks() const; QString positionTicks() const { return m_position; @@ -203,6 +207,7 @@ class RemoteTargetsController final : public QObject { QVariantMap m_optimistic; qint64 m_optimisticUntil = 0; qint64 m_optimisticAt = 0; + qint64 m_positionAt = 0; std::optional m_ackSequence; QTimer m_pollTimer; QTimer m_listTimer; diff --git a/src/app/SearchController.cpp b/src/app/SearchController.cpp index 4800a263..8fccef32 100644 --- a/src/app/SearchController.cpp +++ b/src/app/SearchController.cpp @@ -59,6 +59,10 @@ void SearchController::submit() const RequestGeneration::Token generation = m_searchGeneration.next(); setBusy(true); + if (m_failed) { + m_failed = false; + emit failedChanged(); + } // Results show as each source answers; busy lasts until the last one. auto answered = std::make_shared(false); @@ -84,6 +88,10 @@ void SearchController::submit() [this](const std::exception_ptr& error) { clearResults(); setBusy(false); + if (!m_failed) { + m_failed = true; + emit failedChanged(); + } emit resultsChanged(); emit errorOccurred(exceptionMessage(error)); }); @@ -109,6 +117,10 @@ void SearchController::clear() emit queryChanged(); } setBusy(false); + if (m_failed) { + m_failed = false; + emit failedChanged(); + } clearResults(); emit resultsChanged(); } diff --git a/src/app/SearchController.h b/src/app/SearchController.h index 97f980ff..3db90d54 100644 --- a/src/app/SearchController.h +++ b/src/app/SearchController.h @@ -17,6 +17,9 @@ class SearchController final : public QObject { Q_OBJECT Q_PROPERTY(QString query READ query NOTIFY queryChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + // Query-owned failure state: it survives page eviction and debounce, and + // clears when the next query starts. + Q_PROPERTY(bool failed READ failed NOTIFY failedChanged) Q_PROPERTY(Spool::MovieGridModel *movieResults READ movieResults CONSTANT) Q_PROPERTY(Spool::MovieGridModel *seriesResults READ seriesResults CONSTANT) Q_PROPERTY(Spool::MovieGridModel *episodeResults READ episodeResults CONSTANT) @@ -36,6 +39,10 @@ class SearchController final : public QObject { { return m_busy; } + bool failed() const + { + return m_failed; + } bool suggestionsBusy() const { return m_suggestionsBusy; @@ -79,6 +86,7 @@ class SearchController final : public QObject { signals: void queryChanged(); void busyChanged(); + void failedChanged(); void resultsChanged(); void suggestionsChanged(); void errorOccurred(const QString& message); @@ -99,6 +107,7 @@ class SearchController final : public QObject { MovieGridModel m_suggestions; QString m_query; bool m_busy = false; + bool m_failed = false; bool m_suggestionsBusy = false; bool m_suggestionsLoaded = false; RequestGeneration m_searchGeneration; diff --git a/src/app/SettingsController.cpp b/src/app/SettingsController.cpp index 0cbd0049..254ea8fa 100644 --- a/src/app/SettingsController.cpp +++ b/src/app/SettingsController.cpp @@ -910,6 +910,12 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian if (apply) applyArtworkEncoding(); break; + case SettingTarget::ArtworkDownloads: + // Applied on load too: the worker starts adaptive and must hear a + // fixed choice before its first request. + if (m_artwork) + m_artwork->setDownloadLimit(value.toString() == QLatin1String("auto") ? 0 : value.toInt()); + break; case SettingTarget::AudioTrackMode: m_subtitlePreferences.audioMode = value.toString(); if (apply) { diff --git a/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index 1abf0a87..deb0eb88 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -66,6 +66,9 @@ namespace { = { { "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 kArtworkDownloadChoices[] = { { "auto", "Automatic" }, { "2", "2 at a time" }, + { "4", "4 at a time" }, { "6", "6 at a time" }, { "8", "8 at a time" }, { "12", "12 at a time" }, + { "16", "16 at a time" }, { "24", "24 at a time" }, { "32", "32 at a time" } }; constexpr SettingChoice kTechnicalMetadataChoices[] = { { "Always", "Always" }, { "On details only", "Details page only" }, { "Hidden", "Never" } }; // "%1" is replaced with the user's preferred-language name when shown. @@ -320,6 +323,11 @@ const QVector& settingSpecs() "JPEG needs a higher number than WebP to look the same", "82", 40, 100, 1, "", SettingTarget::ArtworkJpegQuality) .advanced(), + selectSpec("artwork/downloads", "Appearance", "Artwork downloads", + "Automatic measures the server and opens more downloads at once while that keeps posters " + "arriving faster", + "auto", kArtworkDownloadChoices, SettingTarget::ArtworkDownloads) + .advanced(), toggleSpec("remote/acceptCommands", "Remote Control", "Allow remote control", "", true, SettingTarget::RemoteControlTargetEnabled), selectSpec("audio/language", "Playback", "Preferred audio language", "", "", nullptr, 0, @@ -568,13 +576,17 @@ const QVector& settingSpecs() .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/providers", "Accounts", "Add an account", "", SettingType::Action), + // Providers keep themselves current; asking first is for people + // who want to know. + selectSpec("providers/updates", "Accounts", "Provider updates", "", "auto", kProviderUpdateChoices, + SettingTarget::External) + .advanced(), pageSpec("action/manageCertificates", "Accounts", "Remembered certificates", "", SettingType::Action), - selectSpec("home/providerId", "Accounts", "Home provider", - "All providers, or one installed provider, on Home only", "", nullptr, 0, SettingTarget::External, - SettingNormalizer::String), + // Comma-separated provider ids turned off for Home. The settings + // page opens the Home provider picker rather than editing text. + SettingSpec { "home/hiddenProviderIds", "Accounts", "Providers on Home", "", SettingType::Action, "", + SettingTarget::External, SettingNormalizer::String }, pageSpec("action/exportDiagnostics", "Diagnostics", "Export diagnostics", "", SettingType::Action), pageSpec("action/clearLogs", "Diagnostics", "Clear logs", "", SettingType::Action), @@ -620,23 +632,24 @@ const QVector& settingSpecs() 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") }; + QStringLiteral("artwork/downloads"), 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); const QLatin1String group(spec.group); diff --git a/src/app/SettingsSchema.h b/src/app/SettingsSchema.h index 632a6a56..4714519c 100644 --- a/src/app/SettingsSchema.h +++ b/src/app/SettingsSchema.h @@ -52,6 +52,7 @@ enum class SettingTarget { ArtworkFormat, ArtworkWebpQuality, ArtworkJpegQuality, + ArtworkDownloads, AudioTrackMode, RememberSeriesAudioTrack, SubtitleLanguage, diff --git a/src/app/UserItemStateController.cpp b/src/app/UserItemStateController.cpp index 7935ce24..3778a909 100644 --- a/src/app/UserItemStateController.cpp +++ b/src/app/UserItemStateController.cpp @@ -70,6 +70,11 @@ void UserItemStateController::applyPlayed(const QString& itemId, bool played) void UserItemStateController::recordPlaybackStopped(const MovieItem& item, const QString& itemId, qint64 positionTicks, bool watched, const MovieItem& successor, quint64 reportId) { + // An older manual write may still finish, but its acknowledgement must + // not erase progress or completion observed after the user requested it. + const auto pending = m_pendingWrites.find(itemId); + if (pending != m_pendingWrites.end()) + pending->playbackRevision.invalidate(); if (!watched) { applyResumeTicks(itemId, positionTicks); if (m_home && item.id == itemId) @@ -91,58 +96,98 @@ void UserItemStateController::persistPlaybackWatched(const QString& itemId, quin if (pending == m_pendingPlaybackWatched.cend() || pending.value() != reportId) return; m_pendingPlaybackWatched.remove(itemId); - if (!m_api || !m_api->signedIn()) - return; - Async::runScoped( - this, m_api->setItemPlayed(itemId, true), - [this]() { - if (m_home) - m_home->refreshPlaybackRows(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); + // Models already reflect the observed completion; only the server write + // remains, serialized behind any earlier manual writes for this item. + enqueueWrite(itemId, WriteKind::Played, true, false); +} + +void UserItemStateController::reset() +{ + m_writeGeneration.invalidate(); + m_pendingWrites.clear(); + m_pendingPlaybackWatched.clear(); } void UserItemStateController::setFavorite(const QString& itemId, bool favorite) { - if (itemId.isEmpty() || !m_api || !m_api->signedIn()) - return; - applyFavorite(itemId, favorite); - Async::runScoped( - this, m_api->setItemFavorite(itemId, favorite), []() {}, - [this, itemId, favorite](const std::exception_ptr& error) { - applyFavorite(itemId, !favorite); - emit errorOccurred(exceptionMessage(error)); - }); + enqueueWrite(itemId, WriteKind::Favorite, favorite); } void UserItemStateController::setPlayed(const QString& itemId, bool played) { - if (itemId.isEmpty() || !m_api || !m_api->signedIn()) - return; m_pendingPlaybackWatched.remove(itemId); - applyPlayed(itemId, played); - Async::runScoped( - 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)); - }); + enqueueWrite(itemId, WriteKind::Played, played); } void UserItemStateController::clearProgress(const QString& itemId) +{ + m_pendingPlaybackWatched.remove(itemId); + enqueueWrite(itemId, WriteKind::ClearProgress, false); +} + +void UserItemStateController::enqueueWrite(const QString& itemId, WriteKind kind, bool value, bool publish) { if (itemId.isEmpty() || !m_api || !m_api->signedIn()) return; - m_pendingPlaybackWatched.remove(itemId); - applyResumeTicks(itemId, 0); - applyPlayed(itemId, false); - Async::runScoped( - this, m_api->setItemPlaybackPosition(itemId, 0), []() {}, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); + + // Serialize writes for an item so the server sees the same order as the + // client. Manual edits become visible only after the server accepts them; + // a failed edit therefore cannot lose resume rows or invent a prior value. + auto& pending = m_pendingWrites[itemId]; + pending.requests.enqueue({ kind, value, publish, pending.playbackRevision.current() }); + if (pending.requests.size() == 1) + startNextWrite(itemId); +} + +void UserItemStateController::startNextWrite(const QString& itemId) +{ + const auto pending = m_pendingWrites.constFind(itemId); + if (pending == m_pendingWrites.cend() || pending->requests.isEmpty()) + return; + const PendingWrite write = pending->requests.head(); + const auto generation = m_writeGeneration.current(); + QCoro::Task task = write.kind == WriteKind::Favorite ? m_api->setItemFavorite(itemId, write.value) + : write.kind == WriteKind::Played ? m_api->setItemPlayed(itemId, write.value) + : m_api->setItemPlaybackPosition(itemId, 0); + Async::runLatest( + this, std::move(task), m_writeGeneration, generation, + [this, itemId, write, generation]() { finishWrite(itemId, write, generation); }, + [this, itemId, write, generation]( + const std::exception_ptr& error) { finishWrite(itemId, write, generation, error); }, + "item state write"); +} + +void UserItemStateController::finishWrite(const QString& itemId, const PendingWrite& write, + RequestGeneration::Token generation, const std::exception_ptr& error) +{ + auto pending = m_pendingWrites.find(itemId); + if (pending == m_pendingWrites.end()) + return; + const bool publish = write.publish + && (write.kind == WriteKind::Favorite || pending->playbackRevision.isCurrent(write.playbackRevision)); + pending->requests.dequeue(); + const bool more = !pending->requests.isEmpty(); + if (!more) + m_pendingWrites.erase(pending); + + QPointer guard(this); + if (error) { + emit errorOccurred(exceptionMessage(error)); + } else if (publish) { + if (write.kind == WriteKind::Favorite) { + applyFavorite(itemId, write.value); + } else if (write.kind == WriteKind::ClearProgress) { + applyResumeTicks(itemId, 0); + applyPlayed(itemId, false); + } else { + applyPlayed(itemId, write.value); + } + } + if (!error && m_home && write.kind != WriteKind::Favorite) + m_home->refreshPlaybackRows(); + // Model and error signals can reset the account or destroy the controller. + if (guard && m_writeGeneration.isCurrent(generation) && more) + startNextWrite(itemId); } } // namespace Spool diff --git a/src/app/UserItemStateController.h b/src/app/UserItemStateController.h index 3487b3b6..1f5b64ba 100644 --- a/src/app/UserItemStateController.h +++ b/src/app/UserItemStateController.h @@ -1,11 +1,15 @@ #pragma once +#include "../common/RequestGeneration.h" #include "../provider/UserItemStateSink.h" #include #include +#include #include +#include + namespace Spool { class ContentModelController; @@ -28,6 +32,7 @@ class UserItemStateController final : public QObject { const MovieItem& successor, quint64 reportId); // The reporter invokes this after draining the ending session's reports. void persistPlaybackWatched(const QString& itemId, quint64 reportId); + void reset(); 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); @@ -38,6 +43,23 @@ class UserItemStateController final : public QObject { void errorOccurred(const QString& message); private: + enum class WriteKind { Favorite, Played, ClearProgress }; + struct PendingWrite { + WriteKind kind; + bool value; + bool publish; + RequestGeneration::Token playbackRevision; + }; + struct ItemWrites { + QQueue requests; + RequestGeneration playbackRevision; + }; + + void enqueueWrite(const QString& itemId, WriteKind kind, bool value, bool publish = true); + void startNextWrite(const QString& itemId); + void finishWrite(const QString& itemId, const PendingWrite& write, RequestGeneration::Token generation, + const std::exception_ptr& error = {}); + UserItemStateSink *m_api = nullptr; BrowseSessionController *m_browse = nullptr; HomeModelController *m_home = nullptr; @@ -46,6 +68,9 @@ class UserItemStateController final : public QObject { // Pending operations only, not an item-state cache. An explicit mutation // cancels a deferred playback completion before its server write. QHash m_pendingPlaybackWatched; + // Manual writes serialize per item and publish only after server success. + QHash m_pendingWrites; + RequestGeneration m_writeGeneration; }; } // namespace Spool diff --git a/src/automation/LocalCommandServer.cpp b/src/automation/LocalCommandServer.cpp index a34c5cfb..49a6960e 100644 --- a/src/automation/LocalCommandServer.cpp +++ b/src/automation/LocalCommandServer.cpp @@ -9,6 +9,7 @@ #include "provider/SourceHub.h" #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +41,17 @@ namespace { const QStringList SettingKeys { QStringLiteral("playback/seekPreviews"), QStringLiteral("playback/accurateTrickplay"), QStringLiteral("playback/trickplayPreviewScalePercent"), QStringLiteral("appearance/uiScalePercent"), QStringLiteral("playback/renderQuality") }; + QCoro::Task itemStateTask(SourceHub *sources, QString itemId, QString field, QJsonValue value) + { + if (field == QStringLiteral("played")) + co_await sources->setItemPlayed(itemId, value.toBool()); + else if (field == QStringLiteral("favorite")) + co_await sources->setItemFavorite(itemId, value.toBool()); + else if (field == QStringLiteral("position")) + co_await sources->setItemPlaybackPosition(itemId, qint64(value.toDouble() * 10'000'000.0)); + co_return co_await sources->fetchItemDetails(itemId); + } + QJsonObject itemJson(const MovieItem& item, int index) { return { { QStringLiteral("index"), index }, { QStringLiteral("id"), item.id }, @@ -73,6 +86,10 @@ LocalCommandServer::LocalCommandServer(AppController *app, RouterController *rou m_server.setSocketOptions(QLocalServer::UserAccessOption); m_server.setMaxPendingConnections(8); connect(&m_server, &QLocalServer::newConnection, this, &LocalCommandServer::acceptConnections); + m_capture.settle.setSingleShot(true); + m_capture.deadline.setSingleShot(true); + connect(&m_capture.settle, &QTimer::timeout, this, [this] { stopCapture(QStringLiteral("settled")); }); + connect(&m_capture.deadline, &QTimer::timeout, this, [this] { stopCapture(QStringLiteral("deadline")); }); m_tcpServer.setProxy(QNetworkProxy::NoProxy); m_tcpServer.setMaxPendingConnections(8); connect(&m_tcpServer, &QTcpServer::newConnection, this, &LocalCommandServer::acceptConnections); @@ -293,6 +310,24 @@ void LocalCommandServer::reject(QIODevice *socket, const QString& id, const QStr finish(socket, id, LocalControl::failure(id, code, message)); } +// Where keyboard input lands: the focused item's QML type, the nearest +// ancestor with an objectName, and whether it can be seen. Types and object +// names only, never text or bound values. +QJsonObject LocalCommandServer::focusState() const +{ + QQuickItem *item = m_window->activeFocusItem(); + if (!item) + return { { QStringLiteral("item"), QJsonValue::Null } }; + QString named; + for (QQuickItem *ancestor = item; ancestor && named.isEmpty(); ancestor = ancestor->parentItem()) + named = ancestor->objectName(); + QString type = QString::fromLatin1(item->metaObject()->className()); + type = type.section(QStringLiteral("_QML"), 0, 0); + return { { QStringLiteral("item"), type }, { QStringLiteral("named"), named }, + { QStringLiteral("visible"), item->isVisible() }, + { QStringLiteral("contentItem"), item == m_window->contentItem() } }; +} + QJsonObject LocalCommandServer::state() const { const auto player = m_app->player(); @@ -304,7 +339,9 @@ QJsonObject LocalCommandServer::state() const { QStringLiteral("browseLoading"), m_app->browse()->loadingMore() }, { QStringLiteral("homeLoading"), m_app->home()->loading() }, { QStringLiteral("visual"), QJsonObject::fromVariantMap(m_window->automationVisualState()) }, + { QStringLiteral("focus"), focusState() }, { QStringLiteral("source"), QJsonObject::fromVariantMap(m_app->streamingQualitySource()) }, + { QStringLiteral("network"), QJsonObject::fromVariantMap(player->networkMetrics()) }, { QStringLiteral("playback"), QJsonObject { { QStringLiteral("active"), player->sessionActive() }, { QStringLiteral("loaded"), player->fileLoaded() }, { QStringLiteral("paused"), player->paused() }, @@ -396,6 +433,99 @@ void LocalCommandServer::screenshot(QIODevice *socket, const QString& id, const m_window->update(); } +QString LocalCommandServer::startCapture(const QJsonObject& args) +{ + const QString directory = args.value(QStringLiteral("dir")).toString(); + const int settleMs = args.value(QStringLiteral("settleMs")).toInt(1500); + const int maxMs = args.value(QStringLiteral("maxMs")).toInt(10000); + if (directory.isEmpty() || !QDir::isAbsolutePath(directory) || !QFileInfo(directory).isDir()) + return QStringLiteral("Capture needs an existing absolute directory"); + if (!QDir(directory).entryList({ QStringLiteral("frame-*.png") }, QDir::Files).isEmpty()) + return QStringLiteral("Capture directory already holds frames; use an empty one"); + if (settleMs < 100 || settleMs > 30000 || maxMs < 500 || maxMs > 120000) + return QStringLiteral("settleMs must be 100..30000 and maxMs 500..120000"); + if (!m_window->isExposed()) + return QStringLiteral("Window must be exposed to capture rendered frames"); + if (m_capture.running) + stopCapture(QStringLiteral("restarted")); + m_capture.running = true; + m_capture.directory = directory; + m_capture.stopReason.clear(); + m_capture.rendered = 0; + m_capture.saved = 0; + m_capture.lastDigest.clear(); + m_capture.index = {}; + m_capture.clock.start(); + m_capture.settle.setInterval(settleMs); + m_capture.deadline.setInterval(maxMs); + // Queued: frameSwapped is emitted on the render thread, and the readback + // must run on the GUI thread after that frame. The grab renders the same + // scene again without swapping, so it cannot feed back into this signal. + m_capture.frames + = connect(m_window, &QQuickWindow::frameSwapped, this, [this] { captureFrame(); }, Qt::QueuedConnection); + m_capture.settle.start(); + m_capture.deadline.start(); + // Record the state the change starts from. + m_window->update(); + return {}; +} + +void LocalCommandServer::captureFrame() +{ + if (!m_capture.running) + return; + ++m_capture.rendered; + const QImage image = m_window->grabWindow(); + if (image.isNull()) + return; + const QByteArray digest = QCryptographicHash::hash( + QByteArrayView(reinterpret_cast(image.constBits()), image.sizeInBytes()), + QCryptographicHash::Sha1); + // Qt also swaps for changes too small to see, and the grab can repeat a + // frame; only a visible change counts as activity. + if (digest == m_capture.lastDigest) + return; + m_capture.lastDigest = digest; + const QString name = QStringLiteral("frame-%1.png").arg(m_capture.saved, 4, 10, QLatin1Char('0')); + if (!image.save(QDir(m_capture.directory).filePath(name), "PNG")) { + stopCapture(QStringLiteral("save_failed")); + return; + } + m_capture.index.append(QJsonObject { { QStringLiteral("frame"), m_capture.saved }, { QStringLiteral("file"), name }, + { QStringLiteral("ms"), double(m_capture.clock.nsecsElapsed()) / 1e6 }, + { QStringLiteral("route"), m_router->route() } }); + ++m_capture.saved; + m_capture.settle.start(); +} + +void LocalCommandServer::stopCapture(const QString& reason) +{ + if (!m_capture.running) + return; + disconnect(m_capture.frames); + m_capture.settle.stop(); + m_capture.deadline.stop(); + m_capture.running = false; + m_capture.stopReason = reason; + QSaveFile file(QDir(m_capture.directory).filePath(QStringLiteral("index.json"))); + if (file.open(QIODevice::WriteOnly)) { + const QJsonObject index { { QStringLiteral("frames"), m_capture.index }, + { QStringLiteral("rendered"), m_capture.rendered }, { QStringLiteral("stopReason"), reason }, + { QStringLiteral("width"), m_window->width() }, { QStringLiteral("height"), m_window->height() } }; + file.write(QJsonDocument(index).toJson(QJsonDocument::Indented)); + file.commit(); + } +} + +QJsonObject LocalCommandServer::captureStatus() const +{ + return { { QStringLiteral("running"), m_capture.running }, { QStringLiteral("dir"), m_capture.directory }, + { QStringLiteral("rendered"), m_capture.rendered }, { QStringLiteral("saved"), m_capture.saved }, + { QStringLiteral("stopReason"), m_capture.stopReason }, + { QStringLiteral("elapsedMs"), m_capture.clock.isValid() ? double(m_capture.clock.elapsed()) : 0.0 }, + { QStringLiteral("route"), m_router->route() } }; +} + void LocalCommandServer::dispatch(QIODevice *socket, const QJsonObject& request) { const QString id = request.value(QStringLiteral("id")).toString(); @@ -660,6 +790,65 @@ void LocalCommandServer::dispatch(QIODevice *socket, const QJsonObject& request) operation->deleteLater(); }, "local control setting"); + } else if (command == QStringLiteral("capture-start")) { + const QString error = startCapture(args); + if (!error.isEmpty()) { + invalid(error); + return; + } + done(captureStatus()); + } else if (command == QStringLiteral("capture-status")) { + done(captureStatus()); + } else if (command == QStringLiteral("capture-stop")) { + stopCapture(QStringLiteral("stopped")); + done(captureStatus()); + } else if (command == QStringLiteral("open")) { + // Opens an item through the shell, with the same route, model and + // snapshot a person's selection would use. + const QString source = args.value(QStringLiteral("source")).toString(); + const int index = args.value(QStringLiteral("index")).toInt(-1); + QObject *shell = m_window->rootObject() + ? m_window->rootObject()->findChild(QStringLiteral("appShell")) + : nullptr; + QVariant opened; + if (!shell + || !QMetaObject::invokeMethod(shell, "automationOpen", Q_RETURN_ARG(QVariant, opened), + Q_ARG(QVariant, source), Q_ARG(QVariant, index)) + || !opened.toBool()) { + invalid(QStringLiteral("Source must be browse, resume, next-up, context or similar, with an index " + "present in that row")); + return; + } + done(state()); + } else if (command == QStringLiteral("item-state")) { + const QString itemId = args.value(QStringLiteral("id")).toString(); + const QString field = args.value(QStringLiteral("field")).toString(); + if (m_sources->accountOf(itemId).isEmpty() || SourceHub::rawId(itemId).isEmpty()) { + invalid(QStringLiteral("item-state requires an account-qualified item ID")); + return; + } + if (!field.isEmpty() + && !QStringList { QStringLiteral("played"), QStringLiteral("favorite"), QStringLiteral("position") } + .contains(field)) { + invalid(QStringLiteral("Field must be played, favorite or position")); + return; + } + QObject *operation = new QObject(socket); + Async::runScoped( + operation, itemStateTask(m_sources, itemId, field, args.value(QStringLiteral("value"))), + [done, operation, itemId](const MovieItem& item) { + done({ { QStringLiteral("id"), itemId }, { QStringLiteral("title"), item.title }, + { QStringLiteral("type"), item.itemType }, { QStringLiteral("played"), item.played }, + { QStringLiteral("favorite"), item.favorite }, + { QStringLiteral("positionSeconds"), double(item.resumeTicks) / 1e7 } }); + operation->deleteLater(); + }, + [this, socket, id, operation](const std::exception_ptr&) { + reject(socket, id, QStringLiteral("provider_failed"), + QStringLiteral("The provider rejected the item state request")); + operation->deleteLater(); + }, + "local control item state"); } 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")); @@ -738,7 +927,9 @@ void LocalCommandServer::dispatch(QIODevice *socket, const QJsonObject& request) { 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()) { + // Stop also retires asynchronous item lookup/negotiation. Requiring an + // adopted player here could let a timed-out control request start later. + if (command != QStringLiteral("stop") && !m_app->player()->sessionActive()) { reject(socket, id, QStringLiteral("no_playback"), QStringLiteral("No active local playback session")); return; } diff --git a/src/automation/LocalCommandServer.h b/src/automation/LocalCommandServer.h index 78cdb3a4..5f502b2f 100644 --- a/src/automation/LocalCommandServer.h +++ b/src/automation/LocalCommandServer.h @@ -1,11 +1,13 @@ #pragma once #include +#include #include #include #include #include #include +#include #include class QIODevice; @@ -37,8 +39,13 @@ class LocalCommandServer final : public QObject { void finish(QIODevice *socket, const QString& id, const QJsonObject& result); void reject(QIODevice *socket, const QString& id, const QString& code, const QString& message); QJsonObject state() const; + QJsonObject focusState() const; QJsonObject items(const QJsonObject& args) const; void screenshot(QIODevice *socket, const QString& id, const QString& path); + QString startCapture(const QJsonObject& args); + void captureFrame(); + void stopCapture(const QString& reason); + QJsonObject captureStatus() const; AppController *m_app; RouterController *m_router; @@ -55,5 +62,21 @@ class LocalCommandServer final : public QObject { bool m_settingPending = false; Qt::MouseButtons m_pointerButtons = Qt::NoButton; QElapsedTimer m_inputClock; + + // Frame capture for load diagnostics: every rendered frame that differs + // from the last is saved until the screen holds still or a deadline. + struct Capture { + bool running = false; + QString directory; + QString stopReason; + int rendered = 0; + int saved = 0; + QByteArray lastDigest; + QElapsedTimer clock; + QTimer settle; + QTimer deadline; + QMetaObject::Connection frames; + QJsonArray index; + } m_capture; }; } diff --git a/src/automation/SpooletMain.cpp b/src/automation/SpooletMain.cpp index 72ff1e5d..5bdf32b1 100644 --- a/src/automation/SpooletMain.cpp +++ b/src/automation/SpooletMain.cpp @@ -42,7 +42,12 @@ QJsonObject help() QStringLiteral("text TEXT | text --stdin (commit to the focused editable field; no echo)"), 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("settings get [KEY] | settings set KEY JSON_VALUE"), + QStringLiteral("open browse|resume|next-up|context|similar INDEX (select like a person would)"), + QStringLiteral("item-state QUALIFIED_ID [played|favorite true|false | position SECONDS] " + "(writes through the provider, then reads the item back from the server)"), + QStringLiteral("capture start ABS_DIR [SETTLE_MS] [MAX_MS] | capture status | capture stop " + "(saves each visibly changed frame until the screen settles)") } }, { QStringLiteral("notes"), QStringLiteral( "All stdout is JSON. Commands are bounded and never prompt. When multiple instances run, " @@ -196,6 +201,52 @@ int main(int argc, char **argv) return error(QStringLiteral( "Usage: downloads list | options ITEM_ID | start ITEM_ID INDEX | cancel|retry|remove|play JOB_ID")); } + } else if (command == QStringLiteral("open")) { + bool valid = false; + const int index = arguments.size() == 2 ? arguments.last().toInt(&valid) : -1; + if (!valid || index < 0) + return error(QStringLiteral("Usage: open browse|resume|next-up|context|similar INDEX")); + args.insert(QStringLiteral("source"), arguments.first()); + args.insert(QStringLiteral("index"), index); + } else if (command == QStringLiteral("item-state")) { + if (arguments.size() != 1 && arguments.size() != 3) + return error( + QStringLiteral("Usage: item-state QUALIFIED_ID [played|favorite true|false | position SECONDS]")); + args.insert(QStringLiteral("id"), arguments.first()); + if (arguments.size() == 3) { + const QString field = arguments.at(1); + const QString value = arguments.at(2); + args.insert(QStringLiteral("field"), field); + if (field == QStringLiteral("position")) { + bool valid = false; + const double seconds = value.toDouble(&valid); + if (!valid || !std::isfinite(seconds) || seconds < 0) + return error(QStringLiteral("Position must be nonnegative seconds")); + args.insert(QStringLiteral("value"), seconds); + } else if (value == QStringLiteral("true") || value == QStringLiteral("false")) { + args.insert(QStringLiteral("value"), value == QStringLiteral("true")); + } else { + return error(QStringLiteral("played and favorite take true or false")); + } + } + } else if (command == QStringLiteral("capture")) { + const QString action = arguments.isEmpty() ? QString() : arguments.takeFirst(); + if (action == QStringLiteral("start") && count(1, 3)) { + command = QStringLiteral("capture-start"); + args.insert(QStringLiteral("dir"), 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("Capture timings are positive milliseconds")); + args.insert(index == 1 ? QStringLiteral("settleMs") : QStringLiteral("maxMs"), value); + } + } else if ((action == QStringLiteral("status") || action == QStringLiteral("stop")) && count(0, 0)) { + command = QStringLiteral("capture-") + action; + } else { + return error( + QStringLiteral("Usage: capture start ABS_DIR [SETTLE_MS] [MAX_MS] | capture status | capture stop")); + } } else if (command == QStringLiteral("settings")) { if (arguments.isEmpty()) return error(QStringLiteral("Usage: settings get [KEY] or settings set KEY JSON_VALUE")); diff --git a/src/cache/DatabaseManager.cpp b/src/cache/DatabaseManager.cpp index fe263a11..6c8bb069 100644 --- a/src/cache/DatabaseManager.cpp +++ b/src/cache/DatabaseManager.cpp @@ -601,9 +601,12 @@ bool DatabaseManager::initialize(const QString& databasePath) QCoro::Task DatabaseManager::awaitInitialization() { - if (!m_initializationFuture.isValid()) + const QFuture initialization = m_initializationFuture; + if (!initialization.isValid()) co_return false; - co_return co_await m_initializationFuture; + const QPointer manager(this); + const bool initialized = co_await initialization; + co_return initialized&& manager; } void DatabaseManager::shutdown() @@ -641,7 +644,7 @@ void DatabaseManager::saveDeviceId(const QString& deviceId) invokeOnWorkerAsync([this, deviceId]() { m_worker->setValue(QStringLiteral("client/deviceId"), deviceId); }); } -QCoro::Task DatabaseManager::loadHomePayloadAsync(const QString& key, int schemaVersion) +QCoro::Task DatabaseManager::loadHomePayloadAsync(QString key, int schemaVersion) { if (!co_await awaitInitialization()) co_return QJsonObject(); @@ -660,7 +663,7 @@ void DatabaseManager::saveHomePayload(const QString& key, int schemaVersion, con [this, key, schemaVersion, payload]() { m_worker->setHomePayload(key, schemaVersion, payload); }); } -QCoro::Task DatabaseManager::loadSettingAsync(const QString& key, const QString& defaultValue) +QCoro::Task DatabaseManager::loadSettingAsync(QString key, QString defaultValue) { if (!co_await awaitInitialization()) co_return defaultValue; @@ -669,14 +672,14 @@ QCoro::Task DatabaseManager::loadSettingAsync(const QString& key, const [worker, key, defaultValue]() { return worker ? settingFromWorker(worker, key, defaultValue) : defaultValue; }); } -QCoro::Task DatabaseManager::loadValuesAsync(const QStringList& keys) +QCoro::Task DatabaseManager::loadValuesAsync(QStringList keys) { if (!co_await awaitInitialization()) co_return QVariantMap(); DatabaseWorker *worker = m_worker; co_return co_await workerTask(worker, [worker, keys]() { return worker ? worker->values(keys) : QVariantMap(); }); } -QCoro::Task DatabaseManager::loadStartupStateAsync(const QStringList& keys) +QCoro::Task DatabaseManager::loadStartupStateAsync(QStringList keys) { if (!m_initializationFuture.isValid()) co_return StartupState {}; @@ -719,8 +722,7 @@ QCoro::Task DatabaseManager::schemaVersionAsync() co_return co_await workerTask(worker, [worker]() { return worker ? worker->schemaVersion() : 0; }); } -QCoro::Task DatabaseManager::loadCacheEntryAsync( - const QString& nameSpace, const QString& key, qint64 maxAgeMs) +QCoro::Task DatabaseManager::loadCacheEntryAsync(QString nameSpace, QString key, qint64 maxAgeMs) { if (!co_await awaitInitialization()) co_return QByteArray(); diff --git a/src/cache/DatabaseManager.h b/src/cache/DatabaseManager.h index 0969a7f5..aa5b62d4 100644 --- a/src/cache/DatabaseManager.h +++ b/src/cache/DatabaseManager.h @@ -43,17 +43,17 @@ class DatabaseManager final : public QObject { QCoro::Task loadDeviceIdAsync(); void saveDeviceId(const QString& deviceId); - QCoro::Task loadHomePayloadAsync(const QString& key, int schemaVersion); + QCoro::Task loadHomePayloadAsync(QString key, int schemaVersion); void saveHomePayload(const QString& key, int schemaVersion, const QJsonObject& payload); - QCoro::Task loadSettingAsync(const QString& key, const QString& defaultValue = {}); - QCoro::Task loadValuesAsync(const QStringList& keys); - QCoro::Task loadStartupStateAsync(const QStringList& keys); + QCoro::Task loadSettingAsync(QString key, QString defaultValue = {}); + QCoro::Task loadValuesAsync(QStringList keys); + QCoro::Task loadStartupStateAsync(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); + QCoro::Task loadCacheEntryAsync(QString nameSpace, QString key, qint64 maxAgeMs = -1); void saveCacheEntry(const QString& nameSpace, const QString& key, const QByteArray& value, qint64 ttlMs = 0); void invalidateCacheNamespace(const QString& nameSpace); void evictCacheEntries(int maximumEntries); diff --git a/src/diagnostics/InputLatencyMonitor.cpp b/src/diagnostics/InputLatencyMonitor.cpp index 6f54ea86..d6bc6cb1 100644 --- a/src/diagnostics/InputLatencyMonitor.cpp +++ b/src/diagnostics/InputLatencyMonitor.cpp @@ -1122,12 +1122,15 @@ void InputLatencyMonitor::handleCompletedSample(const InputLatencySample& sample void InputLatencyMonitor::cancelMeasurements() { m_timeline.cancel(); - resetUiTransition(); finishCancellationOnGuiThread(); } void InputLatencyMonitor::finishCancellationOnGuiThread() { + // Disabling capture, clearing statistics and scene-graph invalidation + // must retire route work as well as input deadlines. Otherwise a route + // that never became ready keeps its frame-budget timer running. + resetUiTransition(); m_deadlineTimer.stop(); hideWarning(); } diff --git a/src/diagnostics/SystemPerformanceMonitor.cpp b/src/diagnostics/SystemPerformanceMonitor.cpp index 9bfb5538..58903f4b 100644 --- a/src/diagnostics/SystemPerformanceMonitor.cpp +++ b/src/diagnostics/SystemPerformanceMonitor.cpp @@ -9,11 +9,9 @@ namespace Spool { SystemPerformanceMonitor::SystemPerformanceMonitor(QObject *parent) : QObject(parent) { - sample(); m_timer.setInterval(1000); m_timer.setTimerType(Qt::CoarseTimer); connect(&m_timer, &QTimer::timeout, this, &SystemPerformanceMonitor::sample); - m_timer.start(); } void SystemPerformanceMonitor::setAudioDecodeCpuTimeProvider(std::function provider) @@ -22,11 +20,38 @@ void SystemPerformanceMonitor::setAudioDecodeCpuTimeProvider(std::function(); + m_timer.start(); + emit activeChanged(); + sample(); +} + void SystemPerformanceMonitor::sample() { + if (!m_sampler) + return; PlatformPerformanceSample sample; const qint64 audioDecodeCpuTimeNs = m_audioDecodeCpuTimeProvider ? m_audioDecodeCpuTimeProvider() : -1; - if (!m_sampler.sample(audioDecodeCpuTimeNs, sample)) + if (!m_sampler->sample(audioDecodeCpuTimeNs, sample)) return; // Said once, because "the overlay shows nothing" is otherwise diff --git a/src/diagnostics/SystemPerformanceMonitor.h b/src/diagnostics/SystemPerformanceMonitor.h index 34b5c80d..429d07fa 100644 --- a/src/diagnostics/SystemPerformanceMonitor.h +++ b/src/diagnostics/SystemPerformanceMonitor.h @@ -3,14 +3,17 @@ #include "platform/PlatformPerformanceSampler.h" #include +#include #include #include +#include namespace Spool { class SystemPerformanceMonitor final : public QObject { Q_OBJECT + Q_PROPERTY(bool active READ active NOTIFY activeChanged) Q_PROPERTY(bool available READ available NOTIFY metricsChanged) Q_PROPERTY(bool systemStatsAvailable READ systemStatsAvailable NOTIFY metricsChanged) Q_PROPERTY(bool threadBreakdownAvailable READ threadBreakdownAvailable NOTIFY metricsChanged) @@ -33,6 +36,12 @@ class SystemPerformanceMonitor final : public QObject { public: explicit SystemPerformanceMonitor(QObject *parent = nullptr); void setAudioDecodeCpuTimeProvider(std::function provider); + Q_INVOKABLE void observe(QObject *observer); + + bool active() const + { + return !m_observers.isEmpty(); + } bool available() const { @@ -108,12 +117,14 @@ class SystemPerformanceMonitor final : public QObject { } signals: + void activeChanged(); void metricsChanged(); private: void sample(); - PlatformPerformanceSampler m_sampler; + std::unique_ptr m_sampler; + QSet m_observers; QTimer m_timer; std::function m_audioDecodeCpuTimeProvider; bool m_available = false; diff --git a/src/main.cpp b/src/main.cpp index 09ce1f71..42dc44ea 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -840,8 +840,11 @@ int main(int argc, char **argv) player->setDemuxerBudget(memoryBudget.mpvDemuxerMaxBytes, memoryBudget.mpvDemuxerMaxBackBytes); Spool::ScreenSaverInhibitor screenSaverInhibitor; const auto updateScreenSaver = [&screenSaverInhibitor, player = player.get()] { - screenSaverInhibitor.setInhibited( - Spool::screenSaverShouldBeInhibited(player && player->sessionActive(), player && player->paused())); + bool active = player && player->sessionActive(); +#if defined(SPOOL_APPLE_MOBILE) + active = active && player->mediaKind() != QStringLiteral("audio"); +#endif + screenSaverInhibitor.setInhibited(Spool::screenSaverShouldBeInhibited(active, player && player->paused())); }; QObject::connect(player.get(), &Spool::PlayerController::playbackStateChanged, &app, updateScreenSaver); QObject::connect(player.get(), &Spool::PlayerController::sessionActiveChanged, &app, updateScreenSaver); diff --git a/src/media/MediaTypes.h b/src/media/MediaTypes.h index f591f5a9..8c6d5036 100644 --- a/src/media/MediaTypes.h +++ b/src/media/MediaTypes.h @@ -392,6 +392,18 @@ struct PagedMovieItems { int limit = 0; std::optional nextCursor; bool exhausted = true; + // Any row can be fetched by number and totalRecordCount is exact, so a + // browse can load only what is on screen instead of walking from the top. + bool seekable = false; +}; + +// How a name-sorted listing divides by leading character, in listing order: +// "#" for anything before A, then the letters. Counts, not offsets. +struct BrowseIndexEntry { + QString label; + int count = 0; + + friend bool operator==(const BrowseIndexEntry&, const BrowseIndexEntry&) = default; }; struct PlaybackQueueItem { @@ -422,6 +434,9 @@ struct PlaybackSession { int subtitleStreamIndex = -1; bool codecFallback = false; bool restoreStreamSelection = false; + // Empty when the server owns group queue policy; do not report a local mode. + QString repeatMode = QStringLiteral("RepeatNone"); + bool shuffled = false; }; QString exceptionMessage(const std::exception_ptr& exception); diff --git a/src/models/MovieGridModel.cpp b/src/models/MovieGridModel.cpp index b193b1e6..13026939 100644 --- a/src/models/MovieGridModel.cpp +++ b/src/models/MovieGridModel.cpp @@ -33,7 +33,22 @@ int MovieGridModel::rowCount(const QModelIndex& parent) const { if (parent.isValid()) return 0; - return static_cast(m_movies.size()); + return isSparse() ? m_sparseCount : static_cast(m_movies.size()); +} + +int MovieGridModel::slotOfRow(int row) const +{ + if (row < 0 || row >= rowCount()) + return -1; + if (!isSparse()) + return row; + const auto found = std::lower_bound(m_rows.begin(), m_rows.end(), row); + return found != m_rows.end() && *found == row ? static_cast(found - m_rows.begin()) : -1; +} + +bool MovieGridModel::isLoaded(int row) const +{ + return slotOfRow(row) >= 0; } int MovieGridModel::count() const @@ -46,7 +61,24 @@ QVariant MovieGridModel::data(const QModelIndex& index, int role) const if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) return {}; - const auto& movie = m_movies[static_cast(index.row())]; + const int slot = slotOfRow(index.row()); + if (slot < 0) { + switch (role) { + case ItemRole: + return QVariant::fromValue(MovieItem {}); + case DisplayTitleRole: + case DisplaySubtitleRole: + case PlayActionLabelRole: + return QString(); + case ProgressRole: + return 0.0; + case LoadedRole: + return false; + default: + return {}; + } + } + const auto& movie = m_movies[static_cast(slot)]; switch (role) { case ItemRole: return QVariant::fromValue(movie); @@ -58,6 +90,8 @@ QVariant MovieGridModel::data(const QModelIndex& index, int role) const return playbackProgress(movie); case PlayActionLabelRole: return movie.playActionLabel(); + case LoadedRole: + return true; default: return {}; } @@ -71,6 +105,7 @@ QHash MovieGridModel::roleNames() const { DisplaySubtitleRole, "displaySubtitle" }, { ProgressRole, "progress" }, { PlayActionLabelRole, "playActionLabel" }, + { LoadedRole, "loaded" }, }; } @@ -81,6 +116,15 @@ MovieItem MovieGridModel::get(int index) const void MovieGridModel::setMovies(std::vector movies) { + if (isSparse()) { + beginResetModel(); + m_sparseCount = -1; + m_rows.clear(); + m_movies = std::move(movies); + endResetModel(); + emit countChanged(); + return; + } const int oldCount = rowCount(); const int newCount = static_cast(movies.size()); @@ -128,6 +172,19 @@ void MovieGridModel::appendMovies(const std::vector& movies) { if (movies.empty()) return; + if (isSparse()) { + std::vector rows(movies.begin(), movies.end()); + const int first = rowCount(); + beginInsertRows({}, first, first + static_cast(rows.size()) - 1); + m_sparseCount += static_cast(rows.size()); + for (int offset = 0; offset < static_cast(rows.size()); ++offset) { + m_rows.push_back(first + offset); + m_movies.push_back(std::move(rows[static_cast(offset)])); + } + endInsertRows(); + emit countChanged(); + return; + } const int first = rowCount(); const int last = first + static_cast(movies.size()) - 1; @@ -142,6 +199,8 @@ void MovieGridModel::clear() const int oldCount = rowCount(); beginResetModel(); m_movies.clear(); + m_rows.clear(); + m_sparseCount = -1; endResetModel(); if (oldCount != 0) emit countChanged(); @@ -149,9 +208,77 @@ void MovieGridModel::clear() MovieItem MovieGridModel::movieAt(int index) const { - if (index < 0 || index >= rowCount()) + const int slot = slotOfRow(index); + if (slot < 0) return {}; - return m_movies[static_cast(index)]; + return m_movies[static_cast(slot)]; +} + +void MovieGridModel::beginSparse(int count) +{ + const int oldCount = rowCount(); + beginResetModel(); + m_movies.clear(); + m_rows.clear(); + m_sparseCount = std::max(0, count); + endResetModel(); + if (oldCount != m_sparseCount) + emit countChanged(); +} + +void MovieGridModel::setRows(int first, std::vector items) +{ + if (!isSparse() || items.empty() || first < 0 || first >= m_sparseCount) + return; + if (first + static_cast(items.size()) > m_sparseCount) + items.resize(static_cast(m_sparseCount - first)); + const int last = first + static_cast(items.size()) - 1; + const auto begin = std::lower_bound(m_rows.begin(), m_rows.end(), first); + const auto end = std::upper_bound(begin, m_rows.end(), last); + const auto at = begin - m_rows.begin(); + m_movies.erase(m_movies.begin() + at, m_movies.begin() + (end - m_rows.begin())); + m_rows.erase(begin, end); + std::vector rows(items.size()); + for (size_t offset = 0; offset < items.size(); ++offset) + rows[offset] = first + static_cast(offset); + m_rows.insert(m_rows.begin() + at, rows.begin(), rows.end()); + m_movies.insert( + m_movies.begin() + at, std::make_move_iterator(items.begin()), std::make_move_iterator(items.end())); + emit dataChanged(index(first, 0), index(last, 0)); +} + +void MovieGridModel::unloadRows(int first, int last) +{ + if (!isSparse()) + return; + first = std::max(0, first); + last = std::min(m_sparseCount - 1, last); + if (last < first) + return; + const auto begin = std::lower_bound(m_rows.begin(), m_rows.end(), first); + const auto end = std::upper_bound(begin, m_rows.end(), last); + if (begin == end) + return; + const auto at = begin - m_rows.begin(); + m_movies.erase(m_movies.begin() + at, m_movies.begin() + (end - m_rows.begin())); + m_rows.erase(begin, end); + emit dataChanged(index(first, 0), index(last, 0)); +} + +std::vector MovieGridModel::loadedRange(int first, int last) const +{ + std::vector result; + first = std::max(0, first); + last = std::min(rowCount() - 1, last); + if (last < first) + return result; + if (!isSparse()) + return { m_movies.begin() + first, m_movies.begin() + last + 1 }; + const auto begin = std::lower_bound(m_rows.begin(), m_rows.end(), first); + const auto end = std::upper_bound(begin, m_rows.end(), last); + const auto at = begin - m_rows.begin(); + result.assign(m_movies.begin() + at, m_movies.begin() + (end - m_rows.begin())); + return result; } const std::vector& MovieGridModel::movies() const @@ -165,14 +292,14 @@ bool MovieGridModel::updateResumeTicks(const QString& itemId, qint64 resumeTicks return false; bool updated = false; - for (int row = 0; row < rowCount(); ++row) { - auto& movie = m_movies[static_cast(row)]; + for (int slot = 0; slot < loadedCount(); ++slot) { + auto& movie = m_movies[static_cast(slot)]; const qint64 normalizedTicks = normalizedResumeTicks(resumeTicks, movie.runtimeTicks); if (movie.id != itemId || movie.resumeTicks == normalizedTicks) continue; movie.resumeTicks = normalizedTicks; - const QModelIndex changed = index(row, 0); + const QModelIndex changed = index(rowOfSlot(slot), 0); emit dataChanged(changed, changed, { ItemRole, ProgressRole, PlayActionLabelRole }); updated = true; } @@ -185,13 +312,13 @@ bool MovieGridModel::updateFavorite(const QString& itemId, bool favorite) return false; bool updated = false; - for (int row = 0; row < rowCount(); ++row) { - auto& movie = m_movies[static_cast(row)]; + for (int slot = 0; slot < loadedCount(); ++slot) { + auto& movie = m_movies[static_cast(slot)]; if (movie.id != itemId || movie.favorite == favorite) continue; movie.favorite = favorite; - const QModelIndex changed = index(row, 0); + const QModelIndex changed = index(rowOfSlot(slot), 0); emit dataChanged(changed, changed, { ItemRole }); updated = true; } @@ -204,15 +331,15 @@ bool MovieGridModel::updatePlayed(const QString& itemId, bool played) return false; bool updated = false; - for (int row = 0; row < rowCount(); ++row) { - auto& movie = m_movies[static_cast(row)]; + for (int slot = 0; slot < loadedCount(); ++slot) { + auto& movie = m_movies[static_cast(slot)]; const bool clearsResume = !played && movie.resumeTicks != 0; if (movie.id != itemId || (movie.played == played && !clearsResume)) continue; movie.played = played; movie.resumeTicks = 0; - const QModelIndex changed = index(row, 0); + const QModelIndex changed = index(rowOfSlot(slot), 0); emit dataChanged(changed, changed, { ItemRole, ProgressRole, PlayActionLabelRole }); updated = true; } @@ -221,6 +348,8 @@ bool MovieGridModel::updatePlayed(const QString& itemId, bool played) bool MovieGridModel::removeUnresumable() { + if (isSparse()) + return false; bool removed = false; for (int row = rowCount() - 1; row >= 0; --row) { const MovieItem& movie = m_movies[static_cast(row)]; diff --git a/src/models/MovieGridModel.h b/src/models/MovieGridModel.h index a8a648d7..d08c0915 100644 --- a/src/models/MovieGridModel.h +++ b/src/models/MovieGridModel.h @@ -19,6 +19,7 @@ class MovieGridModel final : public QAbstractListModel { DisplaySubtitleRole, ProgressRole, PlayActionLabelRole, + LoadedRole, }; explicit MovieGridModel(QObject *parent = nullptr); @@ -33,7 +34,34 @@ class MovieGridModel final : public QAbstractListModel { void appendMovies(const std::vector& movies); void clear(); MovieItem movieAt(int index) const; + // Every row holding an item, in row order. In sparse mode that is only + // the loaded rows, so an index into it is not a row number there. const std::vector& movies() const; + + // Sparse mode: the model has `count` rows but holds only the ones that + // have been loaded; the rest are placeholders (loaded == false) until a + // block of rows arrives. Ends with setMovies() or clear(). + void beginSparse(int count); + bool isSparse() const + { + return m_sparseCount >= 0; + } + bool isLoaded(int row) const; + // Fills rows [first, first + items.size()), replacing what was there. + void setRows(int first, std::vector items); + // Returns rows [first, last] to placeholders, releasing their items. + void unloadRows(int first, int last); + int loadedCount() const + { + return static_cast(m_movies.size()); + } + // The loaded items whose rows fall in [first, last], in row order. + std::vector loadedRange(int first, int last) const; + // Row of the loaded item at `slot` in movies(). + int rowOfSlot(int slot) const + { + return isSparse() ? m_rows[static_cast(slot)] : slot; + } bool updateResumeTicks(const QString& itemId, qint64 resumeTicks); bool updateFavorite(const QString& itemId, bool favorite); bool updatePlayed(const QString& itemId, bool played); @@ -43,7 +71,13 @@ class MovieGridModel final : public QAbstractListModel { void countChanged(); private: + // movies() position of a row, or -1 for a placeholder. + int slotOfRow(int row) const; + std::vector m_movies; + // Sparse mode only: the row of each m_movies entry, ascending. + std::vector m_rows; + int m_sparseCount = -1; }; } // namespace Spool diff --git a/src/platform/PlatformPlaybackSurface.h b/src/platform/PlatformPlaybackSurface.h index 1ca27b4c..48523e57 100644 --- a/src/platform/PlatformPlaybackSurface.h +++ b/src/platform/PlatformPlaybackSurface.h @@ -15,7 +15,6 @@ class NativeAppWindow; struct PlaybackSession; bool platformIdleMpvPreparationEnabled(); -void runAfterPlatformMpvLoaded(std::function callback); MpvOptionProfile::Platform platformMpvOptionProfile(); // Whether video goes through the Qt scene graph. `directRequested` carries // the viewer's choice for platforms that offer one; the rest ignore it, diff --git a/src/platform/android/AndroidPlaybackSurface.cpp b/src/platform/android/AndroidPlaybackSurface.cpp index 98ce0330..125a2a52 100644 --- a/src/platform/android/AndroidPlaybackSurface.cpp +++ b/src/platform/android/AndroidPlaybackSurface.cpp @@ -76,12 +76,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_sachk_spool_VideoSurfaceBridge_native bool platformIdleMpvPreparationEnabled() { - return false; -} - -void runAfterPlatformMpvLoaded(std::function callback) -{ - callback(); + return qEnvironmentVariable("SPOOL_DISABLE_IDLE_MPV") != QLatin1String("1"); } MpvOptionProfile::Platform platformMpvOptionProfile() diff --git a/src/platform/common/CredentialStoreFileBackend.h b/src/platform/common/CredentialStoreFileBackend.h index 290243cf..bb7bca01 100644 --- a/src/platform/common/CredentialStoreFileBackend.h +++ b/src/platform/common/CredentialStoreFileBackend.h @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace Spool::CredentialStore::FileBackend { @@ -33,18 +34,24 @@ inline QString load(const QString& profileId) return QString::fromUtf8(file.readAll()); } -inline bool save(const QString& profileId, const QString& token) +inline bool saveToPath(const QString& credentialPath, const QString& token) { - const QString credentialPath = path(profileId); QDir directory = QFileInfo(credentialPath).dir(); if (!directory.mkpath(QStringLiteral("."))) return false; - QFile::setPermissions(directory.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); - QFile file(credentialPath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + if (!QFile::setPermissions( + directory.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)) + return false; + QSaveFile file(credentialPath); + if (!file.open(QIODevice::WriteOnly) || !file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner)) return false; - file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner); - return file.write(token.toUtf8()) >= 0; + const QByteArray bytes = token.toUtf8(); + return file.write(bytes) == bytes.size() && file.commit(); +} + +inline bool save(const QString& profileId, const QString& token) +{ + return saveToPath(path(profileId), token); } inline void remove(const QString& profileId) diff --git a/src/platform/desktop/DesktopPlaybackSurface.cpp b/src/platform/desktop/DesktopPlaybackSurface.cpp index 5fff8085..551041ad 100644 --- a/src/platform/desktop/DesktopPlaybackSurface.cpp +++ b/src/platform/desktop/DesktopPlaybackSurface.cpp @@ -14,11 +14,7 @@ namespace { bool platformIdleMpvPreparationEnabled() { - return false; -} -void runAfterPlatformMpvLoaded(std::function callback) -{ - callback(); + return qEnvironmentVariable("SPOOL_DISABLE_IDLE_MPV") != QLatin1String("1"); } MpvOptionProfile::Platform platformMpvOptionProfile() { diff --git a/src/platform/linux/LinuxPlatformStartup.cpp b/src/platform/linux/LinuxPlatformStartup.cpp index 79a2597b..18aa86b5 100644 --- a/src/platform/linux/LinuxPlatformStartup.cpp +++ b/src/platform/linux/LinuxPlatformStartup.cpp @@ -10,6 +10,12 @@ bool configurePlatformEnvironment(const QString&) { if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("wayland")); + // Without a platform theme a QGuiApplication has no native file dialog, + // so Qt drew its own light, hard-to-read QML one. The desktop portal + // opens the system's chooser on KDE, GNOME and others, and Qt falls back + // to its own dialog where no portal is running. A user's choice stands. + if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORMTHEME")) + qputenv("QT_QPA_PLATFORMTHEME", QByteArrayLiteral("xdgdesktopportal")); if (qEnvironmentVariableIsSet("SPOOL_VERBOSE_QT")) { qputenv("QT_DEBUG_PLUGINS", QByteArrayLiteral("1")); qputenv("QT_LOGGING_RULES", diff --git a/src/platform/webos/WebOSApplicationServices.cpp b/src/platform/webos/WebOSApplicationServices.cpp index f6a53faa..15961905 100644 --- a/src/platform/webos/WebOSApplicationServices.cpp +++ b/src/platform/webos/WebOSApplicationServices.cpp @@ -238,7 +238,7 @@ void PlatformApplicationServices::start() static bool started = false; if (!started) { started = true; - WebOSMpvRuntime::preloadAsync(); + QTimer::singleShot(3000, QCoreApplication::instance(), [] { WebOSMpvRuntime::preloadAsync(); }); } }, Qt::QueuedConnection); diff --git a/src/platform/webos/WebOSCredentialStore.cpp b/src/platform/webos/WebOSCredentialStore.cpp index f323493c..8572af98 100644 --- a/src/platform/webos/WebOSCredentialStore.cpp +++ b/src/platform/webos/WebOSCredentialStore.cpp @@ -1,4 +1,5 @@ #include "../CredentialStore.h" +#include "../common/CredentialStoreFileBackend.h" #include #include @@ -33,15 +34,7 @@ QString load(const QString& profileId) bool save(const QString& profileId, const QString& accessToken) { - const QString root = credentialRoot(); - if (!QDir().mkpath(root)) - return false; - QFile::setPermissions(root, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); - QFile file(credentialPath(profileId)); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) - return false; - file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner); - return file.write(accessToken.toUtf8()) >= 0; + return FileBackend::saveToPath(credentialPath(profileId), accessToken); } void remove(const QString& profileId) diff --git a/src/platform/webos/WebOSMpvRuntime.cpp b/src/platform/webos/WebOSMpvRuntime.cpp index 7f3921b8..2c7d822c 100644 --- a/src/platform/webos/WebOSMpvRuntime.cpp +++ b/src/platform/webos/WebOSMpvRuntime.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,7 @@ extern "C" { #include "video/out/starfish/starfish_ctx.h" +#include } // Every mpv_*/starfish_* function the app calls. The list must stay in sync @@ -34,6 +36,7 @@ extern "C" { X(mpv_destroy) \ X(mpv_terminate_destroy) \ X(mpv_wait_event) \ + X(mpv_wakeup) \ X(mpv_request_log_messages) \ X(mpv_observe_property) \ X(mpv_set_option_string) \ @@ -227,6 +230,12 @@ bool loadNow() g_api.starfish_exported_set_crop_cb(g_cropCb, g_cropOpaque); } + // Parse/cache ALSA's routing configuration without opening a PCM, playing + // silence, or taking the TV audio output away from another application. + // Loading a media file merely to prime audio would have those side effects. + const int audioResult = snd_config_update(); + if (audioResult < 0) + qWarning() << "MpvRuntime: ALSA configuration preparation failed:" << snd_strerror(audioResult); qInfo() << "MpvRuntime: libmpv loaded in" << timer.elapsed() << "ms from" << path.c_str(); return true; } @@ -285,7 +294,12 @@ void runAfterLoaded(std::function callback) void preloadAsync() { - std::thread([] { ensureLoaded(); }).detach(); + static std::atomic_bool started { false }; + if (started.exchange(true)) + return; + auto *worker = QThread::create([] { ensureLoaded(); }); + QObject::connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(QThread::LowPriority); } void probeStarfishVideoCodecsAsync(std::function callback) @@ -304,8 +318,8 @@ void probeStarfishVideoCodecsAsync(std::function callback) // --------------------------------------------------------------------------- // mpv client API entry points. Declarations come from the real headers, so // signatures cannot drift. All of them funnel through ensureLoaded(): the -// common case is a no-op atomic load; the cold case (playback started before -// the post-first-frame preload finished) blocks on the dlopen. +// common case is a no-op atomic load; controller creation/initialization runs +// off the GUI thread, including a cold play before the delayed preload. mpv_handle *mpv_create(void) { @@ -340,6 +354,12 @@ mpv_event *mpv_wait_event(mpv_handle *ctx, double timeout) return g_api.mpv_wait_event(ctx, timeout); } +void mpv_wakeup(mpv_handle *ctx) +{ + if (ctx && Spool::WebOSMpvRuntime::ensureLoaded()) + g_api.mpv_wakeup(ctx); +} + int mpv_request_log_messages(mpv_handle *ctx, const char *min_level) { if (!Spool::WebOSMpvRuntime::ensureLoaded()) diff --git a/src/platform/webos/WebOSPlaybackSurface.cpp b/src/platform/webos/WebOSPlaybackSurface.cpp index ce621f82..b74e61e7 100644 --- a/src/platform/webos/WebOSPlaybackSurface.cpp +++ b/src/platform/webos/WebOSPlaybackSurface.cpp @@ -1,7 +1,6 @@ #include "platform/PlatformPlaybackSurface.h" #include "platform/NativeAppWindow.h" -#include "platform/webos/WebOSMpvRuntime.h" #include "player/MpvVideoItem.h" #include @@ -31,11 +30,6 @@ bool platformIdleMpvPreparationEnabled() return enabled; } -void runAfterPlatformMpvLoaded(std::function callback) -{ - WebOSMpvRuntime::runAfterLoaded(std::move(callback)); -} - MpvOptionProfile::Platform platformMpvOptionProfile() { return MpvOptionProfile::Platform::WebOS; diff --git a/src/player/MpvLifecycle.cpp b/src/player/MpvLifecycle.cpp index 951b6b48..bb9128d0 100644 --- a/src/player/MpvLifecycle.cpp +++ b/src/player/MpvLifecycle.cpp @@ -47,6 +47,7 @@ void MpvLifecycle::destroy(BeforeDestroy beforeDestroy) if (m_stopFlag) m_stopFlag->store(true); + mpv_wakeup(handle); if (m_eventThread.joinable()) m_eventThread.join(); @@ -72,26 +73,20 @@ void MpvLifecycle::destroyAsync(BeforeDestroy beforeDestroy) if (m_stopFlag) m_stopFlag->store(true); + mpv_wakeup(handle); + if (m_eventThread.joinable()) + m_eventThread.join(); m_stopFlag.reset(); m_pendingFileLoads = 0; - // The worker owns the event thread and the handle from here on, so this - // object may be reused (or destroyed) immediately. - std::thread([handle, eventThread = std::move(m_eventThread)]() mutable { - if (eventThread.joinable()) - eventThread.join(); + // The event handler can no longer run; only the handle outlives this object. + std::thread([handle]() { qInfo() << "player: calling mpv_terminate_destroy (async)"; mpv_terminate_destroy(handle); qInfo() << "player: mpv_terminate_destroy returned"; }).detach(); } -void MpvLifecycle::requestEventLoopStop() -{ - if (m_stopFlag) - m_stopFlag->store(true); -} - void MpvLifecycle::beginFileLoad() { m_pendingFileLoads.fetch_add(1, std::memory_order_acq_rel); @@ -120,9 +115,16 @@ void MpvLifecycle::runEventLoop( mpv_handle *handle, EventHandler eventHandler, const std::shared_ptr& stop) { while (!stop->load()) { - mpv_event *event = mpv_wait_event(handle, 0.1); - if (event && eventHandler) + // mpv_wakeup interrupts this wait and remembers a wakeup that arrives + // just before it. Idle/paused players need no timeout polling, and a + // teardown need not wait for a polling timeout to expire. + mpv_event *event = mpv_wait_event(handle, -1); + if (stop->load()) + break; + if (event && event->event_id != MPV_EVENT_NONE && eventHandler) eventHandler(event); + if (event && event->event_id == MPV_EVENT_SHUTDOWN) + break; } } diff --git a/src/player/MpvLifecycle.h b/src/player/MpvLifecycle.h index 2cdb6f90..874ec56c 100644 --- a/src/player/MpvLifecycle.h +++ b/src/player/MpvLifecycle.h @@ -20,12 +20,10 @@ class MpvLifecycle final { mpv_handle *handle() const; bool adopt(mpv_handle *handle, EventHandler eventHandler); void destroy(BeforeDestroy beforeDestroy = {}); - // Hands the event-thread join and mpv_terminate_destroy to a detached - // worker: the join can wait out a full mpv_wait_event timeout and the - // destroy tears down the demuxer, neither of which belongs on the GUI - // thread. Safe to call from the GUI thread mid-session. + // Stops event delivery before returning, then hands mpv_terminate_destroy + // to a detached worker. Event handlers may refer to the owning controller; + // only destruction of the self-contained mpv core may outlive it. void destroyAsync(BeforeDestroy beforeDestroy = {}); - void requestEventLoopStop(); void beginFileLoad(); void cancelFileLoad(); @@ -37,8 +35,7 @@ class MpvLifecycle final { mpv_handle *handle, EventHandler eventHandler, const std::shared_ptr& stop); std::thread m_eventThread; - // Owned per adoption and shared with the event thread so a detached - // teardown worker never needs to touch `this`. + // Each adopted core has its own stop flag, shared with its event thread. std::shared_ptr m_stopFlag; std::atomic m_pendingFileLoads { 0 }; std::atomic m_handle { nullptr }; diff --git a/src/player/PlayQueueController.cpp b/src/player/PlayQueueController.cpp index 2e12044d..a89253b6 100644 --- a/src/player/PlayQueueController.cpp +++ b/src/player/PlayQueueController.cpp @@ -4,6 +4,7 @@ #include "../provider/PlaybackSource.h" #include +#include #include #include #include @@ -315,7 +316,7 @@ bool PlayQueueController::next() { if (!canGoNext()) return false; - setCurrentOrderIndex(m_orderIndex + 1); + setCurrentOrderIndex((m_orderIndex + 1) % static_cast(m_order.size())); return true; } @@ -323,10 +324,29 @@ bool PlayQueueController::previous() { if (!canGoPrevious()) return false; - setCurrentOrderIndex(m_orderIndex - 1); + setCurrentOrderIndex(m_orderIndex > 0 ? m_orderIndex - 1 : static_cast(m_order.size()) - 1); return true; } +bool PlayQueueController::advanceAfterCompletion() +{ + if (m_repeatMode == QStringLiteral("RepeatOne")) + return currentIndex() >= 0; + return next(); +} + +void PlayQueueController::setRepeatMode(const QString& mode) +{ + if (mode == m_repeatMode + || (mode != QStringLiteral("RepeatNone") && mode != QStringLiteral("RepeatAll") + && mode != QStringLiteral("RepeatOne"))) + return; + m_repeatMode = mode; + // Wrapping changes the availability of Next/Previous without moving the + // cursor; queueChanged reevaluates those bindings. + emit queueChanged(); +} + bool PlayQueueController::playAt(int index) { if (index < 0 || index >= rowCount()) @@ -361,36 +381,7 @@ void PlayQueueController::setShuffled(bool shuffled) bool PlayQueueController::moveItem(int from, int to) { - if (from < 0 || from >= rowCount() || to < 0 || to >= rowCount() || from == to) - return false; - - const int previousCurrent = currentIndex(); - int nextCurrent = previousCurrent; - if (previousCurrent == from) - nextCurrent = to; - else if (from < previousCurrent && previousCurrent <= to) - --nextCurrent; - else if (to <= previousCurrent && previousCurrent < from) - ++nextCurrent; - - beginMoveRows({}, from, from, {}, to > from ? to + 1 : to); - MovieItem moved = std::move(m_entries[static_cast(from)]); - m_entries.erase(m_entries.begin() + from); - m_entries.insert(m_entries.begin() + to, std::move(moved)); - const bool movedProvenance = m_userQueued[static_cast(from)]; - m_userQueued.erase(m_userQueued.begin() + from); - m_userQueued.insert(m_userQueued.begin() + to, movedProvenance); - endMoveRows(); - - // Shuffle ends here on purpose. The panel lists entries in model order, so - // while shuffled a drag would rearrange rows without touching what plays - // next — the gesture would look broken. Taking manual control of the order - // is the clearer reading, and `shuffled` notifies so the toggle follows. - m_shuffled = false; - rebuildNaturalOrder(); - m_orderIndex = nextCurrent; - emitQueueStateChanged(previousCurrent); - return true; + return moveRange(from, 1, to); } bool PlayQueueController::removeItem(int index) @@ -433,6 +424,8 @@ bool PlayQueueController::removeItem(int index) void PlayQueueController::clear() { + cancelEpisodeSuccessors(); + setRepeatMode(QStringLiteral("RepeatNone")); if (m_entries.empty()) return; const int previousCurrent = currentIndex(); @@ -576,28 +569,41 @@ bool PlayQueueController::addToQueue(const std::vector& items, bool n bool PlayQueueController::moveRange(int from, int count, int to) { - if (count <= 1) - return count == 1 && moveItem(from, to); // `to` is where the block lands once it has been lifted out, matching // moveItem's own erase-then-insert reading of its arguments. - if (from < 0 || count < 0 || from + count > rowCount() || to < 0 || to > rowCount() - count) - return false; - if (to == from) + const int rows = rowCount(); + if (from < 0 || count <= 0 || from >= rows || count > rows - from || to < 0 || to > rows - count || to == from) return false; - // Walk the block a row at a time so every index shift is one moveItem - // already reasons about, the playing row's included. - if (to < from) { - for (int offset = 0; offset < count; ++offset) { - if (!moveItem(from + offset, to + offset)) - return false; - } - return true; - } - for (int offset = 0; offset < count; ++offset) { - if (!moveItem(from, to + count - 1)) - return false; - } + const int previousCurrent = currentIndex(); + int nextCurrent = previousCurrent; + if (previousCurrent >= from && previousCurrent < from + count) + nextCurrent += to - from; + else if (to < from && previousCurrent >= to && previousCurrent < from) + nextCurrent += count; + else if (to > from && previousCurrent >= from + count && previousCurrent < to + count) + nextCurrent -= count; + + if (!beginMoveRows({}, from, from + count - 1, {}, to > from ? to + count : to)) + return false; + // Rotate the block and the rows it crosses once. Moving each member + // separately shifted the queue, rebuilt its play order and invalidated + // the outline once per episode in a folded run. + const auto moveBlock = [from, count, to](auto& values) { + if (to < from) + std::rotate(values.begin() + to, values.begin() + from, values.begin() + from + count); + else + std::rotate(values.begin() + from, values.begin() + from + count, values.begin() + to + count); + }; + moveBlock(m_entries); + moveBlock(m_userQueued); + // Manual moves end shuffle so the visible order is also the play order. + // Publish the final cursor before rowsMoved lets the outline inspect it. + m_shuffled = false; + rebuildNaturalOrder(); + m_orderIndex = nextCurrent; + endMoveRows(); + emitQueueStateChanged(previousCurrent); return true; } @@ -665,7 +671,7 @@ void PlayQueueController::rebuildShuffledOrder(int currentNaturalIndex) auto current = std::find(m_order.begin(), m_order.end(), currentNaturalIndex); if (current != m_order.end()) m_order.erase(current); - std::reverse(m_order.begin(), m_order.end()); + std::shuffle(m_order.begin(), m_order.end(), *QRandomGenerator::global()); m_order.insert(m_order.begin(), currentNaturalIndex); m_orderIndex = 0; } diff --git a/src/player/PlayQueueController.h b/src/player/PlayQueueController.h index bcf7f318..cc58db90 100644 --- a/src/player/PlayQueueController.h +++ b/src/player/PlayQueueController.h @@ -19,8 +19,11 @@ class PlayQueueController final : public QAbstractListModel { Q_PROPERTY(int count READ count NOTIFY queueChanged) Q_PROPERTY(int currentIndex READ currentIndex NOTIFY currentIndexChanged) Q_PROPERTY(bool shuffled READ shuffled WRITE setShuffled NOTIFY queueChanged) - Q_PROPERTY(bool canGoNext READ canGoNext NOTIFY currentIndexChanged) - Q_PROPERTY(bool canGoPrevious READ canGoPrevious NOTIFY currentIndexChanged) + // Repeat policy for this local queue: RepeatNone, RepeatAll or RepeatOne. + // The queue owns one repeat state; a SyncPlay group takes it over. + Q_PROPERTY(QString repeatMode READ repeatMode WRITE setRepeatMode NOTIFY queueChanged) + Q_PROPERTY(bool canGoNext READ canGoNext NOTIFY queueChanged) + Q_PROPERTY(bool canGoPrevious READ canGoPrevious NOTIFY queueChanged) // What is playing, as the same snapshot get() hands out. A queue can be // replaced under an unchanged index -- one item swapped for another at the // same position, which is what starting a second thing in a row does -- so @@ -90,13 +93,23 @@ class PlayQueueController final : public QAbstractListModel { { return m_shuffled; } + QString repeatMode() const + { + return m_repeatMode; + } + Q_INVOKABLE void setRepeatMode(const QString& mode); + // RepeatOne replays the current row; otherwise traversal advances, and + // RepeatAll wraps through the playback order at the end. + bool advanceAfterCompletion(); bool canGoNext() const { - return m_orderIndex >= 0 && m_orderIndex + 1 < static_cast(m_order.size()); + return m_orderIndex >= 0 + && (m_orderIndex + 1 < static_cast(m_order.size()) || m_repeatMode == QStringLiteral("RepeatAll")); } bool canGoPrevious() const { - return m_orderIndex > 0; + return m_orderIndex > 0 + || (m_orderIndex == 0 && !m_order.empty() && m_repeatMode == QStringLiteral("RepeatAll")); } MovieItem itemAt(int index) const { @@ -169,6 +182,7 @@ class PlayQueueController final : public QAbstractListModel { std::vector m_order; int m_orderIndex = -1; bool m_shuffled = false; + QString m_repeatMode = QStringLiteral("RepeatNone"); quint64 m_successorGeneration = 0; QString m_successorItemId; }; diff --git a/src/player/PlaybackPositionTracker.cpp b/src/player/PlaybackPositionTracker.cpp index 3d08710f..67fbb83c 100644 --- a/src/player/PlaybackPositionTracker.cpp +++ b/src/player/PlaybackPositionTracker.cpp @@ -27,13 +27,17 @@ double PlaybackPositionTracker::position() const return m_positionSeconds; } +bool PlaybackPositionTracker::canEstimatePosition() const +{ + return m_positionClock.isValid() && !seekInFlight(); +} + double PlaybackPositionTracker::estimatedPosition(double playbackSpeed, bool advancing) const { // A seek in flight is holding the position at its target; running the // clock forward from there would drift the seek bar away from the place // the next gesture is going to be measured from. - if (!advancing || seekInFlight() || !m_hasMpvPosition || !m_positionClock.isValid() - || !std::isfinite(playbackSpeed)) { + if (!advancing || !canEstimatePosition() || !std::isfinite(playbackSpeed)) { return m_positionSeconds; } return clamp(m_positionSeconds + static_cast(m_positionClock.elapsed()) * playbackSpeed / 1'000.0); diff --git a/src/player/PlaybackPositionTracker.h b/src/player/PlaybackPositionTracker.h index 4a7ab5ed..0412c2a2 100644 --- a/src/player/PlaybackPositionTracker.h +++ b/src/player/PlaybackPositionTracker.h @@ -21,6 +21,9 @@ class PlaybackPositionTracker final { void clear(); double position() const; + // Extrapolation needs an accepted mpv sample after reset or a seek. The + // UI need not run a timer while the tracker is still holding a target. + bool canEstimatePosition() const; double estimatedPosition(double playbackSpeed, bool advancing) const; double duration() const; // A settled sample from mpv, rather than the resume seed or seek target. diff --git a/src/player/PlaybackReporter.cpp b/src/player/PlaybackReporter.cpp index b8ceee32..c1b15942 100644 --- a/src/player/PlaybackReporter.cpp +++ b/src/player/PlaybackReporter.cpp @@ -28,6 +28,7 @@ void PlaybackReporter::start(const PlaybackSession& session, double playbackRate { m_report = std::make_shared(); m_report->session = session; + m_report->context = m_api ? m_api->reportingContext(session.itemId) : nullptr; m_report->id = ++m_nextReportId; m_startPlaybackRate = playbackRate; m_startVolume = volume; @@ -50,6 +51,16 @@ bool PlaybackReporter::setStreamIndexes(int audioStreamIndex, int subtitleStream return true; } +bool PlaybackReporter::setQueue(const std::vector& queue, const QString& repeatMode, bool shuffled) +{ + if (!m_report || !m_report->active) + return false; + m_report->session.nowPlayingQueue = queue; + m_report->session.repeatMode = repeatMode; + m_report->session.shuffled = shuffled; + return true; +} + void PlaybackReporter::reportProgress(qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted) { if (!m_report || !m_report->active || !m_api) @@ -103,14 +114,29 @@ void PlaybackReporter::sendStopIfReady(const ReportPtr& report) sendStop(report, 1); } +bool PlaybackReporter::contextCurrent(const ReportPtr& report) const +{ + return m_api && report->context && m_api->reportingContext(report->session.itemId) == report->context.data(); +} + void PlaybackReporter::sendStop(const ReportPtr& report, int attempt) { + if (!contextCurrent(report)) { + report->watched = false; + finishStop(report); + return; + } Async::runScoped( this, m_api->reportPlaybackStopped( report->session, report->stopPositionTicks, report->stopFailed, report->stopPlaybackRate), [this, report]() { finishStop(report); }, [this, report, attempt](const std::exception_ptr& error) { + if (!contextCurrent(report)) { + report->watched = false; + finishStop(report); + return; + } qWarning() << "player: playback stop report attempt" << attempt << "failed:" << exceptionMessage(error); emit reportFailed(QStringLiteral("playback stop"), exceptionMessage(error)); if (attempt >= kMaxStopReportAttempts) { @@ -124,6 +150,8 @@ void PlaybackReporter::sendStop(const ReportPtr& report, int attempt) void PlaybackReporter::finishStop(const ReportPtr& report) { + if (report->stopFinished) + return; report->stopFinished = true; report->predecessor.reset(); if (m_stopTails.value(report->session.itemId) == report) @@ -139,6 +167,10 @@ void PlaybackReporter::sendStart() const ReportPtr report = m_report; if (!report || !report->active || !m_api || report->startInFlight || report->startReported) return; + if (!contextCurrent(report)) { + report->active = false; + return; + } report->startInFlight = true; Async::runScoped( @@ -167,6 +199,10 @@ void PlaybackReporter::sendProgress() if (!report || !report->active || !m_api || !report->startReported || report->progressInFlight || !m_progressPending) return; + if (!contextCurrent(report)) { + report->active = false; + return; + } report->progressInFlight = true; m_progressPending = false; diff --git a/src/player/PlaybackReporter.h b/src/player/PlaybackReporter.h index bb6e1fd8..45ca0076 100644 --- a/src/player/PlaybackReporter.h +++ b/src/player/PlaybackReporter.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -21,6 +22,9 @@ class PlaybackReporter final : public QObject { void start(const PlaybackSession& session, double playbackRate, int volume, bool muted); bool setStreamIndexes(int audioStreamIndex, int subtitleStreamIndex); void reportProgress(qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted); + // Queue, repeat and shuffle stay truthful across progress reports; group + // playback reports an empty repeat mode. + bool setQueue(const std::vector& queue, const QString& repeatMode, bool shuffled); quint64 stop(qint64 positionTicks, bool failed, double playbackRate = 1.0, bool watched = false); signals: @@ -33,6 +37,7 @@ class PlaybackReporter final : public QObject { private: struct Report { PlaybackSession session; + QPointer context; quint64 id = 0; qint64 stopPositionTicks = 0; double stopPlaybackRate = 1.0; @@ -54,8 +59,9 @@ class PlaybackReporter final : public QObject { void sendStopIfReady(const ReportPtr& report); void sendStop(const ReportPtr& report, int attempt); void finishStop(const ReportPtr& report); + bool contextCurrent(const ReportPtr& report) const; - PlaybackSource *m_api = nullptr; + QPointer m_api; ReportPtr m_report; quint64 m_nextReportId = 0; // Only outstanding stop operations, not item/progress state. Same-item diff --git a/src/player/PlayerController.cpp b/src/player/PlayerController.cpp index 36a5edd8..54ce7497 100644 --- a/src/player/PlayerController.cpp +++ b/src/player/PlayerController.cpp @@ -21,15 +21,16 @@ extern "C" { #include #include +#include #include #include #include #include #include #include -#include #include #include +#include #include #include @@ -38,6 +39,7 @@ extern "C" { #include #include #include +#include #include #ifdef Q_OS_ANDROID @@ -60,6 +62,7 @@ namespace { } constexpr uint64_t kTimePosRefreshReply = 0x6a666e7074730001ULL; + constexpr uint64_t kNetworkMetricsReply = 0x6375726c73746174ULL; constexpr uint64_t kFullscreenSetReply = 0x6673000000000000ULL; constexpr uint64_t kFullscreenObservation = 0x666f000000000000ULL; constexpr uint64_t kFullscreenReplyMask = 0xffff000000000000ULL; @@ -121,6 +124,11 @@ namespace { ok &= setOption(handle, option.name.constData(), option.value.constData()); return ok; } + void destroyMpvOffThread(mpv_handle *handle) + { + if (handle) + std::thread([handle] { mpv_terminate_destroy(handle); }).detach(); + } bool setMpvProperty(mpv_handle *handle, const char *name, const char *value) { const int error = mpv_set_property_string(handle, name, value); @@ -159,6 +167,44 @@ namespace { return static_cast(seconds * 10000000.0); } + // Curl's public snapshot contains numeric counters and nested numeric sample + // maps. Never propagate string values into support/CLI diagnostics. + QVariant networkMetricValue(const mpv_node& node, int depth = 0) + { + if (depth > 4) + return {}; + switch (node.format) { + case MPV_FORMAT_INT64: + return QVariant::fromValue(node.u.int64); + case MPV_FORMAT_DOUBLE: + return std::isfinite(node.u.double_) ? QVariant(node.u.double_) : QVariant(); + case MPV_FORMAT_FLAG: + return bool(node.u.flag); + case MPV_FORMAT_NODE_MAP: { + QVariantMap result; + if (node.u.list) { + for (int i = 0; i < node.u.list->num; ++i) { + const QVariant value = networkMetricValue(node.u.list->values[i], depth + 1); + if (value.isValid() && node.u.list->keys[i]) + result.insert(QString::fromUtf8(node.u.list->keys[i]), value); + } + } + return result; + } + case MPV_FORMAT_NODE_ARRAY: { + QVariantList result; + if (node.u.list) { + result.reserve(node.u.list->num); + for (int i = 0; i < node.u.list->num; ++i) + result.append(networkMetricValue(node.u.list->values[i], depth + 1)); + } + return result; + } + default: + return {}; + } + } + void logMemoryStats() { const QString diagnostics = platformProcessMemoryDiagnostics(); @@ -167,6 +213,24 @@ namespace { } } // namespace +// This is a value snapshot, not a worker-side view of the controller. The +// worker and its queued completion own it until initialization finishes. +struct PlayerController::MpvPreparation { + std::vector options; + std::vector runtimeOptions; + MpvOptionProfile::Platform platform; + bool needsVideoSurface = true; + bool embeddedVideo = false; + bool hardwareDecoding = true; + bool userConfig = false; + mpv_handle *handle = nullptr; + qint64 elapsedMs = 0; + + ~MpvPreparation() + { + destroyMpvOffThread(handle); + } +}; PlayerController::PlayerController(NativeAppWindow *window, PlaybackSource *api, TlsTrustController *tlsTrust, const QString& subtitleFontsPath, QObject *parent) @@ -179,6 +243,15 @@ PlayerController::PlayerController(NativeAppWindow *window, PlaybackSource *api, { if (m_window) { connect(m_window, &NativeAppWindow::hdrOutputChanged, this, &PlayerController::updateRenderTarget); + connect( + m_window, &QQuickWindow::frameSwapped, this, + [this] { + if (!m_firstFramePresented) { + m_firstFramePresented = true; + scheduleIdleMpvPreparation(); + } + }, + Qt::QueuedConnection); connect(m_window, &QWindow::activeChanged, this, [this]() { if (!m_window->isActive()) releaseMpvKeys(); @@ -190,6 +263,7 @@ PlayerController::PlayerController(NativeAppWindow *window, PlaybackSource *api, } if (m_api) { connect(m_api, &PlaybackSource::credentialsChanged, this, [this]() { + m_sessionRequestHeaders.clear(); if (m_trickplay) m_trickplay->clear(); if (auto *handle = m_mpvLifecycle.handle()) @@ -205,6 +279,16 @@ PlayerController::PlayerController(NativeAppWindow *window, PlaybackSource *api, m_seekWatchdogTimer.setInterval(2500); m_backgroundTeardownTimer.setSingleShot(true); m_backgroundTeardownTimer.setInterval(750); + m_networkTimer.setInterval(1000); + connect(&m_networkTimer, &QTimer::timeout, this, &PlayerController::updateNetworkTransport); + connect(&m_backgroundTeardownTimer, &QTimer::timeout, this, [this]() { + if (!m_sessionActive) + return; + const quint64 generation = m_mpvEventGeneration; + stopWithReason(QStringLiteral("background")); + if (generation == m_mpvEventGeneration) + teardownMpv(true); + }); // Five seconds of real playback is enough to tell a device that cannot // keep up from one that merely stuttered while the cache filled, and it // is short enough that the viewer is still watching the opening titles @@ -252,7 +336,7 @@ PlayerController::PlayerController(NativeAppWindow *window, PlaybackSource *api, emit playbackStateChanged(); }); connect(&m_uiPositionTimer, &QTimer::timeout, this, [this]() { - if (m_sessionActive) + if (positionAdvancing()) emit positionChanged(); }); connect(&m_seekWatchdogTimer, &QTimer::timeout, this, [this]() { @@ -298,8 +382,11 @@ PlayerController::~PlayerController() void PlayerController::prepareForShutdown() { + ++m_playGeneration; m_idleMpvPreparationEnabled = false; m_idleMpvPreparationScheduled = false; + m_mpvPreparationContinuation = {}; + destroyIdleMpv("shutdown"); if (!m_mpvLifecycle.handle()) return; @@ -316,14 +403,18 @@ void PlayerController::prepareForShutdown() void PlayerController::teardownMpv(bool async) { releaseMpvKeys(); - ++m_mpvTeardownGeneration; + const quint64 generation = ++m_mpvTeardownGeneration; Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("player_teardown_mpv")); ++m_mpvEventGeneration; m_idleMpvPreparationEnabled = false; m_idleMpvPreparationScheduled = false; + m_mpvPreparationContinuation = {}; destroyIdleMpv("teardown"); // Platform render resources must detach before the mpv core is destroyed. - if (!releasePlatformMpvSurface(m_embeddedVideoOutput)) { + const bool released = releasePlatformMpvSurface(m_embeddedVideoOutput); + if (generation != m_mpvTeardownGeneration) + return; + if (!released) { qCritical() << "player: timed out releasing the mpv render context; preserving the mpv core"; return; } @@ -349,56 +440,120 @@ void PlayerController::teardownMpv(bool async) void PlayerController::scheduleIdleMpvPreparation() { - if (!m_idleMpvPreparationEnabled || m_idleMpvPreparationScheduled || m_idleMpvHandle || m_mpvLifecycle.handle()) + if (!m_firstFramePresented || !m_idleMpvPreparationEnabled || m_idleMpvPreparationScheduled || m_idleMpvHandle + || m_mpvPreparation || m_mpvLifecycle.handle()) return; m_idleMpvPreparationScheduled = true; - QPointer controller(this); - runAfterPlatformMpvLoaded([controller]() { - if (auto *app = QCoreApplication::instance()) { - // Delay past the launch window: idle preparation saves little at - // play-start and must not compete with first-page construction. - QTimer::singleShot(3000, app, [controller]() { - if (controller) - controller->prepareIdleMpv(); - }); - } + // frameSwapped has already crossed the first-frame boundary. Starting the + // delay here (rather than after dlopen) also keeps library loading cold. + QTimer::singleShot(3000, this, [this] { + if (m_idleMpvPreparationScheduled) + prepareIdleMpv(); }); } void PlayerController::prepareIdleMpv() { m_idleMpvPreparationScheduled = false; - if (!m_idleMpvPreparationEnabled || m_idleMpvHandle || m_mpvLifecycle.handle() || m_sessionActive) + if (!m_idleMpvPreparationEnabled || m_idleMpvHandle || m_mpvPreparation || m_mpvLifecycle.handle() + || m_sessionActive) return; - - QElapsedTimer startupTimer; - startupTimer.start(); - mpv_handle *handle = mpv_create(); - if (!handle) { - qWarning() << "player: idle mpv_create failed"; + if (usesUserMpvConfig()) { + // User scripts may load media or open audio during initialize. They + // belong to an explicit play, never a speculative background core. return; } + PlaybackSession video; + prepareMpvAsync(true, platformUsesEmbeddedVideo(video, m_directVideoOutput)); +} - if (!configureAndInitializeMpv(handle, true, false)) { - mpv_terminate_destroy(handle); - qWarning() << "player: idle mpv initialization failed"; +void PlayerController::prepareMpvAsync(bool needsVideoSurface, bool embeddedVideo) +{ + if (m_mpvPreparation && m_mpvPreparation->needsVideoSurface == needsVideoSurface + && m_mpvPreparation->embeddedVideo == embeddedVideo) return; - } - - m_idleMpvHandle = handle; - qInfo() << "player: idle-prepared mpv initialized in" << startupTimer.elapsed() << "ms"; + destroyIdleMpv("output profile change"); + auto preparation = std::make_shared(); + preparation->platform = platformMpvOptionProfile(); + preparation->needsVideoSurface = needsVideoSurface; + preparation->embeddedVideo = embeddedVideo; + preparation->hardwareDecoding = m_hardwareDecoding; + preparation->userConfig = usesUserMpvConfig(); + const int parallelRequests = m_api ? m_api->playbackParallelRequests() : 1; + const auto network = MpvOptionProfile::networkProfile(preparation->platform, parallelRequests); + qInfo().nospace() << "player: curl profile source=MpvOptionProfile platform=" + << platformPlaybackBackendName(embeddedVideo) << " requestsPerStream=" << network.parallelRequests + << " rangeBytes=" << network.rangeBytes << " ringBytes=" << network.ringBytes; + QByteArray certificateBundle = QFile::encodeName(writeTrustBundle()); + if (certificateBundle.isEmpty()) + certificateBundle = MpvOptionProfile::systemCertificateBundle(); + preparation->options = MpvOptionProfile::preInitializeOptions(m_mpvConfigPolicy); + auto applicationOptions = MpvOptionProfile::applicationOptions(preparation->platform, needsVideoSurface, + m_audioOutputMode, {}, m_demuxerMaxBytes, m_demuxerMaxBackBytes, parallelRequests, embeddedVideo, + mpvShaderCachePath(), certificateBundle, m_renderQuality); + applicationOptions.push_back({ "sub-fonts-dir", m_subtitleFontsPath }); + applicationOptions.push_back({ "osd-fonts-dir", m_subtitleFontsPath }); + applicationOptions.push_back({ "osd-font", QByteArrayLiteral("IBM Plex Sans Var") }); + for (auto& option : applicationOptions) + preparation->options.push_back(std::move(option)); + preparation->runtimeOptions = { + { "af", m_nightModeEnabled.load() ? QByteArray(kNightModeFilter) : QByteArray() }, + { "tone-mapping-visualize", mpvBool(m_toneMappingVisualizationEnabled.load()) }, + { "audio-delay", QByteArray::number(static_cast(effectiveAudioDelayMs()) / 1000.0, 'f', 3) }, + { "sub-delay", QByteArray::number(static_cast(m_subtitleDelayMs.load()) / 1000.0, 'f', 3) }, + { "speed", QByteArray::number(effectivePlaybackSpeed(), 'f', 3) }, + }; + for (auto& option : + MpvOptionProfile::subtitleOptions(m_subtitlePreferences, m_tracks.subtitlesEnabled(), m_hdrPlayback)) + preparation->runtimeOptions.push_back(std::move(option)); + m_mpvPreparation = preparation; + auto *worker = QThread::create([preparation] { + QElapsedTimer timer; + timer.start(); + preparation->handle = mpv_create(); + if (preparation->handle && !configureAndInitializeMpv(preparation->handle, *preparation)) { + mpv_terminate_destroy(std::exchange(preparation->handle, nullptr)); + } + preparation->elapsedMs = timer.elapsed(); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + connect( + worker, &QThread::finished, this, + [this, preparation] { + if (m_mpvPreparation != preparation) + return; // The snapshot owns and disposes of stale cores off-thread. + m_mpvPreparation.reset(); + if (!preparation->handle) { + qWarning() << "player: background mpv initialization failed"; + if (m_mpvPreparationContinuation) { + m_mpvPreparationContinuation = {}; + m_errorText = QStringLiteral("Failed to initialize libmpv."); + m_statusText = QStringLiteral("Playback unavailable"); + stopProgressReporting(true); + } + return; + } + m_idleMpvHandle = std::exchange(preparation->handle, nullptr); + m_idleMpvNeedsVideoSurface = preparation->needsVideoSurface; + m_idleMpvEmbeddedVideo = preparation->embeddedVideo; + qInfo() << "player: idle-prepared mpv initialized in" << preparation->elapsedMs << "ms"; + auto continuation = std::move(m_mpvPreparationContinuation); + m_mpvPreparationContinuation = {}; + if (continuation) + continuation(); + }, + Qt::QueuedConnection); + worker->start(QThread::LowPriority); } void PlayerController::destroyIdleMpv(const char *reason) { - if (!m_idleMpvHandle) - return; - - mpv_handle *handle = m_idleMpvHandle; - m_idleMpvHandle = nullptr; - qInfo() << "player: destroying idle-prepared mpv" << (reason ? reason : "unspecified"); - mpv_terminate_destroy(handle); + m_mpvPreparation.reset(); + if (auto *handle = std::exchange(m_idleMpvHandle, nullptr)) { + qInfo() << "player: destroying idle-prepared mpv" << (reason ? reason : "unspecified"); + destroyMpvOffThread(handle); + } } mpv_handle *PlayerController::takeIdleMpvHandle() @@ -410,45 +565,13 @@ mpv_handle *PlayerController::takeIdleMpvHandle() return handle; } -bool PlayerController::configureAndInitializeMpv(mpv_handle *handle, bool needsVideoSurface, bool embeddedVideo) +bool PlayerController::configureAndInitializeMpv(mpv_handle *handle, const MpvPreparation& preparation) { if (!handle) return false; - - const auto platform = platformMpvOptionProfile(); - const int parallelRequests = m_api ? m_api->playbackParallelRequests() : 1; - const MpvOptionProfile::NetworkProfile network = MpvOptionProfile::networkProfile(platform, parallelRequests); - qInfo().nospace() << "player: curl profile source=MpvOptionProfile platform=" - << platformPlaybackBackendName(embeddedVideo) << " requestsPerStream=" << network.parallelRequests - << " rangeBytes=" << network.rangeBytes << " ringBytes=" << network.ringBytes; - // Qt's trust first: it resolves the platform store through the OS rather - // than a path chosen when libcurl was compiled, and it is the only place a - // certificate the viewer remembered can reach playback. The path probe - // stays behind it for the case where the bundle cannot be written. - QByteArray certificateBundle = QFile::encodeName(writeTrustBundle()); - if (certificateBundle.isEmpty()) - certificateBundle = MpvOptionProfile::systemCertificateBundle(); - if (certificateBundle.isEmpty()) - qWarning() << "player: no certificate bundle; playback TLS will use libcurl's built-in trust"; - // mpv's own file sink bypasses Qt redaction. Route its events through the - // application log instead, including opt-in full-URL diagnostics. - auto applicationOptions = MpvOptionProfile::applicationOptions(platform, needsVideoSurface, m_audioOutputMode, {}, - m_demuxerMaxBytes, m_demuxerMaxBackBytes, parallelRequests, embeddedVideo, mpvShaderCachePath(), - certificateBundle, m_renderQuality); - applicationOptions.push_back({ "sub-fonts-dir", m_subtitleFontsPath }); - // mpv's OSD — the performance stats overlay among it — is drawn by libass - // too, and it looks for fonts under its own options rather than the - // subtitle ones. Builds without a system font provider found nothing at - // all and drew an empty overlay, so name the bundled family the app - // already ships for subtitles. - applicationOptions.push_back({ "osd-fonts-dir", m_subtitleFontsPath }); - applicationOptions.push_back({ "osd-font", QByteArrayLiteral("IBM Plex Sans Var") }); - mpv_request_log_messages(handle, mpvLogLevel()); - if (!applyOptions(handle, MpvOptionProfile::preInitializeOptions(m_mpvConfigPolicy)) - || !applyOptions(handle, applicationOptions)) - return false; - if (usesUserMpvConfig() && !applyMpvRuntimeOptions(MpvOptionApplyMode::Initial, handle)) + if (!applyOptions(handle, preparation.options) + || (preparation.userConfig && !applyOptions(handle, preparation.runtimeOptions))) return false; int initializeResult; #if !defined(SPOOL_WEBOS) && !defined(Q_OS_ANDROID) @@ -485,19 +608,19 @@ bool PlayerController::configureAndInitializeMpv(mpv_handle *handle, bool needsV // initialise, losing the pre-warm silently for want of an option that had // nothing to constrain. The option stays at auto for both. QByteArray softwareDecodeOption = QByteArrayLiteral("--hwdec=no"); - if (!m_hardwareDecoding) + if (!preparation.hardwareDecoding) embeddingArguments[std::size(embeddingOptions)] = softwareDecodeOption.data(); char audioVideoOutputOption[] = "--vo=null"; char audioVideoTrackOption[] = "--vid=no"; char audioDisplayOption[] = "--audio-display=no"; - if (!needsVideoSurface) { + if (!preparation.needsVideoSurface) { embeddingArguments[0] = audioVideoOutputOption; // Keep the argument list contiguous when hardware decoding is enabled. - size_t index = std::size(embeddingOptions) + (m_hardwareDecoding ? 0 : 1); + size_t index = std::size(embeddingOptions) + (preparation.hardwareDecoding ? 0 : 1); embeddingArguments[index++] = audioVideoTrackOption; embeddingArguments[index] = audioDisplayOption; } - if (usesUserMpvConfig()) + if (preparation.userConfig) qInfo() << "player: user mpv config enabled; embedding overrides vo, gpu-api, gpu-context," " wid, force-window, idle, keep-open, input-vo-keyboard, input-cursor, terminal and osc"; initializeResult = mpv_initialize_opts(handle, embeddingArguments); @@ -516,14 +639,20 @@ bool PlayerController::configureAndInitializeMpv(mpv_handle *handle, bool needsV // problems are diagnosable wherever the app runs. mpv_request_log_messages(handle, mpvLogLevel()); - if (platform == MpvOptionProfile::Platform::Desktop) { + if (preparation.platform == MpvOptionProfile::Platform::Desktop) { for (const char *name : { "mpv-version", "ffmpeg-version", "hwdec", "gpu-api", "gpu-context" }) { char *value = mpv_get_property_string(handle, name); qInfo() << "player: initialized mpv" << name << (value ? value : "unavailable"); mpv_free(value); } } - return usesUserMpvConfig() || applyMpvRuntimeOptions(MpvOptionApplyMode::Runtime, handle); + if (!preparation.userConfig) { + for (const MpvOption& option : preparation.runtimeOptions) { + if (!setMpvProperty(handle, option.name.constData(), option.value.constData())) + return false; + } + } + return true; } bool PlayerController::usesUserMpvConfig() const @@ -645,6 +774,9 @@ void PlayerController::observeMpvProperties(mpv_handle *handle) mpv_observe_property(handle, 0, "video-params/transfer", MPV_FORMAT_STRING); mpv_observe_property(handle, 0, "video-target-params/transfer", MPV_FORMAT_STRING); mpv_observe_property(handle, 0, "hwdec-current", MPV_FORMAT_STRING); + mpv_observe_property(handle, 0, "demuxer-cache-duration", MPV_FORMAT_DOUBLE); + mpv_observe_property(handle, 0, "video-bitrate", MPV_FORMAT_DOUBLE); + mpv_observe_property(handle, 0, "audio-bitrate", MPV_FORMAT_DOUBLE); if (platformMpvOptionProfile() == MpvOptionProfile::Platform::Desktop) { for (const char *name : { "current-vo", "current-gpu-context", "video-codec", "video-dec-params/pixelformat" }) mpv_observe_property(handle, 0, name, MPV_FORMAT_STRING); @@ -863,8 +995,7 @@ double PlayerController::positionSeconds() const double PlayerController::estimatedPositionSeconds() const { - const bool advancing = m_sessionActive && !m_paused && !m_buffering && !m_seeking; - return m_positionTracker.estimatedPosition(effectivePlaybackSpeed(), advancing); + return m_positionTracker.estimatedPosition(effectivePlaybackSpeed(), positionAdvancing()); } double PlayerController::durationSeconds() const @@ -1007,8 +1138,15 @@ void PlayerController::discardPreparedMpvForOptionChange(const char *reason) if (m_mpvLifecycle.handle()) return; + const bool pendingPlay = static_cast(m_mpvPreparationContinuation); destroyIdleMpv(reason); - scheduleIdleMpvPreparation(); + if (pendingPlay) { + auto continuation = std::move(m_mpvPreparationContinuation); + m_mpvPreparationContinuation = {}; + continuation(); + } else { + scheduleIdleMpvPreparation(); + } } bool PlayerController::applyMpvSubtitleOptions(MpvOptionApplyMode mode, mpv_handle *handle, bool preserveTrackSelection, @@ -1071,31 +1209,24 @@ bool PlayerController::ensureMpv(bool needsVideoSurface, bool embeddedVideo) QElapsedTimer startupTimer; startupTimer.start(); - // The prewarmed core carries a video output profile. Audio must be - // initialized without one, just as embedded video needs its own profile. - if (embeddedVideo || !needsVideoSurface) - destroyIdleMpv(embeddedVideo ? "embedded software video output" : "audio-only output"); mpv_handle *handle = takeIdleMpvHandle(); - const bool idlePrepared = handle != nullptr; - if (!handle) { - handle = mpv_create(); - if (!handle) { - m_errorText = QStringLiteral("mpv_create failed."); - emit playbackStateChanged(); - return false; - } - - if (!configureAndInitializeMpv(handle, needsVideoSurface, embeddedVideo)) { - mpv_terminate_destroy(handle); - m_errorText = QStringLiteral("Failed to initialize libmpv."); - emit playbackStateChanged(); - return false; - } + if (!handle) + return false; + const bool idlePrepared = true; + + if (((m_volumeKnown || !usesUserMpvConfig()) + && !setRequiredMpvProperty(handle, "volume", QByteArray::number(m_volume.load()).constData())) + || ((m_muteKnown || !usesUserMpvConfig()) + && !setRequiredMpvProperty(handle, "mute", m_muted.load() ? "yes" : "no"))) { + destroyMpvOffThread(handle); + m_errorText = QStringLiteral("Failed to restore the playback volume."); + emit playbackStateChanged(); + return false; } QString surfaceError; if (!configurePlatformMpvSurface(handle, *m_window, needsVideoSurface, embeddedVideo, surfaceError)) { - mpv_terminate_destroy(handle); + destroyMpvOffThread(handle); m_errorText = surfaceError; emit playbackStateChanged(); return false; @@ -1115,7 +1246,7 @@ bool PlayerController::ensureMpv(bool needsVideoSurface, bool embeddedVideo) if (!attachPlatformMpvSurface( handle, needsVideoSurface, embeddedVideo, *this, [this](const QString& message) { handleVideoRenderError(message); }, attachmentError)) { - mpv_terminate_destroy(handle); + destroyMpvOffThread(handle); m_errorText = attachmentError; m_statusText = QStringLiteral("Playback unavailable"); emit playbackStateChanged(); @@ -1130,7 +1261,7 @@ bool PlayerController::ensureMpv(bool needsVideoSurface, bool embeddedVideo) if (needsVideoSurface) releasePlatformMpvSurface(m_embeddedVideoOutput); m_embeddedVideoOutput = false; - mpv_terminate_destroy(handle); + destroyMpvOffThread(handle); m_errorText = QStringLiteral("Failed to start the libmpv event loop."); emit playbackStateChanged(); return false; @@ -1163,6 +1294,8 @@ void PlayerController::playLocalFiles(const QList& urls) void PlayerController::playSession(const PlaybackSession& session, bool startPaused, const QList& localFiles) { + const quint64 generation = ++m_playGeneration; + m_mpvPreparationContinuation = {}; const bool needsVideoSurface = MpvOptionProfile::needsVideoSurface(session); const QString nextMediaKind = needsVideoSurface ? QStringLiteral("video") : QStringLiteral("audio"); const bool embeddedVideo = needsVideoSurface && platformUsesEmbeddedVideo(session, m_directVideoOutput); @@ -1173,9 +1306,27 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau << "mediaKind=" << nextMediaKind << "startTimeTicks=" << session.startTimeTicks << "startPaused=" << startPaused; + sampleSettledPlaybackPosition(); + m_progressTimer.stop(); + m_uiPositionTimer.stop(); + m_seekWatchdogTimer.stop(); + m_backgroundTeardownTimer.stop(); + if (m_sessionActive && !m_localPlaylist) { + const auto previous = m_session; + const qint64 position = secondsToTicks(m_positionTracker.position()); + const quint64 reportId = m_reporter.stop(position, false, effectivePlaybackSpeed()); + emit playbackStopped(previous.itemId, position, false, false, reportId); + } else if (m_localPlaylist && m_localEntryId >= 0) { + emit localEntryEnded(QString::number(m_localEntryId), secondsToTicks(m_positionTracker.position()), false); + } + if (generation != m_playGeneration) + return; + if (m_mpvLifecycle.handle()) { qInfo() << "player: tearing down stale mpv before play"; teardownMpv(); + if (generation != m_playGeneration) + return; if (m_mpvLifecycle.handle()) { // A timed-out render-context release preserves the old core. // Never load a different media kind into its output profile. @@ -1195,6 +1346,8 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau } if (hadSubtitleDelay) emit subtitleDelayMsChanged(); + if (generation != m_playGeneration) + return; m_hdrInput = MpvOptionProfile::isHdrPlayback(session.mediaStreams); m_starfishVideoOutput @@ -1207,7 +1360,10 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau if (needsVideoSurface && !embeddedVideo) { QElapsedTimer playbackSurfaceTimer; playbackSurfaceTimer.start(); - if (!m_window->prepareForPlaybackSurface()) { + const bool prepared = m_window->prepareForPlaybackSurface(); + if (generation != m_playGeneration) + return; + if (!prepared) { m_errorText = QStringLiteral("Failed to prepare the native playback surface."); qWarning() << "player: prepareForPlaybackSurface failed after" << playbackSurfaceTimer.elapsed() << "ms"; emit playbackStateChanged(); @@ -1224,10 +1380,16 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau qInfo() << "player: audio-only playback does not request a video surface"; } - if (!ensureMpv(needsVideoSurface, embeddedVideo)) - return; - m_session = session; + m_networkMetrics.clear(); + m_networkQueryPending = false; + m_networkBufferSeconds = 0; + m_networkVideoBitrate = 0; + m_networkAudioBitrate = 0; + m_networkOrigin = localFiles.isEmpty() && m_api ? m_api->mediaOrigin() : QUrl(); + m_sessionRequestHeaders = localFiles.isEmpty() && m_api ? m_api->mediaRequestHeaders() : QByteArray(); + m_sessionNetworkProfile = localFiles.isEmpty() && m_api ? m_api->playbackNetworkProfile() : QVariantMap(); + m_sessionTrustCertificate = m_tlsTrust ? m_tlsTrust->trustedCertificate(m_networkOrigin) : QSslCertificate(); m_localPlaylist = !localFiles.isEmpty(); m_localEntryStarted = false; m_localEntryId = -1; @@ -1261,13 +1423,13 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau m_restoreStreamSelection = session.restoreStreamSelection; m_backAllowed = false; m_backGuardTimer.start(); - m_uiPositionTimer.start(); const bool wasVisible = m_visible; const bool wasSessionActive = m_sessionActive; // Audio has no video surface but still owns the screen: it gets the same // transport chrome over a now playing stage instead of playing unseen. m_visible = true; m_sessionActive = true; + updateUiPositionTimer(); if (wasVisible != m_visible) emit visibleChanged(); if (wasSessionActive != m_sessionActive) @@ -1277,9 +1439,34 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau emit tracksChanged(); emit segmentsChanged(); emit trickplayChanged(); + if (generation != m_playGeneration) + return; + finishPlaySession(session, startPaused, localFiles, generation); +} + +void PlayerController::finishPlaySession( + const PlaybackSession& session, bool startPaused, const QList& localFiles, quint64 generation) +{ + if (generation != m_playGeneration) + return; + const bool needsVideoSurface = MpvOptionProfile::needsVideoSurface(session); + const bool embeddedVideo = needsVideoSurface && platformUsesEmbeddedVideo(session, m_directVideoOutput); + if (!m_idleMpvHandle || m_idleMpvNeedsVideoSurface != needsVideoSurface + || m_idleMpvEmbeddedVideo != embeddedVideo) { + m_mpvPreparationContinuation = [this, session, startPaused, localFiles, generation] { + finishPlaySession(session, startPaused, localFiles, generation); + }; + prepareMpvAsync(needsVideoSurface, embeddedVideo); + return; + } + if (!ensureMpv(needsVideoSurface, embeddedVideo) || generation != m_playGeneration) + return; QString surfaceReadyError; - if (!waitForPlatformMpvSurfaceReady(needsVideoSurface, embeddedVideo, surfaceReadyError)) { + const bool surfaceReady = waitForPlatformMpvSurfaceReady(needsVideoSurface, embeddedVideo, surfaceReadyError); + if (generation != m_playGeneration) + return; + if (!surfaceReady) { m_errorText = surfaceReadyError; m_statusText = QStringLiteral("Playback unavailable"); teardownMpv(); @@ -1292,12 +1479,11 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau // about at all. updateRenderTarget(); - // An idle-prepared mpv was configured before this session's HDR policy - // was known. Reapply subtitle options now so HDR paperwhite is correct - // from the first rendered subtitle, not only after a settings change. - if (!usesUserMpvConfig() && !applyMpvSubtitleOptions(MpvOptionApplyMode::Runtime, handle)) { + // Session-specific HDR and per-file delays may have changed since an idle + // core's snapshot. Restore current runtime settings before loading media. + if (!usesUserMpvConfig() && !applyMpvRuntimeOptions(MpvOptionApplyMode::Runtime, handle)) { m_mpvLifecycle.cancelFileLoad(); - m_errorText = QStringLiteral("libmpv rejected the subtitle appearance."); + m_errorText = QStringLiteral("libmpv rejected the playback settings."); stopProgressReporting(true); return; } @@ -1308,7 +1494,7 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau // timeline before the server's scheduled Unpause command. Set pause on // the idle mpv core before loadfile so even the first decoded frame is // held. Ordinary playback explicitly clears any inherited pause state. - if (!setRequiredMpvProperty(handle, "pause", startPaused ? "yes" : "no")) { + if (!setRequiredMpvProperty(handle, "pause", m_paused ? "yes" : "no")) { m_mpvLifecycle.cancelFileLoad(); m_errorText = QStringLiteral("libmpv rejected the initial playback state."); stopProgressReporting(true); @@ -1324,8 +1510,7 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau } QByteArray urlBytes = session.url.toUtf8(); - if (!MpvOptionProfile::applyRequestHeaders( - handle, !m_localPlaylist && m_api ? m_api->mediaRequestHeaders() : QByteArray {})) { + if (!MpvOptionProfile::applyRequestHeaders(handle, m_sessionRequestHeaders)) { m_mpvLifecycle.cancelFileLoad(); m_errorText = QStringLiteral("libmpv rejected the authenticated media request."); stopProgressReporting(true); @@ -1338,7 +1523,7 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau return; } if (!m_localPlaylist && m_api && m_tlsTrust) { - const QSslCertificate certificate = m_tlsTrust->trustedCertificate(m_api->mediaOrigin()); + const QSslCertificate& certificate = m_sessionTrustCertificate; if (!certificate.isNull()) { const QString trustDirectory = QDir(startupCacheRoot({})).filePath(QStringLiteral("tls")); const QString trustPath = trustDirectory + QLatin1Char('/') @@ -1364,6 +1549,8 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau } } } + const double startSeconds + = session.startTimeTicks > 0 ? static_cast(session.startTimeTicks) / 10000000.0 : 0.0; const double streamStartSeconds = m_timeline.streamSeconds(startSeconds); if (streamStartSeconds >= 0.001) { const QByteArray startValue = QByteArray::number(streamStartSeconds, 'f', 3); @@ -1413,7 +1600,29 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau } return; } - const QByteArray loadFileOptions = MpvOptionProfile::loadFileOptions(session); + QByteArray loadFileOptions = MpvOptionProfile::loadFileOptions(session); + if (m_api && !m_networkOrigin.isEmpty()) { + // Resolve seeds per file, not from whichever account was active when + // the speculative core was initialized. + const QVariantMap& profile = m_sessionNetworkProfile; + const auto addOption = [&loadFileOptions](const char *name, const QByteArray& value) { + if (!loadFileOptions.isEmpty()) + loadFileOptions += ','; + loadFileOptions += name; + loadFileOptions += '='; + loadFileOptions += value; + }; + addOption("curl-adaptive", "yes"); + addOption("curl-parallel-requests", "4"); + addOption("curl-initial-requests", + QByteArray::number(std::clamp(profile.value(QStringLiteral("initial-requests"), 1).toInt(), 1, 4))); + addOption( + "curl-latency-ms", QByteArray::number(profile.value(QStringLiteral("latency-ms")).toDouble(), 'f', 3)); + addOption("curl-observed-bps", + QByteArray::number(profile.value(QStringLiteral("capacity-bytes-per-second")).toDouble(), 'f', 0)); + addOption("curl-media-bitrate", QByteArray::number(networkMediaBitrate(), 'f', 0)); + } + m_networkTimer.start(); const char *loadCommand[] = { "loadfile", urlBytes.constData(), "replace", "-1", loadFileOptions.isEmpty() ? nullptr : loadFileOptions.constData(), nullptr }; if (mpv_command(handle, loadCommand) < 0) { @@ -1598,12 +1807,23 @@ void PlayerController::setMediaSegments(const QString& itemId, const std::vector void PlayerController::togglePause() { qInfo() << "player: toggle pause requested"; + if (!m_mpvLifecycle.handle() && m_sessionActive) { + setPaused(!m_paused); + return; + } mpvCommand({ QByteArrayLiteral("no-osd"), QByteArrayLiteral("cycle"), QByteArrayLiteral("pause") }); } void PlayerController::setPaused(bool paused) { qInfo() << "player: pause requested" << paused; + if (!m_mpvLifecycle.handle() && m_sessionActive) { + if (m_paused != paused) { + m_paused = paused; + notifyPlaybackStateChanged(); + } + return; + } mpvCommand({ QByteArrayLiteral("no-osd"), QByteArrayLiteral("set"), QByteArrayLiteral("pause"), paused ? QByteArrayLiteral("yes") : QByteArrayLiteral("no") }); } @@ -1640,6 +1860,8 @@ void PlayerController::resyncForForeground() { if (!platformUsesBackgroundPlaybackPolicy()) return; + m_idleMpvPreparationEnabled = platformIdleMpvPreparationEnabled(); + scheduleIdleMpvPreparation(); if (m_backgroundTeardownTimer.isActive()) { m_backgroundTeardownTimer.stop(); qInfo() << "player: cancelled transient background teardown"; @@ -1790,7 +2012,8 @@ void PlayerController::selectSubtitle(int index) if (!command) return; - mpvCommand(*command); + if (!mpvCommand(*command)) + return; m_tracks.applySubtitleSelection(index); if (!m_tracks.subtitlesEnabled()) { m_window->clearOverlay(); @@ -1879,6 +2102,12 @@ void PlayerController::updateReportedStreamSelection(bool sendProgress) m_volume.load(), m_muted.load()); } +void PlayerController::setQueueState( + const std::vector& queue, const QString& repeatMode, bool shuffled) +{ + m_reporter.setQueue(queue, repeatMode, shuffled); +} + void PlayerController::stepChapter(int delta) { if (!m_tracks.hasChapters()) @@ -1887,6 +2116,7 @@ void PlayerController::stepChapter(int delta) // it says so -- but it is still a seek, and the samples on the way to it // belong to where playback was, not to where it is going. m_positionTracker.beginBlindSeek(); + updateUiPositionTimer(); m_seekWatchdogTimer.start(); if (mpvCommand({ QByteArrayLiteral("add"), QByteArrayLiteral("chapter"), QByteArray::number(delta) })) return; @@ -1909,38 +2139,49 @@ void PlayerController::stop() stopWithReason(QStringLiteral("explicit-stop"), true); } +bool PlayerController::sampleSettledPlaybackPosition() +{ + if (!m_fileLoaded || m_pendingSeek || m_seeking || m_positionTracker.seekInFlight()) + return false; + auto *handle = m_mpvLifecycle.handle(); + double position = 0.0; + double duration = 0.0; + const bool sampledPosition + = handle && mpv_get_property(handle, "time-pos", MPV_FORMAT_DOUBLE, &position) >= 0 && std::isfinite(position); + const bool sampledDuration + = handle && mpv_get_property(handle, "duration", MPV_FORMAT_DOUBLE, &duration) >= 0 && std::isfinite(duration); + if (sampledDuration) + m_positionTracker.setDuration(m_timeline.sourceDuration(duration)); + if (sampledPosition) + m_positionTracker.update(m_timeline.sourceSeconds(position)); + return sampledPosition && sampledDuration; +} + void PlayerController::stopWithReason(const QString& reason, bool explicitStop) { + const quint64 stopGeneration = ++m_playGeneration; Diagnostics::Task task(QStringLiteral("player_stop"), { { QStringLiteral("reason"), reason }, { QStringLiteral("sessionActive"), m_sessionActive } }); qInfo() << "player: stop requested" << reason << "sessionActive" << m_sessionActive; - emit stopRequested(); + emit stopRequested(reason); + if (stopGeneration != m_playGeneration) + return; + m_mpvPreparationContinuation = {}; + destroyIdleMpv("stop"); if (!m_sessionActive) return; // A paused resume may still show its seed, and a playing clock can be a // little ahead of the last observation. Completion uses one native stop // snapshot, never a requested seek or an estimated UI position. - if (explicitStop && m_fileLoaded && !m_pendingSeek && !m_seeking && !m_positionTracker.seekInFlight()) { - auto *handle = m_mpvLifecycle.handle(); - double nativePosition = 0.0; - double nativeDuration = 0.0; - const bool sampledPosition = handle - && mpv_get_property(handle, "time-pos", MPV_FORMAT_DOUBLE, &nativePosition) >= 0 - && std::isfinite(nativePosition); - const bool sampledDuration = handle - && mpv_get_property(handle, "duration", MPV_FORMAT_DOUBLE, &nativeDuration) >= 0 - && std::isfinite(nativeDuration); - if (sampledDuration) - m_positionTracker.setDuration(m_timeline.sourceDuration(nativeDuration)); - if (sampledPosition) - m_positionTracker.update(m_timeline.sourceSeconds(nativePosition)); - if (!sampledPosition || !sampledDuration) - explicitStop = false; - } + if (explicitStop) + explicitStop = sampleSettledPlaybackPosition(); // Drop the UI synchronously so navigation never waits for backend unload. + const quint64 generation = m_mpvEventGeneration; stopProgressReporting(false, false, explicitStop); + if (generation != m_mpvEventGeneration) + return; if (auto *handle = m_mpvLifecycle.handle()) setMpvProperty(handle, "http-header-fields", ""); @@ -2012,6 +2253,8 @@ void PlayerController::setFileAudioDelayMs(int delayMs) return; if (auto *handle = m_mpvLifecycle.handle()) applyMpvRuntimeOption(MpvRuntimeOption::AudioDelay, MpvOptionApplyMode::Runtime, handle); + else + discardPreparedMpvForOptionChange("file audio delay change"); emit fileAudioDelayMsChanged(); emit effectiveAudioDelayMsChanged(); } @@ -2023,6 +2266,8 @@ void PlayerController::setSubtitleDelayMs(int delayMs) return; if (auto *handle = m_mpvLifecycle.handle()) applyMpvRuntimeOption(MpvRuntimeOption::SubtitleDelay, MpvOptionApplyMode::Runtime, handle); + else + discardPreparedMpvForOptionChange("subtitle delay change"); emit subtitleDelayMsChanged(); } @@ -2040,6 +2285,7 @@ void PlayerController::setAudioOutputMode(const QString& mode) void PlayerController::setVolume(int volume) { + m_volumeKnown = true; const int clampedVolume = qBound(0, volume, 100); if (m_volume.load() == clampedVolume) return; @@ -2061,6 +2307,7 @@ void PlayerController::adjustVolume(int delta) } void PlayerController::setMuted(bool muted) { + m_muteKnown = true; if (m_muted.load() == muted) return; m_muted = muted; @@ -2228,6 +2475,43 @@ void PlayerController::setMpvConfigPolicy(const MpvConfigPolicy& policy) discardPreparedMpvForOptionChange("mpv config policy change"); } +double PlayerController::networkMediaBitrate() const +{ + const double bitrate = m_session.playMethod != QLatin1String("Transcode") && m_session.sourceBitrate > 0 + ? static_cast(m_session.sourceBitrate) + : m_networkVideoBitrate + m_networkAudioBitrate; + return std::clamp(bitrate * effectivePlaybackSpeed(), 0.0, 1.0e12); +} + +void PlayerController::updateNetworkTransport() +{ + auto *handle = m_mpvLifecycle.handle(); + if (!handle || !m_sessionActive) + return; + char bitrateKey[] = "media-bitrate"; + char bufferKey[] = "buffer-seconds"; + char pausedKey[] = "paused"; + char *keys[] = { bitrateKey, bufferKey, pausedKey }; + mpv_node values[3] {}; + values[0].format = MPV_FORMAT_DOUBLE; + values[0].u.double_ = networkMediaBitrate(); + values[1].format = MPV_FORMAT_DOUBLE; + values[1].u.double_ = m_networkBufferSeconds; + values[2].format = MPV_FORMAT_FLAG; + values[2].u.flag = m_paused || m_seeking; + mpv_node_list list { 3, values, keys }; + mpv_node control {}; + control.format = MPV_FORMAT_NODE_MAP; + control.u.list = &list; + // Async APIs copy their arguments. Neither operation waits for curl/the core + // while Qt needs the GUI thread for rendering. + mpv_set_property_async(handle, 0, "curl-transport-control", MPV_FORMAT_NODE, &control); + if (!m_networkQueryPending) { + m_networkQueryPending + = mpv_get_property_async(handle, kNetworkMetricsReply, "curl-transport-metrics", MPV_FORMAT_NODE) >= 0; + } +} + void PlayerController::startProgressReporting() { Diagnostics::logEvent(QStringLiteral("player"), QStringLiteral("progress_reporting_start"), @@ -2258,6 +2542,7 @@ void PlayerController::stopProgressReporting(bool failed, bool reachedEnd, bool m_progressTimer.stop(); m_uiPositionTimer.stop(); m_seekWatchdogTimer.stop(); + m_networkTimer.stop(); const auto session = m_session; reachedEnd = reachedEnd && !failed && m_fileLoaded; @@ -2299,6 +2584,7 @@ void PlayerController::setDirectVideoOutput(bool direct) if (m_directVideoOutput == direct) return; m_directVideoOutput = direct; + discardPreparedMpvForOptionChange("video output change"); qInfo() << "player: video output" << (direct ? "direct" : "enhanced"); } @@ -2307,6 +2593,7 @@ void PlayerController::setRenderQuality(MpvOptionProfile::RenderQuality quality) if (m_renderQuality == quality) return; m_renderQuality = quality; + discardPreparedMpvForOptionChange("render quality change"); qInfo() << "player: render quality" << MpvOptionProfile::renderQualityName(quality).constData(); } @@ -2473,7 +2760,7 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ // thread, whose captured handle lives until the event loop is joined, // so GUI synchronization remains available to the renderer. logColorDiagnostics(handle); - postMpvEvent(generation, [this]() { + postMpvEvent(generation, [this, generation]() { m_mpvLifecycle.completeFileLoad(); qInfo() << "player: file loaded"; m_fileLoaded = true; @@ -2494,13 +2781,17 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ stream.title.toUtf8(), stream.language.toUtf8() }); } notifyPlaybackStateChanged(); - startProgressReporting(); + if (generation == m_mpvEventGeneration && m_sessionActive) + startProgressReporting(); }); break; case MPV_EVENT_PLAYBACK_RESTART: - logColorDiagnostics(handle); postMpvEvent(generation, [this]() { qInfo() << "player: playback restart"; + // The first restart has the final output parameters. Ordinary + // seek completions do not need another synchronous property sweep. + if (!m_seekDispatchReady) + logColorDiagnostics(m_mpvLifecycle.handle()); const bool hadPendingSeek = m_pendingSeek; m_seekDispatchReady = true; if (hadPendingSeek) { @@ -2545,6 +2836,30 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ break; } case MPV_EVENT_GET_PROPERTY_REPLY: { + if (event->reply_userdata == kNetworkMetricsReply) { + const auto *property = static_cast(event->data); + const QVariantMap metrics + = event->error >= 0 && property && property->data && property->format == MPV_FORMAT_NODE + ? networkMetricValue(*static_cast(property->data)).toMap() + : QVariantMap(); + postMpvEvent(generation, [this, metrics] { + m_networkQueryPending = false; + if (metrics.isEmpty()) + return; + m_networkMetrics = metrics; + if (m_api && !m_networkOrigin.isEmpty() && !m_seeking && !m_paused + && m_session.playMethod != QLatin1String("Transcode")) { + QVariantMap sample = metrics; + const quint64 streamId = metrics.value(QStringLiteral("stream-id")).toULongLong(); + sample.insert(QStringLiteral("stream-id"), + QVariant::fromValue((m_mpvEventGeneration << 32) | (streamId & 0xffffffffULL))); + sample.insert(QStringLiteral("observed-at-ms"), QDateTime::currentMSecsSinceEpoch()); + sample.insert(QStringLiteral("media-bitrate"), networkMediaBitrate()); + m_api->updatePlaybackNetworkProfile(m_networkOrigin, sample); + } + }); + break; + } if (event->reply_userdata != kTimePosRefreshReply) break; @@ -2553,13 +2868,30 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ break; const double seconds = *static_cast(property->data); - postMpvEvent(generation, [this, seconds]() { setPositionSeconds(seconds); }); + postMpvEvent(generation, [this, seconds]() { setPositionSeconds(seconds, true); }); break; } case MPV_EVENT_PROPERTY_CHANGE: { auto *property = static_cast(event->data); if (!property || !property->data) break; + if ((strcmp(property->name, "demuxer-cache-duration") == 0 || strcmp(property->name, "video-bitrate") == 0 + || strcmp(property->name, "audio-bitrate") == 0) + && property->format == MPV_FORMAT_DOUBLE) { + const double value = *static_cast(property->data); + const QByteArray name(property->name); + postMpvEvent(generation, [this, name, value] { + if (!std::isfinite(value) || value < 0) + return; + if (name == "demuxer-cache-duration") + m_networkBufferSeconds = value; + else if (name == "video-bitrate") + m_networkVideoBitrate = value; + else + m_networkAudioBitrate = value; + }); + break; + } if (strcmp(property->name, "playlist") == 0) { postMpvEvent(generation, [this] { refreshLocalPlaylist(); }); @@ -2578,6 +2910,13 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ if (strcmp(property->name, "pause") == 0 && property->format == MPV_FORMAT_FLAG) { const bool paused = *static_cast(property->data); postMpvEvent(generation, [this, paused]() { + // Until a file has loaded, mpv's pause is only ever what was + // set or is about to be: an adopted idle core reports its own + // initial state as it is observed, and that report lands + // while setup waits for the surface. Taking it would overwrite + // the requested start (or a pause pressed during setup). + if (!m_fileLoaded) + return; if (m_paused != paused) qInfo() << "player: pause state changed" << paused; m_paused = paused; @@ -2656,6 +2995,7 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ } else if (strcmp(property->name, "volume") == 0 && property->format == MPV_FORMAT_DOUBLE) { const auto volume = static_cast(std::round(*static_cast(property->data))); postMpvEvent(generation, [this, volume]() { + m_volumeKnown = true; const int clampedVolume = qBound(0, volume, 100); if (m_volume.load() == clampedVolume) return; @@ -2681,6 +3021,7 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ } else if (strcmp(property->name, "mute") == 0 && property->format == MPV_FORMAT_FLAG) { const bool muted = *static_cast(property->data); postMpvEvent(generation, [this, muted]() { + m_muteKnown = true; if (m_muted.exchange(muted) != muted) emit volumeChanged(); }); @@ -2789,7 +3130,7 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ // while loading belongs to this request. Clear the pending marker on // both success and failure; otherwise a failed manifest stays stuck in // the preparing state forever. - postMpvEvent(generation, [this, failed, endFileReason, endFileError]() { + postMpvEvent(generation, [this, generation, failed, endFileReason, endFileError]() { if (m_localPlaylist) { if (endFileReason != MPV_END_FILE_REASON_REDIRECT) { const bool completed = PlaybackFailurePolicy::classifyFileEnd(failed, endFileReason, @@ -2801,6 +3142,8 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ emit localEntryEnded( QString::number(m_localEntryId), secondsToTicks(m_positionTracker.position()), completed); } + if (generation != m_mpvEventGeneration) + return; m_localEntryId = -1; m_fileLoaded = false; m_seekDispatchReady = false; @@ -2844,6 +3187,8 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ // against the ending core before emitting it, never its replacement. scheduleMpvTeardown(); stopProgressReporting(failed, completed); + if (generation != m_mpvEventGeneration) + return; if (interrupted) { emit playbackInterrupted(failedItemId, failedPositionTicks, PlaybackFailurePolicy::shouldResumeInterrupted(startSeconds, positionSeconds)); @@ -2857,7 +3202,6 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ } case MPV_EVENT_SHUTDOWN: postMpvEvent(generation, [this]() { - m_mpvLifecycle.requestEventLoopStop(); qInfo() << "player: mpv shutdown"; scheduleMpvTeardown(); if (m_sessionActive) @@ -2899,10 +3243,31 @@ void PlayerController::updatePlaybackStatusText() void PlayerController::notifyPlaybackStateChanged() { + const bool wasAdvancing = m_uiPositionTimer.isActive(); + updateUiPositionTimer(); + // A pause or buffering transition freezes the clock at the latest mpv + // sample. Seek gestures already publish their optimistic target directly. + if (wasAdvancing && !m_uiPositionTimer.isActive() && !m_positionTracker.seekInFlight()) + emit positionChanged(); updatePlaybackStatusText(); emit playbackStateChanged(); } +bool PlayerController::positionAdvancing() const +{ + return m_sessionActive && m_fileLoaded && !m_paused && !m_buffering && !m_seeking + && m_positionTracker.canEstimatePosition(); +} + +void PlayerController::updateUiPositionTimer() +{ + if (!positionAdvancing()) { + m_uiPositionTimer.stop(); + } else if (!m_uiPositionTimer.isActive()) { + m_uiPositionTimer.start(); + } +} + double PlayerController::clampedPosition(double seconds) const { return m_positionTracker.clamp(seconds); @@ -2928,23 +3293,27 @@ void PlayerController::requestMpvPositionRefresh(const char *reason) << mpv_error_string(error); } -void PlayerController::setPositionSeconds(double seconds, bool notifySegments) +void PlayerController::setPositionSeconds(double seconds, bool publishImmediately) { // Tearing down mpv leaves its last position events queued for the main // thread, so they land after play() has reset the tracker for the new item // -- and they are about the item that just ended. Until the new file is // loaded the tracker already holds the requested start position, and // nothing mpv says about the old one is worth hearing. - if (!m_fileLoaded) + if (!m_fileLoaded || !std::isfinite(seconds) || m_positionTracker.seekInFlight()) return; - if (!m_positionTracker.update(m_timeline.sourceSeconds(seconds))) - return; + const bool firstSample = !m_positionTracker.canEstimatePosition(); + const bool changed = m_positionTracker.update(m_timeline.sourceSeconds(seconds)); + updateUiPositionTimer(); - const bool segmentChanged = m_timeline.updatePosition(m_positionTracker.position()); - - emit positionChanged(); - if (notifySegments && segmentChanged) + // Every observation still reaches the tracker and segment boundaries. + // Normal playback publishes its extrapolated UI clock at 250 ms instead + // of also invalidating every QML/JNI consumer for each mpv sample. A first + // sample, explicit refresh, or changed frozen position remains immediate. + if (publishImmediately || firstSample || (changed && !m_uiPositionTimer.isActive())) + emit positionChanged(); + if (m_timeline.updatePosition(m_positionTracker.position())) emit segmentsChanged(); } diff --git a/src/player/PlayerController.h b/src/player/PlayerController.h index c97cd32c..b18de0f3 100644 --- a/src/player/PlayerController.h +++ b/src/player/PlayerController.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,8 @@ #endif #include +#include +#include #include #include @@ -119,6 +122,10 @@ class PlayerController final : public QObject { { return m_containerFps; } + QVariantMap networkMetrics() const + { + return m_networkMetrics; + } bool subtitlesEnabled() const; QStringList subtitleTracks() const; int selectedSubtitleIndex() const; @@ -195,6 +202,9 @@ class PlayerController final : public QObject { Q_INVOKABLE void stop(); Q_INVOKABLE void stopWithReason(const QString& reason, bool explicitStop = false); void setWatchedThresholdPercent(int percent); + // Keeps progress reports truthful about the queue, repeat and shuffle + // while the session plays. + void setQueueState(const std::vector& queue, const QString& repeatMode, bool shuffled); Q_INVOKABLE void setNightModeEnabled(bool enabled); Q_INVOKABLE void setToneMappingVisualizationEnabled(bool enabled); Q_INVOKABLE void setAudioDelayMs(int delayMs); @@ -206,7 +216,10 @@ class PlayerController final : public QObject { void setRenderQuality(MpvOptionProfile::RenderQuality quality); void setHardwareDecoding(bool enabled) { + if (m_hardwareDecoding == enabled) + return; m_hardwareDecoding = enabled; + discardPreparedMpvForOptionChange("hardware decoding change"); } void setSoftwareRenderer(const QByteArray& backend) { @@ -252,7 +265,7 @@ class PlayerController final : public QObject { void trickplayChanged(); void chaptersChanged(); // Also emitted between sessions so Stop cancels pending queue negotiation. - void stopRequested(); + void stopRequested(const QString& reason); void playbackStopped(const QString& itemId, qint64 positionTicks, bool watched, bool reachedEnd, quint64 reportId); void watchedPersistenceRequested(const QString& itemId, quint64 reportId); // The stream ended before the item did. Emitted after playbackStopped, @@ -289,6 +302,8 @@ class PlayerController final : public QObject { private: QHash m_mpvKeys; void playSession(const PlaybackSession& session, bool startPaused, const QList& localFiles); + void finishPlaySession( + const PlaybackSession& session, bool startPaused, const QList& localFiles, quint64 generation); void refreshLocalPlaylist(); void beginLocalEntry(qint64 entryId); bool loadLocalFiles(const QList& urls); @@ -296,7 +311,7 @@ class PlayerController final : public QObject { bool m_localEntryStarted = false; qint64 m_localEntryId = -1; QVariantList m_localPlaylistEntries; - void logColorDiagnostics(mpv_handle *handle); + static void logColorDiagnostics(mpv_handle *handle); bool usesUserMpvConfig() const; int uiTrackIndexForStream(const QString& type, int streamIndex, int firstUiIndex) const; int streamIndexForUiTrack(const QString& type, int uiIndex, int firstUiIndex) const; @@ -319,20 +334,25 @@ class PlayerController final : public QObject { void prepareIdleMpv(); void destroyIdleMpv(const char *reason); mpv_handle *takeIdleMpvHandle(); - bool configureAndInitializeMpv(mpv_handle *handle, bool needsVideoSurface, bool embeddedVideo); + struct MpvPreparation; + void prepareMpvAsync(bool needsVideoSurface, bool embeddedVideo); + static bool configureAndInitializeMpv(mpv_handle *handle, const MpvPreparation& preparation); void observeMpvProperties(mpv_handle *handle); + void updateNetworkTransport(); + double networkMediaBitrate() const; void synchronizeWindowFullscreen(mpv_handle *handle); void scheduleMpvTeardown(); void handleMpvEvent(mpv_event *event, quint64 generation, mpv_handle *handle); template void postMpvEvent(quint64 generation, Callback callback) { QMetaObject::invokeMethod(this, [this, generation, callback = std::move(callback)]() mutable { - if (generation == m_mpvEventGeneration) + if (generation == m_mpvEventGeneration && m_sessionActive) callback(); }); } void startProgressReporting(); void stopProgressReporting(bool failed = false, bool reachedEnd = false, bool explicitStop = false); + bool sampleSettledPlaybackPosition(); bool mpvCommand(QByteArrayList command); bool beginSeekCommand(double targetSeconds, const QByteArray& flags); QByteArrayList buildSeekCommand(double targetSeconds, const QByteArray& flags) const; @@ -341,7 +361,9 @@ class PlayerController final : public QObject { void flushPendingSeek(); void updatePlaybackStatusText(); void notifyPlaybackStateChanged(); - void setPositionSeconds(double seconds, bool notifySegments = true); + void setPositionSeconds(double seconds, bool publishImmediately = false); + bool positionAdvancing() const; + void updateUiPositionTimer(); void requestMpvPositionRefresh(const char *reason); double clampedPosition(double seconds) const; double seekAnchorPosition(); @@ -383,10 +405,26 @@ class PlayerController final : public QObject { MpvLifecycle m_mpvLifecycle; quint64 m_mpvTeardownGeneration = 0; quint64 m_mpvEventGeneration = 0; + quint64 m_playGeneration = 0; mpv_handle *m_idleMpvHandle = nullptr; bool m_idleMpvPreparationScheduled = false; bool m_idleMpvPreparationEnabled = true; + bool m_firstFramePresented = false; + bool m_idleMpvNeedsVideoSurface = true; + bool m_idleMpvEmbeddedVideo = false; + std::shared_ptr m_mpvPreparation; + std::function m_mpvPreparationContinuation; QTimer m_progressTimer; + QTimer m_networkTimer; + QVariantMap m_networkMetrics; + QUrl m_networkOrigin; + QByteArray m_sessionRequestHeaders; + QVariantMap m_sessionNetworkProfile; + QSslCertificate m_sessionTrustCertificate; + bool m_networkQueryPending = false; + double m_networkBufferSeconds = 0; + double m_networkVideoBitrate = 0; + double m_networkAudioBitrate = 0; QTimer m_backGuardTimer; QTimer m_uiPositionTimer; QTimer m_seekWatchdogTimer; @@ -438,6 +476,8 @@ class PlayerController final : public QObject { std::atomic m_subtitleDelayMs = 0; std::atomic m_volume = 100; std::atomic_bool m_muted = false; + bool m_volumeKnown = false; + bool m_muteKnown = false; double m_playbackSpeed = 1.0; double m_syncPlaybackSpeed = 1.0; bool m_syncPlaybackSpeedActive = false; diff --git a/src/provider/Catalog.h b/src/provider/Catalog.h index f6c6bbb6..50f714ad 100644 --- a/src/provider/Catalog.h +++ b/src/provider/Catalog.h @@ -40,6 +40,10 @@ class Catalog { virtual QCoro::Task> fetchNextUpEpisodes(int limit = 24) = 0; virtual QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) = 0; virtual QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) = 0; + // Server-indexed trailers ("trailers") and extras ("extras") for an item, + // as ordinary playable rows. Optional: a source without related media + // returns an empty list. + virtual QCoro::Task> fetchRelatedMedia(QString itemId, QString kind) = 0; virtual QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) = 0; // The top-level libraries the home page and the rail are built from. @@ -47,6 +51,12 @@ class Catalog { // The genres, years and the like a library can be filtered by; empty // when the source offers no filtering. virtual QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) = 0; + // How a name-sorted library, genre or studio listing divides by first + // character, in ascending order. Optional: a source without one returns + // an empty list and the letters are estimated instead. + virtual QCoro::Task> fetchBrowseIndex( + BrowseDescriptor descriptor, QVariantMap queryOptions) + = 0; virtual QCoro::Task> fetchItemsByIds(QStringList itemIds) = 0; // Locally observed user state outranks asynchronously hydrated snapshots diff --git a/src/provider/GroupPlayback.h b/src/provider/GroupPlayback.h index 1ab661f9..62396313 100644 --- a/src/provider/GroupPlayback.h +++ b/src/provider/GroupPlayback.h @@ -37,6 +37,9 @@ class GroupPlayback : public QObject { virtual QCoro::Task publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) = 0; virtual void requestUnpauseWhenReady() = 0; virtual void cancelPendingUnpause() = 0; + // An explicit Stop retires work already accepted for this playback, while + // keeping group membership available for a later group play request. + virtual void cancelPendingPlayback() = 0; virtual void joinGroup(const QString& groupId) = 0; virtual void leaveGroup() = 0; diff --git a/src/provider/PlaybackSource.h b/src/provider/PlaybackSource.h index 3bd800a0..926ce372 100644 --- a/src/provider/PlaybackSource.h +++ b/src/provider/PlaybackSource.h @@ -8,6 +8,7 @@ #include #include #include +#include #include @@ -29,10 +30,29 @@ class PlaybackSource : public QObject { virtual QByteArray mediaRequestHeaders() const = 0; // The origin whose remembered TLS trust decision covers media URLs. virtual QUrl mediaOrigin() const = 0; - // How many parallel range requests the network profile allows per stream. + // Hard range-request cap; the learned initial count lives in the profile. virtual int playbackParallelRequests() const = 0; + // Native-only transport learning. Rates are bytes/s; media bitrate is bits/s. + // {initial-requests:1..4, latency-ms, capacity-bytes-per-second, + // measured-bitrate:bits/s, concurrency-samples:[{requests, capacity-bytes-per-second, + // latency-ms, updated-at-ms}]} contains only fresh observations. + // Feedback is curl-transport-metrics plus observed-at-ms (UTC epoch ms) + // and media-bitrate (bits/s). Only non-cache-blocked concurrency samples + // with increasing stream-id/capacity-samples are learned. + virtual QVariantMap playbackNetworkProfile() const + { + return {}; + } + // origin is captured with the load, never inferred from a later account. + virtual void updatePlaybackNetworkProfile(const QUrl&, const QVariantMap&) { } // Whether reports and follow-up lookups can be made right now. virtual bool signedIn() const = 0; + // Reporting retries must retain the source instance that accepted playback, + // rather than route an old session through a replacement account runtime. + virtual PlaybackSource *reportingContext(const QString&) + { + return this; + } // Turns an item into something mpv can open: the URL, its streams, and // where to start. `forceTranscode` asks a source that can re-encode to diff --git a/src/provider/PortableProvider.cpp b/src/provider/PortableProvider.cpp index aa98c221..47030f62 100644 --- a/src/provider/PortableProvider.cpp +++ b/src/provider/PortableProvider.cpp @@ -121,7 +121,16 @@ class PortableProvider::Playback final : public PlaybackSource { } int playbackParallelRequests() const override { - return m_owner->m_playbackContext.value(QStringLiteral("parallelRequests"), 2).toInt(); + return 4; + } + QVariantMap playbackNetworkProfile() const override + { + return m_owner->m_registry->playbackNetworkProfile(m_owner->m_accountId, m_origin); + } + void updatePlaybackNetworkProfile(const QUrl& origin, const QVariantMap& metrics) override + { + if (origin == m_origin) + m_owner->m_registry->updatePlaybackNetworkProfile(m_owner->m_accountId, origin, metrics); } bool signedIn() const override { @@ -227,6 +236,7 @@ class PortableProvider::Playback final : public PlaybackSource { m_origin = origin; emit credentialsChanged(); } + m_owner->m_registry->rememberPlaybackOrigin(m_owner->m_accountId, m_origin); co_return session; } @@ -275,6 +285,11 @@ class PortableProvider::Playback final : public PlaybackSource { args.insert(QStringLiteral("positionTicks"), QString::number(positionTicks)); args.insert(QStringLiteral("audioStreamIndex"), session.audioStreamIndex); args.insert(QStringLiteral("subtitleStreamIndex"), session.subtitleStreamIndex); + // Empty repeat mode means a group owns queue policy; report no mode. + if (!session.repeatMode.isEmpty()) { + args.insert(QStringLiteral("repeatMode"), session.repeatMode); + args.insert(QStringLiteral("shuffled"), session.shuffled); + } QString queueRevision; const quint64 supportGeneration = m_queueSupportGeneration; if (event != QStringLiteral("stop") @@ -300,9 +315,9 @@ class PortableProvider::Playback final : public PlaybackSource { void PortableProvider::setPlaybackContext(QVariantMap context) { - const int previous = m_playback->playbackParallelRequests(); + const int previous = m_playbackContext.value(QStringLiteral("parallelRequests"), 1).toInt(); m_playbackContext = std::move(context); - if (previous != m_playback->playbackParallelRequests()) + if (previous != m_playbackContext.value(QStringLiteral("parallelRequests"), 1).toInt()) emit m_playback->playbackNetworkProfileChanged(); } @@ -448,17 +463,12 @@ QCoro::Task> PortableProvider::list( throw std::runtime_error("response_limit"); } -QCoro::Task PortableProvider::fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions, std::optional cursor) -{ - startIndex = std::max(0, startIndex); - limit = std::clamp(limit, 1, 100); - QString operation = QStringLiteral("browse"); - QVariantMap args; - switch (descriptor.kind) { - case BrowseKind::Library: - case BrowseKind::Genre: - case BrowseKind::Studio: +namespace { + // A library, genre or studio listing: sorting travels on its own and every + // other option is one of the viewer's filters (sdk/provider.d.ts BrowseFilters). + QVariantMap filteredBrowseArguments(const BrowseDescriptor& descriptor, QVariantMap queryOptions) + { + QVariantMap args; if (!descriptor.id.isEmpty() && descriptor.kind == BrowseKind::Library) args.insert(QStringLiteral("parentId"), descriptor.id); args.insert(QStringLiteral("collectionType"), descriptor.collectionType); @@ -466,14 +476,28 @@ QCoro::Task PortableProvider::fetchBrowsePage( args.insert(QStringLiteral("genre"), descriptor.name); if (descriptor.kind == BrowseKind::Studio) args.insert(QStringLiteral("studio"), descriptor.name); - // Sorting travels on its own; every other option is one of the - // viewer's filters (sdk/provider.d.ts BrowseFilters). for (const char *key : { "sortBy", "sortOrder" }) { if (const QString name = QLatin1String(key); queryOptions.contains(name)) args.insert(name, queryOptions.take(name)); } if (!queryOptions.isEmpty()) args.insert(QStringLiteral("filters"), queryOptions); + return args; + } +} + +QCoro::Task PortableProvider::fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions, std::optional cursor) +{ + startIndex = std::max(0, startIndex); + limit = std::clamp(limit, 1, 100); + QString operation = QStringLiteral("browse"); + QVariantMap args; + switch (descriptor.kind) { + case BrowseKind::Library: + case BrowseKind::Genre: + case BrowseKind::Studio: + args = filteredBrowseArguments(descriptor, std::move(queryOptions)); break; case BrowseKind::FolderChildren: case BrowseKind::BoxSet: @@ -499,13 +523,46 @@ QCoro::Task PortableProvider::fetchBrowsePage( co_return PagedMovieItems { {}, 0, startIndex, limit }; } + // A row number stands in for the cursor only when the caller jumps; the + // caller knows from an earlier page that this listing is seekable. + if (!cursor && startIndex > 0) + args.insert(QStringLiteral("offset"), startIndex); ProviderMediaPage page = co_await listPage(operation, std::move(args), limit, std::move(cursor)); if (!page.exhausted && (!page.cursor || page.cursor->isEmpty())) throw std::runtime_error("invalid_pagination"); const int count = static_cast(page.items.size()); const int total = page.total ? static_cast(*page.total) : startIndex + count + (page.exhausted ? 0 : 1); - co_return PagedMovieItems { std::move(page.items), total, startIndex, limit, std::move(page.cursor), - page.exhausted }; + PagedMovieItems result { std::move(page.items), total, startIndex, limit, std::move(page.cursor), page.exhausted }; + result.seekable = page.seekable && operation == QLatin1String("browse"); + co_return result; +} + +QCoro::Task> PortableProvider::fetchBrowseIndex( + BrowseDescriptor descriptor, QVariantMap queryOptions) +{ + if (descriptor.kind != BrowseKind::Library && descriptor.kind != BrowseKind::Genre + && descriptor.kind != BrowseKind::Studio) + co_return {}; + QVariantMap args = filteredBrowseArguments(descriptor, std::move(queryOptions)); + args.remove(QStringLiteral("sortBy")); + args.remove(QStringLiteral("sortOrder")); + QVariantMap result; + try { + result = co_await call(QStringLiteral("browseIndex"), args); + } catch (const std::exception& error) { + if (QByteArray(error.what()) != "unsupported_operation") + throw; + co_return {}; + } + std::vector entries; + for (const QVariant& value : result.value(QStringLiteral("entries")).toList()) { + const QVariantMap row = value.toMap(); + const QString label = row.value(QStringLiteral("label")).toString().left(8); + const int count = row.value(QStringLiteral("count")).toInt(); + if (!label.isEmpty() && count > 0 && entries.size() < 256) + entries.push_back({ label, count }); + } + co_return entries; } QCoro::Task PortableProvider::fetchItemDetails(QString itemId) @@ -551,6 +608,24 @@ QCoro::Task> PortableProvider::fetchSimilarItems(QString return list(QStringLiteral("similar"), { { QStringLiteral("itemId"), itemId } }, limit); } +QCoro::Task> PortableProvider::fetchRelatedMedia(QString itemId, QString kind) +{ + std::vector rows = co_await list(QStringLiteral("relatedMedia"), + { { QStringLiteral("itemId"), itemId }, { QStringLiteral("kind"), kind } }, 100); + // Related items play through the same authenticated negotiation as the + // main feature. Ignore placeholders and duplicate entries in this shelf. + std::vector items; + items.reserve(rows.size()); + QSet seen; + for (MovieItem& row : rows) { + if (row.id.isEmpty() || row.id == itemId || !isPlayableItem(row) || seen.contains(row.id)) + continue; + seen.insert(row.id); + items.push_back(std::move(row)); + } + co_return items; +} + QCoro::Task PortableProvider::fetchItemsByPerson(QString personId, int maximumItems) { PersonCredits credits; diff --git a/src/provider/PortableProvider.h b/src/provider/PortableProvider.h index fa5480d2..15926781 100644 --- a/src/provider/PortableProvider.h +++ b/src/provider/PortableProvider.h @@ -90,9 +90,12 @@ class PortableProvider final : public Provider, QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task> fetchRelatedMedia(QString itemId, QString kind) override; QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; QCoro::Task> fetchLibraries() override; QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; + QCoro::Task> fetchBrowseIndex( + BrowseDescriptor descriptor, QVariantMap queryOptions) override; QCoro::Task> fetchItemsByIds(QStringList itemIds) override; QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; diff --git a/src/provider/ProviderMediaPage.cpp b/src/provider/ProviderMediaPage.cpp index 0ef511a8..363a89ad 100644 --- a/src/provider/ProviderMediaPage.cpp +++ b/src/provider/ProviderMediaPage.cpp @@ -300,6 +300,8 @@ ProviderMediaPage readProviderMediaPage(const QJSValue& value, int maximumItems) const QJSValue total = value.property(QStringLiteral("total")); if (!absent(total)) result.total = integer(total); + const QJSValue seekable = value.property(QStringLiteral("seekable")); + result.seekable = boolean(seekable) && result.total.has_value(); result.items.reserve(length); for (quint32 i = 0; i < length; ++i) result.items.push_back(reader.item(rows.property(i))); diff --git a/src/provider/ProviderMediaPage.h b/src/provider/ProviderMediaPage.h index 6ad51854..82006ca5 100644 --- a/src/provider/ProviderMediaPage.h +++ b/src/provider/ProviderMediaPage.h @@ -18,6 +18,8 @@ struct ProviderMediaPage { std::optional cursor; std::optional total; bool exhausted = false; + // The listing accepts any start row (`offset`), and `total` is exact. + bool seekable = false; }; namespace Detail { diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index 52529e23..7376cf90 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include +#include #include namespace Spool { @@ -30,6 +32,30 @@ namespace Spool { namespace { const QString kAccountsKey = QStringLiteral("providers/accounts/2"); + constexpr qint64 kNetworkMaxAge = 30LL * 24 * 60 * 60 * 1000; + + QString endpointKey(const QUrl& address) + { + if (!address.isValid() || address.host().isEmpty() + || (address.scheme() != QLatin1String("http") && address.scheme() != QLatin1String("https"))) + return {}; + QUrl endpoint; + endpoint.setScheme(address.scheme().toLower()); + endpoint.setHost(address.host().toLower()); + endpoint.setPort(address.port(address.scheme() == QLatin1String("https") ? 443 : 80)); + return QString::fromLatin1(QCryptographicHash::hash(endpoint.toEncoded(), QCryptographicHash::Sha256).toHex()); + } + + bool networkRate(double rate) + { + // Sanity guard only, not a guessed interface-bandwidth clamp. + return std::isfinite(rate) && rate >= 1024 && rate <= 100.0e9; + } + + bool freshNetworkSample(qint64 timestamp, qint64 now) + { + return timestamp > 0 && timestamp <= now + 5000 && now - timestamp <= kNetworkMaxAge; + } bool requiresSignIn(const std::exception& error) { const std::string_view code(error.what()); @@ -181,10 +207,15 @@ ProviderRegistry::ProviderRegistry(DatabaseManager *database, QObject *parent) , m_database(database) { m_credentialPool.setMaxThreadCount(1); + m_networkSaveTimer.setSingleShot(true); + m_networkSaveTimer.setInterval(30000); + connect(&m_networkSaveTimer, &QTimer::timeout, this, [this] { persist(false); }); } ProviderRegistry::~ProviderRegistry() { + if (m_networkSaveTimer.isActive() && m_database) + persist(false); for (const QString& id : m_running.keys()) stop(id); } @@ -300,6 +331,9 @@ QCoro::Task ProviderRegistry::restore() const QJsonObject root = QJsonDocument::fromJson(stored.toUtf8()).object(); m_activationOptions = root.value(QStringLiteral("activationOptions")).toObject().toVariantMap(); m_startupChoices = root.value(QStringLiteral("startup")).toObject().toVariantMap(); + m_networkProfiles = root.value(QStringLiteral("networkProfiles")).toObject().toVariantMap(); + m_networkEndpoints = root.value(QStringLiteral("networkEndpoints")).toObject().toVariantMap(); + m_lanApprovedModules = root.value(QStringLiteral("lanApproved")).toVariant().toStringList(); std::vector accounts; for (const QJsonValue& row : root.value(QStringLiteral("accounts")).toArray()) { ProviderAccount account = fromJson(row.toObject()); @@ -921,10 +955,210 @@ void ProviderRegistry::persist(bool credentials) QString::fromUtf8( QJsonDocument(QJsonObject { { QStringLiteral("format"), 2 }, { QStringLiteral("accounts"), rows }, { QStringLiteral("activationOptions"), QJsonObject::fromVariantMap(m_activationOptions) }, - { QStringLiteral("startup"), QJsonObject::fromVariantMap(m_startupChoices) } }) + { QStringLiteral("startup"), QJsonObject::fromVariantMap(m_startupChoices) }, + { QStringLiteral("networkProfiles"), QJsonObject::fromVariantMap(m_networkProfiles) }, + { QStringLiteral("networkEndpoints"), QJsonObject::fromVariantMap(m_networkEndpoints) }, + { QStringLiteral("lanApproved"), QJsonArray::fromStringList(m_lanApprovedModules) } }) .toJson(QJsonDocument::Compact))); } +QVariantMap ProviderRegistry::playbackNetworkProfile(const QString& accountId, const QUrl& origin) const +{ + const QString key = origin.isEmpty() ? m_networkEndpoints.value(accountId).toString() : endpointKey(origin); + const QVariantMap saved = m_networkProfiles.value(key).toMap(); + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + QVariantList samples; + double best = 0; + QHash estimates; + // Qt's generated-byte test is a prior, never a replacement for actual + // media delivery measured by curl at the same concurrency. + for (const QString& field : { QStringLiteral("probe-samples"), QStringLiteral("concurrency-samples") }) { + for (const QVariant& value : saved.value(field).toList()) { + const QVariantMap sample = value.toMap(); + const int requests = sample.value(QStringLiteral("requests")).toInt(); + const double capacity = sample.value(QStringLiteral("capacity-bytes-per-second")).toDouble(); + if (requests >= 1 && requests <= 4 && networkRate(capacity) + && freshNetworkSample(sample.value(QStringLiteral("updated-at-ms")).toLongLong(), now)) + estimates.insert(requests, sample); + } + } + for (int requests : { 1, 2, 3, 4 }) { + if (estimates.contains(requests)) { + const auto sample = estimates.value(requests); + samples.append(sample); + best = std::max(best, sample.value(QStringLiteral("capacity-bytes-per-second")).toDouble()); + } + } + int selected = 1; + const bool recentPlayback + = !samples.isEmpty() && freshNetworkSample(saved.value(QStringLiteral("playback-at-ms")).toLongLong(), now); + const double mediaBitrate = recentPlayback ? saved.value(QStringLiteral("media-bitrate")).toDouble() : 0; + if (recentPlayback) + selected = saved.value(QStringLiteral("initial-requests"), 1).toInt(); + if (selected < 1 || selected > 4) + selected = 1; + if (mediaBitrate > 0) { + // Keep substantial headroom without rewarding more sockets for a tiny gain. + for (int requests : { 1, 2, 3, 4 }) { + const auto enough + = std::find_if(samples.cbegin(), samples.cend(), [requests, mediaBitrate](const QVariant& v) { + const auto sample = v.toMap(); + return sample.value(QStringLiteral("requests")).toInt() == requests + && sample.value(QStringLiteral("capacity-bytes-per-second")).toDouble() * 8 + >= mediaBitrate * 1.75; + }); + if (enough != samples.cend()) { + selected = requests; + break; + } + } + } + double capacity = 0; + double latency = 0; + for (const QVariant& value : samples) { + const auto sample = value.toMap(); + if (sample.value(QStringLiteral("requests")).toInt() == selected) { + capacity = sample.value(QStringLiteral("capacity-bytes-per-second")).toDouble(); + latency = sample.value(QStringLiteral("latency-ms")).toDouble(); + } + } + return { { QStringLiteral("initial-requests"), selected }, { QStringLiteral("latency-ms"), latency }, + { QStringLiteral("capacity-bytes-per-second"), capacity }, + { QStringLiteral("measured-bitrate"), qint64(best * 8 * 0.75) }, + { QStringLiteral("concurrency-samples"), samples } }; +} + +void ProviderRegistry::rememberPlaybackOrigin(const QString& accountId, const QUrl& origin) +{ + const QString key = endpointKey(origin); + if (key.isEmpty() || !sourceRunning(accountId) || !accountOriginAllowed(accountId, origin)) + return; + if (m_networkEndpoints.value(accountId).toString() != key) { + m_networkEndpoints.insert(accountId, key); + persist(false); + } +} + +bool ProviderRegistry::updatePlaybackNetworkProfile( + const QString& accountId, const QUrl& origin, const QVariantMap& metrics) +{ + const QString key = endpointKey(origin); + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const qint64 observed = metrics.value(QStringLiteral("observed-at-ms")).toLongLong(); + const quint64 stream = metrics.value(QStringLiteral("stream-id")).toULongLong(); + const quint64 count = metrics.value(QStringLiteral("capacity-samples")).toULongLong(); + const int requests = metrics.value(QStringLiteral("selected-requests")).toInt(); + const auto previous = m_networkFeedback.value(key); + if (key.isEmpty() || !sourceRunning(accountId) || !accountOriginAllowed(accountId, origin) || observed <= 0 + || observed > now + 5000 || now - observed > 5 * 60 * 1000 || !stream || !count || stream < previous.first + || (stream == previous.first && count <= previous.second) || requests < 1 || requests > 4) + return false; + // Only curl's unthrottled capacity observations are accepted; lifetime + // average/current rates include cache backpressure and must not enter here. + QVariantMap saved = m_networkProfiles.value(key).toMap(); + QHash points; + for (const QVariant& value : saved.value(QStringLiteral("concurrency-samples")).toList()) { + const auto point = value.toMap(); + if (freshNetworkSample(point.value(QStringLiteral("updated-at-ms")).toLongLong(), now)) + points.insert(point.value(QStringLiteral("requests")).toInt(), point); + } + auto seen = stream == previous.first ? m_networkPointSamples.value(key) : QHash(); + bool changed = false; + const QVariantList incoming = metrics.value(QStringLiteral("concurrency-samples")).toList(); + if (incoming.isEmpty() || incoming.size() > 4) + return false; + for (const QVariant& value : incoming) { + const auto point = value.toMap(); + const int lanes = point.value(QStringLiteral("requests")).toInt(); + const double rate = point.value(QStringLiteral("capacity-bytes-per-second")).toDouble(); + const double latency = point.value(QStringLiteral("latency-ms")).toDouble(); + const quint64 samples = point.value(QStringLiteral("samples")).toULongLong(); + if (lanes < 1 || lanes > 4 || !networkRate(rate) || !std::isfinite(latency) || latency < 0 || latency > 60000 + || !samples) + return false; + if (samples <= seen.value(lanes)) + continue; + const double old = points.value(lanes).value(QStringLiteral("capacity-bytes-per-second")).toDouble(); + points.insert(lanes, + { { QStringLiteral("requests"), lanes }, + { QStringLiteral("capacity-bytes-per-second"), networkRate(old) ? old * 0.5 + rate * 0.5 : rate }, + { QStringLiteral("latency-ms"), latency }, { QStringLiteral("updated-at-ms"), now } }); + seen.insert(lanes, samples); + changed = true; + } + if (!changed) + return false; + QVariantList merged; + for (int lanes : { 1, 2, 3, 4 }) + if (points.contains(lanes)) + merged.append(points.value(lanes)); + saved.insert(QStringLiteral("concurrency-samples"), merged); + saved.insert(QStringLiteral("initial-requests"), requests); + saved.insert(QStringLiteral("playback-at-ms"), now); + const double bitrate = metrics.value(QStringLiteral("media-bitrate")).toDouble(); + if (std::isfinite(bitrate) && bitrate > 0 && bitrate <= 800.0e9) + saved.insert(QStringLiteral("media-bitrate"), bitrate); + m_networkProfiles.insert(key, saved); + m_networkFeedback.insert(key, { stream, count }); + m_networkPointSamples.insert(key, seen); + rememberPlaybackOrigin(accountId, origin); + // Bound the persistent history even when a provider resolves many mirrors. + while (m_networkProfiles.size() > 64) { + auto oldest = m_networkProfiles.begin(); + for (auto it = m_networkProfiles.begin(); it != m_networkProfiles.end(); ++it) + if (it.value().toMap().value(QStringLiteral("playback-at-ms")).toLongLong() + < oldest.value().toMap().value(QStringLiteral("playback-at-ms")).toLongLong()) + oldest = it; + m_networkFeedback.remove(oldest.key()); + m_networkPointSamples.remove(oldest.key()); + m_networkProfiles.erase(oldest); + } + if (!m_networkSaveTimer.isActive()) + m_networkSaveTimer.start(); + return true; +} + +bool ProviderRegistry::recordSpeedTest(const QString& accountId, const QVariantMap& result) +{ + const QUrl origin(result.value(QStringLiteral("origin")).toString()); + const QString key = endpointKey(origin); + if (key.isEmpty() || !accountOriginAllowed(accountId, origin)) + return false; + const QVariantList input = result.value(QStringLiteral("concurrency-samples")).toList(); + if (input.size() != 3) + return false; + QVariantList samples; + int expected = 1; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + for (const QVariant& value : input) { + const auto sample = value.toMap(); + const double rate = sample.value(QStringLiteral("capacity-bytes-per-second")).toDouble(); + const double latency = sample.value(QStringLiteral("latency-ms")).toDouble(); + if (sample.value(QStringLiteral("requests")).toInt() != expected || !networkRate(rate) + || !std::isfinite(latency) || latency < 0 || latency > 60000) + return false; + samples.append(QVariantMap { { QStringLiteral("requests"), expected }, + { QStringLiteral("capacity-bytes-per-second"), rate }, { QStringLiteral("latency-ms"), latency }, + { QStringLiteral("updated-at-ms"), now } }); + expected *= 2; + } + auto saved = m_networkProfiles.value(key).toMap(); + saved.insert(QStringLiteral("probe-samples"), samples); + m_networkProfiles.insert(key, saved); + while (m_networkProfiles.size() > 64) { + auto victim = m_networkProfiles.begin(); + if (victim.key() == key) + ++victim; + m_networkFeedback.remove(victim.key()); + m_networkPointSamples.remove(victim.key()); + m_networkProfiles.erase(victim); + } + if (!m_networkEndpoints.contains(accountId)) + rememberPlaybackOrigin(accountId, origin); + persist(false); + return true; +} + QCoro::Task ProviderRegistry::install( ProviderPackageContents package, std::function admission, std::function progress) { @@ -932,12 +1166,21 @@ QCoro::Task ProviderRegistry::install( if (root.isEmpty()) throw std::runtime_error("installs_disabled"); const QString id = package.manifest.id; + // Admission spans the request: a newer install or removal supersedes this + // one before its bytes can commit, while the turn below keeps commits and + // directory removal in request order. + const quint64 revision = ++m_installRevisions[id]; + QPromise turn; + turn.start(); + const QFuture previous = std::exchange(m_installTails[id], turn.future()); + const auto releaseTurn = qScopeGuard([&turn] { turn.finish(); }); + if (previous.isValid()) + co_await qCoro(previous).result(); if (admission && !admission()) throw std::runtime_error("install_cancelled"); if (const auto *existing = module(id); existing && ProviderPackage::compareVersions(package.manifest.version, existing->manifest.version) <= 0) throw std::runtime_error("install_version_not_newer"); - const quint64 revision = ++m_installRevisions[id]; QPointer guard(this); // Only inert files are written in the worker. Admission and activation // share the registry thread, so a concurrent replacement cannot slip @@ -967,8 +1210,17 @@ QCoro::Task ProviderRegistry::install( QCoro::Task ProviderRegistry::uninstall(QString moduleId) { - clearGrants(moduleId); + // A removal request retires older pending installs immediately, while the + // turn keeps its directory removal behind any earlier commit and ahead of + // any newer install's activation. ++m_installRevisions[moduleId]; + QPromise turn; + turn.start(); + const QFuture previous = std::exchange(m_installTails[moduleId], turn.future()); + const auto releaseTurn = qScopeGuard([&turn] { turn.finish(); }); + if (previous.isValid()) + co_await qCoro(previous).result(); + clearGrants(moduleId); const ProviderModule *existing = module(moduleId); if (!existing || (existing->bundled && !existing->overridesBundled) || existing->native) co_return; @@ -1466,6 +1718,18 @@ QObject *ProviderRegistry::beginSetup(const QString& moduleId, const QString& ac setupConfiguration = { { QStringLiteral("setupContext"), setupContext }, { QStringLiteral("setupAccount"), existingAccount->configuration } }; } + // What is already added from this provider, so its sign-in can say so + // (a server already in Spool) instead of adding it twice. Identities + // only; never another account's configuration. + QVariantList existing; + for (const ProviderAccount& added : m_accounts) { + if (added.module == moduleId) + existing.push_back( + QVariantMap { { QStringLiteral("account"), added.key }, { QStringLiteral("group"), added.group }, + { QStringLiteral("label"), added.label }, { QStringLiteral("detail"), added.detail } }); + } + setupContext.insert(QStringLiteral("existing"), existing); + setupConfiguration.insert(QStringLiteral("setupContext"), setupContext); if (!owner->manifest.needsAccount()) { const auto existing = std::find_if( m_accounts.begin(), m_accounts.end(), [&](const auto& account) { return account.module == moduleId; }); @@ -1645,8 +1909,15 @@ QCoro::Task ProviderRegistry::allowLanDiscovery(QString draftId, QString s const quint64 generation = running->generation; const quint64 revision = running->networkRevision; QPointer guard(this); - if (!co_await requestNetworkConsent(draftId, scope, QStringLiteral("lan"))) - throw std::runtime_error("discovery_denied"); + const QString moduleId = running->module; + if (!m_lanApprovedModules.contains(moduleId)) { + if (!co_await requestNetworkConsent(draftId, scope, QStringLiteral("lan"))) + throw std::runtime_error("discovery_denied"); + if (!guard) + co_return; + m_lanApprovedModules.append(moduleId); + persist(); + } if (!guard || m_running.value(draftId).generation != generation || m_running.value(draftId).networkRevision != revision) throw std::runtime_error("source_changed"); @@ -1875,6 +2146,7 @@ void ProviderRegistry::removeAccount(const QString& accountId) emit accountIdentityRevoked(accountId); } stop(accountId); + m_networkEndpoints.remove(accountId); if (const auto *removed = account(accountId)) { const ProviderAccount gone = *removed; const QString set = profileSet(gone); diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 5594eac6..39856810 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -134,6 +135,10 @@ class ProviderRegistry final : public QObject { return m_device.value(QStringLiteral("id")).toString(); } QVariantMap activationConfiguration(const QString& accountId) const; + QVariantMap playbackNetworkProfile(const QString& accountId, const QUrl& origin = {}) const; + void rememberPlaybackOrigin(const QString& accountId, const QUrl& origin); + bool updatePlaybackNetworkProfile(const QString& accountId, const QUrl& origin, const QVariantMap& metrics); + bool recordSpeedTest(const QString& accountId, const QVariantMap& result); QVariantList modules() const; QVariantList accounts() const; @@ -280,6 +285,9 @@ class ProviderRegistry final : public QObject { ScriptRuntime::NetworkHooks m_hooks; QHash m_modules; QHash m_installRevisions; + // Installs and removals serialize per module so a pending directory + // removal can never land on top of a newer explicit install. + QHash> m_installTails; std::vector m_accounts; QHash m_running; QHash m_runtimeSources; @@ -295,6 +303,15 @@ class ProviderRegistry final : public QObject { quint64 m_nextGeneration = 0; quint64 m_nextCapabilityCall = 0; QVariantMap m_networkConsent; + // Only endpoint hashes and bounded numeric observations, never URLs or credentials. + QVariantMap m_networkProfiles; + QVariantMap m_networkEndpoints; + // Providers the viewer has let search the local network once; a later + // sign-in with the same provider is not asked again. + QStringList m_lanApprovedModules; + QHash> m_networkFeedback; + QHash> m_networkPointSamples; + QTimer m_networkSaveTimer; QSet m_removingAccounts; std::shared_ptr> m_consentPromise; QString m_consentSource; diff --git a/src/provider/ScriptBridge.cpp b/src/provider/ScriptBridge.cpp index e4d7367d..bbf0a5f6 100644 --- a/src/provider/ScriptBridge.cpp +++ b/src/provider/ScriptBridge.cpp @@ -156,6 +156,28 @@ namespace { } #endif + // Qt hands a QVariantList to script as a sequence wrapper, not an Array, + // and a provider that passes it back unchanged returns an object keyed + // "0", "1"... Results promised as arrays are built as plain values. + QJSValue plainScriptValue(QJSEngine *engine, const QVariant& value) + { + if (value.metaType().id() == QMetaType::QVariantList) { + const QVariantList list = value.toList(); + QJSValue array = engine->newArray(static_cast(list.size())); + for (qsizetype index = 0; index < list.size(); ++index) + array.setProperty(static_cast(index), plainScriptValue(engine, list.at(index))); + return array; + } + if (value.metaType().id() == QMetaType::QVariantMap) { + const QVariantMap map = value.toMap(); + QJSValue object = engine->newObject(); + for (auto it = map.cbegin(); it != map.cend(); ++it) + object.setProperty(it.key(), plainScriptValue(engine, it.value())); + return object; + } + return engine->toScriptValue(value); + } + void rejectWith(QJSEngine *engine, QJSValue& reject, const char *code) { reject.call({ engine->toScriptValue(QString::fromLatin1(code)) }); @@ -450,7 +472,7 @@ void ScriptRequests::speedTest(const QVariantMap& options, QJSValue resolve, QJS return rejectWith(m_access->engine, reject, "request_denied"); auto *test = new SpeedTest( m_access, - [this, resolve, reject](QString error, qint64 bitrate, int parallelRequests) mutable { + [this, resolve, reject](QString error, QVariantMap result) mutable { SpeedTest *test = std::exchange(m_speedTest, nullptr); m_pending.remove(test); test->deleteLater(); @@ -459,10 +481,7 @@ void ScriptRequests::speedTest(const QVariantMap& options, QJSValue resolve, QJS reject.call({ engine->toScriptValue(error) }); return; } - QJSValue result = engine->newObject(); - result.setProperty(QStringLiteral("bitrate"), static_cast(bitrate)); - result.setProperty(QStringLiteral("parallelRequests"), parallelRequests); - resolve.call({ result }); + resolve.call({ plainScriptValue(engine, result) }); }, this); m_speedTest = test; diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp index c925d5b6..bf54a5f7 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -111,6 +111,14 @@ namespace { rank = std::min(rank, matchRank(folded(item.seriesName), query) + 2); return std::min(rank, 4); } + + // An account Home may draw from: switched on, signed in and not leaving. + bool homeEligible(const QVariantMap& account) + { + return account.value(QStringLiteral("enabled")).toBool() && !account.value(QStringLiteral("locked")).toBool() + && !account.value(QStringLiteral("needsSignIn")).toBool() + && !account.value(QStringLiteral("removing")).toBool(); + } } // namespace struct SourceHub::SearchRun { @@ -270,12 +278,25 @@ class SourceHub::Playback final : public PlaybackSource { } int playbackParallelRequests() const override { - return active() ? active()->playbackParallelRequests() : 2; + return active() ? active()->playbackParallelRequests() : 4; + } + QVariantMap playbackNetworkProfile() const override + { + return active() ? active()->playbackNetworkProfile() : QVariantMap(); + } + void updatePlaybackNetworkProfile(const QUrl& origin, const QVariantMap& metrics) override + { + if (PlaybackSource *playback = active()) + playback->updatePlaybackNetworkProfile(origin, metrics); } bool signedIn() const override { return m_hub->signedIn(); } + PlaybackSource *reportingContext(const QString& itemId) override + { + return sourceFor(itemId); + } QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode) override { @@ -298,6 +319,7 @@ class SourceHub::Playback final : public PlaybackSource { item.seriesId = rawId(item.seriesId); item.seasonId = rawId(item.seasonId); item.albumId = rawId(item.albumId); + m_hub->pushPlaybackContext(); PlaybackSession session = co_await playback->resolvePlayback(std::move(item), forceTranscode); session.itemId = m_hub->scoped(account, session.itemId); auto& entry = m_hub->m_entries[prefixOf(account)]; @@ -516,6 +538,12 @@ void SourceHub::addSource(Provider *provider) const QString accountId = provider->id(); const bool browse = accountEnabled(accountId); m_entries.insert(prefixOf(accountId), { accountId, provider, browse }); + const auto profile = m_registry->playbackNetworkProfile(accountId); + auto& restored = m_entries[prefixOf(accountId)]; + restored.measuredBitrate = profile.value(QStringLiteral("measured-bitrate")).toLongLong(); + restored.parallelRequests = profile.value(QStringLiteral("initial-requests"), 1).toInt(); + if (restored.measuredBitrate > 0) + restored.speedState = Entry::SpeedState::Complete; connect(provider, &Provider::capabilitiesChanged, this, [this, accountId, provider] { if (!provider->capabilities().testFlag(Provider::SpeedTest)) { if (m_speedTestAccount == accountId) @@ -524,7 +552,7 @@ void SourceHub::addSource(Provider *provider) if (entry != m_entries.end()) { entry->speedState = Entry::SpeedState::Pending; entry->measuredBitrate = 0; - entry->parallelRequests = 2; + entry->parallelRequests = 1; } pushPlaybackContext(); } @@ -700,47 +728,57 @@ std::vector SourceHub::sources() const QVariantList SourceHub::homeProviderChoices() const { - QVariantList choices { QVariantMap { - { QStringLiteral("id"), QString() }, { QStringLiteral("name"), QStringLiteral("All providers") } } }; + QHash profiles; + for (const QVariant& value : m_registry->accounts()) { + const QVariantMap account = value.toMap(); + if (homeEligible(account)) + ++profiles[account.value(QStringLiteral("moduleId")).toString()]; + } + QVariantList choices; for (const QVariant& value : m_registry->modules()) { const QVariantMap module = value.toMap(); - choices.push_back(QVariantMap { { QStringLiteral("id"), module.value(QStringLiteral("id")) }, + const QString id = module.value(QStringLiteral("id")).toString(); + const ProviderModule *installed = m_registry->module(id); + choices.push_back(QVariantMap { { QStringLiteral("id"), id }, { QStringLiteral("name"), module.value(QStringLiteral("name")) }, { QStringLiteral("version"), module.value(QStringLiteral("version")) }, - { QStringLiteral("iconUrl"), module.value(QStringLiteral("iconUrl")) } }); + { QStringLiteral("iconUrl"), module.value(QStringLiteral("iconUrl")) }, + { QStringLiteral("profiles"), installed && !installed->failed ? profiles.value(id) : 0 } }); } return choices; } -QVariantMap SourceHub::homeProviderStatus(const QString& preferredModuleId) const +QVariantMap SourceHub::homeProviderStatus(const QStringList& hiddenModuleIds) const { - QString effective = preferredModuleId; + QStringList effective = hiddenModuleIds; QString message; - if (!preferredModuleId.isEmpty()) { - const ProviderModule *module = m_registry->module(preferredModuleId); - bool eligible = false; + if (!hiddenModuleIds.isEmpty()) { + bool shownEligible = false; + bool hiddenEligible = false; bool disconnected = false; for (const QVariant& value : m_registry->accounts()) { const QVariantMap account = value.toMap(); - if (account.value(QStringLiteral("moduleId")).toString() != preferredModuleId - || !account.value(QStringLiteral("enabled")).toBool() - || account.value(QStringLiteral("locked")).toBool() - || account.value(QStringLiteral("needsSignIn")).toBool() - || account.value(QStringLiteral("removing")).toBool()) + const QString moduleId = account.value(QStringLiteral("moduleId")).toString(); + const ProviderModule *module = m_registry->module(moduleId); + if (!module || module->failed || !homeEligible(account)) continue; - eligible = true; + if (hiddenModuleIds.contains(moduleId)) { + hiddenEligible = true; + continue; + } + shownEligible = true; disconnected = disconnected || !account.value(QStringLiteral("running")).toBool(); } - if (!module || module->failed || !eligible) { + if (!shownEligible && hiddenEligible) { effective.clear(); - message = QStringLiteral("Your selected Home provider is unavailable or has no authorized active profile. " - "Showing All providers. Choose a provider or reconnect in Sources."); - } else if (homeSources({ preferredModuleId }).empty() && disconnected) { - message = QStringLiteral("Your selected Home provider is temporarily disconnected. " - "Your preference is retained; reconnect in Sources or choose All providers."); + message = QStringLiteral("None of the providers turned on for Home has a signed-in profile, so Home is " + "showing all providers. Turn one on or reconnect in Profiles & servers."); + } else if (shownEligible && disconnected && homeSources({ hiddenModuleIds }).empty()) { + message = QStringLiteral("The providers turned on for Home are temporarily disconnected. " + "Reconnect in Profiles & servers or turn on another provider."); } } - return { { QStringLiteral("moduleId"), effective }, { QStringLiteral("message"), message } }; + return { { QStringLiteral("hiddenModuleIds"), effective }, { QStringLiteral("message"), message } }; } void SourceHub::updateHomeAccounts() @@ -751,9 +789,7 @@ void SourceHub::updateHomeAccounts() const QVariantMap account = value.toMap(); const QString id = account.value(QStringLiteral("id")).toString(); m_homeAccountModules.insert(id, account.value(QStringLiteral("moduleId")).toString()); - if (!account.value(QStringLiteral("enabled")).toBool() || account.value(QStringLiteral("locked")).toBool() - || account.value(QStringLiteral("needsSignIn")).toBool() - || account.value(QStringLiteral("removing")).toBool()) + if (!homeEligible(account)) m_homeUnavailableAccounts.insert(id); } } @@ -764,7 +800,7 @@ std::vector SourceHub::homeSources(const HomeQuery& query) const std::erase_if(selected, [&](Provider *provider) { const QString account = provider->id(); return m_homeUnavailableAccounts.contains(account) - || (!query.moduleId.isEmpty() && m_homeAccountModules.value(account) != query.moduleId); + || query.hiddenModuleIds.contains(m_homeAccountModules.value(account)); }); return selected; } @@ -776,7 +812,7 @@ bool SourceHub::containsHomeItem(const HomeQuery& query, const QString& scopedId const auto entry = m_entries.constFind(scopedId.left(kPrefix)); return entry != m_entries.cend() && entry->provider && entry->browse && !m_homeUnavailableAccounts.contains(entry->accountId) - && (query.moduleId.isEmpty() || m_homeAccountModules.value(entry->accountId) == query.moduleId); + && !query.hiddenModuleIds.contains(m_homeAccountModules.value(entry->accountId)); } QString SourceHub::homeScopeKey(const HomeQuery& query) const @@ -958,6 +994,13 @@ void SourceHub::runItemAction(const QString& actionId, const QString& itemId, co QVariantMap args) -> QCoro::Task { QPointer guard(self); QPointer sourceGuard(self->source(account)); + const auto permitted = [](const QVariantList& actions, const QString& requested) { + return std::any_of(actions.begin(), actions.end(), [&](const QVariant& value) { + const auto action = value.toMap(); + return action.value(QStringLiteral("id")).toString() == requested + && action.value(QStringLiteral("enabled"), true).toBool(); + }); + }; const auto actions = co_await self->fetchItemActions(scopedItemId, args.value(QStringLiteral("itemType")).toString(), scopedContainerId, args.value(QStringLiteral("entryId")).toString(), QStringLiteral("item-action")); @@ -966,12 +1009,7 @@ void SourceHub::runItemAction(const QString& actionId, const QString& itemId, co if (!sourceGuard || sourceGuard != self->source(account) || !self->accountEnabled(account)) throw std::runtime_error("source_unavailable"); const auto requested = args.value(QStringLiteral("action")).toString(); - const bool permitted = std::any_of(actions.begin(), actions.end(), [&](const QVariant& value) { - const auto action = value.toMap(); - return action.value(QStringLiteral("id")).toString() == requested - && action.value(QStringLiteral("enabled"), true).toBool(); - }); - if (!permitted) + if (!permitted(actions, requested)) throw std::runtime_error("action_unavailable"); QVariantMap result = co_await self->call(account, QStringLiteral("runItemAction"), args); if (guard && result.contains(QStringLiteral("pick"))) { @@ -982,6 +1020,17 @@ void SourceHub::runItemAction(const QString& actionId, const QString& itemId, co if (!sourceGuard || sourceGuard != self->source(account) || !self->accountEnabled(account)) throw std::runtime_error("source_unavailable"); args.insert(choice); + // Permission may have been revoked while the picker was open; the + // second execution repeats the same scoped policy check. + const auto refreshed + = co_await self->fetchItemActions(scopedItemId, args.value(QStringLiteral("itemType")).toString(), + scopedContainerId, args.value(QStringLiteral("entryId")).toString(), QStringLiteral("item-action")); + if (!guard) + co_return; + if (!sourceGuard || sourceGuard != self->source(account) || !self->accountEnabled(account)) + throw std::runtime_error("source_unavailable"); + if (!permitted(refreshed, requested)) + throw std::runtime_error("action_unavailable"); result = co_await self->call(account, QStringLiteral("runItemAction"), args); } if (!guard) @@ -1151,8 +1200,9 @@ void SourceHub::pushPlaybackContext() context.insert(QStringLiteral("videoPreviews"), m_videoPreviewsEnabled); for (const Entry& entry : std::as_const(m_entries)) { if (auto *portable = qobject_cast(entry.provider.data())) { - context.insert(QStringLiteral("measuredBitrate"), entry.measuredBitrate); - context.insert(QStringLiteral("parallelRequests"), entry.parallelRequests); + const auto learned = m_registry->playbackNetworkProfile(entry.accountId); + context.insert(QStringLiteral("measuredBitrate"), learned.value(QStringLiteral("measured-bitrate"))); + context.insert(QStringLiteral("parallelRequests"), learned.value(QStringLiteral("initial-requests"), 1)); portable->setPlaybackContext(context); portable->setPlaybackQueueContext( m_queueSnapshots.value(entry.accountId), m_queueIndexes.value(entry.accountId, -1)); @@ -1178,10 +1228,9 @@ void SourceHub::setPlaybackActive(bool active) if (m_playbackActive == active) return; m_playbackActive = active; - // A started bounded probe is allowed to finish; cancelling it here made - // every quick Play leave Auto permanently without a measurement. + // A probe competes with playback and cannot be learned as clean capacity. if (active) - m_speedTestTimer.stop(); + cancelSpeedTest(); else if (m_speedTestAccount.isEmpty()) m_speedTestTimer.start(5000); emit streamingQualityChanged(); @@ -1199,7 +1248,7 @@ void SourceHub::refreshSpeedTests() void SourceHub::startNextSpeedTest() { - if ((m_playbackActive && !m_explicitSpeedTest) || !m_speedTestAccount.isEmpty()) + if (m_playbackActive || !m_speedTestAccount.isEmpty()) return; for (Provider *provider : sources()) { if (!provider->capabilities().testFlag(Provider::SpeedTest)) @@ -1218,14 +1267,12 @@ void SourceHub::startNextSpeedTest() m_speedTestAccount.clear(); auto entry = m_entries.find(prefixOf(account)); if (entry != m_entries.end()) { - const qint64 bitrate = result.value(QStringLiteral("bitrate")).toLongLong(); - const int lanes = result.value(QStringLiteral("parallelRequests")).toInt(); - const bool valid - = bitrate >= 1'000'000 && bitrate <= 1'000'000'000 && (lanes == 1 || lanes == 2 || lanes == 4); + const bool valid = m_registry->recordSpeedTest(account, result); + const auto profile = m_registry->playbackNetworkProfile(account); entry->speedState = valid ? Entry::SpeedState::Complete : Entry::SpeedState::Failed; if (valid) { - entry->measuredBitrate = bitrate; - entry->parallelRequests = lanes; + entry->measuredBitrate = profile.value(QStringLiteral("measured-bitrate")).toLongLong(); + entry->parallelRequests = profile.value(QStringLiteral("initial-requests"), 1).toInt(); pushPlaybackContext(); } } @@ -1362,6 +1409,19 @@ QCoro::Task SourceHub::fetchBrowsePage( co_return page; } +QCoro::Task> SourceHub::fetchBrowseIndex( + BrowseDescriptor descriptor, QVariantMap queryOptions) +{ + QString account = accountOf(descriptor.id); + if (account.isEmpty()) + account = m_lastDetailsAccount; + Provider *provider = source(account); + if (!provider) + co_return {}; + descriptor.id = rawId(descriptor.id); + co_return co_await provider->catalog()->fetchBrowseIndex(std::move(descriptor), std::move(queryOptions)); +} + QCoro::Task SourceHub::fetchItemDetails(QString itemId) { const QString account = accountOf(itemId); @@ -1420,6 +1480,15 @@ QCoro::Task> SourceHub::fetchSimilarItems(QString itemId, co_return scopedItems(co_await provider->catalog()->fetchSimilarItems(rawId(itemId), limit), account); } +QCoro::Task> SourceHub::fetchRelatedMedia(QString itemId, QString kind) +{ + const QString account = accountOf(itemId); + Provider *provider = source(account); + if (!provider) + co_return {}; + co_return scopedItems(co_await provider->catalog()->fetchRelatedMedia(rawId(itemId), kind), account); +} + QCoro::Task SourceHub::fetchItemsByPerson(QString personId, int maximumItems) { const QString account = accountOf(personId); diff --git a/src/provider/SourceHub.h b/src/provider/SourceHub.h index d18492d6..a0c92e9b 100644 --- a/src/provider/SourceHub.h +++ b/src/provider/SourceHub.h @@ -109,10 +109,10 @@ class SourceHub final : public Provider, // Home has its own query scope; it never changes enabled accounts or // the sources used by browsing, search, playback and remote control. struct HomeQuery { - QString moduleId; // Empty means every currently authorized browsing source. + QStringList hiddenModuleIds; // Empty means every currently authorized browsing source. }; QVariantList homeProviderChoices() const; - QVariantMap homeProviderStatus(const QString& preferredModuleId) const; + QVariantMap homeProviderStatus(const QStringList& hiddenModuleIds) const; bool containsHomeItem(const HomeQuery& query, const QString& scopedId) const; QString homeScopeKey(const HomeQuery& query) const; QCoro::Task> fetchHomeResumeItems(HomeQuery query, int limit = 24); @@ -163,9 +163,12 @@ class SourceHub final : public Provider, QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task> fetchRelatedMedia(QString itemId, QString kind) override; QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; QCoro::Task> fetchLibraries() override; QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; + QCoro::Task> fetchBrowseIndex( + BrowseDescriptor descriptor, QVariantMap queryOptions) override; QCoro::Task> fetchItemsByIds(QStringList itemIds) override; // SearchSource: the selected viewer on every independent server, ranked @@ -224,7 +227,7 @@ class SourceHub final : public Provider, enum class SpeedState { Pending, Running, Complete, Failed }; SpeedState speedState = SpeedState::Pending; qint64 measuredBitrate = 0; - int parallelRequests = 2; + int parallelRequests = 1; struct RemotePreview { QString urlTemplate; QByteArray headers; diff --git a/src/provider/SpeedTest.cpp b/src/provider/SpeedTest.cpp index 942908ba..8895a1b1 100644 --- a/src/provider/SpeedTest.cpp +++ b/src/provider/SpeedTest.cpp @@ -43,11 +43,29 @@ void SpeedTest::finish(QString error, qint64 bitrate, int lanes) m_done = true; abort(); auto completion = std::move(m_completion); - completion(std::move(error), bitrate, lanes); + QVariantMap result; + if (error.isEmpty()) { + QVariantList samples; + for (int i = 0; i < 3; ++i) + samples.append(QVariantMap { { QStringLiteral("requests"), 1 << i }, + { QStringLiteral("capacity-bytes-per-second"), m_rates[i] / 8 }, + { QStringLiteral("latency-ms"), m_latencies[i] }, { QStringLiteral("elapsed-seconds"), m_durations[i] }, + { QStringLiteral("received-bytes"), 4 * 1024 * 1024 } }); + QUrl endpoint; + endpoint.setScheme(m_request.url().scheme()); + endpoint.setHost(m_request.url().host()); + endpoint.setPort(m_request.url().port()); + result = { { QStringLiteral("bitrate"), bitrate }, { QStringLiteral("parallelRequests"), lanes }, + { QStringLiteral("concurrency-samples"), samples }, { QStringLiteral("origin"), endpoint.toString() } }; + } + completion(std::move(error), std::move(result)); } void SpeedTest::start(const QVariantMap& options) { + const double target = options.value(QStringLiteral("mediaBitrate")).toDouble(); + if (std::isfinite(target) && target > 0 && target <= 800.0e9) + m_targetBitrate = target; const QVariant address = options.value(QStringLiteral("url")); if (address.metaType().id() != QMetaType::QString) return finish(QStringLiteral("request_denied")); @@ -191,8 +209,8 @@ bool SpeedTest::drain(int index) const qint64 bytes = lane.reply->read(m_buffer.data(), m_buffer.size()); if (bytes <= 0) break; - if (m_round == -1 && m_firstByteMs < 0) - m_firstByteMs = m_elapsed.elapsed(); + if (lane.firstByteNs < 0) + lane.firstByteNs = m_elapsed.nsecsElapsed(); lane.received += bytes; if (lane.received > m_expected) { finish(QStringLiteral("response_limit")); @@ -221,22 +239,30 @@ void SpeedTest::finished(int index) if (m_round >= 0) { const qint64 nanoseconds = std::max(1, m_elapsed.nsecsElapsed()); m_rates[m_round] = static_cast(m_expected * m_count) * 8.0e9 / nanoseconds; + m_durations[m_round] = nanoseconds / 1.0e9; + double latency = 0; + for (int i = 0; i < m_count; ++i) + latency += std::max(0, m_lanes[i].firstByteNs) / 1.0e6; + m_latencies[m_round] = latency / m_count; } if (++m_round == 0) return round(1, 4 * 1024 * 1024); if (m_round == 1) return round(2, 4 * 1024 * 1024); - // Use warmup time-to-first-byte in place of a provider-specific RTT request. - // Otherwise retain the old >=20 ms or >=10% dual-lane gain criterion. - if (m_round == 2 && (m_firstByteMs >= 20 || m_rates[1] >= m_rates[0] * 1.10)) + // Always measure every operating point. The controller needs the complete + // curve to identify negative scaling, not just the winner of a race. + if (m_round == 2) return round(4, 4 * 1024 * 1024); - const double best = *std::max_element(m_rates.cbegin(), m_rates.cend()); int selected = 0; - while (selected < 2 && m_rates[selected] < best * 0.85) - ++selected; - // The ceiling belongs to the selected lane count, not the faster count - // that was deliberately rejected as an insignificant improvement. - const qint64 bitrate = std::llround(std::clamp(m_rates[selected] * 0.75, 1.0e6, 1.0e9)); + if (m_targetBitrate > 0) { + while (selected < 2 && m_rates[selected] < m_targetBitrate * 1.75) + ++selected; + if (m_rates[selected] < m_targetBitrate * 1.75) + selected = 0; + } + // Without playback demand, one lane is the neutral startup choice; the + // full measured curve is preserved for the adaptive controller. + const qint64 bitrate = std::llround(*std::max_element(m_rates.cbegin(), m_rates.cend()) * 0.75); finish({}, bitrate, 1 << selected); } diff --git a/src/provider/SpeedTest.h b/src/provider/SpeedTest.h index 64b3a602..32b50f02 100644 --- a/src/provider/SpeedTest.h +++ b/src/provider/SpeedTest.h @@ -19,7 +19,7 @@ struct ScriptAccess; // the owning ScriptOperation supplies the shared 15-second deadline. class SpeedTest final : public QObject { public: - using Completion = std::function; + using Completion = std::function; SpeedTest(ScriptAccess *access, Completion completion, QObject *parent); ~SpeedTest() override; void start(const QVariantMap& options); @@ -29,6 +29,7 @@ class SpeedTest final : public QObject { QNetworkReply *reply = nullptr; qint64 received = 0; bool responseChecked = false; + qint64 firstByteNs = -1; }; void round(int lanes, qint64 totalBytes); bool drain(int lane); @@ -44,15 +45,17 @@ class SpeedTest final : public QObject { QNetworkRequest m_request; std::array m_lanes; std::array m_rates {}; + std::array m_latencies {}; + std::array m_durations {}; std::array m_buffer; QElapsedTimer m_elapsed; qint64 m_expected = 0; - qint64 m_firstByteMs = -1; int m_count = 0; int m_remaining = 0; int m_round = -1; bool m_done = false; bool m_range = false; + double m_targetBitrate = 0; }; } // namespace Spool diff --git a/src/providers/local/LocalProvider.cpp b/src/providers/local/LocalProvider.cpp index f8c54641..bc01fd67 100644 --- a/src/providers/local/LocalProvider.cpp +++ b/src/providers/local/LocalProvider.cpp @@ -333,6 +333,7 @@ QCoro::Task LocalProvider::fetchBrowsePage( page.exhausted = end >= page.totalRecordCount; if (!page.exhausted) page.nextCursor = QString::number(end); + page.seekable = wholeLibrary; } co_return page; } @@ -393,6 +394,11 @@ QCoro::Task> LocalProvider::fetchSimilarItems(QString, in co_return {}; } +QCoro::Task> LocalProvider::fetchRelatedMedia(QString, QString) +{ + co_return {}; +} + QCoro::Task LocalProvider::fetchItemsByPerson(QString, int) { co_return {}; @@ -400,6 +406,11 @@ QCoro::Task LocalProvider::fetchItemsByPerson(QString, int) QCoro::Task> LocalProvider::fetchLibraries() { + // The offline source is always registered so a download can appear the + // moment it finishes, but with nothing downloaded there is no library to + // offer. Local files are a library only once someone chooses a folder. + if (m_roots.isEmpty() && m_downloadedFiles.isEmpty()) + co_return {}; LibraryItem library; library.id = QLatin1String(kLibraryId); library.name = m_libraryName; @@ -407,6 +418,11 @@ QCoro::Task> LocalProvider::fetchLibraries() co_return std::vector { library }; } +QCoro::Task> LocalProvider::fetchBrowseIndex(BrowseDescriptor, QVariantMap) +{ + co_return {}; +} + QCoro::Task LocalProvider::fetchLibraryFilterOptions(QString, QString) { co_return {}; diff --git a/src/providers/local/LocalProvider.h b/src/providers/local/LocalProvider.h index 219ace44..43778547 100644 --- a/src/providers/local/LocalProvider.h +++ b/src/providers/local/LocalProvider.h @@ -77,9 +77,12 @@ class LocalProvider final : public Provider, QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task> fetchRelatedMedia(QString itemId, QString kind) override; QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; QCoro::Task> fetchLibraries() override; QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; + QCoro::Task> fetchBrowseIndex( + BrowseDescriptor descriptor, QVariantMap queryOptions) override; QCoro::Task> fetchItemsByIds(QStringList itemIds) override; QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; QCoro::Task> fetchSearchSuggestions(int limit = 20) override; diff --git a/tests/TestRunner.cpp b/tests/TestRunner.cpp index 3f3f654d..e800333b 100644 --- a/tests/TestRunner.cpp +++ b/tests/TestRunner.cpp @@ -619,6 +619,11 @@ int main(int argc, char **argv) #endif std::signal(SIGABRT, abortAsOsCrash); #endif + // Bundled QML carries reproducible resource timestamps, so the persistent + // QML cache cannot tell an edited test or component from the last build's + // and a direct run executes stale code. CTest sets this too; setting it + // here covers every way the binary is started, selectors included. + qputenv("QML_DISABLE_DISK_CACHE", "1"); if (!initializeDiagnosticLog(argc, argv)) return 2; if (!initializeReceipt(argc, argv)) @@ -643,6 +648,16 @@ int main(int argc, char **argv) supervisor |= std::string_view(argv[index]) == "--run-all"; list |= std::string_view(argv[index]) == "--list"; } +#if defined(SPOOL_E2E_BINARY) && defined(Q_OS_LINUX) && !defined(SPOOL_ANDROID) + // GUI selectors open real windows and drive input. Never on the person's + // desktop: inside tools/test-gpu-session.sh, or explicitly offscreen. + if (!list && qEnvironmentVariable("SPOOL_PRIVATE_TEST_SESSION") != QLatin1String("1") + && qEnvironmentVariable("QT_QPA_PLATFORM").section(QLatin1Char(':'), 0, 0) != QLatin1String("offscreen")) { + std::cerr << "spool-e2e-tests runs only on a private display: use nix run .#tests, ctest, " + "tools/test-gpu-session.sh, or QT_QPA_PLATFORM=offscreen\n"; + return 2; + } +#endif if (supervisor || list) { QCoreApplication app(argc, argv); if (list) { diff --git a/tests/app/ArtworkConcurrencyTest.cpp b/tests/app/ArtworkConcurrencyTest.cpp new file mode 100644 index 00000000..f8b0dbfa --- /dev/null +++ b/tests/app/ArtworkConcurrencyTest.cpp @@ -0,0 +1,198 @@ +#include "app/ArtworkConcurrency.h" + +#include "TestMain.h" + +#include + +#include +#include +#include + +using Spool::ArtworkConcurrency; + +namespace { + +void require(bool condition, const char *message) +{ + if (condition) + return; + qCritical() << message; + std::exit(EXIT_FAILURE); +} + +// A server `roundTripMs` away whose image scaler runs `workers` requests at +// once for `serviceMs` each, first come first served, asked for `demand` +// posters a screen. Returns how long the last screen took to fill. +struct Server { + double roundTripMs = 0; + double serviceMs = 0; + int workers = 1; + int demand = 1; +}; + +double fillScreens(ArtworkConcurrency& control, const Server& server, int screens) +{ + double now = 0; + double lastFill = 0; + std::vector freeAt(static_cast(server.workers), 0.0); + for (int screen = 0; screen < screens; ++screen) { + const double began = now; + int pending = server.demand; + struct Fetch { + double done; + double started; + int peakOpen; + }; + std::vector open; + while (pending > 0 || !open.empty()) { + while (pending > 0 && static_cast(open.size()) < control.limit()) { + --pending; + auto worker = std::min_element(freeAt.begin(), freeAt.end()); + const double start = std::max(now + server.roundTripMs / 2, *worker); + *worker = start + server.serviceMs; + open.push_back({ *worker + server.roundTripMs / 2, now, 0 }); + for (Fetch& fetch : open) + fetch.peakOpen = std::max(fetch.peakOpen, static_cast(open.size())); + } + const auto first = std::min_element( + open.begin(), open.end(), [](const Fetch& a, const Fetch& b) { return a.done < b.done; }); + const Fetch fetch = *first; + open.erase(first); + now = fetch.done; + control.record(now - fetch.started, fetch.peakOpen); + } + lastFill = now - began; + now += 1000; + } + return lastFill; +} + +// The viewer scrolls on every `dwellMs` before the screen has filled, then +// stops. Cancelled fetches report nothing, but the server still does their +// work, so whatever was sent past its capacity is a queue the final screen +// waits behind. Returns how long that final screen took. +double scrollThenStop(ArtworkConcurrency& control, const Server& server, int scrolls, double dwellMs) +{ + double now = 0; + std::vector freeAt(static_cast(server.workers), 0.0); + struct Fetch { + double done; + double started; + int peakOpen; + }; + for (int scroll = 0;; ++scroll) { + const bool last = scroll == scrolls; + const double began = now; + const double leaveAt = last ? 1e18 : now + dwellMs; + int pending = server.demand; + std::vector open; + while (pending > 0 || !open.empty()) { + while (pending > 0 && static_cast(open.size()) < control.limit()) { + --pending; + auto worker = std::min_element(freeAt.begin(), freeAt.end()); + const double start = std::max(now + server.roundTripMs / 2, *worker); + *worker = start + server.serviceMs; + open.push_back({ *worker + server.roundTripMs / 2, now, 0 }); + for (Fetch& fetch : open) + fetch.peakOpen = std::max(fetch.peakOpen, static_cast(open.size())); + } + const auto first = std::min_element( + open.begin(), open.end(), [](const Fetch& a, const Fetch& b) { return a.done < b.done; }); + if (first->done > leaveAt) { + now = leaveAt; + break; + } + const Fetch fetch = *first; + open.erase(first); + now = fetch.done; + control.record(now - fetch.started, fetch.peakOpen); + } + if (last) + return now - began; + } +} + +// The fastest any fixed number of downloads fills a screen of this server. +double bestFixedFill(const Server& server) +{ + double best = 1e18; + for (int limit = 1; limit <= ArtworkConcurrency::kMaximum; ++limit) { + ArtworkConcurrency fixed; + fixed.setFixed(limit); + best = std::min(best, fillScreens(fixed, server, 2)); + } + return best; +} + +} // namespace + +SPOOL_TEST_MAIN("artwork-concurrency") +{ + // A distant server starts above the LAN default before any poster lands. + ArtworkConcurrency lan; + lan.seed(1); + ArtworkConcurrency distant; + distant.seed(120); + require(lan.limit() == ArtworkConcurrency::kDefault, "a LAN starts at the default"); + require(distant.limit() > lan.limit(), "a long round trip starts with more in flight"); + + // Only far away: every extra request is free, so the limit covers the + // screen and fills it nearly as fast as anything could. + const Server far { 120, 15, 64, 28 }; + ArtworkConcurrency latencyBound; + latencyBound.seed(120); + const double farFill = fillScreens(latencyBound, far, 12); + require(latencyBound.limit() >= 24, "a latency-bound server is given as many as the screen wants"); + require(farFill <= bestFixedFill(far) * 1.25, "a latency-bound screen fills close to the best possible"); + + // A busy scaler: beyond six at once requests only queue. The limit stays + // above six, so the server never idles, and well short of the maximum. + const Server busy { 10, 300, 6, 40 }; + ArtworkConcurrency capacityBound; + capacityBound.seed(10); + const double busyFill = fillScreens(capacityBound, busy, 12); + require(capacityBound.limit() >= 6 && capacityBound.limit() <= 16, "a capacity-bound server is kept just busy"); + require(busyFill <= bestFixedFill(busy) * 1.15, "a capacity-bound screen fills close to the best possible"); + + // Both at once, as a Plex server over the internet is: slower per image + // with more open, but far faster per screen than a cautious few. + const Server remote { 200, 350, 7, 36 }; + ArtworkConcurrency remoteControl; + remoteControl.seed(217); + const double remoteFill = fillScreens(remoteControl, remote, 12); + ArtworkConcurrency cautious; + cautious.setFixed(4); + require(remoteFill <= bestFixedFill(remote) * 1.2, "a distant busy server fills close to the best possible"); + require(remoteFill * 1.4 < fillScreens(cautious, remote, 2), "slower requests are not mistaken for overload"); + + // Scrolling through that same server: sending past its capacity only + // queues abandoned work, so the screen the viewer stops on must not wait + // much longer than the best fixed choice would have made it. + double bestScrolledFill = 1e18; + for (int limit = 1; limit <= ArtworkConcurrency::kMaximum; ++limit) { + ArtworkConcurrency fixedScroll; + fixedScroll.setFixed(limit); + bestScrolledFill = std::min(bestScrolledFill, scrollThenStop(fixedScroll, remote, 30, 600)); + } + ArtworkConcurrency scrolling; + scrolling.seed(217); + require(scrollThenStop(scrolling, remote, 30, 600) <= bestScrolledFill * 1.3, + "scrolling past posters does not bury the server in abandoned requests"); + + // A network that gets slower is followed rather than read as congestion. + ArtworkConcurrency moved; + fillScreens(moved, { 2, 5, 64, 30 }, 8); + const Server slower { 150, 20, 64, 30 }; + const double movedFill = fillScreens(moved, slower, 12); + require(movedFill <= bestFixedFill(slower) * 1.25, "a uniformly slower network is not mistaken for overload"); + + // A fixed setting is the limit, whatever the measurements say. + ArtworkConcurrency fixed; + fixed.setFixed(4); + fixed.seed(200); + fillScreens(fixed, far, 3); + require(!fixed.automatic() && fixed.limit() == 4, "a fixed limit is not adapted"); + fixed.setFixed(0); + require(fixed.automatic(), "zero returns to automatic"); + return 0; +} diff --git a/tests/app/BrowseSparseListingTest.cpp b/tests/app/BrowseSparseListingTest.cpp new file mode 100644 index 00000000..26ca0c72 --- /dev/null +++ b/tests/app/BrowseSparseListingTest.cpp @@ -0,0 +1,279 @@ +#include "app/BrowseSessionController.h" + +#include "TestMain.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +using Spool::BrowseIndexEntry; +using Spool::BrowseSessionController; +using Spool::LibraryItem; +using Spool::MovieItem; +using Spool::PagedMovieItems; + +namespace { + +void require(bool condition, const char *message) +{ + if (condition) + return; + qCritical() << message; + std::exit(EXIT_FAILURE); +} + +constexpr int kRows = 2500; +constexpr int kBlock = 100; + +// A name-sorted library skewed like a real one (lots of S, no Q or X), with +// plain ASCII titles so the expected order is just a case-insensitive sort. +std::vector libraryTitles(bool descending) +{ + std::mt19937 random(7); + const QString common = QStringLiteral("aabbccddeeffgghhiijkllmmmnoopprrsssssstttuvwwyz0"); + std::vector titles; + for (int i = 0; i < kRows; ++i) { + QString title(1, common.at(static_cast(random() % common.size())).toUpper()); + for (int length = 3 + static_cast(random() % 8); length > 0; --length) + title.append(QChar(u'a' + static_cast(random() % 26))); + titles.push_back(title + QString::number(i)); + } + std::sort(titles.begin(), titles.end(), + [](const QString& a, const QString& b) { return a.compare(b, Qt::CaseInsensitive) < 0; }); + if (descending) + std::reverse(titles.begin(), titles.end()); + return titles; +} + +// Where a file browser lands for typed text: the first title starting with +// it, or failing that the first one past it in listing order. +int expectedRow(const std::vector& titles, const QString& prefix, bool descending) +{ + for (int row = 0; row < static_cast(titles.size()); ++row) { + const QString key = titles[static_cast(row)].toLower(); + if (descending ? (key < prefix || key.startsWith(prefix)) : key >= prefix) + return row; + } + return static_cast(titles.size()) - 1; +} + +MovieItem titleItem(const QString& title, int row) +{ + MovieItem item; + item.id = QStringLiteral("id%1").arg(row); + item.title = title; + item.itemType = QStringLiteral("Movie"); + return item; +} + +PagedMovieItems serverPage(const std::vector& titles, int start) +{ + PagedMovieItems page; + page.startIndex = start; + page.limit = kBlock; + page.totalRecordCount = static_cast(titles.size()); + page.seekable = true; + const int end = std::min(page.totalRecordCount, start + kBlock); + for (int row = start; row < end; ++row) + page.items.push_back(titleItem(titles[static_cast(row)], row)); + page.exhausted = end >= page.totalRecordCount; + if (!page.exhausted) + page.nextCursor = QString::number(end); + return page; +} + +// Stands in for AppController: block requests are answered later, the way +// a network fetch would be, never from inside the signal. +struct FakeServer { + BrowseSessionController& session; + std::vector titles; + std::deque requests; + int served = 0; + + FakeServer(BrowseSessionController& browse, std::vector rows) + : session(browse) + , titles(std::move(rows)) + { + QObject::connect(&session, &BrowseSessionController::blockRequested, &session, + [this](int start) { requests.push_back(start); }); + } + void deliverAll() + { + while (!requests.empty()) { + const int start = requests.front(); + requests.pop_front(); + ++served; + session.applyBlock(start, serverPage(titles, start), session.cacheKey()); + } + } +}; + +void openLibrary(BrowseSessionController& session, const std::vector& titles, bool descending) +{ + LibraryItem library; + library.id = descending ? QStringLiteral("lib-desc") : QStringLiteral("lib"); + library.name = QStringLiteral("Movies"); + library.collectionType = QStringLiteral("movies"); + session.enterLibrary(library, + { { QStringLiteral("sortBy"), QStringLiteral("SortName") }, + { QStringLiteral("sortOrder"), descending ? QStringLiteral("Descending") : QStringLiteral("Ascending") } }); + session.setPage(serverPage(titles, 0), QStringLiteral("cache:") + library.id, false); +} + +// Types `prefix` and runs the seek to completion; the row it settled on. +int seek(FakeServer& server, const QString& prefix, int *fetches) +{ + int settledRow = -1; + const auto connection + = QObject::connect(&server.session, &BrowseSessionController::seekMoved, [&settledRow](int row, bool settled) { + if (settled) + settledRow = row; + }); + const int before = server.served; + require(server.session.seekPrefix(prefix), "name-sorted sparse listing seeks"); + for (int guard = 0; guard < 50 && server.session.seeking(); ++guard) + server.deliverAll(); + QObject::disconnect(connection); + require(!server.session.seeking(), "seek settles"); + *fetches = server.served - before; + return settledRow; +} + +std::vector serverIndex(const std::vector& ascending) +{ + std::vector entries; + for (const QString& title : ascending) { + const QString label = title.at(0).isLetter() ? title.left(1).toUpper() : QStringLiteral("#"); + if (entries.empty() || entries.back().label != label) + entries.push_back({ label, 0 }); + ++entries.back().count; + } + return entries; +} + +} // namespace + +SPOOL_TEST_MAIN("browse-sparse-listing") +{ + QCoreApplication app(argc, argv); + QTemporaryDir settingsDir; + require(settingsDir.isValid(), "temporary settings directory"); + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settingsDir.path()); + QSettings().clear(); + const QStringList prefixes { QStringLiteral("m"), QStringLiteral("ma"), QStringLiteral("st"), QStringLiteral("z"), + QStringLiteral("a"), QStringLiteral("q"), QStringLiteral("wzz") }; + + // A seekable first page with more rows behind it holds only that page. + { + BrowseSessionController session(nullptr); + int indexRequests = 0; + QObject::connect(&session, &BrowseSessionController::indexRequested, [&indexRequests] { ++indexRequests; }); + const auto titles = libraryTitles(false); + FakeServer server(session, titles); + openLibrary(session, titles, false); + require(session.sparse(), "seekable listing is sparse"); + require(session.items()->count() == kRows, "every row is present"); + require(session.items()->loadedCount() == kBlock, "only the first block is held"); + require(!session.hasMore(), "nothing is paged sequentially"); + require(indexRequests == 1, "a name-sorted listing asks for its index"); + // Without the server's counts the far end becomes an anchor. + session.setExactIndex({}, session.cacheKey()); + require(server.requests.size() == 1 && server.requests.back() == (kRows - 1) / kBlock * kBlock, + "the last block is fetched as an anchor"); + server.deliverAll(); + + // Jumping deep asks for the rows on screen first. + session.prefetchVisibleRange(1210, 1245); + require(!server.requests.empty() && server.requests.front() == 1200, "viewport block requested first"); + server.deliverAll(); + require(session.items()->movieAt(1222).title == titles[1222], "block lands on its own rows"); + require(!session.items()->isLoaded(700), "rows far from the viewport stay unloaded"); + + session.items()->updateFavorite(QStringLiteral("id1222"), true); + require(session.items()->movieAt(1222).favorite, "sparse rows take user state updates"); + + // Scrolling through the library keeps memory to the budget. + session.setLoadedRowBudget(400); + for (int first = 300; first < kRows - 40; first += 230) { + session.prefetchVisibleRange(first, first + 35); + server.deliverAll(); + require(session.items()->isLoaded(first) && session.items()->isLoaded(first + 35), + "the viewport is always loaded after its blocks arrive"); + require(session.items()->loadedCount() <= 400 + 2 * kBlock, "loaded rows stay near the budget"); + } + require(session.items()->isLoaded(0), "the opening block is kept for the warm cache"); + + // Typing still finds the first matching title by estimate. + for (const QString& prefix : prefixes) { + int fetches = 0; + const int row = seek(server, prefix, &fetches); + require(row == expectedRow(titles, prefix, false), "estimated seek lands on the first match"); + require(fetches <= 6, "estimated seek loads only a few blocks"); + } + + // Once every row has been seen the estimates are the truth. + session.setLoadedRowBudget(kRows); + for (int first = 0; first < kRows; first += kBlock * 3) { + session.prefetchVisibleRange(first, std::min(kRows - 1, first + kBlock * 3 - 1)); + server.deliverAll(); + } + for (const QVariant& value : session.alphabet()) { + const QVariantMap letter = value.toMap(); + const QString label = letter.value(QStringLiteral("label")).toString(); + const int expected + = expectedRow(titles, label == QLatin1String("#") ? QStringLiteral("0") : label.toLower(), false); + require(letter.value(QStringLiteral("exact")).toBool(), "letters seen on both sides are exact"); + require(letter.value(QStringLiteral("row")).toInt() == expected, "learnt letter starts match the listing"); + } + } + + // The server's counts place every letter, ascending or descending. + for (const bool descending : { false, true }) { + BrowseSessionController session(nullptr); + const auto titles = libraryTitles(descending); + FakeServer server(session, titles); + openLibrary(session, titles, descending); + session.setExactIndex(serverIndex(libraryTitles(false)), session.cacheKey()); + require(server.requests.empty(), "an exact index needs no anchor block"); + require(session.alphabetExact(), "server counts make the index exact"); + for (const QVariant& value : session.alphabet()) { + const QVariantMap letter = value.toMap(); + const int row = letter.value(QStringLiteral("row")).toInt(); + const QString label = letter.value(QStringLiteral("label")).toString(); + require(titles[static_cast(row)].left(1).toUpper() == label + || (label == QLatin1String("#") && !titles[static_cast(row)].at(0).isLetter()), + "an exact letter starts on its first title"); + require(row == 0 || titles[static_cast(row - 1)].left(1).toUpper() != label, + "an exact letter starts no later than its first title"); + } + for (const QString& prefix : prefixes) { + int fetches = 0; + const int row = seek(server, prefix, &fetches); + require(row == expectedRow(titles, prefix, descending), "seek lands on the first match"); + require(fetches <= (prefix.size() == 1 ? 1 : 5), "an exact index seeks a letter in one fetch"); + } + require(session.labelAt(1777) == titles[1777].left(1).toUpper() || !titles[1777].at(0).isLetter(), + "an unloaded row is labelled by the exact index"); + } + + // A listing without positions keeps paging from the top. + { + BrowseSessionController session(nullptr); + const auto titles = libraryTitles(false); + openLibrary(session, titles, false); + PagedMovieItems page = serverPage(titles, 0); + page.seekable = false; + session.setPage(page, session.cacheKey(), false); + require(!session.sparse(), "an unseekable listing stays dense"); + require(session.hasMore(), "an unseekable listing pages sequentially"); + require(!session.seekPrefix(QStringLiteral("m")), "a partial dense listing leaves typing to the page"); + } + return 0; +} diff --git a/tests/app/ContentModelControllerTest.cpp b/tests/app/ContentModelControllerTest.cpp index c8de07bb..49dbc71c 100644 --- a/tests/app/ContentModelControllerTest.cpp +++ b/tests/app/ContentModelControllerTest.cpp @@ -242,6 +242,13 @@ class TestCatalog final : public Catalog, public SearchSource { co_return std::vector {}; } + QCoro::Task> fetchRelatedMedia(QString itemId, QString kind) override + { + Q_UNUSED(itemId); + Q_UNUSED(kind); + co_return std::vector {}; + } + QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override { Q_UNUSED(personId); @@ -332,6 +339,11 @@ class TestCatalog final : public Catalog, public SearchSource { co_return QVariantMap {}; } + QCoro::Task> fetchBrowseIndex(Spool::BrowseDescriptor, QVariantMap) override + { + co_return std::vector {}; + } + QCoro::Task> fetchItemsByIds(QStringList itemIds) override { Q_UNUSED(itemIds); diff --git a/tests/diagnostics/SystemPerformanceMonitorTest.cpp b/tests/diagnostics/SystemPerformanceMonitorTest.cpp index ab300550..457198cc 100644 --- a/tests/diagnostics/SystemPerformanceMonitorTest.cpp +++ b/tests/diagnostics/SystemPerformanceMonitorTest.cpp @@ -22,6 +22,10 @@ SPOOL_TEST_MAIN("system-performance-monitor") { QCoreApplication app(argc, argv); Spool::SystemPerformanceMonitor monitor; + // Sampling runs only while an observer (the diagnostics overlay) is + // registered; the bare monitor holds no thread or counters of its own. + QObject overlayObserver; + monitor.observe(&overlayObserver); std::atomic fakeAudioDecodeTimeNs { 0 }; monitor.setAudioDecodeCpuTimeProvider( [&fakeAudioDecodeTimeNs] { return fakeAudioDecodeTimeNs.fetch_add(1'000'000) + 1'000'000; }); diff --git a/tests/e2e/AppJourneyTest.cpp b/tests/e2e/AppJourneyTest.cpp index 078f14a1..74766d42 100644 --- a/tests/e2e/AppJourneyTest.cpp +++ b/tests/e2e/AppJourneyTest.cpp @@ -376,7 +376,7 @@ class DeviceSession final { const QImage image(screenshot); require(!image.isNull(), "actual mobile normal-startup screenshot was not an image"); const QByteArray text = run(ocr, { screenshot, "stdout", "-l", "eng", "--psm", "11" }, environment); - if (QString::fromUtf8(text).contains("Add a provider", Qt::CaseInsensitive)) { + if (QString::fromUtf8(text).contains("Welcome to Spool", Qt::CaseInsensitive)) { rendered = true; break; } @@ -871,6 +871,19 @@ class Journey final { }, "actual library-card activation did not navigate to its browse page"); } + void openPlaybackSettings() + { + // Navigate the actual transport, including on TV/mobile where there + // is no desktop shortcut. Up saturates at Back; Down reaches the + // timeline and then the actions. Right saturates at Playback settings. + for (int step = 0; step < 3; ++step) + command({ "key", "up" }); + command({ "key", "down" }); + command({ "key", "down" }); + for (int step = 0; step < 12; ++step) + command({ "key", "right" }); + command({ "key", "ok" }); + } void await(const std::function& predicate, const char *message, int timeout = 30000) { QElapsedTimer clock; @@ -1099,13 +1112,15 @@ SPOOL_TEST_MAIN("app-journey") { "successfulLogins", fixture.successfulLogins }, { "rejectedLogins", fixture.rejectedLogins }, { "authenticatedViews", fixture.authenticatedViews }, { "authenticatedBrowse", fixture.authenticatedBrowse }, + { "playedFilterRequests", fixture.playedFilterRequests }, { "authenticatedRequests", fixture.authenticatedRequests }, { "playbackNegotiations", fixture.playbackNegotiations }, { "mediaRequests", fixture.mediaRequests }, { "mediaBytes", fixture.mediaBytes }, { "detailRequests", fixture.detailRequests }, { "detailResponses", fixture.detailResponses }, { "episodeRequests", fixture.episodeRequests }, { "episodeDetailRequests", fixture.episodeDetailRequests }, { "episodeDetailResponses", fixture.episodeDetailResponses }, - { "playbackReports", fixture.reports.size() }, { "unexpectedRequests", fixture.unexpected.size() } }; + { "playedMutations", fixture.playedItems.size() }, { "playbackReports", fixture.reports.size() }, + { "unexpectedRequests", fixture.unexpected.size() } }; QSaveFile diagnostics(root.filePath("fixture-counters.json")); const QByteArray bytes = QJsonDocument(counters).toJson(); const bool saved = diagnostics.open(QIODevice::WriteOnly) @@ -1130,7 +1145,7 @@ SPOOL_TEST_MAIN("app-journey") require(fixture.listening(), "isolated loopback fixture could not listen"); Journey journey(control, environment, root.path()); journey.launch(app, fixture.origin(), deviceKind); - journey.label("Add a provider"); + journey.label("Welcome to Spool"); journey.assertControlBoundaries(); const bool television = deviceKind == "tvos" || (deviceKind == "android" && qEnvironmentVariable("SPOOL_E2E_ANDROID_TV") == "1"); @@ -1156,12 +1171,9 @@ SPOOL_TEST_MAIN("app-journey") journey.command({ "key", "ok" }); journey.await([&] { return fixture.successfulLogins == 1 && fixture.authenticatedViews > 0; }, "login did not activate an authenticated provider library"); - journey.label("Who's watching?"); - journey.click("Journey Viewer"); - journey.label("When Spool starts"); - journey.click("Always use Journey Viewer"); + // A sign-in opens straight into Home: no profile or startup question. journey.await([&] { return journey.state()["route"] == "home"; }, - "watching-profile onboarding did not enter the actual Home route"); + "a finished sign-in did not open the actual Home route by itself"); journey.openLibrary("Journey Library"); journey.await([&] { return fixture.authenticatedBrowse > 0 && !journey.state()["browseLoading"].toBool(); }, "browse did not request the selected library"); @@ -1184,6 +1196,59 @@ SPOOL_TEST_MAIN("app-journey") require(itemId != "journey-movie" && (*episodeRow).toObject()["id"].toString() != "journey-episode", "provider items lost account scoping"); + fixturePhase = QStringLiteral("filter-empty-recovery"); + const int filtersBefore = fixture.playedFilterRequests; + journey.click("Filter"); + journey.click("Status"); + journey.click("Played"); + journey.await( + [&] { + return fixture.playedFilterRequests > filtersBefore && !journey.state()["browseLoading"].toBool() + && journey.command({ "items", "browse" })["count"].toInt(-1) == 0; + }, + "Played filter did not reach the provider and replace the library with its empty result"); + require(journey.state()["route"] == "libraryGrid", "empty filtering abandoned the library route"); + journey.click("Reset"); + journey.await( + [&] { + return !journey.state()["browseLoading"].toBool() + && journey.command({ "items", "browse" })["count"].toInt(-1) == 2; + }, + "filter reset did not restore the server catalogue"); + journey.command({ "key", "back" }); + require(journey.state()["route"] == "libraryGrid", "Back from filters popped the library"); + // No pointer refocus or explicit `open`: a remote must be able to use + // the grid immediately after its empty model was replaced. + journey.command({ "key", "ok" }); + journey.await([&] { return journey.state()["route"] == "itemDetails"; }, + "remote activation after filter reset remained stranded in the dismissed menu"); + journey.label("Journey Film"); + + fixturePhase = QStringLiteral("nested-details-return"); + const int nestedDetailsBefore = fixture.episodeDetailResponses; + journey.label("More Like This", true); + journey.click("Journey Series", true); + journey.await([&] { return fixture.episodeDetailResponses > nestedDetailsBefore; }, + "related-item activation did not fetch the selected episode through the real provider"); + journey.label("Journey Episode"); + journey.command({ "key", "back" }); + require(journey.state()["route"] == "itemDetails", "Back from related details skipped the parent details"); + // Return to the parent heading through ordinary remote navigation; + // its identity must not still be bound to the child details/model. + for (int step = 0; step < 6; ++step) + journey.command({ "key", "up" }); + journey.label("Journey Film"); + journey.command({ "key", "back" }); + journey.await([&] { return journey.state()["route"] == "libraryGrid"; }, + "second Back did not return from retained parent details to the originating library"); + journey.command({ "key", "ok" }); + journey.await([&] { return journey.state()["route"] == "itemDetails"; }, + "returning from nested details lost the library's remote selection"); + journey.label("Journey Film"); + journey.command({ "key", "back" }); + journey.await([&] { return journey.state()["route"] == "libraryGrid"; }, + "reopened movie did not return to the library before downloading"); + // Open and operate the real item-menu/download dialog; list is only an observer. fixturePhase = QStringLiteral("movie-download"); journey.click("Journey Film", false, true); @@ -1496,10 +1561,88 @@ SPOOL_TEST_MAIN("app-journey") && frameHasBands(journey.capture(true), 0); }, "explicit episode restart did not decode the beginning"); - journey.command({ "stop" }); + + // EOF is driven by the real finite media, not a synthetic controller + // signal. The decoded restart and server receipt prove repeat crossed + // QML -> application queue -> mpv -> production provider reporting. + fixturePhase = QStringLiteral("repeat-current-eof"); + journey.openPlaybackSettings(); + journey.click("Repeat"); + journey.click("Repeat current item"); + const qsizetype reportsBeforeRepeat = fixture.reports.size(); + const int negotiationsBeforeRepeat = fixture.playbackNegotiations; + journey.command({ "seek", "88" }); + journey.await([&] { return journey.state()["playback"].toObject()["position"].toDouble() > 87.5; }, + "repeat journey did not reach the end of the paused item"); + journey.command({ "resume" }); + journey.await( + [&] { + return fixture.playbackNegotiations > negotiationsBeforeRepeat + && journey.state()["playback"].toObject()["loaded"].toBool() + && std::any_of(fixture.reports.cbegin() + reportsBeforeRepeat, fixture.reports.cend(), + [](const QJsonObject& report) { + return report["endpoint"] == "/Sessions/Playing" && report["ItemId"] == "journey-episode" + && report["RepeatMode"] == "RepeatOne" + && report["PositionTicks"].toVariant().toLongLong() == 0; + }); + }, + "Repeat current item did not renegotiate and report an actual restart from zero"); + journey.command({ "pause" }); + journey.await( + [&] { + const auto playback = journey.state()["playback"].toObject(); + return playback["loaded"].toBool() && playback["paused"].toBool() + && playback["position"].toDouble() < 8.0 && frameHasBands(journey.capture(true), 0); + }, + "repeated item did not decode its beginning"); + + // After-current has precedence over repeat-one. Leave repeat enabled: + // merely clearing the repeat setting would make this a false positive. + fixturePhase = QStringLiteral("sleep-after-current-eof"); + journey.openPlaybackSettings(); + journey.label("Current item"); + journey.click("Sleep timer"); + journey.click("15 minutes"); + journey.openPlaybackSettings(); + journey.label("remaining"); + journey.click("Sleep timer"); + journey.click("After current item"); + journey.openPlaybackSettings(); + journey.label("After current item"); + journey.label("Current item"); + journey.command({ "key", "back" }); + journey.command({ "seek", "88" }); + journey.await([&] { return journey.state()["playback"].toObject()["position"].toDouble() > 87.5; }, + "sleep journey did not reach the end of the paused item"); + const qsizetype reportsBeforeSleep = fixture.reports.size(); + const int negotiationsBeforeSleep = fixture.playbackNegotiations; + journey.command({ "resume" }); + journey.await( + [&] { + return !journey.state()["playback"].toObject()["active"].toBool() + && std::any_of(fixture.reports.cbegin() + reportsBeforeSleep, fixture.reports.cend(), + [](const QJsonObject& report) { + return report["endpoint"] == "/Sessions/Playing/Stopped" + && report["ItemId"] == "journey-episode" + && report["PositionTicks"].toVariant().toLongLong() >= 880000000; + }); + }, + "After current item did not stop real playback and report the completed item's position"); + QElapsedTimer stoppedWindow; + stoppedWindow.start(); + do { + require(!journey.state()["playback"].toObject()["active"].toBool(), + "sleep completion briefly stopped but repeat restarted playback"); + require(fixture.playbackNegotiations == negotiationsBeforeSleep, + "sleep completion negotiated another item despite After current item"); + turn(); + } while (stoppedWindow.elapsed() < 1500); + require(fixture.playedItems.contains("journey-episode"), + "completed episode never persisted its watched state to the real provider"); require(fixture.unexpected.isEmpty(), "fixture observed unexpected or unauthorized production traffic"); fixturePhase = QStringLiteral("complete"); - std::cout << "app-journey: real login/browse/settings/download/offline/online GPU journey completed\n"; + std::cout << "app-journey: real login/filter/retained-details/settings/download/offline/online/repeat/sleep " + "GPU journey completed\n"; return 0; } catch (const std::exception& error) { if (root.isValid()) { diff --git a/tests/e2e/JellyfinFixture.cpp b/tests/e2e/JellyfinFixture.cpp index 4a2830ee..9d137e5f 100644 --- a/tests/e2e/JellyfinFixture.cpp +++ b/tests/e2e/JellyfinFixture.cpp @@ -314,13 +314,23 @@ void JellyfinFixture::handle(QTcpSocket *socket, const QByteArray& header, const { "CollectionType", "movies" } } } }, { "TotalRecordCount", 1 } }); } else if (method == "GET" && (path == "/Items" || path == "/Users/" + userId + "/Items")) { - if (query.queryItemValue("ParentId") == "journey-library") + const bool libraryBrowse = query.queryItemValue("ParentId") == "journey-library"; + if (libraryBrowse) ++authenticatedBrowse; + bool playedOnly = false; + for (const auto& parameter : query.queryItems()) { + if (parameter.first.compare("filters", Qt::CaseInsensitive) == 0) + playedOnly = parameter.second.split(',').contains("IsPlayed"); + } + if (libraryBrowse && playedOnly) + ++playedFilterRequests; const QStringList ids = query.queryItemValue("Ids").split(',', Qt::SkipEmptyParts); QJsonArray items; - if (ids.isEmpty() || ids.contains(movieId)) + // Both immutable catalogue items are unplayed. Filtering is a server + // response contract; selection, empty-state and focus remain app logic. + if (!playedOnly && (ids.isEmpty() || ids.contains(movieId))) items.append(movie()); - if (ids.isEmpty() || ids.contains(episodeId)) + if (!playedOnly && (ids.isEmpty() || ids.contains(episodeId))) items.append(episode()); json(QJsonObject { { "Items", items }, { "TotalRecordCount", items.size() } }); } else if (method == "GET" @@ -347,13 +357,17 @@ void JellyfinFixture::handle(QTcpSocket *socket, const QByteArray& header, const QJsonArray { QJsonObject { { "Id", "journey-season" }, { "Name", "Season 1" }, { "Type", "Season" }, { "SeriesId", "journey-series" }, { "IndexNumber", 1 } } } }, { "TotalRecordCount", 1 } }); + } else if (method == "GET" && path == "/Items/" + movieId + "/Similar") { + json(QJsonObject { { "Items", QJsonArray { episode() } }, { "TotalRecordCount", 1 } }); } else if (method == "GET" && path == "/Users/" + userId + "/Items/Latest") { json(QJsonArray { movie(), episode() }); } else if (method == "GET" && (path == "/Users/" + userId + "/Items/Resume" || path == "/Shows/NextUp" || path == "/MediaSegments/" + movieId || path == "/MediaSegments/" + episodeId - || path == "/Videos/journey-source/AdditionalParts" || path == "/Items/" + movieId + "/Similar" - || path == "/Items/" + episodeId + "/Similar")) { + || path == "/Videos/journey-source/AdditionalParts" || path == "/Items/" + episodeId + "/Similar" + || path == "/Items/" + movieId + "/LocalTrailers" || path == "/Items/" + movieId + "/SpecialFeatures" + || path == "/Items/" + episodeId + "/LocalTrailers" + || path == "/Items/" + episodeId + "/SpecialFeatures")) { json(QJsonObject { { "Items", QJsonArray {} }, { "TotalRecordCount", 0 } }); } else if (method == "GET" && path == "/Items/Filters") { json(QJsonObject { { "Genres", QJsonArray {} }, { "Years", QJsonArray {} } }); @@ -423,6 +437,13 @@ void JellyfinFixture::handle(QTcpSocket *socket, const QByteArray& header, const unexpected.append("unsupported display preference method"); json(QJsonObject {}, 405); } + } else if (method == "POST" + && (path == "/Users/" + userId + "/PlayedItems/" + movieId + || path == "/Users/" + userId + "/PlayedItems/" + episodeId)) { + playedItems.append(path.section('/', -1)); + // Accept the real mutation but keep detail snapshots stale, as on a + // lagging server. The product owns progress/watched reconciliation. + json(QJsonObject {}); } else if (method == "POST" && (path == "/Sessions/Playing" || path == "/Sessions/Playing/Progress" || path == "/Sessions/Playing/Stopped")) { diff --git a/tests/e2e/JellyfinFixture.h b/tests/e2e/JellyfinFixture.h index 8aa1048f..0d45b97a 100644 --- a/tests/e2e/JellyfinFixture.h +++ b/tests/e2e/JellyfinFixture.h @@ -27,11 +27,13 @@ class JellyfinFixture final { int rejectedLogins = 0; int authenticatedViews = 0; int authenticatedBrowse = 0; + int playedFilterRequests = 0; int authenticatedRequests = 0; int playbackNegotiations = 0; int mediaRequests = 0; qint64 mediaBytes = 0; QList reports; + QStringList playedItems; QStringList unexpected; int detailRequests = 0; int detailResponses = 0; diff --git a/tests/platform/MobileTestFixtures.cpp b/tests/platform/MobileTestFixtures.cpp index e4e50db3..4b5879a6 100644 --- a/tests/platform/MobileTestFixtures.cpp +++ b/tests/platform/MobileTestFixtures.cpp @@ -37,7 +37,6 @@ bool prepareMobileFixtures() return false; } } - qputenv("QML_DISABLE_DISK_CACHE", "1"); extractedRoot = directory.path(); return true; } diff --git a/tests/player/MpvLifecycleTest.cpp b/tests/player/MpvLifecycleTest.cpp new file mode 100644 index 00000000..12921a57 --- /dev/null +++ b/tests/player/MpvLifecycleTest.cpp @@ -0,0 +1,229 @@ +#include "player/MpvLifecycle.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Spool; +using namespace std::chrono_literals; + +// This target links the real MpvLifecycle with these three fake client API +// functions, not libmpv. The fake exposes the races a real idle core cannot +// reliably schedule for a test: wakeup just before waiting, a busy callback, +// and asynchronous teardown overlapping another adoption. +struct mpv_handle { + std::mutex mutex; + std::condition_variable changed; + std::deque pending; + mpv_event event {}; + bool wakePending = false; + bool holdWaitEntry = false; + int waitCalls = 0; + int timedWaits = 0; + int wakeups = 0; + int destroyed = 0; +}; + +mpv_event *mpv_wait_event(mpv_handle *handle, double timeout) +{ + std::unique_lock lock(handle->mutex); + ++handle->waitCalls; + if (timeout >= 0) + ++handle->timedWaits; + handle->changed.notify_all(); + handle->changed.wait(lock, [&] { return !handle->holdWaitEntry; }); + const auto ready = [&] { return handle->wakePending || !handle->pending.empty(); }; + if (timeout < 0) + handle->changed.wait(lock, ready); + else + handle->changed.wait_for(lock, std::chrono::duration(timeout), ready); + handle->event = {}; + if (!handle->pending.empty()) { + handle->event.event_id = handle->pending.front(); + handle->pending.pop_front(); + } + handle->wakePending = false; + return &handle->event; +} + +void mpv_wakeup(mpv_handle *handle) +{ + std::lock_guard lock(handle->mutex); + ++handle->wakeups; + handle->wakePending = true; + handle->changed.notify_all(); +} + +void mpv_terminate_destroy(mpv_handle *handle) +{ + std::lock_guard lock(handle->mutex); + ++handle->destroyed; + handle->changed.notify_all(); +} + +namespace { + +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(EXIT_FAILURE); + } +} + +template void waitFor(mpv_handle& handle, Predicate predicate, const char *message) +{ + std::unique_lock lock(handle.mutex); + require(handle.changed.wait_for(lock, 2s, predicate), message); +} + +void enqueue(mpv_handle& handle, mpv_event_id event) +{ + std::lock_guard lock(handle.mutex); + handle.pending.push_back(event); + handle.changed.notify_all(); +} + +void idleWaitAndSynchronousDestroy() +{ + mpv_handle handle; + MpvLifecycle lifecycle; + std::atomic_int events { 0 }; + require(!lifecycle.adopt(nullptr, {}), "a null handle must be rejected"); + require(lifecycle.adopt(&handle, [&](mpv_event *) { ++events; }), "adoption failed"); + require(!lifecycle.adopt(&handle, {}), "an active lifecycle must reject a second adoption"); + waitFor(handle, [&] { return handle.waitCalls == 1; }, "event loop did not start"); + { + std::unique_lock lock(handle.mutex); + require(!handle.changed.wait_for(lock, 250ms, [&] { return handle.waitCalls > 1; }), + "an idle player must not repeatedly wake to poll"); + require(handle.timedWaits == 0, "event loop must use an interruptible indefinite wait"); + } + enqueue(handle, MPV_EVENT_CLIENT_MESSAGE); + enqueue(handle, MPV_EVENT_PROPERTY_CHANGE); + waitFor(handle, [&] { return handle.waitCalls >= 3; }, "queued events were not drained"); + require(events == 2, "real events must reach the handler exactly once"); + mpv_wakeup(&handle); + waitFor(handle, [&] { return handle.waitCalls >= 4; }, "a spurious wakeup stalled the event loop"); + require(events == 2, "a wakeup without an event must not reach the handler"); + int detachCalls = 0; + lifecycle.destroy([&](mpv_handle *detached) { + require(detached == &handle && !lifecycle.handle(), "detach must receive the withdrawn handle"); + ++detachCalls; + }); + require(!lifecycle.handle() && handle.destroyed == 1 && detachCalls == 1, "synchronous teardown failed"); + require(events == 2, "shutdown wakeup must not dispatch a callback"); + lifecycle.destroy(); + require(handle.destroyed == 1, "repeated teardown must not destroy the core again"); +} + +void wakeBeforeWait() +{ + mpv_handle handle; + handle.holdWaitEntry = true; + MpvLifecycle lifecycle; + std::atomic_int events { 0 }; + require(lifecycle.adopt(&handle, [&](mpv_event *) { ++events; }), "adoption failed"); + waitFor(handle, [&] { return handle.waitCalls == 1; }, "event loop did not enter the wait"); + std::thread destroyer([&] { lifecycle.destroy(); }); + waitFor(handle, [&] { return handle.wakeups == 1; }, "teardown did not wake the event loop"); + { + std::lock_guard lock(handle.mutex); + handle.holdWaitEntry = false; + handle.changed.notify_all(); + } + waitFor(handle, [&] { return handle.destroyed == 1; }, "a wakeup before the wait was lost"); + destroyer.join(); + require(events == 0, "a stopped loop must not dispatch the wakeup event"); +} + +void shutdownEventStopsItsOwnLoop() +{ + mpv_handle handle; + std::atomic_int events { 0 }; + MpvLifecycle lifecycle; + require(lifecycle.adopt(&handle, + [&](mpv_event *event) { + require(event->event_id == MPV_EVENT_SHUTDOWN, "unexpected shutdown event"); + ++events; + }), + "adoption failed"); + waitFor(handle, [&] { return handle.waitCalls == 1; }, "event loop did not start"); + enqueue(handle, MPV_EVENT_SHUTDOWN); + for (int attempt = 0; events == 0 && attempt < 200; ++attempt) + std::this_thread::sleep_for(1ms); + require(events == 1, "shutdown was not dispatched"); + { + std::unique_lock lock(handle.mutex); + require(!handle.changed.wait_for(lock, 50ms, [&] { return handle.waitCalls > 1; }), + "shutdown must stop without consulting a later adoption's state"); + } + lifecycle.destroy(); + require(handle.destroyed == 1, "shutdown must still release its mpv core"); +} + +void asynchronousDestroyJoinsCallbacksThenAllowsReuse() +{ + mpv_handle oldHandle; + mpv_handle newHandle; + std::mutex callbackMutex; + std::condition_variable callbackChanged; + bool callbackStarted = false; + bool releaseCallback = false; + { + MpvLifecycle lifecycle; + require(lifecycle.adopt(&oldHandle, + [&](mpv_event *) { + std::unique_lock lock(callbackMutex); + callbackStarted = true; + callbackChanged.notify_all(); + callbackChanged.wait(lock, [&] { return releaseCallback; }); + }), + "old adoption failed"); + enqueue(oldHandle, MPV_EVENT_CLIENT_MESSAGE); + { + std::unique_lock lock(callbackMutex); + require(callbackChanged.wait_for(lock, 2s, [&] { return callbackStarted; }), "callback did not start"); + } + std::thread destroyer([&] { lifecycle.destroyAsync(); }); + // The handle is withdrawn before the callback join begins. + for (int attempt = 0; lifecycle.handle() && attempt < 2000; ++attempt) + std::this_thread::sleep_for(1ms); + require(!lifecycle.handle(), "async teardown must withdraw its handle"); + waitFor(oldHandle, [&] { return oldHandle.wakeups >= 1; }, "teardown did not wake the event loop"); + { + std::lock_guard lock(callbackMutex); + releaseCallback = true; + callbackChanged.notify_all(); + } + // The event thread's callbacks are joined before the core's + // destruction is handed to a worker; destroyAsync returns with them. + destroyer.join(); + require(!lifecycle.handle(), "async teardown kept its handle"); + require(lifecycle.adopt(&newHandle, {}), "reuse after joined async teardown"); + waitFor(newHandle, [&] { return newHandle.waitCalls == 1; }, "new event loop did not start"); + // Destructor joins and destroys the new core while the old worker + // may still own the detached terminate_destroy of the old core. + } + require(newHandle.destroyed == 1, "destructor failed to release the new core"); + waitFor(oldHandle, [&] { return oldHandle.destroyed == 1; }, "old async teardown did not finish"); + require(oldHandle.waitCalls == 1, "old event loop resumed after lifecycle reuse"); +} + +} // namespace + +int main() +{ + idleWaitAndSynchronousDestroy(); + wakeBeforeWait(); + shutdownEventStopsItsOwnLoop(); + asynchronousDestroyJoinsCallbacksThenAllowsReuse(); + return EXIT_SUCCESS; +} diff --git a/tests/player/PlayerControllerTest.cpp b/tests/player/PlayerControllerTest.cpp index 448640fb..4c3d3eb3 100644 --- a/tests/player/PlayerControllerTest.cpp +++ b/tests/player/PlayerControllerTest.cpp @@ -382,7 +382,7 @@ void localPlaylistLifecycle(const QString& directory, NativeAppWindow& window) require(empty.write("#EXTM3U\n") == 8, "empty native playlist fixture writes"); empty.close(); player.playLocalFiles({ QUrl::fromLocalFile(empty.fileName()) }); - require(!player.sessionActive() && !player.errorText().isEmpty(), + waitUntil([&] { return !player.sessionActive() && !player.errorText().isEmpty(); }, "a real zero-entry playlist is rejected with an error instead of remaining active and preparing"); player.teardownMpv(); } diff --git a/tests/player/PlayerSetupBenchmark.cpp b/tests/player/PlayerSetupBenchmark.cpp new file mode 100644 index 00000000..c755d9af --- /dev/null +++ b/tests/player/PlayerSetupBenchmark.cpp @@ -0,0 +1,294 @@ +#include "TestMain.h" +#include "TestRequire.h" +#include "platform/NativeAppWindow.h" +#include "player/MpvVideoItem.h" +#include "player/PlayerController.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace Spool; +using SpoolTests::require; + +namespace { +// Readiness/adoption diagnostics attribute the benchmark, not the playback +// assertion: every sample must still load and present real embedded video. +std::atomic preparedCount { 0 }; +std::atomic adoptedCount { 0 }; +std::atomic preparationMs { -1 }; +QtMessageHandler previousHandler = nullptr; + +void messages(QtMsgType type, const QMessageLogContext& context, const QString& text) +{ + if (text.startsWith(QLatin1String("player: idle-prepared mpv initialized in"))) { + const QStringList words = text.split(QLatin1Char(' '), Qt::SkipEmptyParts); + bool valid = false; + const qint64 elapsed = words.value(words.size() - 2).toLongLong(&valid); + preparationMs.store(valid ? elapsed : -1); + ++preparedCount; + } + if (text.startsWith(QLatin1String("player: adopting idle-prepared mpv handle"))) + ++adoptedCount; + if (previousHandler) + previousHandler(type, context, text); +} + +bool waitUntil(const std::function& ready, int timeout = 30000) +{ + QElapsedTimer timer; + timer.start(); + while (!ready() && timer.elapsed() < timeout) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(1); + } + return ready(); +} + +class Heartbeat { +public: + Heartbeat() + { + clock.start(); + timer.setTimerType(Qt::PreciseTimer); + timer.setInterval(10); + QObject::connect(&timer, &QTimer::timeout, &timer, [this] { tick(); }); + timer.start(); + } + void tick() + { + const double now = clock.nsecsElapsed() / 1e6; + maxGap = std::max(maxGap, now - last); + last = now; + ++ticks; + } + void reset() + { + maxGap = 0; + ticks = 0; + last = clock.nsecsElapsed() / 1e6; + } + QTimer timer; + QElapsedTimer clock; + double last = 0; + double maxGap = 0; + int ticks = 0; +}; + +void writeConfig(const QString& directory, int volume) +{ + QFile file(directory + QStringLiteral("/mpv.conf")); + const QByteArray config = "ao=null\nhwdec=no\nloop-file=inf\nvolume=" + QByteArray::number(volume) + '\n'; + require(file.open(QIODevice::WriteOnly) && file.write(config) == config.size(), + "benchmark custom mpv configuration writes"); +} + +PlaybackSession session() +{ + PlaybackSession result; + result.itemId = QStringLiteral("setup-benchmark"); + result.title = result.itemId; + result.itemType = QStringLiteral("Movie"); + result.url = QUrl::fromLocalFile(SpoolTests::fixturePath("tests/fixtures/local-thumbnail.mkv")).toString(); + result.playMethod = QStringLiteral("DirectPlay"); + return result; +} + +class Surface { +public: + Surface() + : window(QStringLiteral("player-setup-benchmark")) + , video(window.contentItem()) + { + window.resize(320, 180); + video.setSize(QSizeF(window.size())); + QObject::connect(&window, &QQuickWindow::frameSwapped, &window, [this] { ++swaps; }, Qt::DirectConnection); + } + NativeAppWindow window; + MpvVideoItem video; + std::atomic swaps { 0 }; +}; + +QJsonObject measure(bool warm, int iteration) +{ + qputenv("SPOOL_DISABLE_IDLE_MPV", warm ? "0" : "1"); + Surface surface; + Heartbeat heartbeat; + const int preparationsBefore = preparedCount.load(); + const int adoptionsBefore = adoptedCount.load(); + QElapsedTimer clock; + clock.start(); + PlayerController player(&surface.window, nullptr, nullptr, {}); + const double constructMs = clock.nsecsElapsed() / 1e6; + player.setMpvConfigPolicy({ MpvConfigPolicy::Mode::Disabled, {} }); + surface.window.show(); + require(waitUntil([&] { return surface.swaps.load() > 0; }), "benchmark window presents its launch frame"); + heartbeat.reset(); + clock.restart(); + double readyMs = 0; + double prepareGapMs = 0; + int prepareTicks = 0; + if (warm) { + require(waitUntil([&] { return preparedCount.load() > preparationsBefore; }), + "real background player preparation completes"); + readyMs = clock.nsecsElapsed() / 1e6; + heartbeat.tick(); + prepareGapMs = heartbeat.maxGap; + prepareTicks = heartbeat.ticks; + require(!player.sessionActive() && !player.visible(), "idle preparation never starts a playback session"); + } + const bool preparedBeforePlay = preparedCount.load() > preparationsBefore; + heartbeat.reset(); + clock.restart(); + player.play(session(), true); + const double playCallMs = clock.nsecsElapsed() / 1e6; + require(waitUntil([&] { return player.fileLoaded() || !player.errorText().isEmpty(); }), + "benchmark real player completes setup and media loading"); + const double loadedMs = clock.nsecsElapsed() / 1e6; + require(player.fileLoaded() && player.errorText().isEmpty() && player.sessionActive(), + "benchmark loads its real local fixture without a playback error"); + const int loadedSwaps = surface.swaps.load(); + surface.window.update(); + require(waitUntil([&] { + if (!player.paused() || !player.embeddedVideoOutput() || surface.swaps.load() <= loadedSwaps) + return false; + const QImage image = surface.window.grabWindow(); + if (image.isNull()) + return false; + const QColor center = image.pixelColor(image.width() / 2, image.height() / 2); + return std::max({ center.red(), center.green(), center.blue() }) > 32; + }), + "benchmark initialized video presents a nonblack paused frame"); + const double frameMs = clock.nsecsElapsed() / 1e6; + heartbeat.tick(); + const qint64 workerMs = preparationMs.load(); + require(preparedCount.load() > preparationsBefore && workerMs >= 0, + "real setup reports its initialization worker duration"); + const QJsonObject result { { "mode", warm ? "prepared" : "cold" }, { "iteration", iteration }, + { "processCold", !warm && iteration == 0 }, { "constructCallMs", constructMs }, + { "preparationReadyMs", readyMs }, { "setupWorkerMs", workerMs }, + { "preparationHeartbeatMaxGapMs", prepareGapMs }, { "preparationHeartbeats", prepareTicks }, + { "playCallMs", playCallMs }, { "fileLoadedMs", loadedMs }, { "firstFrameMs", frameMs }, + { "playHeartbeatMaxGapMs", heartbeat.maxGap }, { "playHeartbeats", heartbeat.ticks }, + { "preparedAdopted", preparedBeforePlay && adoptedCount.load() > adoptionsBefore } }; + player.stop(); + player.teardownMpv(); + return result; +} + +void lifecycle(const QString& replacement) +{ + qputenv("SPOOL_DISABLE_IDLE_MPV", "0"); + Surface surface; + PlayerController player(&surface.window, nullptr, nullptr, {}); + player.setMpvConfigPolicy({ MpvConfigPolicy::Mode::Disabled, {} }); + const int before = preparedCount.load(); + surface.window.show(); + require(waitUntil([&] { return preparedCount.load() > before; }), "config lifecycle has a real prepared core"); + player.setMpvConfigPolicy({ MpvConfigPolicy::Mode::Custom, replacement }); + player.play(session(), true); + require(waitUntil([&] { return player.fileLoaded() && player.paused() && player.volume() == 43; }), + "changing configuration discards the prepared core and applies the replacement config"); + player.stop(); + player.teardownMpv(); + + // Cancel at the consumer boundary before the asynchronous start can finish. + // A later completion must not resurrect that request or stop its successor. + player.play(session(), true); + player.stop(); + require(!player.sessionActive(), "pending play can be canceled immediately"); + PlaybackSession next = session(); + next.title = QStringLiteral("successor-after-canceled-setup"); + player.play(next, true); + require(waitUntil([&] { return player.fileLoaded() && player.paused(); }), + "a new play succeeds after cancellation during initialization"); + require(player.title() == next.title && player.sessionActive(), "late setup cannot replace the successor session"); + player.setPaused(false); + require(waitUntil([&] { return player.positionSeconds() > 0.2; }), "successor advances on the real media clock"); + player.stop(); + player.teardownMpv(); + + player.play(session(), true); + player.setPaused(false); + require(waitUntil([&] { return player.fileLoaded() && !player.paused() && player.positionSeconds() > 0.2; }), + "resume requested during setup overrides the original paused start and advances real playback"); + player.stop(); + player.teardownMpv(); + + player.play(session(), false); + player.setPaused(true); + require(waitUntil([&] { return player.fileLoaded() && player.paused(); }), + "pause requested during setup overrides the original playing start"); + const double pausedPosition = player.positionSeconds(); + QElapsedTimer pausedWindow; + pausedWindow.start(); + require( + waitUntil([&] { return pausedWindow.elapsed() >= 500; }, 2000), "pending-pause observation window completes"); + require(player.sessionActive() && player.paused() && player.positionSeconds() < pausedPosition + 0.1, + "pending pause holds the actual media clock rather than only echoing the requested state"); + player.stop(); + player.teardownMpv(); +} +} // namespace + +SPOOL_TEST_MAIN("player-setup-benchmark") +{ + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); + QSurfaceFormat format; + format.setRenderableType(QSurfaceFormat::OpenGL); + format.setVersion(3, 3); + format.setAlphaBufferSize(0); + QSurfaceFormat::setDefaultFormat(format); + QGuiApplication app(argc, argv); + std::setlocale(LC_NUMERIC, "C"); + const QString path + = qEnvironmentVariable("SPOOL_PLAYER_SETUP_OUT", QDir::current().filePath("player-setup-benchmark.json")); + require(!QFile::exists(path) || QFile::remove(path), "stale benchmark output is removed before measuring"); + const QByteArray originalIdle = qgetenv("SPOOL_DISABLE_IDLE_MPV"); + previousHandler = qInstallMessageHandler(messages); + QTemporaryDir replacement; + require(replacement.isValid(), "benchmark replacement configuration directory opens"); + writeConfig(replacement.path(), 43); + QJsonArray samples; + for (int iteration = 0; iteration < 3; ++iteration) { + samples.append(measure(false, iteration)); + samples.append(measure(true, iteration)); + } + lifecycle(replacement.path()); + const QJsonObject report { { "schemaVersion", 1 }, { "complete", true }, + { "recordedAt", QDateTime::currentDateTimeUtc().toString(Qt::ISODate) }, { "qtVersion", qVersion() }, + { "cpuArchitecture", QSysInfo::currentCpuArchitecture() }, { "qpaPlatform", QGuiApplication::platformName() }, + { "graphicsApi", "OpenGL" }, { "audioOutput", "video-only fixture; no audio stream" }, { "samples", samples }, + { "commit", qEnvironmentVariable("GITHUB_SHA") }, { "runId", qEnvironmentVariable("GITHUB_RUN_ID") }, + { "coldDefinition", + "fresh controller, no prepared core; only first sample is process-cold; OS caches retained" }, + { "timingOrigin", "play call through real PlayerController, local demux/decode and embedded video frame" } }; + QSaveFile output(path); + const QByteArray bytes = QJsonDocument(report).toJson(); + require(output.open(QIODevice::WriteOnly) && output.write(bytes) == bytes.size() && output.commit(), + "player setup benchmark retains its timing JSON"); + qInstallMessageHandler(previousHandler); + if (originalIdle.isNull()) + qunsetenv("SPOOL_DISABLE_IDLE_MPV"); + else + qputenv("SPOOL_DISABLE_IDLE_MPV", originalIdle); + return EXIT_SUCCESS; +} diff --git a/tests/providers/ProviderRegistryTest.cpp b/tests/providers/ProviderRegistryTest.cpp index 6a0c5021..f15d6125 100644 --- a/tests/providers/ProviderRegistryTest.cpp +++ b/tests/providers/ProviderRegistryTest.cpp @@ -831,6 +831,15 @@ export function createSource(config, sourceHost) { require(failure(registry.allowLanDiscovery(extendedAlice, "lan")) == "unsupported_capability", "signed-in accounts cannot request subnet discovery"); lanDraft->close(); + // Approved once for this provider, a later sign-in searches without asking. + auto *laterDraft = qobject_cast(registry.beginSetup("fixture.test")); + consentSettled = consentApproved = false; + registry.allowLanDiscovery(laterDraft->sourceId(), "lan") + .then([&] { consentSettled = consentApproved = true; }, [&](const std::exception&) { consentSettled = true; }); + waitUntil([&] { return consentSettled; }, "a remembered approval settles without a prompt"); + require(consentApproved && registry.networkConsent().isEmpty(), + "the same provider is not asked to search the local network twice"); + laterDraft->close(); bool pendingStarted = false; bool pendingSettled = false; bool pendingSucceeded = false; diff --git a/tests/providers/ProviderUiContextTest.cpp b/tests/providers/ProviderUiContextTest.cpp index f62fafb1..9703519f 100644 --- a/tests/providers/ProviderUiContextTest.cpp +++ b/tests/providers/ProviderUiContextTest.cpp @@ -299,6 +299,17 @@ export function createSource() { require(registry.networkConsent().isEmpty() && !login->closed(), "cancel local search dismisses its host consent without closing login"); registry.resolveNetworkConsent(dismissedConsentId, true); + // Closing a sign-in dismisses the prompt it was waiting on. + auto *closing = qobject_cast(registry.beginSetup("fixture.test")); + engine.globalObject().setProperty("closing", engine.newQObject(closing)); + engine.evaluate(QStringLiteral(R"JS( + var closedConsentRejected = false; + closing.allowLanDiscovery().then(function() {}, function() { closedConsentRejected = true; }); + )JS")); + waitFor([&] { return !registry.networkConsent().isEmpty(); }); + closing->close(); + waitFor([&] { return engine.globalObject().property("closedConsentRejected").toBool(); }); + require(registry.networkConsent().isEmpty(), "closing login dismisses pending host consent"); engine.evaluate(QStringLiteral(R"JS( var consentReady = false; login.allowLanDiscovery().then(function() { consentReady = true; }); @@ -317,13 +328,13 @@ export function createSource() { && engine.globalObject().property("unrelatedCompleted").toBool(); }); require(!login->closed(), "cancelling discovery leaves unrelated manual/UDP request scopes usable"); + // Once approved for this provider, searching again is not asked again. engine.evaluate(QStringLiteral(R"JS( - var closedConsentRejected = false; - login.allowLanDiscovery().then(function() {}, function() { closedConsentRejected = true; }); + var searchedAgain = false; + login.allowLanDiscovery().then(function() { searchedAgain = true; }); )JS")); - waitFor([&] { return !registry.networkConsent().isEmpty(); }); + waitFor([&] { return engine.globalObject().property("searchedAgain").toBool(); }); + require(registry.networkConsent().isEmpty(), "an approved provider searches without another prompt"); login->close(); - waitFor([&] { return engine.globalObject().property("closedConsentRejected").toBool(); }); - require(registry.networkConsent().isEmpty(), "closing login dismisses pending host consent"); return 0; } diff --git a/tests/providers/ScriptRuntimeTest.cpp b/tests/providers/ScriptRuntimeTest.cpp index c9ad74e9..40d05181 100644 --- a/tests/providers/ScriptRuntimeTest.cpp +++ b/tests/providers/ScriptRuntimeTest.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -331,11 +332,21 @@ SPOOL_TEST_MAIN("script-runtime") const auto measurement = QCoro::waitFor(runtime->call("a", "speedTest")); speedResponsiveness.stop(); waitForSpeedAbort(); - require( - measurement.value("bitrate").toLongLong() >= 1000000 && measurement.value("bitrate").toLongLong() <= 1000000000, - "successful benchmark returns a bounded usable streaming bitrate"); - const int lanes = measurement.value("parallelRequests").toInt(); - require(lanes == 1 || lanes == 2 || lanes == 4, "benchmark selects a supported lane count"); + require(measurement.value("bitrate").toLongLong() > 0, "successful benchmark returns a usable streaming ceiling"); + require(measurement.value("parallelRequests").toInt() == 1, + "without playback demand a benchmark does not blindly select maximum throughput"); + const QVariantList curve = measurement.value("concurrency-samples").toList(); + require(curve.size() == 3, "native result preserves every concurrency operating point"); + for (int index = 0; index < 3; ++index) { + const auto sample = curve[index].toMap(); + const double seconds = sample.value("elapsed-seconds").toDouble(); + const double rate = sample.value("capacity-bytes-per-second").toDouble(); + const double latencyMs = sample.value("latency-ms").toDouble(); + require(sample.value("requests").toInt() == (1 << index) + && sample.value("received-bytes").toLongLong() == 4194304 && seconds > 0 && latencyMs >= 35 + && latencyMs <= seconds * 1000 && std::abs(rate * seconds - 4194304.0) < 1, + "per-concurrency raw capacity divides actual payload by full measured wall time including TTFB"); + } require(speedSizes == QList { 524288, 4194304, 2097152, 2097152, 1048576, 1048576, 1048576, 1048576 }, "warmup and completed 1/2/4 lane rounds use the intended byte budgets"); require(speedMaximum == 4 && speedGuiTurns >= 5, "lane requests overlap without blocking GUI events"); diff --git a/tests/providers/SourceHubSpeedTest.cpp b/tests/providers/SourceHubSpeedTest.cpp index ef3ddbd7..f0ac335e 100644 --- a/tests/providers/SourceHubSpeedTest.cpp +++ b/tests/providers/SourceHubSpeedTest.cpp @@ -1,25 +1,20 @@ #include "ProviderFixture.h" #include "TestMain.h" -#include "app/AppController.h" -#include "app/ArtworkService.h" -#include "app/BrowseSessionController.h" #include "cache/DatabaseManager.h" -#include "common/TlsTrust.h" -#include "platform/NativeAppWindow.h" -#include "player/PlayerController.h" #include "provider/PlaybackSource.h" #include "provider/ProviderRegistry.h" #include "provider/SourceHub.h" #include +#include #include -#include #include #include #include #include #include +#include using namespace Spool; @@ -41,21 +36,26 @@ void waitUntil(const std::function& condition, const char *message) } require(condition(), message); } +QVariantMap feedback(quint64 stream, double rate, int requests = 1) +{ + return { { "stream-id", stream }, { "capacity-samples", 1 }, { "selected-requests", requests }, + { "observed-at-ms", QDateTime::currentMSecsSinceEpoch() }, { "media-bitrate", 80'000'000 }, + { "concurrency-samples", + QVariantList { QVariantMap { { "requests", requests }, { "capacity-bytes-per-second", rate }, + { "latency-ms", 120.0 }, { "samples", 1 } } } } }; +} } SPOOL_TEST_MAIN("source-hub-speed-test") { -#if !defined(Q_OS_ANDROID) && !defined(SPOOL_APPLE_MOBILE) - qputenv("QT_QPA_PLATFORM", "offscreen"); -#endif - QGuiApplication app(argc, argv); + QCoreApplication app(argc, argv); QTemporaryDir directory; + require(directory.isValid(), "temporary directory"); qputenv("SPOOL_CREDENTIAL_STORE_DIR", directory.filePath("credentials").toUtf8()); - DatabaseManager database; - require(database.initialize(directory.filePath("cache.sqlite")), "database opens"); auto package = ProviderFixture::package(); auto manifest = QJsonDocument::fromJson(package.files.value("manifest.json")).object(); manifest.insert("capabilities", QJsonArray { "speedTest", "streamQuality" }); + manifest.insert("origins", QJsonArray { "https://melbourne.invalid", "https://nj.invalid" }); package.files["manifest.json"] = QJsonDocument(manifest).toJson(); package.manifest = *ProviderManifest::parse(package.files.value("manifest.json")); package.files["logic/provider.mjs"] = R"JS( @@ -66,96 +66,175 @@ export function createSource(configuration) { host.emit('probeStarted', {}); return host.delay(40).then(function() { host.emit('probeFinished', {}); - return {bitrate: 48000000, parallelRequests: configuration.lanes}; + return {bitrate:126000000, parallelRequests:1, origin:configuration.origin, + 'concurrency-samples':[1,2,4].map(function(requests, index) { + return {requests:requests, 'capacity-bytes-per-second':[10000000,20000000,21000000][index], + 'latency-ms':120, 'elapsed-seconds':0.4, 'received-bytes':4194304}; + })}; }); }, - resolve() { return {url: 'https://media.invalid/movie', variantId: 'v'}; } + resolve() { return {url:configuration.origin + '/movie?token=private-token', variantId:'v'}; } }; } )JS"; const QString installs = directory.filePath("providers"); + const QString databasePath = directory.filePath("cache.sqlite"); require(ProviderPackage::install(package, installs).has_value(), "fixture installs"); - ProviderRegistry registry(&database); - registry.setInstallDirectory(installs); - registry.loadModules(); - SourceHub hub(®istry); - QCoro::waitFor(registry.restore()); - hub.setPlaybackActive(true); - const auto add = [&](const QString& key, int lanes) { - const QString id = registry.finishSetup({}, - { { "module", "fixture.test" }, { "account", key }, { "label", key }, - { "configuration", QVariantMap { { "lanes", lanes } } } }); - registry.useAccount(id); - return id; - }; - const QString a = add("first", 1); - const QString b = add("second", 4); - waitUntil([&] { return hub.sources().size() == 2; }, "accounts start"); - int starts = 0; - int finishes = 0; - int inFlight = 0; - bool interrupt = true; - QObject::connect(&hub, &SourceHub::accountEvent, [&](const QString&, const QString& type, const QVariantMap&) { - if (type == "probeStarted") { - ++starts; - ++inFlight; - require(inFlight == 1, "accounts benchmark serially, not against each other"); - if (interrupt) { - interrupt = false; - hub.setPlaybackActive(true); + QString melbourne; + QString nj; + QString local; + QVariantMap learnedMelbourne; + { + DatabaseManager database; + require(database.initialize(databasePath), "database opens"); + ProviderRegistry registry(&database); + registry.setInstallDirectory(installs); + registry.loadModules(); + SourceHub hub(®istry); + QCoro::waitFor(registry.restore()); + hub.setPlaybackActive(true); + const auto add = [&](const QString& key, const QString& origin) { + const QString id = registry.finishSetup({}, + { { "module", "fixture.test" }, { "account", key }, { "label", key }, + { "configuration", QVariantMap { { "origin", origin } } } }); + registry.useAccount(id); + return id; + }; + melbourne = add("Melbourne", "https://melbourne.invalid"); + nj = add("NJ", "https://nj.invalid"); + local = add("Local", "file:"); + waitUntil([&] { return hub.sources().size() == 3; }, "accounts start"); + int started = 0; + int finished = 0; + int active = 0; + QObject::connect(&hub, &SourceHub::accountEvent, [&](const QString&, const QString& type, const QVariantMap&) { + if (type == "probeStarted") { + ++started; + require(++active == 1, "probes never benchmark against another account"); + } else if (type == "probeFinished") { + ++finished; + --active; } - } else if (type == "probeFinished") { - ++finishes; - --inFlight; + }); + hub.refreshSpeedTests(); + QCoreApplication::processEvents(); + require(started == 0, "explicit refresh cannot compete with active playback"); + hub.setPlaybackActive(false); + hub.refreshSpeedTests(); + waitUntil([&] { return finished == 3; }, "idle explicit probes complete serially"); + require(active == 0, "all probes settle"); + hub.setPlaybackActive(true); + const auto resolve = [&](const QString& account) { + MovieItem item; + item.id = hub.scoped(account, "movie"); + return QCoro::waitFor(hub.playback()->resolvePlayback(item, false)); + }; + resolve(melbourne); + require(hub.playback()->playbackNetworkProfile().value("concurrency-samples").toList().size() == 3, + "full benchmark curve reaches native playback without SDK changes"); + require(hub.playback()->playbackNetworkProfile().value("initial-requests").toInt() == 1, + "no demand starts conservatively instead of choosing the fastest probe"); + const QUrl melbourneOrigin = hub.playback()->mediaOrigin(); + auto measured = feedback(1, 20'000'000, 2); + hub.playback()->updatePlaybackNetworkProfile(melbourneOrigin, measured); + learnedMelbourne = hub.playback()->playbackNetworkProfile(); + require(learnedMelbourne.value("initial-requests").toInt() == 2, + "startup chooses the minimum measured configuration with playback headroom"); + for (int invalid = 0; invalid < 5; ++invalid) { + auto rejected = feedback(2, 90'000'000, 4); + if (invalid == 0) + rejected["observed-at-ms"] = QDateTime::currentMSecsSinceEpoch() - 600'000; + if (invalid == 1) + rejected["observed-at-ms"] = QDateTime::currentMSecsSinceEpoch() + 60'000; + if (invalid == 2) + rejected["capacity-samples"] = 0; + if (invalid == 3) + rejected = feedback(1, 90'000'000, 4); + if (invalid == 4) { + rejected = feedback(2, std::numeric_limits::quiet_NaN(), 4); + } + rejected["average-bytes-per-second"] = 100.0; + hub.playback()->updatePlaybackNetworkProfile(melbourneOrigin, rejected); + require(hub.playback()->playbackNetworkProfile() == learnedMelbourne, + "stale, replayed, cache-throttled and invalid observations cannot poison learning"); } - }); - hub.setPlaybackActive(false); - hub.refreshSpeedTests(); - waitUntil([&] { return finishes == 2 && !hub.speedTestDescription().contains(QStringLiteral("Measuring")); }, - "in-flight probes finish even when playback starts"); - require(starts == 2 && inFlight == 0, "explicit account probes finish serially without cancellation or restart"); - require(hub.speedTestDescription().contains(QStringLiteral("Measured limit")), - "completed probes replace the deferred status with their measurement"); - const int beforeRefresh = finishes; - hub.refreshSpeedTests(); - waitUntil( - [&] { - return finishes == beforeRefresh + 2 && !hub.speedTestDescription().contains(QStringLiteral("Measuring")); - }, - "explicit refresh can finish during playback"); - waitUntil( - [&] { - return hub.source(a)->playback()->playbackParallelRequests() == 1 - && hub.source(b)->playback()->playbackParallelRequests() == 4; - }, - "accounts retain distinct measured lane budgets"); - MovieItem item; - item.id = hub.scoped(a, "movie"); - QCoro::waitFor(hub.playback()->resolvePlayback(item, false)); - require(hub.playback()->playbackParallelRequests() == 1, "first account selects its own network profile"); - item.id = hub.scoped(b, "movie"); - QCoro::waitFor(hub.playback()->resolvePlayback(item, false)); - require(hub.playback()->playbackParallelRequests() == 4, "switching playback switches the measured profile"); - registry.setAccountEnabled(a, false); - waitUntil([&] { return hub.sources().size() == 1; }, "disabled account leaves browsing"); - hub.setPlaybackActive(true); - registry.restartAccount(b); - waitUntil([&] { return hub.source(b) && hub.source(b)->playback()->playbackParallelRequests() == 2; }, - "restarting an account discards its stale measurement"); - NativeAppWindow window(QStringLiteral("spool-speed-test")); - TlsTrustController trust; - ArtworkService artwork(directory.filePath("artwork"), 1024 * 1024, 1024 * 1024, 1, &trust); - PlayerController player(&window, hub.playback(), &trust, {}); - AppController controller(&database, &hub, &artwork, &player); - hub.setPlaybackActive(false); - controller.browse()->setLoadingMore(true); - const int beforeBrowse = finishes; - hub.refreshSpeedTests(); - waitUntil( - [&] { - return finishes == beforeBrowse + 1 && !hub.speedTestDescription().contains(QStringLiteral("Measuring")); - }, - "explicit measurement also finishes while foreground catalogue work is active"); - controller.browse()->setLoadingMore(false); + resolve(nj); + const QUrl njOrigin = hub.playback()->mediaOrigin(); + hub.playback()->updatePlaybackNetworkProfile(njOrigin, feedback(3, 40'000'000)); + const auto learnedNj = hub.playback()->playbackNetworkProfile(); + require(learnedNj.value("initial-requests").toInt() == 1 + && learnedNj.value("capacity-bytes-per-second").toDouble() > 20'000'000, + "servers sharing a provider retain independent capacity and startup lanes"); + hub.playback()->updatePlaybackNetworkProfile(melbourneOrigin, feedback(4, 99'000'000)); + require(hub.playback()->playbackNetworkProfile() == learnedNj, + "delayed feedback from another media origin is not attributed to the current server"); + resolve(local); + require(hub.playback()->playbackNetworkProfile().value("measured-bitrate").toLongLong() == 0, + "local media never inherits a remote network capacity"); + resolve(melbourne); + require(hub.playback()->playbackNetworkProfile() == learnedMelbourne, + "switching back restores the owning endpoint profile"); + } + { + DatabaseManager database; + require(database.initialize(databasePath), "database reopens"); + ProviderRegistry registry(&database); + registry.setInstallDirectory(installs); + registry.loadModules(); + SourceHub hub(®istry); + QCoro::waitFor(registry.restore()); + hub.setPlaybackActive(true); + waitUntil([&] { return hub.sources().size() == 3; }, "accounts restore after process lifetime"); + MovieItem item; + item.id = hub.scoped(melbourne, "movie"); + QCoro::waitFor(hub.playback()->resolvePlayback(item, false)); + require(hub.playback()->playbackNetworkProfile() == learnedMelbourne, + "actual playback learning survives registry and database restart"); + auto root = QJsonDocument::fromJson(QCoro::waitFor(database.loadSettingAsync("providers/accounts/2")).toUtf8()) + .object(); + const QByteArray learning = QJsonDocument(QJsonObject { { "profiles", root.value("networkProfiles") }, + { "endpoints", root.value("networkEndpoints") } }) + .toJson(); + require(!learning.contains("melbourne") && !learning.contains("nj.invalid") + && !learning.contains("private-token") && !learning.contains("https://"), + "learning storage contains only endpoint hashes and numeric data, not addresses or credentials"); + auto profiles = root.value("networkProfiles").toObject(); + for (auto it = profiles.begin(); it != profiles.end(); ++it) { + auto profile = it.value().toObject(); + for (const QString& field : { QStringLiteral("probe-samples"), QStringLiteral("concurrency-samples") }) { + auto samples = profile.value(field).toArray(); + for (qsizetype index = 0; index < samples.size(); ++index) { + auto sample = samples[index].toObject(); + sample.insert("updated-at-ms", QDateTime::currentMSecsSinceEpoch() - 31LL * 24 * 60 * 60 * 1000); + samples[index] = sample; + } + profile.insert(field, samples); + } + it.value() = profile; + } + root.insert("networkProfiles", profiles); + QVariantMap settings { { "providers/accounts/2", + QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact)) } }; + QCoro::waitFor(database.saveSettings(std::move(settings))); + } + { + DatabaseManager database; + require(database.initialize(databasePath), "database opens aged history"); + ProviderRegistry registry(&database); + registry.setInstallDirectory(installs); + registry.loadModules(); + SourceHub hub(®istry); + QCoro::waitFor(registry.restore()); + hub.setPlaybackActive(true); + waitUntil([&] { return hub.source(melbourne) != nullptr; }, "aged account restores"); + MovieItem item; + item.id = hub.scoped(melbourne, "movie"); + QCoro::waitFor(hub.playback()->resolvePlayback(item, false)); + const auto profile = hub.playback()->playbackNetworkProfile(); + require(profile.value("concurrency-samples").toList().isEmpty() + && profile.value("capacity-bytes-per-second").toDouble() == 0 + && profile.value("initial-requests").toInt() == 1, + "expired measurements cannot seed capacity or retain an unsupported lane selection"); + } return 0; } diff --git a/tests/providers/SourceHubTest.cpp b/tests/providers/SourceHubTest.cpp index 032a2bdf..77e36b05 100644 --- a/tests/providers/SourceHubTest.cpp +++ b/tests/providers/SourceHubTest.cpp @@ -621,7 +621,7 @@ SPOOL_TEST_MAIN("source-hub") .toMap(); }; const QVariantMap beforeFeeds = excludedFeeds(); - const SourceHub::HomeQuery selected { QStringLiteral("fixture.test") }; + const SourceHub::HomeQuery selected { QStringList { QStringLiteral("fixture.other") } }; for (const auto& items : { QCoro::waitFor(homeHub.fetchHomeResumeItems(selected, 1)), QCoro::waitFor(homeHub.fetchHomeNextUpEpisodes(selected, 1)) }) { require(items.size() == 1 && homeHub.accountOf(items.front().id) != otherAccount, @@ -630,7 +630,10 @@ SPOOL_TEST_MAIN("source-hub") require(excludedFeeds() == beforeFeeds && homeHub.sources().size() == 3 && homeHub.libraryScopeKey() == globalScope && homeRegistry.sourceRunning(otherAccount), "Home scope does not request excluded feeds or change global browsing/account state"); - home.selectProvider(QStringLiteral("fixture.test")); + require(home.setProviderShown(QStringLiteral("fixture.other"), false), "turning off one of two providers"); + require(!home.setProviderShown(QStringLiteral("fixture.test"), false) + && home.hiddenProviderIds() == QStringList { QStringLiteral("fixture.other") }, + "the last provider that can fill Home cannot be turned off"); home.refresh(QCoro::waitFor(homeHub.fetchLibraries())); waitUntil([&] { return !home.loading(); }, "multi-account homepage loads"); require(home.latestLibraryRows().size() == 2, "homepage initially includes both accounts' latest rows"); @@ -679,7 +682,8 @@ SPOOL_TEST_MAIN("source-hub") homeRegistry.setAccountEnabled(retainedAccount, false); waitUntil([&] { return !home.loading() && hasAccount(home.resumeItems(), otherAccount); }, "withdrawing the selected provider falls back to authorized All-provider feeds"); - require(home.providerId().isEmpty() && home.preferredProviderId() == QStringLiteral("fixture.test") + require(!home.providerFilterActive() + && home.hiddenProviderIds() == QStringList { QStringLiteral("fixture.other") } && !home.providerScopeMessage().isEmpty() && !hasAccount(home.resumeItems(), retainedAccount) && home.latestLibraryRows().size() == 1, "fallback explains the unavailable selection and never restores withdrawn account content"); diff --git a/tests/qml/tst_MediaRow.qml b/tests/qml/tst_MediaRow.qml index 80e6dc2c..e73c3565 100644 --- a/tests/qml/tst_MediaRow.qml +++ b/tests/qml/tst_MediaRow.qml @@ -1,6 +1,8 @@ import QtQuick import QtTest import "../../qml/primitives" as Primitives +import "../../qml/browse" as Browse +import "../../qml/shell" as Shell TestCase { id: testCase @@ -20,6 +22,15 @@ TestCase { } } + ListModel { + id: libraryOrderModel + } + + Component { + id: libraryOwnerComponent + ListModel {} + } + Primitives.MediaRow { id: libraryRow width: parent.width @@ -41,6 +52,7 @@ TestCase { "to": to } ]) + libraryOrderModel.move(from, to, 1) return true } model: [ @@ -59,13 +71,70 @@ TestCase { ] } + ListModel { + id: episodeModel + ListElement { + title: "One" + movieId: "aaaaaaaa:1" + } + ListElement { + title: "Two" + movieId: "aaaaaaaa:2" + } + ListElement { + title: "Three" + movieId: "aaaaaaaa:3" + } + ListElement { + title: "Four" + movieId: "aaaaaaaa:4" + } + ListElement { + title: "Five" + movieId: "aaaaaaaa:5" + } + ListElement { + title: "Six" + movieId: "aaaaaaaa:6" + } + } + + // A row recycled by a row stack: the same MediaRow shows an item model, + // then a JS array of libraries, then the item model again. + Primitives.MediaRow { + id: recycledRow + y: 120 + width: parent.width + cardWidth: 156 + cardGap: 16 + shell: shellHost + } + + Component { + id: rowStackComponent + + Shell.KeyRouter { + property alias stack: stackView + activeTarget: stackView + platformMayPairHolds: false + + Browse.RowStackView { + id: stackView + anchors.fill: parent + } + } + } + function initTestCase() { initialModel = libraryRow.model } function init() { libraryRow.finishMove() - libraryRow.model = initialModel + libraryOrderModel.clear() + for (let index = 0; index < initialModel.length; ++index) + libraryOrderModel.append(initialModel[index]) + libraryRow.model = libraryOrderModel libraryRow.reserveWhenEmpty = false libraryRow.keyboardFocusActive = true libraryRow.currentIndex = 0 @@ -139,6 +208,37 @@ TestCase { compare(libraryRow.currentCard().emphasizedTitle, true) } + function test_recycledRowRebuildsCardsForANewKindOfModel() { + failOnWarning(/TypeError/) + const libraries = [ + { + "name": "Movies", + "libraryId": "aaaaaaaa:movies", + "collectionType": "movies", + "item": ({}) + }, + { + "name": "Shows", + "libraryId": "bbbbbbbb:shows", + "collectionType": "tvshows", + "item": ({}) + } + ] + for (let round = 0; round < 3; ++round) { + recycledRow.cardKind = "landscape" + recycledRow.model = episodeModel + tryCompare(recycledRow, "count", 6) + recycledRow.currentIndex = 5 + tryVerify(() => recycledRow.currentCard() !== null) + recycledRow.cardKind = "library" + recycledRow.model = libraries + tryCompare(recycledRow, "count", 2) + recycledRow.currentIndex = 1 + tryVerify(() => recycledRow.currentCard() !== null) + compare(recycledRow.currentCard().titleText(), "Shows") + } + } + function test_touchPressDoesNotMoveSelectionBeforeClick() { libraryRow.currentIndex = 0 libraryRow.beginPointerSelection(1) @@ -156,4 +256,135 @@ TestCase { compare(libraryRow.currentCard().focused, false) libraryRow.keyboardFocusActive = true } + + function test_offscreenLibraryRecoveryConsumesHoldUntilPhysicalRelease() { + const router = createTemporaryObject(rowStackComponent, testCase, { + "width": testCase.width, + "height": testCase.height + }) + verify(router) + const cards = [] + for (let index = 0; index < 18; ++index) + cards.push({ + "name": "Library " + (index + 1), + "libraryId": "aaaaaaaa:library/" + index, + "collectionType": "movies", + "item": ({}) + }) + router.stack.sections = [ + { + "key": "libraries", + "title": "Libraries", + "kind": "library", + "model": cards + } + ] + tryVerify(() => router.stack.rowAt(0) !== null) + const row = router.stack.rowAt(0) + row.cardWidth = 156 + row.cardGap = 16 + verify(router.stack.focusPreferred("libraries")) + row.currentIndex = 2 + tryVerify(() => row.currentCard() !== null && row.activeFocus) + waitForRendering(row) + const pointerSurface = findChild(row, "mediaRowPointerArea") + verify(pointerSurface) + const viewport = pointerSurface.parent + // Real native ListView geometry after a horizontal scroll; there is no + // synthetic pending flag or copied candidate/recovery implementation. + viewport.contentX = viewport.originX + 6 * (row.cardWidth + row.cardGap) + waitForRendering(row) + compare(row.currentIndex, 2) + const scrollX = viewport.contentX + const scrollY = router.stack.contentY + const event = { + "modifiers": Qt.NoModifier, + "text": "" + } + verify(router.dispatchNormalized(event, Qt.Key_Right, "press", false)) + compare(row.currentIndex, 6) + compare(viewport.contentX, scrollX) + compare(router.stack.contentY, scrollY) + for (let repeat = 0; repeat < 20; ++repeat) + verify(router.dispatchNormalized(event, Qt.Key_Right, "press", true)) + verify(router.dispatchNormalized(event, Qt.Key_Right, "release", true)) + verify(router.dispatchNormalized(event, Qt.Key_Right, "press", true)) + compare(row.currentIndex, 6) + compare(viewport.contentX, scrollX) + compare(router.stack.contentY, scrollY) + verify(router.dispatchNormalized(event, Qt.Key_Right, "release", false)) + verify(router.dispatchNormalized(event, Qt.Key_Right, "press", false)) + compare(row.currentIndex, 7) + verify(router.dispatchNormalized(event, Qt.Key_Right, "release", false)) + } + + function test_moveKeepsIdentityModeAndViewportThroughOrderPublication() { + const owner = createTemporaryObject(libraryOwnerComponent, testCase) + const router = createTemporaryObject(rowStackComponent, testCase, { + "width": testCase.width, + "height": testCase.height + }) + verify(owner && router) + for (let index = 0; index < 18; ++index) + owner.append({ + "name": "Library " + (index + 1), + "libraryId": "aaaaaaaa:library/" + index, + "collectionType": "movies", + "item": ({}) + }) + function publishOrder() { + const snapshot = [] + for (let index = 0; index < owner.count; ++index) + snapshot.push(Object.assign({}, owner.get(index))) + router.stack.sections = [ + { + "key": "libraries", + "title": "Libraries", + "kind": "library", + "model": snapshot, + "moveItem": function (from, to) { + owner.move(from, to, 1) + return true + } + } + ] + } + owner.rowsMoved.connect(publishOrder) + router.stack.measureFirstRow = true + publishOrder() + tryVerify(() => router.stack.rowAt(0) !== null) + const row = router.stack.rowAt(0) + row.cardWidth = 156 + row.cardGap = 16 + tryVerify(() => row.delegatesPresented) + verify(router.stack.focusPreferred("libraries")) + verify(row.beginMoveById("aaaaaaaa:library/7")) + const viewport = findChild(row, "mediaRowPointerArea").parent + viewport.contentX = viewport.originX + 6 * (row.cardWidth + row.cardGap) + waitForRendering(row) + const scrollX = viewport.contentX - viewport.originX + const scrollY = router.stack.contentY + const event = { + "modifiers": Qt.NoModifier, + "text": "" + } + verify(router.dispatchNormalized(event, Qt.Key_Right, "press", false)) + const movedRow = router.stack.currentRow() + verify(movedRow) + compare(movedRow.moveMode, true) + compare(movedRow.currentIndex, 8) + compare(movedRow.itemAt(movedRow.currentIndex).libraryId, "aaaaaaaa:library/7") + compare(owner.get(8).libraryId, "aaaaaaaa:library/7") + compare(viewport.contentX - viewport.originX, scrollX) + compare(router.stack.contentY, scrollY) + verify(router.dispatchNormalized(event, Qt.Key_Right, "release", false)) + waitForRendering(movedRow) + compare(movedRow.moveMode, true) + verify(movedRow.delegatesPresented) + compare(movedRow.itemAt(movedRow.currentIndex).libraryId, "aaaaaaaa:library/7") + movedRow.activate() + compare(movedRow.moveMode, false) + movedRow.model = [] + compare(movedRow.currentIndex, -1) + } } diff --git a/tests/qml/tst_NavGrid.qml b/tests/qml/tst_NavGrid.qml index bff93a57..228c1dff 100644 --- a/tests/qml/tst_NavGrid.qml +++ b/tests/qml/tst_NavGrid.qml @@ -345,6 +345,23 @@ TestCase { tryCompare(grid, "heldKey", 0, grid.holdReleaseTimeout + 1000) } + function test_appStallDoesNotEndHoldButSilenceStillDoes() { + grid.forceActiveFocus() + verify(grid.routeKey(Qt.Key_Down, "press", false)) + verify(grid.routeKey(Qt.Key_Down, "press", true)) + compare(grid.heldKey, Qt.Key_Down) + // The view is busy past the watchdog's deadline with the next repeat + // still queued: a late watchdog is a stall, not a release. + const blockedUntil = Date.now() + grid.holdReleaseTimeout + 200 + while (Date.now() < blockedUntil) {} + wait(0) + compare(grid.heldKey, Qt.Key_Down) + verify(grid.routeKey(Qt.Key_Down, "press", true)) + compare(grid.heldKey, Qt.Key_Down) + // Then the key genuinely goes quiet and the hold ends as before. + tryCompare(grid, "heldKey", 0, grid.holdReleaseTimeout + 1000) + } + function test_topLeftVisibleIndexEmpty() { modelSize = 0 compare(grid.topLeftVisibleIndex(), -1) diff --git a/tests/qml/tst_OptionPickerInput.qml b/tests/qml/tst_OptionPickerInput.qml index 0940ebc2..335ddb2b 100644 --- a/tests/qml/tst_OptionPickerInput.qml +++ b/tests/qml/tst_OptionPickerInput.qml @@ -77,9 +77,9 @@ TestCase { } function test_pointerSelectionDoesNotClickThrough() { - const panel = findChild(picker, "optionPickerPanel") - verify(panel) - const point = panel.mapToItem(testCase, panel.width / 2, Metrics.scaled(8) + picker.rowHeight / 2) + const list = findChild(picker, "optionPickerList") + verify(list) + const point = list.mapToItem(testCase, list.width / 2, picker.rowHeight / 2) mouseClick(testCase, point.x, point.y, Qt.LeftButton) compare(selectedSpy.count, 1) compare(selectedSpy.signalArguments[0][0], 0) diff --git a/tests/qml/tst_ProviderForms.qml b/tests/qml/tst_ProviderForms.qml index 16917f46..ed997783 100644 --- a/tests/qml/tst_ProviderForms.qml +++ b/tests/qml/tst_ProviderForms.qml @@ -22,6 +22,7 @@ TestCase { title: "Protected account" }) property var rows: [] + property var discovered: [] property var completions: [] property var pendingAuthentication: null property var pendingAuthenticationRejection: null @@ -30,7 +31,7 @@ TestCase { function request(operation, args) { if (operation === "discover") return Promise.resolve({ - servers: [] + servers: discovered }) if (operation === "serverCandidates") return Promise.resolve({ @@ -246,6 +247,23 @@ TestCase { compare(provider.completions.length, 0) compare(view.step, "server") } + function test_singleDiscoveredServerOpensOnceByItself() { + provider.discovered = [ + { + id: "living-room", + name: "Living room", + address: "https://fixture.invalid" + } + ] + const view = form(login) + tryCompare(view, "step", "account") + // Back is a way out of that guess, not into it again. + verify(view.back()) + compare(view.step, "server") + wait(50) + compare(view.step, "server") + provider.discovered = [] + } function test_cancelledCodeCannotCompleteAccount() { const view = form(login) view.connect("fixture.invalid") diff --git a/tests/qml/tst_ProviderInstallDialog.qml b/tests/qml/tst_ProviderInstallDialog.qml index e2bf9426..c49954e4 100644 --- a/tests/qml/tst_ProviderInstallDialog.qml +++ b/tests/qml/tst_ProviderInstallDialog.qml @@ -19,6 +19,8 @@ TestCase { const dialog = createTemporaryObject(dialogComponent, testCase) verify(dialog) verify(!dialog.visible) + // A store install or a background update shows on its own tile, if + // anywhere; it never takes over the screen. dialog.transfers = [ { id: "spool.emby", @@ -28,6 +30,18 @@ TestCase { total: -1 } ] + verify(!dialog.visible) + // An install the viewer started from a file waits on this dialog. + dialog.transfers = [ + { + id: "spool.emby", + name: "Emby", + state: "downloading", + operationToken: "file-1", + received: 0, + total: -1 + } + ] verify(dialog.visible) verify(dialog.routeKey(Qt.Key_Down, "press", false)) verify(dialog.back()) @@ -37,6 +51,7 @@ TestCase { id: "spool.emby", name: "Emby", state: "downloading", + operationToken: "file-1", received: 512, total: 1024 } @@ -47,6 +62,7 @@ TestCase { id: "spool.emby", name: "Emby", state: "installing", + operationToken: "file-1", received: 1024, total: 1024 } diff --git a/tests/qml/tst_RoutePolicy.qml b/tests/qml/tst_RoutePolicy.qml index 895ff1d4..d7e139aa 100644 --- a/tests/qml/tst_RoutePolicy.qml +++ b/tests/qml/tst_RoutePolicy.qml @@ -106,8 +106,13 @@ TestCase { movieId: "only" } ]) - compare(RoutePolicy.modelIndexForItemId(model, "missing", 9), 0) + // A route's identity never falls back to a different row: a missing + // item stays missing. Only a route without an id uses the clamped + // fallback index. + compare(RoutePolicy.modelIndexForItemId(model, "missing", 9), -1) compare(RoutePolicy.modelIndexForItemId(modelFor([]), "missing", 9), -1) + compare(RoutePolicy.modelIndexForItemId(model, "", 9), 0) + compare(RoutePolicy.modelIndexForItemId(model, "", -5), 0) } function test_detailsNavigationMode_data() { diff --git a/tests/qml/tst_RowStackView.qml b/tests/qml/tst_RowStackView.qml new file mode 100644 index 00000000..4ca991d5 --- /dev/null +++ b/tests/qml/tst_RowStackView.qml @@ -0,0 +1,91 @@ +// Requires an application-aware fixture supplying the theme and native +// singletons used by MediaRow. The stock qmltestrunner CTest launcher does +// not provide those services, so this scenario is not registered there. +import QtQuick +import QtTest +import "../../qml/browse" as Browse + +TestCase { + id: testCase + name: "RowStackView" + width: 640 + height: 900 + when: windowShown + + Component { + id: stackComponent + Browse.RowStackView { + width: 640 + height: 900 + loading: true + sections: [ + { + "key": "populated", + "model": [ + { + "title": "First" + }, + { + "title": "Second" + } + ], + "reserveWhileLoading": true + }, + { + "key": "pending", + "model": [], + "reserveWhileLoading": true + } + ] + } + } + + SignalSpy { + id: sectionsSpy + signalName: "sectionsChanged" + } + + function test_loadingPreservesRowsAndHorizontalSelection() { + const stack = createTemporaryObject(stackComponent, testCase) + verify(stack) + tryVerify(function () { + return stack.rowAt(0) !== null && stack.rowAt(1) !== null + }) + const populated = stack.rowAt(0) + const pending = stack.rowAt(1) + populated.currentIndex = 1 + compare(pending.rowVisible, true) + sectionsSpy.target = stack + sectionsSpy.clear() + + stack.loading = false + tryCompare(pending, "rowVisible", false) + compare(stack.rowAt(0), populated) + compare(populated.currentIndex, 1) + compare(sectionsSpy.count, 0) + + stack.loading = true + tryCompare(pending, "rowVisible", true) + compare(stack.rowAt(0), populated) + compare(populated.currentIndex, 1) + compare(sectionsSpy.count, 0) + sectionsSpy.target = null + } + + function test_explicitReservationDoesNotDependOnLoading() { + const stack = createTemporaryObject(stackComponent, testCase, { + "loading": false, + "sections": [ + { + "model": [], + "reserveWhenEmpty": true + } + ] + }) + verify(stack) + tryVerify(function () { + return stack.rowAt(0) !== null + }) + compare(stack.rowAt(0).rowVisible, true) + } +} diff --git a/tests/tools/AltStoreSourceTest.py b/tests/tools/AltStoreSourceTest.py new file mode 100644 index 00000000..ece50ce0 --- /dev/null +++ b/tests/tools/AltStoreSourceTest.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""A SideStore/AltStore source must describe its IPA exactly, or installs are refused.""" + +from __future__ import annotations + +import json +import plistlib +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + + +def ipa(path: Path, info: dict, payload: bytes = b"\0" * 4096) -> Path: + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("Payload/Spool.app/Info.plist", plistlib.dumps(info)) + archive.writestr("Payload/Spool.app/Spool", payload) + archive.writestr("Payload/Spool.app/Frameworks/Qt.framework/Info.plist", plistlib.dumps({})) + return path + + +def source(script: Path, package: Path, notes: Path | None = None) -> subprocess.CompletedProcess: + command = [sys.executable, str(script), str(package), "--download-url", "https://example.org/Spool.ipa", + "--icon-url", "https://example.org/icon.png", "--date", "2026-10-22"] + if notes: + command += ["--notes", str(notes)] + return subprocess.run(command, capture_output=True, text=True) + + +def main() -> None: + script = Path(sys.argv[1]) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + info = {"CFBundleIdentifier": "com.sachk.spool", "CFBundleShortVersionString": "0.9.2", + "CFBundleVersion": "0.9.2", "CFBundleDisplayName": "Spool", "MinimumOSVersion": "17.0", + "NSLocalNetworkUsageDescription": "Servers at home."} + package = ipa(root / "Spool.ipa", info) + notes = root / "notes.md" + notes.write_text("Faster libraries.\n") + result = source(script, package, notes) + assert result.returncode == 0, result.stderr + document = json.loads(result.stdout) + app = document["apps"][0] + version = app["versions"][0] + assert app["bundleIdentifier"] == "com.sachk.spool", "bundle identifier comes from the IPA" + assert (version["version"], version["buildVersion"]) == ("0.9.2", "0.9.2"), "versions come from the IPA" + assert version["minOSVersion"] == "17.0", "minimum iOS comes from the IPA" + assert version["size"] == package.stat().st_size, "size is the published file's" + assert version["downloadURL"] == "https://example.org/Spool.ipa" + assert version["localizedDescription"] == "Faster libraries." + assert app["appPermissions"]["entitlements"] == [], "a free Apple ID can sign no entitlements" + assert app["appPermissions"]["privacy"] == {"NSLocalNetworkUsageDescription": "Servers at home."} + + # An IPA without its versions, or with an ambiguous payload, is not published. + broken = ipa(root / "Broken.ipa", {"CFBundleIdentifier": "com.sachk.spool"}) + assert source(script, broken).returncode != 0, "missing versions fail the release" + ambiguous = root / "Ambiguous.ipa" + with zipfile.ZipFile(ambiguous, "w") as archive: + archive.writestr("Payload/A.app/Info.plist", plistlib.dumps(info)) + archive.writestr("Payload/B.app/Info.plist", plistlib.dumps(info)) + assert source(script, ambiguous).returncode != 0, "two apps in one IPA fail the release" + print("AltStore source checks passed") + + +if __name__ == "__main__": + main() diff --git a/tests/tools/PackageAuditTest.py b/tests/tools/PackageAuditTest.py index 530dd7a3..7830f2b9 100755 --- a/tests/tools/PackageAuditTest.py +++ b/tests/tools/PackageAuditTest.py @@ -69,14 +69,15 @@ def macho_fixture(*, cpu: int = 0x0100000C, platform: int = 3, kind: int = 2, def ipa_fixture(path: Path, *, version: str = "0.9.0", binary: bytes | None = None, - metadata: dict | None = None, extras: tuple = ()) -> bytes: + metadata: dict | None = None, extras: tuple = (), platform: str = "tvOS") -> bytes: info = { "CFBundleExecutable": "Spool", "CFBundlePackageType": "APPL", "CFBundleShortVersionString": version, "CFBundleVersion": version, - "CFBundleSupportedPlatforms": ["AppleTVOS"], "UIDeviceFamily": [3], + "CFBundleSupportedPlatforms": ["AppleTVOS" if platform == "tvOS" else "iPhoneOS"], + "UIDeviceFamily": [3] if platform == "tvOS" else [1, 2], } info.update(metadata or {}) - binary = macho_fixture() if binary is None else binary + binary = macho_fixture(platform=3 if platform == "tvOS" else 2) if binary is None else binary with zipfile.ZipFile(path, "w") as archive: for name, data, mode in ( ("Payload/Spool.app/Info.plist", plistlib.dumps(info, fmt=plistlib.FMT_BINARY), 0o100644), @@ -91,40 +92,44 @@ def ipa_fixture(path: Path, *, version: str = "0.9.0", binary: bytes | None = No return binary -def tvos_contract(script: Path) -> None: +def apple_mobile_contract(script: Path, platform: str) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - ipa = root / "Spool-0.9.0-tvOS-arm64.ipa" + command = "tvos-ipa" if platform == "tvOS" else "ios-ipa" + device_platform = 3 if platform == "tvOS" else 2 + supported_platform = "AppleTVOS" if platform == "tvOS" else "iPhoneOS" + ipa = root / f"Spool-0.9.0-{platform}-arm64.ipa" extracted = root / "extracted" - binary = ipa_fixture(ipa, extras=( + binary = ipa_fixture(ipa, platform=platform, extras=( ("Payload/Spool.app/resources/alias", b"data", stat.S_IFLNK | 0o777), )) - result = run(script, "tvos-ipa", str(ipa), "--version", "0.9.0", "--extract", str(extracted)) + result = run(script, command, str(ipa), "--version", "0.9.0", "--extract", str(extracted)) assert "signing/provisioning and runtime not verified" in result.stdout app = extracted / "Payload/Spool.app" assert (app / "Spool").read_bytes() == binary assert (app / "Spool").stat().st_mode & 0o111 assert (app / "resources/alias").is_symlink() assert (app / "resources/alias").read_bytes() == b"bundled-resource" - nonempty = run(script, "tvos-ipa", str(ipa), "--version", "0.9.0", + nonempty = run(script, command, str(ipa), "--version", "0.9.0", "--extract", str(extracted), expected=1) assert "destination must be empty" in nonempty.stderr cases = ( ({"version": "0.8.0"}, "embedded bundle version"), ({"metadata": {"CFBundleVersion": "99"}}, "embedded bundle version"), - ({"metadata": {"CFBundleSupportedPlatforms": ["AppleTVSimulator"]}}, "AppleTVOS"), - ({"metadata": {"UIDeviceFamily": [1, 2]}}, "device family"), + ({"metadata": {"CFBundleSupportedPlatforms": ["iPhoneSimulator"]}}, supported_platform), + ({"metadata": {"UIDeviceFamily": [1, 2] if platform == "tvOS" else [3]}}, "device family"), ({"metadata": {"CFBundleExecutable": "../Spool"}}, "Spool application executable"), ({"binary": b"not Mach-O"}, "not Mach-O"), ({"binary": macho_fixture(cpu=0x01000007)}, "architecture"), - ({"binary": macho_fixture(platform=8)}, "device tvOS"), - ({"binary": macho_fixture(platform=2)}, "device tvOS"), - ({"binary": macho_fixture(kind=6)}, "not an executable"), - ({"binary": macho_fixture()[:-1]}, "load command bounds"), - ({"binary": macho_fixture(dependency="@rpath/missing.dylib", + ({"binary": macho_fixture(platform=8 if platform == "tvOS" else 7)}, f"device {platform}"), + ({"binary": macho_fixture(platform=2 if platform == "tvOS" else 3)}, f"device {platform}"), + ({"binary": macho_fixture(platform=device_platform, kind=6)}, "not an executable"), + ({"binary": macho_fixture(platform=device_platform)[:-1]}, "load command bounds"), + ({"binary": macho_fixture(platform=device_platform, dependency="@rpath/missing.dylib", rpath="@executable_path/Frameworks")}, "unresolved Mach-O dependency"), - ({"binary": macho_fixture(dependency="/nix/store/build/lib.dylib")}, "forbidden Mach-O dependency"), + ({"binary": macho_fixture(platform=device_platform, dependency="/nix/store/build/lib.dylib")}, + "forbidden Mach-O dependency"), ({"extras": (("../escape", b"bad", 0o100644),)}, "unsafe or unexpected IPA member"), ({"extras": (("Payload/Spool.app/../../escape", b"bad", 0o100644),)}, "unsafe or unexpected"), ({"extras": (("/Payload/Spool.app/escape", b"bad", 0o100644),)}, "unsafe or unexpected"), @@ -142,23 +147,37 @@ def tvos_contract(script: Path) -> None: ({"extras": (("Payload/Spool.app/resources/link", b"data", stat.S_IFLNK | 0o777), ("Payload/Spool.app/resources/link/file", b"bad", 0o100644))}, "non-directory IPA ancestor"), - ({"extras": (("Payload/Spool.app/orphan.dylib", macho_fixture(kind=6), 0o100755),)}, + ({"extras": (("Payload/Spool.app/orphan.dylib", + macho_fixture(platform=device_platform, kind=6), 0o100755),)}, "unreferenced packaged Mach-O"), ) for options, reason in cases: - ipa_fixture(ipa, **options) - rejected = run(script, "tvos-ipa", str(ipa), "--version", "0.9.0", expected=1) + ipa_fixture(ipa, platform=platform, **options) + rejected = run(script, command, str(ipa), "--version", "0.9.0", expected=1) assert reason in rejected.stderr, (options, rejected.stderr) dependency = "@rpath/helper.dylib" - ipa_fixture(ipa, binary=macho_fixture(dependency=dependency, rpath="@executable_path/Frameworks"), - extras=(("Payload/Spool.app/Frameworks/helper.dylib", macho_fixture(kind=6), 0o100755),)) - run(script, "tvos-ipa", str(ipa), "--version", "0.9.0") - ipa_fixture(ipa, binary=macho_fixture(dependency="/System/Library/Frameworks/UIKit.framework/UIKit")) - run(script, "tvos-ipa", str(ipa), "--version", "0.9.0") - wrong = root / "Spool-0.9.0-tvOS-simulator-arm64.ipa" + ipa_fixture(ipa, platform=platform, + binary=macho_fixture(platform=device_platform, dependency=dependency, + rpath="@executable_path/Frameworks"), + extras=(("Payload/Spool.app/Frameworks/helper.dylib", + macho_fixture(platform=device_platform, kind=6), 0o100755),)) + run(script, command, str(ipa), "--version", "0.9.0") + # A device application must not hide a simulator dependency in Frameworks. + ipa_fixture(ipa, platform=platform, + binary=macho_fixture(platform=device_platform, dependency=dependency, + rpath="@executable_path/Frameworks"), + extras=(("Payload/Spool.app/Frameworks/helper.dylib", + macho_fixture(platform=8 if platform == "tvOS" else 7, kind=6), 0o100755),)) + assert f"device {platform}" in run( + script, command, str(ipa), "--version", "0.9.0", expected=1 + ).stderr + ipa_fixture(ipa, platform=platform, binary=macho_fixture( + platform=device_platform, dependency="/System/Library/Frameworks/UIKit.framework/UIKit")) + run(script, command, str(ipa), "--version", "0.9.0") + wrong = root / f"Spool-0.9.0-{platform}-simulator-arm64.ipa" ipa.rename(wrong) - rejected = run(script, "tvos-ipa", str(wrong), "--version", "0.9.0", expected=1) - assert "named Spool-0.9.0-tvOS-arm64.ipa" in rejected.stderr + rejected = run(script, command, str(wrong), "--version", "0.9.0", expected=1) + assert f"named Spool-0.9.0-{platform}-arm64.ipa" in rejected.stderr def website_contract(script: Path) -> None: @@ -193,6 +212,26 @@ def website_contract(script: Path) -> None: assert tvos["size"] == len(b"package-protocol-fixture") assert tvos["url"].endswith("/v0.9.0/Spool-0.9.0-tvOS-arm64.ipa") publisher_contract(script.with_name("publish-website-metadata.py"), output.read_bytes()) + ios_file = assets / "Spool-0.9.0-iOS-arm64.ipa" + ios_file.write_bytes(b"ios-device-package-fixture") + run(script, *args) + ios_manifest = json.loads(output.read_text()) + ios_platforms = {item["id"]: item for item in ios_manifest["release"]["platforms"]} + assert list(ios_platforms) == ["webos", "android", "windows", "macos", "linux", "tvos", "ios"] + ios = ios_platforms["ios"]["downloads"][0] + assert ios["name"] == ios_file.name + assert ios["architecture"] == "arm64" and ios["format"] == "ipa" + assert ios["signing"] == "unsigned" + assert ios["sha256"] == hashlib.sha256(b"ios-device-package-fixture").hexdigest() + assert ios["size"] == len(b"ios-device-package-fixture") + assert ios["url"].endswith("/v0.9.0/Spool-0.9.0-iOS-arm64.ipa") + publisher_contract(script.with_name("publish-website-metadata.py"), output.read_bytes()) + ios_file.write_bytes(b"") + assert "empty" in run(script, *args, expected=1).stderr + ios_file.unlink() + ios_file.symlink_to(assets / names[0]) + assert "regular final package" in run(script, *args, expected=1).stderr + ios_file.unlink() for name in names: package = assets / name package.unlink() @@ -200,6 +239,7 @@ def website_contract(script: Path) -> None: assert "expected exactly one" in rejected.stderr, rejected.stderr package.write_bytes(b"package-protocol-fixture") for name in ("Spool-0.9.0-tvOS-simulator-arm64.ipa", "Spool-0.8.0-tvOS-arm64.ipa", + "Spool-0.9.0-iOS-simulator-arm64.ipa", "Spool-0.8.0-iOS-arm64.ipa", "new-platform.ipa", "new-platform.apk"): extra = assets / name extra.write_bytes(b"extra") @@ -225,16 +265,27 @@ def workflow_artifact_contract(script: Path) -> None: shutil.copyfile(script, checker) release = workflows / "release.yml" release.write_text("pattern: spool-*\n") - for names, expected, reason in ( - (("spool-linux", "spool-tvos-device-arm64", "internal-tvos-simulator-arm64"), 0, "checked"), - (("spool-linux",), 1, "exactly one spool-tvos-device-arm64"), - (("spool-tvos-device-arm64", "spool-tvos-simulator-arm64"), 1, "must be internal-*"), - (("spool-tvos-device-arm64", "spool-tvos-device-arm64"), 1, "exactly one"), - (("spool-tvos-device-arm64", "loose-build"), 1, "neither spool-*"), + ios_workflow = workflows / "ios-review.yml" + for names, ios_names, expected, reason in ( + (("spool-linux", "spool-tvos-device-arm64", "internal-tvos-simulator-arm64"), + ("spool-ios-device-arm64", "internal-ios-simulator-x86_64"), 0, "checked"), + (("spool-linux",), ("spool-ios-device-arm64",), 1, "exactly one spool-tvos-device-arm64"), + (("spool-tvos-device-arm64", "spool-tvos-simulator-arm64"), + ("spool-ios-device-arm64",), 1, "must be internal-*"), + (("spool-tvos-device-arm64", "spool-tvos-device-arm64"), + ("spool-ios-device-arm64",), 1, "exactly one"), + (("spool-tvos-device-arm64", "loose-build"), + ("spool-ios-device-arm64",), 1, "neither spool-*"), + (("spool-tvos-device-arm64",), (), 1, "exactly one spool-ios-device-arm64"), + (("spool-tvos-device-arm64",), ("spool-ios-device-arm64", "spool-ios-device-arm64"), + 1, "exactly one spool-ios-device-arm64"), + (("spool-tvos-device-arm64",), ("spool-ios-device-arm64", "spool-ios-simulator-x86_64"), + 1, "must be internal-*"), ): - (workflows / "build-artifacts.yml").write_text("".join( - f"- uses: actions/upload-artifact@v4\n with:\n name: {name}\n" for name in names - )) + for workflow, uploads in ((workflows / "build-artifacts.yml", names), (ios_workflow, ios_names)): + workflow.write_text("".join( + f"- uses: actions/upload-artifact@v4\n with:\n name: {name}\n" for name in uploads + )) result = subprocess.run(["bash", str(checker)], text=True, capture_output=True) assert result.returncode == expected and reason in result.stdout + result.stderr, result release.write_text("pattern: *\n") @@ -349,7 +400,8 @@ def main() -> int: (root / "lib/libpackage-audit-dep.so").unlink() missing = run(script, "elf", str(root), "--root", "app", "--allow-system", "libc.so.6", expected=1) assert "missing ELF dependency" in missing.stderr - tvos_contract(script) + for platform in ("tvOS", "iOS"): + apple_mobile_contract(script, platform) website_contract(script.with_name("generate-website-manifest.py")) workflow_artifact_contract(script.with_name("check-release-artifacts.sh")) return 0 diff --git a/tests/tools/PlayerSetupReportTest.py b/tests/tools/PlayerSetupReportTest.py new file mode 100755 index 00000000..bebac484 --- /dev/null +++ b/tests/tools/PlayerSetupReportTest.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""The Actions report gates sustained disasters, not noisy hosted-runner samples.""" +import copy +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def report(): + return {"schemaVersion": 1, "complete": True, "samples": [ + {"mode": mode, "constructCallMs": 2, "playCallMs": 5, + "fileLoadedMs": 80, "firstFrameMs": 100, + "playHeartbeatMaxGapMs": 20, "preparationReadyMs": 3100 if mode == "prepared" else 0, + "setupWorkerMs": 100, + "preparationHeartbeatMaxGapMs": 20 if mode == "prepared" else 0, + "preparationHeartbeats": 300 if mode == "prepared" else 0, + "preparedAdopted": mode == "prepared"} + for mode in ("cold", "prepared") for _ in range(3)]} + + +def run(script, value, expected): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "setup.json" + markdown = Path(directory) / "summary.md" + source.write_text(json.dumps(value), encoding="utf-8") + process = subprocess.run([sys.executable, str(script), str(source), "--markdown", str(markdown)], + text=True, capture_output=True, check=False) + assert process.returncode == expected, (process.returncode, process.stdout, process.stderr) + assert markdown.read_text(encoding="utf-8") == process.stdout + return process.stdout + + +def main(): + script = Path(sys.argv[1]) + healthy = report() + assert "| prepared | file loaded | 80.0 |" in run(script, healthy, 0) + # One descheduled sample stays visible, but medians remain healthy. + outlier = report() + outlier["samples"][0].update(fileLoadedMs=24000, firstFrameMs=25000, playHeartbeatMaxGapMs=12000) + assert "25000.0" in run(script, outlier, 0) + for mode in ("cold", "prepared"): + for metric in ("playCallMs", "constructCallMs", "playHeartbeatMaxGapMs", + "fileLoadedMs", "firstFrameMs"): + slow = report() + for sample in slow["samples"]: + if sample["mode"] == mode: + sample[metric] = 24000 + if metric == "fileLoadedMs": + sample["firstFrameMs"] = 25000 + assert "Egregious" in run(script, slow, 1) + for metric, value in (("preparationHeartbeatMaxGapMs", 3000), ("setupWorkerMs", 16000)): + slow = report() + for sample in slow["samples"][3:]: + sample[metric] = value + run(script, slow, 1) + # Unmeasured or nonfinite values must never silently become a fast run. + for bad in (None, -1, True, "1", float("nan"), float("inf")): + invalid = report() + invalid["samples"][0]["playCallMs"] = bad + run(script, invalid, 1) + for change in (lambda value: value.update(complete=False), + lambda value: value.update(samples=value["samples"][:3]), + lambda value: value["samples"][3].update(preparedAdopted=False), + lambda value: value["samples"][0].update(preparedAdopted=True), + lambda value: value["samples"][3].update(preparationHeartbeats=0), + lambda value: value["samples"][0].pop("firstFrameMs")): + invalid = copy.deepcopy(healthy) + change(invalid) + assert "Measurement failed" in run(script, invalid, 1) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/tools/RenderBenchmarkReportTest.py b/tests/tools/RenderBenchmarkReportTest.py index 798310f8..c884c110 100755 --- a/tests/tools/RenderBenchmarkReportTest.py +++ b/tests/tools/RenderBenchmarkReportTest.py @@ -26,7 +26,8 @@ def report(walls: dict[str, list[float]], *, cpu: float = 2.0, gap: float = 0.0, def run(script: Path, current: dict, *, baseline: dict | None = None, - warn_only: bool = False, expected: int = 0) -> str: + warn_only: bool = False, expected: int = 0, require_comparable: bool = False, + baseline_missing: bool = False) -> str: with tempfile.TemporaryDirectory() as directory: root = Path(directory) current_path = root / "current.json" @@ -36,6 +37,10 @@ def run(script: Path, current: dict, *, baseline: dict | None = None, baseline_path = root / "baseline.json" baseline_path.write_text(json.dumps(baseline), encoding="utf-8") args += ["--baseline", str(baseline_path)] + elif baseline_missing: + args += ["--baseline", str(root / "missing.json")] + if require_comparable: + args.append("--require-comparable") if warn_only: args.append("--warn-only") result = subprocess.run(args, text=True, capture_output=True, check=False) @@ -96,6 +101,35 @@ def main() -> int: # Warning-only must not hide a broken measurement with no samples. run(script, report({}), warn_only=True, expected=1) + + # Present-but-unmeasured values must not silently become zero, NaN must + # not disable regression comparisons, and a zero swap count is no frame. + for key in ("wallMs", "guiCpuMs", "instanceMs", "maxGapMs", "actualSwaps", "frameBudgetMs"): + for value in (None, -1, float("nan"), float("inf"), True, "1"): + invalid = report({"home": [20.0] * 4}) + invalid["samples"][0][key] = value + run(script, invalid, warn_only=True, expected=1) + missing = report({"home": [20.0] * 4}) + del missing["samples"][0][key] + run(script, baseline, baseline=missing, warn_only=True, expected=1) + for key in ("wallMs", "actualSwaps", "frameBudgetMs"): + unmeasured = report({"home": [20.0] * 4}) + unmeasured["samples"][0][key] = 0 + run(script, unmeasured, expected=1) + run(script, baseline, baseline=report({}), warn_only=True, expected=1) + + # Local A/B runs can demand proof of comparable work; warning-only must + # not turn absent/incompatible baselines into a successful strict run. + run(script, baseline, baseline=baseline, require_comparable=True) + run(script, baseline, baseline_missing=True) + run(script, baseline, baseline_missing=True, require_comparable=True, warn_only=True, expected=1) + run(script, baseline, require_comparable=True, expected=1) + for key, value in (("qtVersion", "different"), ("providerRuntime", "different"), + ("settleMs", 500), ("iterations", 1), ("warmupPasses", 2)): + incompatible = report({"home": [20.0] * 4, "search": [20.0] * 4}) + incompatible[key] = value + run(script, incompatible, baseline=baseline, require_comparable=True, + warn_only=True, expected=1) return 0 diff --git a/tests/tools/WebosToolingTest.py b/tests/tools/WebosToolingTest.py new file mode 100755 index 00000000..d2309f53 --- /dev/null +++ b/tests/tools/WebosToolingTest.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Exercise webOS orchestration at its CLI/transport boundaries; no TV needed.""" +import json +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import tempfile +import textwrap +import time +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +STUB = r''' +import json, os, pathlib, subprocess, sys, time +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +with open(os.environ['CALLS'], 'a') as out: + out.write(json.dumps([name, args]) + '\n') +scenario = os.environ.get('SCENARIO', '') +if name == 'ssh': + command = args[-1] + if scenario == 'capture-local': + raise SystemExit(subprocess.run(command, shell=True, stdin=sys.stdin).returncode) + if scenario == 'hang': + time.sleep(60) + if 'listLaunchPoints' in command: + app = 'com.sachk.spool.other' if scenario == 'wrong-app' else 'com.sachk.spool' + print(json.dumps(dict(returnValue=True, launchPoints=[dict(appId=app)]))) + elif 'getAppInfo' in command: + print('{"returnValue":true,"appInfo":{"id":"com.sachk.spool"}}') + elif 'executeOneShot' in command: + print('{"returnValue":false,"errorText":"capture unavailable"}' if scenario == 'capture-fail' else '{"returnValue":true}') + elif 'power/getPowerState' in command: + print(json.dumps(dict(returnValue=True, state='Standby' if scenario == 'asleep' else 'Active'))) + elif 'hbchannel.service/install' in command: + print('{"returnValue":true,"finished":true}', flush=True) + time.sleep(60) + elif 'luna://' in command: + print('{"returnValue":true}') +elif name == 'scp': + target = pathlib.Path(args[-1]) + if target.suffix == '.json': + target.write_text(json.dumps({'samples': [dict(routeTo='home', wallMs=5, guiCpuMs=1, + instanceMs=1, actualSwaps=1, maxGapMs=0, frameBudgetMs=16.67)]})) + else: + target.write_bytes(b'nonempty screenshot') +elif name == 'ip': + print('127.0.0.1 dev lo src 127.0.0.1') +elif name == 'getent': + print('127.0.0.1 STREAM stub.invalid') +elif name == 'nix': + if scenario == 'profile-timeout': + print(os.environ['FAKE_HEAPTRACK']) + raise SystemExit(0) + raise SystemExit('nix must not be entered for help or invalid arguments') +''' + + +class WebosToolingTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.work = pathlib.Path(temp.name) + self.bin = self.work / 'bin' + self.bin.mkdir() + self.calls_path = self.work / 'calls.jsonl' + self.env = os.environ | {'PATH': str(self.bin) + os.pathsep + os.environ['PATH'], + 'CALLS': str(self.calls_path), 'TV_HOST': 'root@stub.invalid', + 'WEBOS_COMMAND_TIMEOUT': '2'} + for name in ('ssh', 'scp', 'ares-launch', 'ip', 'curl', 'getent', 'nix'): + self.executable(self.bin / name, STUB, python=True) + + def executable(self, path, body, python=False): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text((f'#!{sys.executable}\n' if python else '#!/usr/bin/env bash\n') + textwrap.dedent(body)) + path.chmod(0o755) + + def run_script(self, relative, *args, root=ROOT, env=None): + return subprocess.run(['bash', str(root / relative), *map(str, args)], cwd=self.work, + env=self.env | (env or {}), text=True, capture_output=True, timeout=15) + + def calls(self): + return [json.loads(line) for line in self.calls_path.read_text().splitlines()] if self.calls_path.exists() else [] + + def verify(self, *args, env=None, root=ROOT): + return self.run_script('tools/webos/verify-device.sh', '--out', self.work / 'verify', *args, env=env, root=root) + + def assert_success(self, result): + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_help_and_invalid_arguments_have_no_external_side_effects(self): + scripts = ('build-ipk.sh', 'tools/webos-native/build-webos.sh', + 'tools/webos-native/build-third-party.sh', 'tools/webos-native/build-curl.sh', + 'tools/webos-native/build-qcoro.sh', 'tools/webos-native/build-ffmpeg.sh', + 'tools/webos/verify-device.sh', 'tools/webos/capture-memory.sh', + 'tools/webos/profile-memory.sh', 'tools/webos/bench-device.sh', + 'tools/webos/package-heaptrack-profile.sh') + for script in scripts: + with self.subTest(script=script): + self.assert_success(self.run_script(script, '--help')) + self.assertEqual(self.run_script(script, '--invalid').returncode, 2) + for script, option in (('verify-device.sh', '--host'), ('profile-memory.sh', '--duration'), + ('capture-memory.sh', '--out'), ('bench-device.sh', '--library')): + self.assertEqual(self.run_script('tools/webos/' + script, option).returncode, 2) + for script, arguments in ( + ('verify-device.sh', ('--app-id', "invalid';id")), + ('verify-device.sh', ('--install-timeout', '0')), + ('capture-memory.sh', ('--duration', '0')), + ('capture-memory.sh', ('--interval', '1;id')), + ('profile-memory.sh', ('--duration', '-1'))): + self.assertEqual(self.run_script('tools/webos/' + script, *arguments).returncode, 2) + self.assertEqual(self.calls(), []) + + def test_read_only_verification_never_launches_or_captures(self): + self.assert_success(self.verify('--no-install')) + calls = self.calls() + self.assertEqual([name for name, _ in calls], ['ssh', 'ssh']) + self.assertIn('listLaunchPoints', calls[0][1][-1]) + self.assertIn('getAppInfo', calls[1][1][-1]) + self.assertTrue(all('BatchMode=yes' in args and 'ConnectTimeout=10' in args for _, args in calls)) + + def test_registration_requires_exact_app_identity(self): + result = self.verify('--no-install', env={'SCENARIO': 'wrong-app'}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('not registered', result.stderr) + self.assertEqual(len(self.calls()), 1) + + def test_explicit_launch_captures_and_cleans_only_its_screenshot(self): + self.assert_success(self.verify('--no-install', '--launch', '--launch-wait', '0')) + calls = self.calls() + self.assertEqual(sum(name == 'ares-launch' for name, _ in calls), 1) + self.assertTrue((self.work / 'verify/screenshot.png').is_file()) + removes = [args[-1] for name, args in calls if name == 'ssh' and 'rm -f' in args[-1]] + self.assertEqual(len(removes), 1) + self.assertIn('/tmp/com.sachk.spool-verify-', removes[0]) + + def test_capture_service_rejection_is_not_a_successful_verification(self): + result = self.verify('--no-install', '--launch', '--launch-wait', '0', env={'SCENARIO': 'capture-fail'}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('screenshot capture failed', result.stderr) + self.assertFalse(any(name == 'scp' for name, _ in self.calls())) + + def test_unresponsive_transport_is_bounded(self): + result = self.verify('--no-install', env={'SCENARIO': 'hang', 'WEBOS_COMMAND_TIMEOUT': '1'}) + self.assertEqual(result.returncode, 124) + self.assertFalse(any(name == 'ares-launch' for name, _ in self.calls())) + + def test_profile_wait_failure_disarms_without_launching_or_closing_app(self): + heaptrack = self.work / 'heaptrack' + for relative in ('lib/heaptrack/libexec/heaptrack_interpret', 'bin/heaptrack_print'): + self.executable(heaptrack / relative, 'exit 0\n') + result = self.run_script('tools/webos/profile-memory.sh', '--wait-timeout', '1', + '--out', self.work / 'profile', env={'SCENARIO': 'profile-timeout', 'FAKE_HEAPTRACK': str(heaptrack)}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Timed out waiting', result.stderr) + commands = [args[-1] for name, args in self.calls() if name == 'ssh'] + self.assertTrue(any(": > '/tmp/spool-heaptrack.on'" in command for command in commands)) + self.assertEqual(commands[-1], "rm -f '/tmp/spool-heaptrack.on'") + self.assertFalse(any('applicationManager/' in command for command in commands)) + + def copy_script(self, root, relative): + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / relative, destination) + + def test_default_install_chooses_mtime_not_lexical_version_and_never_launches(self): + root = self.work / 'checkout' + for relative in ('tools/webos/verify-device.sh', 'tools/webos/device-common.sh', 'tools/webos/luna-response.py'): + self.copy_script(root, relative) + build = root / 'build' + build.mkdir() + older = build / 'com.sachk.spool_99.0.0_arm.ipk' + newer = build / 'com.sachk.spool_1.0.0_arm.ipk' + older.write_bytes(b'old'); newer.write_bytes(b'new') + os.utime(older, (10, 10)); os.utime(newer, (20, 20)) + with socket.socket() as available: + available.bind(('127.0.0.1', 0)) + port = available.getsockname()[1] + self.assert_success(self.verify('--http-port', port, root=root)) + calls = self.calls() + install = next(args[-1] for name, args in calls if name == 'ssh' and 'hbchannel.service/install' in args[-1]) + self.assertIn(newer.name, install) + self.assertNotIn(older.name, install) + self.assertFalse(any(name == 'ares-launch' for name, _ in calls)) + route = next(args for name, args in calls if name == 'ip') + self.assertEqual(route, ['route', 'get', '127.0.0.1']) + self.assertTrue(all('root@stub.invalid' in args for name, args in calls if name == 'ssh')) + self.calls_path.unlink() + self.assert_success(self.verify('--http-port', port, '--host', 'root@127.0.0.1', root=root)) + self.assertTrue(all('root@127.0.0.1' in args for name, args in self.calls() if name == 'ssh')) + + def test_capture_clamps_large_interval_and_preserves_completed_csv(self): + output = self.work / 'capture.csv' + started = time.monotonic() + result = self.run_script('tools/webos/capture-memory.sh', '--duration', '1', + '--interval', '120', '--wait-timeout', '1', '--no-plot', '--out', output, + env={'SCENARIO': 'capture-local'}) + self.assert_success(result) + self.assertLess(time.monotonic() - started, 5) + self.assertGreater(len(output.read_text().splitlines()), 1) + + def test_benchmark_never_patches_installed_tree_or_wakes_panel(self): + result = self.run_script('tools/webos/bench-device.sh', '--out', self.work / 'bench') + self.assert_success(result) + commands = [args[-1] for name, args in self.calls() if name == 'ssh'] + for command in commands: + self.assertNotIn('chown', command) + self.assertNotIn('mv spool', command) + self.assertNotIn('turnOnScreen', command) + self.assertNotIn('com.webos.app.hdmi', command) + self.assertTrue(any("printf '%s\\n'" in command and '/tmp/spool-benchmark.on' in command for command in commands)) + self.assertTrue(any('applicationManager/launch' in command for command in commands)) + + def test_sleeping_panel_is_rejected_before_benchmark_mutation(self): + result = self.run_script('tools/webos/bench-device.sh', env={'SCENARIO': 'asleep'}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(self.calls()), 1) + + def test_packaged_shim_forwards_data_without_evaluating_shell(self): + app = self.work / 'app' + (app / 'bin').mkdir(parents=True) + self.copy_script(app, 'tools/webos/heaptrack-launch-shim.sh') + shutil.copy2(app / 'tools/webos/heaptrack-launch-shim.sh', app / 'bin/spool') + self.executable(app / 'bin/spool.real', 'import json,os; print(json.dumps({key:value for key,value in os.environ.items() if key.startswith("SPOOL_BENCH")}))\n', python=True) + marker = self.work / 'benchmark.marker' + sentinel = self.work / 'must-not-execute' + library = f"Movies'; touch '{sentinel}'; '" + marker.write_text('\n'.join(('spool-benchmark-v1', 'routes', '2', library, '1', '0', '3', '/tmp/report.json')) + '\n') + result = self.run_script('bin/spool', root=app, env={'SPOOL_BENCH_MARKER': str(marker), + 'SPOOL_HEAPTRACK_MARKER': str(self.work / 'absent'), 'SPOOL_BIGALLOC_MARKER': str(self.work / 'absent')}) + self.assert_success(result) + forwarded = json.loads(result.stdout) + self.assertEqual(forwarded['SPOOL_BENCH_LIBRARY'], library) + self.assertEqual(forwarded['SPOOL_BENCH_OUT'], '/tmp/report.json') + self.assertFalse(sentinel.exists()) + marker.write_text('spool-benchmark-v1\nroutes\n') + self.assert_success(result := self.run_script('bin/spool', root=app, env={'SPOOL_BENCH_MARKER': str(marker)})) + self.assertNotIn('SPOOL_BENCH', json.loads(result.stdout)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/analyze-release-artifacts.sh b/tools/analyze-release-artifacts.sh index b60fa1f1..9e2d3fec 100755 --- a/tools/analyze-release-artifacts.sh +++ b/tools/analyze-release-artifacts.sh @@ -59,14 +59,20 @@ for asset in "${assets[@]}"; do [[ -n "$data_archive" ]] || { echo "missing IPK data archive: $base" >&2; exit 1; } tar -xf "$data_archive" -C "$root" ;; - *.ipa) + *-tvOS-arm64.ipa|*-iOS-arm64.ipa) # Audit archive paths, embedded version, device platform and Mach-O closure # before letting scanners read any extracted unsigned/signable payload. - python3 "$root_dir/tools/package-audit.py" tvos-ipa "$asset" \ + ipa_platform=tvos + [[ "$asset" != *-iOS-arm64.ipa ]] || ipa_platform=ios + python3 "$root_dir/tools/package-audit.py" "$ipa_platform-ipa" "$asset" \ --version "$version" --extract "$root" python3 "$root_dir/tools/package-audit.py" inventory "$root" \ --output "$report_dir/$base.inventory.tsv" ;; + *.ipa) + echo "unrecognized IPA platform: $base" >&2 + exit 1 + ;; esac "$SYFT" "dir:$root" --source-name spool --source-version "$version" \ -o "cyclonedx-json=$report_dir/$base.cdx.json" \ diff --git a/tools/apple/altstore-source.py b/tools/apple/altstore-source.py new file mode 100755 index 00000000..44b51c13 --- /dev/null +++ b/tools/apple/altstore-source.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Write the SideStore/AltStore source for a release's unsigned iOS IPA. + +SideStore and AltStore re-sign an IPA with the viewer's own Apple ID, so the +unsigned release IPA installs on an iPhone without a developer account. A +source is the JSON they poll for updates. Everything they compare against the +installed app -- bundle identifier, version, build number, minimum iOS -- is +read from the IPA's own Info.plist rather than restated, because a source +that disagrees with its IPA is refused at install time. +""" + +import argparse +import json +import os +import plistlib +import sys +import zipfile + +DESCRIPTION = ( + "Spool plays your own media: Jellyfin, Plex, Emby and more, with a fast library that stays smooth " + "however large it grows. This build is installed and refreshed through SideStore or AltStore with " + "your own Apple ID." +) + + +def bundle_info(ipa): + with zipfile.ZipFile(ipa) as archive: + names = [name for name in archive.namelist() + if name.startswith("Payload/") and name.endswith(".app/Info.plist") and name.count("/") == 2] + if len(names) != 1: + raise SystemExit(f"{ipa}: expected exactly one Payload/*.app/Info.plist, found {len(names)}") + return plistlib.loads(archive.read(names[0])) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("ipa") + parser.add_argument("--download-url", required=True, help="where the IPA is published") + parser.add_argument("--icon-url", required=True) + parser.add_argument("--date", required=True, help="release date, YYYY-MM-DD") + parser.add_argument("--notes", help="file with this version's release notes") + parser.add_argument("--website", default="https://github.com/spool-player/spool") + args = parser.parse_args() + + info = bundle_info(args.ipa) + for key in ("CFBundleIdentifier", "CFBundleShortVersionString", "CFBundleVersion"): + if not info.get(key): + raise SystemExit(f"{args.ipa}: Info.plist has no {key}") + notes = open(args.notes, encoding="utf-8").read().strip() if args.notes else "" + version = { + "version": info["CFBundleShortVersionString"], + "buildVersion": info["CFBundleVersion"], + "date": args.date, + "downloadURL": args.download_url, + "size": os.path.getsize(args.ipa), + "minOSVersion": info.get("MinimumOSVersion", "17.0"), + } + if notes: + version["localizedDescription"] = notes + privacy = {key: value for key, value in info.items() if key.startswith("NS") and key.endswith("UsageDescription")} + source = { + "name": "Spool", + "identifier": info["CFBundleIdentifier"] + ".source", + "subtitle": "Your media, on every screen", + "description": DESCRIPTION, + "iconURL": args.icon_url, + "website": args.website, + "tintColor": "#0077A0", + "apps": [{ + "name": info.get("CFBundleDisplayName", "Spool"), + "bundleIdentifier": info["CFBundleIdentifier"], + "developerName": "Spool", + "subtitle": "Plays your Jellyfin, Plex and Emby libraries", + "localizedDescription": DESCRIPTION, + "iconURL": args.icon_url, + "tintColor": "#0077A0", + "category": "entertainment", + "screenshots": [], + "versions": [version], + # Free Apple IDs can sign only ordinary apps: Spool asks for no + # entitlements, which is what keeps it installable that way. + "appPermissions": {"entitlements": [], "privacy": privacy}, + }], + "news": [], + } + json.dump(source, sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tools/apple/build-ios-scanner.sh b/tools/apple/build-ios-scanner.sh new file mode 100755 index 00000000..ef8b9448 --- /dev/null +++ b/tools/apple/build-ios-scanner.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT/tools/lib/manifest-sources.sh" +[[ "$(uname -s)" == Darwin ]] || { echo 'iOS host tools require macOS and Xcode.' >&2; exit 1; } +: "${QT_HOST_PATH:?Set QT_HOST_PATH to the pinned macOS Qt kit}" +manifest="$ROOT/tools/manifests/toolchain.json" +version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["qt"]["version"])' "$manifest")" +[[ "$("$QT_HOST_PATH/bin/qtpaths" --query QT_VERSION)" == "$version" ]] || { + echo "error: QT_HOST_PATH must supply Qt $version host tools" >&2; exit 1; +} +base_url="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["qt"]["baseUrl"])' "$manifest")" +sha="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["qt"]["sources"]["qtdeclarative"])' "$manifest")" +series="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["qt"]["series"])' "$manifest")" +source_root="$ROOT/build/apple/qt-sources/$version/qtdeclarative" +archive="$ROOT/build/apple/downloads/qtdeclarative-everywhere-src-$version.tar.xz" +download_verified "$base_url/qtdeclarative-everywhere-src-$version.tar.xz" "$sha" "$archive" +extract_verified_source "$archive" "$sha" "$source_root" +# Use the same pinned scanner and subtree-exclusion fix as the tvOS build. +patch_file="$ROOT/tools/webos-native/patches/qtdeclarative-$series-qmlimportscanner-exclude-subtrees.patch" +if gpatch --dry-run --forward --silent -d "$source_root" -p1 <"$patch_file" >/dev/null 2>&1; then + gpatch --forward -d "$source_root" -p1 <"$patch_file" +elif ! gpatch --dry-run --reverse --silent -d "$source_root" -p1 <"$patch_file" >/dev/null 2>&1; then + echo 'error: pinned host scanner patch no longer applies' >&2; exit 1 +fi +"$QT_HOST_PATH/bin/qt-cmake" -S "$ROOT/tools/apple/qmlimportscanner" \ + -B "$ROOT/build/apple/qmlimportscanner" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DSPOOL_QTDECLARATIVE_SOURCE="$source_root" +cmake --build "$ROOT/build/apple/qmlimportscanner" --parallel "${BUILD_JOBS:-4}" diff --git a/tools/build-ios.sh b/tools/build-ios.sh new file mode 100755 index 00000000..2b4ed977 --- /dev/null +++ b/tools/build-ios.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[[ "$(uname -s)" == Darwin ]] || { echo 'iOS builds require macOS and Xcode.' >&2; exit 1; } +: "${QT_TARGET_PREFIX:?Set QT_TARGET_PREFIX to the iOS Qt kit}" +: "${QT_HOST_PATH:?Set QT_HOST_PATH to the matching macOS Qt kit}" +export APPLE_SYSTEM=iOS +export APPLE_SDK="${APPLE_SDK:-iphoneos}" +if [[ "$APPLE_SDK" == iphonesimulator ]]; then + export APPLE_ARCH="${APPLE_ARCH:-x86_64}" +else + export APPLE_ARCH="${APPLE_ARCH:-arm64}" +fi +export APPLE_DEPLOYMENT_TARGET="${APPLE_DEPLOYMENT_TARGET:-17.0}" +case "$APPLE_SDK:$APPLE_ARCH" in + iphoneos:arm64|iphonesimulator:arm64|iphonesimulator:x86_64) ;; + *) echo "error: unsupported iOS SDK/architecture: $APPLE_SDK/$APPLE_ARCH" >&2; exit 1 ;; +esac +# SPOOL_APPLE_APP_STORE=OFF builds the unsigned sideload flavour: every +# bundled provider and provider downloads, as on Android. Store submissions +# keep the default. +case "${SPOOL_APPLE_APP_STORE:-ON}" in + ON|OFF) ;; + *) echo "error: SPOOL_APPLE_APP_STORE must be ON or OFF" >&2; exit 1 ;; +esac +if [[ "${APPLE_ARCHIVE:-0}" == 1 ]]; then + [[ "$APPLE_SDK" == iphoneos ]] || { echo 'Signed archives require APPLE_SDK=iphoneos.' >&2; exit 1; } + [[ "${SPOOL_APPLE_APP_STORE:-ON}" == ON ]] || { echo 'Store archives require SPOOL_APPLE_APP_STORE=ON.' >&2; exit 1; } + : "${APPLE_DEVELOPMENT_TEAM:?Archiving requires a signing team}" + : "${APPLE_EXPORT_OPTIONS:?Set APPLE_EXPORT_OPTIONS to your ExportOptions.plist}" +fi +xcrun --sdk "$APPLE_SDK" --show-sdk-path >/dev/null +bash "$ROOT/tools/apple/build-ios-scanner.sh" +BASE="$ROOT/build/apple/$APPLE_SDK-$APPLE_ARCH" +export APPLE_DEPS_PREFIX="${APPLE_DEPS_PREFIX:-$BASE/prefix}" +if [[ "${BUILD_DEPENDENCIES:-1}" == 1 ]]; then "$ROOT/tools/apple/build-dependencies.sh"; fi +export PKG_CONFIG_PATH="$APPLE_DEPS_PREFIX/lib/pkgconfig" +export PKG_CONFIG_LIBDIR="$PKG_CONFIG_PATH" +ASSETS="$BASE/Spool.xcassets" +mkdir -p "$ASSETS/AppIcon.appiconset" +magick "$ROOT/app/icons/png/spool/1024.png" -background black -alpha remove -alpha off "$ASSETS/AppIcon.appiconset/icon.png" +printf '%s\n' '{"images":[{"filename":"icon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}' >"$ASSETS/AppIcon.appiconset/Contents.json" +cmake -S "$ROOT" -B "$BASE/app" -GXcode \ + -DCMAKE_TOOLCHAIN_FILE="$QT_TARGET_PREFIX/lib/cmake/Qt6/qt.toolchain.cmake" \ + -DQT_HOST_PATH="$QT_HOST_PATH" -DCMAKE_PREFIX_PATH="$APPLE_DEPS_PREFIX;$QT_TARGET_PREFIX" \ + -DQT_ADDITIONAL_PACKAGES_PREFIX_PATH="$APPLE_DEPS_PREFIX" \ + -DCMAKE_FIND_ROOT_PATH="$APPLE_DEPS_PREFIX;$QT_TARGET_PREFIX" \ + -DQCoro6_DIR="$APPLE_DEPS_PREFIX/lib/cmake/QCoro6" \ + -DCMAKE_OSX_SYSROOT="$APPLE_SDK" -DCMAKE_OSX_ARCHITECTURES="$APPLE_ARCH" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$APPLE_DEPLOYMENT_TARGET" \ + -DSPOOL_WEBOS=OFF -DSPOOL_APPLE_APP_STORE="${SPOOL_APPLE_APP_STORE:-ON}" -DBUILD_TESTING=OFF \ + -DSPOOL_APPLE_QML_IMPORT_SCANNER="$ROOT/build/apple/qmlimportscanner/spool-qmlimportscanner" \ + -DSPOOL_IOS_ASSETS="$ASSETS" \ + -DCMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM="${APPLE_DEVELOPMENT_TEAM:-}" \ + -DCMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED="${APPLE_CODE_SIGNING_ALLOWED:-NO}" +cmake --build "$BASE/app" --config Release --target spool --parallel "${BUILD_JOBS:-4}" +if [[ "${APPLE_ARCHIVE:-0}" == 1 ]]; then + : "${APPLE_DEVELOPMENT_TEAM:?Archiving requires a signing team}" + : "${APPLE_EXPORT_OPTIONS:?Set APPLE_EXPORT_OPTIONS to your ExportOptions.plist}" + xcodebuild -project "$BASE/app/SpoolWebOS.xcodeproj" -scheme spool -configuration Release \ + -destination 'generic/platform=iOS' -archivePath "$BASE/Spool.xcarchive" \ + DEVELOPMENT_TEAM="$APPLE_DEVELOPMENT_TEAM" CODE_SIGNING_ALLOWED=YES archive + xcodebuild -exportArchive -archivePath "$BASE/Spool.xcarchive" -exportPath "$ROOT/dist/ios" \ + -exportOptionsPlist "$APPLE_EXPORT_OPTIONS" +fi diff --git a/tools/check-release-artifacts.sh b/tools/check-release-artifacts.sh index 177c58cb..ae978504 100755 --- a/tools/check-release-artifacts.sh +++ b/tools/check-release-artifacts.sh @@ -14,8 +14,10 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" build="$root/.github/workflows/build-artifacts.yml" release="$root/.github/workflows/release.yml" +ios="$root/.github/workflows/ios-review.yml" status=0 tvos_devices=0 +ios_devices=0 # Artifact names, not the cachix action's own `name:` input, so only the lines # that sit under an upload-artifact step count. @@ -28,7 +30,7 @@ names="$(awk ' next } in_upload && /^[[:space:]]*-[[:space:]]/ { in_upload = 0 } -' "$build")" +' "$build" "$ios")" [[ -n "$names" ]] || { echo "error: found no uploaded artifacts to check in build-artifacts.yml" >&2 @@ -38,6 +40,11 @@ names="$(awk ' while IFS= read -r name; do case "$name" in spool-tvos-device-arm64) tvos_devices=$((tvos_devices + 1)) ;; + spool-ios-device-arm64) ios_devices=$((ios_devices + 1)) ;; + spool-ios-*) + printf 'error: iOS simulator/build inputs must be internal-*, not %s\n' "$name" >&2 + status=1 + ;; spool-tvos-*) printf 'error: tvOS simulator/build inputs must be internal-*, not %s\n' "$name" >&2 status=1 @@ -54,6 +61,10 @@ done <<<"$names" echo "error: expected exactly one spool-tvos-device-arm64 public artifact" >&2 status=1 } +[[ "$ios_devices" -eq 1 ]] || { + echo "error: expected exactly one spool-ios-device-arm64 public artifact" >&2 + status=1 +} grep -Fq 'pattern: spool-*' "$release" || { echo "error: the release download must ask for spool-* only, or internal artifacts reach the release page" >&2 diff --git a/tools/compare-render-benchmark.py b/tools/compare-render-benchmark.py index 68034030..6211f613 100755 --- a/tools/compare-render-benchmark.py +++ b/tools/compare-render-benchmark.py @@ -14,6 +14,7 @@ import argparse import json +import math import statistics import sys from collections import defaultdict @@ -27,6 +28,41 @@ ("actualSwaps", "swaps"), ] +COMPARISON_KEYS = ( + "schemaVersion", "timingOrigin", "script", "qpaPlatform", "graphicsApi", + "framePumpMs", "windowWidth", "windowHeight", "devicePixelRatio", + "library", "listMode", "frameBudgetMs", "measurementScope", "cold", + "quickBackend", "qtVersion", "cpuArchitecture", "buildAbi", "providerId", + "providerRuntime", "warmupPasses", "settleMs", "stepTimeoutMs", "cacheMode", + "interpreterRequested", "deviceLabel", "iterations", +) + + +def validate_report(report: dict) -> None: + """Reject missing/invalid measurements rather than turning them into zero.""" + if not isinstance(report, dict): + raise ValueError("report must be a JSON object") + if report.get("complete") is False or report.get("failures"): + raise ValueError("incomplete run; inspect failures before comparing") + samples = report.get("samples") + if not isinstance(samples, list) or not samples: + raise ValueError("no route samples recorded") + for index, sample in enumerate(samples): + if not isinstance(sample, dict) or not isinstance(sample.get("routeTo"), str) or not sample["routeTo"]: + raise ValueError(f"sample {index} has no route") + for key in [key for key, _ in METRICS] + ["frameBudgetMs"]: + value = sample.get(key) + if (isinstance(value, bool) or not isinstance(value, (float, int)) + or not math.isfinite(value) or value < 0): + raise ValueError(f"sample {index} has missing or invalid {key}") + if sample["wallMs"] <= 0 or sample["actualSwaps"] < 1 or sample["frameBudgetMs"] <= 0: + raise ValueError(f"sample {index} has no measured transition/frame") + + +def comparable(current: dict, baseline: dict) -> bool: + return (summarise(current).keys() == summarise(baseline).keys() + and all(current.get(key) == baseline.get(key) for key in COMPARISON_KEYS)) + def load(path: Path) -> dict: with path.open() as handle: @@ -36,14 +72,15 @@ def load(path: Path) -> dict: def summarise(report: dict) -> dict: by_route: dict[str, list[dict]] = defaultdict(list) for sample in report.get("samples", []): - by_route[sample.get("routeTo", "?")].append(sample) + by_route[sample["routeTo"]].append(sample) summary = {} for route, samples in by_route.items(): entry = { - key: statistics.median(float(sample.get(key, 0)) for sample in samples) + key: statistics.median(sample[key] for sample in samples) for key, _ in METRICS } - entry["__worstGap__"] = max(float(sample.get("maxGapMs", 0)) for sample in samples) + entry["__worstGap__"] = max(sample["maxGapMs"] for sample in samples) + entry["count"] = len(samples) summary[route] = entry return summary @@ -64,36 +101,32 @@ def main() -> int: parser.add_argument("--warn-only", action="store_true", help="report performance regressions as GitHub warnings, without failing") parser.add_argument("--markdown", type=Path) + parser.add_argument("--require-comparable", action="store_true", + help="fail if the requested baseline is absent or uses a different workload/environment") args = parser.parse_args() - report = load(args.current) - samples = report.get("samples", []) - if report.get("complete") is False or report.get("failures"): - print("render benchmark: incomplete run; inspect failures before comparing", file=sys.stderr) - return 1 - if not samples: - print("render benchmark: no samples recorded", file=sys.stderr) + if not math.isfinite(args.tolerance) or args.tolerance < 0: + parser.error("--tolerance must be finite and nonnegative") + try: + report = load(args.current) + validate_report(report) + baseline_report = load(args.baseline) if args.baseline and args.baseline.exists() else None + if baseline_report is not None: + validate_report(baseline_report) + except (OSError, ValueError, TypeError) as error: + print(f"render benchmark: {error}", file=sys.stderr) return 1 + samples = report["samples"] current = summarise(report) - baseline_report = load(args.baseline) if args.baseline and args.baseline.exists() else None - if baseline_report and (baseline_report.get("complete") is False or baseline_report.get("failures")): - print("render benchmark: incomplete baseline; refusing comparison", file=sys.stderr) - return 1 - baseline = summarise(baseline_report) if baseline_report else None - budget = float(samples[0].get("frameBudgetMs", 16.7)) + baseline = summarise(baseline_report) if baseline_report is not None else None + budget = float(samples[0]["frameBudgetMs"]) cold = report.get("cold", False) - comparable = bool(baseline) and ( - current.keys() == baseline.keys() - and cold == baseline_report.get("cold", False) - and report.get("quickBackend", "") == baseline_report.get("quickBackend", "") - and all(report.get(key) == baseline_report.get(key) for key in ( - "schemaVersion", "timingOrigin", "script", "qpaPlatform", "graphicsApi", - "framePumpMs", "windowWidth", "windowHeight", "devicePixelRatio", - "library", "listMode", "frameBudgetMs", "measurementScope", - )) - ) + can_compare = baseline_report is not None and comparable(report, baseline_report) + if args.require_comparable and not can_compare: + print("render benchmark: comparable baseline required but absent or incompatible", file=sys.stderr) + return 1 overall = sum(entry["wallMs"] for entry in current.values()) - was_overall = sum(entry["wallMs"] for entry in baseline.values()) if comparable else None + was_overall = sum(entry["wallMs"] for entry in baseline.values()) if can_compare else None lines = [ f"### Render benchmark ({'cold' if cold else 'warm'})", @@ -111,7 +144,9 @@ def main() -> int: lines += ["", "Legacy timing: synchronous page construction may be excluded and cold loads labelled warm."] if args.warn_only: lines += ["", "Warning-only on shared CI runners. Run on dedicated hardware before enabling performance gates."] - if baseline and not comparable: + if args.baseline and baseline_report is None: + lines += ["", "Baseline file is absent; reporting measurements without a comparison."] + if baseline is not None and not can_compare: lines += ["", "Baseline workload, timing origin or rendering environment differs; no comparison made."] lines += ["", "| route | " + " | ".join(label for _, label in METRICS) + " | max lateness |", "|" + "---|" * (len(METRICS) + 2)] @@ -120,7 +155,7 @@ def main() -> int: cells = [route] for key, _ in METRICS: value = entry[key] - was = baseline.get(route, {}).get(key) if comparable else None + was = baseline.get(route, {}).get(key) if can_compare else None unit = "" if key == "actualSwaps" else " ms" precision = 0 if key == "actualSwaps" else 1 cells.append(f"{value:.{precision}f}{unit}{format_delta(value, was)}") diff --git a/tools/flash-report.py b/tools/flash-report.py new file mode 100755 index 00000000..cc2cd365 --- /dev/null +++ b/tools/flash-report.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Find visual flashes and layout jumps in a `spoolet capture` frame sequence. + +A page should fill in: things appear once in a held place and stay. This +looks for the three ways loading looks wrong instead, and writes a +side-by-side image only for the frames that show one, so nobody has to step +through every frame: + + flash content leaves a place and then returns to it (A, then B, then A + again), such as a title that blanks while details reload + jump everything below some line moves up or down between two frames, + such as an overview arriving and pushing the buttons down + lost content that was on screen is gone when the page settles, and + was not merely replaced by other content + +Tiles that change on most frames or cycle through many looks (a spinner, a +caret) are animation, not loading, and are ignored. The first frame is the +page the action started from, so it only serves as the "before" of frame 1. + +Usage: flash-report.py CAPTURE_DIR [CAPTURE_DIR ...] [--json] +Needs numpy and Pillow, for example: + nix-shell -p "python3.withPackages (p: [ p.numpy p.pillow ])" --run "python3 tools/flash-report.py DIR" +""" + +import argparse +import json +import pathlib +import sys + +import numpy as np +from PIL import Image, ImageDraw + +SCALE = 4 # analyse at a quarter of the window size +TILE = 8 # analysis pixels per tile side (32 window pixels) +CHANGED = 7.0 # mean absolute difference that counts as a visible change +SAME = 4.0 # ... and below which two states are the same +BLANK_STD = 2.5 # a tile this flat holds no content +ANIMATED = 0.45 # share of transitions after which a tile counts as animation +CYCLING = 4 # distinct looks after which a tile counts as animation +DETAIL = 12.0 # gradient that marks an edge or text, as opposed to a flat fill +MIN_JUMP = 2 # analysis pixels (8 window pixels) +BAND = 8 # analysis rows per band when looking for moved content (32 window pixels) +MAX_JUMP = 50 # 200 window pixels; larger matches were coincidences + + +def load(directory): + index = json.loads((directory / "index.json").read_text()) + frames = [] + for entry in index["frames"]: + image = Image.open(directory / entry["file"]).convert("L") + image = image.resize((image.width // SCALE, image.height // SCALE), Image.BOX) + frames.append(np.asarray(image, dtype=np.float32)) + return index, frames + + +def tiles(frame): + rows, cols = frame.shape[0] // TILE, frame.shape[1] // TILE + return frame[: rows * TILE, : cols * TILE].reshape(rows, TILE, cols, TILE).swapaxes(1, 2) + + +def difference(a, b): + return np.abs(tiles(a) - tiles(b)).mean(axis=(2, 3)) + + +def detail(frame): + edges = np.zeros_like(frame, dtype=bool) + edges[:-1, :-1] = (np.abs(np.diff(frame, axis=0))[:, :-1] + np.abs(np.diff(frame, axis=1))[:-1]) > DETAIL + return edges + + +def jumps(a, b): + """Bands of existing content that moved vertically from `a` to `b`. + + The frame is cut into horizontal bands, and each band that held detail + in `a` (edges, text) looks for itself in `b`. Bands find their own + offsets, so a page that pushes one row 40px and the next 120px is still + seen. Only detail is compared, so artwork filling an empty placeholder + is not a move: a row of identical empty cards would otherwise match + itself one row down. Returns [(top, bottom, dy)] in analysis pixels. + """ + height = a.shape[0] + edges, after = detail(a), detail(b) + moved = [] + for top in range(0, height - BAND + 1, BAND): + bottom = top + BAND + mask = edges[top:bottom] + if mask.mean() < 0.02: + continue + # Content that moves leaves its place. Where most of the band's edges + # are still there, something filled in around them (a poster in its + # card) and any offset that matches is a coincidence. + if (mask & after[top:bottom]).sum() > 0.5 * mask.sum(): + continue + band = a[top:bottom] + + def cost(dy): + if top + dy < 0 or bottom + dy > height: + return np.inf + return np.abs(band - b[top + dy: bottom + dy])[mask].mean() + + still = cost(0) + if still <= CHANGED: + continue + best, best_cost = 0, still + for dy in range(-MAX_JUMP, MAX_JUMP + 1): + if abs(dy) >= MIN_JUMP: + candidate = cost(dy) + if candidate < best_cost: + best, best_cost = dy, candidate + if best and best_cost < min(still * 0.5, SAME * 2): + moved.append((top, bottom, best)) + return moved + + +def analyse(directory): + index, frames = load(directory) + report = {"capture": directory.name, "frames": len(frames), "stopReason": index.get("stopReason"), + "events": [], "score": 0} + # Frame 0 shows the page before the action; changes into frame 1 are the + # navigation itself. Indices below stay those of the captured frames. + first = 1 + if len(frames) - first < 2: + return report + stack = np.stack([tiles(f) for f in frames]) # frame, row, col, y, x + blank = stack.std(axis=(3, 4)) < BLANK_STD + changes = np.stack([difference(frames[i - 1], frames[i]) > CHANGED for i in range(first + 1, len(frames))]) + animated = changes.mean(axis=0) > ANIMATED if len(changes) >= 4 else np.zeros(changes.shape[1:], bool) + rows, cols = blank.shape[1:] + + flashes = {} + for r in range(rows): + for c in range(cols): + if animated[r, c]: + continue + seen = [] # distinct states of this tile, as (frame, pixels) + found = [] + for i in range(first, len(frames)): + pixels = stack[i, r, c] + if seen and np.abs(pixels - seen[-1][1]).mean() <= SAME: + continue + for frame, state in seen[:-1]: + if not blank[frame, r, c] and np.abs(pixels - state).mean() <= SAME: + found.append((i, seen[-1][0])) + break + seen.append((i, pixels)) + if len(seen) > CYCLING: + animated[r, c] = True + continue + for frame, origin in found: + flashes.setdefault(frame, []).append((r, c, origin)) + for frame, cells in sorted(flashes.items()): + report["events"].append({"kind": "flash", "frame": frame, "from": min(cell[2] for cell in cells), + "tiles": [[r, c] for r, c, _ in cells]}) + + for i in range(first + 1, len(frames)): + moved = jumps(frames[i - 1], frames[i]) + if moved: + side = frames[i].shape[1] // TILE + report["events"].append({"kind": "jump", "frame": i, "from": i - 1, + "dy": max((dy * SCALE for _, _, dy in moved), key=abs), + "bands": [[top * SCALE, bottom * SCALE, dy * SCALE] for top, bottom, dy in moved], + "tiles": [[r, c] for top, bottom, _ in moved + for r in range(top // TILE, (bottom + TILE - 1) // TILE) + for c in range(side)]}) + + final = len(frames) - 1 + lost = [[r, c] for r in range(rows) for c in range(cols) + if not animated[r, c] and blank[final, r, c] + and any(not blank[i, r, c] for i in range(first, final))] + if lost: + report["events"].append({"kind": "lost", "frame": final, "from": first, "tiles": lost}) + + # A jump moves the whole page under the eye, so it outweighs a few + # flashing tiles; content lost at the end counts least. + report["score"] = sum(len(e["tiles"]) if e["kind"] == "flash" else 25 * len(e["bands"]) if e["kind"] == "jump" + else len(e["tiles"]) // 4 for e in report["events"]) + return report + + +def annotate(directory, report): + for number, event in enumerate(report["events"]): + names = [f"frame-{event['from']:04d}.png", f"frame-{event['frame']:04d}.png"] + before, after = (Image.open(directory / name).convert("RGB") for name in names) + canvas = Image.new("RGB", (before.width * 2 + 8, before.height), "white") + canvas.paste(before, (0, 0)) + canvas.paste(after, (before.width + 8, 0)) + draw = ImageDraw.Draw(canvas) + side = TILE * SCALE + for r, c in event.get("tiles", []): + for offset in (0, before.width + 8): + draw.rectangle([offset + c * side, r * side, offset + (c + 1) * side - 1, (r + 1) * side - 1], + outline="red", width=2) + label = f"{event['kind']} frame {event['from']} -> {event['frame']}" + if event["kind"] == "jump": + label += f" dy {event['dy']}px" + draw.rectangle([0, 0, len(label) * 8 + 12, 22], fill="black") + draw.text((6, 5), label, fill="yellow") + path = directory / f"flagged-{number:02d}-{event['kind']}.png" + canvas.save(path) + event["image"] = str(path) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("captures", nargs="+", type=pathlib.Path) + parser.add_argument("--json", action="store_true", help="print the full report as JSON") + args = parser.parse_args() + reports = [] + for directory in args.captures: + for old in directory.glob("flagged-*.png"): + old.unlink() + report = analyse(directory) + annotate(directory, report) + (directory / "report.json").write_text(json.dumps(report, indent=2)) + reports.append(report) + if args.json: + json.dump(reports, sys.stdout, indent=2) + return + for report in sorted(reports, key=lambda r: -r.get("score", 0)): + kinds = {} + for event in report["events"]: + kinds[event["kind"]] = kinds.get(event["kind"], 0) + 1 + summary = ", ".join(f"{count} {kind}" for kind, count in sorted(kinds.items())) or "clean" + print(f"{report.get('score', 0):5d} {report['capture']}: {report['frames']} frames, {summary}") + + +if __name__ == "__main__": + main() diff --git a/tools/flash-sweep.py b/tools/flash-sweep.py new file mode 100755 index 00000000..140fc575 --- /dev/null +++ b/tools/flash-sweep.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Capture and score page loads on every provider of a running Spool. + +Drives an instance through spoolet the way a person moves through it -- +library, movie, series, season, episode, back, Home rows -- capturing every +visibly changed frame of each load with `spoolet capture`, then ranks the +loads with flash-report.py. Only the flagged frames need looking at; each +capture directory holds them as flagged-*.png next to its report.json. + +Start an instance first, offscreen so it does not take over the desktop: + nix run .#run -- -platform offscreen --instance flashprobe +then: + nix-shell -p "python3.withPackages (p: [ p.numpy p.pillow ])" \\ + --run "python3 tools/flash-sweep.py --instance flashprobe --out /tmp/flash" + +Output names carry account prefixes and positions, never titles or URLs. +""" + +import argparse +import importlib.util +import json +import pathlib +import shutil +import subprocess +import sys +import time + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class Spoolet: + def __init__(self, binary, instance): + self.binary, self.instance = binary, instance + + def __call__(self, *args, check=True): + result = subprocess.run([self.binary, "--instance", self.instance, *map(str, args)], + capture_output=True, text=True, timeout=120) + try: + reply = json.loads(result.stdout) + except json.JSONDecodeError: + raise SystemExit(f"spoolet {args[0]}: no JSON reply") + if check and not reply.get("ok"): + raise RuntimeError(f"spoolet {' '.join(map(str, args))}: {reply.get('error')}") + return reply.get("result", {}) + + +def capture(spoolet, out, name, action, settle_ms, max_ms): + directory = out / name + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True) + spoolet("capture", "start", directory, settle_ms, max_ms) + try: + action() + except RuntimeError as error: + spoolet("capture", "stop") + print(f" skip {name}: {error}") + shutil.rmtree(directory) + return None + while spoolet("capture", "status")["running"]: + time.sleep(0.2) + status = spoolet("capture", "status") + print(f" {name}: {status['saved']} frames, {status['stopReason']}") + return directory + + +def first_of(spoolet, kind, prefix, types): + for row in spoolet("items", kind, 0, 200)["items"]: + if row["id"].startswith(prefix + ":") and row["type"] in types: + return row + return None + + +def sweep(spoolet, out, prefixes, settle_ms, max_ms, pick): + captured = [] + + def run(name, action): + directory = capture(spoolet, out, name, action, settle_ms, max_ms) + if directory: + captured.append(directory) + return directory + + libraries = spoolet("items", "libraries", 0, 200)["items"] + accounts = sorted({row["id"].split(":", 1)[0] for row in libraries}) + for account in accounts: + if prefixes and account not in prefixes: + continue + print(f"account {account}") + for kind in ("movies", "tvshows"): + library = next((row for row in libraries if row["id"].startswith(account + ":") + and row["type"] == kind), None) + if not library: + continue + spoolet("home") + time.sleep(1) + if not run(f"{account}-{kind}-library", lambda: spoolet("library", library["id"])): + continue + if spoolet("items", "browse", 0, 1)["count"] <= pick: + continue + if not run(f"{account}-{kind}-details", lambda: spoolet("open", "browse", pick)): + continue + if kind == "tvshows": + if run(f"{account}-season", lambda: spoolet("open", "context", 0)): + run(f"{account}-episode", lambda: spoolet("open", "context", pick)) + run(f"{account}-episode-back", lambda: spoolet("back")) + run(f"{account}-season-back", lambda: spoolet("back")) + run(f"{account}-{kind}-details-back", lambda: spoolet("back")) + + spoolet("home") + time.sleep(1) + for kind in ("resume", "next-up"): + rows = spoolet("items", kind, 0, 200)["items"] + for account in accounts: + if prefixes and account not in prefixes: + continue + index = next((row["index"] for row in rows if row["id"].startswith(account + ":")), None) + if index is None: + continue + spoolet("home") + time.sleep(1) + run(f"{account}-{kind}-open", lambda: spoolet("open", kind, index)) + return captured + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--instance", required=True) + parser.add_argument("--out", required=True, type=pathlib.Path) + parser.add_argument("--spoolet", default=str(ROOT / "build/linux-release/install/bin/spoolet")) + parser.add_argument("--account", action="append", default=[], help="limit to an account prefix") + parser.add_argument("--settle-ms", type=int, default=1500) + parser.add_argument("--max-ms", type=int, default=12000) + parser.add_argument("--pick", type=int, default=0, + help="open the Nth library item and episode; a fresh N avoids the details cache") + args = parser.parse_args() + spoolet = Spoolet(args.spoolet, args.instance) + if not spoolet("status").get("initialized"): + raise SystemExit("instance is not initialized yet") + args.out = args.out.resolve() + args.out.mkdir(parents=True, exist_ok=True) + captured = sweep(spoolet, args.out, set(args.account), args.settle_ms, args.max_ms, args.pick) + + spec = importlib.util.spec_from_file_location("flash_report", ROOT / "tools/flash-report.py") + report = importlib.util.module_from_spec(spec) + spec.loader.exec_module(report) + sys.argv = ["flash-report.py", *map(str, captured)] + print("\nscore load") + report.main() + + +if __name__ == "__main__": + main() diff --git a/tools/generate-website-manifest.py b/tools/generate-website-manifest.py index d6341f40..8fa56802 100755 --- a/tools/generate-website-manifest.py +++ b/tools/generate-website-manifest.py @@ -36,6 +36,9 @@ ("tvos", "Apple TV", ( ("arm64", "Unsigned ARM64 IPA", "arm64", "ipa", "Spool-{version}-tvOS-arm64.ipa"), )), + ("ios", "iOS", ( + ("arm64", "Unsigned ARM64 IPA", "arm64", "ipa", "Spool-{version}-iOS-arm64.ipa"), + )), ) @@ -53,6 +56,9 @@ def generate_manifest(assets: Path, tag: str, repository: str) -> dict: selected = set() for platform_id, platform_label, packages in PLATFORMS: downloads = [] + # iOS is attempted separately; only advertise a successfully built IPA. + if platform_id == "ios" and not any(assets.glob(packages[0][4].format(version=version))): + continue for package_id, label, architecture, package_format, pattern in packages: matches = sorted(assets.glob(pattern.format(version=version))) if len(matches) != 1: @@ -77,10 +83,10 @@ def generate_manifest(assets: Path, tag: str, repository: str) -> dict: "sha256": checksum, "size": size, } - if platform_id == "tvos": + if platform_id in ("tvos", "ios"): # The archive audit verifies a device bundle, not Apple signing. download["signing"] = "unsigned" - download["note"] = "Installing needs your own Apple signing and provisioning. It is not on the App Store." + download["note"] = "Installing needs Apple signing and provisioning. It is not on the App Store." downloads.append(download) selected.add(package) platforms.append({"id": platform_id, "label": platform_label, "downloads": downloads}) diff --git a/tools/lib/build-common.sh b/tools/lib/build-common.sh index efe66a12..80713160 100644 --- a/tools/lib/build-common.sh +++ b/tools/lib/build-common.sh @@ -402,7 +402,9 @@ import re import sys content = pathlib.Path(sys.argv[1]).read_text(errors="replace") -paths = set(re.findall(r"/nix/store/[a-z0-9]{32}-[^\s$\"',;:]+", content)) +# Store items are immutable. CMake object names embed source paths followed by +# ".o"; those are not files in the store and must not invalidate a valid cache. +paths = set(re.findall(r"/nix/store/[a-z0-9]{32}-[^/\s$\"',;:=]+", content)) for path in sorted(candidate.rstrip("\\)]}") for candidate in paths): if not pathlib.Path(path).exists(): print(path) diff --git a/tools/package-appimage.sh b/tools/package-appimage.sh index d78bbb43..34ea8eaa 100755 --- a/tools/package-appimage.sh +++ b/tools/package-appimage.sh @@ -134,6 +134,7 @@ prime_linuxdeploy_qt_plugins() { for plugins_dir in "${plugin_roots[@]}"; do for plugin_dir in \ platforminputcontexts \ + platformthemes \ tls \ wayland-decoration-client \ wayland-graphics-integration-client \ diff --git a/tools/package-audit.py b/tools/package-audit.py index 040ab6df..9743f717 100755 --- a/tools/package-audit.py +++ b/tools/package-audit.py @@ -248,7 +248,7 @@ def expand_macho_name(name: str, owner: MachOInfo, executable_dir: Path) -> Path return Path(name).resolve() -def tvos_macho_info(path: Path) -> MachOInfo | None: +def apple_mobile_macho_info(path: Path, platform: str) -> MachOInfo | None: """Read device load commands directly, including on Linux release runners.""" with path.open("rb") as source: magic = source.read(4) @@ -257,15 +257,15 @@ def tvos_macho_info(path: Path) -> MachOInfo | None: b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca"): return None if magic != b"\xcf\xfa\xed\xfe": - raise AuditError(f"tvOS binary must be thin little-endian ARM64 Mach-O: {path}") + raise AuditError(f"{platform} binary must be thin little-endian ARM64 Mach-O: {path}") header = source.read(28) if len(header) != 28: raise AuditError(f"truncated Mach-O header: {path}") cpu, subtype, filetype, count, size, flags, reserved = struct.unpack("<7I", header) if cpu != 0x0100000C or subtype & 0x00FFFFFF != 0: - raise AuditError(f"wrong tvOS Mach-O architecture (expected arm64): {path}") + raise AuditError(f"wrong {platform} Mach-O architecture (expected arm64): {path}") if filetype not in (2, 6, 8): - raise AuditError(f"unsupported tvOS Mach-O file type: {path}") + raise AuditError(f"unsupported {platform} Mach-O file type: {path}") if size > path.stat().st_size - 32 or count > size // 8: raise AuditError(f"invalid Mach-O load command bounds: {path}") commands = source.read(size) @@ -281,7 +281,7 @@ def tvos_macho_info(path: Path) -> MachOInfo | None: if length < 8 or length % 8 or offset + length > len(commands): raise AuditError(f"invalid Mach-O load command size: {path}") data = commands[offset:offset + length] - if command == 0x32: # LC_BUILD_VERSION: tvOS device is 3, simulator is 8. + if command == 0x32: # LC_BUILD_VERSION: iOS device is 2, tvOS device is 3. if length < 24: raise AuditError(f"invalid LC_BUILD_VERSION: {path}") platforms.append(struct.unpack_from(" MachOInfo | None: else: dependencies.append(value) offset += length - if offset != size or platforms != [3]: - raise AuditError(f"Mach-O must declare exactly one device tvOS LC_BUILD_VERSION: {path}") + if offset != size or platforms != [{"iOS": 2, "tvOS": 3}[platform]]: + raise AuditError(f"Mach-O must declare exactly one device {platform} LC_BUILD_VERSION: {path}") kind = {2: "executable", 6: "library", 8: "bundle"}[filetype] return MachOInfo(path, frozenset({"arm64"}), kind, install_name, tuple(dependencies), tuple(rpaths)) -def audit_tvos_ipa(args: argparse.Namespace) -> int: +def audit_apple_mobile_ipa(args: argparse.Namespace) -> int: """Validate an unsigned/signable device IPA, not Apple signing or runtime.""" + platform = args.mobile_platform if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", args.version): - raise AuditError("tvOS package version must be MAJOR.MINOR.PATCH") - expected = f"Spool-{args.version}-tvOS-arm64.ipa" + raise AuditError(f"{platform} package version must be MAJOR.MINOR.PATCH") + expected = f"Spool-{args.version}-{platform}-arm64.ipa" if args.ipa.name != expected or args.ipa.is_symlink() or not args.ipa.is_file(): - raise AuditError(f"expected regular tvOS device package named {expected}") + raise AuditError(f"expected regular {platform} device package named {expected}") with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) try: @@ -326,7 +327,7 @@ def audit_tvos_ipa(args: argparse.Namespace) -> int: kinds: dict[str, int] = {} entries = archive.infolist() if not entries: - raise AuditError("empty tvOS IPA") + raise AuditError(f"empty {platform} IPA") for entry in entries: name = entry.filename.rstrip("/") parts = PurePosixPath(name).parts @@ -338,7 +339,7 @@ def audit_tvos_ipa(args: argparse.Namespace) -> int: or (parts == ("Payload", "Spool.app") and not entry.is_dir())): raise AuditError(f"unsafe or unexpected IPA member: {entry.filename}") if "_CodeSignature" in parts or "embedded.mobileprovision" in parts: - raise AuditError(f"public tvOS IPA must be unsigned/unprovisioned: {name}") + raise AuditError(f"public {platform} IPA must be unsigned/unprovisioned: {name}") if name in names: raise AuditError(f"duplicate IPA member: {name}") names.add(name) @@ -389,43 +390,47 @@ def audit_tvos_ipa(args: argparse.Namespace) -> int: except (OSError, ValueError, RuntimeError, zipfile.BadZipFile) as error: if isinstance(error, AuditError): raise - raise AuditError(f"invalid tvOS IPA: {error}") from error + raise AuditError(f"invalid {platform} IPA: {error}") from error app = root / "Payload/Spool.app" try: with (app / "Info.plist").open("rb") as source: metadata = plistlib.load(source) except (OSError, ValueError, plistlib.InvalidFileException) as error: - raise AuditError(f"missing or invalid tvOS Info.plist: {error}") from error + raise AuditError(f"missing or invalid {platform} Info.plist: {error}") from error if not isinstance(metadata, dict): - raise AuditError("tvOS Info.plist must be a dictionary") + raise AuditError(f"{platform} Info.plist must be a dictionary") if metadata.get("CFBundleExecutable") != "Spool" or metadata.get("CFBundlePackageType") != "APPL": - raise AuditError("tvOS Info.plist must describe the Spool application executable") - if (metadata.get("CFBundleSupportedPlatforms") != ["AppleTVOS"] - or metadata.get("UIDeviceFamily") != [3]): - raise AuditError("tvOS Info.plist must target AppleTVOS device family 3") + raise AuditError(f"{platform} Info.plist must describe the Spool application executable") + supported_platform, device_family = { + "tvOS": ("AppleTVOS", [3]), "iOS": ("iPhoneOS", [1, 2]), + }[platform] + if (metadata.get("CFBundleSupportedPlatforms") != [supported_platform] + or metadata.get("UIDeviceFamily") != device_family): + raise AuditError(f"{platform} Info.plist must target {supported_platform} device family {device_family}") if any(metadata.get(key) != args.version for key in ("CFBundleShortVersionString", "CFBundleVersion")): - raise AuditError(f"tvOS embedded bundle version must match {args.version}") - closure = argparse.Namespace(app=app, tvos=True, architecture=["arm64"], root_binary=[]) + raise AuditError(f"{platform} embedded bundle version must match {args.version}") + closure = argparse.Namespace(app=app, mobile_platform=platform, architecture=["arm64"], root_binary=[]) audit_macho(closure) if args.extract: args.extract.mkdir(parents=True, exist_ok=True) if any(args.extract.iterdir()): raise AuditError("IPA extraction destination must be empty") shutil.copytree(root / "Payload", args.extract / "Payload", symlinks=True) - print(f"tvOS device IPA passed: {expected} (signing/provisioning and runtime not verified).") + print(f"{platform} device IPA passed: {expected} (signing/provisioning and runtime not verified).") return 0 def audit_macho(args: argparse.Namespace) -> int: app = args.app.resolve() - tvos = getattr(args, "tvos", False) - executable = app / ("Spool" if tvos else "Contents/MacOS/Spool") + mobile_platform = getattr(args, "mobile_platform", None) + executable = app / ("Spool" if mobile_platform else "Contents/MacOS/Spool") if not executable.is_file(): raise AuditError(f"main Mach-O executable is missing: {executable}") files: list[MachOInfo] = [] for path in app.rglob("*"): if path.is_file() and not path.is_symlink(): - info = tvos_macho_info(path) if tvos else macho_info(path, args.file_tool, args.lipo, args.otool) + info = (apple_mobile_macho_info(path, mobile_platform) if mobile_platform + else macho_info(path, args.file_tool, args.lipo, args.otool)) if info: files.append(info) by_path = {info.path.resolve(): info for info in files} @@ -443,7 +448,7 @@ def audit_macho(args: argparse.Namespace) -> int: if info.path.resolve() == executable.resolve() or info.kind == "executable" or relative(info.path, app).startswith( - ("PlugIns/", "qml/") if tvos else ("Contents/PlugIns/", "Contents/Resources/qml/") + ("PlugIns/", "qml/") if mobile_platform else ("Contents/PlugIns/", "Contents/Resources/qml/") ) } for root in args.root_binary: @@ -528,11 +533,12 @@ def parser() -> argparse.ArgumentParser: macho_parser.add_argument("--lipo", default="lipo") macho_parser.add_argument("--otool", default="otool") macho_parser.set_defaults(handler=audit_macho) - tvos_parser = commands.add_parser("tvos-ipa", help="Audit an unsigned/signable device tvOS IPA") - tvos_parser.add_argument("ipa", type=Path) - tvos_parser.add_argument("--version", required=True) - tvos_parser.add_argument("--extract", type=Path, help="Extract validated Payload into an empty directory") - tvos_parser.set_defaults(handler=audit_tvos_ipa) + for command, platform in (("tvos-ipa", "tvOS"), ("ios-ipa", "iOS")): + mobile_parser = commands.add_parser(command, help=f"Audit an unsigned/signable device {platform} IPA") + mobile_parser.add_argument("ipa", type=Path) + mobile_parser.add_argument("--version", required=True) + mobile_parser.add_argument("--extract", type=Path, help="Extract validated Payload into an empty directory") + mobile_parser.set_defaults(handler=audit_apple_mobile_ipa, mobile_platform=platform) return result diff --git a/tools/package-ios.sh b/tools/package-ios.sh new file mode 100755 index 00000000..bfcaa15f --- /dev/null +++ b/tools/package-ios.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tools/lib/build-common.sh +source "$APP_ROOT/tools/lib/build-common.sh" +APP_VERSION="$(read_project_version "$APP_ROOT")" + +if (( $# < 2 || $# > 3 )); then + echo 'usage: package-ios.sh APP_BUNDLE OUTPUT_DIR [VERSION]' >&2 + exit 1 +fi +APP_BUNDLE="$1" +ARTIFACT_DIR="$2" +if [[ "${3:-$APP_VERSION}" != "$APP_VERSION" ]]; then + printf 'error: requested version %s does not match VERSION %s\n' "$3" "$APP_VERSION" >&2 + exit 1 +fi +if [[ ! -d "$APP_BUNDLE" || -L "$APP_BUNDLE" || "$(basename "$APP_BUNDLE")" != Spool.app ]]; then + echo 'error: supply the real device Spool.app bundle, not a simulator or placeholder' >&2 + exit 1 +fi +APP_BUNDLE="$(cd "$APP_BUNDLE" && pwd -P)" +for tool in python3 zip; do + command -v "$tool" >/dev/null || { echo "error: missing $tool" >&2; exit 1; } +done +ARTIFACT_DIR="$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve())' "$ARTIFACT_DIR")" +if [[ "$ARTIFACT_DIR" == "$APP_BUNDLE" || "$ARTIFACT_DIR" == "$APP_BUNDLE/"* ]]; then + echo 'error: output directory must be outside Spool.app' >&2 + exit 1 +fi +mkdir -p "$ARTIFACT_DIR" +# Audit before atomic publication, matching the tvOS device packaging gate. +stage="$(mktemp -d "$ARTIFACT_DIR/.spool-ios-XXXXXX")" +trap 'rm -rf "$stage"' EXIT +mkdir "$stage/Payload" +cp -R "$APP_BUNDLE" "$stage/Payload/Spool.app" +archive="$stage/Spool-${APP_VERSION}-iOS-arm64.ipa" +( + cd "$stage" + zip -q -r -y "$archive" Payload +) +python3 "$APP_ROOT/tools/package-audit.py" ios-ipa "$archive" --version "$APP_VERSION" +mv -f "$archive" "$ARTIFACT_DIR/Spool-${APP_VERSION}-iOS-arm64.ipa" +printf '%s\n' "$ARTIFACT_DIR/Spool-${APP_VERSION}-iOS-arm64.ipa" +printf '%s\n' 'iOS IPA is unsigned and unprovisioned; re-sign with your own Apple identity and provisioning profile before installation. This is not an App Store or TestFlight package.' >&2 diff --git a/tools/profile-native.py b/tools/profile-native.py new file mode 100755 index 00000000..329cdf24 --- /dev/null +++ b/tools/profile-native.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Measure real route transitions on Linux's private native graphics display. + +Run through nix develop .#native. Never attaches to a running app, copies an +account profile, or uses the host compositor. No raw application logs are saved. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import math +import os +from pathlib import Path +import signal +import statistics +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +GRAPHICS_ENV = ("SPOOL_TEST_DRI_DIR", "SPOOL_TEST_VULKAN_ICD", + "SPOOL_TEST_DRIVER_LIB_DIR", "SPOOL_TEST_EGL_VENDOR") +SPEC = importlib.util.spec_from_file_location("render_report", ROOT / "tools/compare-render-benchmark.py") +REPORT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(REPORT) + + +class Cancelled(Exception): + pass + + +def cancel(signum, _frame): + raise Cancelled(signum) + + +def write_json(path: Path, value: dict) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n", encoding="utf-8") + temporary.replace(path) + + +def stop_group(process: subprocess.Popen) -> None: + # Kill the entire launcher/compositor/app group, even if the group leader + # exited first. Only descendants of this invocation can be targeted. + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +def measure(app: Path, output: Path, cold: bool, args) -> dict: + environment = os.environ.copy() + for key in list(environment): + if (key.startswith("SPOOL_") and key not in GRAPHICS_ENV) or key in ( + "QT_QPA_PLATFORM", "QT_QUICK_BACKEND", "QT_QPA_PLATFORMTHEME", + "QV4_FORCE_INTERPRETER", "QSG_RHI_BACKEND", + ): + environment.pop(key) + environment.update({ + "SPOOL_BENCH": "routes", + "SPOOL_BENCH_OUT": str(output), + "SPOOL_BENCH_COLD": "1" if cold else "0", + "SPOOL_BENCH_ITERATIONS": str(args.iterations), + "SPOOL_BENCH_WARMUP": str(args.warmup), + "SPOOL_BENCH_TIMEOUT_MS": str(args.step_timeout_ms), + "SPOOL_BENCH_SETTLE_MS": str(args.settle_ms), + "SPOOL_BENCH_PUMP_MS": "8", + "SPOOL_BENCH_DEVICE_LABEL": "native-private", + "SPOOL_WINDOW_SIZE": "1920x1080", + "SPOOL_TEST_DISPLAY_BACKEND": "weston", + "QSG_RHI_BACKEND": "opengl", + "QT_LOGGING_RULES": "*.debug=false", + }) + # Both app and compositor keep all caches/state/diagnostics under this run's + # temporary root, including on timeout. Do not retain potentially private + # logs (even startup failures), environment dumps or account databases. + with tempfile.TemporaryDirectory(prefix="spool-profile-") as directory: + environment["TMPDIR"] = directory + process = subprocess.Popen( + ["bash", str(ROOT / "tools/launch-native-app.sh"), str(app)], + env=environment, cwd=ROOT, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, + ) + try: + code = process.wait(timeout=args.timeout) + if code != 0: + raise ValueError(f"benchmark/isolated graphics launcher exited {code}; no valid run") + except subprocess.TimeoutExpired: + raise ValueError(f"benchmark exceeded {args.timeout:g}s deadline") from None + finally: + # A second Ctrl-C must not interrupt teardown and orphan the app. + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + try: + stop_group(process) + finally: + signal.signal(signal.SIGINT, cancel) + signal.signal(signal.SIGTERM, cancel) + if not output.is_file(): + raise ValueError("app exited without writing its benchmark report") + report = REPORT.load(output) + REPORT.validate_report(report) + if (report.get("schemaVersion") != 2 or report.get("complete") is not True + or report.get("iterations") != args.iterations + or report.get("warmupPasses") != args.warmup + or report.get("cold") != cold or report.get("qpaPlatform") != "wayland" + or report.get("measurementScope") != "route-transition"): + raise ValueError("app report does not describe the requested native route measurement") + script = report.get("script") + if not isinstance(script, list) or not script or any(not isinstance(route, str) for route in script): + raise ValueError("app report has no benchmark script") + # The warmup ends on the final route, so repeated identical routes are + # deliberately skipped by the real benchmark. Require every other step in + # every measured pass; a partial walk must never look like a faster walk. + previous = script[-1] + steps = [] + for step, route in enumerate(script): + if route != previous: + steps.append(step) + previous = route + expected = {(run, step) for run in range(args.iterations) for step in steps} + observed = [(sample.get("pass"), sample.get("step")) for sample in report["samples"]] + if len(observed) != len(expected) or set(observed) != expected: + raise ValueError("benchmark did not measure every requested route step/pass exactly once") + if any(sample["routeTo"] != script[sample["step"]] for sample in report["samples"]): + raise ValueError("benchmark samples do not match their script steps") + idle = report.get("idleGapsMs") + if (not isinstance(idle, list) or len(idle) != len(observed) + or any(isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(value) or value < 0 for value in idle)): + raise ValueError("benchmark did not record a valid idle noise floor beside every route sample") + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app", type=Path, default=ROOT / "build/linux-release/install/bin/spool") + parser.add_argument("--output", type=Path, required=True, help="new artifact directory; must not already exist") + parser.add_argument("--mode", choices=("warm", "eviction", "both"), default="both") + parser.add_argument("--repeats", type=int, default=3, help="fresh app/display runs per mode") + parser.add_argument("--iterations", type=int, default=4, help="measured walks per app process") + parser.add_argument("--warmup", type=int, default=1, help="unmeasured walks (at least one)") + parser.add_argument("--timeout", type=float, default=120, help="total seconds per run including display startup") + parser.add_argument("--step-timeout-ms", type=int, default=8000) + parser.add_argument("--settle-ms", type=int, default=120) + args = parser.parse_args() + if sys.platform != "linux": + parser.error("this driver requires Linux's private graphics session; it never uses the host desktop") + if (min(args.repeats, args.iterations, args.warmup, args.step_timeout_ms, args.settle_ms) < 1 + or not math.isfinite(args.timeout) or args.timeout <= 0): + parser.error("counts/timings must be positive and finite") + app = args.app.resolve() + if not app.is_file() or not os.access(app, os.X_OK): + parser.error("--app must name an existing executable; build the native release first") + if any(not os.environ.get(key) for key in GRAPHICS_ENV): + parser.error("missing pinned graphics environment; run through nix develop .#native -c python3") + output = args.output.resolve() + os.umask(0o077) + try: + output.mkdir(parents=True, exist_ok=False, mode=0o700) + except OSError: + parser.error("--output must be a writable new directory (existing artifacts are never overwritten)") + signal.signal(signal.SIGTERM, cancel) + signal.signal(signal.SIGINT, cancel) + summary = { + "schemaVersion": 1, "complete": False, + "scope": "isolated-empty-profile-route-transitions", + "rendering": { + "display": "private-weston-x11", "apiSelection": "opengl", + "driverSelection": "Mesa llvmpipe", "softwareRendering": True, + }, + "appSha256": None, + "configuration": {key: getattr(args, key) for key in ( + "mode", "repeats", "iterations", "warmup", "timeout", "step_timeout_ms", "settle_ms")}, + "runs": [], "modes": {}, + } + write_json(output / "summary.json", summary) + try: + with app.open("rb") as handle: + summary["appSha256"] = hashlib.file_digest(handle, "sha256").hexdigest() + modes = ("warm", "eviction") if args.mode == "both" else (args.mode,) + for mode in modes: + reference = None + for repeat in range(1, args.repeats + 1): + name = f"{mode}-{repeat:02d}.json" + report = measure(app, output / name, mode == "eviction", args) + if reference is not None and not REPORT.comparable(report, reference): + raise ValueError("rendering environment changed between repeats; refusing to aggregate") + reference = report + routes = REPORT.summarise(report) + summary["runs"].append({ + "report": name, "mode": mode, + "overallMs": sum(route["wallMs"] for route in routes.values()), + "routes": routes, + "idleMedianMs": statistics.median(report["idleGapsMs"]), + "idleMaxMs": max(report["idleGapsMs"]), + "environment": {key: report.get(key) for key in REPORT.COMPARISON_KEYS}, + }) + write_json(output / "summary.json", summary) + runs = [run for run in summary["runs"] if run["mode"] == mode] + totals = [run["overallMs"] for run in runs] + summary["modes"][mode] = { + "medianOverallMs": statistics.median(totals), + "minOverallMs": min(totals), "maxOverallMs": max(totals), + } + summary["complete"] = True + lines = ["# Native route profile", "", + "Fresh empty profiles on private Weston/Mesa llvmpipe, not hardware/display pacing.", + "Overall = sum of per-route median wall times within each app run.", + "Eviction requests app memory-pressure eviction, not a cold OS/disk cache.", "", + "| mode | runs | median overall | min | max |", "|---|---:|---:|---:|---:|"] + for mode, totals in summary["modes"].items(): + lines.append(f"| {mode} | {args.repeats} | {totals['medianOverallMs']:.2f} ms | " + f"{totals['minOverallMs']:.2f} ms | {totals['maxOverallMs']:.2f} ms |") + lines += ["", "Per-route timings, swap callbacks and idle timer noise are in summary.json.", + "No accounts, playback/provider pipeline, panel presentation, RSS or allocations are measured."] + (output / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + print("\n".join(lines)) + return 0 + except Cancelled as error: + summary["complete"] = False + summary["failure"] = "cancelled" + print("native profile: cancelled; owned child processes stopped", file=sys.stderr) + return 128 + error.args[0] + except (OSError, ValueError, TypeError) as error: + summary["complete"] = False + summary["failure"] = str(error) + print(f"native profile: {error}", file=sys.stderr) + return 1 + finally: + write_json(output / "summary.json", summary) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/profile-streaming.py b/tools/profile-streaming.py new file mode 100755 index 00000000..46a0bbd2 --- /dev/null +++ b/tools/profile-streaming.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Measure real playback through an already-running private Spool instance. + +Use the normal credential restoration path; this driver never handles media URLs, +headers or account configuration. Playback updates normal server watch progress. +The report contains only numeric source/transport data and account ID prefixes. +""" +from __future__ import annotations + +import argparse +import json +import math +import os +from pathlib import Path +import statistics +import subprocess +import time + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--spoolet", type=Path, required=True) + parser.add_argument("--instance", required=True) + parser.add_argument("--item", action="append", required=True, help="qualified playable item ID; repeat for servers") + parser.add_argument("--output", type=Path, required=True, help="new private report directory") + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--seconds", type=float, default=20, help="observed playback seconds after loading") + parser.add_argument("--timeout", type=float, default=90, help="per-play startup deadline") + args = parser.parse_args() + if args.repeats < 1 or any(not math.isfinite(value) or value <= 0 for value in (args.seconds, args.timeout)): + parser.error("counts and durations must be positive and finite") + cli = args.spoolet.resolve() + if not cli.is_file() or not os.access(cli, os.X_OK): + parser.error("--spoolet must be an executable") + os.umask(0o077) + args.output.mkdir(parents=True, exist_ok=False, mode=0o700) + report = {"schemaVersion": 1, "complete": False, "scope": "real-server-auto-playback", "samples": []} + + def save(): + destination = args.output / "streaming.json" + temporary = destination.with_suffix(".tmp") + temporary.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n") + temporary.replace(destination) + + def call(command, *values): + result = subprocess.run([str(cli), "--instance", args.instance, command, *map(str, values)], + capture_output=True, text=True, timeout=15) + try: + reply = json.loads(result.stdout) + except (ValueError, TypeError): + raise RuntimeError("control command returned no valid response") from None + if result.returncode != 0 or not reply.get("ok"): + # Do not copy provider/server errors into the report. + raise RuntimeError("control command rejected: " + command) + return reply.get("result", {}) + + def transport(state): + raw = state.get("network", {}) + # The production snapshot is already credential-free. Keep this second + # numeric allowlist so future diagnostics cannot expand report contents. + keys = ("received-bytes", "current-received-bytes-per-second", "average-received-bytes-per-second", + "peak-received-bytes-per-second", "elapsed-seconds", "selected-requests", "latency-ms", + "capacity-bytes-per-second", "capacity-samples", "active-requests", "peak-active-requests", + "failed-requests", "retry-attempts", "stream-id", "range-bytes", "ring-bytes") + result = {key: raw[key] for key in keys if isinstance(raw.get(key), (int, float)) and not isinstance(raw[key], bool) + and math.isfinite(raw[key])} + for key in ("lane-samples", "concurrency-samples"): + result[key] = [{field: value for field, value in row.items() + if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)} + for row in raw.get(key, []) if isinstance(row, dict)] + return result + + save() + active = False + try: + initial = call("status") + if initial.get("playback", {}).get("active"): + raise RuntimeError("instance is already playing; refusing to interrupt it") + for item in args.item: + account = item.split(":", 1)[0] + for repeat in range(args.repeats): + # Let deferred teardown finish: repeats include true core setup, + # but intentionally retain learned per-server transport history. + time.sleep(1.5) + start = time.monotonic() + call("play", item) + active = True + sample = {"account": account, "repeat": repeat + 1, "loadedMs": None, + "advancingMs": None, "bufferingSeconds": 0, "network": []} + report["samples"].append(sample) + last_tick = start + previous_buffering = False + loaded = None + initial_position = None + next_snapshot = start + while True: + state = call("status") + now = time.monotonic() + playback = state.get("playback", {}) + if playback.get("failed") or state.get("appError"): + raise RuntimeError("playback failed") + if previous_buffering: + sample["bufferingSeconds"] += now - last_tick + last_tick = now + previous_buffering = bool(playback.get("buffering")) + if loaded is None and playback.get("loaded"): + loaded = now + sample["loadedMs"] = (now - start) * 1000 + initial_position = playback.get("position", 0) + source = state.get("source", {}) + sample["source"] = {key: source[key] for key in ("bitrate", "width", "height", "qualityHeight") + if isinstance(source.get(key), (int, float))} + if loaded is not None and sample["advancingMs"] is None: + if playback.get("position", 0) > initial_position + 0.1 and not playback.get("paused"): + sample["advancingMs"] = (now - start) * 1000 + if now >= next_snapshot: + sample["network"].append({"atSeconds": now - start, **transport(state)}) + next_snapshot = now + 1 + save() + if loaded is None and now - start > args.timeout: + raise RuntimeError("startup deadline exceeded") + if loaded is not None and now - loaded >= args.seconds: + if sample["advancingMs"] is None: + raise RuntimeError("loaded media did not advance") + if not any("received-bytes" in row for row in sample["network"]): + raise RuntimeError("no real curl transport metrics observed") + break + time.sleep(0.05) + call("stop") + active = False + save() + report["complete"] = True + lines = ["# Real-server automatic streaming profile", "", + "Observed on this client/network, not a TV or a guarantee of globally fastest startup.", + "Startup includes provider negotiation, player setup, container probing and rendering.", + "Repeats retain learned server history; playback updates normal watch progress.", "", + "| Account | Runs | Median loaded | Median advancing | Buffering total |", + "|---|---:|---:|---:|---:|"] + for account in dict.fromkeys(row["account"] for row in report["samples"]): + rows = [row for row in report["samples"] if row["account"] == account] + lines.append(f"| {account} | {len(rows)} | {statistics.median(row['loadedMs'] for row in rows):.0f} ms | " + f"{statistics.median(row['advancingMs'] for row in rows):.0f} ms | " + f"{sum(row['bufferingSeconds'] for row in rows):.2f} s |") + text = "\n".join(lines) + "\n" + (args.output / "streaming.md").write_text(text) + print(text, end="") + return 0 + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error: + report["failure"] = str(error) + print("streaming profile: " + str(error)) + return 1 + finally: + if active: + try: + call("stop") + except (OSError, RuntimeError, subprocess.TimeoutExpired): + pass + save() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/publish-website-metadata.py b/tools/publish-website-metadata.py index d5873f4f..8489a514 100755 --- a/tools/publish-website-metadata.py +++ b/tools/publish-website-metadata.py @@ -45,8 +45,9 @@ def release_version(manifest: bytes) -> tuple[int, int, int]: platforms = [platform["id"] for platform in document["release"]["platforms"]] except (KeyError, TypeError): raise ValueError("invalid release snapshot") from None - if platforms != ["webos", "android", "windows", "macos", "linux", "tvos"]: - raise ValueError("invalid release snapshot: expected all six release platforms") + required = ["webos", "android", "windows", "macos", "linux", "tvos"] + if platforms not in (required, required + ["ios"]): + raise ValueError("invalid release snapshot: expected all six release platforms, optionally followed by iOS") return version diff --git a/tools/report-player-setup.py b/tools/report-player-setup.py new file mode 100755 index 00000000..a302ebbf --- /dev/null +++ b/tools/report-player-setup.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Report real PlayerController setup; gate only egregious median costs. + +Absolute ceilings deliberately do not compare different hosted runner machines. +A single scheduling outlier remains visible but cannot fail the performance gate. +""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import statistics +import sys + +# Milliseconds, not interactive frame budgets: these catch seconds-long blocking +# or a catastrophically slow local setup without policing normal CI variance. +CEILINGS = { + "constructCallMs": 2000, + "playCallMs": 2000, + "fileLoadedMs": 15000, + "firstFrameMs": 20000, + "playHeartbeatMaxGapMs": 2000, + "setupWorkerMs": 15000, + "preparationHeartbeatMaxGapMs": 2000, +} +METRICS = { + "constructCallMs": "construct call", + "playCallMs": "play call", + "fileLoadedMs": "file loaded", + "firstFrameMs": "visible frame", + "playHeartbeatMaxGapMs": "play heartbeat gap", + "preparationReadyMs": "preparation ready (includes heuristic delay)", + "setupWorkerMs": "initialization worker", + "preparationHeartbeatMaxGapMs": "preparation heartbeat gap", +} + + +def describe(report: dict) -> tuple[str, bool]: + if not isinstance(report, dict) or report.get("schemaVersion") != 1 or report.get("complete") is not True: + raise ValueError("missing or incomplete player setup measurement") + samples = report.get("samples") + if not isinstance(samples, list) or len(samples) < 6: + raise ValueError("at least three real samples per mode are required") + groups = {mode: [] for mode in ("cold", "prepared")} + for sample in samples: + if not isinstance(sample, dict) or sample.get("mode") not in groups: + raise ValueError("invalid sample mode") + for metric in METRICS: + value = sample.get(metric) + if (isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(value) or value < 0): + raise ValueError(f"missing or invalid {metric}") + if sample["fileLoadedMs"] <= 0 or sample["firstFrameMs"] < sample["fileLoadedMs"]: + raise ValueError("sample did not measure media loading and a subsequent frame") + if not isinstance(sample.get("preparedAdopted"), bool): + raise ValueError("sample does not attribute prepared core adoption") + if sample["mode"] == "prepared": + if not sample["preparedAdopted"] or sample.get("preparationHeartbeats", 0) < 1: + raise ValueError("prepared sample did not observe background preparation and adoption") + elif sample["preparedAdopted"]: + raise ValueError("cold sample unexpectedly adopted a prepared core") + groups[sample["mode"]].append(sample) + if any(len(group) < 3 for group in groups.values()): + raise ValueError("at least three samples of each mode are required") + + lines = ["### Player setup benchmark", "", + "Real PlayerController + libmpv, local demux/decode, embedded OpenGL video; the fixture has no audio stream.", + "Cold means a fresh controller without a prepared core; only the first cold sample is process-cold. " + "OS/library caches are not flushed. Prepared readiness includes the launch-safe heuristic delay.", + "Heartbeat gaps are GUI timer delivery gaps, not display presentation or dropped-frame counts. " + "Visible-frame timing includes framebuffer capture; actual panel presentation is not measured.", "", + f"Qt {report.get('qtVersion', '?')} · {report.get('cpuArchitecture', '?')} · " + f"{report.get('qpaPlatform', '?')} · {len(samples)} samples", "", + "| mode | metric | median ms | worst ms | fail ceiling (median ms) |", + "|---|---|---:|---:|---:|"] + failures = [] + for mode, group in groups.items(): + for metric, label in METRICS.items(): + if mode == "cold" and metric.startswith("preparation"): + continue + values = [sample[metric] for sample in group] + median = statistics.median(values) + ceiling = CEILINGS.get(metric) + if ceiling is not None and median > ceiling: + failures.append(f"{mode} {label}: {median:.1f} ms > {ceiling} ms") + lines.append(f"| {mode} | {label} | {median:.1f} | {max(values):.1f} | " + f"{ceiling if ceiling is not None else 'diagnostic only'} |") + lines += ["", "Only egregious absolute median costs fail on shared runners; individual outliers and " + "cold/prepared ratios are diagnostic, not gates."] + if failures: + lines += ["", "**Egregious player setup slowdown:**", *[f"- {failure}" for failure in failures]] + return "\n".join(lines) + "\n", bool(failures) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("current", type=Path) + parser.add_argument("--markdown", type=Path) + args = parser.parse_args() + try: + text, failed = describe(json.loads(args.current.read_text(encoding="utf-8"))) + except (OSError, ValueError, TypeError) as error: + text = f"### Player setup benchmark\n\n**Measurement failed:** {error}\n" + failed = True + print(text, end="") + if args.markdown: + args.markdown.write_text(text, encoding="utf-8") + return int(failed) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test-gpu-session.sh b/tools/test-gpu-session.sh index ded98dde..517d9f4d 100755 --- a/tools/test-gpu-session.sh +++ b/tools/test-gpu-session.sh @@ -2,6 +2,9 @@ # Run GUI consumers on a private display, never the developer's desktop. set -euo pipefail [[ $# -gt 0 ]] || { echo 'usage: test-gpu-session.sh [args...]' >&2; exit 2; } +# Already inside a private session (run-tests.py wraps the whole e2e phase and +# CTest wraps the e2e entry too): one session is enough. +[[ "${SPOOL_PRIVATE_TEST_SESSION:-}" == 1 ]] && exec "$@" : "${SPOOL_TEST_DRI_DIR:?run through nix develop .#native}" : "${SPOOL_TEST_VULKAN_ICD:?run through nix develop .#native}" : "${SPOOL_TEST_DRIVER_LIB_DIR:?run through nix develop .#native}" @@ -30,7 +33,7 @@ trap 'exit 143' TERM mkdir -p "$work/runtime" chmod 700 "$work/runtime" export XDG_RUNTIME_DIR="$work/runtime" -export SPOOL_E2E_ISOLATED_DISPLAY=1 +export SPOOL_E2E_ISOLATED_DISPLAY=1 SPOOL_PRIVATE_TEST_SESSION=1 unset DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS QT_QUICK_BACKEND unset VK_ICD_FILENAMES VK_ADD_DRIVER_FILES MESA_GL_VERSION_OVERRIDE MESA_GLSL_VERSION_OVERRIDE export LIBGL_DRIVERS_PATH="$SPOOL_TEST_DRI_DIR" diff --git a/tools/webos-native/build-curl.sh b/tools/webos-native/build-curl.sh index a159d374..9e3ed2fb 100755 --- a/tools/webos-native/build-curl.sh +++ b/tools/webos-native/build-curl.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +PHASE="${1:-all}" +if (( $# > 1 )); then echo "usage: $0 [fetch|build|all]" >&2; exit 2; fi +case "$PHASE" in + -h|--help) echo "usage: $0 [fetch|build|all]"; exit 0 ;; + fetch|build|all) ;; + *) echo "usage: $0 [fetch|build|all]" >&2; exit 2 ;; +esac + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck source=tools/lib/build-common.sh source "$ROOT/tools/lib/build-common.sh" @@ -10,7 +18,6 @@ source "$ROOT/tools/lib/manifest-sources.sh" source "$ROOT/tools/webos-native/nixos-sdk-compat.sh" MANIFEST="${WEBOS_THIRD_PARTY_MANIFEST:-$ROOT/tools/manifests/webos-third-party.json}" -PHASE="${1:-all}" SDK_ROOT="${WEBOS_SDK_ROOT:-$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot}" SYSROOT="$SDK_ROOT/arm-webos-linux-gnueabi/sysroot" TARGET_PREFIX="${WEBOS_TARGET_PREFIX:-/usr/local/webos-native}" @@ -25,10 +32,6 @@ if [[ "$PHASE" == "fetch" ]]; then echo "Fetched verified curl source into $SRC_DIR" exit 0 fi -if [[ "$PHASE" != "all" && "$PHASE" != "build" ]]; then - echo "usage: $0 [fetch|build|all]" >&2 - exit 2 -fi ensure_webos_sdk_host_tools "$SDK_ROOT" rm -rf "$BUILD_DIR" diff --git a/tools/webos-native/build-ffmpeg.sh b/tools/webos-native/build-ffmpeg.sh index 2c7a212e..e9bb81a0 100755 --- a/tools/webos-native/build-ffmpeg.sh +++ b/tools/webos-native/build-ffmpeg.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +if [[ $# == 1 && ( "$1" == -h || "$1" == --help ) ]]; then + echo "Usage: $0" + echo 'Fetches pinned FFmpeg and makes a fresh webOS dependency build.' + exit 0 +fi +(( $# == 0 )) || { echo "Usage: $0" >&2; exit 2; } + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck source=tools/lib/build-common.sh source "$ROOT/tools/lib/build-common.sh" diff --git a/tools/webos-native/build-qcoro.sh b/tools/webos-native/build-qcoro.sh index 56551461..3348584c 100755 --- a/tools/webos-native/build-qcoro.sh +++ b/tools/webos-native/build-qcoro.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +PHASE="${1:-all}" +if (( $# > 1 )); then echo "usage: $0 [fetch|build|all]" >&2; exit 2; fi +case "$PHASE" in + -h|--help) echo "usage: $0 [fetch|build|all]"; exit 0 ;; + fetch|build|all) ;; + *) echo "usage: $0 [fetch|build|all]" >&2; exit 2 ;; +esac + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck source=tools/lib/build-common.sh source "$ROOT/tools/lib/build-common.sh" @@ -11,7 +19,6 @@ source "$ROOT/tools/webos-native/nixos-sdk-compat.sh" MANIFEST="${WEBOS_THIRD_PARTY_MANIFEST:-$ROOT/tools/manifests/webos-third-party.json}" QT_MANIFEST="${QT_MANIFEST:-$ROOT/tools/manifests/qt-webos-6.11.json}" -PHASE="${1:-all}" QT_STATIC="${QT_STATIC:-1}" QT_VERSION="${QT_VERSION:-$(manifest_qt_field "$QT_MANIFEST" qtVersion)}" QT_BUILD_TAG="${QT_VERSION%.*}" @@ -49,10 +56,6 @@ if [[ "$PHASE" == "fetch" ]]; then exit 0 fi -if [[ "$PHASE" != "all" && "$PHASE" != "build" ]]; then - echo "usage: $0 [fetch|build|all]" >&2 - exit 2 -fi if [[ ! -f "$QT_TARGET_PREFIX/lib/cmake/Qt6/Qt6Config.cmake" ]]; then echo "error: target Qt prefix is missing at $QT_TARGET_PREFIX" >&2 diff --git a/tools/webos-native/build-third-party.sh b/tools/webos-native/build-third-party.sh index a6704f6d..bc70ea33 100755 --- a/tools/webos-native/build-third-party.sh +++ b/tools/webos-native/build-third-party.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +PHASE="${1:-all}" +if (( $# > 1 )); then echo "usage: $0 [fetch|build|all]" >&2; exit 2; fi +case "$PHASE" in + -h|--help) echo "usage: $0 [fetch|build|all]"; exit 0 ;; + fetch|build|all) ;; + *) echo "usage: $0 [fetch|build|all]" >&2; exit 2 ;; +esac + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck source=tools/lib/build-common.sh source "$ROOT/tools/lib/build-common.sh" @@ -9,7 +17,6 @@ source "$ROOT/tools/lib/manifest-sources.sh" # shellcheck source=tools/webos-native/nixos-sdk-compat.sh source "$ROOT/tools/webos-native/nixos-sdk-compat.sh" MANIFEST="${WEBOS_THIRD_PARTY_MANIFEST:-$ROOT/tools/manifests/webos-third-party.json}" -PHASE="${1:-all}" SDK_ROOT="${WEBOS_SDK_ROOT:-$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot}" SDK_BIN="$SDK_ROOT/bin" SYSROOT="$SDK_ROOT/arm-webos-linux-gnueabi/sysroot" @@ -49,10 +56,6 @@ if [[ "$PHASE" == "fetch" ]]; then exit 0 fi -if [[ "$PHASE" != "all" && "$PHASE" != "build" ]]; then - echo "usage: $0 [fetch|build|all]" >&2 - exit 2 -fi describe_parallel_jobs "$WEBOS_BUILD_JOBS" "webOS third-party" "$WEBOS_BUILD_MEMORY_PER_JOB_MIB" "$WEBOS_BUILD_MEMORY_RESERVE_MIB" diff --git a/tools/webos-native/build-webos.sh b/tools/webos-native/build-webos.sh index d1c7a9a0..6741f211 100755 --- a/tools/webos-native/build-webos.sh +++ b/tools/webos-native/build-webos.sh @@ -2,28 +2,25 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# shellcheck source=tools/lib/manifest-sources.sh -source "$ROOT/tools/lib/manifest-sources.sh" -MANIFEST="${WEBOS_THIRD_PARTY_MANIFEST:-$ROOT/tools/manifests/webos-third-party.json}" +usage() { + echo "Usage: $0 [all|fetch|qt-host|qt-target|dependencies|app|stage|package]" +} +if (( $# > 1 )); then + usage >&2 + exit 2 +fi PHASE="${1:-all}" -SDK_ROOT="${WEBOS_SDK_ROOT:-$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot}" - case "$PHASE" in - all|qt-target|dependencies|app|stage) - WEBOS_SDK_ROOT="$SDK_ROOT" "$ROOT/tools/webos-native/restore-toolchain.sh" - ;; + -h|--help) usage; exit 0 ;; + all|fetch|qt-host|qt-target|dependencies|app|stage|package) ;; + *) usage >&2; exit 2 ;; esac +TOOLS_ROOT="${WEBOS_TOOLS_ROOT:-$ROOT/tools/webos-native}" +# shellcheck source=tools/lib/manifest-sources.sh +source "$ROOT/tools/lib/manifest-sources.sh" +SDK_ROOT="${WEBOS_SDK_ROOT:-$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot}" -fetch_archive() { - local source_name="$1" - local filename="$2" - local url sha - url="$(manifest_source_field "$MANIFEST" "$source_name" url)" - sha="$(manifest_source_field "$MANIFEST" "$source_name" sha256)" - download_verified "$url" "$sha" "$ROOT/build/downloads/$filename" -} - fetch_toolchain_ffmpeg() { local version url sha version="$(toolchain_field "$ROOT" ffmpeg.version)" @@ -36,23 +33,25 @@ run_phase() { local phase="$1" case "$phase" in fetch) - "$ROOT/tools/webos-native/build-qt6-611.sh" fetch - "$ROOT/tools/webos-native/build-third-party.sh" fetch - "$ROOT/tools/webos-native/build-curl.sh" fetch - "$ROOT/tools/webos-native/build-qcoro.sh" fetch + "$TOOLS_ROOT/build-qt6-611.sh" fetch + "$TOOLS_ROOT/build-third-party.sh" fetch + "$TOOLS_ROOT/build-curl.sh" fetch + "$TOOLS_ROOT/build-qcoro.sh" fetch fetch_toolchain_ffmpeg ;; qt-host) - "$ROOT/tools/webos-native/build-qt6-611.sh" host + "$TOOLS_ROOT/build-qt6-611.sh" host ;; qt-target) - QT_STATIC="${QT_STATIC:-1}" "$ROOT/tools/webos-native/build-qt6-611.sh" target + WEBOS_SDK_ROOT="$SDK_ROOT" "$TOOLS_ROOT/restore-toolchain.sh" + QT_STATIC="${QT_STATIC:-1}" "$TOOLS_ROOT/build-qt6-611.sh" target ;; dependencies) - "$ROOT/tools/webos-native/build-third-party.sh" build - "$ROOT/tools/webos-native/build-curl.sh" build - QT_STATIC="${QT_STATIC:-1}" "$ROOT/tools/webos-native/build-qcoro.sh" build - "$ROOT/tools/webos-native/build-ffmpeg.sh" + WEBOS_SDK_ROOT="$SDK_ROOT" "$TOOLS_ROOT/restore-toolchain.sh" + "$TOOLS_ROOT/build-third-party.sh" build + "$TOOLS_ROOT/build-curl.sh" build + QT_STATIC="${QT_STATIC:-1}" "$TOOLS_ROOT/build-qcoro.sh" build + "$TOOLS_ROOT/build-ffmpeg.sh" ;; app|stage|package) "$ROOT/build-ipk.sh" "$phase" diff --git a/tools/webos/bench-device.sh b/tools/webos/bench-device.sh index 154e2b7d..899021c8 100755 --- a/tools/webos/bench-device.sh +++ b/tools/webos/bench-device.sh @@ -1,153 +1,121 @@ #!/usr/bin/env bash -# Run the render benchmark on a real webOS television and bring the numbers back. -# -# This is deliberately not part of ctest. It needs a rooted television with the -# app installed, a signed-in Jellyfin server, and a screen that is on -- none of -# which CI has, and all of which make it a manual measurement rather than a -# gate. tools/compare-render-benchmark.py is the gate that does run in CI. -# -# The app is started through the application manager rather than over ssh. A -# binary launched by hand runs as root, misses the environment the television -# hands its apps, and never comes to the foreground; it also leaves root-owned -# files in the app's own data directories, after which the app -- which is not -# root -- can no longer write its settings or database. So the benchmark's -# environment is handed over in a launch wrapper instead, and the wrapper and -# the appinfo that points at it are always put back. -# -# Usage: -# tools/webos/bench-device.sh # warm routes -# tools/webos/bench-device.sh --cold -# tools/webos/bench-device.sh --mode library --library Movies -# tools/webos/bench-device.sh --mode library --library Movies --list --cold +# Explicit on-device measurement using a previously installed benchmark IPK. set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT/tools/webos/device-common.sh" +HOST="${TV_HOST:-${SPOOL_TV_HOST:-}}" +APP_ID="${APP_ID:-com.sachk.spool}" +MODE=routes +ITERATIONS=6 +LIBRARY=Movies +COLD=0 +LIST_MODE=0 +SCROLL_STEPS=0 +OUT_DIR="${SPOOL_BENCH_DIR:-$ROOT/dist/tv-benchmark}" +WAIT_SECONDS=240 +LUNA_BIN="${LUNA_BIN:-luna-send}" +usage() { + cat <&2; exit 2 ;; + --host|--mode|--script|--iterations|--library|--scroll-steps|--out|--wait) + webos_require_value "$@" + case "$1" in + --host) HOST="$2" ;; --mode|--script) MODE="$2" ;; + --iterations) ITERATIONS="$2" ;; --library) LIBRARY="$2" ;; + --scroll-steps) SCROLL_STEPS="$2" ;; --out) OUT_DIR="$2" ;; --wait) WAIT_SECONDS="$2" ;; + esac + shift 2 ;; + --cold) COLD=1; shift ;; + --list) LIST_MODE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) webos_error "unknown argument: $1" ;; esac done - -label="${SCRIPT_ROUTES:+walk}" -[[ -z "$label" ]] && label="$MODE" -[[ -n "$COLD" ]] && label="$label-cold" || label="$label-warm" -[[ -n "$LIST_MODE" ]] && label="$label-list" -remote_out="/tmp/spool-bench-$label.json" +webos_app_id "$APP_ID" +webos_uint --iterations "$ITERATIONS" 1 100 +webos_uint --scroll-steps "$SCROLL_STEPS" 0 10000 +webos_uint --wait "$WAIT_SECONDS" 1 3600 +[[ "$MODE" =~ ^[a-zA-Z0-9,_-]+$ ]] || webos_error 'invalid benchmark mode/route walk' +[[ "$LIBRARY" != *$'\n'* && "$LIBRARY" != *$'\r'* ]] || webos_error 'library must be one line' +webos_transport_init "$HOST" +webos_require_command python3 +webos_require_command scp mkdir -p "$OUT_DIR" +label="${MODE//,/-}" +(( ! COLD )) || label+="-cold" +(( COLD )) || label+="-warm" +(( ! LIST_MODE )) || label+="-list" +remote_out="/tmp/spool-bench-$$.json" +marker=/tmp/spool-benchmark.on local_out="$OUT_DIR/$label.json" - -tv() { ssh -o StrictHostKeyChecking=no "$HOST" "$@"; } +armed=0 +launched=0 luna() { - local uri="$1" - local payload="${2:-{\}}" - ssh -F /dev/null -tt -o StrictHostKeyChecking=no "$HOST" \ - "${LUNA_BIN:-luna-send} -n 1 '$uri' '$payload'" 2>&1 | tr -d '\r' | sed 's/^/ luna: /' -} - -# A panel that goes to sleep mid-run does not stop the walk. It quietly changes -# what is being measured -- present and swap stretch against a panel that is no -# longer refreshing -- and the run still writes a plausible-looking report. So -# check before, and check again afterwards: a number taken against a sleeping -# screen should announce itself rather than pass for a good one. -power_state() { - ssh -F /dev/null -tt -o StrictHostKeyChecking=no "$HOST" \ - "${LUNA_BIN:-luna-send} -n 1 'luna://com.webos.service.tvpower/power/getPowerState' '{}'" 2>/dev/null \ - | tr -d '\r' | sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 + local result + result="$(webos_luna "$HOST" "$1" "${2:-\{\}}")" || return + python3 "$ROOT/tools/webos/luna-response.py" success <<<"$result" } - -require_awake() { - local state - state="$(power_state)" - if [[ "$state" != "Active" ]]; then - echo "==> screen reports '${state:-unknown}'; asking for it to come on" - luna "luna://com.webos.service.tvpower/power/turnOnScreen" "{}" >/dev/null 2>&1 || true - sleep 3 - state="$(power_state)" +cleanup() { + local status=$? + if (( armed )); then + webos_ssh -n "$HOST" "rm -f '$marker' '$remote_out'" >/dev/null 2>&1 || status=1 fi - if [[ "$state" != "Active" ]]; then - echo "error: screen is '${state:-unknown}', not Active -- the walk would measure a sleeping panel" >&2 - exit 1 + if (( launched )); then + luna luna://com.webos.applicationManager/closeByAppId "{\"id\":\"$APP_ID\"}" || status=1 fi + return "$status" } - -restore() { - echo "==> restoring $APP_ID" - luna "luna://com.webos.applicationManager/closeByAppId" "{\"id\":\"$APP_ID\"}" || true - # Put the television back on the input somebody was actually watching. A - # benchmark that leaves the set staring at a closed app is a benchmark that - # gets noticed by the person who owns the room. - luna "luna://com.webos.applicationManager/launch" "{\"id\":\"${SPOOL_TV_RETURN_APP:-com.webos.app.hdmi1}\"}" || true - tv "cd '$APP_DIR/bin' \ - && if [ -f spool.real ]; then mv -f spool.real spool; fi \ - && cd '$APP_DIR' \ - && true \ - && chown -R 5486:5000 .config .cache .local 2>/dev/null || true" >/dev/null 2>&1 || true +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +power_state() { + webos_luna "$HOST" luna://com.webos.service.tvpower/power/getPowerState '{}' | python3 -c ' +import json,sys +reply=json.load(sys.stdin) +if reply.get("returnValue") is not True: + raise SystemExit(1) +print(reply.get("state", "unknown"))' } -trap restore EXIT - -env_lines=( - "export SPOOL_BENCH='$MODE'" - "export SPOOL_BENCH_ITERATIONS='$ITERATIONS'" - "export SPOOL_BENCH_OUT='$remote_out'" -) -[[ -n "$COLD" ]] && env_lines+=("export SPOOL_BENCH_COLD=1") -# The library is opened whenever one is named, not only in library mode, so a -# custom walk can measure switches with a real library on screen. -env_lines+=("export SPOOL_BENCH_LIBRARY='$LIBRARY'") -[[ -n "$LIST_MODE" ]] && env_lines+=("export SPOOL_BENCH_LIST_MODE=1") -[[ -n "$SCROLL_STEPS" ]] && env_lines+=("export SPOOL_BENCH_SCROLL_STEPS='$SCROLL_STEPS'") - -require_awake -echo "==> preparing $label on $HOST" -luna "luna://com.webos.applicationManager/closeByAppId" "{\"id\":\"$APP_ID\"}" || true -tv "cd '$APP_DIR/bin' \ - && rm -f '$remote_out' \ - && { [ -f spool.real ] || mv spool spool.real; } \ - && printf '%s\n' '#!/bin/sh' $(printf "'%s' " "${env_lines[@]}") 'exec \"\$(dirname \"\$0\")/spool.real\" \"\$@\"' > spool \ - && chmod 755 spool" - -echo "==> launching through the application manager" -luna "luna://com.webos.applicationManager/launch" "{\"id\":\"$APP_ID\"}" - -echo "==> waiting up to ${WAIT_SECONDS}s for $remote_out" -deadline=$(( $(date +%s) + WAIT_SECONDS )) -until tv "[ -s '$remote_out' ]" 2>/dev/null; do - if [[ $(date +%s) -ge $deadline ]]; then - echo "error: no report at $remote_out -- the run did not finish" >&2 - tv "tail -25 '$APP_DIR/.cache/logs/$APP_ID.log' 2>/dev/null" | sed 's/^/ /' || true - exit 1 - fi - sleep 5 +[[ "$(power_state)" == Active ]] || { echo 'panel must already be Active; refusing to wake it' >&2; exit 1; } +webos_ssh -n "$HOST" "test -x /media/cryptofs/apps/usr/palm/applications/$APP_ID/bin/spool.real && test -f /media/cryptofs/apps/usr/palm/applications/$APP_ID/bin/.spool-benchmark-v1" \ + || { echo 'install the benchmark/profiling IPK first; installed files are never patched' >&2; exit 1; } +luna luna://com.webos.applicationManager/closeByAppId "{\"id\":\"$APP_ID\"}" +config="" +for value in spool-benchmark-v1 "$MODE" "$ITERATIONS" "$LIBRARY" "$COLD" "$LIST_MODE" "$SCROLL_STEPS" "$remote_out"; do + config+=" $(webos_quote "$value")" done - -scp -o StrictHostKeyChecking=no "$HOST:$remote_out" "$local_out" >/dev/null -finished_state="$(power_state)" -if [[ "$finished_state" != "Active" ]]; then - echo "WARNING: screen was '${finished_state:-unknown}' when the run finished." >&2 - echo "WARNING: it slept partway through -- treat $local_out as suspect and rerun." >&2 -fi -echo "==> $local_out" -python3 tools/webos/summarise-device-benchmark.py "$local_out" +armed=1 +webos_ssh -n "$HOST" "umask 022; printf '%s\\n'$config > '$marker'" +launched=1 +luna luna://com.webos.applicationManager/launch "{\"id\":\"$APP_ID\"}" +deadline=$(( SECONDS + 10#$WAIT_SECONDS )) +while ! webos_ssh -n "$HOST" "test -s '$remote_out'"; do + (( SECONDS < deadline )) || { echo 'benchmark report deadline expired' >&2; exit 1; } + sleep 2 +done +webos_scp "$HOST:$remote_out" "$local_out" +[[ "$(power_state)" == Active ]] || { echo "panel slept during run; discard $local_out" >&2; exit 1; } +python3 "$ROOT/tools/compare-render-benchmark.py" "$local_out" +python3 "$ROOT/tools/webos/summarise-device-benchmark.py" "$local_out" +echo "Report: $local_out" diff --git a/tools/webos/capture-memory.sh b/tools/webos/capture-memory.sh index 9cef27da..6fb60815 100755 --- a/tools/webos/capture-memory.sh +++ b/tools/webos/capture-memory.sh @@ -1,10 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT/tools/webos/device-common.sh" + host="${TV_HOST:-}" app_id="com.sachk.spool" interval="1" -duration="0" +duration="300" +wait_timeout="600" out="" plot=1 wait_for_process=0 @@ -22,7 +26,8 @@ Options: --host HOST required SSH host unless TV_HOST is set --app-id ID app id/process match (default: com.sachk.spool) --interval SEC sample interval (default: 1) - --duration SEC stop after SEC; 0 means until Ctrl-C (default: 0) + --duration SEC bounded capture window, 1..86400 (default: 300) + --wait-timeout SEC maximum process-lifetime wait (default: 600) --out PATH output CSV path (default: build/memory/.csv) --wait-for-process wait until the app process exists before writing samples @@ -38,11 +43,15 @@ EOF } while [[ $# -gt 0 ]]; do + case "$1" in + --host|--app-id|--interval|--duration|--out|--wait-timeout) webos_require_value "$@" ;; + esac case "$1" in --host) host="$2"; shift 2 ;; --app-id) app_id="$2"; shift 2 ;; --interval) interval="$2"; shift 2 ;; --duration) duration="$2"; shift 2 ;; + --wait-timeout) wait_timeout="$2"; shift 2 ;; --out) out="$2"; shift 2 ;; --wait-for-process) wait_for_process=1; shift ;; --stop-on-exit) stop_on_exit=1; shift ;; @@ -54,10 +63,19 @@ while [[ $# -gt 0 ]]; do esac done [[ -n "$host" ]] || { echo "missing required --host HOST (or TV_HOST)" >&2; usage >&2; exit 2; } +webos_app_id "$app_id" +webos_uint --interval "$interval" 1 3600 +webos_uint --duration "$duration" +webos_uint --wait-timeout "$wait_timeout" +interval=$((10#$interval)) +duration=$((10#$duration)) +wait_timeout=$((10#$wait_timeout)) +webos_transport_init "$host" +(( ! plot )) || webos_require_command python3 if [[ -z "$out" ]]; then - mkdir -p build/memory - out="build/memory/tv-memory-$(date +%Y%m%d-%H%M%S).csv" + mkdir -p "$ROOT/build/memory" + out="$ROOT/build/memory/tv-memory-$(date +%Y%m%d-%H%M%S).csv" else mkdir -p "$(dirname "$out")" fi @@ -67,8 +85,8 @@ trap 'rm -f "$tmp"' EXIT echo "Writing $out" -ssh -F /dev/null -o BatchMode=yes "$host" \ - "APP_ID='$app_id' INTERVAL='$interval' DURATION='$duration' WAIT_FOR_ABSENT='$wait_for_absent' WAIT_FOR_PROCESS='$wait_for_process' STOP_ON_EXIT='$stop_on_exit' sh -s" >"$tmp" <<'REMOTE' +timeout --signal=TERM --kill-after=5s "$((wait_timeout + duration + 15))s" "${WEBOS_SSH[@]}" -- "$host" \ + "APP_ID='$app_id' INTERVAL='$interval' DURATION='$duration' WAIT_TIMEOUT='$wait_timeout' WAIT_FOR_ABSENT='$wait_for_absent' WAIT_FOR_PROCESS='$wait_for_process' STOP_ON_EXIT='$stop_on_exit' sh -s" >"$tmp" <<'REMOTE' app_id=${APP_ID:-com.sachk.spool} interval=${INTERVAL:-1} duration=${DURATION:-0} @@ -76,6 +94,7 @@ wait_for_absent=${WAIT_FOR_ABSENT:-0} wait_for_process=${WAIT_FOR_PROCESS:-0} stop_on_exit=${STOP_ON_EXIT:-0} start=$(date +%s) +wait_deadline=$((start + WAIT_TIMEOUT)) observed_pid= printf '%s\n' 'epoch,pid,vmrss_kb,vmsize_kb,vmdata_kb,vmswap_kb,pss_kb,threads,mem_total_kb,mem_free_kb,mem_available_kb,buffers_kb,cached_kb,swap_total_kb,swap_free_kb' @@ -110,25 +129,33 @@ meminfo_value() { if [ "$wait_for_absent" = "1" ]; then while [ -n "$(find_pid)" ]; do - sleep "$interval" + [ "$(date +%s)" -lt "$wait_deadline" ] || { echo 'timed out waiting for current process to exit' >&2; exit 1; } + remaining=$((wait_deadline - $(date +%s))) + pause=$interval + [ "$pause" -le "$remaining" ] || pause=$remaining + [ "$pause" -le 0 ] || sleep "$pause" done fi while :; do now=$(date +%s) - if [ "$duration" != "0" ] && [ $((now - start)) -ge "$duration" ]; then + if { [ "$wait_for_process" != "1" ] || [ -n "$observed_pid" ]; } && [ $((now - start)) -ge "$duration" ]; then break fi pid=$(find_pid) if [ -z "$observed_pid" ]; then if [ -z "$pid" ] && [ "$wait_for_process" = "1" ]; then - sleep "$interval" + [ "$now" -lt "$wait_deadline" ] || { echo 'timed out waiting for app process' >&2; exit 1; } + remaining=$((wait_deadline - now)) + pause=$interval + [ "$pause" -le "$remaining" ] || pause=$remaining + [ "$pause" -le 0 ] || sleep "$pause" continue fi if [ -n "$pid" ]; then observed_pid=$pid - start=$now + [ "$wait_for_process" != "1" ] || start=$now fi elif [ "$stop_on_exit" = "1" ]; then if [ ! -r "/proc/$observed_pid/status" ]; then @@ -156,7 +183,10 @@ while :; do "$(meminfo_value Cached)" "$(meminfo_value SwapTotal)" \ "$(meminfo_value SwapFree)" - sleep "$interval" + remaining=$((duration - ($(date +%s) - start))) + pause=$interval + [ "$pause" -le "$remaining" ] || pause=$remaining + [ "$pause" -le 0 ] || sleep "$pause" done REMOTE diff --git a/tools/webos/collect-diagnostics-bundle.sh b/tools/webos/collect-diagnostics-bundle.sh index 1f2ddb12..c97d6a08 100755 --- a/tools/webos/collect-diagnostics-bundle.sh +++ b/tools/webos/collect-diagnostics-bundle.sh @@ -1,13 +1,36 @@ #!/usr/bin/env bash set -euo pipefail - -TARGET="${1:-}" -[[ -n "$TARGET" ]] || { echo "Usage: $0 root@tv.local" >&2; exit 2; } +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT/tools/webos/device-common.sh" +if [[ $# == 1 && ( "$1" == -h || "$1" == --help ) ]]; then + echo "Usage: $0 root@tv.local (APP_ID and OUT may be set)" + echo 'Streams a read-only diagnostic archive; never stages files on the device.' + exit 0 +fi +[[ $# == 1 ]] || webos_error "Usage: $0 root@tv.local" +TARGET="$1" APP_ID="${APP_ID:-com.sachk.spool}" OUT="${OUT:-diagnostics-bundle-$(date -u +%Y%m%d-%H%M%S).tar.gz}" - -# Logs live in /tmp/$APP_ID/ since the log-dir refactor; also grab the legacy -# flat /tmp/*.log paths and the persistent fallback dir for older builds. -ssh -tt "$TARGET" "rm -rf /tmp/${APP_ID}-diagnostics-bundle; mkdir -p /tmp/${APP_ID}-diagnostics-bundle; cp -a /tmp/${APP_ID}-diagnostics /tmp/${APP_ID}-diagnostics-bundle/tmp-diagnostics 2>/dev/null || true; cp -a /var/palm/data/${APP_ID}/diagnostics /tmp/${APP_ID}-diagnostics-bundle/appdata-diagnostics 2>/dev/null || true; cp -a /tmp/${APP_ID}/. /tmp/${APP_ID}-diagnostics-bundle/tmp-logs 2>/dev/null || true; cp -a /media/cryptofs/apps/usr/palm/applications/${APP_ID}/.cache/logs/. /tmp/${APP_ID}-diagnostics-bundle/appdir-logs 2>/dev/null || true; cp -a /tmp/${APP_ID}.log /tmp/spool-mpv.log /tmp/${APP_ID}-diagnostics-bundle/ 2>/dev/null || true; ps > /tmp/${APP_ID}-diagnostics-bundle/ps.txt; tar -czf /tmp/${APP_ID}-diagnostics-bundle.tar.gz -C /tmp ${APP_ID}-diagnostics-bundle" -scp "$TARGET:/tmp/${APP_ID}-diagnostics-bundle.tar.gz" "$OUT" -printf 'Wrote %s\n' "$OUT" +webos_app_id "$APP_ID" +webos_transport_init "$TARGET" +mkdir -p "$(dirname "$OUT")" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +webos_ssh -n "$TARGET" ps >"$work/ps.txt" +# tar reads only existing paths and streams straight to the desktop. No remote +# rm/mkdir/cp/archive is needed even when collection fails or is interrupted. +webos_ssh "$TARGET" "APP_ID='$APP_ID' sh -s" >"$work/logs.tar.gz" <<'REMOTE' +set -e +set -- +for path in "tmp/$APP_ID-diagnostics" "var/palm/data/$APP_ID/diagnostics" \ + "tmp/$APP_ID" "media/cryptofs/apps/usr/palm/applications/$APP_ID/.cache/logs" \ + "tmp/$APP_ID.log" tmp/spool-mpv.log; do + [ ! -e "/$path" ] || set -- "$@" "$path" +done +[ "$#" -gt 0 ] || { echo 'no diagnostic files found' >&2; exit 1; } +tar -czf - -C / "$@" +REMOTE +# Keep the remote archive nested: no extraction of device-controlled paths. +tar -czf "$work/bundle.tar.gz" -C "$work" ps.txt logs.tar.gz +mv "$work/bundle.tar.gz" "$OUT" +printf 'Wrote %s (ps.txt and logs.tar.gz; may contain sensitive logs)\n' "$OUT" diff --git a/tools/webos/device-common.sh b/tools/webos/device-common.sh new file mode 100755 index 00000000..03dbaa0c --- /dev/null +++ b/tools/webos/device-common.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Shared validation and bounded, noninteractive transport for device helpers. + +webos_error() { printf 'error: %s\n' "$*" >&2; exit 2; } +webos_require_value() { + [[ $# -ge 2 && -n "$2" && "$2" != --* ]] || webos_error "$1 requires a value" +} +webos_uint() { + local name="$1" value="$2" minimum="${3:-1}" maximum="${4:-86400}" + [[ "$value" =~ ^[0-9]{1,6}$ ]] || webos_error "$name must be an integer ($minimum..$maximum)" + (( 10#$value >= minimum && 10#$value <= maximum )) || webos_error "$name must be $minimum..$maximum" +} +webos_app_id() { + [[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || webos_error "invalid app id: $1" +} +webos_require_command() { + command -v "$1" >/dev/null 2>&1 || { printf 'missing required command: %s\n' "$1" >&2; exit 1; } +} +webos_transport_init() { + local host="$1" + [[ -n "$host" && "$host" != -* && "$host" != *[[:space:]]* ]] || webos_error 'a non-option SSH host is required' + WEBOS_COMMAND_TIMEOUT="${WEBOS_COMMAND_TIMEOUT:-30}" + webos_uint WEBOS_COMMAND_TIMEOUT "$WEBOS_COMMAND_TIMEOUT" + webos_require_command timeout + webos_require_command ssh + WEBOS_SSH=(ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=10 + -o ConnectionAttempts=1 -o ServerAliveInterval=5 -o ServerAliveCountMax=2) + WEBOS_SCP=(scp -F /dev/null -q -o BatchMode=yes -o ConnectTimeout=10 + -o ConnectionAttempts=1 -o ServerAliveInterval=5 -o ServerAliveCountMax=2) +} +webos_timeout() { timeout --signal=TERM --kill-after=5s "${WEBOS_COMMAND_TIMEOUT}s" "$@"; } +webos_ssh() { webos_timeout "${WEBOS_SSH[@]}" "$@"; } +webos_scp() { webos_timeout "${WEBOS_SCP[@]}" -- "$@"; } +# Quote one argument for the remote POSIX shell (not Bash's locale-dependent %q). +webos_quote() { printf "'%s'" "${1//\'/\'\\\'\'}"; } +webos_luna() { + local host="$1" uri="$2" payload="${3:-\{\}}" bin="${LUNA_BIN:-luna-send}" + [[ "$bin" =~ ^[a-zA-Z0-9_./-]+$ && "$bin" != -* ]] || webos_error 'invalid LUNA_BIN' + # luna-send on current firmware prints nothing without a terminal, even + # though it exits 0, so give it one; luna-response.py skips tty chatter. + webos_ssh -tt "$host" "$bin -n 1 $(webos_quote "$uri") $(webos_quote "$payload")" &2; exit 2; } +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT/tools/webos/device-common.sh" +if [[ $# == 1 && ( "$1" == -h || "$1" == --help ) ]]; then + echo "Usage: $0 root@tv.local" + echo 'Reads current instance metadata; prints only an allowlist, never raw logs.' + exit 0 +fi +[[ $# == 1 ]] || webos_error "Usage: $0 root@tv.local" +TARGET="$1" APP_ID="${APP_ID:-com.sachk.spool}" - -# Logs live in /tmp/$APP_ID/ since the log-dir refactor; older builds wrote -# flat /tmp/*.log files and the persistent fallback is /.cache/logs. -ssh -tt "$TARGET" "set -e; echo '== app processes =='; ps | grep '$APP_ID\|spool' | grep -v grep || true; echo '== temp logs =='; ls -l /tmp/${APP_ID}/ /media/cryptofs/apps/usr/palm/applications/${APP_ID}/.cache/logs/ /tmp/${APP_ID}.log /tmp/spool-mpv.log 2>/dev/null || true; echo '== current instance =='; for p in /media/cryptofs/apps/usr/palm/applications/$APP_ID/diagnostics/current-instance.json /var/palm/data/$APP_ID/diagnostics/current-instance.json /tmp/$APP_ID-diagnostics/current-instance.json; do [ -f \"\$p\" ] && { echo \"--- \$p\"; cat \"\$p\"; }; done" +webos_app_id "$APP_ID" +webos_transport_init "$TARGET" +webos_require_command python3 +# Do not print process command lines, diagnostic roots or raw instance JSON. +webos_ssh -n "$TARGET" "for p in /media/cryptofs/apps/usr/palm/applications/$APP_ID/diagnostics/current-instance.json /var/palm/data/$APP_ID/diagnostics/current-instance.json /tmp/$APP_ID-diagnostics/current-instance.json; do if [ -f \"\$p\" ]; then cat \"\$p\"; break; fi; done" | python3 -c ' +import json, sys +text = sys.stdin.read() +if not text.strip(): + raise SystemExit("no current instance metadata found") +value = json.loads(text) +allowed = {key: item for key, item in value.items() + if key in ("pid", "uptimeMs", "uptimeSeconds") and isinstance(item, (int, float))} +if value.get("state") in ("starting", "running", "stopping", "stopped", "hung"): + allowed["state"] = value["state"] +print(json.dumps(allowed, indent=2))' diff --git a/tools/webos/heaptrack-launch-shim.sh b/tools/webos/heaptrack-launch-shim.sh index 25f41d51..a2284de1 100755 --- a/tools/webos/heaptrack-launch-shim.sh +++ b/tools/webos/heaptrack-launch-shim.sh @@ -20,6 +20,30 @@ preload="$appdir/lib/heaptrack/libheaptrack_preload.so" marker="${SPOOL_HEAPTRACK_MARKER:-/tmp/spool-heaptrack.on}" breadcrumb="${SPOOL_HEAPTRACK_BREADCRUMB:-/tmp/spool-heaptrack.last}" +# Benchmark configuration is data, never a sourced shell program. Only an +# explicitly installed profiling/benchmark IPK contains this launch shim. +bench_marker="${SPOOL_BENCH_MARKER:-/tmp/spool-benchmark.on}" +if [ -r "$bench_marker" ] && [ -x "$real" ]; then + if { + IFS= read -r bench_version && + IFS= read -r bench_mode && + IFS= read -r bench_iterations && + IFS= read -r bench_library && + IFS= read -r bench_cold && + IFS= read -r bench_list && + IFS= read -r bench_scroll && + IFS= read -r bench_output + } < "$bench_marker" && [ "${bench_version:-}" = "spool-benchmark-v1" ]; then + export SPOOL_BENCH="$bench_mode" + export SPOOL_BENCH_ITERATIONS="$bench_iterations" + export SPOOL_BENCH_LIBRARY="$bench_library" + export SPOOL_BENCH_COLD="$bench_cold" + export SPOOL_BENCH_LIST_MODE="$bench_list" + export SPOOL_BENCH_SCROLL_STEPS="$bench_scroll" + export SPOOL_BENCH_OUT="$bench_output" + fi +fi + # bigalloc: size-gated allocation tracer. Backtraces only allocations >= # threshold (default 1 MiB) and periodically dumps the live big-alloc set with # stacks. Far lower overhead/crash exposure than heaptrack (which unwinds every diff --git a/tools/webos/kill-stale.sh b/tools/webos/kill-stale.sh index 0b862f36..4ded7ca1 100755 --- a/tools/webos/kill-stale.sh +++ b/tools/webos/kill-stale.sh @@ -1,8 +1,37 @@ #!/usr/bin/env bash set -euo pipefail - -TARGET="${1:-}" -[[ -n "$TARGET" ]] || { echo "Usage: $0 root@tv.local" >&2; exit 2; } +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT/tools/webos/device-common.sh" +if [[ $# == 1 && ( "$1" == -h || "$1" == --help ) ]]; then + echo "Usage: $0 root@tv.local" + echo 'Explicitly terminates only processes executing this app bundle binary.' + exit 0 +fi +[[ $# == 1 ]] || webos_error "Usage: $0 root@tv.local" +TARGET="$1" APP_ID="${APP_ID:-com.sachk.spool}" - -ssh -tt "$TARGET" "pids=\$(ps | grep '$APP_ID\|spool' | grep -v grep | awk '{print \$1}' || true); if [ -z \"\$pids\" ]; then echo 'No matching processes'; exit 0; fi; echo \"Killing stale processes: \$pids\"; kill \$pids; sleep 2; for p in \$pids; do kill -0 \$p 2>/dev/null && kill -9 \$p || true; done" +webos_app_id "$APP_ID" +webos_transport_init "$TARGET" +webos_ssh "$TARGET" "APP_ID='$APP_ID' sh -s" <<'REMOTE' +set -e +matches() { + exe=$(readlink "/proc/$1/exe" 2>/dev/null) || return 1 + case "$exe" in + */usr/palm/applications/"$APP_ID"/bin/spool|*/usr/palm/applications/"$APP_ID"/bin/spool.real) return 0 ;; + *) return 1 ;; + esac +} +pids= +for proc in /proc/[0-9]*; do + pid=${proc##*/} + if matches "$pid"; then pids="$pids $pid"; fi +done +[ -n "$pids" ] || { echo 'No matching app processes'; exit 0; } +echo "Terminating app processes:$pids" +for pid in $pids; do kill "$pid" 2>/dev/null || true; done +sleep 2 +# Recheck the executable before escalation in case a PID has been reused. +for pid in $pids; do + if matches "$pid"; then kill -KILL "$pid" 2>/dev/null || true; fi +done +REMOTE diff --git a/tools/webos/luna-response.py b/tools/webos/luna-response.py new file mode 100755 index 00000000..ff4eb3ce --- /dev/null +++ b/tools/webos/luna-response.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Validate JSON LS2 replies, including tty chatter and subscription updates.""" +import json +import sys + + +def objects(text): + decoder = json.JSONDecoder() + offset = 0 + while offset < len(text): + start = text.find("{", offset) + if start < 0: + return + try: + value, end = decoder.raw_decode(text, start) + except json.JSONDecodeError: + offset = start + 1 + continue + yield value + offset = end + + +def contains_app(value, app_id): + if isinstance(value, dict): + return any(key in ("id", "appId") and item == app_id or contains_app(item, app_id) + for key, item in value.items()) + return isinstance(value, list) and any(contains_app(item, app_id) for item in value) + + +def main(): + mode = sys.argv[1] + replies = list(objects(sys.stdin.read())) + if any(reply.get("returnValue") is False or "errorText" in reply for reply in replies): + return 1 + if mode == "install": + return 0 if any(reply.get("finished") is True or reply.get("statusText") == "Finished." + or reply.get("state") == "installed" for reply in replies) else 2 + successful = [reply for reply in replies if reply.get("returnValue") is True] + if mode == "registration": + return 0 if any(contains_app(reply, sys.argv[2]) for reply in successful) else 1 + return 0 if successful else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/webos/package-heaptrack-profile.sh b/tools/webos/package-heaptrack-profile.sh index 94a544dc..045caec8 100755 --- a/tools/webos/package-heaptrack-profile.sh +++ b/tools/webos/package-heaptrack-profile.sh @@ -2,40 +2,54 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +benchmark_only=0 +case "${1:-}" in + --benchmark-only) benchmark_only=1 ;; + -h|--help) + echo "Usage: $0 [--benchmark-only]" + echo 'Builds a complete fresh IPK, then packages a separate instrumented copy.' + echo 'No device is contacted; install the resulting IPK explicitly.' + exit 0 ;; + '') ;; + *) echo "unknown option: $1" >&2; exit 2 ;; +esac +(( $# <= 1 )) || { echo 'unexpected extra arguments' >&2; exit 2; } SDK_ROOT="${WEBOS_SDK_ROOT:-$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot}" HEAPTRACK_INSTALL="${HEAPTRACK_INSTALL:-$ROOT/build/webos-heaptrack/install}" -APP_DIR="${WEBOS_STAGE_DIR:-$ROOT/build/webos/stage/app}" +SOURCE_STAGE="${WEBOS_STAGE_DIR:-$ROOT/build/webos/stage/app}" INSTALL_DIR="${WEBOS_APP_BUILD_DIR:-$ROOT/build/webos/app}/install" PROFILE_OUTPUT="${WEBOS_PROFILE_OUTPUT_DIR:-$ROOT/build/webos-profile}" PRELOAD="$HEAPTRACK_INSTALL/lib/heaptrack/libheaptrack_preload.so" HEAPTRACK_BIN="$HEAPTRACK_INSTALL/bin/heaptrack" HEAPTRACK_ENV="$HEAPTRACK_INSTALL/lib/heaptrack/libexec/heaptrack_env" -for required in "$PRELOAD" "$HEAPTRACK_BIN" "$HEAPTRACK_ENV"; do - [[ -f "$required" ]] || { - printf 'error: heaptrack profiling payload is missing: %s\n' "$required" >&2 - exit 1 - } -done - -HEAPTRACK_UNWIND_FLAGS="-fasynchronous-unwind-tables -funwind-tables -fno-omit-frame-pointer -g" \ - "$ROOT/build-ipk.sh" - -[[ -x "$APP_DIR/bin/spool" ]] || { - printf 'error: canonical webOS stage is missing: %s\n' "$APP_DIR/bin/spool" >&2 - exit 1 -} +if (( ! benchmark_only )); then + for required in "$PRELOAD" "$HEAPTRACK_BIN" "$HEAPTRACK_ENV"; do + [[ -f "$required" ]] || { printf 'missing heaptrack payload: %s\n' "$required" >&2; exit 1; } + done + export HEAPTRACK_UNWIND_FLAGS='-fasynchronous-unwind-tables -funwind-tables -fno-omit-frame-pointer -g' +fi +"$ROOT/build-ipk.sh" +[[ -x "$SOURCE_STAGE/bin/spool" ]] || { echo 'canonical webOS stage is missing' >&2; exit 1; } +mkdir -p "$PROFILE_OUTPUT" +APP_DIR="$(mktemp -d "$PROFILE_OUTPUT/stage.XXXXXX")" +trap 'rm -rf "$APP_DIR"' EXIT +cp -a "$SOURCE_STAGE/." "$APP_DIR/" mv "$APP_DIR/bin/spool" "$APP_DIR/bin/spool.real" install -m 0755 "$ROOT/tools/webos/heaptrack-launch-shim.sh" "$APP_DIR/bin/spool" -mkdir -p "$APP_DIR/lib/heaptrack/libexec" "$PROFILE_OUTPUT" -install -m 0755 "$PRELOAD" "$APP_DIR/lib/heaptrack/libheaptrack_preload.so" -install -m 0755 "$HEAPTRACK_BIN" "$APP_DIR/bin/heaptrack" -install -m 0755 "$HEAPTRACK_ENV" "$APP_DIR/lib/heaptrack/libexec/heaptrack_env" -patchelf --force-rpath --set-rpath '$ORIGIN/..' "$APP_DIR/lib/heaptrack/libheaptrack_preload.so" -cp -f "$INSTALL_DIR/bin/spool" "$ROOT/build/spool.unstripped" -"$SDK_ROOT/bin/arm-webos-linux-gnueabi-strip" --strip-unneeded \ - "$APP_DIR/lib/heaptrack/libheaptrack_preload.so" "$APP_DIR/bin/heaptrack" \ - "$APP_DIR/lib/heaptrack/libexec/heaptrack_env" -rm -f "$PROFILE_OUTPUT"/*.ipk +printf '1\n' >"$APP_DIR/bin/.spool-benchmark-v1" +if (( ! benchmark_only )); then + mkdir -p "$APP_DIR/lib/heaptrack/libexec" + install -m 0755 "$PRELOAD" "$APP_DIR/lib/heaptrack/libheaptrack_preload.so" + install -m 0755 "$HEAPTRACK_BIN" "$APP_DIR/bin/heaptrack" + install -m 0755 "$HEAPTRACK_ENV" "$APP_DIR/lib/heaptrack/libexec/heaptrack_env" + patchelf --force-rpath --set-rpath '$ORIGIN/..' "$APP_DIR/lib/heaptrack/libheaptrack_preload.so" + cp -f "$INSTALL_DIR/bin/spool" "$ROOT/build/spool.unstripped" + "$SDK_ROOT/bin/arm-webos-linux-gnueabi-strip" --strip-unneeded \ + "$APP_DIR/lib/heaptrack/libheaptrack_preload.so" "$APP_DIR/bin/heaptrack" \ + "$APP_DIR/lib/heaptrack/libexec/heaptrack_env" +fi +app_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$APP_DIR/appinfo.json")" +rm -f "$PROFILE_OUTPUT/${app_id}_"*.ipk npx -y -p @webos-tools/cli@3.2.3 ares-package "$APP_DIR" --outdir "$PROFILE_OUTPUT" -printf '%s\n' "$PROFILE_OUTPUT"/*.ipk +printf '%s\n' "$PROFILE_OUTPUT/${app_id}_"*.ipk diff --git a/tools/webos/profile-memory.sh b/tools/webos/profile-memory.sh index f0277e68..ee6aa425 100755 --- a/tools/webos/profile-memory.sh +++ b/tools/webos/profile-memory.sh @@ -14,14 +14,16 @@ # build output. set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT/tools/webos/device-common.sh" + HOST="${TV_HOST:-}" APP_ID="${APP_ID:-com.sachk.spool}" DURATION="${DURATION:-60}" OUTDIR="" -OPEN_GUI=1 +OPEN_GUI=0 STOP_AFTER=1 WAIT_TIMEOUT="${WAIT_TIMEOUT:-600}" -LUNA_BIN="${LUNA_BIN:-luna-send}" usage() { cat <) --no-stop leave the app running after recording (partial-but-live trace) --no-gui do not open heaptrack_gui at the end + --gui explicitly open heaptrack_gui after analysis (default: no GUI) --wait-timeout SEC how long to wait for a profiled launch (default: $WAIT_TIMEOUT) -h, --help this help EOF } while [[ $# -gt 0 ]]; do + case "$1" in + --host|--duration|--out|--wait-timeout) webos_require_value "$@" ;; + esac case "$1" in --host) HOST="$2"; shift 2 ;; --duration) DURATION="$2"; shift 2 ;; --out) OUTDIR="$2"; shift 2 ;; --no-stop) STOP_AFTER=0; shift ;; --no-gui) OPEN_GUI=0; shift ;; + --gui) OPEN_GUI=1; shift ;; --wait-timeout) WAIT_TIMEOUT="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; @@ -55,24 +62,46 @@ while [[ $# -gt 0 ]]; do done [[ -n "$HOST" ]] || { echo "missing required --host HOST (or TV_HOST)" >&2; usage >&2; exit 2; } -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +webos_app_id "$APP_ID" +webos_uint --duration "$DURATION" +webos_uint --wait-timeout "$WAIT_TIMEOUT" +webos_uint PLAY_WAIT "${PLAY_WAIT:-240}" +DURATION=$((10#$DURATION)) +WAIT_TIMEOUT=$((10#$WAIT_TIMEOUT)) +PLAY_WAIT=$((10#${PLAY_WAIT:-240})) +webos_transport_init "$HOST" +webos_require_command nix +webos_require_command scp +webos_require_command python3 [[ -n "$OUTDIR" ]] || OUTDIR="$ROOT/build/memory/heaptrack/$(date +%Y%m%d-%H%M%S)" mkdir -p "$OUTDIR" -SSH=(ssh -F /dev/null -o BatchMode=yes) -SSH_TTY=(ssh -F /dev/null -tt) +SSH=(webos_ssh -n) MARKER=/tmp/spool-heaptrack.on CRUMB=/tmp/spool-heaptrack.last +armed=0 +cleanup() { + if (( armed )); then + "${SSH[@]}" "$HOST" "rm -f '$MARKER'" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM echo "Resolving desktop heaptrack tools from nixpkgs..." -HP="$(nix build nixpkgs#heaptrack --no-link --print-out-paths)" +HP="$(timeout --signal=TERM --kill-after=5s 1800s nix build nixpkgs#heaptrack --no-link --print-out-paths)" INTERP="$HP/lib/heaptrack/libexec/heaptrack_interpret" PRINT="$HP/bin/heaptrack_print" GUI="$HP/bin/heaptrack_gui" [[ -x "$INTERP" ]] || { echo "heaptrack_interpret not found at $INTERP" >&2; exit 1; } +[[ -x "$PRINT" ]] || { echo "heaptrack_print not found at $PRINT" >&2; exit 1; } +"${SSH[@]}" "$HOST" "test -x /media/cryptofs/apps/usr/palm/applications/$APP_ID/bin/spool.real && test -f /media/cryptofs/apps/usr/palm/applications/$APP_ID/lib/heaptrack/libheaptrack_preload.so" \ + || { echo 'Install the profiling IPK before arming; installed app files are never patched.' >&2; exit 1; } # 1. Arm one-shot profiling and clear any stale breadcrumb. echo "Arming one-shot profiling on $HOST ..." +armed=1 "${SSH[@]}" "$HOST" "rm -f '$CRUMB'; : > '$MARKER'" # 2. Block until the shim reports a profiled launch (writes the breadcrumb). @@ -87,9 +116,13 @@ while :; do done # Enforce one-shot from here (the app user can't delete the root-owned marker in # sticky /tmp, so the shim's own rm may fail -- we clear it as root instead). -"${SSH[@]}" "$HOST" "rm -f '$MARKER'" 2>/dev/null || true -PID="$("${SSH[@]}" "$HOST" "pidof spool.real 2>/dev/null || pgrep -f spool.real | head -1" || true)" -echo "Profiling live: trace=$RAW_REMOTE pid=${PID:-?}" +"${SSH[@]}" "$HOST" "rm -f '$MARKER'" +armed=0 +[[ "$RAW_REMOTE" =~ ^/tmp/heaptrack\.spool\.([0-9]+)\.raw$ ]] || { echo 'invalid profiling breadcrumb' >&2; exit 1; } +PID="${BASH_REMATCH[1]}" +"${SSH[@]}" "$HOST" "test -r /proc/$PID/maps && test -f '$RAW_REMOTE'" \ + || { echo 'profiled process or trace is no longer available' >&2; exit 1; } +echo "Profiling live: trace=$RAW_REMOTE pid=$PID" # 2b. The +memory-on-stream-start happens at playback start, not app launch. # Wait for a play event in the app log so the record window brackets it. @@ -97,12 +130,12 @@ if [[ "${WAIT_PLAYBACK:-1}" == "1" ]]; then # The app truncates its log on each launch, so a pre-launch line baseline is # invalid here (and auto-resume can log "play requested" before we'd sample # it). Since the log is fresh for this session, any occurrence is ours. - LOGF="/tmp/${APP_ID}.log" + LOGF="/tmp/$APP_ID/$APP_ID.log" echo "Waiting for playback to start (replay the movie now; falls back after ${PLAY_WAIT:-240}s)..." pdeadline=$(( $(date +%s) + ${PLAY_WAIT:-240} )) while :; do - hit="$("${SSH[@]}" "$HOST" "grep -m1 'player: play requested' '$LOGF' 2>/dev/null" || true)" - [[ -n "$hit" ]] && { echo "Playback started: ${hit#*player: }"; break; } + hit="$("${SSH[@]}" "$HOST" "grep -m1 'player: play requested' '$LOGF' >/dev/null 2>&1 && echo started" || true)" + [[ -n "$hit" ]] && { echo "Playback started."; break; } [[ $(date +%s) -ge $pdeadline ]] && { echo "No play event seen; recording from now."; break; } sleep 2 done @@ -112,19 +145,20 @@ fi echo "Recording ${DURATION}s ..." sleep $(( DURATION / 2 )) if [[ -n "$PID" ]]; then - "${SSH[@]}" "$HOST" "cat /proc/$PID/maps" > "$OUTDIR/maps.txt" 2>/dev/null || true - "${SSH[@]}" "$HOST" "cat /proc/$PID/smaps_rollup" > "$OUTDIR/smaps_rollup.txt" 2>/dev/null || true + "${SSH[@]}" "$HOST" "cat /proc/$PID/maps" > "$OUTDIR/maps.txt" + "${SSH[@]}" "$HOST" "if [ -f /proc/$PID/smaps_rollup ]; then cat /proc/$PID/smaps_rollup; fi" > "$OUTDIR/smaps_rollup.txt" fi sleep $(( DURATION - DURATION / 2 )) # 4. Finalise: close the app so the preload flushes a complete trace. if [[ "$STOP_AFTER" == "1" ]]; then echo "Closing app to finalise the trace ..." - "${SSH_TTY[@]}" "$HOST" "$LUNA_BIN -n 1 luna://com.webos.applicationManager/closeByAppId '{\"id\":\"$APP_ID\"}'" >/dev/null 2>&1 || true + webos_luna "$HOST" luna://com.webos.applicationManager/closeByAppId "{\"id\":\"$APP_ID\"}" >"$OUTDIR/close.json" + python3 "$ROOT/tools/webos/luna-response.py" success <"$OUTDIR/close.json" || { echo 'app close failed' >&2; exit 1; } # Wait for the trace file to stop growing. last=-1 for _ in $(seq 1 30); do - sz="$("${SSH[@]}" "$HOST" "wc -c < '$RAW_REMOTE' 2>/dev/null" || echo 0)" + sz="$("${SSH[@]}" "$HOST" "wc -c < '$RAW_REMOTE'")" [[ "$sz" == "$last" && "$sz" != "0" ]] && break last="$sz"; sleep 1 done @@ -133,16 +167,20 @@ fi # 5. Pull the raw trace. RAW_LOCAL="$OUTDIR/$(basename "$RAW_REMOTE")" echo "Pulling raw trace -> $RAW_LOCAL" -scp -F /dev/null -q "$HOST:$RAW_REMOTE" "$RAW_LOCAL" +webos_scp "$HOST:$RAW_REMOTE" "$RAW_LOCAL" +[[ -s "$RAW_LOCAL" ]] || { echo 'profile trace is empty' >&2; exit 1; } # 6. Build a sysroot from exactly the modules the process had mapped. SYSROOT="$OUTDIR/sysroot" mkdir -p "$SYSROOT" if [[ -s "$OUTDIR/maps.txt" ]]; then echo "Assembling sysroot from mapped modules ..." - mapfile -t mods < <(awk '$6 ~ /^\// {print $6}' "$OUTDIR/maps.txt" | sort -u | sed 's#^/##') + mapfile -t mods < <(awk '$6 ~ /^\// && $6 !~ /(^|\/)\.\.(\/|$)/ {print substr($6, 2)}' "$OUTDIR/maps.txt" | sort -u) if [[ ${#mods[@]} -gt 0 ]]; then - "${SSH[@]}" "$HOST" "tar -C / -cf - ${mods[*]} 2>/dev/null" | tar -C "$SYSROOT" -xf - 2>/dev/null || true + remote_modules="" + for module in "${mods[@]}"; do remote_modules+=" $(webos_quote "$module")"; done + "${SSH[@]}" "$HOST" "tar -h -C / -cf - --$remote_modules" \ + | tar --no-same-owner --no-same-permissions -C "$SYSROOT" -xf - fi fi @@ -151,7 +189,8 @@ fi INTERPRETED="$OUTDIR/heaptrack.spool.gz" EXTRA=() [[ -f "$ROOT/build/spool.unstripped" ]] && EXTRA+=(--extra-paths "$ROOT/build") -[[ -d "$ROOT/app/lib" ]] && EXTRA+=(--extra-paths "$ROOT/app/lib") +[[ -d "$ROOT/build/webos/app/install" ]] && EXTRA+=(--extra-paths "$ROOT/build/webos/app/install") +[[ -d "$ROOT/build/webos/stage/app/lib" ]] && EXTRA+=(--extra-paths "$ROOT/build/webos/stage/app/lib") echo "Interpreting trace ..." # The preload writes an uncompressed raw stream when pointed at a file, but be # robust to gzip/zstd too. @@ -160,12 +199,12 @@ case "$(od -An -tx1 -N2 "$RAW_LOCAL" | tr -d ' ')" in 28b5*) DECOMP=(zstd -dc) ;; *) DECOMP=(cat) ;; esac -"${DECOMP[@]}" "$RAW_LOCAL" | "$INTERP" --sysroot "$SYSROOT" "${EXTRA[@]}" 2>"$OUTDIR/interpret.log" \ +"${DECOMP[@]}" "$RAW_LOCAL" | timeout --signal=TERM --kill-after=5s 300s "$INTERP" --sysroot "$SYSROOT" "${EXTRA[@]}" 2>"$OUTDIR/interpret.log" \ | gzip -c > "$INTERPRETED" # 8. Text summary + GUI. echo "Writing summary ..." -"$PRINT" "$INTERPRETED" > "$OUTDIR/summary.txt" 2>/dev/null || true +timeout --signal=TERM --kill-after=5s 120s "$PRINT" "$INTERPRETED" > "$OUTDIR/summary.txt" 2>"$OUTDIR/summary.err" echo echo "================ heaptrack summary ($OUTDIR) ================" grep -iE "peak heap|peak rss|total runtime|allocations|leaked|temporary" "$OUTDIR/summary.txt" | head -20 || true diff --git a/tools/webos/verify-device.sh b/tools/webos/verify-device.sh index c13bab62..244eb85b 100755 --- a/tools/webos/verify-device.sh +++ b/tools/webos/verify-device.sh @@ -2,260 +2,204 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - +source "$ROOT/tools/webos/device-common.sh" host="${TV_HOST:-}" device="${ARES_DEVICE:-}" app_id="${APP_ID:-com.sachk.spool}" ipk="" outdir="" install=1 -launch=1 +launch=0 screenshot=1 launch_wait="${LAUNCH_WAIT:-8}" http_port="${HTTP_PORT:-18927}" install_timeout="${INSTALL_TIMEOUT:-120}" -luna_bin="${LUNA_BIN:-luna-send}" serve_pid="" +install_pid="" +remote_shot="" usage() { cat <) - --no-install skip install and only verify/launch/capture - --no-launch install and verify registration without launching - --skip-screenshot skip screenshot capture - --launch-wait SEC seconds to wait after launch before capture (default: $launch_wait) - --http-port PORT temporary package server port (default: $http_port) - --install-timeout SEC - install subscription timeout (default: $install_timeout) - -h, --help show this help + --ipk PATH package (default: newest matching build/dist IPK by mtime) + --out DIR artifact directory (default: build/webos/verify/) + --no-install skip installation + --launch explicitly launch and capture logs/screenshot + --no-launch install/verify only (the default) + --skip-screenshot skip screenshot when launching + --launch-wait SEC post-launch delay, 0..3600 (default: $launch_wait) + --http-port PORT package server port (default: $http_port) + --install-timeout SEC subscription limit, 1..3600 (default: $install_timeout) + -h, --help show help + +SSH, scp and ares commands have WEBOS_COMMAND_TIMEOUT seconds each (default 30). EOF } - -while [[ $# -gt 0 ]]; do +while (( $# )); do case "$1" in - --host) host="$2"; shift 2 ;; - --device) device="$2"; shift 2 ;; - --app-id) app_id="$2"; shift 2 ;; - --ipk) ipk="$2"; shift 2 ;; - --out) outdir="$2"; shift 2 ;; + --host|--device|--app-id|--ipk|--out|--launch-wait|--http-port|--install-timeout) + webos_require_value "$@" + case "$1" in + --host) host="$2" ;; --device) device="$2" ;; --app-id) app_id="$2" ;; + --ipk) [[ -z "$ipk" ]] || webos_error 'IPK supplied more than once'; ipk="$2" ;; + --out) outdir="$2" ;; --launch-wait) launch_wait="$2" ;; + --http-port) http_port="$2" ;; --install-timeout) install_timeout="$2" ;; + esac + shift 2 ;; --no-install) install=0; shift ;; + --launch) launch=1; shift ;; --no-launch) launch=0; shift ;; --skip-screenshot) screenshot=0; shift ;; - --launch-wait) launch_wait="$2"; shift 2 ;; - --http-port) http_port="$2"; shift 2 ;; - --install-timeout) install_timeout="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; - --*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; - *) - if [[ -n "$ipk" ]]; then - echo "unexpected extra argument: $1" >&2 - usage >&2 - exit 2 - fi - ipk="$1" - shift - ;; + -*) webos_error "unknown option: $1" ;; + *) [[ -z "$ipk" ]] || webos_error "unexpected extra argument: $1"; ipk="$1"; shift ;; esac done -[[ -n "$host" ]] || { echo "missing required --host HOST (or TV_HOST)" >&2; usage >&2; exit 2; } - -require_command() { - command -v "$1" >/dev/null 2>&1 || { - echo "missing required command: $1" >&2 - exit 1 - } -} - -find_default_ipk() { - local candidate - candidate="$( - find "$ROOT/build" "$ROOT/dist" -type f -name "${app_id}_*.ipk" -print 2>/dev/null \ - | sort \ - | tail -n 1 - )" - [[ -n "$candidate" ]] && printf '%s\n' "$candidate" -} +[[ -n "$host" ]] || webos_error 'missing --host HOST (or TV_HOST)' +webos_app_id "$app_id" +webos_uint --launch-wait "$launch_wait" 0 3600 +webos_uint --http-port "$http_port" 1 65535 +webos_uint --install-timeout "$install_timeout" 1 3600 +webos_transport_init "$host" +webos_require_command python3 + +if (( install )); then + if [[ -z "$ipk" ]]; then + ipk="$(python3 - "$ROOT" "$app_id" <<'PY' +import pathlib, sys +root, app = pathlib.Path(sys.argv[1]), sys.argv[2] +packages = [path for directory in (root / 'build', root / 'dist') + for path in directory.rglob(f'{app}_*.ipk') if path.is_file()] +if packages: + print(max(packages, key=lambda path: (path.stat().st_mtime_ns, str(path)))) +PY +)" + fi + [[ -n "$ipk" && -f "$ipk" ]] || { echo 'no IPK found; pass --ipk PATH or --no-install' >&2; exit 1; } + ipk="$(realpath -- "$ipk")" + webos_require_command curl + webos_require_command ip + webos_require_command getent + webos_require_command sha256sum +fi +if (( launch )); then + webos_require_command ares-launch + (( ! screenshot )) || webos_require_command scp +fi +[[ -n "$outdir" ]] || outdir="$ROOT/build/webos/verify/$(date -u +%Y%m%d-%H%M%S)" +mkdir -p "$outdir" -run_luna() { - local uri="$1" - local payload="${2:-}" - [[ -n "$payload" ]] || payload="{}" - ssh -F /dev/null -tt "$host" "$luna_bin -n 1 '$uri' '$payload'" +stop_process() { + local pid="$1" + [[ -n "$pid" ]] || return 0 + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true } - -stop_ipk_server() { - if [[ -n "$serve_pid" ]]; then - kill "$serve_pid" 2>/dev/null || true - wait "$serve_pid" 2>/dev/null || true - serve_pid="" +cleanup() { + stop_process "$install_pid" + stop_process "$serve_pid" + if [[ -n "$remote_shot" ]]; then + webos_ssh -n "$host" "rm -f $(webos_quote "$remote_shot")" >/dev/null 2>&1 || true fi } -trap stop_ipk_server EXIT - -monitored_luna() { - local uri="$1" - local payload="$2" - local log_file="$3" - - { - timeout "${install_timeout}s" ssh -F /dev/null -tt "$host" \ - "$luna_bin -i -f '$uri' '$payload'" 2>&1 & - echo "$!" - } | { - read -r pid - awk -v pid="$pid" ' - BEGIN { result = 2 } - { - print - fflush() - if ($0 ~ /"finished"[[:space:]]*:[[:space:]]*true/ || - $0 ~ /"statusText"[[:space:]]*:[[:space:]]*"Finished."/ || - $0 ~ /"state"[[:space:]]*:[[:space:]]*"installed"/) { - result = 0 - system("kill -TERM " pid " 2>/dev/null") - exit - } - if ($0 ~ /"returnValue"[[:space:]]*:[[:space:]]*false/ || - $0 ~ /"errorText"[[:space:]]*:/) { - result = 1 - system("kill -TERM " pid " 2>/dev/null") - exit - } - } - END { exit result } - ' - } | tee "$log_file" -} - -install_ipk() { - local package="$1" - local package_dir package_name package_hash tv_ip host_ip url payload - - package_dir="$(dirname "$package")" - package_name="$(basename "$package")" - package_hash="$(sha256sum "$package" | cut -d' ' -f1)" - tv_ip="${host##*@}" - tv_ip="${tv_ip%%:*}" - host_ip="$(ip route get "$tv_ip" 2>/dev/null | - awk '{ for (i = 1; i <= NF; ++i) if ($i == "src") { print $(i + 1); exit } }' || true)" - [[ -n "$host_ip" ]] || host_ip="$(hostname -I | awk '{ print $1 }')" - url="http://${host_ip}:${http_port}/${package_name}" - payload="$(printf '{"ipkHash":"%s","ipkUrl":"%s","subscribe":true}' "$package_hash" "$url")" - - echo "Serving $package_name at $url" - python3 -m http.server "$http_port" --bind "$host_ip" --directory "$package_dir" \ - >"$outdir/http-server.log" 2>&1 & +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +response() { python3 "$ROOT/tools/webos/luna-response.py" "$@"; } + +if (( install )); then + package_name="$(basename "$ipk")" + tv_name="${host##*@}" + tv_name="${tv_name#[}"; tv_name="${tv_name%]}" + tv_ip="$(webos_timeout getent ahosts "$tv_name" | awk 'NR == 1 { address=$1 } END { print address }')" + [[ -n "$tv_ip" ]] || { echo "cannot resolve TV host $tv_name" >&2; exit 1; } + host_ip="$(webos_timeout ip route get "$tv_ip" 2>/dev/null | awk '{ for (i=1; i<=NF; i++) if ($i == "src") { print $(i+1); exit } }' || true)" + [[ -n "$host_ip" ]] || { echo 'cannot determine package server address for TV' >&2; exit 1; } + url="$(python3 - "$host_ip" "$http_port" "$package_name" <<'PY' +import sys, urllib.parse +host, port, name = sys.argv[1:] +if ':' in host: + host = f'[{host}]' +print(f'http://{host}:{port}/{urllib.parse.quote(name)}') +PY +)" + package_hash="$(sha256sum "$ipk" | cut -d' ' -f1)" + payload="$(python3 -c 'import json,sys; print(json.dumps(dict(ipkHash=sys.argv[1],ipkUrl=sys.argv[2],subscribe=True)))' "$package_hash" "$url")" + python3 -m http.server "$http_port" --bind "$host_ip" --directory "$(dirname "$ipk")" >"$outdir/http-server.log" 2>&1 & serve_pid="$!" sleep 1 if ! kill -0 "$serve_pid" 2>/dev/null || ! curl -fsSI --max-time 2 "$url" >/dev/null; then - cat "$outdir/http-server.log" >&2 - echo "failed to serve $package at $url" >&2 + echo 'package server failed; see http-server.log' >&2 exit 1 fi - - echo "Installing through Homebrew Channel" - monitored_luna "luna://org.webosbrew.hbchannel.service/install" "$payload" \ - "$outdir/install.log" - stop_ipk_server -} - -ares_args=() -if [[ -n "$device" ]]; then - ares_args+=(--device "$device") + luna_bin="${LUNA_BIN:-luna-send}" + [[ "$luna_bin" =~ ^[a-zA-Z0-9_./-]+$ && "$luna_bin" != -* ]] || webos_error 'invalid LUNA_BIN' + : >"$outdir/install.log" + # A terminal, as in webos_luna: without one luna-send never makes the call. + timeout --signal=TERM --kill-after=5s "${install_timeout}s" "${WEBOS_SSH[@]}" -tt -- "$host" \ + "$luna_bin -i -f 'luna://org.webosbrew.hbchannel.service/install' $(webos_quote "$payload")" \ + "$outdir/install.log" 2>&1 & + install_pid="$!" + deadline=$(( SECONDS + 10#$install_timeout )) + while :; do + result=0 + response install <"$outdir/install.log" || result=$? + if (( result == 0 )); then break; fi + if (( result == 1 )); then echo 'installation rejected; see install.log' >&2; exit 1; fi + if (( SECONDS >= deadline )) || ! kill -0 "$install_pid" 2>/dev/null; then + echo 'installation ended without success; see install.log' >&2 + exit 1 + fi + sleep 1 + done + stop_process "$install_pid"; install_pid="" + stop_process "$serve_pid"; serve_pid="" fi -if [[ "$install" == "1" ]]; then - [[ -n "$ipk" ]] || ipk="$(find_default_ipk || true)" - if [[ -z "$ipk" || ! -f "$ipk" ]]; then - echo "no IPK found; pass --ipk PATH or use --no-install" >&2 - exit 1 - fi - require_command curl - require_command ip - require_command python3 - require_command sha256sum - require_command timeout -fi - -if [[ "$launch" == "1" ]]; then - require_command ares-launch -fi -require_command ssh -if [[ "$launch" == "1" && "$screenshot" == "1" ]]; then - require_command scp -fi - -if [[ -z "$outdir" ]]; then - outdir="$ROOT/build/webos/verify/$(date -u +%Y%m%d-%H%M%S)" -fi -mkdir -p "$outdir" - -echo "Output: $outdir" - - -if [[ "$install" == "1" ]]; then - echo "Installing $ipk" - install_ipk "$ipk" -fi - -echo "Checking app registration for $app_id" -run_luna "luna://com.webos.applicationManager/listLaunchPoints" "{}" \ - >"$outdir/listLaunchPoints.json" 2>"$outdir/listLaunchPoints.err" -if ! grep -Fq "$app_id" "$outdir/listLaunchPoints.json"; then - echo "app id $app_id was not present in applicationManager launch points" >&2 +webos_luna "$host" 'luna://com.webos.applicationManager/listLaunchPoints' '{}' >"$outdir/listLaunchPoints.json" 2>"$outdir/listLaunchPoints.err" +if ! response registration "$app_id" <"$outdir/listLaunchPoints.json"; then + echo "app $app_id is not registered (or LS2 rejected the query)" >&2 exit 1 fi -run_luna "luna://com.webos.applicationManager/getAppInfo" "{\"id\":\"$app_id\"}" \ - >"$outdir/getAppInfo.json" 2>"$outdir/getAppInfo.err" || true - -if [[ "$launch" != "1" ]]; then - echo "Installed and verified $app_id; launch skipped" +webos_luna "$host" 'luna://com.webos.applicationManager/getAppInfo' "{\"id\":\"$app_id\"}" >"$outdir/getAppInfo.json" 2>"$outdir/getAppInfo.err" +response success <"$outdir/getAppInfo.json" || { echo 'getAppInfo failed' >&2; exit 1; } +if (( ! launch )); then + echo "Verified $app_id; launch skipped. Artifacts: $outdir" exit 0 fi -echo "Launching $app_id" -ares-launch "${ares_args[@]}" "$app_id" | tee "$outdir/ares-launch.log" +ares_args=() +[[ -z "$device" ]] || ares_args+=(--device "$device") +webos_timeout ares-launch "${ares_args[@]}" "$app_id" | tee "$outdir/ares-launch.log" sleep "$launch_wait" - -echo "Capturing process snapshot and logs" -ssh -F /dev/null -o BatchMode=yes "$host" "ps | grep '$app_id\|spool' | grep -v grep || true" \ - >"$outdir/ps.txt" -for remote in \ - "/tmp/${app_id}/${app_id}.log" \ - "/tmp/${app_id}/spool-mpv.log" \ - "/media/cryptofs/apps/usr/palm/applications/${app_id}/.cache/logs/${app_id}.log" \ - "/media/cryptofs/apps/usr/palm/applications/${app_id}/.cache/logs/spool-mpv.log" \ - "/tmp/${app_id}.log" \ - "/tmp/spool-mpv.log" \ - "/tmp/${app_id}-diagnostics/current-instance.json" \ - "/var/palm/data/${app_id}/diagnostics/current-instance.json"; do - local_name="${remote#/}" - local_name="${local_name//\//_}" - if ssh -F /dev/null -o BatchMode=yes "$host" "test -f '$remote'"; then - ssh -F /dev/null -o BatchMode=yes "$host" "cat '$remote'" >"$outdir/$local_name" - fi +webos_ssh -n "$host" "ps | grep -F '$app_id' | grep -v grep || true" >"$outdir/ps.txt" +for remote in "/tmp/$app_id/$app_id.log" "/tmp/$app_id/spool-mpv.log" \ + "/media/cryptofs/apps/usr/palm/applications/$app_id/.cache/logs/$app_id.log" \ + "/media/cryptofs/apps/usr/palm/applications/$app_id/.cache/logs/spool-mpv.log" \ + "/tmp/$app_id.log" /tmp/spool-mpv.log "/tmp/$app_id-diagnostics/current-instance.json" \ + "/var/palm/data/$app_id/diagnostics/current-instance.json"; do + local_name="${remote#/}"; local_name="${local_name//\//_}" + webos_ssh -n "$host" "if [ -f '$remote' ]; then cat '$remote'; fi" >"$outdir/$local_name" done - -if [[ "$screenshot" == "1" ]]; then - remote_shot="/tmp/${app_id}-verify-$(date -u +%Y%m%d-%H%M%S).png" +if (( screenshot )); then + remote_shot="/tmp/$app_id-verify-$(date -u +%Y%m%d-%H%M%S)-$$.png" payload="{\"path\":\"$remote_shot\",\"method\":\"DISPLAY\",\"format\":\"PNG\"}" - echo "Capturing screenshot" - if ! run_luna "luna://com.webos.service.capture/executeOneShot" "$payload" >"$outdir/screenshot.json" 2>"$outdir/screenshot.err"; then - run_luna "luna://com.webos.service.tv.capture/executeOneShot" "$payload" \ - >"$outdir/screenshot.json" - fi - scp -F /dev/null -q "$host:$remote_shot" "$outdir/screenshot.png" + captured=0 + for service in com.webos.service.capture com.webos.service.tv.capture; do + if webos_luna "$host" "luna://$service/executeOneShot" "$payload" >"$outdir/screenshot.json" 2>"$outdir/screenshot.err" \ + && response success <"$outdir/screenshot.json"; then captured=1; break; fi + done + (( captured )) || { echo 'screenshot capture failed' >&2; exit 1; } + webos_scp "$host:$remote_shot" "$outdir/screenshot.png" + [[ -s "$outdir/screenshot.png" ]] || { echo 'screenshot was empty' >&2; exit 1; } fi - echo "Verification artifacts written to $outdir"