From 4943bcb3a767ae7a759821d2347721576621e7c4 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 19:59:09 +1100 Subject: [PATCH 01/78] refactor(shell): integrate provider UX, native input and build fixes - build: pin the published provider UX cohort - fix(build): validate immutable Nix store roots - fix(ui): preserve directional gestures, reorder state and native drop data - fix(ui): reconcile row sections from the published payload Original commits: bf0c8be5a31524809d6dc4e29e6e5557d6c568c8 01f3fed451ea57956f1ca252655ca3c8409fb47f 38fa648734ba2d0016c47f08687dd6e54a78dba7 3e839c37fe8ca2fe0e04b17c403f838cd70b9cfb --- README.md | 35 +++- providers/bundled/spool.emby-0.1.8.szo | Bin 52660 -> 52664 bytes providers/bundled/spool.jellyfin-0.2.13.szo | Bin 40504 -> 40508 bytes ...lex-0.1.9.tar.zst => spool.plex-0.1.9.szo} | Bin 54956 -> 54960 bytes providers/lock.json | 8 +- qml/browse/RowStackView.qml | 104 ++++++++--- qml/pages/HomePage.qml | 2 + qml/pages/LibraryGridPage.qml | 19 +- qml/pages/PersonDetailsPage.qml | 3 +- qml/pages/SearchPage.qml | 8 + qml/pages/SettingsNavigation.js | 27 +-- qml/primitives/InputKeys.qml | 35 ++++ qml/primitives/MediaRow.qml | 77 ++++++--- qml/shell/AppShell.qml | 18 +- tests/qml/tst_MediaRow.qml | 163 +++++++++++++++++- tools/lib/build-common.sh | 4 +- 16 files changed, 423 insertions(+), 80 deletions(-) rename providers/bundled/{spool.plex-0.1.9.tar.zst => spool.plex-0.1.9.szo} (99%) diff --git a/README.md b/README.md index 1ad8c67c..becc54e5 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 @@ -875,6 +900,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: diff --git a/providers/bundled/spool.emby-0.1.8.szo b/providers/bundled/spool.emby-0.1.8.szo index 042e291a6ef2732f420d37eaa7593fe3ce759c9e..6cdb8705274f7b250498b390443e925d14c67f72 100644 GIT binary patch delta 21 dcmdlon|a4$@@`hS;fWL$@@`hOQ}WL$@@`hS;fWL$@@`hOQ}WL= 0 && index < sectionCount ? sections[index] : null @@ -214,13 +263,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 +300,8 @@ FocusScope { onActiveFocusChanged: { clearPendingPointerNavigation() + if (!activeFocus) + recoveryGesture.key = 0 if (activeFocus && Metrics.keyboardFocusActive) { navigationFocusVisible = true Qt.callLater(focusCurrentSection) @@ -249,7 +312,7 @@ FocusScope { id: sectionList anchors.fill: parent - model: root.sections + model: root.sectionObjects header: root.header footer: root.footer spacing: root.rowSpacing @@ -277,25 +340,26 @@ 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) + 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 +369,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 +381,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/HomePage.qml b/qml/pages/HomePage.qml index 58398c63..1770c161 100644 --- a/qml/pages/HomePage.qml +++ b/qml/pages/HomePage.qml @@ -12,10 +12,12 @@ 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) || { diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index 84ea25c1..2374afb7 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -76,10 +76,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) } @@ -808,10 +813,18 @@ FocusScope { } function routeKey(key, phase, repeat) { - if (phase === "release" && InputKeys.isDirection(key)) + 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) diff --git a/qml/pages/PersonDetailsPage.qml b/qml/pages/PersonDetailsPage.qml index 4b923f50..3f13b1da 100644 --- a/qml/pages/PersonDetailsPage.qml +++ b/qml/pages/PersonDetailsPage.qml @@ -11,6 +11,7 @@ FocusScope { id: root property var shell + readonly property bool directionRelease: rows.directionRelease readonly property var person: shell ? shell.personItem : ({}) // The controller supplies title/model/kind per credit row; the page adds // the presentation the row kind implies. A person's episode credits name @@ -85,7 +86,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/SearchPage.qml b/qml/pages/SearchPage.qml index 123e53d2..51a8935f 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 @@ -101,6 +102,13 @@ FocusScope { } function routeKey(key, phase, repeat) { + 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() diff --git a/qml/pages/SettingsNavigation.js b/qml/pages/SettingsNavigation.js index aae80743..b1d1785e 100644 --- a/qml/pages/SettingsNavigation.js +++ b/qml/pages/SettingsNavigation.js @@ -189,22 +189,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 +200,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/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/MediaRow.qml b/qml/primitives/MediaRow.qml index 028071fa..d5311b68 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 @@ -75,7 +76,7 @@ FocusScope { visible: rowVisible onVisibleChanged: if (!visible && (moveMode || dragActive)) finishMove() - onActiveFocusChanged: if (!activeFocus && (moveMode || dragActive)) + onActiveFocusChanged: if (!activeFocus && !publishingMove && (moveMode || dragActive)) finishMove() focus: true @@ -83,19 +84,23 @@ FocusScope { onAtomicPopulateChanged: Qt.callLater(resetPresentation) onModelChanged: { ++modelRevision - if (moveMode || dragActive) + 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)) @@ -121,8 +126,10 @@ FocusScope { } 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() { @@ -162,6 +169,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 +297,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 +332,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) { diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 8180d561..d15a632e 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -1230,11 +1230,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/tests/qml/tst_MediaRow.qml b/tests/qml/tst_MediaRow.qml index 80e6dc2c..d6e361f8 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,31 @@ TestCase { ] } + 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 @@ -156,4 +186,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/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) From 727095e0219edb274058818193878acff7e97d63 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 21:59:40 +1100 Subject: [PATCH 02/78] feat(ios): integrate native review build with current mobile owners - feat(ios): integrate native review build with current mobile owners Original commits: 4804eb8c88035c30cd2642003a97e69b502d95d3 --- .github/workflows/ios-review.yml | 57 ++++++++++++++++++++++++++++++++ CMakeLists.txt | 5 ++- README.md | 46 ++++++++++++++++++++++++++ app/ios/Info.plist.in | 19 +++++++++++ app/ios/PrivacyInfo.xcprivacy | 11 ++++++ cmake/platform/Common.cmake | 9 ++++- cmake/platform/IOS.cmake | 24 ++++++++++++++ qml/pages/PlayerOverlayPage.qml | 4 +++ qml/pages/SettingsPage.qml | 6 ++-- qml/shell/AppShell.qml | 6 ++-- qml/theme/Metrics.qml | 8 ++--- src/main.cpp | 7 ++-- tools/build-ios.sh | 45 +++++++++++++++++++++++++ 13 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/ios-review.yml create mode 100644 app/ios/Info.plist.in create mode 100644 app/ios/PrivacyInfo.xcprivacy create mode 100644 cmake/platform/IOS.cmake create mode 100755 tools/build-ios.sh diff --git a/.github/workflows/ios-review.yml b/.github/workflows/ios-review.yml new file mode 100644 index 00000000..3356d2c1 --- /dev/null +++ b/.github/workflows/ios-review.yml @@ -0,0 +1,57 @@ +name: iOS review build +on: + workflow_dispatch: + push: + branches: [feature/ios] +permissions: + contents: read +jobs: + native: + if: github.ref == 'refs/heads/feature/ios' + runs-on: macos-15-intel + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - sdk: iphonesimulator + arch: x86_64 + - sdk: iphoneos + arch: arm64 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install native build tools + run: brew install cmake ninja meson pkg-config bash python imagemagick nasm + - name: Install pinned Qt host and iOS kits + shell: bash + run: | + python3 -m venv "$RUNNER_TEMP/aqt" + "$RUNNER_TEMP/aqt/bin/pip" install aqtinstall + 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 }} + BUILD_JOBS: '4' + run: 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 + - uses: actions/upload-artifact@v4 + with: + name: ios-review-${{ matrix.sdk }}-${{ matrix.arch }} + path: build/apple/${{ matrix.sdk }}-${{ matrix.arch }}/app/Release-${{ matrix.sdk }}/Spool.app diff --git a/CMakeLists.txt b/CMakeLists.txt index bab44ae9..fbcaa4ea 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) diff --git a/README.md b/README.md index becc54e5..13b78278 100644 --- a/README.md +++ b/README.md @@ -759,6 +759,52 @@ 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 review build + +The iOS implementation is integrated on `big-refactor` for review, not released +or App Store-approved. Its native build requires macOS, Xcode 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`. +`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. + ## Android development The Android toolchain is pinned to SDK 36, Build Tools 36.0.0 and NDK 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/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/qml/pages/PlayerOverlayPage.qml b/qml/pages/PlayerOverlayPage.qml index d871098f..e4c82015 100644 --- a/qml/pages/PlayerOverlayPage.qml +++ b/qml/pages/PlayerOverlayPage.qml @@ -1059,6 +1059,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/SettingsPage.qml b/qml/pages/SettingsPage.qml index 684de970..b5255662 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -198,6 +198,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") @@ -1471,9 +1473,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 diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index d15a632e..a98b56bf 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -123,8 +123,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 +135,7 @@ KeyRouter { Binding { target: Metrics property: "baselinePx" - value: Platform.isAndroid ? 720 : 1440 + value: Platform.isAndroid || Platform.isMobile ? 720 : 1440 restoreMode: Binding.RestoreNone } 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/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/tools/build-ios.sh b/tools/build-ios.sh new file mode 100755 index 00000000..cd41cc6d --- /dev/null +++ b/tools/build-ios.sh @@ -0,0 +1,45 @@ +#!/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}" +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=ON -DBUILD_TESTING=OFF \ + -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 From 6375e5cf5a81644b8f5ddf443b8619a9bfda7b8e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 22:14:29 +1100 Subject: [PATCH 03/78] fix(sdk): validate provider archive bounds and manifest types - fix(sdk): bound provider archives before extraction - fix(sdk): reject malformed manifest field types Original commits: 3db68570890715daafb2d944295ff0c06be1f18f c53f5955bf266690663e8dcb633a9a56414fcb01 --- sdk/spool-provider.py | 98 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 16 deletions(-) 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()) From c3506bd838d911efd9e9fb6c22dabf47a3c694ff Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 22:15:58 +1100 Subject: [PATCH 04/78] fix(storage): retain credentials and own database initialization inputs - fix(storage): preserve file credentials on failed replacement - fix(cache): own database inputs across initialization Original commits: 1d4ae77bcdf06d4cd2cf5d9da50fe764c4f053a8 f25c81ecf5f9c96e18ccff7cb16beed2a938e0fa --- src/cache/DatabaseManager.cpp | 18 +++++++++------- src/cache/DatabaseManager.h | 10 ++++----- .../common/CredentialStoreFileBackend.h | 21 ++++++++++++------- src/platform/webos/WebOSCredentialStore.cpp | 11 ++-------- 4 files changed, 31 insertions(+), 29 deletions(-) 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/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/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) From 9c95af0ac89fd48cb21a2393bc57d79b08f715dc Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 22:16:32 +1100 Subject: [PATCH 05/78] fix(player): fence core lifecycle and source-bound playback reports - fix(player): join event callbacks before asynchronous core destruction - fix(player): fence reentrant starts and retain core controls - fix(player): bind delayed playback reports to their original source Original commits: da1dfd135cdff927ad6706f33b50dfdb2eaae3a5 83c01cea99360bd61927fe49ee45bc922228e224 b5038ae0a2512c45a82cf5e4bef59c891a081146 --- src/platform/webos/WebOSMpvRuntime.cpp | 7 ++ src/player/MpvLifecycle.cpp | 21 ++--- src/player/MpvLifecycle.h | 11 +-- src/player/PlaybackReporter.cpp | 26 +++++ src/player/PlaybackReporter.h | 5 +- src/player/PlayerController.cpp | 126 +++++++++++++++++++------ src/player/PlayerController.h | 8 +- src/provider/PlaybackSource.h | 6 ++ src/provider/SourceHub.cpp | 4 + 9 files changed, 165 insertions(+), 49 deletions(-) diff --git a/src/platform/webos/WebOSMpvRuntime.cpp b/src/platform/webos/WebOSMpvRuntime.cpp index 7f3921b8..b80f5009 100644 --- a/src/platform/webos/WebOSMpvRuntime.cpp +++ b/src/platform/webos/WebOSMpvRuntime.cpp @@ -34,6 +34,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) \ @@ -340,6 +341,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/player/MpvLifecycle.cpp b/src/player/MpvLifecycle.cpp index 951b6b48..a87e0ee4 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); @@ -121,6 +116,10 @@ void MpvLifecycle::runEventLoop( { while (!stop->load()) { mpv_event *event = mpv_wait_event(handle, 0.1); + if (stop->load()) + break; + if (event && event->event_id == MPV_EVENT_SHUTDOWN) + stop->store(true); if (event && eventHandler) eventHandler(event); } 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/PlaybackReporter.cpp b/src/player/PlaybackReporter.cpp index b8ceee32..d8b26b1f 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; @@ -103,14 +104,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 +140,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 +157,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 +189,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..6ae5bd83 100644 --- a/src/player/PlaybackReporter.h +++ b/src/player/PlaybackReporter.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -33,6 +34,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 +56,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..fb80ff2a 100644 --- a/src/player/PlayerController.cpp +++ b/src/player/PlayerController.cpp @@ -205,6 +205,14 @@ PlayerController::PlayerController(NativeAppWindow *window, PlaybackSource *api, m_seekWatchdogTimer.setInterval(2500); m_backgroundTeardownTimer.setSingleShot(true); m_backgroundTeardownTimer.setInterval(750); + 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 @@ -298,6 +306,7 @@ PlayerController::~PlayerController() void PlayerController::prepareForShutdown() { + ++m_playGeneration; m_idleMpvPreparationEnabled = false; m_idleMpvPreparationScheduled = false; if (!m_mpvLifecycle.handle()) @@ -316,14 +325,17 @@ 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; 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; } @@ -1093,6 +1105,16 @@ bool PlayerController::ensureMpv(bool needsVideoSurface, bool embeddedVideo) } } + if (((m_volumeKnown || !usesUserMpvConfig()) + && !setRequiredMpvProperty(handle, "volume", QByteArray::number(m_volume.load()).constData())) + || ((m_muteKnown || !usesUserMpvConfig()) + && !setRequiredMpvProperty(handle, "mute", m_muted.load() ? "yes" : "no"))) { + mpv_terminate_destroy(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); @@ -1163,6 +1185,7 @@ void PlayerController::playLocalFiles(const QList& urls) void PlayerController::playSession(const PlaybackSession& session, bool startPaused, const QList& localFiles) { + const quint64 generation = ++m_playGeneration; const bool needsVideoSurface = MpvOptionProfile::needsVideoSurface(session); const QString nextMediaKind = needsVideoSurface ? QStringLiteral("video") : QStringLiteral("audio"); const bool embeddedVideo = needsVideoSurface && platformUsesEmbeddedVideo(session, m_directVideoOutput); @@ -1173,9 +1196,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 +1236,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 +1250,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,7 +1270,8 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau qInfo() << "player: audio-only playback does not request a video surface"; } - if (!ensureMpv(needsVideoSurface, embeddedVideo)) + const bool initialized = ensureMpv(needsVideoSurface, embeddedVideo); + if (generation != m_playGeneration || !initialized) return; m_session = session; @@ -1277,9 +1324,14 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau emit tracksChanged(); emit segmentsChanged(); emit trickplayChanged(); + if (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(); @@ -1790,7 +1842,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(); @@ -1909,38 +1962,47 @@ 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; 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", ""); @@ -2040,6 +2102,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 +2124,7 @@ void PlayerController::adjustVolume(int delta) } void PlayerController::setMuted(bool muted) { + m_muteKnown = true; if (m_muted.load() == muted) return; m_muted = muted; @@ -2473,7 +2537,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,7 +2558,8 @@ 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: @@ -2656,6 +2721,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 +2747,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 +2856,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 +2868,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 +2913,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 +2928,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) diff --git a/src/player/PlayerController.h b/src/player/PlayerController.h index c97cd32c..950f2cd2 100644 --- a/src/player/PlayerController.h +++ b/src/player/PlayerController.h @@ -252,7 +252,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, @@ -327,12 +327,13 @@ class PlayerController final : public QObject { 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; @@ -383,6 +384,7 @@ 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; @@ -438,6 +440,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/PlaybackSource.h b/src/provider/PlaybackSource.h index 3bd800a0..68198c35 100644 --- a/src/provider/PlaybackSource.h +++ b/src/provider/PlaybackSource.h @@ -33,6 +33,12 @@ class PlaybackSource : public QObject { virtual int playbackParallelRequests() const = 0; // 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/SourceHub.cpp b/src/provider/SourceHub.cpp index c925d5b6..181c76c7 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -276,6 +276,10 @@ class SourceHub::Playback final : public PlaybackSource { { return m_hub->signedIn(); } + PlaybackSource *reportingContext(const QString& itemId) override + { + return sourceFor(itemId); + } QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode) override { From 3595ebae4f266fe07144b5392be2490cb3c36205 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 22:46:07 +1100 Subject: [PATCH 06/78] fix(queue): notify traversal changes and shuffle for real - fix(queue): notify traversal changes and shuffle for real Original commits: b7edc678d07f29f0aef53d28d97e134429ddc81f --- src/player/PlayQueueController.cpp | 4 +++- src/player/PlayQueueController.h | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/player/PlayQueueController.cpp b/src/player/PlayQueueController.cpp index 2e12044d..1ad68495 100644 --- a/src/player/PlayQueueController.cpp +++ b/src/player/PlayQueueController.cpp @@ -4,6 +4,7 @@ #include "../provider/PlaybackSource.h" #include +#include #include #include #include @@ -433,6 +434,7 @@ bool PlayQueueController::removeItem(int index) void PlayQueueController::clear() { + cancelEpisodeSuccessors(); if (m_entries.empty()) return; const int previousCurrent = currentIndex(); @@ -665,7 +667,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..36b88ebb 100644 --- a/src/player/PlayQueueController.h +++ b/src/player/PlayQueueController.h @@ -19,8 +19,8 @@ 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) + 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 From 4baa22cc9cc1daa695c7a919be7a41b868ca6eee Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 22:51:52 +1100 Subject: [PATCH 07/78] fix(home): preserve warm rows and retire obsolete content requests - fix(home): preserve warm rows and retire obsolete content requests Original commits: b7acf99624f542489f21bf81362f90ced7b0325a --- src/app/HomeModelController.cpp | 73 +++++++++++++++++++++++---- src/app/HomeModelController.h | 2 + src/app/LibraryPrefetchController.cpp | 10 ++++ src/app/LibraryPrefetchController.h | 1 + 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/app/HomeModelController.cpp b/src/app/HomeModelController.cpp index 857168ea..861e305c 100644 --- a/src/app/HomeModelController.cpp +++ b/src/app/HomeModelController.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -321,9 +322,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 +388,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 +404,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(); @@ -463,6 +507,7 @@ QCoro::Task HomeModelController::refreshAsync( QCoro::Task> HomeModelController::fetchLatestLibraryItems(LibraryItem library) { + const QPointer guard(this); constexpr int kTargetItems = 20; constexpr int kMaximumRawItems = 200; constexpr int kInitialTvRawItems = 120; @@ -470,7 +515,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 +722,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 +741,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 +787,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..c3c55fcc 100644 --- a/src/app/HomeModelController.h +++ b/src/app/HomeModelController.h @@ -128,10 +128,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); From d16dddb4c35157c33f2a5aa077faf11aedba6dc0 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:02:24 +1100 Subject: [PATCH 08/78] fix(remote): preserve the playback clock across pause and seek - fix(remote): preserve the playback clock across pause and seek Original commits: cd9ad7f7bf4e44a5d41fd504ebd9a109b1f79613 --- src/app/RemoteTargetsController.cpp | 52 ++++++++++++++++++++++++++--- src/app/RemoteTargetsController.h | 6 +++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/app/RemoteTargetsController.cpp b/src/app/RemoteTargetsController.cpp index dfb29840..de8ed1d1 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,17 @@ 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 running clock before the transport changes state: a + // stale poll must neither rewind it nor restart an optimistic + // unpause's clock, and playback advances even from a zero position. + if (!m_position.isEmpty()) { + const QString captured = QString::number(predictedPositionTicks()); + m_optimistic.insert("positionTicks", captured); + m_position = captured; + } 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 +662,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 +763,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 +925,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..ea4be603 100644 --- a/src/app/RemoteTargetsController.h +++ b/src/app/RemoteTargetsController.h @@ -64,9 +64,12 @@ class RemoteTargetsController final : public QObject { return m_target; } QVariantMap state() const; + // The running clock: 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; + return m_position.isEmpty() ? m_position : QString::number(predictedPositionTicks()); } QString runtimeTicks() const { @@ -203,6 +206,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; From df39f2ba748828d08141d96404ca3b5dda13f9dc Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:02:24 +1100 Subject: [PATCH 09/78] fix(discovery): keep servers without IDs distinct - fix(discovery): keep servers without IDs distinct Original commits: 467304c44da34d8b1c0a07041e45e09699d91023 --- qml/shell/ServerLogin.qml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/qml/shell/ServerLogin.qml b/qml/shell/ServerLogin.qml index 8ee3b842..e0c9d111 100644 --- a/qml/shell/ServerLogin.qml +++ b/qml/shell/ServerLogin.qml @@ -120,9 +120,20 @@ FocusScope { 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 } From e8cab98f47b3e926e0452e54570575a367170ec8 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:10:27 +1100 Subject: [PATCH 10/78] fix(navigation): preserve item identity across retained pages - fix(navigation): preserve item identity across retained pages Original commits: 4fd251d1eacb64d51d9475f22ba509723c6027c3 --- qml/pages/ItemDetailsPage.qml | 44 ++++++++++++++++++++++++--------- qml/pages/LibraryGridPage.qml | 19 ++++++++++---- qml/pages/PersonDetailsPage.qml | 23 +++++++++++++++-- qml/shell/AppShell.qml | 38 ++++++++++------------------ qml/shell/RoutePolicy.js | 22 ++++++++++++++--- 5 files changed, 98 insertions(+), 48 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index a7f7da00..7531093a 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -64,7 +64,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 @@ -495,6 +495,21 @@ 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() + } + + onFullDetailItemChanged: { + if (fullDetailItem.movieId) { + syncUserState() + Qt.callLater(root.refreshDetailRows) + } + } + onRouteActiveChanged: { if (routeActive) enterRoute(false) @@ -530,12 +545,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 +563,6 @@ FocusScope { root.updateDetailCounts() root.rebuildSeasonEntries() } - function onDetailItemChanged() { - if (root.fullDetailItem.movieId) - root.syncUserState() - } } Connections { @@ -641,6 +652,8 @@ FocusScope { } function refreshDetailRows() { + if (!routeActive || !visible) + return const itemId = item.movieId || "" const key = itemId + ":" + typeText + ":" + seriesIdText + ":" + seasonIdText if (key === loadedDetailKey) @@ -847,9 +860,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) { @@ -1449,7 +1469,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 +1478,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 +1488,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() } } diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index 2374afb7..c96bf567 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -158,6 +158,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() @@ -1336,11 +1349,7 @@ FocusScope { 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 { diff --git a/qml/pages/PersonDetailsPage.qml b/qml/pages/PersonDetailsPage.qml index 3f13b1da..cd45ecce 100644 --- a/qml/pages/PersonDetailsPage.qml +++ b/qml/pages/PersonDetailsPage.qml @@ -6,13 +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 bool directionRelease: rows.directionRelease - readonly property var person: shell ? shell.personItem : ({}) + 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. @@ -38,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) diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index a98b56bf..d62eb181 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -215,7 +215,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) @@ -456,17 +455,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 @@ -927,22 +917,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 } diff --git a/qml/shell/RoutePolicy.js b/qml/shell/RoutePolicy.js index dde9c2ca..7b2d21be 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 } @@ -33,7 +49,6 @@ function detailsContext(args, fallbackModel) { const model = routeArgs.model || fallbackModel const index = modelIndexForItemId(model, routeArgs.itemId, routeArgs.focusIndex) let item = modelItem(model, index) - let effectiveIndex = index if (itemIdFor(item).length <= 0 && String(routeArgs.itemId || "").length > 0) { item = { movieId: String(routeArgs.itemId), @@ -43,11 +58,10 @@ function detailsContext(args, fallbackModel) { seasonId: String(routeArgs.seasonId || ""), playable: true } - effectiveIndex = 0 } return { model: model, - index: effectiveIndex, + index: index, item: item, source: String(routeArgs.source || "movies"), returnRoute: String(routeArgs.returnRoute || "libraryGrid") From a50d97d4a03cf9b30345510d87daa4fb218ba1e2 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:11:36 +1100 Subject: [PATCH 11/78] fix(qml): finish picker placement and respect menu boundaries - fix(qml): finish picker placement and respect menu boundaries Original commits: ab00c67a68492af67b302c568aa627cb311025fe --- qml/primitives/MenuListView.qml | 2 +- qml/primitives/OptionPickerDialog.qml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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/OptionPickerDialog.qml b/qml/primitives/OptionPickerDialog.qml index 4f5aef75..9c3dddf8 100644 --- a/qml/primitives/OptionPickerDialog.qml +++ b/qml/primitives/OptionPickerDialog.qml @@ -69,6 +69,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 } From d5f4da355f3e5d4e13a60774bf243a8b9042cf67 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:11:40 +1100 Subject: [PATCH 12/78] fix(android): launch intents without visibility-sensitive preflight - fix(android): launch intents without visibility-sensitive preflight Original commits: bc1f3e42f6cb587dca2256b8267a42b97f191899 --- .../src/com/sachk/spool/AndroidUpdateBridge.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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); From d6c9a953aeb25a0ccac889c5b231aa2f82ee223b Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:19:57 +1100 Subject: [PATCH 13/78] fix(state): publish manual item changes after confirmed writes - fix(state): publish manual item changes after confirmed writes Original commits: ef69d35d04537be51d6961d0a3c728032c5254fd --- qml/pages/ItemDetailsPage.qml | 6 +- qml/shell/ItemContextMenu.qml | 8 +- src/app/AppController.cpp | 1 + src/app/UserItemStateController.cpp | 119 +++++++++++++++++++--------- src/app/UserItemStateController.h | 25 ++++++ 5 files changed, 112 insertions(+), 47 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 7531093a..17e56065 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -882,15 +882,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() { diff --git a/qml/shell/ItemContextMenu.qml b/qml/shell/ItemContextMenu.qml index bd7048d5..a3bc5829 100644 --- a/qml/shell/ItemContextMenu.qml +++ b/qml/shell/ItemContextMenu.qml @@ -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/src/app/AppController.cpp b/src/app/AppController.cpp index d4218e8d..2e2e05f1 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -513,6 +513,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(); 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 From 1edfa1b118a93f36da015367b1e0db275653149e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:47:33 +1100 Subject: [PATCH 14/78] fix(app): fence every playback intent with one retirement path - fix(app): fence every playback intent with one retirement path Original commits: d585f3f6efb5f9b31230efa891de80a8b9d3345b --- src/app/AppController.cpp | 145 +++++++++++++++++++++------ src/app/AppController.h | 7 ++ src/app/AppControllerRemote.cpp | 56 ++++++++--- src/app/GroupPlaybackController.cpp | 150 +++++++++++++++++++++------- src/app/GroupPlaybackController.h | 4 + src/provider/GroupPlayback.h | 3 + 6 files changed, 280 insertions(+), 85 deletions(-) diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 2e2e05f1..49f2a69a 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -232,14 +233,22 @@ AppController::AppController( 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 +297,12 @@ 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; + cancelPendingPlaybackRequests(true); }); connect(m_player, &PlayerController::playbackStopped, this, &AppController::handlePlaybackStopped); connect(m_player, &PlayerController::playbackSeekOutsideStream, this, @@ -389,7 +398,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 +507,15 @@ QCoro::Task AppController::initializeAsync() void AppController::resetApplicationState() { - m_remotePlaybackRequestGeneration.invalidate(); + 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() @@ -591,10 +606,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; @@ -604,6 +623,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; @@ -689,7 +709,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. @@ -698,6 +720,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; @@ -713,6 +736,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; @@ -722,6 +746,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(); @@ -741,11 +789,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 = {}; @@ -781,6 +825,7 @@ void AppController::playLocalQueueNext(PlayDestination destination) } if (!m_playQueue->next()) return; + cancelPendingPlaybackRequests(); playQueueCurrent(false); } @@ -809,6 +854,7 @@ void AppController::playLocalQueuePrevious(PlayDestination destination) } if (!m_playQueue->previous()) return; + cancelPendingPlaybackRequests(); playQueueCurrent(true); } @@ -824,16 +870,20 @@ 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); } @@ -882,9 +932,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); } @@ -940,6 +991,7 @@ void AppController::loadMoreCurrentItems() 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)) { @@ -1010,12 +1062,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…")); @@ -1025,6 +1077,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; @@ -1046,6 +1099,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; @@ -1060,6 +1114,7 @@ void AppController::cancelEpisodicPlaybackSelection() return; ++m_episodeQueueGeneration; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); } @@ -1069,6 +1124,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)) { @@ -1087,8 +1143,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), @@ -1096,6 +1154,7 @@ void AppController::playEpisodeWithContext( if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -1149,6 +1208,7 @@ void AppController::playEpisodeWithContext( if (generation != m_episodeQueueGeneration) return; m_episodeQueuePending = false; + m_playbackLookupPending = false; setBusy(false); if (!destinationIsCurrent(destination)) return; @@ -1236,18 +1296,33 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau 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; @@ -1292,7 +1367,7 @@ 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; @@ -1304,11 +1379,19 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau const int fileAudioDelayMs = restartActive ? m_player->fileAudioDelayMs() : 0; const int subtitleDelayMs = restartActive ? m_player->subtitleDelayMs() : 0; 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; @@ -1647,7 +1730,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); @@ -1677,7 +1760,7 @@ 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; } diff --git a/src/app/AppController.h b/src/app/AppController.h index 632b8ee2..8221b810 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -210,6 +210,10 @@ 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 playQueuedItems( const std::vector& items, int startIndex, bool fromStart, PlayDestination destination); bool modelIsOrderedList(MovieGridModel *model) const; @@ -277,6 +281,9 @@ 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; bool m_initialized = false; QString m_busyText; QString m_errorText; diff --git a/src/app/AppControllerRemote.cpp b/src/app/AppControllerRemote.cpp index f2c9edbd..d496fe82 100644 --- a/src/app/AppControllerRemote.cpp +++ b/src/app/AppControllerRemote.cpp @@ -239,40 +239,64 @@ 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. + std::vector ordered; + int resolvedIndex = -1; + for (qsizetype i = 0; i < itemIds.size(); ++i) { + const auto found = std::find_if( + items.begin(), items.end(), [&](const MovieItem& item) { return item.id == itemIds.at(i); }); + if (found == items.end()) + 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/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp index e2014980..2e10a752 100644 --- a/src/app/GroupPlaybackController.cpp +++ b/src/app/GroupPlaybackController.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -241,6 +242,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 +299,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 +490,38 @@ 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; + 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 (!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 +534,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 +544,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 +561,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 +599,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 +663,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 +790,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/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; From d049efa18237e5b3194d212d7feb964b9ea249c0 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:50:19 +1100 Subject: [PATCH 15/78] perf(player): reduce event-loop and queue model work - perf(player): wait on the mpv event loop explicitly - perf(player): move queue ranges as one model operation Original commits: 1333306e680f7a635d3b4f01e6ef4909c5df6411 0a6c91ed408dca98232ecf212b80d64b0372ef3b --- src/player/MpvLifecycle.cpp | 11 ++-- src/player/PlayQueueController.cpp | 80 ++++++++++++------------------ 2 files changed, 39 insertions(+), 52 deletions(-) diff --git a/src/player/MpvLifecycle.cpp b/src/player/MpvLifecycle.cpp index a87e0ee4..bb9128d0 100644 --- a/src/player/MpvLifecycle.cpp +++ b/src/player/MpvLifecycle.cpp @@ -115,13 +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); + // 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_SHUTDOWN) - stop->store(true); - if (event && eventHandler) + 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/PlayQueueController.cpp b/src/player/PlayQueueController.cpp index 1ad68495..5f389587 100644 --- a/src/player/PlayQueueController.cpp +++ b/src/player/PlayQueueController.cpp @@ -362,36 +362,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) @@ -578,28 +549,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; } From d5e7255f4c6fcf70aae794567cbb3ec3308057ee Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sat, 10 Oct 2026 23:54:44 +1100 Subject: [PATCH 16/78] perf(remote): reuse queue metadata lookups as hashed joins - perf(remote): reuse queue metadata lookups as hashed joins Original commits: a9e793a99cb93feffbf9960d2be740047fec6779 --- src/app/AppControllerRemote.cpp | 14 ++++++++++---- src/app/GroupPlaybackController.cpp | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/AppControllerRemote.cpp b/src/app/AppControllerRemote.cpp index d496fe82..775d2078 100644 --- a/src/app/AppControllerRemote.cpp +++ b/src/app/AppControllerRemote.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -261,16 +262,21 @@ void AppController::playRemoteItems(const QString& accountId, const QVariantMap& 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 = std::find_if( - items.begin(), items.end(), [&](const MovieItem& item) { return item.id == itemIds.at(i); }); - if (found == items.end()) + 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); + ordered.push_back(**found); } if (ordered.empty() || (replacesPlayback && resolvedIndex < 0)) { if (replacesPlayback) diff --git a/src/app/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp index 2e10a752..9866d620 100644 --- a/src/app/GroupPlaybackController.cpp +++ b/src/app/GroupPlaybackController.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -499,12 +500,17 @@ void GroupPlaybackController::applyQueue(const QVariantMap& event) 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 = std::find_if(fetched.begin(), fetched.end(), - [&](const MovieItem& item) { return item.id == itemIds.at(i); }); - if (found == fetched.end()) + const auto found = itemsById.constFind(itemIds.at(i)); + if (found == itemsById.cend()) continue; - MovieItem item = *found; + MovieItem item = **found; item.playlistItemId = entryIds.at(i); if (i == playingIndex) resolvedIndex = static_cast(ordered.size()); From 797d5c923faf37df8c24605a87a6c911eb2c225b Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 00:00:26 +1100 Subject: [PATCH 17/78] perf(diagnostics): sample only while an overlay observes - perf(diagnostics): sample only while an overlay observes Original commits: a56c69d35529b666ef419873621f2eb4ad230707 --- qml/shell/DiagnosticsOverlay.qml | 2 ++ src/diagnostics/SystemPerformanceMonitor.cpp | 31 ++++++++++++++++++-- src/diagnostics/SystemPerformanceMonitor.h | 13 +++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/qml/shell/DiagnosticsOverlay.qml b/qml/shell/DiagnosticsOverlay.qml index dc1e568c..b30852b4 100644 --- a/qml/shell/DiagnosticsOverlay.qml +++ b/qml/shell/DiagnosticsOverlay.qml @@ -13,6 +13,8 @@ Item { readonly property var syncPlay: ProviderCapabilities.groupPlayback ? Group : null readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false + Component.onCompleted: SystemPerformance.observe(root) + function formatBytes(bytes) { const value = Math.max(0, Number(bytes || 0)) if (value >= 1024 * 1024 * 1024) 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; From 2318984e27c7d9eddd870cb6a5daadf3ac601060 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 00:04:25 +1100 Subject: [PATCH 18/78] perf(player): bound clock publication and seek diagnostics - perf(player): publish the playback UI clock at a bounded cadence - perf(player): avoid color diagnostic queries after every seek Original commits: b91a834936abf0a6948a16db7e25860882ae38db e9142728a5962d6591781ff1e3922bd5b4a0c5f8 --- src/player/PlaybackPositionTracker.cpp | 8 +++- src/player/PlaybackPositionTracker.h | 3 ++ src/player/PlayerController.cpp | 56 +++++++++++++++++++------- src/player/PlayerController.h | 4 +- 4 files changed, 54 insertions(+), 17 deletions(-) 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/PlayerController.cpp b/src/player/PlayerController.cpp index fb80ff2a..b22d7dc2 100644 --- a/src/player/PlayerController.cpp +++ b/src/player/PlayerController.cpp @@ -260,7 +260,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]() { @@ -875,8 +875,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 @@ -1308,13 +1307,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) @@ -1940,6 +1939,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; @@ -2563,9 +2563,12 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ }); 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) { @@ -2618,7 +2621,7 @@ 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: { @@ -2969,10 +2972,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); @@ -2998,23 +3022,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 950f2cd2..0768b35b 100644 --- a/src/player/PlayerController.h +++ b/src/player/PlayerController.h @@ -342,7 +342,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(); From 162cf3097a78c64f4a7bddcacd7642484284ba6b Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 00:06:45 +1100 Subject: [PATCH 19/78] perf(diagnostics): stop route sampling on cancellation - perf(diagnostics): stop route sampling on cancellation Original commits: ab7ddaaa856b100cacd03b5b514beac556e89b7a --- src/diagnostics/InputLatencyMonitor.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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(); } From dc9865596f52032762f6e6b039f9c859c0986671 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 00:09:46 +1100 Subject: [PATCH 20/78] perf(qml): stabilize home rows and avoid repeated catalogue work - perf(qml): bind media cards to row-specific model roles - perf(qml): search library titles in a single traversal - perf(qml): keep home rows stable while loading changes - perf(home): retain descriptors when only row content changes - perf(qml): refresh settings rows only for availability changes Original commits: 2fb12f49a3c3928efecc68883f2c597bf64554e9 2bcca5cc9d5abe77493e3f438d857290764914d0 81494ec9ebd5755f6e09c8804df8bc961168f026 b44195f82a54e5de0ea5b407fd49e10e201c607f 09b9e3e010860accfd902e386e09fc9bb88c04dd --- qml/browse/RowStackView.qml | 9 +++++++-- qml/pages/HomePage.qml | 9 +++++---- qml/pages/LibraryGridPage.qml | 29 +++++++---------------------- qml/pages/LibraryNavigation.js | 19 +++++++++++++++++++ qml/pages/SettingsNavigation.js | 11 +++++++++++ qml/pages/SettingsPage.qml | 5 ++++- qml/primitives/MediaRow.qml | 31 ++++++++++--------------------- src/app/HomeModelController.cpp | 2 +- 8 files changed, 64 insertions(+), 51 deletions(-) diff --git a/qml/browse/RowStackView.qml b/qml/browse/RowStackView.qml index 8e9c4b76..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. @@ -350,7 +354,8 @@ FocusScope { useSeriesPoster: Boolean(sectionDescriptor.useSeriesPoster) preferEpisodeTitle: Boolean(sectionDescriptor.preferEpisodeTitle) enabledRow: sectionDescriptor.enabled === undefined ? true : Boolean(sectionDescriptor.enabled) - reserveWhenEmpty: Boolean(sectionDescriptor.reserveWhenEmpty) + reserveWhenEmpty: Boolean(sectionDescriptor.reserveWhenEmpty) || (root.loading && Boolean( + sectionDescriptor.reserveWhileLoading)) loading: Boolean(sectionDescriptor.loading) emptyText: String(sectionDescriptor.emptyText || "") headerBadge: sectionDescriptor.headerBadge || null diff --git a/qml/pages/HomePage.qml b/qml/pages/HomePage.qml index 1770c161..84b0c9ff 100644 --- a/qml/pages/HomePage.qml +++ b/qml/pages/HomePage.qml @@ -144,14 +144,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) { @@ -163,7 +163,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, @@ -358,6 +358,7 @@ FocusScope { anchors.topMargin: Metrics.scaled(8) shell: root.shell sections: root.buildSections() + loading: Home.loading contextReturnRoute: "home" measureFirstRow: true focus: true diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index c96bf567..272087d0 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -598,29 +598,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) { 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/SettingsNavigation.js b/qml/pages/SettingsNavigation.js index b1d1785e..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 diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index b5255662..1d5c362e 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -1053,7 +1053,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 { diff --git a/qml/primitives/MediaRow.qml b/qml/primitives/MediaRow.qml index d5311b68..ed677a65 100644 --- a/qml/primitives/MediaRow.qml +++ b/qml/primitives/MediaRow.qml @@ -51,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() @@ -83,14 +82,12 @@ FocusScope { Component.onCompleted: resetPresentation() onAtomicPopulateChanged: Qt.callLater(resetPresentation) onModelChanged: { - ++modelRevision if (!publishingMove && (moveMode || dragActive)) finishMove() if (!publishingMove) Qt.callLater(resetPresentation) } onCountChanged: { - ++modelRevision if (!publishingMove) { currentIndex = count > 0 ? Math.max(0, Math.min(currentIndex, count - 1)) : -1 if (moveMode || dragActive) @@ -121,35 +118,20 @@ FocusScope { target: root.model && root.model.rowCount !== undefined ? root.model : null ignoreUnknownSignals: true - function onDataChanged() { - ++root.modelRevision - } function onModelReset() { - ++root.modelRevision 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) } @@ -420,8 +402,15 @@ 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: { + const itemRole = cardData.item + return libraryCard ? (itemRole || ({})) : itemRole !== undefined ? itemRole : cardData + } readonly property bool libraryCard: root.cardKind === "library" readonly property bool personCard: root.cardKind === "person" readonly property var badge: root.cardBadge ? root.cardBadge(cardData) : null diff --git a/src/app/HomeModelController.cpp b/src/app/HomeModelController.cpp index 861e305c..45066955 100644 --- a/src/app/HomeModelController.cpp +++ b/src/app/HomeModelController.cpp @@ -501,7 +501,7 @@ QCoro::Task HomeModelController::refreshAsync( : LibraryPrefetchController::ImageKind::Poster); } m_prefetch->schedule(libraries, m_recentLibraryIds); - if (latestRowsChanged || !m_latestLibrarySections.empty()) + if (latestRowsChanged) emit latestLibraryRowsChanged(); } From 8517e37a07af9563b13e83fb0bffac7685d46654 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 00:37:36 +1100 Subject: [PATCH 21/78] feat(details): expose server trailers and extras on detail pages - feat(details): expose server trailers and extras on detail pages Original commits: e28e7ef2e399e7fea316e168d5a5fc00d50bc986 --- docs/features/related-media.md | 20 +++++ qml/pages/ItemDetailsPage.qml | 105 +++++++++++++++++++++++++- src/app/ContentModelController.cpp | 35 ++++++++- src/app/ContentModelController.h | 12 +++ src/provider/Catalog.h | 4 + src/provider/PortableProvider.cpp | 18 +++++ src/provider/PortableProvider.h | 1 + src/provider/SourceHub.cpp | 9 +++ src/provider/SourceHub.h | 1 + src/providers/local/LocalProvider.cpp | 5 ++ src/providers/local/LocalProvider.h | 1 + 11 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 docs/features/related-media.md 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/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 17e56065..5e23105f 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) @@ -98,6 +100,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") @@ -528,6 +532,10 @@ 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 } @@ -638,6 +646,14 @@ 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() Qt.callLater(positionContextRow) } @@ -664,6 +680,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) } @@ -754,6 +774,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 @@ -768,12 +792,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 } @@ -790,6 +818,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 @@ -993,6 +1025,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) @@ -1081,6 +1118,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") { @@ -1119,6 +1160,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, @@ -1574,7 +1619,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 @@ -1586,18 +1632,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 @@ -1648,6 +1703,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 diff --git a/src/app/ContentModelController.cpp b/src/app/ContentModelController.cpp index 0e90353c..45df8219 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) { @@ -374,6 +399,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); } @@ -388,6 +415,8 @@ void ContentModelController::updateFavorite(const QString& itemId, bool 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 +436,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,6 +451,8 @@ 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) diff --git a/src/app/ContentModelController.h b/src/app/ContentModelController.h index f987bb2d..603bf792 100644 --- a/src/app/ContentModelController.h +++ b/src/app/ContentModelController.h @@ -22,6 +22,8 @@ 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) @@ -43,6 +45,14 @@ class ContentModelController final : public QObject { { return &m_detailSimilarItems; } + MovieGridModel *detailTrailers() + { + return &m_detailTrailers; + } + MovieGridModel *detailExtras() + { + return &m_detailExtras; + } QVariantList personItemRows() const; MovieGridModel *linkedItems() { @@ -103,6 +113,8 @@ 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; diff --git a/src/provider/Catalog.h b/src/provider/Catalog.h index f6c6bbb6..c5443857 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. diff --git a/src/provider/PortableProvider.cpp b/src/provider/PortableProvider.cpp index aa98c221..846b0cbe 100644 --- a/src/provider/PortableProvider.cpp +++ b/src/provider/PortableProvider.cpp @@ -551,6 +551,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..41642ef5 100644 --- a/src/provider/PortableProvider.h +++ b/src/provider/PortableProvider.h @@ -90,6 +90,7 @@ 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; diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp index 181c76c7..d5c30df6 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -1424,6 +1424,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..74d216c3 100644 --- a/src/provider/SourceHub.h +++ b/src/provider/SourceHub.h @@ -163,6 +163,7 @@ 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; diff --git a/src/providers/local/LocalProvider.cpp b/src/providers/local/LocalProvider.cpp index f8c54641..0abc5917 100644 --- a/src/providers/local/LocalProvider.cpp +++ b/src/providers/local/LocalProvider.cpp @@ -393,6 +393,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 {}; diff --git a/src/providers/local/LocalProvider.h b/src/providers/local/LocalProvider.h index 219ace44..f2380258 100644 --- a/src/providers/local/LocalProvider.h +++ b/src/providers/local/LocalProvider.h @@ -77,6 +77,7 @@ 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; From 4dbef65d3d1616f187610b8f3ddd0c5c2fdf580e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 00:40:45 +1100 Subject: [PATCH 22/78] feat(settings): add preferred audio language - feat(settings): add preferred audio language Original commits: 1afe13c4e20de83de01a7435c6f473e6ac28c44a --- qml/pages/ItemDetailsPage.qml | 3 ++- qml/pages/LibraryGridPage.qml | 3 ++- qml/pages/SettingsPage.qml | 23 ++++++++++++++++------- qml/pages/SubtitleSettingsPanel.qml | 2 +- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 5e23105f..3fd1f4e1 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -92,7 +92,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 diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index 272087d0..41348d16 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -734,7 +734,8 @@ 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 { diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index 1d5c362e..00d42c7c 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -526,16 +526,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)) @@ -588,10 +590,17 @@ FocusScope { 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 || [] } diff --git a/qml/pages/SubtitleSettingsPanel.qml b/qml/pages/SubtitleSettingsPanel.qml index bb056639..04527050 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)) From c0094499160d11e6f891a65495b73abdef8997ed Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 01:03:25 +1100 Subject: [PATCH 23/78] feat(player): add local repeat controls and cancellable sleep timer - feat(player): add local repeat controls and report queue state - feat(player): add a sleep timer with cancellable playback stop Original commits: 74e027b9b00d3c8dd9c23c2b16981f5e877edf19 44334aafbc9d293f0f61d860c1043fa3907951eb --- CMakeLists.txt | 1 + docs/playback-controls.md | 56 ++++++++++++++ qml/pages/PlayerOverlayPage.qml | 119 ++++++++++++++++++++++++++++- src/app/AppController.cpp | 118 ++++++++++++++++++++++++++-- src/app/AppController.h | 23 +++++- src/app/AppControllerRemote.cpp | 2 +- src/app/PlaybackStopPolicy.h | 87 +++++++++++++++++++++ src/media/MediaTypes.h | 3 + src/player/PlayQueueController.cpp | 24 +++++- src/player/PlayQueueController.h | 18 ++++- src/player/PlaybackReporter.cpp | 10 +++ src/player/PlaybackReporter.h | 3 + src/player/PlayerController.cpp | 6 ++ src/player/PlayerController.h | 3 + src/provider/PortableProvider.cpp | 5 ++ 15 files changed, 460 insertions(+), 18 deletions(-) create mode 100644 docs/playback-controls.md create mode 100644 src/app/PlaybackStopPolicy.h diff --git a/CMakeLists.txt b/CMakeLists.txt index fbcaa4ea..196e39ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -267,6 +267,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 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/qml/pages/PlayerOverlayPage.qml b/qml/pages/PlayerOverlayPage.qml index e4c82015..b3e7c79b 100644 --- a/qml/pages/PlayerOverlayPage.qml +++ b/qml/pages/PlayerOverlayPage.qml @@ -154,6 +154,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 +197,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 +466,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 +479,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 +491,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") @@ -490,7 +575,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 +602,11 @@ FocusScope { openMenu("quality") return } + if (action === "repeat" || action === "sleep") { + if (!syncPlayActive) + openMenu(action) + return + } if (action === "subtitleSettings") { openSubtitleSettings() return @@ -981,6 +1083,15 @@ FocusScope { onScrubbingChanged: if (!scrubbing) maybeRestartAutohide() + Connections { + target: syncPlay + + function onGroupChanged() { + if (syncPlay.enabled && (overlay.menuKind === "repeat" || overlay.menuKind === "sleep")) + overlay.closeMenu() + } + } + Connections { target: player diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 49f2a69a..71f40497 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -187,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); @@ -228,6 +234,19 @@ 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_database, &DatabaseManager::recoveryNotice, this, &AppController::showToast); @@ -302,6 +321,7 @@ AppController::AppController( // 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); @@ -507,6 +527,8 @@ QCoro::Task AppController::initializeAsync() void AppController::resetApplicationState() { + cancelSleepTimer(); + m_playQueue->setRepeatMode(QStringLiteral("RepeatNone")); cancelPendingPlaybackRequests(true); resetVisibleModels(); m_playQueue->clear(); @@ -887,6 +909,75 @@ void AppController::playQueueItem(int index) 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. @@ -1293,6 +1384,9 @@ 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 } }); @@ -1371,6 +1465,8 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau 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; @@ -1378,6 +1474,11 @@ 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; @@ -1432,6 +1533,7 @@ void AppController::shutdown() return; m_shuttingDown = true; qInfo() << "app: shutdown requested"; + cancelSleepTimer(); m_player->prepareForShutdown(); m_prefetch->stop(); m_provider->shutdown(); @@ -1764,19 +1866,19 @@ void AppController::handlePlaybackStopped( 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 8221b810..e30e1d9d 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(); @@ -214,6 +231,8 @@ class AppController final : public QObject { // 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; @@ -284,10 +303,12 @@ class AppController final : public QObject { // 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 775d2078..ddc9275d 100644 --- a/src/app/AppControllerRemote.cpp +++ b/src/app/AppControllerRemote.cpp @@ -173,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")) { 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/media/MediaTypes.h b/src/media/MediaTypes.h index f591f5a9..b074280e 100644 --- a/src/media/MediaTypes.h +++ b/src/media/MediaTypes.h @@ -422,6 +422,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/player/PlayQueueController.cpp b/src/player/PlayQueueController.cpp index 5f389587..a89253b6 100644 --- a/src/player/PlayQueueController.cpp +++ b/src/player/PlayQueueController.cpp @@ -316,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; } @@ -324,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()) @@ -406,6 +425,7 @@ bool PlayQueueController::removeItem(int index) void PlayQueueController::clear() { cancelEpisodeSuccessors(); + setRepeatMode(QStringLiteral("RepeatNone")); if (m_entries.empty()) return; const int previousCurrent = currentIndex(); diff --git a/src/player/PlayQueueController.h b/src/player/PlayQueueController.h index 36b88ebb..cc58db90 100644 --- a/src/player/PlayQueueController.h +++ b/src/player/PlayQueueController.h @@ -19,6 +19,9 @@ 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) + // 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 @@ -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/PlaybackReporter.cpp b/src/player/PlaybackReporter.cpp index d8b26b1f..c1b15942 100644 --- a/src/player/PlaybackReporter.cpp +++ b/src/player/PlaybackReporter.cpp @@ -51,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) diff --git a/src/player/PlaybackReporter.h b/src/player/PlaybackReporter.h index 6ae5bd83..45ca0076 100644 --- a/src/player/PlaybackReporter.h +++ b/src/player/PlaybackReporter.h @@ -22,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: diff --git a/src/player/PlayerController.cpp b/src/player/PlayerController.cpp index b22d7dc2..ef92da5a 100644 --- a/src/player/PlayerController.cpp +++ b/src/player/PlayerController.cpp @@ -1931,6 +1931,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()) diff --git a/src/player/PlayerController.h b/src/player/PlayerController.h index 0768b35b..04a17ad9 100644 --- a/src/player/PlayerController.h +++ b/src/player/PlayerController.h @@ -195,6 +195,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); diff --git a/src/provider/PortableProvider.cpp b/src/provider/PortableProvider.cpp index 846b0cbe..65c3295a 100644 --- a/src/provider/PortableProvider.cpp +++ b/src/provider/PortableProvider.cpp @@ -275,6 +275,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") From 61dcf2b24c2761b712187939cca9f2aeae58895f Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 01:14:56 +1100 Subject: [PATCH 24/78] fix(ui): route modal Back and contain pointer input - fix(ui): honor routed Back and contain modal pointer input - fix(ui): route certificate dialog cancellation through Back Original commits: 69052b89fb13039f72293f0a438e50ff1da0f5fe ba72096ed8f14a1279e81b18116249e204784194 --- qml/primitives/ConfirmationDialog.qml | 7 +++++-- qml/primitives/OptionPickerDialog.qml | 8 ++++++-- qml/shell/RememberedCertificatesDialog.qml | 6 +++--- qml/shell/TlsTrustDialog.qml | 4 ++-- 4 files changed, 16 insertions(+), 9 deletions(-) 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/OptionPickerDialog.qml b/qml/primitives/OptionPickerDialog.qml index 9c3dddf8..b6b13d09 100644 --- a/qml/primitives/OptionPickerDialog.qml +++ b/qml/primitives/OptionPickerDialog.qml @@ -85,9 +85,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) @@ -111,6 +113,8 @@ FocusScope { Component.onCompleted: schedulePresentation() onVisibleChanged: schedulePresentation() onAnchorItemChanged: schedulePresentation() + onWidthChanged: schedulePresentation() + onHeightChanged: schedulePresentation() MouseArea { anchors.fill: parent diff --git a/qml/shell/RememberedCertificatesDialog.qml b/qml/shell/RememberedCertificatesDialog.qml index de14892e..336a8c5e 100644 --- a/qml/shell/RememberedCertificatesDialog.qml +++ b/qml/shell/RememberedCertificatesDialog.qml @@ -14,9 +14,9 @@ FocusScope { function routeKey(key, phase, repeat) { if (root.inputKeys.isBack(key, false, false)) { - if (phase === "release") + if (phase === "press" && !repeat) dismissed() - return true + return phase === "press" } if (!root.inputKeys.isDirection(key)) return true @@ -105,7 +105,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/TlsTrustDialog.qml b/qml/shell/TlsTrustDialog.qml index 0b75a88d..b5085400 100644 --- a/qml/shell/TlsTrustDialog.qml +++ b/qml/shell/TlsTrustDialog.qml @@ -14,9 +14,9 @@ FocusScope { function routeKey(key, phase, repeat) { if (root.inputKeys.isBack(key, false, false)) { - if (phase === "release") + if (phase === "press" && !repeat) root.trustController.cancel() - return true + return phase === "press" } if (!root.inputKeys.isDirection(key)) return true From e552b94b5effb51644537324799cefe8c0d384c9 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 01:19:40 +1100 Subject: [PATCH 25/78] fix(ui): expose accessible actions and remote-scrollable bounded content - fix(ui): make media information remotely scrollable - fix(ui): reserve space for setting selectors - fix(ui): expose accessible button actions and bounded content Original commits: 4566f10db535f0ce89a9aab2fda21ba3fe224049 626305edd3a336abadd9e7e7c22ad2ac54e94a81 ba93a0917639712e038e3f1fc19e516700ecb50d --- qml/primitives/ActionButton.qml | 17 ++++++++-- qml/primitives/IconButton.qml | 15 +++++++++ qml/primitives/SelectRow.qml | 23 +++++++------ qml/shell/MediaInfoOverlay.qml | 57 +++++++++++++++++++++++++-------- 4 files changed, 88 insertions(+), 24 deletions(-) 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/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/SelectRow.qml b/qml/primitives/SelectRow.qml index 2fb52384..85a13ef2 100644 --- a/qml/primitives/SelectRow.qml +++ b/qml/primitives/SelectRow.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Layouts import "../theme" SettingRow { @@ -25,18 +26,22 @@ SettingRow { onClicked: opened() - Rectangle { + trailing: Rectangle { readonly property int sidePadding: Metrics.scaled(16) - anchors.right: parent.right - anchors.rightMargin: Metrics.scaled(14) - anchors.verticalCenter: parent.verticalCenter + Layout.alignment: Qt.AlignVCenter // 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) + // 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(120), Math.min(valueLabel.implicitWidth + + dropdownIcon.width + + sidePadding + * 2 + Metrics.scaled( + 10), Math.max( + Metrics.scaled( + 180), root.width + * 0.46)))) + Layout.preferredHeight: 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 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 { From 2b2b9d5a6d7a2b22c6c483121965a393cce3e238 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 01:43:37 +1100 Subject: [PATCH 26/78] fix(ui): give diagnostics confirmation ownership of its routes - fix(ui): give diagnostics confirmation ownership of its routes Original commits: c52124a6edfb25d2edb251b9e5cf138c2203ac09 --- qml/pages/SettingsPage.qml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index 00d42c7c..83d8efe5 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -819,7 +819,12 @@ FocusScope { function back() { if (diagnosticsExportVisible) { - diagnosticsExportLoader.item.back() + if (diagnosticsExportLoader.item) + diagnosticsExportLoader.item.back() + else { + diagnosticsExportVisible = false + InputKeys.focus(settingsList) + } return true } if (certificateManagerVisible) { @@ -992,7 +997,8 @@ FocusScope { function activate() { if (diagnosticsExportVisible) { - diagnosticsExportLoader.item.activate() + if (diagnosticsExportLoader.item) + diagnosticsExportLoader.item.activate() return } if (mpvFolderDialogVisible) From c8b0ce934c7565f74ecb5d7063ce080cc371cbb1 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 01:45:31 +1100 Subject: [PATCH 27/78] fix(player): preserve overlay state and viewport-bounded controls - fix(ui): make playback Back and segment actions consistent - fix(ui): reflect the player mute state without changing it - fix(ui): constrain the playback group menu to the viewport Original commits: a4274a5f7f49bcab0d45bb7342b6df6e074431a1 988a2f8d9b47d82b2a98344173e48f0d8bb30ef8 0df128b8b18b9fe206cfd3f34d754e66b9899088 --- qml/pages/PlayerOverlayChrome.qml | 16 ++++++---- qml/pages/PlayerOverlayInput.qml | 12 ++++---- qml/pages/PlayerOverlayPage.qml | 45 ++++++++++++++++++++--------- qml/pages/PlayerQueuePanel.qml | 2 ++ qml/pages/PlayerSkipSegmentCard.qml | 14 +++++---- qml/pages/PlayerTransportBar.qml | 17 +++-------- 6 files changed, 64 insertions(+), 42 deletions(-) diff --git a/qml/pages/PlayerOverlayChrome.qml b/qml/pages/PlayerOverlayChrome.qml index 27e22d43..e3ad105d 100644 --- a/qml/pages/PlayerOverlayChrome.qml +++ b/qml/pages/PlayerOverlayChrome.qml @@ -404,11 +404,17 @@ 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. + maximumHeight: 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 { 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 b3e7c79b..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" @@ -538,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() } @@ -881,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) @@ -947,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 @@ -965,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) @@ -984,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") @@ -1048,6 +1061,10 @@ FocusScope { stopPlayback("overlay-back") return } + if (focusZone === "skip") { + skipActiveSegment() + return + } if (focusZone === "timeline") { if (!commitScrub() && hasPlayer) togglePlayback() @@ -1082,6 +1099,8 @@ FocusScope { } onScrubbingChanged: if (!scrubbing) maybeRestartAutohide() + onHasActiveSegmentChanged: if (!hasActiveSegment && focusZone === "skip") + focusZone = "timeline" Connections { target: syncPlay 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() } } From 84fa04943135875698e1433b0d676f4c6ce9f93b Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 01:54:57 +1100 Subject: [PATCH 28/78] fix(ui): keep detail actions and links usable on narrow screens - fix(ui): keep detail actions and links usable on narrow screens Original commits: b8d3693d726fd7b83595f67bc26b289a10e32913 --- qml/pages/ItemDetailsPage.qml | 59 ++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 3fd1f4e1..b8583902 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -202,7 +202,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 @@ -241,23 +244,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 @@ -267,6 +274,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)) @@ -855,16 +863,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) { @@ -874,6 +875,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 } @@ -881,6 +883,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 @@ -1087,6 +1114,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") { @@ -1479,10 +1508,12 @@ FocusScope { elide: Text.ElideRight } - 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 From 1ec900aea2d612f6378e36d08800ad5e06798a58 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 02:22:13 +1100 Subject: [PATCH 29/78] fix(ui): offer persistent retry for a failed search - fix(ui): offer persistent retry for a failed search Original commits: 0bc907e3e4cf1369a09c2810a21c3391fcde1e69 --- qml/pages/SearchPage.qml | 64 ++++++++++++++++++++++++++++++++++-- src/app/SearchController.cpp | 12 +++++++ src/app/SearchController.h | 9 +++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/qml/pages/SearchPage.qml b/qml/pages/SearchPage.qml index 51a8935f..3b6af56a 100644 --- a/qml/pages/SearchPage.qml +++ b/qml/pages/SearchPage.qml @@ -19,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", @@ -102,6 +103,11 @@ 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) @@ -129,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() @@ -145,6 +155,10 @@ FocusScope { } function activate() { + if (retryButton.activeFocus) { + retrySearch() + return + } if (field.activeFocus && !field.editing) { field.activate() return @@ -167,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 @@ -256,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" @@ -286,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/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; From 3b16c040f45595c204d042da3251f6acd644be95 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 02:23:16 +1100 Subject: [PATCH 30/78] fix(ui): keep updater input modal and honor routed Back - fix(ui): keep updater input modal and honor routed Back Original commits: af370645ab5dc1f8d6529bb9beaee113ab1ef457 --- qml/shell/UpdateDialog.qml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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)) From 6d714989ea28f67cfc5ae6893b0f1801526df652 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 02:39:25 +1100 Subject: [PATCH 31/78] fix(downloads): release acquired plans exactly once across destruction - fix(downloads): release acquired plans exactly once across destruction Original commits: 5464476e3ff46220dbcc1ad411767c7eeb387081 --- src/app/DownloadManager.cpp | 18 ++++++++++++------ src/app/DownloadManager.h | 4 +++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/app/DownloadManager.cpp b/src/app/DownloadManager.cpp index d9c96cf4..eafd2da1 100644 --- a/src/app/DownloadManager.cpp +++ b/src/app/DownloadManager.cpp @@ -133,6 +133,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; @@ -288,10 +291,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 +336,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 +489,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..bdaa6f44 100644 --- a/src/app/DownloadManager.h +++ b/src/app/DownloadManager.h @@ -80,7 +80,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 = {}); From deb7ae8a61273ec8e3a3e351581e790502155f25 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 02:46:10 +1100 Subject: [PATCH 32/78] fix(providers): serialize installs and reauthorize selected actions - fix(providers): serialize installs against pending removal - fix(providers): repeat authorization after action picks Original commits: d83af234b676ad571df21e37f877b6f9429325ea 50ac7f88c87b770919d79a22992e358914d4ed30 --- src/provider/ProviderRegistry.cpp | 12 ++++++++++++ src/provider/ProviderRegistry.h | 3 +++ src/provider/SourceHub.cpp | 25 +++++++++++++++++++------ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index 52529e23..1c16ea55 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -932,6 +932,12 @@ QCoro::Task ProviderRegistry::install( if (root.isEmpty()) throw std::runtime_error("installs_disabled"); const QString id = package.manifest.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); @@ -967,6 +973,12 @@ QCoro::Task ProviderRegistry::install( QCoro::Task ProviderRegistry::uninstall(QString 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); ++m_installRevisions[moduleId]; const ProviderModule *existing = module(moduleId); diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 5594eac6..5e32494c 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -280,6 +280,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; diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp index d5c30df6..fe0a22eb 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -962,6 +962,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")); @@ -970,12 +977,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"))) { @@ -986,6 +988,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) From f695444fc6fb356ce3eedc9185c35a3c83dcb28f Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 02:55:02 +1100 Subject: [PATCH 33/78] refactor(sdk): keep sockets off the operation host contract - refactor(sdk): keep sockets off the operation host contract Original commits: a9d7fe2b13cec2030e1192281d07439d437e1a9c --- sdk/provider.d.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts index 91c8744a..06bc44c4 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). */ From ea5b24bb59a9081d4e801ea56a2341747cdafe66 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 05:09:40 +1100 Subject: [PATCH 34/78] fix: repair terminal suite failures from the integration - fix: repair terminal suite failures from the integration Original commits: 77a5a9c0ba072f9173a08d807fcfc857ff2bdcb4 --- qml/pages/PlayerOverlayChrome.qml | 9 +++++---- qml/shell/GroupMenu.qml | 15 +++++++-------- qml/shell/RememberedCertificatesDialog.qml | 5 +++-- qml/shell/TlsTrustDialog.qml | 5 +++-- tests/e2e/JellyfinFixture.cpp | 4 +++- tests/qml/tst_RoutePolicy.qml | 7 ++++++- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/qml/pages/PlayerOverlayChrome.qml b/qml/pages/PlayerOverlayChrome.qml index e3ad105d..893b37c2 100644 --- a/qml/pages/PlayerOverlayChrome.qml +++ b/qml/pages/PlayerOverlayChrome.qml @@ -408,16 +408,17 @@ Item { 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. - maximumHeight: Math.min(Metrics.scaled(520), spaceAboveHud >= Metrics.controlHeightPx * 2 ? spaceAboveHud : Math.max(0, - root.height - - edgeMargin - * 2)) + 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/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/RememberedCertificatesDialog.qml b/qml/shell/RememberedCertificatesDialog.qml index 336a8c5e..15e5afcf 100644 --- a/qml/shell/RememberedCertificatesDialog.qml +++ b/qml/shell/RememberedCertificatesDialog.qml @@ -14,9 +14,10 @@ FocusScope { function routeKey(key, phase, repeat) { if (root.inputKeys.isBack(key, false, false)) { - if (phase === "press" && !repeat) + // Back claims both phases; only the release dismisses. + if (phase === "release") dismissed() - return phase === "press" + return true } if (!root.inputKeys.isDirection(key)) return true diff --git a/qml/shell/TlsTrustDialog.qml b/qml/shell/TlsTrustDialog.qml index b5085400..4fbd9162 100644 --- a/qml/shell/TlsTrustDialog.qml +++ b/qml/shell/TlsTrustDialog.qml @@ -14,9 +14,10 @@ FocusScope { function routeKey(key, phase, repeat) { if (root.inputKeys.isBack(key, false, false)) { - if (phase === "press" && !repeat) + // Back claims both phases; only the release dismisses. + if (phase === "release") root.trustController.cancel() - return phase === "press" + return true } if (!root.inputKeys.isDirection(key)) return true diff --git a/tests/e2e/JellyfinFixture.cpp b/tests/e2e/JellyfinFixture.cpp index 4a2830ee..2fb8c937 100644 --- a/tests/e2e/JellyfinFixture.cpp +++ b/tests/e2e/JellyfinFixture.cpp @@ -353,7 +353,9 @@ void JellyfinFixture::handle(QTcpSocket *socket, const QByteArray& header, const && (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 == "/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 {} } }); 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() { From 0e8fb48233cf1968b4bc63edaa1ca3c1089dfe59 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 05:14:03 +1100 Subject: [PATCH 35/78] perf(downloads): snapshot status inventories per change - perf(downloads): snapshot status inventories per change Original commits: f9463231449ca8959065d40dadbececd75bf52d5 --- src/app/DownloadManager.cpp | 14 ++++++++++++++ src/app/DownloadManager.h | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/src/app/DownloadManager.cpp b/src/app/DownloadManager.cpp index eafd2da1..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; @@ -158,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) { @@ -168,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); @@ -180,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) diff --git a/src/app/DownloadManager.h b/src/app/DownloadManager.h index bdaa6f44..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 { @@ -97,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 From 49883646b8006040fc2b6740702e2ac5b1deb1e9 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 05:32:13 +1100 Subject: [PATCH 36/78] test: integrate lifecycle, row fixtures and current native contracts - test: register the mpv lifecycle regression and row-stack fixture - fix: align terminal unit contracts with the imported behavior Original commits: 40de096afdb703e50e1f654f6f11dc0a01b2b2d7 4d9dbfdfb074b31f8f2a2a3eddbe8e8520477275 --- CMakeLists.txt | 27 +++ src/app/RemoteTargetsController.cpp | 11 +- src/app/RemoteTargetsController.h | 7 +- src/provider/ProviderRegistry.cpp | 10 +- tests/app/ContentModelControllerTest.cpp | 7 + .../SystemPerformanceMonitorTest.cpp | 4 + tests/player/MpvLifecycleTest.cpp | 229 ++++++++++++++++++ tests/qml/tst_RowStackView.qml | 91 +++++++ 8 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 tests/player/MpvLifecycleTest.cpp create mode 100644 tests/qml/tst_RowStackView.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 196e39ff..1792f9cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1173,6 +1173,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 @@ -1259,6 +1260,32 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) 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") set_tests_properties(gui-e2e-native PROPERTIES LABELS e2e ENVIRONMENT "${SPOOL_TEST_ENV}" TIMEOUT 0) + # 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) diff --git a/src/app/RemoteTargetsController.cpp b/src/app/RemoteTargetsController.cpp index de8ed1d1..163d8945 100644 --- a/src/app/RemoteTargetsController.cpp +++ b/src/app/RemoteTargetsController.cpp @@ -643,14 +643,11 @@ void RemoteTargetsController::beginOptimistic(const QVariantMap& command) const auto name = command.value("action").toString(); m_ackSequence.reset(); if (name == "pause" || name == "unpause" || name == "stop") { - // Capture the running clock before the transport changes state: a + // Capture the current position before the transport changes state: a // stale poll must neither rewind it nor restart an optimistic - // unpause's clock, and playback advances even from a zero position. - if (!m_position.isEmpty()) { - const QString captured = QString::number(predictedPositionTicks()); - m_optimistic.insert("positionTicks", captured); - m_position = captured; - } + // 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") m_optimistic.insert("positionTicks", command.value("positionTicks")); diff --git a/src/app/RemoteTargetsController.h b/src/app/RemoteTargetsController.h index ea4be603..a8360b50 100644 --- a/src/app/RemoteTargetsController.h +++ b/src/app/RemoteTargetsController.h @@ -64,12 +64,13 @@ class RemoteTargetsController final : public QObject { return m_target; } QVariantMap state() const; - // The running clock: the last accepted position advanced by elapsed time - // while the target plays. Zero is a real position, not a frozen clock. + // 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.isEmpty() ? m_position : QString::number(predictedPositionTicks()); + return m_position; } QString runtimeTicks() const { diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index 1c16ea55..5410afe1 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -932,6 +932,10 @@ 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()); @@ -943,7 +947,6 @@ QCoro::Task ProviderRegistry::install( 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 @@ -973,6 +976,10 @@ QCoro::Task ProviderRegistry::install( QCoro::Task ProviderRegistry::uninstall(QString 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()); @@ -980,7 +987,6 @@ QCoro::Task ProviderRegistry::uninstall(QString moduleId) if (previous.isValid()) co_await qCoro(previous).result(); clearGrants(moduleId); - ++m_installRevisions[moduleId]; const ProviderModule *existing = module(moduleId); if (!existing || (existing->bundled && !existing->overridesBundled) || existing->native) co_return; diff --git a/tests/app/ContentModelControllerTest.cpp b/tests/app/ContentModelControllerTest.cpp index c8de07bb..c036882c 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); 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/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/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) + } +} From 613895844f5c271354bc402063577d95f93074b6 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 09:05:19 +1100 Subject: [PATCH 37/78] fix(qml): guard card role reads during delegate construction - fix(qml): guard card role reads during delegate construction Original commits: 3fee68e19b25b34e1cc8f3d6be18b8a1ed4653b3 --- qml/primitives/MediaRow.qml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qml/primitives/MediaRow.qml b/qml/primitives/MediaRow.qml index ed677a65..43403f85 100644 --- a/qml/primitives/MediaRow.qml +++ b/qml/primitives/MediaRow.qml @@ -408,8 +408,11 @@ FocusScope { // directly so one changed row does not re-fetch every card. readonly property var cardData: modelData || ({}) readonly property var cardItem: { - const itemRole = cardData.item - return libraryCard ? (itemRole || ({})) : itemRole !== undefined ? itemRole : cardData + // 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" From 617feb2af7a05426e2b639597990568e923a91b1 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 15:35:36 +1100 Subject: [PATCH 38/78] feat(settings): simplify search, Home provider filters and dropdowns - fix(qml): make text fields legible against the page and panels - feat(settings): replace the search button with an always-visible field - feat(home): turn providers on and off for Home, and modernise dropdowns Original commits: ab5382e203575b53ec2d013525bda337a46983f5 2d8e38c0ae44cfa523948fa55b82811c71ce3571 be6f3feb676a9e46ee1959c5efe890476a5476f5 --- CMakeLists.txt | 2 + qml/pages/HomePage.qml | 159 +++---------- qml/pages/HomeProviderPicker.qml | 308 ++++++++++++++++++++++++++ qml/pages/SettingsPage.qml | 178 +++++++++------ qml/pages/SubtitleSettingsPanel.qml | 1 + qml/primitives/OptionPickerDialog.qml | 74 ++++++- qml/primitives/SelectRow.qml | 52 +++-- qml/primitives/SliderRow.qml | 7 +- qml/primitives/SwitchIndicator.qml | 40 ++++ qml/primitives/TextFieldRow.qml | 36 ++- qml/primitives/ToggleRow.qml | 33 +-- qml/theme/Theme.qml | 9 + src/app/HomeModelController.cpp | 83 +++++-- src/app/HomeModelController.h | 23 +- src/app/SettingsSchema.cpp | 7 +- src/provider/SourceHub.cpp | 70 +++--- src/provider/SourceHub.h | 4 +- tests/providers/SourceHubTest.cpp | 10 +- tests/qml/tst_OptionPickerInput.qml | 6 +- 19 files changed, 788 insertions(+), 314 deletions(-) create mode 100644 qml/pages/HomeProviderPicker.qml create mode 100644 qml/primitives/SwitchIndicator.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 1792f9cc..84f9c7d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -568,6 +568,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 @@ -637,6 +638,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 diff --git a/qml/pages/HomePage.qml b/qml/pages/HomePage.qml index 84b0c9ff..51d784cb 100644 --- a/qml/pages/HomePage.qml +++ b/qml/pages/HomePage.qml @@ -19,14 +19,19 @@ FocusScope { 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 @@ -39,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 || ""))) } @@ -64,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 @@ -204,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) @@ -245,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) { @@ -258,7 +215,7 @@ FocusScope { return } if (allProvidersButton.activeFocus) { - Home.selectProvider("") + Home.showAllProviders() return } if (!rows.activeFocus) { @@ -322,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 { @@ -369,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/SettingsPage.qml b/qml/pages/SettingsPage.qml index 83d8efe5..5d4576ba 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) { @@ -281,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, @@ -320,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)", @@ -510,6 +524,8 @@ FocusScope { "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" @@ -572,6 +588,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") @@ -580,10 +602,6 @@ 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) @@ -605,10 +623,6 @@ FocusScope { } 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") @@ -689,9 +703,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, "") @@ -739,7 +751,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() @@ -817,7 +832,19 @@ 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) { if (diagnosticsExportLoader.item) diagnosticsExportLoader.item.back() @@ -873,33 +900,44 @@ 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" } - 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" && shell) { + Qt.inputMethod.hide() + shell.focusNavBar() + return true + } + if (action === "left" && !searchField.editing && backButton.visible) { + InputKeys.focus(backButton) + return true + } return false } if (InputKeys.isTextInputItem(focused) && navigationMode !== "value-editing") @@ -974,6 +1012,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) @@ -996,6 +1039,11 @@ FocusScope { } function activate() { + if (homeProvidersVisible) { + if (homeProvidersLoader.item) + homeProvidersLoader.item.activate() + return + } if (diagnosticsExportVisible) { if (diagnosticsExportLoader.item) diagnosticsExportLoader.item.activate() @@ -1115,30 +1163,27 @@ FocusScope { id: backButton text: "Back" visible: root.pageMode !== "index" - width: Math.min(Metrics.scaled(100), Math.max(0, (parent.width - parent.spacing) / 2)) + width: visible ? Math.min(Metrics.scaled(100), Math.max(0, (parent.width - parent.spacing) / 2)) : 0 + height: searchField.height 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() - } - } - 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) - } - onAccepted: { - Qt.inputMethod.hide() - InputKeys.focus(settingsList) + TextFieldRow { + id: searchField + width: Math.max(0, parent.width - (backButton.visible ? backButton.width + parent.spacing : 0)) + 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) + } } } SecondaryText { @@ -1181,12 +1226,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 @@ -1310,6 +1350,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) } } @@ -1521,6 +1562,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 04527050..1549174d 100644 --- a/qml/pages/SubtitleSettingsPanel.qml +++ b/qml/pages/SubtitleSettingsPanel.qml @@ -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/OptionPickerDialog.qml b/qml/primitives/OptionPickerDialog.qml index b6b13d09..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 @@ -122,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" @@ -129,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() @@ -148,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 85a13ef2..b972a4f2 100644 --- a/qml/primitives/SelectRow.qml +++ b/qml/primitives/SelectRow.qml @@ -7,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))]) : "" @@ -27,52 +29,68 @@ SettingRow { onClicked: opened() trailing: Rectangle { - readonly property int sidePadding: Metrics.scaled(16) + id: field + readonly property int sidePadding: Metrics.scaled(14) Layout.alignment: Qt.AlignVCenter - // Hug the value instead of always reserving the widest pill: short + // 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(120), Math.min(valueLabel.implicitWidth - + dropdownIcon.width + Layout.preferredWidth: Math.min(root.availableWidth * 0.5, Math.max(Metrics.scaled(140), Math.min(valueLabel.implicitWidth + + chevron.width + sidePadding * 2 + Metrics.scaled( - 10), Math.max( + 12), Math.max( Metrics.scaled( 180), root.width * 0.46)))) - Layout.preferredHeight: 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.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/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/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/src/app/HomeModelController.cpp b/src/app/HomeModelController.cpp index 45066955..b34ddccc 100644 --- a/src/app/HomeModelController.cpp +++ b/src/app/HomeModelController.cpp @@ -161,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( @@ -179,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::showAllProviders() +{ + storeHiddenProviderIds({}); } -void HomeModelController::selectProvider(const QString& moduleId) +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(); } @@ -215,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. diff --git a/src/app/HomeModelController.h b/src/app/HomeModelController.h index c3c55fcc..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; diff --git a/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index 1abf0a87..d79f3100 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -572,9 +572,10 @@ const QVector& settingSpecs() selectSpec("providers/updates", "Accounts", "Provider updates", "", "ask", kProviderUpdateChoices, SettingTarget::External), 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), diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp index fe0a22eb..8e94eebc 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 { @@ -704,47 +712,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() @@ -755,9 +773,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); } } @@ -768,7 +784,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; } @@ -780,7 +796,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 diff --git a/src/provider/SourceHub.h b/src/provider/SourceHub.h index 74d216c3..c67f70a9 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); 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_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) From 152ae0f0d1ead60a0324f4cdf786741a70b1e822 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 15:55:05 +1100 Subject: [PATCH 39/78] fix(details): keep what is known on screen while details load - fix(details): keep what is known on screen while details load Original commits: d7e0017ebb3a53e8ee4d6e0f90a95093cf1f0aef --- qml/pages/ItemDetailsPage.qml | 99 +++++++++++++++++++++++++++--- qml/shell/AppShell.qml | 5 +- qml/shell/RoutePolicy.js | 39 ++++++++---- src/app/ContentModelController.cpp | 26 +++++++- src/app/ContentModelController.h | 10 +++ 5 files changed, 152 insertions(+), 27 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index b8583902..9ea705c9 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -30,9 +30,18 @@ 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 usually take, so they fill in rather than push the page. + readonly property bool detailsPending: Content.detailItemBusy && !fullDetailItem.movieId + readonly property bool videoDetail: typeText === "Movie" || typeText === "Series" || typeText === "Episode" + || typeText === "Season" readonly property string seasonIdText: typeText === "Season" ? String(item.movieId || "") : String(item.seasonId || "") @@ -117,10 +126,10 @@ 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 && (typeText === "Movie" || typeText === "Series" || typeText + === "Episode") readonly property bool showPeopleRow: people.length > 0 || reservePeopleRow readonly property var genreList: fullDetailItem.genres && fullDetailItem.genres.length > 0 ? fullDetailItem.genres : (item.genres || []) @@ -548,7 +557,15 @@ FocusScope { return item } + // 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: "" onRouteItemChanged: { + const id = String(routeItem.movieId || "") + if (id === enteredItemId) + return + enteredItemId = id seasonPickerOpen = false overflowOpen = false if (routeActive) @@ -1042,7 +1059,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) { @@ -1498,8 +1518,8 @@ FocusScope { AppText { Layout.fillWidth: true Layout.topMargin: root.compactEpisodicDetail ? 2 : 8 - visible: Boolean(root.item.overview && root.item.overview.length > 0) - text: root.item.overview || "" + visible: root.overviewText.length > 0 + text: root.overviewText color: Theme.textSecondary wrapMode: Text.Wrap font.pixelSize: Metrics.bodySizePx + 1 @@ -1508,6 +1528,28 @@ FocusScope { elide: Text.ElideRight } + Column { + id: overviewPlaceholder + Layout.fillWidth: true + Layout.topMargin: root.compactEpisodicDetail ? 2 : 8 + visible: root.detailsPending && root.videoDetail && root.overviewText.length === 0 + spacing: Math.round((Metrics.bodySizePx + 1) * 0.5) + Accessible.ignored: true + + Repeater { + model: root.compactEpisodicDetail ? 2 : 3 + delegate: Rectangle { + required property int index + width: overviewPlaceholder.width * (index === (root.compactEpisodicDetail ? 1 : 2) + ? 0.62 : 0.96) + height: Math.round((Metrics.bodySizePx + 1) * 0.72) + radius: Theme.radiusSmall + color: Theme.bgHover + opacity: 0.8 + } + } + } + Flow { id: actionRow Layout.fillWidth: true @@ -1576,6 +1618,45 @@ FocusScope { font.pixelSize: Metrics.metaSizePx } + Surface { + id: metadataPlaceholder + Layout.fillWidth: true + Layout.topMargin: root.compactEpisodicDetail ? 12 : 28 + // One credit row for an episode or season, a typical + // three for a film or show. + readonly property int rowCount: root.compactEpisodicDetail ? 1 : 3 + readonly property int rowHeight: Metrics.metaSizePx + 13 + implicitHeight: rowCount * rowHeight + (rowCount - 1) * 12 + 34 + visible: root.detailsPending && root.videoDetail && root.metadataRows.length === 0 + baseColor: Theme.floatingPanel + radius: Theme.radiusPanel + Accessible.ignored: true + + 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 Layout.fillWidth: true diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index d62eb181..7ed9a495 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -604,11 +604,10 @@ 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) - } function openSeriesDetails(seriesId, seriesName, returnRoute) { diff --git a/qml/shell/RoutePolicy.js b/qml/shell/RoutePolicy.js index 7b2d21be..f45d04ac 100644 --- a/qml/shell/RoutePolicy.js +++ b/qml/shell/RoutePolicy.js @@ -48,17 +48,27 @@ 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) - 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 - } + 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: index, @@ -87,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 } } @@ -110,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({ @@ -118,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/src/app/ContentModelController.cpp b/src/app/ContentModelController.cpp index 45df8219..40ee7aaf 100644 --- a/src/app/ContentModelController.cpp +++ b/src/app/ContentModelController.cpp @@ -209,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)); }); @@ -411,6 +427,8 @@ 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); @@ -458,6 +476,8 @@ void ContentModelController::reset() 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 603bf792..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 @@ -27,6 +28,7 @@ class ContentModelController final : public QObject { 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) @@ -62,6 +64,10 @@ class ContentModelController final : public QObject { { return m_detailRowsBusy; } + bool detailItemBusy() const + { + return m_detailItemBusy; + } int detailContextInitialIndex() const { return m_detailContextInitialIndex; @@ -118,6 +124,10 @@ class ContentModelController final : public QObject { 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; From 20db764619ed6471ab755e322dfe8670791875d3 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 15:55:08 +1100 Subject: [PATCH 40/78] fix(diagnostics): leave out figures that are not being measured - fix(diagnostics): leave out figures that are not being measured Original commits: 2e6a7a3dc9ed56a01870b4e567bd6e7280ed1251 --- qml/shell/DiagnosticsOverlay.qml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/qml/shell/DiagnosticsOverlay.qml b/qml/shell/DiagnosticsOverlay.qml index b30852b4..27985a3a 100644 --- a/qml/shell/DiagnosticsOverlay.qml +++ b/qml/shell/DiagnosticsOverlay.qml @@ -12,6 +12,10 @@ 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) @@ -77,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 } @@ -92,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 { @@ -109,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 } @@ -145,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 : From 078f6848fdadf14d207b261032d2c3746f4ebb86 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 16:09:48 +1100 Subject: [PATCH 41/78] fix(qml): rebuild a recycled row's cards when its model kind changes - fix(qml): rebuild a recycled row's cards when its model kind changes Original commits: 5a500f37d940b571473d10b4f544c457b8c76e8c --- qml/primitives/MediaRow.qml | 53 +++++++++++++++++++++++----- tests/qml/tst_MediaRow.qml | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/qml/primitives/MediaRow.qml b/qml/primitives/MediaRow.qml index 43403f85..d2fef089 100644 --- a/qml/primitives/MediaRow.qml +++ b/qml/primitives/MediaRow.qml @@ -79,9 +79,37 @@ FocusScope { 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: { + bindModel() if (!publishingMove && (moveMode || dragActive)) finishMove() if (!publishingMove) @@ -416,23 +444,32 @@ FocusScope { } 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 @@ -605,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/tests/qml/tst_MediaRow.qml b/tests/qml/tst_MediaRow.qml index d6e361f8..e73c3565 100644 --- a/tests/qml/tst_MediaRow.qml +++ b/tests/qml/tst_MediaRow.qml @@ -71,6 +71,45 @@ 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 @@ -169,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) From ae932b918c75fe625e42540bd8014e6b428e5841 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 16:09:52 +1100 Subject: [PATCH 42/78] fix(local): offer no library from the offline source until something is downloaded - fix(local): offer no library from the offline source until something is downloaded Original commits: 07ea021890edb9201282ff2f2ae1782e5f592e53 --- src/providers/local/LocalProvider.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/providers/local/LocalProvider.cpp b/src/providers/local/LocalProvider.cpp index 0abc5917..42668464 100644 --- a/src/providers/local/LocalProvider.cpp +++ b/src/providers/local/LocalProvider.cpp @@ -405,6 +405,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; From 05fa005d524c4fd4bf30084a49a5fa4939255756 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 16:09:52 +1100 Subject: [PATCH 43/78] test: disable the QML disk cache in the test binaries themselves - test: disable the QML disk cache in the test binaries themselves Original commits: 3354ac038662d30fb0ebc6f63ad04a14eaed8277 --- tests/TestRunner.cpp | 5 +++++ tests/platform/MobileTestFixtures.cpp | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/TestRunner.cpp b/tests/TestRunner.cpp index 3f3f654d..ab01d8d8 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)) 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; } From 7aad9d6b288c8b327a38a5cf7c4ffbc0cc88494f Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 17:30:48 +1100 Subject: [PATCH 44/78] fix(linux): open the system file chooser through the desktop portal - fix(linux): open the system file chooser through the desktop portal Original commits: a807bd142c0f6ae9fed59fcb286254cca2d415a1 --- src/platform/linux/LinuxPlatformStartup.cpp | 6 ++++++ tools/package-appimage.sh | 1 + 2 files changed, 7 insertions(+) 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/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 \ From b6739163d72c463a953dd90b8aa7270f9be66a03 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 17:30:58 +1100 Subject: [PATCH 45/78] fix(shell): refine settings navigation, spacing and QML bindings - fix(qml): clear the warnings logged on details, settings and item menus - fix(shell): space the top bar's right-hand icons like the left - feat(settings): replace the Back button with a back arrow by the title Original commits: 00e74d32bae3580c68ac04ce6660c6fd8de45ad7 900446c027c918ad7cef9c8b35196f094062ed7c bbfbd924ae64fe83443e6bfd5f302ddc1788b4c6 --- qml/pages/ItemDetailsPage.qml | 2 +- qml/pages/SettingsPage.qml | 89 +++++++++++++++++------------------ qml/shell/AppShell.qml | 6 +-- qml/shell/ItemContextMenu.qml | 4 +- qml/shell/TopBar.qml | 13 +++++ 5 files changed, 62 insertions(+), 52 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 9ea705c9..f2a416b1 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -185,7 +185,7 @@ FocusScope { root.focusOverflow(root.overflowIndex) }) } - function onExtensionSupportChanged(accountId) { + function onCapabilitySupportChanged(accountId) { if (root.overflowOpen) root.loadProviderActions() } diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index 5d4576ba..0a328f26 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -904,10 +904,6 @@ FocusScope { if (action === "activate") return back() if (action === "down") { - InputKeys.focus(settingsList) - return true - } - if (action === "right") { searchField.focusRow() return true } @@ -915,7 +911,7 @@ FocusScope { shell.focusNavBar() return true } - return action === "left" + return action === "left" || action === "right" } if (searchField.activeFocus || searchField.editing) { // Resting on the row (TV): Select opens the keyboard. Typing in @@ -929,13 +925,12 @@ FocusScope { InputKeys.focus(settingsList) return true } - if (action === "up" && shell) { + if (action === "up") { Qt.inputMethod.hide() - shell.focusNavBar() - return true - } - if (action === "left" && !searchField.editing && backButton.visible) { - InputKeys.focus(backButton) + if (backButton.visible) + InputKeys.focus(backButton) + else if (shell) + shell.focusNavBar() return true } return false @@ -1148,42 +1143,44 @@ 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: visible ? Math.min(Metrics.scaled(100), Math.max(0, (parent.width - parent.spacing) / 2)) : 0 - height: searchField.height + iconName: "arrow_back" + accessibleName: "Back" onClicked: root.back() } - TextFieldRow { - id: searchField - width: Math.max(0, parent.width - (backButton.visible ? backButton.width + parent.spacing : 0)) - 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) - } + 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 + 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) } } SecondaryText { @@ -1283,7 +1280,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 @@ -1292,10 +1289,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 diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 7ed9a495..6fd2633d 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -1111,7 +1111,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 @@ -1145,9 +1145,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" diff --git a/qml/shell/ItemContextMenu.qml b/qml/shell/ItemContextMenu.qml index a3bc5829..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() } 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 { From 5b27ed6a583dd8f4079120eedf657652979d797a Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 17:31:27 +1100 Subject: [PATCH 46/78] feat(spoolet): capture page loads and detect visual instability - feat(spoolet): capture page loads frame by frame, open items, read item state - feat(tools): find flashes and layout jumps in page loads automatically Original commits: 5e782f19027eafad93e7ad58c17d45fa1ccb5cd1 e6cd334f94a4addfae878b56db8dffdb289485be --- qml/Main.qml | 1 + qml/shell/AppShell.qml | 24 +++ src/automation/LocalCommandServer.cpp | 169 +++++++++++++++++++ src/automation/LocalCommandServer.h | 22 +++ src/automation/SpooletMain.cpp | 53 +++++- tools/flash-report.py | 228 ++++++++++++++++++++++++++ tools/flash-sweep.py | 152 +++++++++++++++++ 7 files changed, 648 insertions(+), 1 deletion(-) create mode 100755 tools/flash-report.py create mode 100755 tools/flash-sweep.py 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/shell/AppShell.qml b/qml/shell/AppShell.qml index 6fd2633d..e9cbb09e 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -610,6 +610,30 @@ KeyRouter { index) } + // 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) { const id = String(seriesId || "") if (id.length <= 0) diff --git a/src/automation/LocalCommandServer.cpp b/src/automation/LocalCommandServer.cpp index a34c5cfb..9822cf02 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); @@ -396,6 +413,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 +770,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")); diff --git a/src/automation/LocalCommandServer.h b/src/automation/LocalCommandServer.h index 78cdb3a4..e7bdeadf 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; @@ -39,6 +41,10 @@ class LocalCommandServer final : public QObject { QJsonObject state() 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 +61,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/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() From e8d2fcf213ec7b9ec5d8bcdf708d31ebb09fb821 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 17:31:44 +1100 Subject: [PATCH 47/78] fix(details): hold the space details will take, and keep it - fix(details): hold the space details will take, and keep it Original commits: 6e45c9cd8c1fdfdb4332613416f9d3b1e71f1c7c --- qml/pages/ItemDetailsPage.qml | 303 +++++++++++++++++++++++++--------- qml/shell/AppShell.qml | 25 +++ 2 files changed, 253 insertions(+), 75 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index f2a416b1..7f8c8a52 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -38,10 +38,21 @@ FocusScope { 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 usually take, so they fill in rather than push the page. + // and credits will take, so they fill in rather than push the page. readonly property bool detailsPending: Content.detailItemBusy && !fullDetailItem.movieId - readonly property bool videoDetail: typeText === "Movie" || typeText === "Series" || typeText === "Episode" - || typeText === "Season" + 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 || "") @@ -128,14 +139,15 @@ FocusScope { readonly property var people: metadataPeople // Seasons rarely carry their own cast, so reserving a row for one only // drew a heading that then vanished. - readonly property bool reservePeopleRow: detailsPending && (typeText === "Movie" || typeText === "Series" || typeText - === "Episode") + 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 @@ -432,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() @@ -525,6 +537,53 @@ FocusScope { 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() @@ -557,15 +616,38 @@ FocusScope { 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) @@ -680,6 +762,10 @@ FocusScope { 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) } @@ -1389,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 { @@ -1515,37 +1618,68 @@ 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: 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 - } + 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 + } - Column { - id: overviewPlaceholder - Layout.fillWidth: true - Layout.topMargin: root.compactEpisodicDetail ? 2 : 8 - visible: root.detailsPending && root.videoDetail && root.overviewText.length === 0 - spacing: Math.round((Metrics.bodySizePx + 1) * 0.5) - Accessible.ignored: true - - Repeater { - model: root.compactEpisodicDetail ? 2 : 3 - delegate: Rectangle { - required property int index - width: overviewPlaceholder.width * (index === (root.compactEpisodicDetail ? 1 : 2) - ? 0.62 : 0.96) - height: Math.round((Metrics.bodySizePx + 1) * 0.72) - radius: Theme.radiusSmall - color: Theme.bgHover - opacity: 0.8 + // 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 + } } } } @@ -1618,53 +1752,72 @@ FocusScope { font.pixelSize: Metrics.metaSizePx } - Surface { - id: metadataPlaceholder + // 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 - // One credit row for an episode or season, a typical - // three for a film or show. - readonly property int rowCount: root.compactEpisodicDetail ? 1 : 3 - readonly property int rowHeight: Metrics.metaSizePx + 13 - implicitHeight: rowCount * rowHeight + (rowCount - 1) * 12 + 34 - visible: root.detailsPending && root.videoDetail && root.metadataRows.length === 0 - baseColor: Theme.floatingPanel - radius: Theme.radiusPanel - Accessible.ignored: true - - 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 + 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 - 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() + 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() + } } } diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index e9cbb09e..865fc226 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" @@ -610,6 +611,30 @@ KeyRouter { 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) { From fe0ded40240c290558b49bf2df78a9a6e9effd2c Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 17:31:46 +1100 Subject: [PATCH 48/78] test: keep GUI selectors off the developer's desktop however they start - test: keep GUI selectors off the developer's desktop however they start Original commits: 38e800cf8996b9b2cc9b4fbce3a06bd40e9dd7ae --- CMakeLists.txt | 12 ++++++++++-- tests/TestRunner.cpp | 10 ++++++++++ tools/test-gpu-session.sh | 5 ++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 84f9c7d8..5419f0d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1155,7 +1155,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) @@ -1260,7 +1260,15 @@ 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) # Exercise the event-thread lifecycle with a controllable client API. # Keep this separate from the core targets so the fake symbols cannot diff --git a/tests/TestRunner.cpp b/tests/TestRunner.cpp index ab01d8d8..e800333b 100644 --- a/tests/TestRunner.cpp +++ b/tests/TestRunner.cpp @@ -648,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/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" From 7969b369fa79238a01c30f2658c1fd2ada00dc1e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 17:31:47 +1100 Subject: [PATCH 49/78] docs(sdk): say what a zero resume position means - docs(sdk): say what a zero resume position means Original commits: 03dfb208ea0d32410c1b8181201a88ffa29c672d --- sdk/provider.d.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts index 06bc44c4..6a78303c 100644 --- a/sdk/provider.d.ts +++ b/sdk/provider.d.ts @@ -417,7 +417,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. */ From 31fe6bc250db16bedd9cb293d45cf66101e7c5a1 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 20:26:50 +1100 Subject: [PATCH 50/78] fix(library): keep remote focus usable after clearing filters - fix(library): keep remote focus usable after clearing filters Original commits: acabad4c1186bbec968b22d5e2e99119cd5d5b43 --- qml/pages/LibraryGridPage.qml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index 41348d16..7e38736d 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -682,6 +682,12 @@ FocusScope { filterEntries = buildFilterEntries() } + function clearFilters() { + savedIndex = 0 + gridReveal.reset() + Browse.clearFilters() + } + function focusToolbar() { InputKeys.focus(libraryButton) } @@ -905,7 +911,7 @@ FocusScope { else if (filterButton.activeFocus) openFilterMenu() else if (clearFiltersButton.activeFocus) - Browse.clearFilters() + clearFilters() else grid.activate() } @@ -1066,11 +1072,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() } } @@ -1689,9 +1697,6 @@ FocusScope { onCurrentIndexChanged: root.filterIndex = currentIndex onDismissed: root.closeMenus() onAccepted: entry => root.activateFilterEntry(entry) - onReset: { - root.savedIndex = 0 - Browse.clearFilters() - } + onReset: root.clearFilters() } } From 4c1c4accf597ed75f148d3bb638edc43487eef30 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 20:26:52 +1100 Subject: [PATCH 51/78] feat(spoolet): report where keyboard focus is in status - feat(spoolet): report where keyboard focus is in status Original commits: a72d53114c0738f747fb138244a9985ad87534ce --- src/automation/LocalCommandServer.cpp | 19 +++++++++++++++++++ src/automation/LocalCommandServer.h | 1 + 2 files changed, 20 insertions(+) diff --git a/src/automation/LocalCommandServer.cpp b/src/automation/LocalCommandServer.cpp index 9822cf02..ac43361a 100644 --- a/src/automation/LocalCommandServer.cpp +++ b/src/automation/LocalCommandServer.cpp @@ -310,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(); @@ -321,6 +339,7 @@ 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("playback"), QJsonObject { { QStringLiteral("active"), player->sessionActive() }, diff --git a/src/automation/LocalCommandServer.h b/src/automation/LocalCommandServer.h index e7bdeadf..5f502b2f 100644 --- a/src/automation/LocalCommandServer.h +++ b/src/automation/LocalCommandServer.h @@ -39,6 +39,7 @@ 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); From c3a4d35595f0612b732b83c41a3ef6ee732fa153 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 21:34:18 +1100 Subject: [PATCH 52/78] fix(menus): keep the remote's row when a menu's entries are rebuilt - fix(menus): keep the remote's row when a menu's entries are rebuilt Original commits: afb04cbcf296a78bf1a1215fd478d7ca97f254da --- qml/pages/ItemDetailsPage.qml | 2 +- qml/pages/LibraryGridPage.qml | 6 +++--- qml/primitives/LazyMenuPanel.qml | 31 ++++++++++++++++++++++++++++--- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 7f8c8a52..c36a174f 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -2169,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 7e38736d..98fbe49e 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -1649,7 +1649,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) } @@ -1672,7 +1672,7 @@ FocusScope { entry && entry.value === root.currentSortBy() } - onCurrentIndexChanged: root.sortIndex = currentIndex + onMoved: index => root.sortIndex = index onDismissed: root.closeMenus() onAccepted: entry => root.activateSortEntry(entry) } @@ -1694,7 +1694,7 @@ 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.clearFilters() 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) From 7f69e22ec39bad6ec92900098f6eb7ff1b5ddcdb Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 11 Oct 2026 21:34:22 +1100 Subject: [PATCH 53/78] fix(webos): link the SVG image reader so Jellyfin's mark renders - fix(webos): link the SVG image reader so Jellyfin's mark renders Original commits: a889d9b37e2aa87ca47bd32d0209b592acd4b96c --- cmake/platform/WebOS.cmake | 2 ++ 1 file changed, 2 insertions(+) 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() From a02f42cef37174d2e659b0b7912f6a59ac0946b1 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 18 Oct 2026 00:06:04 +1100 Subject: [PATCH 54/78] feat(profiling): capture reproducible isolated route measurements --- docs/debug/memory-profiling.md | 155 ++++++++++++-- tests/tools/RenderBenchmarkReportTest.py | 36 +++- tools/compare-render-benchmark.py | 93 ++++++--- tools/profile-native.py | 251 +++++++++++++++++++++++ 4 files changed, 492 insertions(+), 43 deletions(-) create mode 100755 tools/profile-native.py 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/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/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/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()) From fce45a3da4854742b5574db90a26113c30bd3820 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 18 Oct 2026 00:07:01 +1100 Subject: [PATCH 55/78] fix(webos): make packaging and device diagnostics explicit and bounded --- AGENTS.md | 2 +- CMakeLists.txt | 4 +- build-ipk.sh | 71 +++-- docs/debug/lifecycle-diagnostics.md | 54 +++- tests/tools/WebosToolingTest.py | 245 +++++++++++++++ tools/webos-native/build-curl.sh | 13 +- tools/webos-native/build-ffmpeg.sh | 7 + tools/webos-native/build-qcoro.sh | 13 +- tools/webos-native/build-third-party.sh | 13 +- tools/webos-native/build-webos.sh | 53 ++-- tools/webos/bench-device.sh | 242 +++++++-------- tools/webos/capture-memory.sh | 52 +++- tools/webos/collect-diagnostics-bundle.sh | 41 ++- tools/webos/device-common.sh | 40 +++ tools/webos/diagnose.sh | 31 +- tools/webos/heaptrack-launch-shim.sh | 24 ++ tools/webos/kill-stale.sh | 39 ++- tools/webos/luna-response.py | 45 +++ tools/webos/package-heaptrack-profile.sh | 66 ++-- tools/webos/profile-memory.sh | 83 +++-- tools/webos/verify-device.sh | 358 +++++++++------------- 21 files changed, 994 insertions(+), 502 deletions(-) create mode 100755 tests/tools/WebosToolingTest.py create mode 100755 tools/webos/device-common.sh create mode 100755 tools/webos/luna-response.py 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 5419f0d7..34b684d3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1139,6 +1139,8 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) ) add_test(NAME test-driver-ordering COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/TestDriverTest.py") + add_test(NAME webos-tooling + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/WebosToolingTest.py") # URL-only native playlists require real video-surface admission; the # desktop controller consumer therefore belongs to the serial GPU phase. @@ -1301,7 +1303,7 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) 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 test-driver-ordering webos-tooling PROPERTIES LABELS traditional) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") set_tests_properties(package-audit PROPERTIES LABELS traditional) endif() 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/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/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/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..b6e3bb45 --- /dev/null +++ b/tools/webos/device-common.sh @@ -0,0 +1,40 @@ +#!/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' + webos_ssh -n "$host" "$bin -n 1 $(webos_quote "$uri") $(webos_quote "$payload")" +} diff --git a/tools/webos/diagnose.sh b/tools/webos/diagnose.sh index 58b1c1c4..dd8adfb2 100755 --- a/tools/webos/diagnose.sh +++ b/tools/webos/diagnose.sh @@ -1,10 +1,27 @@ #!/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 '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..f7cf2b56 100755 --- a/tools/webos/verify-device.sh +++ b/tools/webos/verify-device.sh @@ -2,260 +2,202 @@ 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" + timeout --signal=TERM --kill-after=5s "${install_timeout}s" "${WEBOS_SSH[@]}" -n -- "$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" From 83de4d5a67c31fb913f332538a3f5d2f157b728d Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 18 Oct 2026 00:07:07 +1100 Subject: [PATCH 56/78] feat(ios): build and audit unsigned device IPA artifacts --- .github/workflows/build-artifacts.yml | 13 +++ .github/workflows/ios-review.yml | 73 +++++++++++++-- .github/workflows/release.yml | 7 +- README.md | 31 ++++++- tests/tools/PackageAuditTest.py | 126 ++++++++++++++++++-------- tools/analyze-release-artifacts.sh | 10 +- tools/apple/build-ios-scanner.sh | 29 ++++++ tools/build-ios.sh | 12 +++ tools/check-release-artifacts.sh | 13 ++- tools/generate-website-manifest.py | 8 +- tools/package-audit.py | 70 +++++++------- tools/package-ios.sh | 46 ++++++++++ tools/publish-website-metadata.py | 5 +- 13 files changed, 352 insertions(+), 91 deletions(-) create mode 100755 tools/apple/build-ios-scanner.sh create mode 100755 tools/package-ios.sh diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 636342f5..9145311c 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 @@ -757,6 +762,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 index 3356d2c1..cd504ebd 100644 --- a/.github/workflows/ios-review.yml +++ b/.github/workflows/ios-review.yml @@ -1,15 +1,26 @@ -name: iOS review build +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: - if: github.ref == 'refs/heads/feature/ios' + name: iOS (${{ matrix.sdk }} ${{ matrix.arch }}) runs-on: macos-15-intel - timeout-minutes: 90 + timeout-minutes: 120 strategy: fail-fast: false matrix: @@ -19,16 +30,29 @@ jobs: - sdk: iphoneos arch: arm64 steps: - - uses: actions/checkout@v4 + - 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 + 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 + "$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 @@ -39,8 +63,9 @@ jobs: env: APPLE_SDK: ${{ matrix.sdk }} APPLE_ARCH: ${{ matrix.arch }} + APPLE_CODE_SIGNING_ALLOWED: 'NO' BUILD_JOBS: '4' - run: tools/build-ios.sh + run: bash tools/build-ios.sh - name: Smoke native startup on simulator if: matrix.sdk == 'iphonesimulator' shell: bash @@ -51,7 +76,35 @@ jobs: 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 - - uses: actions/upload-artifact@v4 + - 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: ios-review-${{ matrix.sdk }}-${{ matrix.arch }} - path: build/apple/${{ matrix.sdk }}-${{ matrix.arch }}/app/Release-${{ matrix.sdk }}/Spool.app + 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..a849582c 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 — unsigned (re-sign required)' '*-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,14 @@ 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 also **unsigned** and requires your own Apple signing identity and provisioning profile before installation. It is not distributed through the App Store or TestFlight; simulator startup does not certify device playback.' + fi } >release-notes.md - name: Publish GitHub prerelease env: diff --git a/README.md b/README.md index 13b78278..0efc9d26 100644 --- a/README.md +++ b/README.md @@ -759,12 +759,17 @@ 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 review build +## iOS -The iOS implementation is integrated on `big-refactor` for review, not released -or App Store-approved. Its native build requires macOS, Xcode 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`. +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 @@ -805,6 +810,18 @@ 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 @@ -1027,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/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/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/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 index cd41cc6d..a3d27899 100755 --- a/tools/build-ios.sh +++ b/tools/build-ios.sh @@ -12,6 +12,17 @@ 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 +if [[ "${APPLE_ARCHIVE:-0}" == 1 ]]; then + [[ "$APPLE_SDK" == iphoneos ]] || { echo 'Signed archives require APPLE_SDK=iphoneos.' >&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 @@ -30,6 +41,7 @@ cmake -S "$ROOT" -B "$BASE/app" -GXcode \ -DCMAKE_OSX_SYSROOT="$APPLE_SDK" -DCMAKE_OSX_ARCHITECTURES="$APPLE_ARCH" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="$APPLE_DEPLOYMENT_TARGET" \ -DSPOOL_WEBOS=OFF -DSPOOL_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}" 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/generate-website-manifest.py b/tools/generate-website-manifest.py index d6341f40..95c6220c 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", "iPhone and iPad", ( + ("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,7 +83,7 @@ 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." 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/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 From 437c542f2a417d648015ba127fead09c54a1383d Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 18 Oct 2026 00:11:21 +1100 Subject: [PATCH 57/78] chore(release): prepare v0.9.1 with published provider cohort --- VERSION | 2 +- app/com.sachk.spool.metainfo.xml | 3 ++ packaging/aur/.SRCINFO | 6 ++-- packaging/aur/PKGBUILD | 2 +- .../bundled/example.open-movies-1.1.1.tar.zst | Bin 5221 -> 0 bytes .../bundled/example.open-movies-1.1.2.szo | Bin 0 -> 5222 bytes providers/bundled/spool.emby-0.1.8.szo | Bin 52664 -> 0 bytes providers/bundled/spool.emby-0.1.9.szo | Bin 0 -> 52665 bytes providers/bundled/spool.jellyfin-0.2.13.szo | Bin 40508 -> 0 bytes providers/bundled/spool.jellyfin-0.2.14.szo | Bin 0 -> 44325 bytes providers/bundled/spool.plex-0.1.10.szo | Bin 0 -> 55223 bytes providers/bundled/spool.plex-0.1.9.szo | Bin 54960 -> 0 bytes providers/bundled/spool.stremio-0.1.2.tar.zst | Bin 12789 -> 0 bytes providers/bundled/spool.stremio-0.1.3.szo | Bin 0 -> 12781 bytes providers/lock.json | 30 +++++++++--------- 15 files changed, 23 insertions(+), 20 deletions(-) delete mode 100644 providers/bundled/example.open-movies-1.1.1.tar.zst create mode 100644 providers/bundled/example.open-movies-1.1.2.szo delete mode 100644 providers/bundled/spool.emby-0.1.8.szo create mode 100644 providers/bundled/spool.emby-0.1.9.szo delete mode 100644 providers/bundled/spool.jellyfin-0.2.13.szo create mode 100644 providers/bundled/spool.jellyfin-0.2.14.szo create mode 100644 providers/bundled/spool.plex-0.1.10.szo delete mode 100644 providers/bundled/spool.plex-0.1.9.szo delete mode 100644 providers/bundled/spool.stremio-0.1.2.tar.zst create mode 100644 providers/bundled/spool.stremio-0.1.3.szo diff --git a/VERSION b/VERSION index ac39a106..f374f666 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.9.1 diff --git a/app/com.sachk.spool.metainfo.xml b/app/com.sachk.spool.metainfo.xml index 4478572d..0467d73b 100644 --- a/app/com.sachk.spool.metainfo.xml +++ b/app/com.sachk.spool.metainfo.xml @@ -21,6 +21,9 @@ Spool + +

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/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 3905f42f..072a26c9 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.1 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.1 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.1/Spool-0.9.1-linux-x86_64.tar.zst sha256sums = SKIP pkgname = spool-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 272d18b2..66555bdf 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.1 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 1a6aacb19ab1013de61f203c6d69207d239e94ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5221 zcmV-r6q@TOwJ-f-08hQ50Q#jMA3iXPI0wMMzzSnnP#p#a25?ks(Vxf&+ag!3WcTKh zVoZl|oZ9Nzs$ap%nhx=blB%eFH;Pxj>v{K*hH|2O`2orS=mD942~+XIgvp;r{p+a$ zF=4*UO%}?88GDN8yg8=mj#NyRh@SLh!hBqdl2cMoSOt41$x^9=VV5xHZ^FE-;73zX zDiRY)4zuRi2y` z??q$htXGTHz@ZsVwsoz5&Uv<`W)23dbREj2Ckgg6%w2q8{~W0AqofU=Cf z_;5NJ85{*3&Ss;d@q9oT1sN!U5#l&#E#rhd8BljRnbtBmnT|%|Y#Q{z2yJ9~z^q z#_m1M_)?5#DfB88twWOTNYAvQ>qPgglg-bgSlz_wa)D?d%cOd zrnqxaSiLVh!CMmV*kg5jYhUiwL4nj}sPSjE^Mc~Q*1f(UD1CX)qNSC-;OV30HPhS| zRXtn!R9`p>(%l{5XiWaj_=X>j{d+pLMvscRbicmx)5y2cuT-^v_4e$Dos_kF$Mi$@ z5%#atjbRmuh=->DN^GOvtrZlJ3J-&LuwaYCgi`vYix967{&*gVR^u-Z__2R(WpnLoS!4qpDJC!wNB|z z!48i5Rqr619RJ@RdpI#x#}hxs#G*$#EMCp?bi7%epfA^@&U~#Ztnj2bzYjNXRr0NC z0%7SzsXitsGbm_H?qhM2`Mv9_@WO@mW=Lk*9nc0ye>UeYw&xz(6Xxh zo@EDx!(I&swBg*{oCBw=qHPQ&KwF!Ccvbb}gOh(&`EA|7QNjFG9S=RsGQdfw3}_Mz zyaWjp1v)4ua8yi|fetLeG=ZaX3-p`ou9z4_#WZ0$$6rs1zI<5fSy9D3@oF4@^q4r9(Sy&%y>F3s7@^Ncxhh`GGD6&hTDP91{!HCoeEVhxpT#@QjuaVW1{O+% zQi%wupjUc4--f6E1VTmAszelT@7#sz3mY&2bL>Ag0fn!qbLf(wlaL`ANs=VX z0i$3TARrsg1%yF)`?F(WO&$X>j$;^cgb_qU0+B#OM1m1fkb)#>H53xMU+He2q~(@> zh06n`whdxJ=ADU%_Ok*k9<`QgOxa-Gl!#VNMAr}kdtSPrBRuiEX5gX^TE<8=Do-+n z5Ek+=?u9hG^6@gLhGVK}0|JYj;&W$^oYkCL`|z%bv(`yt0JZhQoAA{6iTMxQ2U`i& zTyU0I$D-YX1m$;SU2ir{Vpa1(>McnQ)cUuEPNF1d9rSW7diZxuA8nReah3FiE}B-% zXyTuy%6!kFeLzX`K`>@;*3aEReBBcBiS(0f_Y8>Hkrr%#?Wl&M`Ke;r# zi7M}kDp{Nf$VNd6x!%teaO#vOG%}GYQM`dCeCXx^J{K#Tnq{mk>QXKN^5z)4;slnH z9f_pRvhO(AwQw0T-KB|=cP=u81@w@Z5305TNzj%b!DJVr!k4OL)LTV6sjed?TJkEZ z*>Vw^gn=#7NNp`x?6vm7=t#Bo2Ja|xu(*kHftTY}AM`%ubY)(Pkfa^4#=P!bS~SDN z)YUSz$~)(0H6oDXKwk?XJQC1Zj;M)EN>WiBIgHeP)Wt_US4%ya;-x&J6wJwjsjVi8$?PCa;dZHD}@Leqg6B|8}aAhBcO@^J;z}h$Y&Kjny{EzNx#o#i_zD(LO1| zM(KVf`G^>6Dm(%0PiC-@(ZV?ndqs(t<)KRJgYt+)2mm9kQ4xE z*)So+;fBRJc8Vx-AhO%AiAYL_B2GP+BL&;)xKbW>?Behr6G}Idg14q47J>-k)vo)d z(ASz^@)|i!;@sfu5d`glwnD_aGeP#zL1WOV08oj86sHq{AaX|!Yy-L^{Rx=k7BL%% zCRYQoRS;R@#mOn_Jd9eY#JiTnqiN_mA@km0H`1)(!T~S(0_6a~qRbWU={HgNJOegL z1}EqgF@D}<>Ka;7Bx(gX(R6nrgQTn+OY&i>M7g>cDM}=c(v)x(<_p*|DXa_4DEcix zJ5`0yK-nc@Y7Tc?;({aGE)mJExZaMNMp!0BF8nQYy19F4T^<-4&04~eHqd12DgNPE zmJ#POj6o8ZcS_Nk!O^3q#rZMC8(;&2FU4~QVbwj6pP#8+C{?j)PbPL>@x~M zlki`nX-y$Z(z(NNT9jCIwpsvi?Rmd6P7QZ$H%cVdjX~f_E7656BPz+9X=1{@RuL$r zo_wWPE_Tb;C$Mm->U^MHPh>8)F(NDS9Drv}V;`^?_MWfM?i`da4=RR?OQ@IQN#;<} z)F|b2U$kvPEakqE>=sL2S`}!u?AA^=8|zm}w6EOCW}nQ@`kmt_jtYAo)h%ak(Jc&% zSYRm zb-LB6Wa6eP^dDC^R^(p6oPP2PhuN5&)gfuXhGEE?doWye1aMU@0WQGPx6%~Ar6Oa< zqdboq|C>#1Rk@5oWy&7|t_*kpz#R-tuNsqMQ?2Flh)adE!?0{jaCZf3+2TwKDIEeQo@=E+LigQ--@Fc;%{L|%La&}U0GRnNmhqJCrQr4Q6n|jeiP&CEs z%ngxKCtWunj6tclzCgIq>Q~AbWp*X@{tPfOHCe0?;M(yiB4Lc&8{|*o(10f7gmpXNUT5@$0#4AKV(!up7`Qw@ ztjBNXV$~TagfQf|`7n}3Ca~a>Xtx@Q1~V2~lWdI-chfo_zM??Ja7&fPRn>`MwVG4i zATD6=ou7+6lopfr_6#>Sh2VV1!?>J;Kv z1m)w9)5kBE9XI2SVOt|`uq(<-Xl+Bu*$o!O9yK3Z+@8e5bPxx4(F83_tH#H;07{%D zpac=Y?9qEH4y$(A0wGwOYRaJc7d~nsq-1hkXsI9!v~$J(81Kic5yVDdA)c6%8=B@2!IZ{Fn%v z5} zh9!+I!)|O$sXyRhAh5^@f%Rk}!oy6zS7aD9t140zL)aH>#b|;M+vA!0>wLJ-K2yrJ zOf(bH!}ctkY&V~5RP$|k%ai1&l5){1dOVe+XLSyl%g2iX<77+&0IE2AzmdB>cuMfx zH{$@$$sHhPqtjXg+{E!McyQIWjRh0~%&*01;FBfu-2`Jo;NCWG8Lbb1G2!X+W@|j!>lPAy2UfR2l{ehzeh+JHgW8pghjVW^tpY85P;H3Hq}U;Y)_F zMx1lPli!^~H-&DQrTPsnJ$>%P`Pm#WZYU~wRS`Eui=nlH0*J3zMmt-uD7@I?h`+J+ z68O)jsaUht28ILzq`q}sksY)O7Rd^Zm|vKI!Gpxb79I)96w_Ev)MKJaXXF%Ty9%zb zgxt5UkixeWMiZnYq4H+A)O)3pNAbX&QnOjE07liQtucWhHBg!OVC5u9;-9m*_d<3& zWhKcD_zw&pv9OHh)U%cTVDpztKw+%!nA$`8diHV^`W``O3n?7~c9~1xxQ!@gUB*s#~xVfkv#7yzOdnwcM z!coG(me_t)*C6c5a?oS*9F#5R*>5EwFynd@&e7r8a7f*rfO)Y%i4`wD%2PECjQO_%0rI0d zInwF|Igp6Op#e09#l?ZWQ8TI}!i=sBER@QB6-x=pp)+Ppe1~^q_0l#2p$?g1xx+_NtId5$1!!Xqw z0;U4mD^Td#kNuwLYkrkupf{o%(6ET^6c{-oAH5K~onZ|yQm#aC0FiT=kdFe2hagln z_)sxJYI2Syhcj7d$S{U$)P%Zx3O7A!=2e0iiqqu}D`2orS=K%|_03=Jr3X&yv z9QCfH$%16*@{}kN$r852&{=c*(EWgrC=4Cxh-A^Y5hJIfmafUAFp8tm=(;Lj&Yfg= zTRn|_kW3mPl8FKXCwh%upZ#C9`?rIGOg7bfzw8Rl_vu;EO+N5#pVI4mt=G?!8s3zO zF8*|MPKGr3SvtY0S^98$Z9Bv%Z?&U4+q-z{>0bVK&ALWwzC7Mu&%2m*kz)+l6^CyG zQCUt4UWvv&t=EWl0D^w6(yhJwL>L$v7$}iXhm+tDB`y>mtODb7J{Fx!dW0Oumn*e2ql*)$e0k@bgz8Gv<%(=p31&POBhK;Wa3CO#Snone{}=i=$j=F%C3=CZ+LnoN5-6CaC9 zXE>LZvWwIiETb`?8k_h$9&viM5_27b9DCR9TW9d%+g+xk`(f~V zg!pdlSdQ{`zn^xwJ%b>M^V8Y*Uen5r&g-=mephR&Z*F_(KlQLzJRX)uCW~l)P*x1& zVox9emifU1kxrmMX<(R0C=`ZB0xaWT7|kXV1xd%l!D28wo-*>a6NN9vSdZLR08qVA zhqhQ~+#15t8&yq8S#Wc}c=-49ep_^bp58sJAn<$I*5}1xtstn~vArGFTlKdxcP%po zfh3lcn;uyZ5fNG&(m)U-yPxa%=L@3Bi`@A~F2TPpW>gY+LI!M;* zuXkBVF*tjp-ffB=?QZV;4l2+OUhJHe_IBqAPT8S zmc5+Bvnk#j^i^-mO7D)q8+KUT-rALGVN4#imnnRit(2H9uywAj1xZ)lGid3gD_DA% zdCfGpHC4}+{?wI8H@dqY7j4Pk8Q)LCv42a)Rmf3Lk8amie){M%+Ks68uil&uv5T;d z@0kAQ9>U&@y02@}z@YFGym)DpyA|T$0l{4h3KUFfh)5u$g@u^uZEoAP1qy&HNa_NK zNMX_q6AH;PyUX&sq)a5!5E6rZbT^m!?iVZAEtT2QJ0Vuqr)#1zt=~rsRw_l(f*Bdp zt!o{XsWPd<)MP6umLi&QbB3m^ti4Zhevt(cNsLlQM^_jV>>v;1Sd|T` zd+@6&D!=E5A-S*@qw!eyyqZr>+6vmXSODlsa}B4cj%aA|%NjeaF)pf?J8I(}cU1~z z1WG{`LBU6e@IU~@gMRn512Pqvgd(8?HOuO`#QdNuR$e-^ijtFR z@}glE-3fc82E;s#Rl+iZBlQCiw1iPvv9qNU8+!j+`10 zySH9;ShF@=B(s+7e{a`P0}Mpqo)F7vujX035!CaHL{~;xwRe-Gn6rj|zVGs=CXy<( z*W~va6%_F`inCL6MOJ(}>w&ehH)k-Sx@M(TX$g0p?5yC$NK%Gdskiu{JILT?HU771 zltekMOnc*n)Kb-whM=ZvP>GwPq%8_ANkbkYm8MK05|D$n(ijaWr~|1jDMGriAc&IQ z1LFV#v`9WE%ou-d(~e#kX&U@EN?J+(hs} zen62-Bol^?>UpIS*9HYKTB8u`kiR?-*k#JOce_zv$03%U2R@!eNtC<A* z>EXLc3zUUPgS|h&H)^G4ZRE>Y69Cy>KA29C2 z<(_|q%R{EN4Prv%orw_bX9FxBwU+8k*2&F7Vq9GaESIF$ovWSII6~n}zrW1NprYpg za%p@MRo*o#S)3Wjih`E8-p3VCDkM>0WFl2wya7%4(9H!rE_OI|%UId!GA;r3%`vm$ z1eSvxi9nxa-*NJ5;WlQdOA{sUTx1#x@Q|1rRBZ*4pshcG$u6R@FICH|w~9WguA^Y0 zD`blxHW3rHpb?l_yxM#0kI^~H>J3~`?qKgs$pU!}tyL)dl<&;E5P?cNq8hW+>$IzU zgF#%&V^v%B&+1A**MYhgBJfD2gB(d43n|G(b@($-`%#IH;#{ru6r>kvozbt(^2oM3|(IfQD|#1Q@VmqREqWJGHK)+Bwn@yl*CL`%Z; zw?qA7@)FUYr<=Gs>(+_KSNK7VzWQx+rw?i-OXk)2g&~lrHXA|PRP$1G$rpc#VL|&; zhKtpGN%DmW)GiN_5q>)1J>%z?-T!=@rB5k;JOu#OaXtK&{R?%2iQcTOnHNDAJXj97>f#A~?D zn^IrvgUMs$Hi2!hBTJ+KY%lJqBFzFWj( zB$`|egjPXjjn^cnx4{vhuuiCCJP6==pvK@1Pd}(xF_F4?eh%S zs2P}`Q^fc=m#J%LNs+7-;6&=~L=uv+awN%ztrErRVx%aMI7(B)QJ61a%cQU_HlyhG z0PR#2f&*ojjHy}Oaj6TAaQj6hzv6y7ZXaQp7`gbj*y-l=(z-mbHky42OWHs$nf$OU z%Lwop#vlpAJB4VCa;bT)bsLNy7su&Q!h%~xl4pQKp2IH_+1mT>D^xY=3g82{ zHP7mFt5r$GO;zYWu5hTxyn+G!!9fQi0cMO;^-~j+97@A%+B*#p(p39>t719pFvY|`nb3m&r8%RhT z-qV(o;VhPyowmD~5l?V&Nn5ri6r7o7D{T&@QkP*W#`lQ4@D)Ix4c$~dPYa0*K2}*g ztGBlg!DwbhSqLvHzt%o1AUH*TQfplA{R-l54f{WT+d%4GM@G$CoA7n-&_0AWK-1H0 zb%?k%SRIN0c5g~9xI9W4vU8AKxAlW9&;C|Ld!+#Kv?kL(4K);{8`VfzYi@4pMH4~M z6sI#cL{61-&43^VrQY%a;YZ6~DRq?DmH7HIz{uQ4qJ%Sr8xCp#^xkQ9tHM+BQbpuL zCMIt{Dm|Jsi(o5iM*=m}h&28?M3fAKV0fzZ(PnF?>+`E3;2x0$U3!wncq$Lu;d@V4sU!|I=ZwA|*=iw&E_tLdGqdydIg6@>LOFv-f z@<6d3ek&KN&p;uBk>ke0s5CNx1*b&I)lfE=vCvAgX?(hy*7@)i0WyYLs=Tf$P7JBl zoahE}frIbW902j%Ilb)#`l;orPT_=TxG$DQ^HzZ>J!R>%o775b7-v|FD8er5 z9Fv{2c3`>{;`l_krx29>maJ#9?aN4C&>QnI_q!iar^JpV)0LyvGs#WBnOP1}T$O^*jjn9Fi)Vvdpa&vLXP&g&rjG>|d3irKrP=Fs3 zZBqi2&6+Nu%m0MMcnv~uOsVhmo|8cvhgn`+SIuoLa>t(-pu=_Wek!S|&R*0)NPC!$ z|ID1E(PP-v#`N?@JPZMgcoSGpCL%n{^m~mAOS7sVRWXEp@m7o`46!|)xw+1#3+*$d zXv=gnAw6u*LeF;d$woEbh_^gRjw&lht*XaUX?j!#k-2=lDlkq)GytxO!~YwZ>Vu~Q z&wVoz@SNNMayB}xHNZ_AVw2k9|9NB;b z{irE02?I$Q@0<(>7U&3NsvhzbdqAZjkN~JKmiiMgEdu3nMs5~&YMN1zJr~fQjYz&^ z0&B!OCn)*dJ#=5_)>*1=u-#MEPMn|Z0mFu(l2;XBQ@9vx$W-Rj|k`;Kcle8Q32JZnp5aSf{>VcWupPxxyONqZVR@?XdH^#8{qChq z%L@k?2ODC0TTO$wE89U2%X2`slxMxQgwTxl(LQHCj1v`tuy6jR)kSRw{YH1!D36Psas6weoxYZ9r~&;{p# zAssp=bd<55TO^>n! zlDe2;vc=24O&@F!aY_=)?iA;oT?gmw$wCnf7ictyVP=bII?3()hPo?93OR4A>%%bB z90H~S8YWQa8j$^-=xctJW1u&p9MG_UZWS0g1|Pi$-j(42FjB5Wc>tAjfRK(dioYOK zHTX~gLu>MmCWkXwXyf>foM3|*zK7dQ!U{Xly%i<-&`NDEtJNX7wW6X@r$xe8+0TF_*o=S>lm*fDjJe5PUgZ|!t-mlRQJ gxI|m?r01ST7SNF`jh`TP;~hf+)YFBrB}<8Zec++}w*UYD literal 0 HcmV?d00001 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 6cdb8705274f7b250498b390443e925d14c67f72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52664 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%0Al69c?^S_h1i=Ktt(Ll} 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 OYt!2`LRdozOo==~qZ=v! literal 0 HcmV?d00001 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 24f7fc2674be2a9e87d9f96818d4674daa7c5b4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40508 zcmV(rK<>XNwJ-go0Qdp`Z2SOvm-HDoU~AI>Fbu;m3`2sbE@rJYDU;Ana@8WWy_1w< zBM^$DcpPPgUaw>D?f?4SWYl!g!2ASoY(Ynf0fPaN0nGPfH3H$@kc5bhVK1)Vo%f|N zvf{gzQ{T4?vO))ghs^stjMh5+eVp6>m@6|q`?U4P#oqi!N&lC%OKm}p*!7A$gh>z| zvAPVEz`EHNt=qO*(~R057k`~EHtk$L5tX*nYTmguxwv89i61fC^$$NbqZsjrMhiZ5 z2x1_1jbbd|+bFVb70>I}@sqC7eyT>l7a&&-ikI=;JUOA+?=*AyvU6QcqM4PjweeCH^PY!^mCb{O-a zk^Lale8pfGV>a0^Bku*djd_&N%M);Qkuh|Y+SAmWoVFh`GCj?QA^F~qov1C7b=|Q3bZBI3a0LHYHHDk1U8A*S>Lj)5qP3_Y*Q@5D{)k!PBD52tM^x}O7`JDS8d9m8 zo^?poO4oG^M%R!Zrqe@j6l)e-4IcybP44wqex0{LfH^5z2vtq0(9um}o??mF`eP^I zN7d3f45c1-QS4$_l~)rAry8Z(o;S7q3hAs1Eb58R5dUi4-~PI^6E1BbWJyIVyAM&? zurW+uc|;^87iU*f@0&DgsFlWlyCsEXZ-3c=2$UA`Okirx5r*W&YdTujZOtClFP8(m z<7wxdx6|%O<=(oc)XXFQ@(R9hZq#fYCAD1)1*H@`kA`KJbKuwWYcI&{q+OFb>8G_U zw*8oKjkZB1=-k4`tKpB1eXQj5Q)|~B$poEF?YxmR2WcqP8UuF4{8RXm0TBiu@Zo2@ zBZ)ElykWYKzWu1|v2|{_d^auSLhQHX@pZ)RT`y0bIqM03DLh|LCQgJdUVnzM0*zcx z9q$LJ9u*Z78}V2iipN5q3W*8}3#Vd{uvnNAAwh9r6%|%7u`OcP)uQ-HAX~`n*yEPk zW0Q01AYHfQ5iv1wp`ZXjAwq{r09#X)>D~;xh-M+}oPR>0P$2I~M4?=~1R;h)!bJ0& zN=Y)6dWN1vWYtEx)YHv$QIk`bABo zdR&s!@K@`G)_*(Sg*4LkTw~_E_P$;#vJg9HAB-Y%W)&nH6cY~YQJwO$doxyPeWMtG ztn*-}al>n7o*{P$7j7O6rn|msPJfgq%Fi3{zkDX#S)vBPQ4-#?a%2UaR%|IEKG& zecQ(hvUp~@MeFf(Awcc=Vc1gGhy@~vR3MOBedf5} zD{JcZ+c{;@+^fVsW5U>b>`t4S>tBr3Ee2Yw9I;0Rl(eZ*5R|`KSL+f-AYN=oF^xbb zWJ{oaSJ_((bQoI(+!Y@AX2OpJXK7u9-x_ zz=8*b;tO9Riqp=ad_;*m6beLn)=dNafD=i_#sUl+92yRCB@)9!f+0a>ED6jb1y~GF z7^p;4S~OwMv7w*?V?qMTLuGIJixH?ab&3?NR5bzt01^OmFc1g~4H_Abrvz^J0aU|G z43q?Bbcl=!MG*{8fCvHwU?7+P0001j0GVS+6;mn-v!;PLv1u#U%Xmn$!?Zki^>9bE z+0Q+_v+R!(ha7PZdXWKK3Q8Vo3)j~__DmNQv@b@N%G*oaxf&OQ+8^}1fTF-42M6B2 zn=a5Z&8~P24yzD#=d1b&a)e*V$n(>~h0{Z&3GJQ-^vo3$Ncf@xb^n*=7NtC$gH1L_ z#!0aSKNw;ft~b~8z|^hEdn8DuKEZ@+bTF3M?_FZ=AV&OkFAtb8c!%^(NCS)^`-kVn zL;(JobTqcD4t$|UcItP(qYNhyiVt?~qxY7&dUuzVn>$VZD?uL#z3`70JV?RYP}a#8 zbxG@BgsM@`(%8`^Lfo*Qf-q}z*;8d1)5ShFV z*gS1d8&RNZD+j^M#E*UFfDq&>)#l(XAQ&&MLKNKtQgS?5aAYI&GBYGyu@X*TkqSkV zGUd~)4L7)9C3#Q9K9OJhqnRCC%`&3B@Mou&%A+s73^S_g*F4>?H9yvo_R~8s32-Ib)ly7ELSN~Q>+Fs7X9Fk zAyJlV(+?_yX}1YK{r83JnJ0+2t^DxmLP9Cw4OIXiIb?AmGYn+amCA} zQ8i-RIIZi;K-p}~2svWE+-<*lNviGbaSo<}TQmXXBwtR6E5Ooi5Ie>M@@!7*TZ&HM zO`(!72&td|tAio7JtH-oh(j01ejHkYP3}Cupauo{k@VwlxUVZo_DdV-_LypOlIuIE z9N%=7sL9@C06fcH$gy~k zLkA+zCUow#`EEdLpNT&xVzw+5qWF~;la7Wdz$qp@u9o>cWeE|81ti|Vf4&h9F8gaB zrf~e7omF%!n4J+GaHHm|ue<9e=n@EECHvms~#~GVbG6_`{nr|Jc@TP+A6fKJprj36?Lg8@g^|$&> zB7f(5@8lq>9Km=!8qZ%#hnHd8Q{MycGAovW=lU~w2KeIr@xZ-;(yzxtBho>#Kf8;K zP!?c7jPeodjm{zVP zFX=XR44+Zs?+XH|mi{GSaH!2VVoOFTzDkD8$^b&!N$u8>=usDCBA%3d%DY@k{TDzb zOmEduOcH_=1qTuF5k8R_P#P(e8j|QK3Y`_-=63u#(K%5q2v=uh-Q#0XwkSpdXsHi* zVfXD=7Zga!t!88?=*Bjltv$b{f-${@6z_0biScZdRqVji2a96d&B9VVyd~Wp;&}XM zE3AYL4C-resyv(2eQ^*`naDtRdIVt#JyX0nu*Hhp9|$cypy-6ZEK>E+Z^vWO9Qqr{ z3Vn_ck&8|W3XF6>Jw=rl72sww*4NtGg)UGagvF)xA*7N2v1E9g!rU&~xD0BF$q}ed zc)_JMPL|L>QkYoB-LT^q`19aWdv~Kn`W^4}!j>g6?*vKSaV!iIqKcMf?-0RO1?i#* zn&#oVW`UDZPP*Xh;H*HxU>#}@ZD4SherzOzV1Vx6Xok_s1MWlY>HqcOikMFFbv2t} z^we-Qj=A}k@E_0>9>Oh#D(2vOtzz#*0Tj4wybMz`#{8EXT{i2p#xcEg*CLEQOD5vYDgGLAVrf(L#7bcIS0R1m>nf6M=q6a8=M9e4)^;qvO#aj-9K)wKig8Ty= zXRn74b&~o3A+zjx;{7pM?i zbFb=8pq1v*NaJ(t*H3X_BZ>K#ZMjGec5NW?3<8S;JfQC@6X?)ZZS@}vbMe4BMJnzZ z*?`u*45%HH0Y~pw?%w|$x`G46VHvT~_KY!smWzX%1pP^yHG9}o2Do;j%nB~obu2>f zphvqYq9{S1AFBk2H(N)*T8(P&tqo@0StODmXZHDRcQlxl#vKD!j_NMm)l|$`*{~MC zRgoNfhZh4q0(`>1scaQ*@sV)Z1-7g=(pxvUHzI2smlYLru?PC*-M5BfR@&R)V)7i= zF8GOd+`%Iw(Hvv+l}i2Bm}8_~9<0W6fWQq&Pr}+mK*f=>+Pci=&4f=uq0(jNlfBwx zjjnlo$k)qW%&~>+^y|*n%F-uvk&X%9ZVnIU0flbV4y~lsU(xdbHk&}ypV(=`{;yMm zS@`i;&}xdM7i-88Ei18AwpT5M_?<(W2IK9iW?@bVrnc?>EQkQfvqdy!QGqtPeY_`D zT84EuS?yTNu0p$dJ00|Vr?WWXZ>eh&kb0E6BJ?pp`>D?0N!kIzUw}X`Iz0(=5q+eH zu_{eoTd5pDzRh9v)d1o*5L~i)VTTMqPLMXJCpcoV((GhfLe_9)gqwgCkRQ%MG_e|_ zjeQAiKyL3MeEw`)7HWD_Gp{sLqC*a9g7 zax%1h1XZCqkN4*aiTM_zLz&;E-(v{KJS&1c*rLP``J=q5IaF^i&tozismRhvz<-dSppOF3jXnj zE~+9h)O{d3cIGY=PU#vlrWS2{7(U>(#ei{QZbm~ z)`9P2252RA?OsP4)5Cw2` z(4f~Q5^L|K(C<3@|D2V#go(t?20+nKXaz#^*KO}61YlwvoCX+@Mk6tnY%!*%yNCE? zjjU%<=t6;jb1+sQ78>69L!G%a>Pi>*ZEom$0oPkyaLIeTwzyc71a}A8{Bl?vpX9V5jE29|;#OM# z`G93DlFg)60a`^Mt`Bdwc)1o$<9Zb8a*DfXOD;yJ(goTqLmP}Q`<)l`%!s!Cwd70% z-W}&C(r6fQOrD2o8zz>3X`5{|H3Id9%vAoXSwgEwo5}( zx@w@g`EhMOYrMddIRW!l$M*`Avm@*g1s_2%Db{@gy(n)3DYW#-3+}bcy00;tS#RUB zoXx>d=}jKWViHlhV)SLlzNf4FS+KuJe}KTi@+uEIxf!E#`tcS*%+8O`juFpxVfm{& z!BLEA;{V>@26H@iJhB?x170P?@joz?D>VhF9(F9f{`D7S|9s4g!51!1>XCdDSdjsX zd$&W(b0IXxj5!hfj4J1O<(nj;Q2S@!pjnm~Sp;OHw_5ZH?O7>G6k6PMm#|gBXISQF zV}~Vn#{X)Br*r3c;9_XHd|iU^agod-oj6HJPAw%HO6=YT&WrX6{M`g3 z(fnQnQbk|LVck}`3-&RkVsw=8Ozz!?H4U?IquQVn8GJHSLtP zabJ9a!AT)LS|$ay#{%di&ex0F^|IavEq(2E#5jBAbYM#Y2KH zEZhQ(&hSO%-v5;-=1+9$%Xkzcz0oaqX-fN!A>Bu~JF2 zAQ-(q=@0>R!4pePr@Qlr9BY{45_$Buh=1Wb?wfWvCQp6s5fju>@EJ81o}46D8i)M% zSE4iloY5EydSFwu;0;>=+C~!j}xE!vB&H8ikW+^ z3J8%0EF#cCuD8pFj}TpK3f!$!frp)eB=(KCyB+hFvCD>o64 zkxkVCRQkX2EO;4O zw<#F}sj%6Ckg5Ss#uGA7<8-5}Ja?i?$1xY;Gd~xur=`4w@T)@P@^tWZw7(Wcod!5V zcZdaQiS9@Dw!r=Hih7-+|G+bk_=hL5%z;0WSO6A(_l{S0wrj-8xOOsy)XU7B@Je!Z zm@iXo7R6lTF%V+Q{~biCIJ+9r;$4?Q8gO_>%l9>-i&|4vK^XI@JtMIN^Gph|j8ce$)>fS8@9lUc0b{hm!;;EbNyBA0Y!M2LT8PO zeYr6;EbFPy`E8vVYrJw3yf944FqPYsM8)1)dF9r!vX5xTz-$Lr12z5qHE#s55jLAViXbCm<`nL_v7l}~2`Q8!ABb<- zl7uU}km(%Bl1e5c+szCqo(3weri@ax0rh_B!Da;nV)AU3Nduzp`kI~>H7YQI^i=to zu+RgILBX1aJ|xd{V=kr$Y+1>W#aWw5|8gpzXofPFAZ~#`$d8BKV;%4TyU9MPPfzyl zg$iwCy!4@#ho0+~(v8_%pgcS}Wi4t?sE&vNGUqi9ZMt2`BF8Z*?N~C(2wU3Y)yqas zLkFLPv12pBOej6piUj@bpVV~g< z=qGJn+(p)|;)3KU5EXy1vZ$2$fwLUX0l{#_4YMCjWiO-}`1GVvc-c&@t|h}8uVw={ z?^gYA@;FN%U{T)dj++ZxAPcaKyGIvwc6WUD9W>@=!(@t{J;k_GKTxafXPFo3BOF21Ym_e77rj6AOB z!%eBkP)`CQ^TGE2AyqkEI+Nx1dvMFVV~+fd1{b5Rye(n2)Ugkul#~t0Ym6k`gkIc% zMbGqU6yhJoCr-1UPH@=_!%OgLyx?JblOyXADSQ0r0`Xo);kU)bb{ z_hFI)Ao+?H>G2Ump9u17vQaCEQU(kh7IZ;Gb0zb_h&s)eHPi!_0ewf&;e;P!qGb!WUfp2LZZ}B^E@U zhlm5xBtViE19msATL4Rq)p;9-ANp1;(g4OxlwOy0b-a|)Hyu3k5DvLhut{DC)e4IH z&qK3;`uV8Oo>}cEeRBxWxPU`j0XNYRH(IG(T!w)eXn1QLVThN}`JUH<7b!qX%U-5i zHwSQ*tr#8U>2Mt0BGkQR2KoB@7ROWdv9jf&SbrINl7n6aw6h<6T#DK@Y1jnFvS+nez1fGYaIu13SYhfy-BdbY6SfY)*-;<`v_F7#B?_e5%fsVOiVx5 ziebb+Q2emV`Xy)Q5#)`}#%BxkX-%sQI}%W9RNo!Xc@(;P1<&Cqii^f23$v|{IJLjZ zJB*y8wsX)(U4@RzgP4Geis)2GRf#sEJ@t}G-U?y^h}7p!4I_gTI1mg8gsWYTxkGwx}BLW4!5wSWy~B`YsVRN(-wQnc_=^GfZ5yN$svaHPIL zfVALzoG;wRR`w+#i$U2?{6(`XjlEh8o|HX6Om{;e?^JT5d#aaU5c=-yadq@tI60BB zgC_J=`=62UFSed~&?c0f+vj1tBZU=;k(i_Brf1^d2n-#(`=}0NLsHjKl2X+_uQufQ z%$3hd*e98g<}O0Z>q(U!Bd)fy=j%qp-r#m^BQrnrSh%2?fl3RVsnZV^(cBE=sWh#t zC9WmH{9W^XK2x9l@{UR@?wk;3Ff?QL9&Uo@gz&cHD-M>R;P7w;@gy)j1!R`J+jkT9 zU_hAXap-b3c5)k z5=X%x1fO#Lhd%3@4w{lYdB$|bk*FJ}{m5ioWrAa}r2`RD* z>z)WfV7YFBbLsAd_{$5M!=JMPzdFV`ft=VE=5Pcme>2_m6b(oK#U8yue87OJ$5IIC z4PVSiq(1wmwFK;DP$0!er1ih1A;W7JRYTpuyrhYSN+j|gUpA&U3P&BoCP+y3+kLY> zEryU_X(m3S5VY9ly(#^AtG|?h+wlZ^NtdrZJ5)z-9(D+T{YD5Q?k`dgt*ev+NU3W) ze;InA8+1xoWf&ySjIs?2!&iYKM$BQ0g0)w>MOs$!Hs6CO(C%){1d%@&HhWuX4k2ev z)H!k2Q&Q6r(!N0*FXGFL;RNLZj$Q;r_+#c_3=en~Zv-pvffk$y$?%d%0FIRK10}@i zKv+2dUg~=Xz{&;l4-;Sp!$3m9dj_^r$v`{>RDh{eJTLSrpm;@a0`rX_<)Q#{K`@FK zQ3S=$6EolEksk;FQr@e8z35En9pAj3-d;6;2QXdy%LLkpR~ zM3CVKQZkkA68;!o%Rt1C844KM5sXmq_9BAc1@nanp1}x)r06O5@q!6SZv;gNP<+hg zM~hbpy$Np&AEWmbFBC0L0Y?#pfWW*3D-Yo~VkDltfaC%YL%tO;e*qDAqzFqODFSzzB&kXyctCpl`D}55 zxG2rMPEvh3qzBvak{v{FsUz%D|&2PlOP*F`aoTms)bH-1j&;7)nmw-YXvNofB zA0aoc*&nIp-zi}aVICb1ACCnT2t>28imJBE>JLCSi*SlN5Qhti%CbHR=24X6`o7Hb z0yreOXHZP;;Lyl(@2N&JpLbx^rE%7!E{u0nl|xdQm>=k#YwX)Z)%(Cu=J*I7(WnEe1`XV>GKC*Gy-K^)fi@VnKn1xYS!#)zp zv};*k#bwovV6Rn`bur80keEMZ)OK;WtFky$fO>Kf4n0Dj0u2WT#JVo-#l?j~($NzX z;T;*>s6+DB_S91jNre4yk+nsxc!#w)Bo(PyV6!ysvKse0IK34i+}J7FK)Gi(cB6l9 zWD14)wrK`sv1rW*7N5h zGV|Mx?}h>$SKNzTb1o&GPmMXrvxJ4P)P-{F zOOOr$h_k8&4{xj&KuFZd=LF5R(^sN&nsO=aQU_xR)2xb)mZVaHW(YgwaC8WF zuEb8KZt@v|L=Pw}T_<#BvrY`RPBvHwbT^RAq)#B5icX3v4rh`aK+=4oA`S;qq8q*m zu-RxJZ%>}-{sVbTtZH;+Oe5?u;wA_fSyx6DrK8S!6X%y$yK4w?rIAI(H||)8cnkfJ zAjydR$tVTOENio@ZsL8G44T`eG>2Y-lF1y!T#V11V?ACdAMNqH@kHO&-hFAUrlUnD zu9Gyka#7E`n<$o95pCkMNOft|wpmnFQd3ERBb+9CCYYWH9Z!|Ys_Qh}JV%}Furt&JUxdBk8OS_Y)o78d=Fu9x2l}u1m@@HvjCbg3zLdz{G(DvZ<4)s}n=H+4PNp0Fw^lKr^9EliD{Of%0(~N(m2aAsa}&Qw3d7D0i-V zZt_+gVYbRBJrC)rJt5KNAF1msPFyin4@qOv5CP?eq3k>l9C=V0jW%VO^^yY9$g)(1 zxe1FAsH7GNHVauv7zj)fV^9DfFhnq#5eGwA);?5&3JFjI%t#?Q5e;UL21p=;00e{q zKmiN@AOesKuOoKPxzL;t{Rqfd5NSjn9x;aeJ>uyiLPRN6@WqI?OupVu`^A4Z#WO9) zt;F+{Hmi0=`SI8fuEfwNfHd4WJyvV<4gsoLo@Bo4pWNcPyYA4)Yo3BJ7Z8H#%`OmD zv&Pp9%Q`u+85-W?1%P7L_}qTzYYN_${Jewv5$EW&uo(BX@*L4JD_c;5*j{rVLYz8} z*zf3MprpPZ?63u4c6veAPn7zI(RQi#t}WfO4NWobh+<4o6K-7k#)~{nxkNs_OW`-& zqbg~wH8HSDx6|&oKId-OZ7znaHSr_<&j*rW}xgg{ia=n z((lNsOfsVYj!XA zcH-ng+gALM_EsyOUX$<1!&3NqzgZn@C$ptZmOiO=vz~TFdZOa9!y@Y1hIe0fBs)by zd>`}Lh4n3j0TjT2OzW%@1Mk;kCj5Gi3Fn%7VKTl5@))d#%ad}&EM|W4p~6JGc-LP{ z6La3oWvHG9xSe#p5J&rWFhvwq`$q4xG-I^_n&KSJgYB)g$H_gGJ+XKm2!_I{k zJI40u0NEVAodZq^?}Rf3L)rzE#3Q~Hg81bV-}do1h&)$i>+sXbF0k?9)p9A16qZY4 z`A$}^sq>CtzULL<_^bR9#HjfT(d1xij;2;{rN$Wa=jb>Pefag=r!h*m*&wiepYn5K4%rSbEez$fLn| zf&2)PUXKfsDI*b+pQCC)`Uzu1v+Nk39LZ8IxZHxYZlnxhtb=&!jS~uI&brgbOjXmv zQjsm|A^+lud|9j~Hj+wt6|a$fPj(p0=0+v32~~J3UmW8zo)h z!s!3Dz5`{DhECtS^JTFQ1R*PAllRHw{;Do8xLWn%)~rNWF`vq}qB(~=AJP>7PUx)0 z0H*mgxi@b}rLglttz8EnlR}ax32M9c)oqdK2`LMC7#ERta;`i;G1f?Iyf80Q%*`>p z?731*pxb$;lnInwQb8edahkRO3oJ1njfHT(ys9!O33)+gsN_1s*b6}xa)e~z1r6*F z1_YUw7%aT3UB*@}4%`7o@VJCR9FkQalda~@<7x-wlm;;nO*SosUlJ z*%Zr7L$18o8QSOYH=_f3CZJb}*y`mbvxm7sNXI`s?tl8L3U&8b?;VWnDvBF>?F%eT^0$Zyi^CR!~~H0+7;dr}m+`CZ$1W6mn+dXGa8eK{qmOy*r$bYP>XI=|gcQ3i$vE?U}b`cBBD7+4kKMn*r z-af?ea}T7DUsGHf*u6w(%J#WorNK0$!YAsugsWnHP_3hGvl397RAK~9M>uhYqa`_X z0%yAJr1{o`w1@v_c86f%O3!$HATnLmyLkRavk#*Wxb0rGdzv1>+NBjN$7*p15Z@_u zKV@-Q6nV9#`!EVLbXcjh?IP(8@ul&6MwUX@uy666gZaVw6qKA54#`+hT2Zg`ej8Zu z>Jyl{)D|XC^R!FxF=>^bmt170Da&)exJ6uwP6l= z*K-c#I0IvPsiRVLo>K}4?&co;Jj>lB>dtM9Wci_x$iTuU6jpk^be9XB(#Gg)1qeGY zK7(cGFV{l>9RC&?E2&eNZME}KB=GF}j0Go*`X~|&Sj+}NhS~jE$)hGvWf|m?7&dB{xCejQD+@q7IHl zt9hQBexZ<5KOjY{hn@qp6bhR>MYFo%_1hmG(OL%EC|O%}+7#7OFQM04R%N2>~_GBpq@;ftkb{))dfxQfRA)lEPg^2DvD{6Z9haNHq+%EEBl``6s_ zOM9T8*^5H}6uBeM+b?s{wuX1PC{<9(Bz?p!A!^Vm5lUC*UlEH={Nu}P&I zwx01XmNrb(o)sXB>= z452!#+w#DN>d{eM7TM1xkQQNmQ#BKPU!pdZoBbeJJ>2BJlFE}8Y0x4-aRsUol4l?F zc}*Q7_8NCz!6&(lbjjYZ{;z0vgU05x+Kybz#Swd2HYgNBCt^zLUz^wRdD6LxuZTKn zwWp92#fVhs5|C}4W_@28ykEHwVw)W9jqEKH_drg`oU&*!w?y(E;C(~PfDfF+e zJdGc@mFt$CaCexHvq5ofex86`Mj}@}M7{5Od4!pmFVd!G*Zo`r!k9E{0YfxjId*}X zNVs!WHJlQ)OTU8h2F_V=2Zn`zGBhAT0D+%{I9~hDF?sQUV9Bq}H~v z2z?v3L671_;G&~e+lIhJ{|#+s=S^{U@YzJ!h4L_pXKim7{lXDIP0*RdpCvN<#KWa$b&nMR7=iGgssARKi^QP>y6;WuR1Y1}&DIW<^_M1@M*$4;?R zVem1d;aW=i0%R}M+l?&Jg6D_AE!L0pJ6tN6-8>Do0exk(4a%OL#-$%51o0Qdqe)j%AffGTe2!^yd3W}?n9YVi{Dz}iyx>ak*oJY-2=#Sr*#x$aw; zZHLX^Xp|~?XF1p%L@#s<8cYWl!4b6*9ol0EVdQ362_voqZ{rRW!{T{F+EYKtH~7eE z;h?C&{&zx?%ExkEb;F7U#AxuI8t-BT;a|RiUdDJE!9d|MIS;JxKAO)JeEzDI>nlf- z2mJ>-9Z5d`CgBFbOa!2OI6~JA!8bV->QN$6O8g?Ia{;TJ*DbuNcWm-3P9Iwt&OhD# zJwYHtXGUD38lGO-7J1IAUDnFW-0Ap)bVX$I=}BZfEPlyLK%nd>0QYmNzy;V3pGt-z(uU&p=vgt z>OvEJ!2}b(8y6bbYC~O!M$BgqH!26h77^}2p3`oY`I6e?N${YhrGNfY-ZHcn6^?gu z92p$jH^92fVcij;xomfLEB#W5ZhW6)jDb#O&^CZgnRcAYhA9OHq`f&=o07X8q;Nd% z1^>W3e@&ZNGP*#4%*XmgO0A3lpq~zTmJu;$y7R}`W6)26B9AS+Pi;`n;uVY|LhC3o=qMbuMX-Uu)-qr=UmbR#KXmxaSh4gE$|pmix3IGdeH^9#c!Fi(vd<6b2~oL?3^(tU z+h>V<_YQsj5CB?naXo0qe>9|QSc-iX$ybuhmqHO>VuASwxZM2fLX}uKqI3LPOfEDG zLA#h`x+eBQyV2$z%F{smc%*V~32ZCo!WfFP0SpE9bP~d&Nj`zOD%R?Vmp{*-mCK51 z0;nW?jGa*f*tjd0E!99tLzfci%>2?Hr~LSNT3|YLqP$%;!-ybIx`y1Xz9YxrCY|p; z7I%|=5<*>%ZgVQ7<@XnrgX7pI;0S)o9Ar|Izb;^pI_%WduXd2qX}N?9*BS^uf+=dt@3m}oUAglaN$d#*>xBQ zlQ4PNn?_8aLEi#Wjso_C$Jl7mFmx09BMDM)a%0ZxGi`hyYK!rvaE3`GukB?Dt*C

#N(L2XgWjGe#J8Nk$bBgY;e9SWo$*@+$rIIXwvD4$qr)Gu_ z;smX&m_j#KQw0Tre=IS&kYyK`1t7N<`1B#KqOjf`auI_9>xww*fOfI*(|a0 z$ii4khzP%pC)&>Tn!UV6O3^K;ZN);>0pncCkFQ9ws}EMTGws?}va!j}ZrlW2o0cmODNTWjc^gQi98E@_i57m$cb6F^a4l5})(#j#(&0 z?lE&atqDkIiHeR{V@ z2m_>>+o(ZgUmA&ymjGsCVME=@=-C;LAWGN6Zh%1oAc{h4gp;!c4}xd$zN^O=#BSF{ zxB~@yet|3KE-R}nL8^tl3W9(c3r5%^^OJP!@aX|1(8FsRaT~i^3MK6R^v1Yvom=n z@v40*94J^SSbkHj5dt?Fp>P_Cy#n2(VDs^{TeWB|kHNCJh9oO??ob63L3x{&F3}52 z3P`7UU|?EQ-cTHUL7WW3{fdXvp`E`eZiy=bH>j@S1qm9NQ>vRGyg<#LZJr%ht-X@w zaiHbX+w3XMLRzGD2)FFA;=`dK`ibL@r36#_76QJ;XJ}N(x}vO$oIvq`GYlG?oBv*cOUC`KdeNr}wpaZL zjW{x(E*5A^fta1F*kN_zK}!(#Yn9vJa$L__a+(-z*s8I*ghn-_%5D6eT@d_sW9cXf zv(iLg?r{^V;x%W%&I)`3kix}E2tM%fczbuZU80cY?DkR~9fcZ@QhxefAZ|z%ki`Ve zg%L07Ax1KrdYG;nfuh5+)vba`-j`e?nNfQBS8k+qO?Y0RecFZ>7RFvtG68C^9G3W{ zl_us46;6;?zEk)mtfsqr3yV&PxlnR#)=p(iiG4M#)`s;Dw6CjD_)s!e5u|Pm$07@# z;61&p`Ll3uB_Nt9l(FSyC<=K$l_MDp5W{ff$i3#K735<*X3WQaa^IT$j zh!h@c6XA-v2B`SOIWOJspaP5Yyogf(SnPs`;Z805AVf{9Rk#bamSx>Bm{1 zwngM>3lAX+owpe|iu?`uF3ca9y*(eDXF%y&ThpC66MEPmkUA7M*94I2OA;nPD2v=N zduvFry-ZAW4DgUt*lzw3&4KZqBxnlg(&X!v(e}V9U?seW;ys}xjXg}Y&d=h!UUVn1 z4>LmVVNm8+mQ>RJTJxXU=bjf!3}81dbUu@$y7WPyiCUpY^)%5omKIyq1y2r97_%!) z!Xze?RCtm$dl{xh>n6K5m#j(_(H)d2w{2d)2A8l^*c7W{iea&_XCVZyns zQX3{OlQ9M;*i9hb3@V4mqMA+eC{|dSP4JeTmZ5Y%0|SISqnE*qxsM&XqH=oJiUAT} zG5bJEV*ChpdONf1+DA)koSSQo6y;nYT&^dY(C^>fMnXtiLLE0*>eJ>73!xNZVOFFH z+(h6D(s;)b0G0)iS+$Cwwb$b>7gVT-%eA@G+-WKeU-?Cbu{BzL*eM(PcEqUJIsmy_ zOOT7;c7amWEE~E}x+}U#?YR9_5RYL>Ta`UU&9w9l%wb@~3jQ9&ydu3}ivy6_bG;7z$mJ z`OuN7dX8tp_*FQCo%eU^oF3Bll$6XW>j*DNnEJ-&TtFqccWbL^3bH>LJPt?>5)HgD zbck?xp#?(2iuuXod+P)7nYB*7qGRDP-TY-e04zY$zg@+_<;Hqr4uxPy80ROT-Qf z3#l0V(E&<=WEeLfZPf+-4P z{8&?!Z?=$5c23PC{x6Mu>cac-;UP5td{+RJ7MpqjYXN-$9nmp}LCt)78&n@rcol4x z%ZszBkar`0%-l^X(<^pJ7$$q^)@T?`L!oPw(Xg}_Ir{a@x9D=Jlq>85BTUmX*gld@ z#gE59bqnm7dqp&dJaaY8=j=aMDHXgbM?LagBnMjH*u3@5HrC zj8YL@3@?SMeo5ZJ=&x3O2Z3BU|oWu{Ni()2jb?D3vA_y#m`g3kk4n zyKz6NkiZnihr3aM5M2h`Fy32<1mc{i;#RbiAv;nu^#p0$4utB-dadKw9bYfZuC?^>E^JOrzU~!jTbeYoJgZxlON1-h@~8eC?)go&enh={F!23#OwsSS?0B5|T)P$bwhFd>;d(JsPa4M`0SI1w9S^ z992nT>H|OlK$+CSFs!hS%ZV$v;PDK#1}SoOZ06+mjgFaXv707%D?T9x;}N-tz;2MiRLn1EQo8boRkdhb=&Rtb$Wecm{nxcL)DoBE zEn&re3L t*acGqkpV)NfRuqL}|)IA~7WO@(fz1EXfz$9RL3_{)+^+Bq_z#%_{jw z^K|sE|3o4&@$ep3{S6cX4T6wWXI#k8^)mUuqmiO8gYgaOtqU)81Yn@zLw zMs^1smZg@gn$}K(gRyX9ae;7jY9U#C;kYBLD~x@H?4LL4rD+dJD@D(UOd^qpb+l^4 zO>X3La-ymR`{U^f^p6wsC@F&mGB3tBE(fCs&3ZBBLUhK93X?o4nsKCjVLS$6Dvk#7 z03mFLN>wa-w<0EBLj>g$QSm@5?SkFrqEzegM$>2f?Cz(qc%M<=J3GFkYzHgmG0z*s zbNe5u#aOL)m3^kL_o<4aT!=`4P?QR!wb*`X8kZ?Bp&56>wT*j|9dR5q#Re;yNP$5a zeIw_$3f2N-2gT8)@wML2WOeH+`h%~t_?!@yBdDbLI`3!z08>B!C`3>y5=um3sV-9= z!~qD3(nU_l0)jF$8bd{(0Hi1Y4PXFJ05l>P5(EGMEAOzn8Q^WMH& zw(j) zP(2DJiTC8icPs^&C)uVKAF{1(gXormZ34$FHCYPW=S3}e1(%c#d;CfJKreL8QZ%tJ z$va|xhtm-}VA@{;;gLp&heq}r_Z%vfDlfI^e=6To#qm zutVS;4+2R5SUCP(c+Pvksp?rkH`*gXE5)Yxzp@{YV*RfsE65opiXRsj`)$d`2r^I| zLsnzZyW$1z2K5Y4QHiGsuMkvN|4AExT{^MXXxJUbX;T=iE07Iyb&uBj$pZ7d?m%37 zd>Ixna1~7`$RKzbQO#BqJ^NK=&f7+JaVnHqbG}WcLgns{vIC45$x@Ie-k%GMD72BM z@0H5^rcZn^#B~YO{f(13{}ob*QEAZ?pYakJ#lt8^90Usde5ip3@y|fDb{|32r=5dYULh?w zjf&gB3t0PET|7e$>dX+e#js=dk5c8140qMxHD(5*G# zr;4fNHRoMTD2Jr{tHo?&*7Mep;M7Y9@~*0d#Dp>CI@I=mZsX*0)mIQJDp_%a8cLY$ zgqyvWVnUy9pQbGCkq9w{WDltZB=~hMJp`z0Iyv0D>4>AY`X5DkUX>wb5Ye+NTvrWM z5q#L!Q+-POB{b0@!2I$4ZZTp@t0;ZpX<%ZF#*rDCiN-m|`28H6ZpU-_S9llPQg7G8ExUBGyhzkTT1;s|r6Y75`C=uI%)}JWc zRCZ#~SZt}s9XC>rYPu&x0ljM`z@aWyMCJe8Dw!N4vEnx2xC$Mpp}sajG5mk9agaW; z0$#rXngte7e?eS`EDsXm)cS%(;(;uQKo%hTY1O|MSC#@wAVDeIC4z(aHg$_&QWM^ z#|PrwoXAa^Sa&i?7kc^t$Mu{zS&``PB$k_Rp!5!t_<-fEVgkJImo?+mx4Mr5 zJM*Zz{bwdsj3GNUSVGzrMPjvofCTN+tyG)$%%akS)Dw?agO&U9-9LgGQtL{}Xt*Do z#PxKEE4RI=)3GbAlRn~N;3Fl|Gm~snFEemmxMbj*nSm-w4IRGBQvEO1K9AJWad(>? zpv=AW(#wK>WU;IiF5pi;oD0}ByQe+FKe`TA9H8KJ?91nsM9fpNr$!NzlDl4MD_G&v zt1&{D6DS0nkJCQoa&T^m&N+e8)1!fAs!DpXu(;EjS`H!`h4WxCkk?R&(4L*j58>#% z5RNfk9#xI!M~+kSsvOd`gogNjv_e|y@w((QGIdON#nE&!x3SMMd#{Rz=)D&vkC?0G z|8y?=K_>2KpcYV-xRB@zp7O!(;t(A=M#4g<_~8g|w)R`ON}6WU_gFtq-rF<~%&bwB zENI5ThoOf(2OJHfa()^J=Y$fhZ*ut&US_#`1STE%x+p_;oN{aEcUkadG2Q4Fz^J-(AIbfukh9)i-p9m0dIIp0_kXJ1zR=Nz$2k$HhaPUFLVHdRytn1Tx~=o?}^nb5G|stf<8 zb%GLtVXrVkh2yt0vKNo2%FFpO4=KS0;Jt&xg>!)!1h%ZLeoYKC!*#xuX#b(6x3-e( zvR6iASUPnglthR(+3CJ6`DkBm7(2g%MCq|r7H=$;H17D_Fto9So17Ehp(B5%E@n;g zcA+<+YgW&%V?rmkVSZiG3AMp@&rAb}-I6Oeh3*N&XlWP1#30}l0}(y~RhOV@1F@b6 zOPRXTfi1+H+(TC;!zD{~{e5uRMi5-_`|~;w6$sw~E+kz%@Ri&TSi}mxZRwQYGcGa= zszt00dsPo3GC&yMfHm-Gd9Qc?UW~!+fi!-B3rf*mpaU(4NCGAN=Gt*#V|pgu)&2`4 z&IXpXoetKnIvB;hNp?45F@w|r)U_Wkfqgg1YUTSq?N~n_z{~QLouaIHE@M zfNTJP(`jMUp0UHJfP9z2t$tK}C&yqMoTovtNYmLC>lDC?JFV{6)z@1?_OJD+~| zC`Ajl!z1ZfI9jflENGelT;k5a&jStkv58G%O>Bt{4cTrer^!x5@3`k24n7aCsb0;`!QCG zN0&I0XqUet8V?uDK`oLR2eWt}cA+rlrgJRtN)C=!fCRG~Q&p6mv7Bz=JJ$9M`>KQJ1>%b1wIuy+TO zEK!r~E&5L^1@TANG}{2z0wQv>rZ%gMqL{X5KuMuYeYxa4e6n&hG&oJnPPR4c_|Z(f z7*N;~0`v+tM^Ar8i!xosXnd@Ba|BgKQ8dIxhdd!fM!4+Cf?B8aRgC|nYKrWu-zETi zrpOo!-RJ`(z)@umqAyH&4TO!jFlkzfTQ#n7u(BB#l22RJO_-d%Xz{G;V_dJ26Amt< z5PTAm{=pW6>}ClHhZIsoivEGAM|&aI2TQ6&44~IV08w7!q+586!n>3@^jslhd=zy7 z(qk&Rb^taQ(z$|38hQQYD~Y#Da6l4BI|BjKZ5|xPBUF|hl5DKPgWE~SB}ob%*yODN8 z?)D8tJUGeVjtj()ymK*z$Dx_3>6{zd6xuE%aNpB|6*|9CEY@y3h!MTZ1o1&TjvER= z?Raxx#VvZ}^ScX3o&NzkpwBus-hakm!Hriql7@b-ScjH;MCM6G-1ud#ZZ`6`Y~*7J!}&qXeS%)KUxce+X=m5vD`9xo>UkU$_?Tsmqv3S{xZvlSnC8vTnOBmL1za!7MkpaKzQ(J5T%_9 z0A_q^kV?ccPL&}c61d(Y2N*)hL+db=jQfUmFNg0njvbtFkMQJ6$(?6w}^L`4ft# zv5KqGW;xe_B?W%JO%^sZmw(QkcMkChN5Jz9T@m`G#35j;iF;(!1_-M7l?(NIPs6_0 zvan$9f_5NXp^`iS39_%(R+)pO7c^eEF;4ubHBj$m3M{{>SC;O)a5x(C-Bye6E0r>xTq z&0~R?5Wsya6N+VgsAYA#qn?4Bi%F>(NlKKe*wlDR4dM-V#f6XB)C z_plq99rzU2nE^@^n|ZX6`y}T#xa1B92AKL9rj-^50KC`wqq!3`FlC+2$0r0`GnW*SRfMq53{W}7g zXIjyvDo<4vKx*%B@Z+(bzSaW9;pzEfVvKVFk9vbg=y%AxLEdukIWekKfI>_GegrFV zWRQGry-r+GV(UT*uMjU-b>tU%kn31dQg_9+j-m%GJ=>;hSO?;tI9xTb`_d`nHY}eU zvvcq~h)bLF?XGvti$L<)Dgt1zPR{_4314kJeYBs^sA@qfU0oN?G!IaCi%^Ezx!SM^ zka$uUIS*<5d)TglDjmD^Y&$!|P}U6(qr>MabFe&)DhS-$UnVr!&`F7)oCH<~ijYZ2 z0;S@APnigeGQ?+RGV}n%;QRde7^7kIj&D8#X?U@ohNTB4ya(zTQLxhUKZS;}=d>)az2KJ(Zak)GdwUMl{7ZurSnBrNM^*h%R67u!tdWdNVGA(E@y} z`L7Z0{gi~d*|;uSbo9b}K+BOJIyK==pSXXEI3~wNV#L`r3>?qX39853LqdWIO5Nrb z03XoaMM9S)9*lJK-jjZlhjPGYRC;F+Pz4$KTI2ks3h~Nhhm|eIDx9SQ+=D&Ba?+g7 zgtDOQ2Dp@?LynhP8CzfJzGnie+L~`+ojW}5TObb0FXu(Zkqn^=vIlZicX0{}X#=x? z8}DB*g6EhVLDw3f)ubO%*xqG~^-|&=-5Lpm^afd;Ywx+2jrk%G z`>p#iGATs5B2fT-f4p*G6tlmIhNkDS0$9hd^Hfspvy$qCW$1D|=Jqc2XoFIZuZbun z`bUd=l4S+F}vhiW-x0C&^cVk5RhfD-0WhG}}Ias2)QE@>A#-J^g>Xz$#jj zk*}E-pQoK$<^o$;gCmJ&)O3;p(OKftiWCv4Kzx~jOnH%YE`bk&*wfNIsYEK}4pw42 z`882!89AJge$n-C3iU=@QOTH@g~Th)xIbGsMYRX+O|TQ^(s?`!e7l^GvZO98#ybBh zD#K?Kyd;SqA@Y}5lU3&6Fl69J#955i4M&&pfmh;3)59>g7`XOX|ICbir@KpmWK|;v z`SzKT1a%3LKXa~NqGmxifPKUIz3FUuxmg$v0|Il9#w#=96vaKP8Y&JOF%0nAQrfUj z5d=g3qBV*^Vt@Dr zd%eYQ;J4K)vX9V>ui$U8o42neLVqDbeCHjg0@yAvpq(^m`-laf_18~)x68xMzpY=W z8fcrLYsBGCABPmWZ6~~K0&up{s+1FDdNrfQlfN!AUsmey6%O`h49VtPTH#2F z@h;l37>aG~K;^6eM8sw+d7>qN8Ldo=ym8n8TpM{z@tJwm;m<=D=PS5q#4$4Nwz+`^ z)&`KI9sxyTP(fJAP@ht5Z9ji)kO5EgClNHMhp;A0nWau2Tjz`;; zFgGWoA-%E1`80%KEzEu#btcKt?AvQnl(WH)+wq+~NP(W#(sUM!J(gT!Mi{XGU&*YYTp7Zdi7$S$dXu`f-^GOL$`x{g|p6G2Qm_o z;e``>nQx|QmRh_R|NEprU zN}lofcpE^yL05ubG8$UJ(}IvtFfmZCarV*caz-F=<=K{q{nP`BwygkKxuAJXCn`lg z;Z6Kt)ahC^&t}2CKw~@+4U-OOi8z?rCFVw3J*#GWGrKwf;9jKL0ZoEA07H0=<8!;z z1R|wnkd0$vb7K-QSX9jFw_V%4?;X2D7OSZd6$CLYE~Q<|%*90RJVr z%|HHken;u7Qr-Yv>*T~Jh}ZNr?hCz>9?92>_j(hHE{oDvazKTbnZhzwl4j$(W?xYf zz{2#jl(aK+(ZN>WY?80v$wbst^9nE)fETPLYWEWu!lH6rs3_i}uO@Xl*68V7?4N7m zeCw_`TCM*-{pot_VJ}Q`+sf4hNQK6RLIG?s5csE=wfXvJ-_FQ&)&&ABOi+jYeFu_tGYN=EgX^xbOL=3uiy_GTNiy@_ zWO^o!H8cz2n5Jy<$tn}IK_f62dB6Qi!L+&Er!8>s#(H$0!<|E14Tqm@;b{#XAuSJd zXg_Rk7iKH5$ia1Lj-9@M*kP`mOCe3v_K@#~L8fB8U(q7>^Rp%t@(nIS_+qvbikK-M zPUI=B^G6SzvLHLfbW99xgVRQcj;GeC_-Xp@+#R1aLV7Yar8G(4Et_66=Y_UQRQA09 zVCcWJ7;%iKf5pNeUCisOU15>4;aR8hWB@5FAw)U@HEk6j@BD=Ipo7t7yVNMagWA-+ z{_bJ!xfXJ=<{JN5pY|xovPnJu{?cg|;}3US+>d7CGNgV&qS8p34%J!IC`|H;13w(-N0i5yQZ3?>V^JZ4Z5eWG?kO7 zsTq5;+dF$@@XPfFk5VuxEA46307Pxab-^Vd+C2~mOP1HeS9`CC)DR=HG={{IrT?kQ zqSx~`5HYq|Qf8fPjJ~P}t|X<4oYdDj1es3)N0<){nv-8b!HGKaSK;)SF~m?b0?9jU zjkVZ{699a_gH-yD7pvPXn2$h-ip5{$YX108S_-JKFqYFE1K<$Y5UGNY90osMK8FCW zdN(jg){ql0#9`3b_p@BSRivx{JC5#jM_MS0|z0);PpCG4>;juZhU5hRJLi8ShAwsr2MaQ>Jh1- zPy!F%@Noi0R<##MS1Z7o_m)i8^tr-z>I*hjNb_si60XqS(jyySpjN2zjSrJo;dGx9 z1mGLZ%89HYf4a_0DIOx%6)}-Q%KtwV6XL?L??^;9Q1%x%L-Z_PQNxaaoCu4gH;JMFm|{nOyJ;%3&grXYXh!q4KN@9 zQV)6h;&Z|jP(dna=4dN>jld89ZT{CAd*@Y|xr^VU0QxIhv3EiG3}wg23fiSwaR!3O zw8*5)tfG;^0b;^F9bPU^F>CB&4p#?`iw5{XV|kB)IH*Q z)F;$Vsn=c!q^p=jB8B1lLYnUP-!rSArx_PE3xbh`r=3b7Qb=a3p6;kh+CbT{frB^t zLLB&`1tK2#Fm2;mkyM@^Ph7@dyynr6aN*-Z&%Q4`BG*cG6r^5mnVzlaoKD!ox^F4( z;UF=TLq0cW32EfnM^&zTl(a0Ko^$xJoOCzDK=phLbn|I-Yp;wg0zWK7`VgmG1Rkl&Zf&@?HuGV;SS9VeartF@R$#pxKqF3e+LS4L4_4 ziag=~53X3K>sd8UtPn}>4fX(-t7T6#~Jh{yaMc6&_ ziB~)YTelidS#6YX-tAWhke_t$&& z?8JL+Fjo`RF^>LAJw=XaD>n%9{@}eu2+N^r0d%;H<0ZE@ia!+W-&}Az$oCT!C}p!h zp)HGiGRl{zSktXMEg`AXn>hQ6uO50~P@m#BR6d$RxmyYv4xTdg`;*2nfks`cE@QD3 z$|m;bhuJVrdrj$FJDggvugu#ayCDg}{8f@ooFl@0-pYz zCNB=Exec^77{uGi{?^a@f6XWe+^m?*37-t!uNkE#9!@-O8mp;|0m3DWM&1p;sV%F zE3uzKd>||q^Y4=^s2chDISuOS7?){~2MKV%eTkEFpIY4-K){XXnO$6|%5Ob1a1?NE zL6JarTQ+P&#Bc_)lHbWi$F%QixYj{8DB;IwiD=`8;7cB2L5DoqKsL&K2%OFVC*{)) zDySQbqYL|-x#qDTeGT#CsZ9}BdYm<+a?MN9cS-4V5IQ(7nxJ7e_9DH@8`$r3{Y4}7~5$1C|cC3 zFzsWMbzWYGm|;T2AQ@&f7Oy%FlUt*A9P?ZhlB}7oN9?m%Tu#vv!ITb-77|v>I2yFJDyf!-R_abffC?hFe@S#Q^rv%X zHs@+IIHpSY-c>xnp?jjBK8n*1elH7d-69vcFJ$2i9{dwSow6X6HvpN$hmJU|LmFh# zDEeeo2xC_>Ofh+ZsncRBOOYdOhcD@8Zmm*yEMc@AEByDd%r4P4HR&_%V;?{^_c0Ez%e6>Ooz`#utnY-0-@=tSm~gMS>aS(my|t=QV@JH=B7YIGUr} zMF}c$KXfxiGh2%6iy+UAVQ?iJReqiwDOKyHYfx~`$W4lrQ{cgaxdcuL7;3B;Q(MN) zU_X*m3*B>MmfbheKA>#(j(v#q%>fmnrtJjB8TF$YxfjVrfcK*mD(oH-#Bqo-xy=Db zaNBB*)44Zz(%@yaCIz+58g(TLO&sDA}Yu>#fIHqdAU~(KIb$CqxzTiVEbNHzgcV} zUs|$dJm68yaRKKuAX#*wnm9t+kh~710eKF5nD(=_ERsX;pn;e0=5~&;%)f6Pn*_+d z5tVhy-k8m6Cr~TMMXlSBolA39Ir>tQ(5Ay!_5h=+*^zL2G-n}8{}1WT=Da0oL8)N& z;P0Agc)l|=zK)fE2DQm3E#4IqXx5vRY)ZKqVDYBEN18EnL&OCBx?G6BKgR>gTvY|g zuOS-Bj|kv(Mzd!{_jYc^`L?HNW?FZN(R&^TA>R$XzdtbCrLh1y)*xqT9o(8-uom#Z z?^XNdXb6mr?C%w(*J*%wxCVY@`yTcZfKqa(iy3eBEwXu-}%}>?_SLkg}%6f;|m3a z;8TG$cJ+eeDvSzQISVoSF36-mgm$X6)5eT|?@^r@F_69SlBN%tiM=ESB^W9XEy3p# zVw)_x*BH@A5-h>cEDM7iK#-z@{;6(`17R8Rf|G_?glL(;@si2&fn6e_ngAF@=L{?@ z%8(v3T$nA4GU*#ZoyH*X0Q{h9rzRId;#pBF4oEY@gVGb=g9WAQ?<*RESu3-qWpgf_ z&sSo)<;DXbsTJLquf?wJFxut^6+U(U47ieCDKHVq{g10Qt07XR@jE$@nKZQ( zsgxO<3gf@%ZgoDtB|eVHxL{%n0^bCKY=-hmsF5*{DJ#KT$aGU@MIqE4kbG6Cd0`Kd zV8Xlf1#@m|m|c29KN@jD7SbBWm#=V420PIWL!f{rzuw#WOi`EPVppeh;fapHyX2y~ z?^yF3|0NOl3{9CSL+GaS6nbjYP1Zs=IP7d)iMUcT@LmwPQ4Jc5N{x%KrHmu zU+GXftW^mQ&&SFE@z(yMuHRWb_tdYFf$LF#l{^vD-Vm=?kE!uNGvFPHn7`7C##y0a zuhu?6I%fV&Te2^=W!jgSqHiK!IHE&Dj1WZsHrBESJc(Nt_PM?F~;>`JRy z#iMqnNRN;^(lU^%b==~*PgECH7(k^|k1GkoP82)x=1aKu5B8y8YQPl5Q2L_X+Sb4~ zC4C*irecU}kD4b0y)+T&4jD^4-~hBei#0D!zDHF$5SVsu6-RcpQw0WA0+X#&gp&tJ z{1@B)Q>_aVvBE`rI!@LUL{weAdz$Q(o)jgA_q;68r)w1Ar@+xKptf;bv>!$nMjxK; zxWwB}a)2Byq{)qc9)!t6TjT2>@c8AB-jvG&Xp&G)321I}mWb1&tAn!ZNz;dM^H4qHo-HrM7P;i<&p8=_R+6>9THQ-;wstt7BH)j{@m$txx=_%sLqh z(}Z)nz1@tnHEHTeyFwm*>J;cFl7i^qoZ^Xi&bT)jBjWKpcqFQnK_Bc0*M9I!qe$>Yt5N0kZd8^NS9 z<)xpuUUqG!^sixqvhy4Y;|H6+mFvT}8E?M`>&3d3JgnppV)K^Q&uWEFqtHv7R#HlD zj1v-Oku4x8-8O*45VyR*Z4ps<420I(ag$Z|yiwlhL`yW#_F@4Qa5MV&s^#|3G->&z zz>Jdi=D-40UHb*T8|@o03o#ioVI7~MS50BexP4AEZZ@EJb~c2w`dYUhyDeDZ>3&82 z2|?Lt#wB5VL`{*I)}r7>M%dUHgXAJ;bB^1PR>eh|1;BM7*4$dKy{sXhT<(XmQd%Tu z%ZT3NR>2Y*P~^f)kjAAGTpc7z(H4$9(+=Zb^5dNe;!PNx0c9S0sX`mzgc2PIrgcIuka}%nM>t?-k~aycMi0Pc_~S!XadMNOigzd ze;I+^h55}GN~VL~78NzWNQt2+gab_tY^Oxa(G`u|%!sBU88lnb8eklyn#*-h`$D$I z;8YhD#4*mIFR&lfpP17qp|T9EF*fo_9{f?x?($6QS!b^ejdhYlQWp|j{(sCzimx29 zxk?fGR+&(?8N*|772!mQi-NL z12Jll&=5ItLN7s!Us=p(Q*7X7fti)m;LGJfi*i)@nF7^wpvm(g-CQL&MEBnl+cS;< zQMw?62WP0n6m0C9wgp6DV93TJ$NcDCyI2UDDU5!J&HZfdLOdxWXbz>x^CG3(SHDjt zaw#;VwYOm|fkCVZ0Qu&vCMAj}$kB+&Gry5~pj8%Y6x&KDm%aIal)G5dfdN|e1B|UD z;Mnn2cYX>%B&QIG|K8I#fS}L+b7?EA#&pcS-C{!Y1SjjVFoeb&Ye++7Qy+WPx_zH_ z?owMBgFz<$QoWyY;>F_4YMGVH{Bt<0k!^I-&V5rrOLwp^yg=te{7787hlIm-`b4pP zq)UthoV*j4$qO44$X&F#3i)=zhW%{Gu`rKa$K35aqW)g%P5t4TRCXOb$U4eIrMK}j z{!xRZRPkf4c+A=+I+&r>dIV!}Q#VR{jgRj!g4GWi?LZ|I0N1a>m*VL*aOG zthFG``pH-n2t3yP#jePE*{Ze%rB#{xd(7K)&%8d4rKEx{{;x$r1v^9+qnv7s7@ z1zAa0g>yL0$>_zc2mqp?d=BRUj5lBjo~dSLmEk}1sIexP(X3a?N%1qkcPqThPGUYi zj)y40Z!zQoe^ryk?IuMiX#^OlUkE*u$kTU{WvQZ|52fr9S7Z;C-UdVHRI;}WSR#HK zs+|~p(aqbhDaspVyz;!^kkFA+IaqHxJ_JvMZZoQ0iPx{xyOR2$n1P|42r9{K28%o& zTjv~QPkDtl29VyVjJytUIu+2tSvh)#%uA(>Ego$5E-S$T`0$c%`((RQ7#fL0RgG4 zMMgZ<5IZgQzrP*S@2e_9GRM$EuI;@*TVGgh1a4N~Z@_67RtDB)ozy_;)s3*ZZ zHxr1Z2W|l*Nu1}IZT4Ad=@6|uwvj!c%u&u;V+=js4u(XzbIpSLts!VtY?d{w#MKXM z;eM-1pfHy~Hz&Zi{c{s{TN7W2^hbuH>+D<2<#iCjZ$@elcZhbqD8-M9#*|=-bEZM_8P5-X(N? zDuxGNG4{Gxa?3mQRV-$SpRAhgM+Jr+nU_eeys|O z7(R5!7M~|fz@jSgfjtU>w}8kjq=KK%Q>6k60xdUj1$Qv7{tkr|fI1N&v>XtaeX>t1 zUQv`t*-=_kCs%O}HhEJZASy~VW0%61I2kk_Rnst;g}6pb=iCosG|<~15D>q^QD629 zQgs-WDh#2-cXWdM?RKdU2Nl*Z^zO8KZWlPB-fm+-<&zzy_e}FR$l#aad2Mjp|0*2O zyoa(MkagOl9=?Rmn)$7b(6{!skIp?`u39LK^N#*4RHNgY=)VFD^QO#B6=2J<({hJk zGNO7NRnC0yd#7+aLi>Gi-YFnnpQHf<@@hB!m&nRjE1WQO0=!2P{odQZdi?$j^k~Bx zE=LSc=KFys=RnwVmLa;Ev&uXwxyk zY@rQ;+|*59!v=>!{(I)TOry`PwW=YNmBq57Csv*@y7DP3-N(5pu(K72e_>%My#R($ z+JkwVDL@dE14SGGHhsf2K#SbNFbu;mAgYR@>l0^kBioHJWIc#F6ZGEExXy z`;UKtHO5L-6^U$}{mKdVhnwt0>HIB<0MjUl&cb(TV>3v39@H4y>mia~ZpGRWw{887 zxUB|IYCAvxC$k5!(E{=TwlmN3x|Od0qNl3JQZB(SRmkd(Q`t-E9xmh&bI?cUM4&r! zb6UU~Zf@(gR}$^}y!lx|9CDH@FB7)3oAU0kMIq!G1Jm~xvKThpnRCkU;iJeSr?C0s zmksJnLWaiX(;8cx&oQEC+23UQ!gxb&Evzwfa^@U)2y@MSI;VXR$wl6EdfD-WuCZjq zh=wzpEqtp^TA4vZcwhdLPKtZU27ugDf z(e^2nLH5e?9BzuN>o#F%oq2~mIzB+<#Q9LCIrpcu=bR##F>q(HWbaal5#^`)TTjQl zwWjSCoz^4PBkp@nJAKswjZUakS{IQd0003bvRn47UlYXH0m;{44->1*IYunTQVb7~PkFC1Bb?x`IH z8X!5(48F?m(+q|d!z@L~J+1}zeRIa*TK%pidy#dGf?tzUF@fP^-LAleKJLuZ9h{>w zXGI^OJPBBcUq!IKC6k~Ihx*9Ko@^-}_|tsF3``9kC~=s|0BX`loHdB#^f+!e^8T66 zoWty<-*ds91P7bL)+7iChDG$cw4HNheNW%Kda8_QxK`5{jg?b(&K9*TaFgJt;2(gx2(wuwGY_M z>Vd}>ZNX9veTIYwQjyHu7!BYY#>m=Eh^2EKSAKi^B?Rsav0j|L!OAla`p>vxfbd1-!td%v{w;gRmWTr)3k98x?U}Dr% zOy<&M;F#ht6i6`yBIOp*P5-ql-ue#O7@uGc^HIeL$+);XXhKb4Z;L5NyH4QT^FYc& zo8gA`Y%BlFW%9^Exi1aoALwW68=cf&@*_KD!GpC&9bVRTqtIjq_Vq?f~SE*Kkv<7xm7_gEPDQF4!weQg@M9J*95 zO$CV28fkI}I*CqsH_39z2~%SNbHaOSY(5jyfCU0ZTq7q~RS!ODwZhyxrsXyQR4}(x zw%=$k5+d@;w8*%As^s4;DH~33VEj&4fz(NB96Ut zo(6$=g*@IFYA6cd&0(Itl+-}kO~+xFD_+ACJGE3Y`#*u6!l^PG8Oorh%XJ_ue+(3Ae82(p1o9UoustJ>>8P8Sw%cJ!X{m7 z_KwbZ{n4!dsrdZK0TnzfTn>q{KA|aIqiU+JdZz50yWYX36D#!(siSt_d=a7Pe#sd+ zq?j)tysEmcryu+ML+#tM&Yp1CLcrqiiRF(zk?DTwTnBZnz01ot!|;idT(oX*Y_k;& zfTp*mT?WOf9#kesYA`^3n*tdDW>zJB5|*j%ZH!e_51U9+L4Qr>`H6HB9+H@^n7#->j~A8Gm5xYz&a8~ z6i9@qOP7b)%Swhgd7Nv!AONQe<5j}(Q@ZSNi{j}M9eGiT9ivmmrQtxaZFta(^{<;7 zv6_F#Kj35Sim9=&kMu$M><87Va4u{T(p_NiIg*`-G9%CC)Eo!aC~zict1bcb=Jl?6 z*PkXIsCw_9U3AYVY%)yBPmm|F$=?@Z9cQSMOChLrb43FbMs5$r(gP`-lPaSKsLX;F zf^I64;bcZouJ6N-=DmShESgvQL8kC5uTBar@?GM9i5giGbcyuye6QKWiz(0( zeL;%pFIf&-)s=WL59x45lBlAeBK3VKH;8mtb0G#&Ypr>YVsqI`XlH{@f2Wz%6T)8c zuBvs@Xpw&9sYF3x3qi>_;ZOI6xY$ue6bh}N54SKn#$_cughi8Kij7uHEUfJEqAkfg z3w2Zn$!u)vEEEp+!ZHg2v+7_bg<^DrR|w zHuvEZGx7=TEntO=BKgCxW#TFWsX+YW=6U(}ap~K~UvIHJ3jzl|Uq7(-D|XKii5s$h zBHucda!(4O2iZZ%!RWe8pb&t2(ZE;Z8J8} zVyQ|5FXsh>DCS2wwU%eg8$S9(RHdO-LnzP2I%^mMS*;)8=ciE|s_EIDIp6%-AU2wm zGlo?}TixMq;&deO%+RZP^qi|~M6tLR1v9C_A!+}%b_J9V3YTvQeq2XTYhEZVwcrm7eh5aa zMo6+0Ena)WF%zfHnAzp>HmtF@2!9Z3Q1ENzm+v+pC&(9lp#YuF@Vb{1x=O!FiCgYC zPSisR)>?iQnra3-!E(>*@|5F~2m#rQRZ*6r^oLEO2Y`Se-?FAKrbW~?oLwm8QUS!y8(nhsx;bH@Gl^kjvlG-!VGyDk zMt<%sNvxqYO@#nkA(@wAPEKsX%L#U=dXOcM|zwyItqnq*Ma z1|kdQY>26C>>+pKa<1y5oa{2=4gaJuTB81Um3!S)3;EHl!E&yKL3NWLICTXa2Epz` zM4RCKm?(jAJpm7Cv@k~jIFt_u{ZQ^IZ;TGU-d@@?iuUvOLw%+P(o zywYbm5!}FFG=juEg0C52Tq*JGIXu-ltdlsKHEC_aY6QFmk_y$64;? zMxVQpoclt%b~{W%M~zk6aW@WOI9OvAeud`Tn6=IKXY3eN_;ni5q>HCfiQi_KLed(4)oS>r=O;u z2v+nYWc+b8wlF>$6@OKn4#v=!VCU7dUe=2uB%-P*WfYC#3F4YT478voC2<_k zm?_uY-1bAk)}|AoRH?lT5CB!_JvvjfZ;c#*2i!FO2sN7CvPGUUZBp({PMs7??2weW z-b!ju*(4jWcxdOs-aK?o5BsPp^SCM1IUV$!IDw#7We#@y>|YmF6Br+ud_Ct-<%W zB95w)IKbleqA+<5==6sC4-O_u56T|9^^GgN5dCM-gy!BI)n#eiQzk9vwak@iQ`}X9 zjDY|REfr3K4Ea(xLAwJ$p)ji!ZYO9Fc-1ZPzkh581S`<;tGF{c>jL*2LF!rbKcqf` zPog|J&I4miZFKW@V(Y^`dseX>qVA;sru=@zK{VD{J=_sCYtpq7 z;}GDjD+<)o6#va@v8W}@=mJdwb)Y_V_23Wt0QKp@!!tKkjl;+}YCXnFfjn^yA`S?s zM@%T)0BYv0>{lWWZ}I?wY1FyKmpDcQDi4F$W^dHSAU!OlpI3Y@FLLJu5h|kX8ciC5 zOPH}Q_?6UXJYgRrLlHc*=+cW(qn$HW{Uh z^vRE5eaf?yFc7p+eS5C53j@^myjzU8^Cqv$s#rcw$i9YQkUvTs!QmzYIW7m z_h2i=G7f{XTxIymnA{(szRf}%6PCu8&#ggU>DZDe4^zQQptEWagU?S5iZk=#vW@UN zZYl+DOg+&1(tw#YPa2%b6MJ+R_ZNB$|9NPjeB6DVh9cStUv%9wV2&oD58uR`fmi~* z84$$ann8##U-)8H1SVpg0w&}J6PJd;$Z=_RQ*L4_;t1r5(PC{wx&4#9s5IGt$D%j5 zWEOWoZw^gNm6rUpf>+opoH0Bh3u7dF!l^;7Nj=nC7=am-Y`zMlCy!z@@-{wYGJ+1^ zZ}^}zU>2wTmT2f~7PUXJM5VJ5(Ij%CWQCld6!H;Mh}vovY+MhYL2|STX|~yWUP42{#890#o-!;e-yYa)-7e_9TO^M5gZ0lyyhih^{1?Y5vLr7<^bU`p;Dy!j z4TVcO6umWp`jC|KrKD3Yq|hAD^bc9MYO}}Amir1+eO+gp^1OtMIon*CHiI1HW#ybY zxxa@cywo%k**F#v7b9Rg6Cb|ZTuDUGeWJy?D~9b}W;I>;Bq*1o~Fj6xuB{*R{ z(tW68Op@j&)TqbYn<1gTy17wN&ysKfF%Mv8&i)^rR`Ak6%>icQnZU8hLj95v_P#@c z;XP^#|LC*~phx5)ugvSYp{F407WseC&DVuZl6&exGW;`4*7bYXmBEewo7i4YX@Pr@ zV)A*4r$bQOm^MTEx?Ne)&Mk`YD85%>hD38Nbdc*G1(^s#I{;`heX1LtX}vS`^%iMDi{D|hY7cJh z*;BF4J82f&%A(8*%T+Cr49Z5Js{sji$t1`sT)`q3!oj+GBd>PnX9dl9TsFSQ$0kDo zg?!KhC33UrL1Wa9lA<^36x!Rcx&Bl?Za=@;ZsLxYZ{b|Q-{D$AqS55RQ9o~CVOl`* z`%39^_=FvlZUT*$h-IvpQ8S}k(8kQNWnC&=l zB>VH&(f}g+{3uhR@un3;Lzs{FoUS!EOIDzU13Y4ntNmcxP6}Ao>KHSRaVC6+oy9Q< zKjHCWw>AgLsqN|On{ayeQOmIr1E_7iC8by1T+79z67b;ytL2J2QgnwuD_HfEsrbC> zW6z}7rV`pKryLeC=x{D#3CAI?{J+KLyW!<8>_FQhEX$%9894<2 zH$E+@+R@8}nTs8HSCi|=#US5=<3gX@a#f{!ml$vxbT|lq5|w10r%WgtZv8h1@Yto% z#wXO+b|~!h1Mqu2ZB7j*$s=@nkvzB&tPH&>bttj36@|v-kPyS>WZl|;|>p91bThD+N)!k$%2bpJ{tdB0T99N-O;~F2s z;-Ja5FF#PK$27bLJ<9j-F)iXi#IEN(JR*8QLP>KeR>d()RH^WZ*1sr&GeQv{CExX2 zN31bxUrs1w1T|q0TW@UfE+1G|EZaOswnvb00H63^rv*XuwMKK^uk=Sanen zy*B6eirLD`?}%N|th~6xc|U{=;9bU@_<1?H3}qKn|h1GEnB>iHct`PBTaYDZDY+Ta+Z+ zVTUFj_2etKTWSzkpS|A7|9wVfwjqnd<4|@m$cCb{*asL?wphAd2lj6<%v@G2hQ{kU z-#-l{_OoJ=UU)C4Cj~;7Pok^DO+t+ZYZ7)XEfitDObPP}CPSOd72AuEyqPEbo6#cG zQ?<;sC(Qn?F%^+D*+0O`+IXEq-b)Je>@R{-)W~l(mvR%6;R291??sC-uE zya=)(f5>r?OxJc|&*>8^f06&e#7U=A%+}F>r9!W40ABSz2H5erW+a`Z2i$7Ffen8> z+ZK6d0nx{GGIkY$6QL)LImkdH<50BR*XJgKdT?6iq9k%?CwY3N&Qw={z<9Bb_^Oja zVSha61DOwS9j3aU&>wvcN>mhBzTKojtjjxk6k!B)<+RNFWMI1JH2IuW*qykg$Meuh zVG4osuQsK=wsuZo%xJVVm0D|OfohtqXbl-4lYR(7^Sg8(DFKvf1JW{03O!t`>(N5G*aGxOM}+ zDS!sD4y&Ih1|Zi(h6O0s;F(ql#~kH8)HU&z*B`P1wgipv)>BAatqsSNp9 z7GhD=wH5>2U_zxps$<%~DY51JqT5g#eYzQF+)N8MhB)FTn_i5ROt${<-i`M5I$IM{ zkTLdSjwhe!bchs83@Ak&NTJ*W_TOM0J~9*=-=gYyBvOR}|54VlkaPdZGC~12DtAI^ z9qOf}0G*Yiyw)g6s=HUgA%Vr8syTtCcC~E-APhbAaJpE6YZrz~j`*j(xFU+&*20n@ zpC5GXwz? z)`{{L@2#}dHtC_TH0}uR+m_s=vA4UAh$OdhS}8RzC!+;>irY>+jVDl1-68Lk@5M?f z4^uOv(|z6VKMr&m|3m@)#wI?#szi0Y3&dx4hk_PCzBmh!uK)wRMmB(|yM0>ZPXN}{ zS2`3{W|_9eWo?o6fSZBf(7@td)3LLk{K)>ix+NC(qgI)RjkXe8ku!(L$;leO-0SO% z_LmBVPiOQVHdHV_RN?qaz^JwKawc_OE7DJQEKuU)wjK-d&=8S4N3hY#wOaro|KtF8 zZFa_At{Tm#nV6^lWUFHCevu(~>F!&A2KABQkgZl?C$5G{64$5F*%pB8JnH3F@6o7Y z!VnlyK0jpyiX)4zl@B)65e9yEKrNPBg~v}oNNFuiv`-d$J%A1bsbUnjwF}TU$+-Yq_KSmxIh(d;_Ox+n=ZyraF#px zHEymN+(p5jpd>DC8#uzB;hym<;j#Sw*xLGw?TX~g<9XW>@+NTS%bHIbcivP-8sV6f z1Yzp2C4uNfT^y<#E8;TK*5Vs4ZbAx&jGFY%Q-=ryW{}brQ6yavv_|0=U33oou|Pt5 z)|G5E20=|%w6x#l_`Fp1rw;GVA+0Ul#pc}4^|l|{Mns{l06WIy1HUsYu()7PW;gE@ zxFEPbrXs3YL3E8KGf1C>?G5C0|4ii#}es3eh2bBU_)4e)gr<6+B0!euAcKGlR z=r@5kmMsNgO0)t7g9I%I##MoW!rksD>=^Kr9y^>RWF&0brF-tD^*&s4Yg2wLu?hUv z#T%WHktkrZ<_dSN+#e0MjDXN5yONSM1n7!gB@Yf~EF7F1ZyoaxL~9u*=CY{6ZF?CT zdhsZ2+E65zZmb?kL$)e1hum)_g_!BgMZm1SY=E4i1Qae>(uqJ15VjM-@+`)3HdaXv z8HeeAW~<)D)y?^>n`5J0>=G#~tIpX&0HAycb0fHX&rF@aZmxrr*#$1StNH%{2Lrkg zDI>Z$0=aw@pW+|SK~Y76IC1aTf(H(;q%}&XXc~h56p#yu9tzf40OO7jUf2fhXdu!y z3ccRtr)MXZY$d~nB`hbxjwsC!~~2zl~2elQ!@bg!X>V>AC>G9LmOc zZ-~n}N)2zgDgVgX-{+7Mf2*LnL6`SoUz5h>bZvEE?AGQZ;F652og9Q+jD&(11gmhL2)J7DG-EIIO+?dYf2K5u0Ff>Synh4WyHQ2u}sMS{ S5w?4a2e2o-}uT3z6nfQ*|F?vEY(x0oGA zmu_x^(%sR;(2u?@I%;~$3dBo%`ojF{pJQ}9(NX9}|$9f>>+!5MWiwG#d)tpv(Y UN~F3EHUV7D8&;Ti0@er$?-i5EJ^%m! diff --git a/providers/bundled/spool.jellyfin-0.2.14.szo b/providers/bundled/spool.jellyfin-0.2.14.szo new file mode 100644 index 0000000000000000000000000000000000000000..934edcfa17996cab1315da26fdaf949e16b6aa44 GIT binary patch literal 44325 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

_-o^(4GvXzp#MX0K(mK4goSdjM7NfkaO@ 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 YOKsRMhQ$k##LfoKB6BURmwOXtZ5f^JDgXcg literal 0 HcmV?d00001 diff --git a/providers/bundled/spool.plex-0.1.9.szo b/providers/bundled/spool.plex-0.1.9.szo deleted file mode 100644 index 8f3e5e13a2af9ec0a6d66cf7a5f2c326813a7f8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54960 zcmV(oK=HpQwJ-go0GI;+^l$(gLU;=!@OIlR-9)7{QAX3+a>*3E<^18oXTfnCvoN|O z2aCfpfh{7l0ILA30PSK1oBx`XvoU*EvYUoXCRT(}?)rrqqE_UQVzFyJGmH5F-#1%J zU&*aqRpa>Qs#I{7)0FidVK-5{6tFKPo!GG)J5Mo!ip+@|GsHNbtVC;7WS^SV&W31M% znHy{0>ywxP;46OuSQdZ*LIMK9<-n53WDqO>6CD~85f>N%FaE>Chh@OnTW81>>qDcW zn_ouz?7YX%NGgXN$L_rOKc)TW7F}{ocGNGj%2S#Y@KNuJ*o|I_VZ@I)w=0V7y!q@5 zRD923;JD)H-(%IACH&3&s3o(1X~HX-QJXLc*B=Qz?bAXuTXYKF?D@5`_@6@_UyKAu z;*oL|Lj1RDv3@>9&eGYz`Ff|_h-w-j+Ow-HMZfZOEIUMk7Y9yckJ5TzyZPF0+tyBw z|6_SC#YLTdp5B=Qqm>KuQ*tl^R;hM5CI5$TYdv!00x(VKVm}7QK37Bc^zI~1?)m1s{&njEX zuf6EJg}8~#B0f-J?5vF*4-d07NU|zdsn)5S83l67s82^~eUibr7phFPCnniL7~kOG z#Xx&fv|8=TW~m54ZK?G^)p;0gmYvb>KRu5o4skrxKCM^-*YdB1aE9w+y zt&x@vaGFG2IN6(Xe8{b)VbiZ(DFO-lZs8heq#R#7|DLpbi>^t>q(_~C2?7;_C{fNn zr+!YjJV9XNHwQgDX?RsN)SbR5DZW*uK+X?fr0x$INnHG?mMP4anP=3+>Aq59jY%-e zy(znK-_Asj`K`lm+fAfv&)S$=N6r$aG@GQiCfHU61XFMawI`k|NzginXuUrHQ$f>kUSgn&Ba&VT zr|^w5%NADk@wQp6%MOsN@Xd>5MI!QdbSF_WuL+&NUj1%?BRD{)o~;! zDIf?L%u&zKAU#xJEk|e7(%a~T%!GTZIrYdge!1wp5SXG4Vei`nPk8LIF`;Uimh~J> zQ8Zq!K1q10$bC#*3t=jb3}olh1Q*j{PZ}oZiVc8OL-9+%P|oc3CSw$lD?lP-Muydr zewN(*d_6IukC=`g`{1cR01Shc#KBrgf8jo`68kw)6m~O1fgFU*f9u7RlrWF0uI5MN z%{^r?^xoU^QCfLsucmUy3=tQnRHf9wiC7O2tnKFaIY#rMl1!$f-;Xm9!i=zBBjM$4 z@uXx5l4f%tAi4HbLU(NJgx9sgkKw)FQ??|+`p1W9YL_5 zPm$BMOZOSR_4+=)KBKPbKNMw-{`)4%%u$b27RLVjZN_yEyHa?83pltl-=))-yTv>B zl0+SPWvbp7%vn6PnHcj|25}!f{eIubT)f&hl1GF57T{NqvfX!*E|{j`p)>~8Hj}7e zvPp4|9HdIvZ>gKq-d&@za2p%^FZ3V-dTQPmuUElNosya{G_7o5?-t?7G%#Wzek7vA zQ?V>c*Mmtz|ABI=*cVEvgRY`qtMut#Km;|OSpu!ksD{&_t6epefUChH>TAUDb&HOTlX zR4!s3fBR@tq&}Z_6Uva!Y;ST&a$I(J=$!D9T3klZ4Ga-V!@B=D6swH~@49+m-Mm1! zJlk@b3t{nkBw&iJhT)J(6~_b>_S841hNp!O4NS+y{b69R(m@8+X-fP{o&t7#4PXNI zEleSu`Ve@m(|_+e_GzEx>|!r>Z=)tlAG18L+-hnZrr^Ti@c6Q}(KCA@b(|}}Kl6Op zrn@Q=$8`~|p7>cZh_DUOAMNpd#K^?wVI_d$5EhA3Vgx6{{HG5wHPi=)Zassn7~XqX7&)d3w`lZ>nDINMCdxdO8< zxvFIR*)6Z6QB=G+e6ruMr2MsF#sFIw{$7`A)8)Rh5){WIeE3#HUCIv1Bb4gvS>Ti# z8L%u|f;lqKrl52*FZ(y0$EPg~*vr~-gceNR$j#7jzM!g4$RHGs5t&4Zvaaq9Uf2aP zK0^ohD55UDF4rA22A#pG^vIT|1p5%nG>uATci*0tRu~wWz$QX-0yA5RZ&b-xVN}2d z0{>P$QS0=>7dp4yRBxh6|G7a{Sp_^l4c@|wv&QNvxT_vVQ|4-{d#WJ!`)@%W+p|)~TD^mO(FpII zUbFZYTTK#bWWGMF4Ss5lFx@0Vw4fS4?1iPtz&wFbz}zl5HEzTW`r_kdAh4FDqYhYt zR_8_Hax~dX>7uUg*6Y?Mp$dIoEwInU zGGVNOD*x1GlPf6whN~3cw+gSmIk;#ZSh<4g7Km_tay_7n=`xNtknQ~p#tN=206W4XTQKE8tndb{a|ac}yBtt{f_OHIU~<6(L8JU7 zFx>v>+7u7MArMPUAXk$xu0fD~aIjvN*=2&S@j(1upmuAFZTG)`ictXW$9ZoJI%ZAJ zDq~`r9vThVbh}mJDE?|SoO0&6(~d}Ea8; zjsZ?8(`l_h7Enc?=*)NlVw1qXipqHZ@ppi;TM3^kkPn3$rU%*k!ibuQr||>{7R|Nk zhRY>#-V-Vr;Fn^;u+E3+V<{LKM>ST>XRM!L=JgJHmIA`E$pUhJ8t=P0D9IbFl=43+g6ow`)2_hIMvY+rv z&URjA86APpGtrlV(BJn9SiL9) zCj@-99U+_GMc(3uDz0Ll){28D2?%;qbo%k6OGc?H*hP(EV87H)$(;vi9Ub`duraPE zbLq^-WOl#Mb-A6W9Y08OhV*S|@@_GX_&8GxNVw8wR4mAv9YzeR;HChAi9%V<+CAbL zSkY}ZkZRiKJ;cLZ&<=)KP>r`4CptLYW1) z03p}mU}V#n^vLeLE$?m*O08gZ(wspX>GE8Wn{Knm5-NSi%F|$iOnqz**nqa2hrUD5 zP?A*1P>?c#rd4`li@MhMH8JNW&jucj%x?VL|JoJP?L{biOLlM)7N{6UfH<3mbu zEl6XxSRpZ>{KO%#Gzu9O70*$ANH}Di(dPaPt6X2oSEcB-ryHORfjYJ?#@h7pC7>uL z0Ba!R(FOEEH9097!SJa}FBM9*nZywpD)zk8yZKu%8ty~imxeUqy#fwX&$ND zD!*dr*ae57=VSdEX_}-ndQw`^oSARV_{Hb03XQ2zdPOMHCIxm6P;X}(aXg0I?kcO% z*{y48kzYT^E~g8V|8@Op?Tp*st{TDmfrtbmNunf4NzwtV9T)_;61~Y*gLc+!{9t(|6TMO+k*0RY8+d~QEd<~T{0(&h ziSz5$HKq%jW+o*kVj{wCprBuFkE8zpo4KQS{yCTJ^HMWvL^$G{fqAA!KJpY62$Quh zF#YXmea4U@2vnB2^1zih1cx6k4eBdm4yaDn#~9FZ%oY$WnC<}4t3Ze*PXj_Vh=>*G zSry5vz|s*^6bDvT^LodPi~fb(lL4H}HYfx~1X$Oes+ItDWl~|6Y`$8+ig4hW=`;un zlf%A9X<^#y8<0PDmlZobp&8HQRR$t*(5*V;dEQ6_f0siUk58F#AR81lkaTE^=+M+Z zo|GwuwjH%n=;dM!=&ERa$Y{+YcWgNh#2Q<9FFBL;1k%jn9A)F$SgmljvTj&K$l1h9 zSd$jIP!qW2mDb_1oFisW{c(2o)>1$XS7fA-c%{KX;2odINajF?`Vi!z|O~YGNLD zmYuP{9otphI0^VA0G;K-FSE8(|Jecaa=dR16Uu@VC?4L?|E_V*f2vj08N?Da8P9=4 zh+({mb_~A}^u;|cNP>cW`P0SJhcZ(bm7oIdw6U_uW5ovzMJGCVVamC%AY=wJ7o2|! zK%kS|d(s-1vdo$G)do*$&&M;^0|x-%_Dy+>P5mlbA?0X}lHMfLip~BPuE!Px+@-xU z*;<7N!Idhk{@sv5S`B6`jeUTrs3Y7den%t)Er1z#ZmK-$r#|DT;Ch?}Ch}K}vPP(7 z#_P{w$i}S5mM|=`S4ue~4})&RiSlUtRLH$=26!)+*&>#+vmQmWu7y_@C-xs^YLuX8 z#k6WNbfKDnXDIg5xXkZs0k{hqpW6hITT_^GY=Q{X*TMLwjJ)BPaB%hzic~`jRhp%4 z@ii;}3+o^?=qza>O$=l{R8W$7h*fK$dZvFDod_QX+XcFXg80? z&-F&?;w|oDOFc#a)J=ZBHFhgyjYpG0y?~dG2rDzy^OvpnKhlwizYC-@V zS9zfaaYJG%r>fgZ!nl!t3P4PpsqPs#xRJ5PK`LO;-^iwqdxcWkxVHxn5f(GPIWODn zh!_poxD1S(3xGly5tXlQK{A-qXfvrMRRk=S)!3fWwRJdzBam<~UR1zs;ApR2e{Atb z`^LeT=n;HP06(9?$kp@K^w|RwysSbTv^9b(JYhCx(R=1mrHH0kH5_~KaYrd@ zY*9)QCt?FCIFy6V&g~%z*rb>Q)FvLjyfsScyjxxn*}BZ~8oZ1YHWo|IoC7MoT_}%} zB$NVE<1(b)y;TA%jAxUekdF?==eLE8_3-5In$u@kI3>KjjM?B}k5NgDTyH0yzfuVf zV3Z~f_l7r^@7UWQ)4_O)y`|Xt16sw0rV!y_vZe3q&+Xs2qJkL@U+w`3={hY|^ynDH zw?2<05Cg$_E4arDmLsfksParU-}uuNd^o;_1*N3W~c3CCHT! zW@hH3?y%k~wlf~DW<5R9@g*0>bm?`8(ML!b4$=XVR0q^M*??mANjR?#XAZ*L2nPC- z<{^g)0`g4CVloYfcx3ov7wk}{T0#KwdEukX`&>9O8QV9Si>mxrB;#Y@68$TPj>!zo zNV<^kR>`5#JCSZ=`6d95aV=pkLS9uaaBgR1d2Zr>nR9P)VsdA4VtFiZWo|MzG%+$UGl@`6M-2)Z4M|8u zQUCx0ga`x9kPis}00ECsOjIZ;E&s*}{lO6ay%F`i81%dt`nVSJx*GGj8uGXs^0yoI zv>EWW9PqUq^0OT8v>ooU9`vsq?Xe&2upjWP9_+3l>#ZT`tRdvDA?m9k>8c{>sUq*D zALytf=cgp>qao&{B;}+f+o&b!p(5m>CFq|c%zi1ch%?H3Dam{(x_~dkdn>YkGP`yxs(Le_em1joFQ9cbt#2=tb~vSMF`I2P zk83xTWHXLoG>2j~eqlIwUO0VMHFZ`tZdW*NQ8i~%IA={ZU`;n!NH$bOHBLe`Nj@|~ zJ2O2uGB-0ZGBGbME-or7CMYZKK19VbOQvl@g z3f?fh001o~Nkl`fpq+E_ zvwr&PSXh7vTQ;Ab)_q0Y-cy@)eSg^X^=vmEHl+6xVbi{*AKlPbGj#r5D?o&8yM7K; zH>BNRak9Uc2-`N@-d0ry(%sQhcrOt)?lQh~%jj5mC%pXGRy`K3-fklz%pBo3&HK&c zr#r>?pS}C*NgWB}IR1wAf{Q)P##2opXiH$J1{$jCh8SHB8aPVYT}goey=^k!@^$>4V+$y zwa#ZaA>Ab)WKcLeZ~!T}+N;!&$tUVf)F#tG>T!sz<2%(IzRe~t}l@qmy; z$@I+am6z|_;PzTat9z%@E3Zyxf`^5L1415^eS7WjBEW{UXh6uM=+Q6r_8MNNC>{_p zDM*H;j=Jb{p76-NU_i*F=;gvG_x2iw0T&Di*_2G&QilQC7zSK0Amme2?l52*!+;A0 zgnWvg`{mwVyT&l!LIEM4f@JJ?z4G#$ae|+P1p-1wg;A-)fO{?!5OOM>o!Pw-I~?Fw zVS#{%(8C@aTH4*_Q!ET_^^G+=?H2?uX-}>o`N4%?jdM$Ny-4biVCRp-?R# zWLGqQ$J=Y3az1XasRo4nT1{H+?)ZvlKj3UeDInz6&bQaR-LlVcdrc`IWSHuw?9~N! zQ*Hk7@LPn3g$e;7$C8orzW%yOxz6~n1(FU3ITnWMpRylCsCNq#C4t@CF*ER+ZcITz2)ZB>BT_?nru zQ&|JtUXut2S(n^99$_$#pgcd?qGgS7LYf~CvThk%*?uNLLLlK!Rie>f;Dj_cAmm*< z^<96HU=Bb6U%||q7!wfl0z%#e$?(u445|xMuU^Ne>~jM`=0z`;j)-9PBG`Pl%*z*? z&Ey1x+>0MwmlC)JD8;YwZ_S3N^`AGY8+WS($`{=@J<6aY!A*8YL>nZ(XC7l1a85wT zzG&XJcOgjNqf1FNu0q^i%M1wlmyBEP+90(P%>;O)+!|ute85teZy67)6R|)VAce1< zl^COiVZhk|p$0{>Gh2z^8X%=l$)fQMUk=R*2z4lau#dM4w*QL|NCH%scwmiiLYff} z>QES7TV0Vr+8>Qi-D1Nyo#XZz9S~|!GWG0k9zj`wHDQ?!jhh%7QZgXaqF^<&n%f3x zf-?FPFq-uVmO`UtaA|E2qyfs%Q|4&&F?)>+2=(YCermTe2+9krW<*1*RcAQ2S)S9@(2<{5p$Y7HB&l)Ti+I+!6>R{n5Ji z1!Y;Ry-BQAt}qN(7!YbyGBS^7g7tiX`}xspV(p+Z!l&#-0ii~P(Y28>NE4*}eHE8Y z@lBy|iebQ_fKaEBS=W3s0DdL((H2DvD+An{5d?%S6?ER9Cz?ha;-K#$0XJI-Z)U0UHuhh~6KDtbYuL>)_<8_L3K&V^E z#575QDS?!~FM=qDDTaHMJDkm=0z&PI|1?qp1pv44eNkvj;|~xkRlH7-3JA3;n*U}9 z3D$PjD}o8pkB;k)_IEgTkJ1ca6r z?f$4FAhflx^=bY; z3c>tEsGm`$LrLNM$|)A4y@1f#y1UJ$B9QgxE0xtKkHh*FjwyNpp|u6^+Wc!W$Sgw{ zfQ@v#Lw+9C-ycHZc|d4y;nDI-X$4o%TVl)emKU0Gzkc8EhFazow1<{&I23dl& zQgQ22mEnSix7UEs`Z_1eo+OaxSIsViBJ0pu!B5!(-5uwJ@9SqE1F)#XA_{#EZ?6HN z?RBEnihdnh5+Ie&kQ6S=<1%0%w7%$Q(NWf+`Tm~u%cm$glo`6K);|>jq3yNeE%!x3 zOHgNzjc1rW4eRqew(pPvq4k}1Rw@FaM3CplSDc|w3f<+F<+Bh7QFQRuoy(qr&x)sa zsGi@GLTBCb%YcCp1!r58>YzZD-_xvEipn^YreUMLXZI)sLL}Z^tB^rPAVZIr$S{;Q zXvBi_v)v&QmjU~3Cc&pA%3!FOg+gK7-H2^IECfQ76s=cYqy*~pJ;_Z&k#m?DhTg(~ z%{!z(h?2sS#gLtbP1ov)&UT;7GEeUHX?o>H|v&G9AhuTNOK10{u^^1?*PFKkwBXC%U@-$S3VP!S4 z^sv96yLo4}Fu}+DIELyp)UQctccxa~wR98$A-X8&e3?!OWZW?+A{mF=F!Y^eOMlUi zqJ!F_0fn5vKjkEcJi}+K!iK0~>1Dt`h<+4wH|}T&lD~|!4z-Vl%#zgiYIgpVJrE)) zY%kwY2(kcqhr=p#?Ys;a2+=^@z1atY0@M5+WhYT2hYUki;hei^<1|nZuT7`~Re=vF zEyR3=)n1Z9_w8ZaYTFhNqQS!B#p(Ve)C+vbq-98+rTrOB;atVWrRwfZKS~K?@No@F zOgUtRp*#znE&DD5211mo6MY*O28p0f-~)U3ps^iLF%kpw;lyTBo%Mnc|-siefbupai}t6W}#=_=M+GQq=L@ZiN`&I zB(OxOamYuD5HbQwj6-=*ny*l7&Q#W7yWSUcH^v`4(g2)E zO8&VoH!~yt#?C{Yq5iqB=390dFc2c8@bu%jLU2ZOpKnH_Fr-B(Da@ONzVpeh&nbWq zDYfFg`_pNIBrq$6VOfQ>%0E`^%>W_N6s?a-1Q~X$!}1CVVZ(iA)ii~zrHO|cfA=yP zfXFzM7?MKyvCyx4=~%QN1wy1LINP1L{ZKM^FE`yhl=o+tStxChIyPMf41`G6Y5Z;c z{sUGZpJp1;EG(o@Ae?jGTXb!m<5v?is=vEzyhCCr6v_c( zeS9g1@)nJxP-dtuNY_G3zQh!qZH?XjVQ{Ko8iv{jLk&V{bEf9)T5{C|5F)Nl@NtYK z$fp^Gyh3^`RL|_Kxe;4<@cg!x;AE=IaVRm&e=Z#Os;RPW$01@1x*KDRKe?g`MLmV= zV<9~iy7hf4E}Q{E#22=f#_mc0*%*d-3fa9G@i~PZ%IIwG)^&zpuEYElsf3Ue(%WmB zHoV9Ugea?cb!<`x@YY3{5L3fYR`|Gpp#_J9K!~ymkN+B5Wgzscp1D6|4}_># zr{UL;8!3P?L}ePP3Yn+E{8I|wcg(#}!Q#U|MoFO738A8pJEqVg6bjcOLsYQfY;)ua zv11?-LrM9)8SN>B@7EiIkEv&GQP*o^x&G1KsDTGPRO2@oXb+>60mQqLv zX@fL%RLGy#6T)WQ**-IFhZG1=sXEbD8ii_v@ER>h38B0}N=Jn`>(L8@P4B~zapj7S zp3^Mk8A_w@=hvfsgbVJfab)7{wV@Q!D5Rr8>Aap>j}pRWy>8qp+3D%WY=!h#$d5wx ztr-d7Qx0L%T|O~vL2S{%AEQ@?RX8P$LiW5~Jt-6jMb4UGDqC>2QG68A-V8Sid4%fg zqr%`qy>8e_7Y0is1x8^e!n?|OJ^%VBA?zviKDUe-h1crtT^%hn3yW;c&`UF!Z>|x- zrtke@)I{-G&Vp1`$Q>2xzodAY^-#Dl|K`9XhA2Ft&ne1`LKWcyQCWlwu2CAIpu01Y zy&zRLNSXC$L4>q3BNR40lYTIDy5B~Idn)7+(nBGKkVawsy+LT0TG7$-T!pkj${|eR z5hk$+7d(SBRN^w=%Rwko-{H0PM1as?2jrB zg;OQBM+sqL5aFBo&nISFbpeEE*jmy4zebB4Qt&%7!gurEJHP+l3=pE9sny*b{e=kse(i1j z{`##E&i@Yxf4_k6GW+$*Q3F1v07CQ={rZg=6Mm%$;fUfg0zx!=IIO>vfDjEGUR%4I z->-jx+u1PVw+a<~%2XQR)XyWtr%bzf7C0&N4+@#AsRAVZ*f_!(ic5lKEnCs z6GH|e5TfDw^;aJ5oSE{2y?6PS|0b2b{MUpNLw0%B;3wf4eDv#o5axeo-0{?IreFWl zXwFJ#&T8p~{gKcl{79%05TeqR`?>5S{9HB=0^y~F7o+M)J@ZKT=wc8O!YUyg+?QGK zKDP}yrcVMwR5s>)H5Q>Ts6%*<-Ib6VZK2cgdxmZjS@oNS?QQ@rNxhc z5S1%nF8k#OD!VddFSZ)>-kNaPZz>=}CCa(%_o|AJ4C)ZhNUzJ}eL|7s8gZqH+Vt^+ zULYBy2SR=jGAH#M!lwHrFmFUbyfJcBi!en{G01Fc z$QYCmY6cKvpqLiU?_6`}Lt*w8@;{mXu@U+X6UENoC7g!Dw1cT!*Vc8uH^ zxbXP-==EOLDB{-_s1hWDGzjHg8G0h*5Y9Q@BO`Z4+t!J{ja*M9`J*XF21R;zON>%pu0PEB_VcQjTwWoL4GmHpHC3NhO=ej`)VQ}M1>NU{f^$ed83zx z9|4pIDhBUM2H7XVYRcf3j=7Pd)ls2KFrLzQDS&S+Md4%rULxTtWTJiqmnBb*2 zB!NW?(yk1HkU5{IS2m2@sPJ@23_^+*NCbH(htPLFx6FKBO$3A}KXKXbsK9lTj|37y zGN?Wf^7({cs~L9riQE#ras+Y&X=9W#$e$64C&J2FZ0Pv~osVOaw}e7b4nZ;~k_`%k z(gUG{@R^FRvfMIwXSBbwt+AUuTLL20lSkcRdL9&|4ALBww?;j0$Lz%@5Tcy1$z&2L z4i$k!P&KH3Abgy=D>LUi2K_iaFmL%H$kR)SPWIueA6P5TfkFWxwN7lLK)AWr8#Yl{v_4$|$=s0%2t(GWv+H-BY|e zCQwbLQvvb@X%4cRGW-*v`}WZI%+B_1UGIaMEYeg;phS@7Aag>f7<}Pu8vVYSW?_42 z>@Kk?Dv&owbC7=^q~i(S`C{n(Y6Afw$|{K0$EKuMfqH_%phz{CzZh+}{( ziX?v|kO-2&oVBRBDI*Y8D?4XxO9FHJYGS5G=^i=E>RvuUel4mF!tbHkU-F3QH2yZ8 zC=v__Vv+8XKqAOJ4)O-w_Xc0~n+gb#XX5t51Q0R2Uqm1glng#9XRzUH+44XLgh*d_ zvNVwxOrmL+lr4}UsLVkTZ%`ogD=Qs4jtJXrqV>K(f~Yp&P6kK-WY%I=-~xJN*L2i6n`LrjKS|GgrtxdGO+~98=UhkdPptax+Ca3 zB8H-p0%;7Y21TsFhUc35DMd%G?j&mdyl^NaQrY^*pCnKogLF0_8=P}it=g1n10j;@ zMBm0Ai~c-MVJKqwF$8G}4APr2;<8_Rnw=$vG!2g#CaJNz+ee zMUUlqD9Yx?0F(p@g3Ma9F;`g$ty_%(AsQ@**Ct2~F-#m%8hsQAl!l=CIH(#FKTdDS zl@>p)yEpsbiKI|4B#2A|kSCaLaL(Pd>7CK@fDok$+sk*Jh=d)INE9gv9`Pf9oIoNd z8T4H{F8fUdglMp!yCDqrk{FUhHWE0bL9b@j#}mC49n>B@O(`UY6zA2@!uQogK!^sW z6DZ_{Ad*Enh94`iDvv=jXw7B6selksL3i`c>{C)m43$)Uj6fntQ;;#}*Xp~Ly)$|q zK6*P{6yGJ@fDrwt6RzB;K6|Dr6b&no2<94eKO9>+2)8TG zdOanG>5AebfT}<`93+CmVBNltCwkWkj~6SoYL#b5G5!h~tPSppCY=-X8#!rUF9L?`-$UY_;Drf_UCg{CI)%FxV$3a<;5I)GIt$ zuDqyKFCvBgh7-se6b%?`IxDBvE=GY6ofoYq25Yq{F&xU_Q;GyK1P2Xz3kTNj$^aoc zFWUEAflxS9AQDANm8baQ|GZ``J&D5Lb0v{cV zIe}2ekSZk`beCIp4?c@kO=YyMUIWz3!*hQ8ArVu5=5d{IMSyAB!QIo{?Pg-yH>nk z_XGw`Ayrx+PmuE6P1|=yfzbBC;Iq@`Kn&^WP_D2)5=aD9g9{6uox2O-4VO&#Dn9a; z61PtkBuq+f z@B9A%zWsQGB_#4&gzGgUB;D)d#Zb!3b2#)N!eUpCJ@A7+S9&xCox$N?PWVcT_ZT7N zn&lUvq*E9ehhPYZ@!&5g&~GnLDkbiC4z@8u!forxg;q*Q48yoX)Z-5XBL=h1pcdbs z`L9eHBP87J*FY+lj4%Ym6NA9`7*wg?^J@DTA>HafSBZKL4!wq6C*T9Wzz}rFi)qb& zMqP}MY@6mzNUc4EU>I+bJ|UI1RJ(9A%h-AgHBy;6I~pF+!Sc|J^3Wws{VbbY=U_;7=+~pf+0E5Ph6J z6eA?r=5;1aQ9vP9rDt-Oc>;Zc#z?tnR%`BAv;3BiMnx|}ILuB4n8_ePZQ8^=)yL^W zF+z%MHZDo)z1(RQ3M|V#t9m0#grGLjBri5Z9Pqjvh)>#p$iN{662{* z+9zmJzUxJchz3LpVGgq7Djl6-l||LFmT_snz_xyd1Yx`^peRP(&I< zZwW-$Ciyz-c-~+@9STDvZy{2R)r$X0^^tD(V1_S*B@khYBL`rdI-8Yui zwGI!?@pAf7h_E%{Y-V^wBE!O73VdVyv(7Nmq6{KzjhCfqjz*#iiAB2a>IO&!7K|&L zjFyo;oS36mg2mA8i9@thOYDBINY6OO%jpFWVN1l>+#HWbqs7x1BGlh=HZ#Xzp=2=>cSs=;+5X7ur*w`m>~||fsK5MX z=(F&{ggO+3NQw?AMm2+Xp4aPO*zb0TPq2 zGjV`yua9`2I%C{dtAGe~m)-h4dlS~rkV^ChV%}GOq-Xq#lhF!@P;=?ynac{X;O$T6 z>=7rU-s1eK&p0FDwhZ^*VXt?G&1e-wsJCnt`4L4Zk>OrDv^$p>hW%DSgj#!!&z*=D z#2u0`rMQO{N3V>NM}!EqmS4M!fB0S@+04oT7J4Rz{YpTDI?L9_4ljroL-A@z9TKVh zh&fAfDpL&+YAi`F`urP41nVKCNJe$M{+)S@VZSmEp~mus!}xbhDnsg!sP$JEnG1}x zkbwyGmE4~UBTfXnQZf;CNUG{1BXf#jzfusPzOtFiNyV;|OiGNEO(tfv!pW!+WpHAI~3$fA;$wX{hd&E4)d^JgkP*ZU>?{lJ-A(_SeC|eEsY2>-=MuxvJjKiDr6EEs#o4tR za5=0*C{mr@o8kBtn^9?qP)qqL8b(|mD;3seUgEx*JVdCYB$>JqSHt!fB6Ck3uo;zy z2z8V%T&|7@g=&2F1}~>qfCx2|u8)VjCPoyKq<`;UU^A)$5$cB?o~VcIhC3Mcs|FG3 z=Vdwcd7+^Woj*7kRe}h$lRnVH6T4#SqCOs;P=p9|6K8Wb;(Mgnpc7%(uOdXKo9veR zeE*oFz`a6)iV&e@lBYA5e`ugZ?wrM#ai|Cp>Lt#vT*ey{1q}Dqlp#XBWGm)JygB_j z-Uq4-5o#rlr%sa%x~I6WrVhV$7;larEO9EM4iV}kTOT>RLH!xN5Y|HX)tccSg|H?>s0Vs@ zq8a_u7(Wr#g$VWVLc@NW5Clt{jMjz-wUDole7(eGv_3?rgCw2yn>-mEV%YC45TOpT z#gI2A3Whf?Pf!~o)IhR6ZuY*~MTU_UJ3xd#U!2Zd-k>2Kp4bf{{P|v%ZV>V2I1Btt zzY|3Gx z9xX=CB*M9!UhX#|M_TNz_jX3lCBnJ={dqHV*ssSED-hw-9#5agzT~|<_z<2=gi{+l z?00wd;E3>iBAnS7Kc`>C<(<=s6^L+RPs`9@zc<_Wzb!^rAi{|)58I(fgxhaEgliDt zyqjFo4v*8N<=uRWq-XJ`*ZpiAHtQ0a89Q`x0~%i8~4@LBf>d7?Z4a&wO8{pOW2LoFuc&=~cDoI67M6N<$f@xzfWN&wFE^TUaF*Y(WG%+(bFE1cd zP()ZQElF}ZEg*YxAblq;FKKfpJ8o=c3UhF8X+0n(FK;h8FE4d&X+1qYJw0rEd@wLX zb8~QTWOre7E(&FMfm5i_7&3+sLI@#*5CI{^n2|A?12G7KU=(9aLPvMP<65E`5d|qI zDF3VM9A*bDgL+?>I_5u740&0mmL#aXnSrtuD-n$+^nPtyVc$wZD&uJGD^_R?AG4S4 z*P!K};G0Z#N?-C_s=H)SENU{Oh?fT>(=j%**@ku6av-Z_tGe%(NJ8Dpti13r?Ub~5 zF{$N3u6sd^^_IPI7HDY)OS|JCg(FFy3TQdpk7mJ_Y672C&+otdbXVM%n@D5>cD4>w zs13-YdzoUU?6o@|fYS+gwIa%zHj4bIDF8h{!oNvfbm>p^K+dUqBX zk0*e_fJWi09P~EO(2;U>zX^c;K=2!YL=?-xNm)cjUR6o2!Fh;+8t|@=mgyeMCI^`M zZ=sfperSJeykit>?8IuBK(r^2_U?HRlk4Q^3oO(J>tIc*NFakRhx*Z6{JN$tZAP#~ z8b+4PhbdgZ9YA@zdogH``;^P8XAT9-EM%dL($Xa5i7175cO-AqTv_Z8!xBuzL?pTV z*8hz0`>$r9!sz|eR3)8cH3li05Eg2)TPh%N!lwZ!bV2K1`D2-fT{4f~7<;xa7a_<6 z2&jbv_@2;AL|+#QPvvOc-sN>Un4|0q>Ci)+g02~p|7%M@c;kDM;m7tqJC(O(@GzCx z2T?4G6akl20$kZ}68{v54)AOs&our)OZyxR!h;>w0q}5y<@q6QLyqry*|#f+x*UfPsG>Vyzw49AY6C+O+hO zIFteaWaLalR@U2*zM)_rgEk((C~yE3FhcP{YJk4QiO9rIpJ+Ejw6nzpn@cbbV-7i9 zm-Vy`hF9A4-)2zUwPdnjJSi^ikST?SSSl~8Ph8Q9<(T5rQ>Ja9=VhW8GBkEc$Y66+uUK6L>G^gYr^!=Y{yY9`=biXGJmG{sqd*`5z0uaS_i9 z_Q(uaRIm-azCXG<0`n~Z zE&wk8^{Xkmzv>@j$ett=%@a@FY1@lVA)Hn2&Pw>UtcU|=Uq#1zcaIDo_va6JE)Ceg zk~J;nULM34Rd(!!QNNP*%=?X|A?~XtGxDO{*v;bL0aKSEzPE4!bzeBk#InRiYuIjT z?xQFQMX46Lp(qt99=0f8gy1wm%|%21p-bA&gbyKu9AmkGRH%Wrq9h$52*O|#q!PLb z^x~-)s3=2-34{U(ED#Y*ic+c37|*=IV{D&;V@5Tbsqxk$AwJ+NBpB#l6k#Gh5B#cd zs}P^Copp^=ti|z31BM|f0iizF5qiC+X7AFg;vn=WOJJN{2`XygCvQfo?DstHum*~{ zbF7G@y`^?ecp-!&Suv5hNBeDP=_ga4uXX6NeGeW<*uA&i&tTyxdWol5tfKbR{XU? z8ydV=6=Yoke5g$!4CpC6Xl&xW4(?FQ2RQy1#wj6_zk!!4VAMFOQz$>PFdc~=y-9Ze zu*QXMe7A?nUbXu zVSz{vW~`LR+BI#(r84DjnJzPxVsOC>>D-Xfk;*~Q8q4vj&Ae*oY z`N>TB-ByfrPymB<9-1MmD1_PQd(N_T1X=2iV+i6hN19NU*SlZO^n-r!$7 z%+x^OOC@C{K$C!|aV!wj-nWe*@i?6ipa9=PnR^zKVjGv;VdCE{;9uuV`*{j;Cod*V zd9zg<9zajf=H4>OURQB3C*bx#x7AYKL){N<3mpdV8zBj$(BId0WY_+NhN>}mQo1$R zQ&dAh{l58ikUFfCD)AhmMtuS#+5iec7!4?pmfZow|JNlCp-#72^MQhgOar6Nz#QjG zV2<OV@eoG zMd8Th#+l#%Oqi;Xseku_&h38*T}d!0&D4)aA85$nve?3hH|WK33*FE1_Q!jZOC%U$ z&QHD>b)B;W^Ol489c2l&QV;o zvipXO;;Y)SoIJx*AB;bwV1L~TLA)jm9av<_G@>=ctcTABSMd3bAYD7DA1w7Gz z{E5P~CKctc9H)bk#PhRFKTVU@X*OKjrvD&un5#f(a-GI?hLOmz><$}awEK0m`!zIz z%-ltd0_CczZ^^;&SDfv&EV2|K%Z%N9nwsP2S8y9M(@|_f2#QsyDi%?;SZxgq>}A0R zr3y_KftlyFp(Vk%f-4M3Vw&WvR*NU9DP2tR5>_4m$xY2$e7kCU4AN{;L#ASDOjcAi zq2dLZ3DEk!(2MvzQuzQJ$f%7EQy$90vs&^Xa^%S4K4Lj$O~;*E&Xg&$l>Nbcm10qQ zNIh4O@lqjFE>6e>pY@eg_ySk>a-mcZER*WGOeWWrU@-;!t;N{?%Rf@TR|*hqNZ*N^ z3}m@}NG)veAt%MAKE(e$N>&$2h6|`;%D>8SraeTDS;>xi{{b=X5KQGzy?Kfh<;&@> z=h!v2(?8Fw=X8Gb5!K&GboE+;u1~G1QqgE8`=@!vx5j>#^S>UmX_V_u6dcwr=W)dB zYVfM^G|iW*h;~G=XS9oroe({TpQEnP-IL0YoNZk9s|*M`9X?SxjvMS62MkFu?q@Y9 zZuk2@Et21u{;uYY>oD$_t0pmE^h9^2K+5C!sGT*6cCXk)7sIKzgyWuW0Te^T9 z{O7}t{!CEvWqEtH$HOyHxN-J>whQp6ikhg@cv>kVAV84>Lofg^c%Wb? zmy9NKMfCx+1jY{dSce>SvkFl5FXFVT0fjd$32|Jw%TjPgjw{cdy&dTp-g?c|V; z^VzvK*ik3SsA(Xrv*0m$-+P?E=hg{x*Iq^W)pxW zuyhfJ8JE%{WZ8@so(RM}9u#N*dwbSk)F%%&nx=xwMi~)%U5j6!ea~9ur`_*mze7dm zY=?)H-9HYpT6ic;rtufG06D6$XE$zqBZMeKX6F%_S-;L(<=|T;!|Rsi|_51apb%IZCaHj?K>A?DC(| zL4x)n$ohLBY`kp>Li}=X1KjP+b2ansO#)nLwHr~&VNHFNaz=p^3I`e8V&mR4?sOJ1 zv5YT5F0R=_SaxZicJjpX)rojkS)iisD%jA?3PvbP`;t3efZP`2&lM82!OuQ zDBBY#$HIQv%4A>!RLu#g1JZr!C04eU9F*TJmhgchXPM@z{{dsNR_l_R!A@OWcB#!L zbf2*J`X-eijs54aFdh&pZ^x>IAV$m%HjQ+L@fA z&?}jIU~rAR;@R)V;_d31OIhc(`&G*iE$(5D#bV;i?s!6V|KDR>aK=vcbapk7ecFP# z!mYh?>Fr%GNB2mos%PybNK|hvWZom^h5X)%1?xq|>CY}A=degx4NU*CPoIP{u_$bg zIXe^fNIax#P#6AKaOUdIL4-B|9k>ipmiN>&Eh{7DQC~P)$Kq4znIM=IUH%Q|_~V49 zwEsmEGu{_8Y#+@=SB`xPvyV_3F4~4WEt=n-m!D)z(U*$^Can|%3LoNsoATAfYUUdx z99kiiLSIDrl0MY}Pj)-v0_H&h;{qM4b;n|dpouY8D_kjSFjahw3}Hf}uVb{rua&$} zB;ISe#m}a!8DO%!q2xn8wmN(upL*_kI?+;cqI|obwl-t9B-!qz=+8X9$7jQwkZj4t zZW7TH5SVJ&66XMerr@YLAw0RSi!^%v;fxF9pT}TQ3+N5K?>=K?hXy4tGOk-~;b9rg zwzEJVOKeXRxX-{n#ZEw+5u*KG=*}!pxl*W9Lqr_r?a{ zh61qU0`TIw8I@#UVTlzpOULfgz5(Hw-_}N1@(mALvA$rGtN5)~-7Ji-D`GnkL2&bp z^_*4-sMV!{QuiY5_(#6*{~0$}+p%_T`e`ZP$BbxgAq4Z-Z*miF7!y8&F;HCbu+xmI^^JkV^J-Db~)+cw|Y zf>5m2I1>w+Ps%D&SV=vOEI9Mc^#N`^PrC%I-?=oT{Jpo3$IxWX9UO4!;R#3*mCm9u zcpnXU4wcN6V!LlEfp1QqUpKXHO;J>A3X+?z2r|6j$c)uq&?|3h?b3Oi*G{yFWrBrj4lL@dN9j7&3O=8AKuYR)Q)3t9*@~h7lupGz`#A z9=oNy@>ukKf*wUo&Y%slAf90WU}YB%*R5J;cFBEImVXIN(b(U&R*4fu&1)+-*H7o< zk5iq@ay<1SPcg$}1kJ#6q>vjgN1fRc86f!j`(d)IZoUqPzFY}{W?>Ly`JqQDaRiqw z+lw@ppX3Y==R`aG-@U$a&Lp<*-t95uqSNpo(2E4y`gFV?mo9Plp-d z(h9RN0sJ9esE9LVvytAe2lNbCqBBcG3?!-22tXf$tBlRqcR9_>2F^t(jeH*g7dU4( z5*VV(Ff(fs=a)fYIzb~GsJ(ejc|wb9G@mR2>v5&<@$D?kr?U1U_%nV(lt9&grNU;3 zTLh@V@S8PhG>e>>2jJ!?bc8D0>BUCCsuY@P65SsSohm~=9imC2RdR@J?UUKn2J8<~ za2411GDJ4FNMvqm>nELd`TDzJC3H=JOOwNwVATc0*%E|*fry(IXip=0b2nK~57)W`r4-+EiYwmS)YDc4 z+UwP4Vzw8fODkOv^gAqvVR1by4>K;(_&MT!t4`t}dYpg%%&Vi`N0hz1B3o+r2gUm! z@5)IoPtxhWR9|C?D&BheZtU_afwwTxS}mrQ#fGM$DlT@wtxc}k$?Dx4F0h|2X| z;803^uy&>6iv&L41N4~Q8NqF!-m1GV`j5#1Gz0aOg1jjfdP4wwpqwD7#z;h3$uTU* z(%|v1Alo5!hLUx%XiU3Nay3Fo8uw*i=<$dXf{^7{|+Zn|2XxxuRh@Og7EzHn&~v)N=gTCiY-)3fdML< zW}VN(D-7XX-`i_fH1dTZz{SK#cgF*S9jlW~xRxPC2*7WIF9Xh25Mb`8?}o=7fgs=~ zI6BoDh)8UmD?jER(6AV;i|(#$q%w~5jbV)d|0L&Ng{=8&kB%DfunUr6Y-s%i0&jS< z5b!5s04C1dX7C$uQ z($^N@9rh=f2N$+lskVmYAl=Mu3p<2>_OFD>)9gwABxf>dgC(%&;i%0eVYj9|q_egBMeLaPGlMk0a_TwKc6#UnOT>=Q z2`4&$<4WvsM}_{5Sg|cbK(6|6BDo_Gp?g>>a~Sg@*}C9 zS7aLo6cRG^VKCP6s`<$Dot_I=B-jzT!%f2u4ye|)>r+9|07Wr#51K%lmfEoOidBfr zJkHe**fh#io&E7UB5aY}!CoHm!6GX_Gqc0zyI5YOl>r_xf8%tMa(^O2HWi?5Ox0-R zrSk2!B1KeAMFdJs0QtUOY<*Y?PDL0^5xQP{xN*CPDr69iA16J_8R91a{uS|Dy-L)S z9jZvw7~!basEA&GWDXg3@Ol#_HIa)^w;wHmEp0uiiWz=QY>shS`l>)0d#AOWE66M4 z>&@HXmmHvL*R@MBBZQ{=)oTO_#>4$#;#S~c(MPIG1Kl$K>%w~~*x!v84$8*S*wLbv zxM3FNhhqr?2?~UEpF9HiKMZy>kMj@yZt^o=;y5khj0F!OP`Y=j4Gcr5;bIV2yv1VF zat=ElN|QT3OGEV13EtT`yiWpTv&|fhV=A1^I}?6wKzr3jZcq=&{9V|<4_MpsP+RP> z(gh%ke->K`^}1WlJ~Kgd{5Q{oZ$gfKO5!hj;j+gufHGrP4w$PhUWgl&!2>*Hwn-^0 zKg|)XvJ+>yTCPK(;WkwotuU)M8hjK1Yqn*^6krR49%6$?pCC(Z+7E9v^wl|CLL1^V zshruw=lS*eiSYd};&|u5`Jq2cVAQty0@dGQuD^X+vLZSgo-x^}jG~}Pfk-8W$#sJd?_8S(Uv%;)5UvC?mKvuvp^da?l=tT_OBZiE(Mi|XB&l@knrG~HyF1(!@bb198-t&MO9zl&I2-H9h%MR2ewKa#gY`G zxDs_DW%~Va!uu$V@qn_ru(|cnn~g8Dw-JIu+cD!%BO_vtu*?|*13>f*F`}4Tk=l8i z+u^zr12V$*F(N!U40bw*Q}@v1hupR2uEJbMDcK`NIdbt0nHTWmomgvs6iZ>Qg>3`< z9inyCD}yG&M}J7ad710i<69{YUEZMIQ)|&6t^_|vmIjFOu0Vao%-eX}leT|s;7Eae zEp0|9(C+DJI8R>d8mW82bRb~;PY?5JX4DQQ+Vs>?D3qBHNYyYF$eLz$O02CbPc4tw z-ZKOJ>zF_IgiV9N%29_a<(cXAL%x^uLfyX$uiB7}xB-n99!I+7MyJdk=zmJ7H4 zVBL*_{QG5qB(_2DVJ;x>eB4;;@C=@?jWziLqZAjp zq1k%F$x#4-WI;gpNOKo)iVPVSk^&HLA%5zxY6S+6v-K#hGMT`@kHYW;j&FeBxZ`XM zDh*0_%ZXv_Bi34aqIGRm0|!C|!j;i~UZY4F*kh0%kS654voSqj6Rbt%?Ggg4$1VYN z(8JT>S3FFbh!iGttwQs{)ZyGG!+fn2|M%k`{J*?#^Q*wgkh6dOiYKdDf;v+?KIl2N z-?HuSaT+&JBDd16h%%9DUlS{TS|4OGC z`w@DW;fnTQthPiRjj(ZyWLd|y-eO)J`?(LQ-e)^o1C>`S%Lh#Gx zI8W@!tYDFtrp6ukEzm~~jXEXAN)I8*B5RRs2~B%yn{~6)<7qbzlRzj9FzIF+1E^{L z_Z9~{S^&{U02?5xE5Q)di`N7H*_z1>kaG;Qv&#qFW1Dszj-3Ce%I~nQ-4`2fphb`Z zDgW$hc>sCW8bcc;J7E#4hTqqtP)i=UX!<#-rvAu%y)tj$O|yX23{7O197V7g!LH0MtasrZLB|;b#BsSR3TG;!ROie4y=*SaAoS<~kw9uCHYX_i9ZSC zPWP=+F@F-hH9j5gkOT#*nY%mU$sZ2$eCEz%QAxB!tTSa& zgePp61)9>0&5r2Oj~q#k+~;~e+Kg0*LVNJv%D=7a-)!Ea#6JOp$6aD3M|^yP5~Boc z*G_stZZpADJ%2Ha$qc{tD0;FJlH?7M8~u?Gg4gSe89ia9>`rJ?=TF>Eu^Xp~!HUKp zNjhh5gDA0xH%A#jGAH)mxN^xb{d)jB&~QMbCHJU0LA0Zr1NuWMZ=I=C)-hWd< z=eriyyMj5`5U14GQH;h<$sO{QFhh}A#$c64vH@)U7s8}_6cd_+bY&G1f~vyr)NVAj z9iOhsk~oaFPie^G!T&Td^)v|6T|d)+UCah|Q!0l@oxO@WmXs9^-lfZzq2>EAF3CD&lT?P-Ut!@~|-IDjdoV!XUVY6E8 zvH&J5fBnSH<)}kQqY|YptJr8xN^lK&ul7%9HPJL!*$cx_}eo}f)==$Hq|D<@VO^{!fWvH}Gg`KLc2 zFEzAQ;TW@iNp!6t*WcJxWCnsfrNCr08PybI%KNaNr^d{3T)D)m6>_L>T|5LgrrN1L z*72k;FkV9p>&@J4T8T1m9Icvl=&E#0o5}y7zxH|weAtz(^?40g%Z5&GSsBfKql#D< z7cd)YR3I~35HV;>gJozbKl;gK(BCi~LdpP5+*XX7bPT!n;~XQ|PVE9i>0|VJ1Sn4S zYgl|*9vkl4vK_jO;Amk$A9SIT&o<`$c(_LSD)3*DWj5x`P)1*+@k_y9L_R=GE9?t# zCVz49(_Apa-hP%@cRGnF3YDrdiI&7AsH-9*>(R%EfhPbv?*lFaG=_X1S?hQ0+3f%o zpBvo$X8pe5gK>(3;8WGh0x3Q}gAerJv`2{E2gG}96+&S$3;_oBexYGl{C&&CKpHY| zPSciZRd9oxI#k4nj7OIsrE4qP4$9j&r9*72^wr{@-dVvj7lvXQnex8QU>$5+i)#~-=FH3aRu#}0?E?Xz2KlA z0X%3Y@w*LzO$QT#Kf3Lv%VZ2Jp;6aH&)} zFv@j1(s73%?gs>(W!OL|mMFGNg_KUn1mu!P?-?`7hXO&S7ACJxmJzsH8OLt7$KKG6 zzuW#=Ud=I-SegCjceihln`IQs$K_)(vDl1a+|hBt|EXwR_kJDrxZjW2sgr4fu*?y} zrY88_x8A)q1mXSyxmFX6Koslhbcs?hq$BEhI-Ht!`siaWjfGhl)e5u#EiSN6HN`)e znmawchp6uoTr%qd(@$@YNsA@V3%f49xo2L%t zT@eEfKSZDNRkx{Ji-#)5P9m}3tY3rHRr(e-3)x`O4Ka93tJVsJT>yh91Un>+$&_yM z&j1St1muQ&>Nw^` z&T#8B;#GYjouCS;5gJboAA_{QQ90Ow5=l0Hmp07M1d5I|YHzOeBA$eJf-#QafATwa z_pYtAI>$$N75BQ2k4=j<35!411_7jVj;f$Hh z4)ao(6#)dh_X=Xulwdb&=FMYIm9Dt@@%Mkl&}(BQ5)N-WvQlZ{`DPlKXpu=)v1pN% zO53#a-b-_W4Had1uzWazCLnV34sN}IPWp(Hf(P16rPu1_U`leGr6$`l{2WMyI!224?{2jC|HPCA`{7^a;eDu6oUqc)5A;$f?`?_GJ_*z0x~2( z0b>BcV4z?C3>yHCv?>EiRHTDBapoMe2RIYA_xvHhSH7xjWSD;XdanpMbIxC9O64HJ-^0Mdt0J*QB?oVAxN5z0DeUCj2{ zNS&8l6G-m}_J~4{aUsYoatp@qVUFCG~2 zt24T%aD!8Zi)X6GGIdM~S)r@EZQy#(87lC{$?vFTDG9CHK8y&3mF$851`0_pENkLL5d`@HP5gyv z;7e5%jEp*hSv-*E0|&)w%S_0aO?9GRuQd#SsSUkgaCX%J>;vFJo*7g~c`G%B!M2>L zqtgxv`*eUheXM_+6F4SI*(N!Yq}VMQsbv98p)%cN8}`Mx0?2E3xi?I9&F;`BdMC*X zX@KjK)-C#BlF-7SJ>#%Nh->(C0KX?DbdTQ;fC-ft5UUlm7d>Qsh_baFYEAHmd%922 zN2_(4=>^0g%hM5m9uQ}UILW>_og(NitVe)>xeFO^)v%1+_b#9DBpCz!*g5f z)4uP(D<;%22{>WbyPSBRIhy+4Q28{<=UZYXeVyG~co~X3n7ZPc4g$a~K|{2cP~a;=o~{ z0`vdcBT#~Dm}3_-^!byZs_m(+@GRW&5z2Y!0>4S?TL||#`@~&n@|2w$QenOBWbGKy zv7?$l9Nkm@eRbNI7*WRLsp$?iJ#(I--1Ro>*oha2+jdz3KLXP=c$*DB0=;5nTpdmOx3hed$`+^BOy<>dp*8h^68BMQ^4+}`_uYE zjO?@io()56TVD_Hlw5tVA{*MemF!phnh~Ss3#Pw?g^N)O%r29=WD(ud!AsplejovE zL06cj$Ufz(ugbE-b)`gNyFeXvXv%j>c>!I?MA3pX(r%ZX?@K^-)^iLb!&9q#wkUNi_^fDP?1L764_*dGh4>7re zB=-T=Y|zF9jb)$B51fs`JC6xw%2W`bbV?d|IQJ>>4Wx zG!gdt(YFG4mTsU`PB!XxdC360>L_frjq#ucO}Bu-i5WACA6V)roI!iuL~CQ&`%QxI z!On572}-K&vcK}Er5w4O*W0B3#CEXhq_LTuc&fNwUgZquiyMBtdM%z@Q!C!$zcsZW zdR54KnDXfA-t#RnXV4{Z)Y!$WlSz!eIy6b8FH-Dj$~N)38Qk_I{7dqd;(M)<&RAGc z;+J0ls6nAghHM@IO-(Z(l=@tCDF!w;=lpxSebhP_yxb~wSghdr4}#o_HA41rXArf@ zrwb!yDDqy3ZM(ai{pU=0aOGB5LI>$jCBhcC9Epi(@*LiuEGzQ+IX#L7FV)-rQh$!_ zVw#pICb#PAR8*Uze;at2Y8qf5eMwq*8=C963r-0uPN_P?9|sX}$6Q8S>8ozF>ZmBi zmx82DoMyR4h=YjUjSXl@!yM&F%0*FU2$erE?6IDjlu4&3{F~6s2YjnZ%xzymz-8d9 z@{62k#DYSFpRvt@Mcmj4zhwo=^&)>JRjD~Py6tA3xlcUIGoe0>>J+P>zX7Sh48heq zt$uh=>}0LV6Yk2-7@eHcD{6#(8Ba1Wx&kL%kO!+QtHXJ+kv?)gnsz7^JExo$Z=u|H zZ+&*QG@K8}S>;_rEPQ4N>zdwkoI6J+SR$y3;SsVg{9Lyi)#y7koHWaHB+*jhTPu8la)g74HBtKnP}QtK~)pyad`B(TZ{^%%mn26Wwvc z2Wpa?^BYy5L5BA4vxAJae&zrMlmv$JL*RrPQ9p3!<&)f+;$2%Wu`u|ha7<|Q4+~aa z2AwL4sGXO_Y|#%ET{?v(#QOngG~q?AmBozZS)59W^-%hvhoh+=`l+IlAsN%G%lc!~ z5+fVM&?;KeIXQ%H4Gk_h*6AHZ;mvrc0E+7hU8JC`&^A30z#WL82$wWSCAHoy-&jW& z!##Yz#^X?6Yo-TKE}B5CZUi{xHZF_#Y80U5gZ# zp@}o0e!JNiGoD{@(UJSR9R)@SAP*PNA0d9myzIh@trBxl$D!8r~ zp;Me$1u@%4Y?8n+(=G7-#zjB0ZuF8TL0nsgmP(68mVQ9=-8|yQfZp;Lh=ng5MFXIA zS6u5iG;1RX|MpDj5~kCb$4~?m2}5WTCF9i`HhLNqi`i$bC1#f9) z-EtE$pcYgLVZu9>&U0QpU!IvRka~^5<3Ugk)Rt`cQ9_QO+K=to$L4N|D3duv5}UpF zZtQr|$E7NAGwVX$Q=KD2o;8;IUJ6*tj^vTBYsz%@QQO{eq=XS6C4+L{j!~f!#$2Nc zW_(Ni{qO&_R<x;*>X`Pj9ZHpN@)^um6?T8?=rrjZHV2Pw2?o32g!tu(T$YgWgm~v3 z`P&{tH~(r9t~Ud94&5u(eVZFhHgwO%B0A+;kfn5AX`Z$IfvKQ+7L$2J%O-b~=mfKm z^9G^f+TCZIeH*?vmZ!h$#ta5DK&JPgKXy7i7WYAJ3m|BH6Wi(EVu61gK|dy9*#ejS zDzZy=Tyt2b+z~dS%J_d#U;6#o{2e^wj^8f_L4dUQ()sEEn;I#QL=}^!;|zmKu)k8~ zqgrGQaQ3>i4)?O&PN5{Bz@RB1N*Wd4w2iDy#Oqb>-(^E(M4YfpLwm5Sd51a%YQtgBdAC1`7yOFyP0n>C)36xw|xi+;fuYUSKid$9#fGM|1BHPt z9jK{#WftHu2t3Xj!QyINI{zzl%r>pHT_m8S7~CKOO*>mbT4l0r?+oB|*Rs~HWUWgI z+O=88ZM9Rx_C$oQPEg{A+NB6Ql(&C2TOGIi;ZOmGzWn1>e=}%BPAb!7V zE|#uO!3W4+%Gw5Q$R!aC^0}FcEsTHn4F6MKIZgs{=gBp@q5H>*jaTl$C4zycCIOGc+`sovlj2tB7aCM?_NUMtf zhcg{$ek6{48KOp=dk10WrdOO{rOsaFB?=v*v@{;k&8gx*ipG>!#Q&j2dIGtStx0C> zPWK_MDH3i_>hjY{&qk%!XA9*bP6vqJRXy_mO**sg>38-h<+06w@uvBVjf+@uyxjpN zlm5R`Vj%m5T`1ZS{%u7TNQP0v=+ijg-+)Fu>f1pSWCA~rM>#w9dj)5HCsBG{Vrv{h zaujkg;U9pCE2DClu#tO4!<0*@qRV^Aa6B zXA~SLbRYzbO~$p=b<^CA4F$6GUcWrePYx(gz159=NUNFL#VFceImzHuo79bQ{uUHcgtOIF+^FyYRfbrE2eBmYjEG3&BtEIan)O~%e zh|{zZFGoHv#&P#iD#Je|EBw%ym=RPMd&H_r+kyV5RYe$VEDm3-VLU_}KrSv7gN3sA zKkm-zOi8Ah=_|UivXPjckcpolvl?9cqUaSzKVW8KB%H*^G@H>R;vgddo|~c7d5Yjo z@5zL_@!cK(v)cWFZy-HfN(!Iw7B+6$Yov$1^>=-+2W7i{oU|G`TtYX5BLMayp2JUU z9a5vSa9P1apb>dk`4!HYe;Yt;wfXmhkvPy$_IqwPyOs8k{T@Sd=&aJ>q050!K-_;R znK97AaTq+kJTh;H49O8D4okB5D{JIzkOoa#hJ+t9J1OHBns#QL;I!c%gu5Fr$UH;{ zW!Z8x!jG1fP8&vLg0B-v(z_{HLBJVGr}E2Nky^QESCkM{Q6AYnQYQE+wK_jC=Lt~w zJ?x)Mq%-M;aR+dFmHNynlC2(9!(mVx?UN*pR+dLY=QiGMsOi4#^XLI*p!9NeS9wp6 zVCLw<9PJ8tDc%O83(OY-2;Lv$PC!=nu7(piA1RW*wvEJq<;c8_R#s{Qp zec2dnZDKf*#uZH=XROaPt_6D>Hd&S+Zq5#@O)~NHr2*p}#babdFDFCI)vs6f4 zd-!$SbnwPi31l+#Th9HtOy4hl{51q&41DHLXRz5e7WQwx$=f*yVQ^4cJNa@P5!oqC zMUhJhy`IG0Umyw+7X=A(D{a<$C0W#{etc}%1uC+SvRcTs$0Loysx`E?3ts>+%l;1t z4P+nOek~K*Zk%^4TiDM1kLcc!2!S~=`VmNp2GX)B9$t^;EF%xJO$Qx!Mz^}l?#r?G zcJyspd9pq+r#apVoEb;n=xwa{K$p)z2HQ+86Os--EO~Rk;+sc_CZ0l4ogQ*0JKMno z9#fvo`4B9O`Uhg=#OMx|Err-7+QxwxUQB8lOSP{&$gkFb++s!AxF1B;JqyP_l7%t| zoY&k0#)6oH_Afh((nhEw3}}nb^b$Vw2s1@hfn*mAVwBrtH*zMzN+uTU&gss~`>=ZyB zJB74LX75iLlP?2ZYlg3|_2{nX& za>>pFh^X#HhC=Fp0F8>d!;xFTfuqq!qB||3qfgpE(Ii# z1qN$=(n%%A($C)SAc`i23IuU2lX?JgevO`}1((E0ybMoIQCLmPkD5w*luA0nu*$>H ztRONKu1jQM%plS1(5*d_L=@N4aZ}yBwufNQH5Yijn5T*21Lr;VqEe$4KX2}s$n-vn z3W}Y)G@fQBdR-Ptp&@9lqE^)eOBONPfH*Q9yp8g!VJKM@mfj!K^t6c%hGLi>ZWg5X z_Hm*&Q|F zYEq+0#-(HNMUp;3Mfc0F`w`{Qi+#S14&Is&R6c}_>DNe&PyiC!9*>`Jk(>#Nmq&y; z+8{7DSMkn@)^Y>gzw&>h66+qH8g5|=Z+A)Ss7J*wRro?#{Fdn@mUlmJnEVoov1}xME<8y5KaYW*tv5Gms@Op zs6V9eN4;;&-^#;Eux2zW=RFe_mxq`-yh*Ctn?bnP+@ znKvvPDdK1{2=CAAQ@f3{>H6K9JUW1_Jdmz7%)i@51G7lAaI7=z2@n69|Lik`LY@34bVvf~|W0_}ix*kmx4B z&*1x82H*JXxk&+yrU5cw66$Q-KBWqMS}Bf^aJnn^Y%Z*OVg1Q#>fh*AfVuxF7(#MH zdZ?r@L-@bp^Vz`$`AY?eIZiuYuP{MI)zf(~Wkp-31OdWS+PNc@hoRQB7!uy9@&S&6 z-V($cJVc=L-PdX$Y+acKUM)|tiQndW>;WucbRM|{HPMV!naP4^0arRhZS*&t{e^0I zq{EjBd(6>rv5@h8NRB6 z-ac|fn)kC=HitJAEu9aA$*K|$ z7*SX1D^>r{_B|W%#X|p@d80u8x0}E9OYUXJ@<@Yk&};)vm8WI+aWO0SOnd*5j5XP? zE`i;mrD+q`aF#G%mw1*QEmanQYc@-J>Q_e2tYn(g6EY~xQi)Ib>dJsN_XfuwrTF8b zssetES0&D$^NK-761o`Q_*PvI_w_I*0yNxa$`Ff?th`E*Wq%7GG6l&5yI>W`U=5@4 z_6C_ANgo*K!VG?EK$DlS=k7D(&}uPQ!5<~tSEIEI2gf+`FjQf1NJ8|xy?gy%kc2Ri z`}J3mgCpV`uDqKfKzq6>P6N|44o88a2Qt!ikYPeVj_B|}=OC!%C%gY717eY>=ch9u z7**$MF6oJ2t%cmw57nV!;z6dUo>*$ON>u8I+lh!jJ*vZEQ6EqZIkb6*3 zS_N(`k8dtJ9#rOd3D4kJZ3!#T5c*0kYc$EmLv${N)tH_UtwNlRspXKw*^#nfY?Z?3 zr_KW_la3Tt%ypMy9e`ldy(ym54BFx}uu2!g=m1ckQ{^V4L95ieK#>IU;1Z0hYI)7T zO-iHBCKBAHPVxhb225g}4+ld}02S{C8Suikk-ZkKvY2XyDQeCYcSu&uEe=$p%!dZt z3vq!w{k{m+NS1<@N}L8T{n=b9y2Qw!?Pw#XJd5j*G1htu1L#=>^3p#5K|sF0hxaEK zAq4>bJ*3Gb5GU4+YG4-(vh9Dg$irLnXdKqJgvhj_Xe7{e%%+y(ryC$Z_I{Js&$GRr z5W?>U_BDGE9S!p!MU{idB7G5ZRz25dbX)%7W z`VS84t!ksK@S67!CteKchp4SYcN~y_fb}idQw*%Cu+63XEU6|%Jxjx&H3(EG(*B$_ zowje#%_`~r{qoxwwO{8DcvR6Z{r*ZP?C5G0O4i!}DyhUt2RBn>toKl|QuEe{3^qQY zeG5-l^35Srd$D3F>{T|~G9l&Gjcf&nb8mB)y~2ayhT|b>c;gRJj%UI}QWg?V^C?AR zjqcD0^6UX*e*u(ENS0*_&ErQ4-I5<`Uk;^2zV5#RZT97X_1%b>BV%KUjVr@M5{t4< zz<*5_pWFh1vS#4dPrf!Bf_AsO=4D0N(@SWy;0BJNET(uUgAj)Au^eiO!4?z-8=73i zP}7)fH)DDq%DP^`;ZU#J9)TcGVuT9bqC8Z2(0W3zP*I7khhuPM-KtE86h$Q;oz~a} z%(&@&OQx^Gxq@sK_B_t}jzD2}W%H}?81=wGYS`0g<|;Lua9dn{YzE-g z2xtHZYcC|tgSh=;^(vGODY3305OVEPU9mePf?#@%^uSeRPwD&sO%JY73Nn`m$DN00 zoRXX_R1H=;v}7+7$NJ!c!akmewdoSHGFi9*%$07@PcVTrMHk;C5_JA5g4O~km-(Wcp`)b>n?5SrGECGa`~b><&Ne}8}}DR+VsLhZ_;uEKDTM3`355M7K0{sq&;&WYne zUeUQ(xj0MS5EWnIk|5;bFl9!Pul&EQbMiz(7=qWIF6jk#;h4NHl6ctd@AOQ;HL%>o zf|)6>9iMC!&K{#2=!WKTfJdCS!tj~m4RBYoy>fuIK-&0T zNYtt;$Qi>Nin2hUPw~*1u^$mmbAo?mPL9S-fI**=Rb&L}S`-1D&Y~i0L0j*B5_WFy zbkM|*eGGsS#SnwR2>S!Qoy8Lptfm$qEcpkJQ8?qcC?mC!2U%#o6sFh8UH8VTQQfWt#EUG&sM@Zy7^V|T%5{N?)p z0a7|24=nkM^MK(EH?x7yxas}D{1`58QX^CCwj>&xx{)y#KO_2YM^0LcbHR$dz zG`RyDhTBB~CQ}m6iK~lZ!$d7huCz;Ljx*$v8g%N5HfU^j`1gH6MDEc+qU`3^hmQ)4A?f`9GbT4S%# zb#iYCt>7*}l}9B5s5w<~ARDo4L`38KU#XN^F?4SH2k>p06_adz@h)ma{k+c|6QF{AuLLOeX!R%L%Yq z_ewjyJP_OC@yBd`0}8#W8z;MY9ToTBroDvx6HJygQc(t`8pg;_3lhQLCopMJ*d(S` z5QVftiztyxKHtSlj3yzQb3Nck3?<}`gqVm@w8p;Fn-(qmYy~>2Ks)vTw_bM?c-sQ% zP;TPDCk6Fr%0f~!5p!f4?vsO5c?oQ4F=w&D=YF_5tz_$IQ zVR(979C1uDc+qd>KXDNB1-5cYn*r|-Gv0-OzPlu1|E!r1-1%Y5I}6_PvGk!;aiR3^ zvT*#L6p}vQ zVm%!Hk?PWyX>hl~oPsC(LAqImcHMTM+naKw@2-0W#^^e+Y#Lm1xT&#HOU0^09EQ3} zfWSP>zRIHw78HIjE#l~=HtpECjU-SG5T$QA)tLL{XQjW=OJV(N$qa8!uR7L2#zdq5jLDi=+M!lzvYF?h*f6xP`rc)?D9iTLg?AD5Ur_w8_cA11DFGN}RF7)-#9 z3l%O*cN?DhYG6!emH4H^9bQYb9CiB)L_c6)Sa6&Jr7AEwq{3=WDk?80gYM_nimDSr zaGlx{6g;k6@H)(ASVbiRIxgySjnskg-&g_>Xx;1MRw4G}Plco4@7xCvdH~c&ck<{( zg<;vQj?L-!T$2=2GT0Q-I@ql{TXB*e1N>q0CVlw8L>bk8PUaQNg@|o=QvCAWYVNEvpf)iVlb)_1$A{~Otmlk0nE!zu|?AcMIxrt zmk^Psb&^4Bk;qcA+=SfS&N-bTLRsyPZlYaIlCjVvhcuhR!$+=fr+ajdF3Ri>pV(@v zSR!{?WRr9=VgzfGWMyN2!aArbyXI=Hfv}4ZiPvXkJrlGPQ5)$E7=p^jOP7$oV&e+< zFjlTt&U1ltoqkb9F{!RCnuv6^Syi#Iup-m-<+;pC&f{eaBO|&&f-g>o9(Q!r{0bO^ z7-B9%fc?&q>l?`i6G1}y+zpe zJJjvBELV#n{#+)^)=;V;9&`&elCRi=M z&8|F?RXM{V)@w^baTT_x$a<6BHhK4^vh-O8wi_YXvY;Z9-{faY)*)ak@J5z1Jm7AB zd|_>cO*VD{qSGsA&T%KvEUv124t5%`wDM}(ZLy(fC{YZcHo^hAShA6X>)x^D41*mE z#hQ>?pbkx9_;M&7;!h6RQihH%Oj*Y<4W)MegAGzCAw)1+M`nEmyBFf-`TR9yb3NU1 zY#p1cLmtiw-a8ZJl{xHZuSyL{?Zg18ziI`%WS>SU@a}e~pB=Ch!FU_Vh|NTifEy!i z2~p8ZL2B`9j7qHN;uf(MG&Q(RiRR>r8_7MT&VbGE+E603ufyJVSf!!l%&=;2nk7ZL z_`bF*9X8IYQcuLI0IUWLD$G+5(?9Fr5&AyzTtCPj{9eWUkh5dkX*FKR$2mJB+%u~u z|5B9doc+8Si4%?rv=+q%?*gbF-%^r*+A|L3B#O+jE(WRhnO(~SipoM8IXUV_K!W9qm2~~5$aUQ)NVbGIj*`NAuC&;5 zD#A3DbS6r7cK0N`lj+v%-Ji2QtlMs%UfJc|BSDHGDQk3)?p}8>$`@7LoXgR5OLF}Y z&Yfh&49zq)>c^SfMcj*2CPMa_8F)~L1caB@j2CCXzARD5tZB~kVD9sJ(pDh}5C=Ak zS>%wOAfg#VC%@eOjuOi6WhQS=PEWhNg5N_L>&})E<>>uUqGU%0%ct<2Hs=Nm6La)* zK6Xap7tXf4mqbjcmEVAs?3&l3_B)1y6H#nx*JSfa&iyRrPX-6xm%VORf94%xte#`( zFdU&SLgYncBB+N4zNdezqh(qwDj@#s>a6m4WtU_h5P+2vUrRcRQ0MNF6*!~{a3Nv5 zghRYfG7%0_&CKZ=VUr&kss$~AKkP~okvf{^c5E@0=O!u?R;MreQ}Gs>nvM1`op4S- zc3=Y*xNfq*!b|a?M4rj++1Pg)S-)vrH50RdXof56f6jJ>xo*<}z_YG4`{B$=0&ZN6 z6hhGS{m(0?7u;a3-z;Uv`#h!xr*Qu{;8&no%p)-gf}rULqL``zOrGw{e;6En1@pd4 zLWE%QZsjL@^iD@DVJ=XOrgPRQ23CH-U2H-828=XOw(^BA88mGUL@_jg!RvTYB=~3} zv8z@S&xQ@JY5vj+bd9 zdT+!?N)0k2`cl~*U~-1x`TigN1b2Bz|30^c)VAv>Y(gf|kpsga0MEH+v+djokJV6< z7LmC{x*!lE*-NU&w@|4Zr-7zrZ(G=7*vdFU*P4H z5F3=^b5D-87>44Cbh6WG&9LIP5kC)yJ_)wy*KOdDJ`XafK)>ZNBnQyU`v{(;8k!lf z)qv0oykrK8_9F4DO=GDZ8QvX_G3(eu$YePs8;sQEaO+8-3~ur7{Azh^)#};S{F5!c zbo9NGemrN^tR8pfJbNEU`d>o^Un!Pf6q0=?7=G1^BhOJC!;jxOn2L$hlB#M5;&04xpIi_W7IDJ@`)cbUY z5I@x#h27B9iD_GcjX}BCj_eQef$fudzVj-62#kEo<$Kmm4<4z_;@l6)!y4~i0Sb(p z*_bC=&N|Q~LCP(&?UI)>8JYCTkFq=I*Y~=Z^yB~R7-}S+drh#_bQfjWrABxlw*@VbQN7ZDII)(tP~OkY z1TUKm+EHhL^CC=sGa3P5nSxx{8vuVyuJns=D$OKs_g8=Ew^PcfVk+ZF9Uwp{aqqBz z2}0b>m!d{WH&bSP2)OKDwz&*{Cm$c%G=iBkQ&i*Z4Y|yjS%hIY zcMGwd!io&i-v%6ZSKD}zws=hiZpS4+~@r%wSVTBFR7aGZ;()q%2Te zXPl~vqk&DiAC7f2j690dK(MNGBe4Paw*)r;Q!EFo)XQV4sf>0?nA}Snk<^_ig>kF%bdMRiT&0?tI8b zGLn0<#1IYynAOU-R(}bEq(-a60BB+7_5tuz%-;cB1rZA9=GVapc%tg<74Tqdc+suI zR=mdKi?Pa*)HP;*qL-F2C72yit~<{__hSJWuoBIif{pkA`a_rvC&19|y!K`&f~dR* zK8>R8wD&REU(&OcK&H&5+&;T7VG_w)LVXqU1t^n}s@IHJo z11%D%Dhh#SbLOs(yVbTbg=OggKM0t&#;pRTjWr=4cxK#q zW4v$peJ7O|cPZZS{|q;@pAUsM-frcBAO!m%$VcGM$F!DY7fmlQ{G(?*Pw{u-R)@jU z7M(kdoip-l4-c~>7%q!l#XZ( z`)JqTqG*CGaUd%Hzg;3S2^I-+^N2UbpEy1m;-*LxoAtC-fgT3suH0zo&<)HQyzf*& z!>xDAs%)u8L6^dzZXpUmujIEZd*xSGMiJnw&8!MzEwewDZvJe+%~xn6eSE9?xlakL zsoFQ&z|6fwUF=pDSrqmU7vwc0@0v@INPAV=Wa4=yO>Z7(Y2Z9)?Ah#g_LN$1aE$sk zG)34)O>P@#QF~b&PaaxWw7kcIQA)1RSvm`PR>ONbj@dD6c`wB0@xB9@^1x%AP6zdS*PKw!Hi)bLpMBfoJhp zFc#Q48n-B}H_X;ae)$(WnCeUx~GelxnLPR&Ohz3E4++97jHm1>y+P zS|)K$kMUqO3=ItaZ3r54ZW_?HWjLQn8)2nmU7hUbPzdW0_G7|lk6kGlD-ge21i zm54{(w@L+2d7*TwU8x(ar`Ob4CrHicuT$l?sSHcr=|Yc_V@lLtVy=(TQ+BO&a}zIZ zvH>#HTKP7TJpTvJq|Gz+*IIQh<0hrl$^)1bs05s4Gyo)1FaRJdI3y7XB{HE%mZcvQ z;su5ggG>m*Lkfbx87u@LAQ_h75eP#75*WaYAxN644I1W!&yS@cutp&3cep-Qr*ks+ z(}4fY0(O$^htJMV-3!sw3nil=>(B0n{x#MVZ6m?ktTdJRc^8G3?5)Eb*|>iq>TTg( zd}gmlROZu5)H(*YXED!^tx`C~;ak^_7MRkvT%31*Zibt=IG^TQXTy=bT{0l`-)r>n zgSyC84FPnFz=;5Ut;A1hNF;nRn=^HbP98kS@0;~Tb1zbx)qbp1A6ID`>VvsjSTq8T zkn-atT1@y0f_L@CLV)%qddw==1unaLK zJlJd+CMO=vkE=y)wT;2ri2iK|6}HjvSBDu=hGGP5DghhICaIqZ=1_X#%r&j^hA7x${1k_;_7z#o(Tq` zvY;?%dKlmcN91!$^o+Y&nntiuLR0%IRSz*0g68l2rtH@@5n^2mRWO?D%xhoZ$WSA8 zU)wI4v3m&l8H zqZ8yjA(7x$B-xXpFl|H|Lq&n0j%P8_;-J?#$*CwLVT=?CbE501sKPl5sAawXnS3Lm zJ@XAD8vh*>6XyVh(yPf{AK;c&50Ng2101g%uGxUf_X=1X&2G5uXr=k}SCs&n^Uof!OMp39x&!|jmSl_^Q>AVS4$O6@X zfr5zUR;>rt!X%P{cBoB9B8XTn2zqjPwO}RfQGlkxtOq-yqZoKYt=zeEq0~0!M?L&+ zlWd)%q`=NgqRRLXgR9f3`imhlw+EDRAH_hWtIGpMKimie^=$kC1rl79G`ft? z3_p-W1EDI*2_VO&Y4x%l{Ut!vtYt+(3(kKWyNO6ZmmHl7ma-eI9K;I}1JZ$nb?~tDyk|Icsw~2yNBBZtzDP<(F+ZnXMxMm-XTLuoH94y*K zvG)`}ibIP#=xejqR01JoogaBiSM=s+a=%o9F&ZDBD(M(L>HohT!uy;{DA>h2^yDH$ zgrydDHC$?8doOSljkcbZtu`vOpj#bjW~sdPA;>f@R;NjSxqV11=#)eCT-2`z>76rz z(d24gyyZ0Nsd{qDKdTT0zXgPJg9IBia%j%JACj>2#C_lwRG0m-(Z29GiR_tRs#Ffh ztwOGB1?9i{-3oBM2wy;*zh!+Vdn0WFD|a^E=Zf*q&Wqo(z=}{}*O}=Vf5BsjD|8Gk zt-2i3v5z3vElSQ>Df(5?oqlhcD9YFkh&KMIPcPBSPVSIr)brk|ZtkeR7TJi~$sPj6 z{q2hAcMj3dUQIm1UR@yvZA~P8f!Ik55L?P9CZJ4YCX8;-(YI|v|0c%=q6Uw|njjlK zlTe#{!`t4Ih;~ou-(4)!NB_aB{avwtR|WtvLp)6NiENkyAn9KQV1IH0yBkQ)ko@b> zVjEA0m;n$~A~Dg2(!UziHh}ZuhyWdPL*eEy zfwF3d5O10@)%FIQ^2mcbQ~!Q#2a)@M_E>+zD$C+fl%QQ=@>v~X7j zkcj|o7=9Qw91NX3C|?_H34lyO#2f|e7xY@v1)QfQ>~nh!N3iHs0&>V#02tZv76NrO zT%Pp3USQ;amjK8ncHq0@c%YYpZ~AI)hdn&_0qUpj{pwjKsA?5SZFZX2Tr|NxZ#wN& zVAC&z4i^K^&eaDaG-;-~Q#aT5&T_Nn1yCRtm8(I0v&3LMOH_{@KOTwv z2g7=!G$L0*iebd&%kd}v|FgOgK>gcmbB0`v&??6}$?j84^}udAXns-vphL1Q(T$P| z-$(6)qSyWzV!x*PJAOO3)+iMT%Z4h-qSG6)7GD6(Hsi!fF#wn#qq^d95ny#wdPlX7izZid$Mh=0v&)DUVZ$KmxA zw46@lYxnZj6FxB z1r?nb*K*tuTC>JMya~<8_=^?zPLyZM0`KzScvRZQB*(F%wT_KO9(eyBpGUsSh%d+N345NGGU`)C~-x(_c`$pJIV^A9-t%d0lTL?yMi(I!XiHj3T zCeGZ34s3rvLF`*?D(=`cjk6lxF%=S-HY!yZ&Z_J)!H2xrsvibRpC_(e|; z^o~Q(ut{J$N>WMiggR1zaD^P0&VZ447TRgdZvr{(?zYX!*Q(6x3sY+_)(6bJpgoL% z-tqRzy|m~XXtbyYgfECBfO?)@!Fc`;RS0b|q%32`&IeH4*OLX>1#RLa{J&6lAe-&p zq`8C!$hgkdi@|7b1zR!78SpA_8y1gj8jEDa@Oc!)H?STB=d(mc53CSr>Ax6A%u}RH zEfG|WS{6YXS0^gZtz&F|ew40+ij_5)~1G zfT^lk0k`uaFAqr>-Z>^v8R~sY>;yBGZ>{UQkv4XE>wvE0)0}9x zsczoH`4vv5&4ncKa9m#HfUAjr&Rba0R_SFkG(YvnPsxVq7aXzhd%;G>6KROI_2K93 zM2f@N{~gtd?FV1I1<~nV|49XE2|P;n6vSYvsAp!n67nz-nC>DgJduRhXY+DmwibZv zXAq7E$y5`dE)l$HP`U}6G{$2&yk_no7Xwi$kCT+4n)nsd@tm1Up`6qj}A&+(5FY!BDtv5tY9~x!7ZjS@jR6$lR}zlX;Eb+b>24qjL`l zg{&)MS|wTlvp4C&`|m>PPZALe9ue{}Z7T30C#KD+fk!70c)PkLE@+WxPBG7qclGDM^(UhS$ytVhO!z8v|YjbRSk8xV@sMnlSpv3Hcg-U*? zyCr1m`sL`zS^QzaY29$)7{*&h%2-~d@;KA;P?%#I2lN&n$m97^qsvUx3VpXF9|zv* zU)K0&*I1<#UR`l+k?bKB%P1w_ov--dF>CGg<9Kt8$%~b%AxZour$S}=~`wiWBHK9D0^WSJA5Oe<;-4Csv!W4I4`=_~CNryp*4 zj0V#B_=^%obHd)3M<`I8W-_%WgIu{i%HE8=p;3l{Ht_09M}zf7G^9-o_Z9U9aH+~y zTNO5%+H*DUS4Y+1e6XP;#XD-=w#CL;UzIHeg98Q&CA#1!W@sDW#d#_5kvmDFZW#=* zE3WJ6>QOusJM&qtif9VUwh%>X^mB{OOJ$DC+#>#%sa8p^>%rN)FP>-mt4_IC zDSlUBuk@X!sggdmqjr!`dE6R2Q}YRzm08rqyR4 zARk?35E`r1lEYI=?zi<%7+N`dk$wyrh;$Yjl50@H9kzu+KImQ?Iif|MrDa}@+!=JT zWjU7&3awg}zz_A6{%TwT;8m8&GVxYce?Q5$Mo|QKIVth z;b+Kt@R%9NPgE}R3>`Ds06C^4Vd3l$Y;$Aone2)Ca7T?PMHpzzs#r5)Cj9VRgzd>e zxYM3B7f|e&6ZDC#t5rl+-x;|KSj#H0HfB;WqCyKrMSuq(VTL5yx@53WAm|@QON88Q zD7hJy-d>d*2ui)RCKz4l<)Y=OO_1;Tu5F zeY?W$vOs&|YC?ZFy&ZR0+P`GMD?P32z#l*{CYK3E4)8l~q=Q&u2AQk2w0O=QQCx=@ zXYeB8V>Sz*?i|80qH0YE$}?4mXMlfq0N5Ae?oiD@#f=3qT|SHs(~%HiL>LC3M$i%- z76?>LgZvDr93)l&st7KH`u^z{(G`mC&96qL&=mMn)%;{w7@8cI z&=l9vOgCND$yX3BU)z0a8YIWe+$*LX4gx0bm3l3~bc~Prs4N}K5HGNb!zbNO$3>$m zdL0cl75Wpz1_K7XvU=+)b%$}3S7qFb7kq;!*4NPs2xzx9EPwo@9xcm$y*R9{1Mz%* zHHK$}Gaa)y6kuR07rYj|MhJqIkmg({KOhHR|Rt<7H zX<56dqBvLmYB9}}vJ|ommistbD7M#lKbO4@GO0DCAAkoKq4e$xOXMD8(!Q0ll5TYndotIMUKFb#gYwmU~`4jmgX;j|^w{|JlOH&l@B=V7G*M%(f)y5AsT$IEWy;<^5 zLffJXTTu$FmL`R^7%|4ZlZdXH*ZRhWMV&3Nl986cg!&KT%Ul|>J_z8%?~|E&NDaD{ z^?B5*U%7&UW?$_PQI0turcf4!qHKED8%HA^_Q%UNsU=-c2bsjb6C*78A|pkma+(RC{FGUa{KrLTR$X!k@0!t;~)5yXh;AJTnKpvHqHt#X?Z4ilT)$&^43 z<#4S@HoS>0Z`>!nVzyd_v9^}s#sH;_R@#@{z*jZgO3bxzy0471N!b9YOIrc_UJm2G z`}YHaj1&nKg?wi|2J_s-u%Qc_=54QbX+Vbv@1A5FmUx@S$sPDnt7hr4|MUA8(w|JSQDPG0|A z%QOU|OVg|EXc4PQL*g1a@#uEv0Af1pAcY|1leqBRWwhHe;pSW|jG&E9RgGM+^MJ_h2!)4V_k*G;d8F1IiazpKuG;(Bh4H!{IO zeipN~M;3poaaqHND$--EV-CnFUag?yRF>750SYakG&+w7JC1lMk$~J(^hpb6s!_cw zG(w%QjS&WSL}>w#{)#3P>nlB7fi`gBurioo*4kMyt!aL!HV3n4#0ugGQwrGF!V2HL zCser@k(5y4iNro=`zK%Y;5zAxFt3uKPu&$r4K^<8Dhq=|O|uu5Z>S|BIp`KRg?I7; zSCuz#g6q1KNy&K-sfI)s-Zw-Erh+MI2s_8X$Az}k=v|1v?#WDdrgyQpWiF1(4tfP# zNDYhfdV1SPiXCFrMquqSL;eq;?LrGFKQ(e}TZlOh7%$q7Vw6akk)5VqOKS3aosZr0_^NQChv_X;uh z#F!hL%&cHikn_s7lMRQ})DOC_GKuU&q`2&+*vYj844ln#U{aUAQeiE2n^`ci`>9)3 zzKz9=iCBX0{eJ^l`Vq5dErYj6J$#burL?VS9;)a(bZ^KLXne2y*aBI~sI2L5!e?3Z z&?Y&GjFuq`5>}1)F+!ag;gOyn-j-p8{RPI2X@ieW@rNCY?)Fz8s&}b?>Wp0U$hAfJ9Q@DBc`_yGg-~X}VTu^ok6U zl)TNf*6=uu{MZosx?T)orf|h{Qm;-f=4jZ}V>4}D`Z<1riWz?Z0FNdJj##%IO*q)@ zu>3INbw+-jAc}>z13K${%3zMh@lVa&(65$If%Z6#vlAUk=pGS6>{jIIAZt71ybrTW zm}Uhh`6u@y@k2T@(4r4e?>X)f(uF-3u}Jjgc}P|Rp1-ht7RSLr9|3yGXq94R8*p8L z!o2-FkN!rD@HJX$LQ6 z<%31NQ+*Y?dl6>Vta7yiD3dDSNpZ}P98;+GQVkV32R*DLwt(F?_|DR#_|J5BYX%95 zD_c!(4$n$WV^g5daSD@DTHA%NMx53JT&uHQYLvg_+;=8{7#@T4uMI?Kk{_-OWidyr^R}BWp}VW|M!}0< zZ+{ZP8Wv(AV`CeFwR%>Zsk8lL;PF6UfZsZ`7!FStO$X&L zWj*swR%s$IiG;gF;wx%>_#QuZ4Qs31S#1yAFxF~6j$cN$pPo3K1H_^E$>$+Q+M3|e zL=leEm1fWvb)4Cbv+^5}j`C#IBvK0uy!lal=CwO%xK|&p;X2_s9kYWo`G z=2ZFg_pw&<7;`c}tc_#XoMA!4C6&6Fcq^Pad}}8InFcBO>cKSxVf+yW&b=qfeZ)(a zmM7wkaavLsygtt79DC*~#!bpT@679T5K)#7wZmT}EwcYhCGa!oR8??*h}RNif#7QO zY1j~UzMl5l26{XQ03WT#y3CCQn&i7n+~@S5!IrC#6cB{@8WIE-+q*_VCD?Jrf>$mg zUia~<;lFW}(d_LN8Hk4jmvtAwCwMPzsJwadE@R;(({?e@XYdBngLtPwC(FE0Zn$^} zkE-1>0cow4c|i*hw~-gGJ!i7jd|_k7oCeJK z_yr4=u_fAjfV;^J4O+r;5|?sklC|7+64MFA8Upt7EyZ$anof+WuT7J$;+wS|@jJK& zfj1tZyaeR|5d)?d-u;Qx2VjA0Vh^$duod~mCB^dU_D?ZuyjEh*@}lkvQJW@*hupHOcoRNLP8j#~i`KIZ+Xxp;gngFP z=FUxHFnbofKmnhu_7oNrknf{&xu(KZcB=2mId;UVz#o2Jz6|IZF1(z~bb{Y9nifrC z=a-tvgTMIFU*SE18(wO*|0puVYCqEC!Hyfj0=l{*I}A3p4|S`<{9EHRTx*|^d)72 zjHO>0jgV1hjI)`GKS}R~b`r_B`SR9Vw%hLIQktKwUUN02e60 zhT2>_r?mnAM|)T6tG)Rc<5J?HjK4%7`6&CubYC`QW5t@OQ}+OfV!K)ujd}PziV)}- zOh4ti9UDLj1F~wN7vi66!|HzmkCNd5gCEu~6dWMNv(rfe^!E6J%tpAx_)8YraS9Ah zgP~!#cdaelv+mZyq?%YcXb5RdbU~Cdmx<{4Vg3EpAbvYu;K8i`#QEE-uF_l&`P5(K z=TC*JSg{Kh_(PksTNoYEaS?uPp(zA0xE5ktR#n*FjhWOquh`QvknahJU56Mi1a7Mj zIrmHsn5AUx9m`+kJFRUH9PYwBMcm5EY`4j{MRaQiHxNUOTR^z-&t3cJt)ndyeh(-% zur0MRB?x12FhkjI`tDoqDqvcb=@XPsR(aarVVAP`O5d;LBW<3(2Rl)VmXv-khp3TztQKyMY#ywBcZQbpHNv zk+$4nSX(USVTU4`G7Puh4y#_cX{7jSvw@YzT2n;KA=k>w$`96F;HgoGz8W=$3dDN( z2Ha>`-V#w8K8!gs&Nd(x-+*SkW^S8aK| z)@zS~S$os4QTO;3fsCAxZyc1&PtC%bMxbb|DTgDk4m{PQFcdlAhX_r4e-+$0-*RRc zfTq^+Q12IWf$9!5uu@5$<5_F%GVwI4>tKjlKlMh5G|_M4WxE9up+haYGR8Et$Wg-J ztJjZuze}YlElX&Kgm}WZDnF`SH+8L9wHvtA$aHyp6e3s8@V@NrF|o4jr!nJHb`tQO z-GHXibp{ru`!j09YQ$K{V5A`fUBToOTuPWSb@dx$HH!{Az*vt6;Q|hGu)?Eu5#aNs zaax|U7oklA{LI|@<=M`O)4rwh6uvPOmrvq<+jb5e>PL&Bj$vXT3}vzGQQCNo_K=%Ad~3|f7A(m zz6(U2E1&L>S1c*VA6hBcYu=;=yQ<;fIzkSoKDu$!CvkiSz;A`ti|{Sf_we8KTCBLc zK{~O!&6~bah;kVADnnj&ob~=WHW`y}k;OaQ zY@|(@QFWQ@6t`^VeTk0Xr5!AzIvG-oAZVqlP?#8o1>~r4KShD($qmNxF{{fpW)K0f zTchF?PNxNNqT=7eF37>ri%cv8Wn{HAQA;3^F;L^U=4pv3G+v)z2WBm!5wd5Y9Ci(evE zS<$hfn_kAX{*5K!x|Lm-(2SgOIG{x{GiJF?hoYw2ImjA#*5ng7G^Y@Kl0)2`DDO{9 zqxcPoNe_;8i?h7%hep!yJ&5gf))F%IAeBkIgtBn1tN{`h|O-s#s zvghQ>g(^F2aCe(HsY&&j6}6Fae41W+sUx@>w+iiWYbF+G1xG%TA&C@8mfKCXi$l+x z3=f02Ot85(6!3zHQ$(N{H`^%Ws;nO$N|iIupZKbC9wxO;^BP zh~XXizjuTguy1i~Wd6;OI^zY#g&XJ0NLkqcBk@IoJ@;+75dkC?+v#?rPgQjYN7F||Q0by-VX&+siAJ!_tm6vc;Dkf)&8ba^ z9&9Yn2oQ3?KXPVIy^2X@`la|4p|p~h_q^<4NK1u2!P2grHH!^ZL-$LgXCq`Z`!wb; zU&4Vqlu z?qPBIyEmVvRC+8D%3$2%+^659!WgCz)Hd`&4MUc-qR$VB>G@oH(~Z0oevc21>H1-8 z?0fuJE9m@NHHY>O=UeI=j*va@jNY+SZ+ch60^IJwO164?DYy@Bx=ZwAac>e10La;z zXYQ#=m)~<{7Pp^>2T1bXW|aeFOpSo4!H(AzEG&#(6!k91KV7@3$%~_weN_|Xie(cJ z;_O8_Jfma#hhX>1^kdkp+9>$2KA8=LI$pT8?Pn>J2Y3BY3w>vm_P)WOyZL)8aKDI- zS`igk&Z;ocg89J;GAsM=FGgUNk*og2%!Zg<&@E8cR@AMWxfl~OOxlXEqJ*G}qojP; z0RBsuXAtMT_kUe>7aR1HdV8Dxm?aaAFW#48FrZ@fF^`I7F#uB(<80< z*M&DSHn|w#Kd+A7d<_9G(|4$^W?e|jhSH7z6cTvuHRt8SFr!E)J`?uzVvJE`4RQ43 zV0-KG?*@HDS42K1%Kv(4HU-w8~X-GZFJ^W{oO(S8m|9>@hDFH zfAMekT}Da=bvoJm8jy+MU}9~w>%ovQLZw=&?bO9OpKMt5pXimJ0UE|*sngPSlShLvW z5fcAx1ydyuHWqVOIVI1_Yef{_E0&uKyx?HNZQhEZ9bEcFI|sy!R6 z_P1M7LT)u2PKG=rknLJ9R(8jX9%v4bxrw#(2Ha7Tl9SrPiiZ2v5b2+r@D&l}C|R$G zx_>Rep`a6?_sz{kPliYOfig<(aVARB|AAcfK{5a|Z|)Llrhp=kBLNsqY)s^IFGg=j zLw!OLBxTVX%BBrMDUDy?p;M^9*C83MGbFWG?wS27-F|c6o2#CKC6AnWctykOB;eOu zI!;2offK1XD3QGH{v&yTc|qumSZu&P(T;>9^siX92g!*gP4Z`AS6gU! z9>&n4x~ut7a{**EJX#!?WT$;iSQqgGm-CJGkm&nBVqlQ=C+MS#->8gf1`nSRPtvRE zAF!~5n~J3%3*xDE0c>qoD+tLr<6KNv!Bx+oC(YxO11Z{cD)4=X3Z)S?X2c7Wh(3r( zg7j}>tS#olH&~N{5x?c~*!6NqY*jwMVO4`bM1pDzF%YZt#rD@v19QeDVWG?Ip>iCM z=?I(qs%${bMSK6+5I-`^0p`^K^KK&`pevy&VW_R`5~o{b zz=p}$rFNlAySmVs4ZDfq`+Gmtl=PV8C6Sz&^q6FceY-Gs1R|X{QoZR$w+i)-hHnZj zN;2A!hyZ@7O^&1h$v}+K6o5%0WK0^ZOoHe)ndWp>f(;`#6_%0D)C3^85Ma=%Sh>lJ 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 Vs4(qarap*q4;s9h?jTN?vexb-V@m)4 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 67597612ad5e2bc5407288756b08b8765e1a0205..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12789 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 literal 0 HcmV?d00001 diff --git a/providers/lock.json b/providers/lock.json index 8621742b..8d3686fb 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": "09d1322b200681a83c0f0eec9418f55ea5041be38ac0dc7bd9e747e5f0eacd07" + "version": "0.2.14", + "archive": "bundled/spool.jellyfin-0.2.14.szo", + "sha256": "9034c1e8dbea0f92df277be91610561a387b0fbbc6b7bb0ade21484595c537ad" }, { "id": "spool.emby", "appleAppStore": true, - "version": "0.1.8", - "archive": "bundled/spool.emby-0.1.8.szo", - "sha256": "a4b8da7d77cf6ea13ec1a46be8704d7ca4049c015868d1a01be2095d6bdc8d62" + "version": "0.1.9", + "archive": "bundled/spool.emby-0.1.9.szo", + "sha256": "5dfd3995d09c92b3b8606d31524709966dc9e1e5083f4ee09e9fd2d1ab81294f" }, { "id": "spool.plex", "appleAppStore": true, - "version": "0.1.9", - "archive": "bundled/spool.plex-0.1.9.szo", - "sha256": "5c68acc463963ef5a4f7957727c3f64c13bc279408d0cdece547da4ffd845ccf" + "version": "0.1.10", + "archive": "bundled/spool.plex-0.1.10.szo", + "sha256": "f50b016c1e08adcbfdaed59c1df20c06171028badc3e65b508e22e45304c64ae" }, { "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" } ] } From 1dde8dc8d8b7fab1feae394dcc7ba04190a8cfda Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 18 Oct 2026 00:12:25 +1100 Subject: [PATCH 58/78] test(e2e): cover filter recovery, retained details, repeat and sleep --- docs/provider-parity-verification.md | 37 +++++++ tests/e2e/AppJourneyTest.cpp | 152 ++++++++++++++++++++++++++- tests/e2e/JellyfinFixture.cpp | 31 ++++-- tests/e2e/JellyfinFixture.h | 2 + 4 files changed, 213 insertions(+), 9 deletions(-) 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/tests/e2e/AppJourneyTest.cpp b/tests/e2e/AppJourneyTest.cpp index 078f14a1..76687fe4 100644 --- a/tests/e2e/AppJourneyTest.cpp +++ b/tests/e2e/AppJourneyTest.cpp @@ -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) @@ -1184,6 +1199,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 +1564,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 2fb8c937..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,14 +357,16 @@ 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 == "/Items/" + movieId + "/LocalTrailers" - || path == "/Items/" + movieId + "/SpecialFeatures" || path == "/Items/" + episodeId + "/LocalTrailers" + || 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") { @@ -425,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; From 61da5135867dee4730583cc8887330f04714dcbb Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 18 Oct 2026 00:12:26 +1100 Subject: [PATCH 59/78] fix(release): keep unsigned mobile download metadata concise --- tools/generate-website-manifest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/generate-website-manifest.py b/tools/generate-website-manifest.py index 95c6220c..8fa56802 100755 --- a/tools/generate-website-manifest.py +++ b/tools/generate-website-manifest.py @@ -36,7 +36,7 @@ ("tvos", "Apple TV", ( ("arm64", "Unsigned ARM64 IPA", "arm64", "ipa", "Spool-{version}-tvOS-arm64.ipa"), )), - ("ios", "iPhone and iPad", ( + ("ios", "iOS", ( ("arm64", "Unsigned ARM64 IPA", "arm64", "ipa", "Spool-{version}-iOS-arm64.ipa"), )), ) @@ -86,7 +86,7 @@ def generate_manifest(assets: Path, tag: str, repository: str) -> dict: 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}) From 9cf2cd25e433103289d7b27b3c3ff07c58402a47 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Sun, 18 Oct 2026 00:18:19 +1100 Subject: [PATCH 60/78] fix(tests): scope Linux webOS tooling consumers to Linux --- CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 34b684d3..f11adc3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1121,6 +1121,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" @@ -1139,8 +1141,6 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) ) add_test(NAME test-driver-ordering COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/TestDriverTest.py") - add_test(NAME webos-tooling - COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/WebosToolingTest.py") # URL-only native playlists require real video-surface admission; the # desktop controller consumer therefore belongs to the serial GPU phase. @@ -1303,9 +1303,9 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) 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 webos-tooling PROPERTIES LABELS traditional) + network-shaper render-benchmark-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) endif() endif() if(SPOOL_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") From 830d99cd0e28219fef234f68832e332d08b2d1bd Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 21 Oct 2026 23:31:54 +1100 Subject: [PATCH 61/78] feat(player): learn network profiles and drive curl transport control Per-origin playback network profiles carry learned initial request counts, latency and capacity; speed tests report latency and durations beside bitrate, and platform surfaces feed curl-transport-metrics back so PlayerController can write curl-transport-control during playback. Prepared mpv handles are built asynchronously and discarded when playback options change. Record the mpv universal branch with adaptive range scheduling, and add player setup benchmark, streaming profiler and report gate tooling. --- .github/workflows/build-artifacts.yml | 23 + CMakeLists.txt | 19 +- flake.lock | 8 +- mpv | 2 +- src/automation/LocalCommandServer.cpp | 5 +- src/platform/PlatformPlaybackSurface.h | 1 - .../android/AndroidPlaybackSurface.cpp | 7 +- .../desktop/DesktopPlaybackSurface.cpp | 6 +- .../webos/WebOSApplicationServices.cpp | 2 +- src/platform/webos/WebOSMpvRuntime.cpp | 19 +- src/platform/webos/WebOSPlaybackSurface.cpp | 6 - src/player/PlayerController.cpp | 484 ++++++++++++++---- src/player/PlayerController.h | 35 +- src/provider/PlaybackSource.h | 16 +- src/provider/PortableProvider.cpp | 16 +- src/provider/ProviderRegistry.cpp | 235 ++++++++- src/provider/ProviderRegistry.h | 11 + src/provider/ScriptBridge.cpp | 7 +- src/provider/SourceHub.cpp | 42 +- src/provider/SourceHub.h | 2 +- src/provider/SpeedTest.cpp | 50 +- src/provider/SpeedTest.h | 7 +- tests/player/PlayerControllerTest.cpp | 2 +- tests/player/PlayerSetupBenchmark.cpp | 294 +++++++++++ tests/providers/ScriptRuntimeTest.cpp | 21 +- tests/providers/SourceHubSpeedTest.cpp | 269 ++++++---- tests/tools/PlayerSetupReportTest.py | 76 +++ tools/profile-streaming.py | 162 ++++++ tools/report-player-setup.py | 113 ++++ 29 files changed, 1655 insertions(+), 285 deletions(-) create mode 100644 tests/player/PlayerSetupBenchmark.cpp create mode 100755 tests/tools/PlayerSetupReportTest.py create mode 100755 tools/profile-streaming.py create mode 100755 tools/report-player-setup.py diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 9145311c..aa7677f9 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -367,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 diff --git a/CMakeLists.txt b/CMakeLists.txt index f11adc3f..b2beb11a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1139,6 +1139,10 @@ 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 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") @@ -1146,7 +1150,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) @@ -1272,6 +1276,10 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) 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. @@ -1303,9 +1311,16 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) 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 player-setup-report test-driver-ordering PROPERTIES LABELS traditional) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") 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/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/src/automation/LocalCommandServer.cpp b/src/automation/LocalCommandServer.cpp index ac43361a..49a6960e 100644 --- a/src/automation/LocalCommandServer.cpp +++ b/src/automation/LocalCommandServer.cpp @@ -341,6 +341,7 @@ QJsonObject LocalCommandServer::state() const { 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() }, @@ -926,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/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/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/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/WebOSMpvRuntime.cpp b/src/platform/webos/WebOSMpvRuntime.cpp index b80f5009..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 @@ -228,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; } @@ -286,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) @@ -305,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) { 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/PlayerController.cpp b/src/player/PlayerController.cpp index ef92da5a..236783a9 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,8 @@ 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; @@ -309,6 +385,8 @@ void PlayerController::prepareForShutdown() ++m_playGeneration; m_idleMpvPreparationEnabled = false; m_idleMpvPreparationScheduled = false; + m_mpvPreparationContinuation = {}; + destroyIdleMpv("shutdown"); if (!m_mpvLifecycle.handle()) return; @@ -330,6 +408,7 @@ void PlayerController::teardownMpv(bool async) ++m_mpvEventGeneration; m_idleMpvPreparationEnabled = false; m_idleMpvPreparationScheduled = false; + m_mpvPreparationContinuation = {}; destroyIdleMpv("teardown"); // Platform render resources must detach before the mpv core is destroyed. const bool released = releasePlatformMpvSurface(m_embeddedVideoOutput); @@ -361,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() @@ -422,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) @@ -497,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); @@ -528,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 @@ -657,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); @@ -1018,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, @@ -1082,33 +1209,16 @@ 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"))) { - mpv_terminate_destroy(handle); + destroyMpvOffThread(handle); m_errorText = QStringLiteral("Failed to restore the playback volume."); emit playbackStateChanged(); return false; @@ -1116,7 +1226,7 @@ bool PlayerController::ensureMpv(bool needsVideoSurface, bool embeddedVideo) QString surfaceError; if (!configurePlatformMpvSurface(handle, *m_window, needsVideoSurface, embeddedVideo, surfaceError)) { - mpv_terminate_destroy(handle); + destroyMpvOffThread(handle); m_errorText = surfaceError; emit playbackStateChanged(); return false; @@ -1136,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(); @@ -1151,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; @@ -1185,6 +1295,7 @@ 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); @@ -1269,11 +1380,16 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau qInfo() << "player: audio-only playback does not request a video surface"; } - const bool initialized = ensureMpv(needsVideoSurface, embeddedVideo); - if (generation != m_playGeneration || !initialized) - 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; @@ -1325,6 +1441,26 @@ void PlayerController::playSession(const PlaybackSession& session, bool startPau 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; const bool surfaceReady = waitForPlatformMpvSurfaceReady(needsVideoSurface, embeddedVideo, surfaceReadyError); @@ -1343,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; } @@ -1359,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); @@ -1375,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); @@ -1389,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('/') @@ -1415,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); @@ -1464,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) { @@ -1649,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") }); } @@ -1691,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"; @@ -1995,6 +2166,8 @@ void PlayerController::stopWithReason(const QString& reason, bool explicitStop) emit stopRequested(reason); if (stopGeneration != m_playGeneration) return; + m_mpvPreparationContinuation = {}; + destroyIdleMpv("stop"); if (!m_sessionActive) return; @@ -2080,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(); } @@ -2091,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(); } @@ -2298,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"), @@ -2328,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; @@ -2369,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"); } @@ -2377,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(); } @@ -2619,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; @@ -2634,6 +2875,23 @@ void PlayerController::handleMpvEvent(mpv_event *event, quint64 generation, mpv_ 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(); }); diff --git a/src/player/PlayerController.h b/src/player/PlayerController.h index 04a17ad9..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; @@ -209,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) { @@ -292,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); @@ -299,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; @@ -322,8 +334,12 @@ 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); @@ -393,7 +409,22 @@ class PlayerController final : public QObject { 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; diff --git a/src/provider/PlaybackSource.h b/src/provider/PlaybackSource.h index 68198c35..926ce372 100644 --- a/src/provider/PlaybackSource.h +++ b/src/provider/PlaybackSource.h @@ -8,6 +8,7 @@ #include #include #include +#include #include @@ -29,8 +30,21 @@ 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, diff --git a/src/provider/PortableProvider.cpp b/src/provider/PortableProvider.cpp index 65c3295a..c4a8aba4 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; } @@ -305,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(); } diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index 5410afe1..62b2821c 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,8 @@ 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(); std::vector accounts; for (const QJsonValue& row : root.value(QStringLiteral("accounts")).toArray()) { ProviderAccount account = fromJson(row.toObject()); @@ -921,10 +954,209 @@ 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) } }) .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) { @@ -1893,6 +2125,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 5e32494c..5a3898b0 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; @@ -298,6 +303,12 @@ 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; + 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..b435bcaa 100644 --- a/src/provider/ScriptBridge.cpp +++ b/src/provider/ScriptBridge.cpp @@ -450,7 +450,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 +459,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({ engine->toScriptValue(result) }); }, this); m_speedTest = test; diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp index 8e94eebc..01968306 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -278,7 +278,16 @@ 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 { @@ -310,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)]; @@ -528,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) @@ -536,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(); } @@ -1184,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)); @@ -1211,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(); @@ -1232,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)) @@ -1251,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(); } } diff --git a/src/provider/SourceHub.h b/src/provider/SourceHub.h index c67f70a9..db81c6f7 100644 --- a/src/provider/SourceHub.h +++ b/src/provider/SourceHub.h @@ -225,7 +225,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/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/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/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/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/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()) From b72be39ff44501bba40198ea449de036fb0058cb Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 00:34:01 +1100 Subject: [PATCH 62/78] feat(sdk): let listings say they are seekable and offer a letter index A browse page may now carry `seekable: true` with an exact total. Spool then asks for any row with `offset` in place of the cursor, so a library of tens of thousands of titles no longer has to be walked from the top. The optional `browseIndex` operation answers how a name-sorted listing divides by first character. Both are additive to format 3; providers without them keep sequential paging and an estimated alphabet. --- sdk/README.md | 8 +++ sdk/provider.d.ts | 13 ++++- src/media/MediaTypes.h | 12 ++++ src/provider/Catalog.h | 6 ++ src/provider/PortableProvider.cpp | 72 +++++++++++++++++++----- src/provider/PortableProvider.h | 2 + src/provider/ProviderMediaPage.cpp | 2 + src/provider/ProviderMediaPage.h | 2 + src/provider/SourceHub.cpp | 13 +++++ src/provider/SourceHub.h | 2 + src/providers/local/LocalProvider.cpp | 6 ++ src/providers/local/LocalProvider.h | 2 + tests/app/ContentModelControllerTest.cpp | 5 ++ 13 files changed, 128 insertions(+), 17 deletions(-) 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 6a78303c..50fa886b 100644 --- a/sdk/provider.d.ts +++ b/sdk/provider.d.ts @@ -151,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; @@ -393,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. */ diff --git a/src/media/MediaTypes.h b/src/media/MediaTypes.h index b074280e..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 { diff --git a/src/provider/Catalog.h b/src/provider/Catalog.h index c5443857..50f714ad 100644 --- a/src/provider/Catalog.h +++ b/src/provider/Catalog.h @@ -51,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/PortableProvider.cpp b/src/provider/PortableProvider.cpp index c4a8aba4..47030f62 100644 --- a/src/provider/PortableProvider.cpp +++ b/src/provider/PortableProvider.cpp @@ -463,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); @@ -481,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: @@ -514,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) diff --git a/src/provider/PortableProvider.h b/src/provider/PortableProvider.h index 41642ef5..15926781 100644 --- a/src/provider/PortableProvider.h +++ b/src/provider/PortableProvider.h @@ -94,6 +94,8 @@ class PortableProvider final : public Provider, 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/SourceHub.cpp b/src/provider/SourceHub.cpp index 01968306..bf54a5f7 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -1409,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); diff --git a/src/provider/SourceHub.h b/src/provider/SourceHub.h index db81c6f7..a0c92e9b 100644 --- a/src/provider/SourceHub.h +++ b/src/provider/SourceHub.h @@ -167,6 +167,8 @@ class SourceHub final : public Provider, 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 diff --git a/src/providers/local/LocalProvider.cpp b/src/providers/local/LocalProvider.cpp index 42668464..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; } @@ -417,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 f2380258..43778547 100644 --- a/src/providers/local/LocalProvider.h +++ b/src/providers/local/LocalProvider.h @@ -81,6 +81,8 @@ class LocalProvider final : public Provider, 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/app/ContentModelControllerTest.cpp b/tests/app/ContentModelControllerTest.cpp index c036882c..49dbc71c 100644 --- a/tests/app/ContentModelControllerTest.cpp +++ b/tests/app/ContentModelControllerTest.cpp @@ -339,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); From 5ea18b9465620ff566fdb3c893a752102a91e724 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 00:34:40 +1100 Subject: [PATCH 63/78] feat(library): hold only the rows near the viewport of a large library A seekable listing becomes a sparse model: every row exists, but only blocks of 100 around the viewport are fetched, on-screen blocks first and at most three at a time, and blocks far from the view are released once a row budget is passed. The letter index is exact when the server counts first characters and otherwise starts from how titles usually divide across the alphabet, pinned by every row that arrives and anchored by the last block. Typing seeks by name the way a file browser does: the loaded rows bracket the target, the index and the key's characters interpolate inside the bracket, and each guess loads the block that tightens it. --- CMakeLists.txt | 3 + src/app/AppController.cpp | 39 +++ src/app/AppController.h | 2 + src/app/BrowseIndex.cpp | 257 +++++++++++++++ src/app/BrowseIndex.h | 81 +++++ src/app/BrowseSessionController.cpp | 445 +++++++++++++++++++++++++- src/app/BrowseSessionController.h | 85 +++++ src/models/MovieGridModel.cpp | 155 ++++++++- src/models/MovieGridModel.h | 34 ++ tests/app/BrowseSparseListingTest.cpp | 279 ++++++++++++++++ 10 files changed, 1366 insertions(+), 14 deletions(-) create mode 100644 src/app/BrowseIndex.cpp create mode 100644 src/app/BrowseIndex.h create mode 100644 tests/app/BrowseSparseListingTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b2beb11a..1a627b69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -237,6 +237,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 @@ -864,6 +866,7 @@ 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 settings-schema tests/app/SettingsSchemaTest.cpp settings-controller tests/app/SettingsControllerTest.cpp settings-sync-controller tests/app/SettingsSyncControllerTest.cpp diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 71f40497..4ff29388 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -249,6 +249,8 @@ AppController::AppController( }); 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) { @@ -1079,6 +1081,43 @@ 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) { diff --git a/src/app/AppController.h b/src/app/AppController.h index e30e1d9d..e15e7f85 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -209,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()); 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/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/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; +} From d922ba479d057df9535b884fdbdd8b5bf7fc9d66 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 00:34:52 +1100 Subject: [PATCH 64/78] feat(library): add a letter rail, placeholders and type-to-seek A name-sorted library shows its letters beside the scroll bar, each where its titles are, so the bar doubles as a map. Pointed at, the rail opens into an evenly spaced index magnified under the pointer; from the remote, Right off the last column steps onto it and Up/Down jump letter by letter with the grid following. Page/channel keys step letters from the grid. Unloaded rows show the letter they fall under, and blocks keep loading during long scrolls. Typed text seeks through the controller; in other sort orders a miss offers to search for what was typed. --- CMakeLists.txt | 1 + qml/pages/LibraryGridPage.qml | 287 +++++++++++++++++++++++++++++--- qml/primitives/AlphabetRail.qml | 191 +++++++++++++++++++++ 3 files changed, 452 insertions(+), 27 deletions(-) create mode 100644 qml/primitives/AlphabetRail.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a627b69..ba8859b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -625,6 +625,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 diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index 98fbe49e..b3ff0643 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,26 @@ 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). + readonly property bool alphabetRail: Boolean(Browse.alphabetical && (Browse.sparse || !Browse.hasMore) && grid.count + >= 60) + + readonly property var alphabetLetters: alphabetRail && Browse.alphabetRevision >= 0 ? Browse.alphabet : [] + // 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 @@ -615,18 +634,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) @@ -746,6 +841,9 @@ FocusScope { Connections { target: Browse + function onSeekMoved(row, settled) { + root.jumpToRow(row) + } function onChanged() { if (root.filtersOpen) root.filterEntries = root.buildFilterEntries() @@ -818,6 +916,28 @@ FocusScope { } function routeKey(key, phase, repeat) { + 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) @@ -896,6 +1016,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) @@ -931,8 +1059,14 @@ FocusScope { } function back() { + if (railActive) { + leaveRail() + return true + } if (typeAheadBuffer.length > 0) { typeAheadBuffer = "" + typeAheadMissed = false + Browse.cancelSeek() typeAheadReset.stop() return true } @@ -1206,7 +1340,9 @@ 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.alphabetRail + ? 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 @@ -1233,6 +1369,8 @@ FocusScope { onContentYChanged: { requestPageIfNeeded() artworkWindowDebounce.restart() + if (Browse.sparse && !artworkWindowThrottle.running) + artworkWindowThrottle.start() alphabetFeedback.restart() } onCurrentIndexChanged: { @@ -1266,6 +1404,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 + flickable: grid + letters: root.alphabetLetters + columns: root.columns + cellHeight: grid.cellHeight + active: root.railActive + currentIndex: root.railActive ? root.railIndex : root.viewLetterIndex + interactive: Platform.hasPointer + onLetterChosen: (index, final) => { + root.beginPointerNavigation(null) + root.jumpToLetter(index) + } + } onEdgeUp: root.focusToolbar() onAccepted: root.activateCurrent() onHoldStarted: Browse.prefetchNextPage() @@ -1339,6 +1497,15 @@ FocusScope { } } + // 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 @@ -1367,8 +1534,14 @@ 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.expanded + ? letterRail.expandedWidth : + letterRail.restingWidth) + - Math.max(Metrics.scaled( + 10), 10) + + Metrics.scaled(4) : 0) y: Math.max(0, Math.min(grid.height - height, desiredY)) width: Metrics.scaled(52) + neckWidth height: Metrics.scaled(48) @@ -1376,7 +1549,8 @@ 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 + opacity: alphabetFeedback.running || libraryScrollBar.pressed || root.railActive + || letterRail.pointing ? 1 : 0 visible: opacity > 0 preferredRendererType: Shape.CurveRenderer z: 21 @@ -1470,6 +1644,7 @@ FocusScope { id: gridDelegate required property int index + required property bool loaded property bool artworkPooled: false GridView.onPooled: artworkPooled = true GridView.onReused: artworkPooled = false @@ -1480,8 +1655,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 @@ -1489,14 +1664,43 @@ 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 + 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 + } + } } } @@ -1507,6 +1711,7 @@ FocusScope { id: listRow required property int index + required property bool loaded required property string displayTitle required property string displaySubtitle @@ -1522,7 +1727,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 @@ -1556,13 +1764,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" } } @@ -1587,21 +1798,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 + } } } @@ -1609,7 +1839,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. diff --git a/qml/primitives/AlphabetRail.qml b/qml/primitives/AlphabetRail.qml new file mode 100644 index 00000000..c6d1f3d7 --- /dev/null +++ b/qml/primitives/AlphabetRail.qml @@ -0,0 +1,191 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../theme" + +// The letters of a name-sorted list, beside its scroll bar. At rest each +// letter sits where its titles are in the list, so the bar doubles as a map +// of the library. Pointed at, or entered from the remote, the rail opens into +// an evenly spaced index with the letters nearest the pointer magnified, so a +// letter with three titles is as easy to hit as one with three thousand. +Item { + id: root + + required property Flickable flickable + // [{label, row, exact}] in list order. + property var letters: [] + property int columns: 1 + property real cellHeight: 1 + // The letter under the view or under remote focus; highlighted. + 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 expanded: active || pointing + readonly property real restingWidth: Metrics.scaled(24) + readonly property real expandedWidth: Metrics.scaled(64) + readonly property real letterSize: Metrics.scaled(15) + readonly property real spacing: letters.length > 0 ? height / letters.length : height + property real pointerY: -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 restingCenter(row) { + const range = Math.max(0, flickable.contentHeight - flickable.height) + const offset = Math.floor(Math.max(0, row) / Math.max(1, columns)) * cellHeight + const fraction = range > 0 ? Math.min(1, offset / range) : 0 + return letterSize / 2 + fraction * Math.max(0, height - letterSize) + } + + function expandedCenter(index) { + return (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 / Math.max(1, spacing)))) + } + + // At rest, letters crowded together by a small library or a lopsided + // alphabet give way to the ones before them; the current one always shows. + readonly property var restingVisible: { + const shown = [] + let lastY = -Infinity + for (let i = 0; i < letters.length; ++i) { + const y = restingCenter(letters[i].row) + const keep = y - lastY >= letterSize * 1.15 + shown.push(keep) + if (keep) + lastY = y + } + return shown + } + + // Backdrop of the open index, reaching out over the grid. + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.expandedWidth + radius: Theme.radiusMedium + color: Theme.floatingPanel + border.width: 1 + border.color: root.active ? Theme.accent : Theme.border + opacity: root.expanded ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.reducedMotion ? 0 : 140 + } + } + } + + 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 real centerY: root.expanded ? root.expandedCenter(index) : root.restingCenter( + modelData.row) + + // Magnified by closeness to the pointer, dock-style. + readonly property real lens: { + if (!root.pointing || root.pointerY < 0) + return current && root.active ? 1.6 : 1 + const distance = (centerY - root.pointerY) / Math.max(1, root.spacing * 1.8) + return 1 + 1.1 * Math.exp(-distance * distance) + } + + width: root.restingWidth + height: root.letterSize + x: root.width - width - (root.expanded ? (lens - 1) * Metrics.scaled(16) + Metrics.scaled(18) : 0) + y: centerY - height / 2 + visible: root.expanded || root.restingVisible[index] || current + z: current ? 2 : 1 + + Behavior on y { + enabled: !Theme.reducedMotion + NumberAnimation { + duration: 160 + easing.type: Easing.OutCubic + } + } + Behavior on x { + enabled: !Theme.reducedMotion + NumberAnimation { + duration: 90 + } + } + + Rectangle { + anchors.centerIn: parent + width: Math.max(parent.height, label.implicitWidth + Metrics.scaled(8)) * letter.lens + height: parent.height * letter.lens + Metrics.scaled(4) + radius: height / 2 + color: Theme.accent + visible: letter.current && root.expanded + } + + AppText { + id: label + anchors.centerIn: parent + text: letter.modelData.label + scale: letter.lens + font.pixelSize: root.letterSize + font.weight: letter.current ? Font.Bold : Font.DemiBold + // An estimated position is drawn a little quieter than one + // the server or a loaded row has confirmed. + color: letter.current && !root.expanded ? Theme.accent : root.expanded ? Theme.textPrimary : + Theme.textMuted + opacity: root.expanded || letter.modelData.exact || letter.current ? 1 : 0.6 + } + } + } + + MouseArea { + id: pointer + + property int lastIndex: -1 + + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.expanded ? root.expandedWidth : root.restingWidth + enabled: root.interactive && root.letters.length > 0 + hoverEnabled: true + preventStealing: true + acceptedButtons: Qt.LeftButton + + function choose(y, final) { + const index = root.indexAt(y) + if (index < 0) + return + if (index !== lastIndex || final) + root.letterChosen(index, final) + lastIndex = index + } + + onPositionChanged: mouse => { + root.pointerY = mouse.y + if (pressed) + choose(mouse.y, false) + } + onPressed: mouse => { + lastIndex = -1 + root.pointerY = mouse.y + choose(mouse.y, false) + } + onReleased: mouse => choose(mouse.y, true) + onExited: root.pointerY = -1 + } +} From bd324bd3b3be7936b991421e649957785fc7febd Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 00:35:22 +1100 Subject: [PATCH 65/78] feat(artwork): adapt download concurrency to each server Posters were fetched six at a time whatever the server. Each server now gets its own limit: seeded from the round trip implied by the first fetches' first bytes, raised while fetches finish about as fast as the fastest seen and the limit is what holds requests back, and lowered in proportion when they slow. A brief low-load dip every so often, as in BBR's PROBE_RTT, re-measures that baseline so overload and a slower network are told apart. Qt's six-connection HTTP/1.1 cap is lifted for artwork, and prefetching takes half the limit. "Artwork downloads" in advanced Appearance settings fixes the number instead and shows what the automatic limit has settled on. --- CMakeLists.txt | 3 + qml/pages/SettingsPage.qml | 2 + src/app/ArtworkConcurrency.cpp | 99 ++++++++++++++++ src/app/ArtworkConcurrency.h | 74 ++++++++++++ src/app/ArtworkService.cpp | 163 ++++++++++++++++++++++++++- src/app/ArtworkService.h | 14 +++ src/app/SettingsController.cpp | 6 + src/app/SettingsSchema.cpp | 43 ++++--- src/app/SettingsSchema.h | 1 + tests/app/ArtworkConcurrencyTest.cpp | 80 +++++++++++++ 10 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 src/app/ArtworkConcurrency.cpp create mode 100644 src/app/ArtworkConcurrency.h create mode 100644 tests/app/ArtworkConcurrencyTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ba8859b2..c7e95ebc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -229,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 @@ -868,6 +870,7 @@ 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 diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index 0a328f26..432216c5 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -519,6 +519,8 @@ 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" diff --git a/src/app/ArtworkConcurrency.cpp b/src/app/ArtworkConcurrency.cpp new file mode 100644 index 00000000..f216e966 --- /dev/null +++ b/src/app/ArtworkConcurrency.cpp @@ -0,0 +1,99 @@ +#include "ArtworkConcurrency.h" + +#include +#include + +namespace Spool { + +namespace { + + // Fetches this much slower than the fastest still count as unqueued: + // image scaling on the server varies from poster to poster, and a few + // milliseconds of jitter on a LAN is not congestion. + constexpr double kTolerance = 1.5; + constexpr double kSlackMs = 8.0; + // Fetches between baseline probes, and unqueued fetches each probe takes. + constexpr int kProbeInterval = 80; + constexpr int kProbeFetches = 6; + // A probe that cannot get its fetches (the screen stopped asking) ends. + constexpr int kProbePatience = 30; + constexpr double kRecentWeight = 0.25; + // How far each sample moves the limit toward its new target. + constexpr double kStep = 0.3; + constexpr int kWarmupSamples = 4; + +} // 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; + const int limit = std::clamp(static_cast(std::lround(m_limit)), kMinimum, kMaximum); + return m_probeLeft > 0 ? std::min(limit, kProbeLimit) : limit; +} + +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), 24.0); +} + +void ArtworkConcurrency::record(double durationMs, int inFlight) +{ + if (!(durationMs >= 0)) + return; + ++m_samples; + if (m_probeLeft > 0) { + // Only fetches that started light speak for the unqueued baseline; + // ones still finishing from before the dip are ignored. None of them + // say what the full limit costs, so the recent average waits. + if (inFlight <= kProbeLimit) { + m_fastestMs = m_fastestMs < 0 ? durationMs : std::min(m_fastestMs, durationMs); + ++m_probeSamples; + } + if (--m_probeLeft <= 0 || m_probeSamples >= kProbeFetches) { + m_probeLeft = 0; + m_untilProbe = kProbeInterval; + } + return; + } + + m_recentMs = m_recentMs < 0 ? durationMs : m_recentMs + (durationMs - m_recentMs) * kRecentWeight; + + m_fastestMs = m_fastestMs < 0 ? durationMs : std::min(m_fastestMs, durationMs); + if (automatic() && --m_untilProbe <= 0) { + // Start a fresh window: the next light fetches set the baseline. + m_probeLeft = kProbePatience; + m_probeSamples = 0; + m_fastestMs = -1; + return; + } + if (m_samples < kWarmupSamples) + return; + + const double expected = m_fastestMs * kTolerance + kSlackMs; + const double gradient = std::clamp(expected / std::max(1.0, m_recentMs), 0.5, 1.0); + // A sharp slowdown is either overload or a slower network; only a fresh + // baseline can tell which, so it comes sooner. + if (gradient < 0.6) + m_untilProbe = std::min(m_untilProbe, 10); + // Growing is only evidence-based while the limit is what holds requests + // back; a half-empty pipe says nothing about whether more would help. + const bool saturated = inFlight >= limit(); + const double headroom = saturated && gradient >= 1.0 ? std::sqrt(m_limit) : 0.0; + const double target = m_limit * gradient + headroom; + 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..2227bce8 --- /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. +// +// Posters are small, so a grid's worth of them is limited by round trips far +// more often than by bandwidth: on a LAN six at a time is plenty, while over +// the internet at 80 ms every extra request in flight is nearly free until +// the link or the server's image scaler fills up. The controller starts from +// a round trip estimated off the first fetches and then follows them. While they +// finish about as fast as the fastest one seen, nothing is queueing anywhere +// and more requests in flight are pure gain, so the limit grows -- but only +// while it is actually the bottleneck, so it settles at what the screen asks +// for. When fetches slow down, something is saturated and the limit backs +// off in proportion. This is the gradient form of TCP-style congestion +// control, applied to requests rather than packets. +// +// "As fast as the fastest" needs a fastest measured without queueing, and a +// busy grid never runs light on its own. So now and then, like BBR's +// PROBE_RTT, the limit dips to a few requests for a handful of fetches and +// the baseline is measured again; that is also how a move to a slower +// network stops looking like congestion. +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; + + // Requests allowed while re-measuring the unqueued baseline. + static constexpr int kProbeLimit = 3; + + // 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`, having + // started with `inFlight` fetches (itself included) open to this server. + void record(double durationMs, int inFlight); + + double roundTripMs() const + { + return m_roundTripMs; + } + double fastestMs() const + { + return m_fastestMs; + } + double recentMs() const + { + return m_recentMs; + } + +private: + int m_fixed = 0; + double m_limit = kDefault; + double m_roundTripMs = -1; + double m_fastestMs = -1; + double m_recentMs = -1; + int m_samples = 0; + int m_untilProbe = 12; + int m_probeSamples = 0; + int m_probeLeft = 0; +}; + +} // namespace Spool diff --git a/src/app/ArtworkService.cpp b/src/app/ArtworkService.cpp index 8c8b15c1..9c1b99b4 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,68 @@ class ArtworkFetchWorker final : public QObject { QByteArray headers; }; + struct Origin { + ArtworkConcurrency control; + int inFlight = 0; + // 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"; + 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 +313,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 +337,44 @@ 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); + const int inFlight = ++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, inFlight, 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()) { + 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, inFlight); + noteLimit(*origin); + } + } finishReply(reply, [this, requestId, key, diskCache, queuedNs, fetchStartedNs, finishedNs]( QByteArray bytes, QString error) { @@ -261,7 +393,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 +469,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 +848,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/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 d79f3100..af5aa00b 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, @@ -621,23 +629,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/tests/app/ArtworkConcurrencyTest.cpp b/tests/app/ArtworkConcurrencyTest.cpp new file mode 100644 index 00000000..b249310f --- /dev/null +++ b/tests/app/ArtworkConcurrencyTest.cpp @@ -0,0 +1,80 @@ +#include "app/ArtworkConcurrency.h" + +#include "TestMain.h" + +#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 whose fetches take `baseMs` until more than `capacity` run at +// once, after which they queue and slow in proportion. Returns the highest +// limit seen over the last 50 fetches: where it has settled, probes aside. +int drive(ArtworkConcurrency& control, double baseMs, int capacity, int demand, int fetches) +{ + int settled = 0; + for (int i = 0; i < fetches; ++i) { + const int inFlight = std::min(control.limit(), demand); + const double durationMs = baseMs * std::max(1.0, static_cast(inFlight) / capacity); + control.record(durationMs, inFlight); + if (i >= fetches - 50) + settled = std::max(settled, control.limit()); + } + return settled; +} + +} // 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() + 6, "a long round trip starts with more in flight"); + + // Latency-bound: fetches cost the same however many run, so the limit + // climbs to what the screen asks for and no further. + ArtworkConcurrency latencyBound; + latencyBound.seed(80); + const int latencyLimit = drive(latencyBound, 90, 64, 28, 300); + require(latencyLimit >= 26, "a latency-bound server is given as many as the screen wants"); + require(latencyLimit <= 31, "the limit does not run away past the demand"); + + // Capacity-bound: beyond four at once the server queues; the limit backs + // off to about what it can serve. + ArtworkConcurrency capacityBound; + capacityBound.seed(80); + const int capacityLimit = drive(capacityBound, 40, 4, 40, 300); + require(capacityLimit <= 8, "a saturated server gets fewer requests"); + require(capacityLimit >= ArtworkConcurrency::kMinimum, "never below the floor"); + + // A network that gets slower is followed rather than read as congestion + // forever: after the move it may grow again. + ArtworkConcurrency moved; + const int before = drive(moved, 5, 64, 30, 150); + require(drive(moved, 60, 64, 30, 400) >= before, "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); + require(drive(fixed, 90, 64, 30, 100) == 4 && !fixed.automatic(), "a fixed limit is not adapted"); + fixed.setFixed(0); + require(fixed.automatic(), "zero returns to automatic"); + return 0; +} From 502db8c860692f2c6df15b78318ed15ea1688997 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 00:35:34 +1100 Subject: [PATCH 66/78] feat(accounts): make adding an account one step The add page asked people to install, update and version providers before they could sign in. It is now "Add an account" ("Welcome to Spool" on first run): one tile per service, and choosing it does whatever it takes -- download, update -- with a spinner on the tile, then opens the sign-in. Community services carry a small label; a shared provider link is one quiet button. Provider updates are automatic by default and the choice moves to advanced settings. Versions leave the profile tiles, server cards, sign-in headers and the accounts page; the provider page keeps them in a footnote for support. Store installs and background updates no longer raise the modal install dialog, which is left to installs started from a file. --- qml/pages/AccountsPage.qml | 47 +- qml/pages/AddProviderPage.qml | 616 ++++++++++++------------ qml/pages/ProviderDetailsPage.qml | 69 +-- qml/pages/ProviderScreenPage.qml | 3 +- qml/shell/ProviderInstallDialog.qml | 6 +- qml/shell/ProviderLinkScreen.qml | 1 - qml/shell/ServerLogin.qml | 1 - src/app/SettingsSchema.cpp | 9 +- tests/e2e/AppJourneyTest.cpp | 4 +- tests/qml/tst_ProviderInstallDialog.qml | 16 + 10 files changed, 358 insertions(+), 414 deletions(-) diff --git a/qml/pages/AccountsPage.qml b/qml/pages/AccountsPage.qml index 99987d6f..3b5d38bc 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 } @@ -717,7 +712,7 @@ FocusScope { root.lastFocus = focusKey kind: "flat" iconName: "settings" - text: "Provider settings" + text: "Settings" onClicked: { root.focusStop(providerSettingsButton) root.shell.pushRoute("providerDetails", { @@ -781,7 +776,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 +808,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 +817,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..b577829d 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,64 +18,62 @@ 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) { @@ -84,90 +83,65 @@ FocusScope { 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 +149,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 +172,7 @@ FocusScope { return true } if (linkEditing) { - linkEditing = false - InputKeys.focus(list) + closeLink() return true } return false @@ -209,12 +187,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 +224,9 @@ FocusScope { if (root.pendingSetup === id || root.pendingSetup === "*") root.beginSetup(id) } - function onProblem() { + function onProblem(message) { root.pendingSetup = "" + root.problemText = message } } @@ -236,17 +235,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 +255,7 @@ FocusScope { spacing: Metrics.scaled(14) IconButton { - visible: Providers.hasAccounts + visible: !root.firstAccount iconName: "arrow_back" accessibleName: "Back" onClicked: Router.pop("accounts") @@ -262,148 +263,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 +392,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 +506,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 +519,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/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/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..3ec26d83 100644 --- a/qml/shell/ProviderLinkScreen.qml +++ b/qml/shell/ProviderLinkScreen.qml @@ -119,7 +119,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 : "" enabled: !root.busy onAccepted: { choicesList.currentIndex = index diff --git a/qml/shell/ServerLogin.qml b/qml/shell/ServerLogin.qml index e0c9d111..8fc5725f 100644 --- a/qml/shell/ServerLogin.qml +++ b/qml/shell/ServerLogin.qml @@ -522,7 +522,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/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index af5aa00b..deb0eb88 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -576,9 +576,12 @@ 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), // Comma-separated provider ids turned off for Home. The settings // page opens the Home provider picker rather than editing text. diff --git a/tests/e2e/AppJourneyTest.cpp b/tests/e2e/AppJourneyTest.cpp index 76687fe4..10d992b4 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; } @@ -1145,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"); 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 } From ef83a9b1d914cb04226fbe055bf420b9b6130ebe Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 00:36:25 +1100 Subject: [PATCH 67/78] chore(providers): bundle seekable Plex, Jellyfin and Emby spool.plex 0.1.11, spool.jellyfin 0.2.15 and spool.emby 0.1.10 page their libraries by offset and say so; Plex also counts first letters. These archives are reproducible local builds of provider commits that are not yet pushed or released; publish those releases before cutting an app release so the pins match published assets. --- providers/bundled/spool.emby-0.1.10.szo | Bin 0 -> 52774 bytes providers/bundled/spool.emby-0.1.9.szo | Bin 52665 -> 0 bytes providers/bundled/spool.jellyfin-0.2.14.szo | Bin 44325 -> 0 bytes providers/bundled/spool.jellyfin-0.2.15.szo | Bin 0 -> 44437 bytes providers/bundled/spool.plex-0.1.10.szo | Bin 55223 -> 0 bytes providers/bundled/spool.plex-0.1.11.szo | Bin 0 -> 55475 bytes providers/lock.json | 18 +++++++++--------- 7 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 providers/bundled/spool.emby-0.1.10.szo delete mode 100644 providers/bundled/spool.emby-0.1.9.szo delete mode 100644 providers/bundled/spool.jellyfin-0.2.14.szo create mode 100644 providers/bundled/spool.jellyfin-0.2.15.szo delete mode 100644 providers/bundled/spool.plex-0.1.10.szo create mode 100644 providers/bundled/spool.plex-0.1.11.szo 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 0000000000000000000000000000000000000000..a43566f37df2cdd5608a5f1cdd7c08eeddcd3cad GIT binary patch literal 52774 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%0Al69c?^S_h1i=Ktt(Ll} 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 OYt!2`LRdozOo==~qZ=v! diff --git a/providers/bundled/spool.jellyfin-0.2.14.szo b/providers/bundled/spool.jellyfin-0.2.14.szo deleted file mode 100644 index 934edcfa17996cab1315da26fdaf949e16b6aa44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44325 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
_-o^(4GvXzp#MX0K(mK4goSdjM7NfkaO@ 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 YOKsRMhQ$k##LfoKB6BURmwOXtZ5f^JDgXcg diff --git a/providers/bundled/spool.plex-0.1.11.szo b/providers/bundled/spool.plex-0.1.11.szo new file mode 100644 index 0000000000000000000000000000000000000000..41c83d2d51c28b3ee65fa02a99bd72a5031f13dd GIT binary patch literal 55475 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 Q;>%CH>|T%TJhJG2R7YE5qyPW_ literal 0 HcmV?d00001 diff --git a/providers/lock.json b/providers/lock.json index 8d3686fb..0ec5ffab 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -4,23 +4,23 @@ { "id": "spool.jellyfin", "appleAppStore": true, - "version": "0.2.14", - "archive": "bundled/spool.jellyfin-0.2.14.szo", - "sha256": "9034c1e8dbea0f92df277be91610561a387b0fbbc6b7bb0ade21484595c537ad" + "version": "0.2.15", + "archive": "bundled/spool.jellyfin-0.2.15.szo", + "sha256": "b7ab008c791cc8f4c1eb4aaf9a4806c9484f0f7925168ee74c5779381168ae09" }, { "id": "spool.emby", "appleAppStore": true, - "version": "0.1.9", - "archive": "bundled/spool.emby-0.1.9.szo", - "sha256": "5dfd3995d09c92b3b8606d31524709966dc9e1e5083f4ee09e9fd2d1ab81294f" + "version": "0.1.10", + "archive": "bundled/spool.emby-0.1.10.szo", + "sha256": "65d01078277127dde86aed58cd36fe58a69982857684c8366409b2311bd63dbd" }, { "id": "spool.plex", "appleAppStore": true, - "version": "0.1.10", - "archive": "bundled/spool.plex-0.1.10.szo", - "sha256": "f50b016c1e08adcbfdaed59c1df20c06171028badc3e65b508e22e45304c64ae" + "version": "0.1.11", + "archive": "bundled/spool.plex-0.1.11.szo", + "sha256": "ed744e0a98cf0bf7bbade4818c65ad778568a671a058fc1fd5bdff9a10e9edc1" }, { "id": "spool.stremio", From 375c43bb72542f2ff93c5cb16bc7dfc3b057b8ea Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 01:48:13 +1100 Subject: [PATCH 68/78] fix(webos): give luna-send a terminal when driving the TV On current firmware luna-send exits 0 without printing anything, and without making the call, unless it has a terminal; installs then ended "without success" before the TV ever fetched the package. Allocate one for luna calls and the install subscription; luna-response.py already skips terminal chatter. --- tools/webos/device-common.sh | 4 +++- tools/webos/verify-device.sh | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tools/webos/device-common.sh b/tools/webos/device-common.sh index b6e3bb45..03dbaa0c 100755 --- a/tools/webos/device-common.sh +++ b/tools/webos/device-common.sh @@ -36,5 +36,7 @@ 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' - webos_ssh -n "$host" "$bin -n 1 $(webos_quote "$uri") $(webos_quote "$payload")" + # 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")" "$outdir/install.log" - timeout --signal=TERM --kill-after=5s "${install_timeout}s" "${WEBOS_SSH[@]}" -n -- "$host" \ - "$luna_bin -i -f 'luna://org.webosbrew.hbchannel.service/install' $(webos_quote "$payload")" >"$outdir/install.log" 2>&1 & + # 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 From 720cd82b52cdb1ed8ced433d2c241019f55ce998 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 02:15:51 +1100 Subject: [PATCH 69/78] fix(library): keep holds alive, race cheaply and calm the letter rail Holding a direction through a large library stopped dead while rows were loading. With every row in the model the hold's top speed is now that of the whole library, the view is busy building rows, and the release watchdog fired late, before the queued repeat, reading the app's own stall as a release. A late watchdog now gives the key another interval, up to a bound, so a release the platform never sends still ends it. Moving faster than a few screens a second -- a fast hold, a fling, the scroll bar -- no longer starts artwork or block loads for rows only passed through; posters already showing stay, and loading resumes where the view slows. The letter rail is an evenly spaced index with the current letter marked and a large bubble under the pointer, in place of proportional letters that crowded together and a fisheye that moved them about. The scroll callout appears only on lists long enough to need it, and the rail's space is reserved so its arrival never reflows. --- qml/pages/LibraryGridPage.qml | 61 +++++++--- qml/primitives/AlphabetRail.qml | 189 ++++++++++++++----------------- qml/primitives/ImageCard.qml | 18 ++- qml/primitives/MediaItemCard.qml | 3 + qml/primitives/NavGrid.qml | 40 ++++++- tests/qml/tst_NavGrid.qml | 17 +++ 6 files changed, 207 insertions(+), 121 deletions(-) diff --git a/qml/pages/LibraryGridPage.qml b/qml/pages/LibraryGridPage.qml index b3ff0643..67009a31 100644 --- a/qml/pages/LibraryGridPage.qml +++ b/qml/pages/LibraryGridPage.qml @@ -47,10 +47,20 @@ FocusScope { } // 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). - readonly property bool alphabetRail: Boolean(Browse.alphabetical && (Browse.sparse || !Browse.hasMore) && grid.count - >= 60) + // 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 @@ -1340,9 +1350,8 @@ FocusScope { boundsBehavior: Flickable.StopAtBounds model: Browse.items leftMargin: focusPadding - rightMargin: focusPadding + Math.max(Metrics.scaled(10), 10) + Metrics.scaled(6) + (root.alphabetRail - ? letterRail.restingWidth : - 0) + 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 @@ -1367,9 +1376,18 @@ 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 && !artworkWindowThrottle.running) + if (Browse.sparse && !grid.racing && !root.scrollingFast && !artworkWindowThrottle.running) artworkWindowThrottle.start() alphabetFeedback.restart() } @@ -1412,10 +1430,7 @@ FocusScope { anchors.rightMargin: -Math.max(Metrics.scaled(10), 10) z: 5 visible: root.alphabetRail - flickable: grid letters: root.alphabetLetters - columns: root.columns - cellHeight: grid.cellHeight active: root.railActive currentIndex: root.railActive ? root.railIndex : root.viewLetterIndex interactive: Platform.hasPointer @@ -1424,6 +1439,9 @@ FocusScope { 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() @@ -1497,6 +1515,16 @@ 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 { @@ -1536,12 +1564,8 @@ FocusScope { // With the letter rail it stands clear of the rail, open or not. anchors.right: libraryScrollBar.right - anchors.rightMargin: libraryScrollBar.visualWidth + (root.alphabetRail ? (letterRail.expanded - ? letterRail.expandedWidth : - letterRail.restingWidth) - - Math.max(Metrics.scaled( - 10), 10) - + Metrics.scaled(4) : 0) + 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) @@ -1549,8 +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 || root.railActive - || letterRail.pointing ? 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 @@ -1664,6 +1690,7 @@ 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. + artworkDeferred: grid.racing || root.scrollingFast artworkEnabled: loaded && !artworkPooled && grid.cellHeight > 0 && root.columns > 0 && (Math.floor( index / root.columns) + 1) * grid.cellHeight diff --git a/qml/primitives/AlphabetRail.qml b/qml/primitives/AlphabetRail.qml index c6d1f3d7..c504cfd4 100644 --- a/qml/primitives/AlphabetRail.qml +++ b/qml/primitives/AlphabetRail.qml @@ -3,85 +3,62 @@ pragma ComponentBehavior: Bound import QtQuick import "../theme" -// The letters of a name-sorted list, beside its scroll bar. At rest each -// letter sits where its titles are in the list, so the bar doubles as a map -// of the library. Pointed at, or entered from the remote, the rail opens into -// an evenly spaced index with the letters nearest the pointer magnified, so a -// letter with three titles is as easy to hit as one with three thousand. +// 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 - required property Flickable flickable // [{label, row, exact}] in list order. property var letters: [] - property int columns: 1 - property real cellHeight: 1 - // The letter under the view or under remote focus; highlighted. + // 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 expanded: active || pointing + readonly property bool engaged: active || pointing readonly property real restingWidth: Metrics.scaled(24) - readonly property real expandedWidth: Metrics.scaled(64) - readonly property real letterSize: Metrics.scaled(15) - readonly property real spacing: letters.length > 0 ? height / letters.length : height - property real pointerY: -1 + 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 restingCenter(row) { - const range = Math.max(0, flickable.contentHeight - flickable.height) - const offset = Math.floor(Math.max(0, row) / Math.max(1, columns)) * cellHeight - const fraction = range > 0 ? Math.min(1, offset / range) : 0 - return letterSize / 2 + fraction * Math.max(0, height - letterSize) - } - - function expandedCenter(index) { - return (index + 0.5) * spacing + 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 / Math.max(1, spacing)))) - } - - // At rest, letters crowded together by a small library or a lopsided - // alphabet give way to the ones before them; the current one always shows. - readonly property var restingVisible: { - const shown = [] - let lastY = -Infinity - for (let i = 0; i < letters.length; ++i) { - const y = restingCenter(letters[i].row) - const keep = y - lastY >= letterSize * 1.15 - shown.push(keep) - if (keep) - lastY = y - } - return shown + return Math.max(0, Math.min(letters.length - 1, Math.floor((y - inset) / Math.max(1, spacing)))) } - // Backdrop of the open index, reaching out over the grid. + // A quiet pill behind the column while it is in use, so it reads as a + // control rather than as stray letters. Rectangle { - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - width: root.expandedWidth - radius: Theme.radiusMedium + anchors.fill: parent + radius: width / 2 color: Theme.floatingPanel - border.width: 1 + border.width: root.active ? Theme.focusBorderWidth : 1 border.color: root.active ? Theme.accent : Theme.border - opacity: root.expanded ? 1 : 0 + opacity: root.engaged ? 1 : 0 visible: opacity > 0 Behavior on opacity { NumberAnimation { - duration: Theme.reducedMotion ? 0 : 140 + duration: Theme.reducedMotion ? 0 : 120 } } } @@ -95,61 +72,76 @@ Item { required property int index required property var modelData readonly property bool current: index === root.currentIndex - readonly property real centerY: root.expanded ? root.expandedCenter(index) : root.restingCenter( - modelData.row) - - // Magnified by closeness to the pointer, dock-style. - readonly property real lens: { - if (!root.pointing || root.pointerY < 0) - return current && root.active ? 1.6 : 1 - const distance = (centerY - root.pointerY) / Math.max(1, root.spacing * 1.8) - return 1 + 1.1 * Math.exp(-distance * distance) - } + readonly property bool hovered: index === root.hoverIndex + readonly property bool shown: current || hovered || index % root.stride === 0 || index + === root.letters.length - 1 - width: root.restingWidth - height: root.letterSize - x: root.width - width - (root.expanded ? (lens - 1) * Metrics.scaled(16) + Metrics.scaled(18) : 0) - y: centerY - height / 2 - visible: root.expanded || root.restingVisible[index] || current - z: current ? 2 : 1 - - Behavior on y { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 160 - easing.type: Easing.OutCubic - } - } - Behavior on x { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 90 - } - } + width: root.width + height: root.spacing + y: root.centerOf(index) - height / 2 Rectangle { anchors.centerIn: parent - width: Math.max(parent.height, label.implicitWidth + Metrics.scaled(8)) * letter.lens - height: parent.height * letter.lens + Metrics.scaled(4) - radius: height / 2 + width: Math.min(root.width - Metrics.scaled(4), root.letterSize + Metrics.scaled(8)) + height: width + radius: width / 2 color: Theme.accent - visible: letter.current && root.expanded + visible: letter.current } AppText { - id: label anchors.centerIn: parent + visible: letter.shown text: letter.modelData.label - scale: letter.lens font.pixelSize: root.letterSize - font.weight: letter.current ? Font.Bold : Font.DemiBold - // An estimated position is drawn a little quieter than one - // the server or a loaded row has confirmed. - color: letter.current && !root.expanded ? Theme.accent : root.expanded ? Theme.textPrimary : - Theme.textMuted - opacity: root.expanded || letter.modelData.exact || letter.current ? 1 : 0.6 + 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 { @@ -157,14 +149,12 @@ Item { property int lastIndex: -1 - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - width: root.expanded ? root.expandedWidth : root.restingWidth + 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) @@ -175,17 +165,14 @@ Item { lastIndex = index } - onPositionChanged: mouse => { - root.pointerY = mouse.y - if (pressed) - choose(mouse.y, false) - } onPressed: mouse => { lastIndex = -1 - root.pointerY = mouse.y choose(mouse.y, false) } + onPositionChanged: mouse => { + if (pressed) + choose(mouse.y, false) + } onReleased: mouse => choose(mouse.y, true) - onExited: root.pointerY = -1 } } 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/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/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/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) From 7c5083f3e6641bcf2ad829d6ea325f98758c05bb Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 02:15:54 +1100 Subject: [PATCH 70/78] fix(artwork): aim at a server's capacity, not single-request latency Against a remote Plex server the adaptive limit fell to three or four: posters took longer each with more in flight, which the latency gradient read as overload, and its periodic low-load probes pinned it there while a screen waited seconds in the queue. The limit now follows what the server actually works on at once, estimated by Little's law from each fetch's duration against the fastest recently seen, and keeps a little more than that open. Starting high is avoided too: a scroll cancels requests the server still completes, so anything sent past its capacity is a backlog the next screen waits behind. The test simulates servers with real queues, including scrolling with abandoned work, and holds the adaptive limit to the best fixed choice. --- src/app/ArtworkConcurrency.cpp | 82 ++++++------ src/app/ArtworkConcurrency.h | 58 ++++----- src/app/ArtworkService.cpp | 16 ++- tests/app/ArtworkConcurrencyTest.cpp | 178 ++++++++++++++++++++++----- 4 files changed, 226 insertions(+), 108 deletions(-) diff --git a/src/app/ArtworkConcurrency.cpp b/src/app/ArtworkConcurrency.cpp index f216e966..5441c3b6 100644 --- a/src/app/ArtworkConcurrency.cpp +++ b/src/app/ArtworkConcurrency.cpp @@ -7,21 +7,26 @@ namespace Spool { namespace { - // Fetches this much slower than the fastest still count as unqueued: - // image scaling on the server varies from poster to poster, and a few - // milliseconds of jitter on a LAN is not congestion. - constexpr double kTolerance = 1.5; - constexpr double kSlackMs = 8.0; - // Fetches between baseline probes, and unqueued fetches each probe takes. - constexpr int kProbeInterval = 80; - constexpr int kProbeFetches = 6; - // A probe that cannot get its fetches (the screen stopped asking) ends. - constexpr int kProbePatience = 30; + // 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) @@ -33,8 +38,12 @@ int ArtworkConcurrency::limit() const { if (!automatic()) return m_fixed; - const int limit = std::clamp(static_cast(std::lround(m_limit)), kMinimum, kMaximum); - return m_probeLeft > 0 ? std::min(limit, kProbeLimit) : limit; + 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) @@ -45,53 +54,36 @@ void ArtworkConcurrency::seed(double 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), 24.0); + m_limit = std::clamp(kDefault + roundTripMs / 10.0, static_cast(kDefault), kSeedCeiling); } void ArtworkConcurrency::record(double durationMs, int inFlight) { - if (!(durationMs >= 0)) + if (!(durationMs >= 0) || inFlight < 1) return; ++m_samples; - if (m_probeLeft > 0) { - // Only fetches that started light speak for the unqueued baseline; - // ones still finishing from before the dip are ignored. None of them - // say what the full limit costs, so the recent average waits. - if (inFlight <= kProbeLimit) { - m_fastestMs = m_fastestMs < 0 ? durationMs : std::min(m_fastestMs, durationMs); - ++m_probeSamples; - } - if (--m_probeLeft <= 0 || m_probeSamples >= kProbeFetches) { - m_probeLeft = 0; - m_untilProbe = kProbeInterval; - } - return; + 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; - m_fastestMs = m_fastestMs < 0 ? durationMs : std::min(m_fastestMs, durationMs); - if (automatic() && --m_untilProbe <= 0) { - // Start a fresh window: the next light fetches set the baseline. - m_probeLeft = kProbePatience; - m_probeSamples = 0; - m_fastestMs = -1; - return; - } + // 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; - const double expected = m_fastestMs * kTolerance + kSlackMs; - const double gradient = std::clamp(expected / std::max(1.0, m_recentMs), 0.5, 1.0); - // A sharp slowdown is either overload or a slower network; only a fresh - // baseline can tell which, so it comes sooner. - if (gradient < 0.6) - m_untilProbe = std::min(m_untilProbe, 10); + // 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. - const bool saturated = inFlight >= limit(); - const double headroom = saturated && gradient >= 1.0 ? std::sqrt(m_limit) : 0.0; - const double target = m_limit * gradient + headroom; + 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)); } diff --git a/src/app/ArtworkConcurrency.h b/src/app/ArtworkConcurrency.h index 2227bce8..e5891946 100644 --- a/src/app/ArtworkConcurrency.h +++ b/src/app/ArtworkConcurrency.h @@ -4,23 +4,22 @@ namespace Spool { // How many artwork downloads to have open at once against one server. // -// Posters are small, so a grid's worth of them is limited by round trips far -// more often than by bandwidth: on a LAN six at a time is plenty, while over -// the internet at 80 ms every extra request in flight is nearly free until -// the link or the server's image scaler fills up. The controller starts from -// a round trip estimated off the first fetches and then follows them. While they -// finish about as fast as the fastest one seen, nothing is queueing anywhere -// and more requests in flight are pure gain, so the limit grows -- but only -// while it is actually the bottleneck, so it settles at what the screen asks -// for. When fetches slow down, something is saturated and the limit backs -// off in proportion. This is the gradient form of TCP-style congestion -// control, applied to requests rather than packets. +// 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. // -// "As fast as the fastest" needs a fastest measured without queueing, and a -// busy grid never runs light on its own. So now and then, like BBR's -// PROBE_RTT, the limit dips to a few requests for a handful of fetches and -// the baseline is measured again; that is also how a move to a slower -// network stops looking like congestion. +// 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; @@ -28,9 +27,6 @@ class ArtworkConcurrency final { // What a fixed setting and an unmeasured server start from. static constexpr int kDefault = 6; - // Requests allowed while re-measuring the unqueued baseline. - static constexpr int kProbeLimit = 3; - // A positive value pins the limit; zero adapts. void setFixed(int limit); bool automatic() const @@ -42,33 +38,37 @@ class ArtworkConcurrency final { // 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`, having - // started with `inFlight` fetches (itself included) open to this server. + // 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 - { - return m_fastestMs; - } + 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_fastestMs = -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; - int m_untilProbe = 12; - int m_probeSamples = 0; - int m_probeLeft = 0; }; } // namespace Spool diff --git a/src/app/ArtworkService.cpp b/src/app/ArtworkService.cpp index 9c1b99b4..7d1d4cbd 100644 --- a/src/app/ArtworkService.cpp +++ b/src/app/ArtworkService.cpp @@ -235,6 +235,9 @@ class ArtworkFetchWorker final : public QObject { 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; @@ -278,7 +281,8 @@ class ArtworkFetchWorker final : public QObject { return; origin.lastLoggedLimit = limit; qInfo() << "artwork: downloads at once" << limit << "fastest" << qRound(origin.control.fastestMs()) - << "ms recent" << qRound(origin.control.recentMs()) << "ms"; + << "ms recent" << qRound(origin.control.recentMs()) << "ms server parallelism" + << QString::number(origin.control.serverParallelism(), 'f', 1); publishStatus(); } @@ -349,7 +353,10 @@ class ArtworkFetchWorker final : public QObject { } const RenderRequest request = m_renderQueue.takeAt(index); const QString originKey = originOf(request.url); - const int inFlight = ++origin.inFlight; + ++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); @@ -361,17 +368,18 @@ class ArtworkFetchWorker final : public QObject { }); connect(reply, &QNetworkReply::finished, this, [this, reply, requestId = request.requestId, key, queuedNs = request.queuedNs, fetchStartedNs, - originKey, inFlight, firstByteNs]() { + 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, inFlight); + origin->control.record(static_cast(finishedNs - fetchStartedNs) / 1e6, sharedWith); noteLimit(*origin); } } diff --git a/tests/app/ArtworkConcurrencyTest.cpp b/tests/app/ArtworkConcurrencyTest.cpp index b249310f..f8b0dbfa 100644 --- a/tests/app/ArtworkConcurrencyTest.cpp +++ b/tests/app/ArtworkConcurrencyTest.cpp @@ -6,6 +6,7 @@ #include #include +#include using Spool::ArtworkConcurrency; @@ -19,20 +20,108 @@ void require(bool condition, const char *message) std::exit(EXIT_FAILURE); } -// A server whose fetches take `baseMs` until more than `capacity` run at -// once, after which they queue and slow in proportion. Returns the highest -// limit seen over the last 50 fetches: where it has settled, probes aside. -int drive(ArtworkConcurrency& control, double baseMs, int capacity, int demand, int fetches) +// 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) { - int settled = 0; - for (int i = 0; i < fetches; ++i) { - const int inFlight = std::min(control.limit(), demand); - const double durationMs = baseMs * std::max(1.0, static_cast(inFlight) / capacity); - control.record(durationMs, inFlight); - if (i >= fetches - 50) - settled = std::max(settled, control.limit()); + 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 settled; + return best; } } // namespace @@ -45,35 +134,64 @@ SPOOL_TEST_MAIN("artwork-concurrency") ArtworkConcurrency distant; distant.seed(120); require(lan.limit() == ArtworkConcurrency::kDefault, "a LAN starts at the default"); - require(distant.limit() > lan.limit() + 6, "a long round trip starts with more in flight"); + require(distant.limit() > lan.limit(), "a long round trip starts with more in flight"); - // Latency-bound: fetches cost the same however many run, so the limit - // climbs to what the screen asks for and no further. + // 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(80); - const int latencyLimit = drive(latencyBound, 90, 64, 28, 300); - require(latencyLimit >= 26, "a latency-bound server is given as many as the screen wants"); - require(latencyLimit <= 31, "the limit does not run away past the demand"); + 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"); - // Capacity-bound: beyond four at once the server queues; the limit backs - // off to about what it can serve. + // 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(80); - const int capacityLimit = drive(capacityBound, 40, 4, 40, 300); - require(capacityLimit <= 8, "a saturated server gets fewer requests"); - require(capacityLimit >= ArtworkConcurrency::kMinimum, "never below the floor"); + 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 - // forever: after the move it may grow again. + // A network that gets slower is followed rather than read as congestion. ArtworkConcurrency moved; - const int before = drive(moved, 5, 64, 30, 150); - require(drive(moved, 60, 64, 30, 400) >= before, "a uniformly slower network is not mistaken for overload"); + 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); - require(drive(fixed, 90, 64, 30, 100) == 4 && !fixed.automatic(), "a fixed limit is not adapted"); + 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; From 3b30cb49d4531e19c808d5ed1d600b18e676e458 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 02:16:00 +1100 Subject: [PATCH 71/78] feat(accounts): add servers from a sign-in Spool already has Each server section on the accounts page offers "Add a server", and a service tile on the add page starts from an account already signed in, with the new addServer purpose. A provider that can reuse its sign-in (Plex) lists the account's other servers at once, as a Plex app does; one that cannot simply asks again, and the shared server sign-in no longer pre-fills the existing server for this purpose. Setup contexts now say which accounts this provider already has -- identities only -- so a sign-in can mark servers that are already added; server rows take a badge and an icon for that. --- qml/pages/AccountsPage.qml | 22 +++++++++++++++- qml/pages/AddProviderPage.qml | 6 ++++- qml/primitives/ServerCard.qml | 44 +++++++++++++++++-------------- qml/shell/ProviderLinkScreen.qml | 3 +++ qml/shell/ServerLogin.qml | 4 ++- src/provider/ProviderRegistry.cpp | 12 +++++++++ 6 files changed, 68 insertions(+), 23 deletions(-) diff --git a/qml/pages/AccountsPage.qml b/qml/pages/AccountsPage.qml index 3b5d38bc..53f0ac20 100644 --- a/qml/pages/AccountsPage.qml +++ b/qml/pages/AccountsPage.qml @@ -659,7 +659,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 = [] @@ -748,6 +749,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) + } + } } } diff --git a/qml/pages/AddProviderPage.qml b/qml/pages/AddProviderPage.qml index b577829d..420c635e 100644 --- a/qml/pages/AddProviderPage.qml +++ b/qml/pages/AddProviderPage.qml @@ -78,7 +78,11 @@ FocusScope { 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) } 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/shell/ProviderLinkScreen.qml b/qml/shell/ProviderLinkScreen.qml index 3ec26d83..bda81bd0 100644 --- a/qml/shell/ProviderLinkScreen.qml +++ b/qml/shell/ProviderLinkScreen.qml @@ -116,6 +116,9 @@ 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 : "" diff --git a/qml/shell/ServerLogin.qml b/qml/shell/ServerLogin.qml index 8fc5725f..96056c1d 100644 --- a/qml/shell/ServerLogin.qml +++ b/qml/shell/ServerLogin.qml @@ -361,7 +361,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) diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index 62b2821c..c07a195c 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -1716,6 +1716,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; }); From 8d05011a2891d52c6c771dc11d2b3b6dfde365de Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 02:16:21 +1100 Subject: [PATCH 72/78] chore(providers): bundle Plex 0.1.12 Lists a saved account's servers without a new link code. Like the other current pins, a reproducible local build not yet published as a release. --- providers/bundled/spool.plex-0.1.11.szo | Bin 55475 -> 0 bytes providers/bundled/spool.plex-0.1.12.szo | Bin 0 -> 56045 bytes providers/lock.json | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 providers/bundled/spool.plex-0.1.11.szo create mode 100644 providers/bundled/spool.plex-0.1.12.szo diff --git a/providers/bundled/spool.plex-0.1.11.szo b/providers/bundled/spool.plex-0.1.11.szo deleted file mode 100644 index 41c83d2d51c28b3ee65fa02a99bd72a5031f13dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55475 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 Q;>%CH>|T%TJhJG2R7YE5qyPW_ 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 0000000000000000000000000000000000000000..3fa77e3e0c0ba5943e9edaa87a6378194cd3ba2b GIT binary patch literal 56045 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

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.

diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 072a26c9..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.1 + 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.1 + provides = spool=0.9.2 conflicts = spool options = !strip options = !debug options = !lto - source = https://github.com/spool-player/spool/releases/download/v0.9.1/Spool-0.9.1-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 66555bdf..c108874b 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Sacha Korban pkgname=spool-bin -pkgver=0.9.1 +pkgver=0.9.2 pkgrel=1 pkgdesc='Fast media player for desktop and TV, built on Qt Quick and mpv' arch=('x86_64')

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 OTxtJHq!9~u2zO~HbALww literal 0 HcmV?d00001 diff --git a/providers/lock.json b/providers/lock.json index 0ec5ffab..b13c89e3 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -18,9 +18,9 @@ { "id": "spool.plex", "appleAppStore": true, - "version": "0.1.11", - "archive": "bundled/spool.plex-0.1.11.szo", - "sha256": "ed744e0a98cf0bf7bbade4818c65ad778568a671a058fc1fd5bdff9a10e9edc1" + "version": "0.1.12", + "archive": "bundled/spool.plex-0.1.12.szo", + "sha256": "10c055f0342aff065033cae9e2c3a34d460dd06ec30b9696524ceb6baa2a5a7f" }, { "id": "spool.stremio", From 61475f52663005bb06242c90c016df9251ec4df9 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 03:43:17 +1100 Subject: [PATCH 73/78] feat(ios): publish a sideloadable IPA with a SideStore/AltStore source The iOS IPA is unsigned, so it was only useful to someone with their own signing setup, and it was built in App Store mode, bundling only store-approved providers despite never going through review. It is now built as the open flavour (every provider, provider links) for sideloading, and each release publishes a SideStore/AltStore source at spool-player.github.io/spool/altstore.json, generated from the IPA's own Info.plist so its version, build, bundle identifier and minimum iOS cannot disagree with the file. App Store mode stays the default for signed builds and tvOS. docs/install/ios.md says how to install and what a free Apple ID allows. --- .github/workflows/ios-review.yml | 4 ++ .github/workflows/release.yml | 20 ++++++- CMakeLists.txt | 7 ++- cmake/platform/AppleMobile.cmake | 7 ++- docs/install/ios.md | 51 +++++++++++++++++ tests/tools/AltStoreSourceTest.py | 67 +++++++++++++++++++++++ tools/apple/altstore-source.py | 91 +++++++++++++++++++++++++++++++ tools/build-ios.sh | 10 +++- 8 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 docs/install/ios.md create mode 100644 tests/tools/AltStoreSourceTest.py create mode 100755 tools/apple/altstore-source.py diff --git a/.github/workflows/ios-review.yml b/.github/workflows/ios-review.yml index cd504ebd..77ff63e2 100644 --- a/.github/workflows/ios-review.yml +++ b/.github/workflows/ios-review.yml @@ -64,6 +64,10 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a849582c..926fbdbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +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 — unsigned (re-sign required)' '*-iOS-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' @@ -176,7 +176,8 @@ jobs: 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 also **unsigned** and requires your own Apple signing identity and provisioning profile before installation. It is not distributed through the App Store or TestFlight; simulator startup does not certify device playback.' + 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 @@ -255,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/CMakeLists.txt b/CMakeLists.txt index c7e95ebc..1fe2c947 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1146,6 +1146,11 @@ 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" @@ -1318,7 +1323,7 @@ if(BUILD_TESTING AND NOT SPOOL_WEBOS) 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 player-setup-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 webos-tooling PROPERTIES LABELS traditional) # Exercise the actual curl scheduler/counters, not just its exposed 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/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/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/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/build-ios.sh b/tools/build-ios.sh index a3d27899..2b4ed977 100755 --- a/tools/build-ios.sh +++ b/tools/build-ios.sh @@ -16,8 +16,16 @@ 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 @@ -40,7 +48,7 @@ cmake -S "$ROOT" -B "$BASE/app" -GXcode \ -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=ON -DBUILD_TESTING=OFF \ + -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:-}" \ From 63b25f02cfd7e9b256fbe73ac55d2983411886d1 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 03:50:34 +1100 Subject: [PATCH 74/78] feat(accounts): sensible defaults instead of questions after sign-in Every sign-in ended on "Who's watching?", asked the viewer to pick the profile they had just signed in as, then asked how Spool should start. A sign-in that opens now goes straight to Home: the new profile becomes what Spool opens with for that server, unless the viewer already chose to be asked, and both stay changeable on the accounts page. A failure still lands beside the profile on that page. Searching the local network is asked once per provider rather than at every sign-in, and a sign-in screen that finds exactly one server at home opens it, once, so Back still reaches the list. --- qml/pages/AccountsPage.qml | 6 ++++-- qml/shell/AppShell.qml | 24 +++++++++++++++++++++++ qml/shell/ServerLogin.qml | 14 +++++++++++-- src/provider/ProviderRegistry.cpp | 15 +++++++++++--- src/provider/ProviderRegistry.h | 3 +++ tests/e2e/AppJourneyTest.cpp | 7 ++----- tests/providers/ProviderRegistryTest.cpp | 9 +++++++++ tests/providers/ProviderUiContextTest.cpp | 21 +++++++++++++++----- tests/qml/tst_ProviderForms.qml | 20 ++++++++++++++++++- 9 files changed, 101 insertions(+), 18 deletions(-) diff --git a/qml/pages/AccountsPage.qml b/qml/pages/AccountsPage.qml index 53f0ac20..40c672c3 100644 --- a/qml/pages/AccountsPage.qml +++ b/qml/pages/AccountsPage.qml @@ -603,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 @@ -611,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." diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 865fc226..70c2e875 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -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) } diff --git a/qml/shell/ServerLogin.qml b/qml/shell/ServerLogin.qml index 96056c1d..415419a7 100644 --- a/qml/shell/ServerLogin.qml +++ b/qml/shell/ServerLogin.qml @@ -117,6 +117,7 @@ FocusScope { validAddress = !!result.servers.length }, () => {}) } + property bool autoChosen: false function mergeServers(found) { const merged = servers.slice() for (const entry of found) { @@ -146,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 }) diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index c07a195c..7376cf90 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -333,6 +333,7 @@ QCoro::Task ProviderRegistry::restore() 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()); @@ -956,7 +957,8 @@ void ProviderRegistry::persist(bool credentials) { QStringLiteral("activationOptions"), QJsonObject::fromVariantMap(m_activationOptions) }, { QStringLiteral("startup"), QJsonObject::fromVariantMap(m_startupChoices) }, { QStringLiteral("networkProfiles"), QJsonObject::fromVariantMap(m_networkProfiles) }, - { QStringLiteral("networkEndpoints"), QJsonObject::fromVariantMap(m_networkEndpoints) } }) + { QStringLiteral("networkEndpoints"), QJsonObject::fromVariantMap(m_networkEndpoints) }, + { QStringLiteral("lanApproved"), QJsonArray::fromStringList(m_lanApprovedModules) } }) .toJson(QJsonDocument::Compact))); } @@ -1907,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"); diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 5a3898b0..39856810 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -306,6 +306,9 @@ class ProviderRegistry final : public QObject { // 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; diff --git a/tests/e2e/AppJourneyTest.cpp b/tests/e2e/AppJourneyTest.cpp index 10d992b4..74766d42 100644 --- a/tests/e2e/AppJourneyTest.cpp +++ b/tests/e2e/AppJourneyTest.cpp @@ -1171,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"); 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/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") From 0f749a517b0a115e8b49e61fc9377cd45f97bdc6 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 03:52:57 +1100 Subject: [PATCH 75/78] chore(providers): pin the published Plex, Jellyfin and Emby releases spool.plex 0.1.12, spool.jellyfin 0.2.15 and spool.emby 0.1.10 are now published; the pins are their release assets. The Jellyfin and Emby archives unpack to exactly what was bundled before, compressed by CI's zstd; Plex 0.1.12 also connects a lone server without asking. --- providers/bundled/spool.emby-0.1.10.szo | Bin 52774 -> 52778 bytes providers/bundled/spool.jellyfin-0.2.15.szo | Bin 44437 -> 44441 bytes providers/bundled/spool.plex-0.1.12.szo | Bin 56045 -> 56072 bytes providers/lock.json | 6 +++--- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/bundled/spool.emby-0.1.10.szo b/providers/bundled/spool.emby-0.1.10.szo index a43566f37df2cdd5608a5f1cdd7c08eeddcd3cad..cbaef29f1609d7c70f17846607bc5c3fde04ba55 100644 GIT binary patch delta 21 dcmZ2Bhk4Z;W>$@@`hS;fWK}%JqH17z2>@PG2u1(^ delta 16 YcmZ2Ahk4l?W>$@@`hOQ}WK}!|06AF(;s5{u diff --git a/providers/bundled/spool.jellyfin-0.2.15.szo b/providers/bundled/spool.jellyfin-0.2.15.szo index af042abb5e3dd621c47754768d0a8e21f7b289db..0a23e13b9e300ac5a70596891ba1ff301c84df6f 100644 GIT binary patch delta 21 dcmbPwn`!24CRUBD`hS;fWSy{<={0%@_iDNPG?`u#(*Ysw&Y=l97@eGX$`ASv)}Ias+W_1E-2fi0wxunsN=OJsLcKFq#k9l^tT4*`Xw*6P zIxO6XZzN=TF#bLXA&#$sjS9w_Evs^r;w2P|F?h{`+T4pVR#MldgqeIkQ@Jf>*| z8UE`vT`}IlhSnaG!_C%_`2wC3Vmp=x5ia0tKUoS4ahRMb5d!~#`ra-ya)$(KX z8>1`xRn3>Y@3lcZlhSlE#;_2BY|DOB0hBt5(l~9yKpRHD|7&i^8!7KQ%LGPaiKE8_ zYcuZ+KY?ujLuD#*Kv=g6$o|NGNF>v#*BvPj@$WazqQ~@S#<&-FqI|LA+_EU$+W&~D z9O4)vG5pXHqbR5N>dGbq-o2Ss<(y#!TXwxJVGZ5`tgp_)XIx?L`H5&v)y7#DV(=y6 zB)7Q8qH>SGW6zu=&tS^qyjLfYe4lC4x-YCea3Zw)-3)v7v|@mi80D{jn2NCNf9h=n zjnqC(nrbOpnY8-ngH6zqp;PJH@Vg!T)8V9i>MY`Xo$snCv z``J05eF1q&Boa}nWtn196<1Gc$p{`&OfwS3-Am1(OCSV8J}_esVdJ=}niOnTYwrRB zR>As`9FgP4>a8$oZ{2o(qukoGoB#m)Mz0VbBEzx*ObbI>+FX47My_hCV=c+8)i@5@ zN>ga>gmO`|tYFI5qbQNcr6qioPvv6y5;o4VEQ{hkOzw+4lvFPuG2?rGa9StJm_!N*$k@Q@ z$ceqSr=snb5|W(UBu&#SP4j>xVcMoWIBLIqE*po64coK=Ei3KZ<*T&K52tx!V6<7qkpTX*i3fa*Cb> z-Wk_Q=Y;qS5d+tJwgd%*PDOP@o_*nx8DWeoNj&wJ!XVQQGO6RO5w%P_0JxHT6f2?j zjI}mIic_Tj?{~yb*m{*Wp`xdwLjY=dL4UtUU1C_XQTTt*n45KCZTWrvC;geUYMFGlIb)jsMJ;w;0-2kr1l(OTAV^a%03awhBoWC(GMP-3 zr5_~Xg~by?j0oakk|1yf3xP;ThDKn9p%5Sf0vs`fq$z4x09@zyi<26p5#ER#PBvF( zdopC&03dUJ4?Rly8_#}8T}^5FhjMBO`P0pYLOK>n?lTMg>^>QSc_#%gF{L9Eva8CC z6WcEuu6w8wrU-s>Cq^?MsT%Ha}(N3C;Ak?I`=N&_AiNa z0T>x{F4a9r&jmm+VlhJU!y3U$qb=c-W{y`3ozn4tKw)pz7%Jlu;o??wVs&T>OxQBf z5FJA9BA>LFPSO$W&3JYT+Lsh2{CHQ1Qxs3P>MqglXJ~~EQ3jL7>inq4QDk@j)3k*s zL`;|_xve** zH(V~oA+lWWzKaFK}7YRUU zijhNwj&9z0y+nj^((*jxU0OChhBL%{yPYK}mcs1%1y01gg^AJ_nA=@%XFXTy{zUqJ z`y)0tTHZ5IN`V!;gLJ|i;7)qUyj~mN6sdZXw00bjdEM|)fV@VMUaxJHZFAf=Y>m3u zMPgxC@FDZ4>uO31^*Q7%pi|CdxURjtjc9V*Nm!y=8Jca>49h{UOvl>+J{J*H+RJ8@ zQW*C~NhNzA`^feKS1o0?jaSMW7~F4v9Y`}5PeHk@zK3b^UjRa$I+u#EbRiYC{00!z z=Q2DNl!epvZLK{fFPDu7gG0|fP>G;11P`>N=wqe!AkbtV>&508Lo0YrP3R9TT&BiC z0T%q#Nya6V99Z%~DExTwmSiOt1wGW(CxFzCE#jafU*m z0>HGvK?*6dymoQs4_J`E47d1kpn&6LDtyco561&a`A1Rkq0CqTPnCrPf`V9mD?Bom zOLC1sx+Z3`Rv#JFujyOheUe-sVhH@V)73OU%c#H?(01Cem z`nbIr={PdDQgyVa--xK)$vN2bIcJFgtZ*FQg_f1{1m6H+=WOcicOfjXKMiRbj^j}A3!6)G<;h_Q z(nLB=FeW<>%&o-m!F zG#P}e&n?sULJm|%!o{1gFb~n=+H}Mx!e}fseHcnGg7yRCP29JxCF*H{`E&W2`6c{|X z&!f;2A8=>TkI0`G{-fx|JBm1hZF#ZcO_6w>-p~}X{e&Y6hdKv&&zkX#32d*)(e!;) zy#;HE;Cv+FJc(Izfh3u#moc62FEyqfKqsE zf!MRaH}iTn_vkARsoxez&*>CIXP7o7D(2tgC5UIV(eD1DIdo9u zs8%H@vJ>HdK!q8xS9UPJ7B}sTj5-mi!&wsbS!gO#TDDqYCIG@mk+W&mOcH5L3hj6t z6kjXNZ2<69wx+vLpoRkP2nA7+I-nqJ`$sr-hG0_7m1lchtuEtmuEYy#FdXcJ>_d)Q z>su*dnn8!(=^!Kc<6#h79-#O>3_qm_1czdQirb@q$}H2jQvjvgx36uqXy(DwdDy({9^!@;h zK2h=Neu6M)tbL;1Eh;{w%E@+~HcOz)S+PCa+L}%OEXf&37&;CgEjffPBX=?f89-pJvfT@~2LXa#2O*KG+pHTcOU zw~GjGr?=19)FIDL#yN(z;3~aZ#GJ=cYM)~7ogY$SAWAriFSJwZTEnt@u}0Fen|EUA zowFOH@ir7-YA-G;rHcTYP``Ez8>JTc96Q;6GV^dMPB~!#Fs>8*1u-L7<>;**(b$Vx zj3bCwxl>EMtWEPw?Ndkoo?vuzIx?cOkQi)Ft};B`m>z zN?b_vp%Yt`#0J3C9{Op6*wDfB&sgcN3F-5xSZW107Y2pAvBSUv!C*(v^Y@4gM$(G_ z?5z^RqW)q+@?h-)KVif4J^;K~OoWvJuN{wugaLy5 zQR-GKMU$I*bfGHME4|tC#Z?jTSmAMhOZBAb$(-SJMhQE>dH-Mt!4w#j?->@T=mL_nw;{X8FaE9cA*IGdsohy@Rgvsm=<5AMx) zM(S`6XUa~tIWfTB3S$YPzIutYvZ!F`JkO-X%s|+3bK^yPqnVhvll{56vSV0(FD}9D zj%dd`HLsnecC5*BLPizCK&r8OEfZ&A*%R5rE93xZV6$K()ShL1<5CpF8h|=*FP$)w z&~mp!(KL8-f$2Qm^1*CIxCm}BW+u)tzx#ybs-ogoLR39Vze5#LcO&JtIN<7K3?7LQ z8@3}@5sD4ac8QJ9g%hZUzC)RR3cB|!_l_PDQeJG@+=Ilyb(|m3vAzV7yWkuY0n9%U zCT*~S4_tOZt{_FC#M?Z#Hqk!N@S;K>f(0a9=!$x!%>%^yS*4Z>%GNganh4#WI$go4 z!1`DVzs&t>AlYj7j-h$POx`##29Nz#co(D5jc^8EzK_{h!%7B0AenQ2F~XpXtrIo` zmyD$G*xXzkOhSF3nGL;&p};KiB)ndi>#}2!gV!vtT>8zvQhODiA01X0=tfP_JBOi$ z007#e#nFNeuq<5UltQ+5W))&Nk%E1c5P0|4fzIt4Sx3H6{_RI16b|_#Unk&kOv!n8 z?fOC!uRxy(pBrt1T^7B6o%iK`0ed0fg2VR9UvlV&?0PI=u9IJA}kH zvhkxkb15^(81sR};farwR~A%etUEF<#wCxRe#_?%y1-rwk)K}+XE4ckS8&S(4eqP9 zj8IWg3och7J#|eol@p`3v7MPevQ5DMVOE}EI?ri_z!I1xMN2||9TQD=@8kbCtk;WYBd)pgyO4vclnXkQ>{8CLV z(-s#lwg9}$K1qnTJp!kE%_#?!cC~1z9{s(!kv53SpL1T97e^{+C$tEO^S}Q8r7za( zhFOr`pSI4jRR-jLYRJ2^ogJJ~IfnXjc?VVDY?~kNDY!6fCqz!_xdDPa#d~Sf=c5(K zfn9ZvFf)0Yx^zD5)FD#zt2&S40*e?AVE4}5FFPGubCtVb&P z%SDYQ=Iv!3VXzLcSgX|y-MZmF4B?kj0d^%MgViHhw*$m4@)eA{mvZ4Z_xVOybU{$3 zBJG-!pu-OZSw&O?o7GT|Xt0$|JZ!t6^)2YZ*Fo5t!omlMGJ_lrF_n71`e{hBmzV>d z7;JzAoobqYuHEGBp6H!2bI#|>qjWR{{hRQ^_efz1Jkk&vo1tso*CMC(mI7RdDX|Q>KG4y0Nsm`>; zS*v)Jxh&IkKz_vyEXc}ArpLcQq}Kdj2b$Bu&Kxw2KjE3*hvCD)3$k85lcIwG&job5 zJj7ig5}X3EP(ua)IClfI2!?0yWVL#vkp=*`kKzT%z}+ySzZrJ3g3;2ZITiOi4PU__ zYX|3l!Ed?03uAhL6qG|8A32j=mUgkVwI630Gi+R-K-TTSfE3%zQ&!QU>kx=rCqfn- z+^7H{z?f6U3V7x010UgKmha&?w_@zTu-w!NiB%2RSy8CCDaEw5*+8<50-w!1P1v#? zhAS$WlHAs0I7EHv6dK2d!VDcYhD>OU2@8~ePPqONb`@&5kr|IpJWUW^Ex7((YCR8Z zkW#d+2(rf*ll6-!f(oMi?X3Sn9|lFA!J`QN^L-|1+~1ZaZrWQBpmJSfb6 z$ar$VT9uUOy3N9L}T(11&3G zti5SVF%AooZmBUB<*OH`Q8>Gt|A4ej%}LC$c}KY@VEJ5sBiT+4aIVBWtF5{PAUhHb zE*}+lq_k&B42RC%@AQ5CjGBWpo8@GG^r5qRn~4ijLxGcS(e3<}hMG1nF-rcfG+S)I z{bf6JgI}8cZ_9^s{7rpH_evJfpeC3w&Pu>R{yF)MBT%d&)Uh~Anzofq`IR9yFO`@} zoXeF~<;AgR;pmSf?KjND2p_1E3r^LD+jC zbw6vX>W0F(JQh~uxMtxNLA#B2V z-(QyC4hU3)gqz6P8VYjcqC<#E_@1Wo!uc$%Lg3b@D;K}^YB!W|Mt4hr@y^glkKZ-x z8WQDHQo_z)iLi&Y)LzlXEq%{_udRmhgMy~Gp_U5NYpsO}326K+4*hD=DIsqd)X0Hg zR){s4OIaN{WA#2K^J#dZ?0M=9@Uhqx6Y-LH;1@MV1GhJ zT}Mn}{aEad0qB!1+A5+YcIdNaIiw?{1@|~k&?T_){f%Svbia~xsn`F1^Qm(Y-CNb% zQJ&a=O`M}42!f?1WHPXfpu94M<8ierXHE>`arH-_GI^|5r8kQJTEHex;(}8HA(Kgh z(ay$8Ww^de>!)DX7<|IVoh7)YO9dGCT-q;#2Q@VJaVcxcdD;7H{Vion+btEPwm91h zv)NKX(;APfg;z@jsW>fvEg-C_r}LuEO;g45QGyI!4giM*%qDw z<=tV&K1_GVn1LcZnPxhbur5p_LNF@~o5w}~H9W@&8n8qJ1r!2ER{_>(uR?SGpGfcu z+aa5fmPo8ZDkn65{$=AH8z+F~`twtp98$I$8p<2;J8PVn`58<+FHn}xdB!E>NdF~k zR7rW(X!hD751wnLh@qg8Tud$9A874?qIiqLMF)9I48g32M9azOfzx*3atC3yi15Ab zW*A5HA`cqc|Bz$K7-DvNLo^6rlQuITP{5pYYlt=UQ^^y5U9#II`7#pB&Ldqw+Eb;6 z=#`Lyee{Swg<%}9F&}&+NOOS;0%II3WIPjE0+q9&K|sOHpbGY#)k8_7fKzSTm}3Yi zXOKm-uFmUfMPmJrK*Xd7Q<#+Gur^D!PY6BkrceU2m?2_dii=^`^N@G7YW)_@K{FI@ z*HbLaS0W~VEB}41v*w>O_=q2FicP0#L-Jb~{EeYc+_cieXGy=mg3Rjcl(!$LUpeMe zF&)@L0ODRW*J?-y>)5@rAY&%v=_^%yqQvxcG)>S$M%28={`D;wfa2ADx6#yHqNB`Q zrcPkwnf&j*f?hyizO@A#R|WA#-KarCEuvFbw~c39%D3T9=xARJv=xge+l z30I2pse8kOPfWx9Ix}$X&aeoOY|Kx7F5&dvdYaNv?Q^_Y;^92|u(Ev{l^0GKo@NFXq}}NxNd0+hq_nIEdKZDKBxx~5%xpYZhS2>V z8TITJdOzb{2NDgihMMNdLru)f9Cn3IW`B1WR$<8Xfv39T~1v|sspwuD}mVy|k_0xW}l zGcSPS4uz`o3^0P48w@uG0U)8Es*ytjKOR4kq<};{7`Ae9j5?T{y5<$@aHJc`g5$hxg4MH|Ma94ExH^DKwVeaQW_f=6em=67(=b=EcL+{PQaba&WGX|Yq`#&l71}~6ju9~HWACr zc`$`&0LaFsV{<;QeJ&8MY@e(fcRIj-B>r&%xTO#A^K&$e*o0skXMVDLm$?Y;* z6=BG;Ww=6s@RR(Vv+oL{-2vkFMdjImppofNCJ?pTs0y)- zw~MyV(14;;)Zo|z>{br62hhUMR^$jM4I*khAlm#No+=%}3%HAtMq_YkOg}X9+7iYt zagC06aJGtqVp8Rx2|O-jclqsqY{oaMj>Wy!BCI}oK!MDu(I3M8|CrjssxRd`Ore?s zw#&8~uW?ae-whO-EVqfVY3qSz(B5)HDbU!L3c8;?LvD z8b?&&9@ccufyeW#C^kxE>6xWKc^Oox`Iuk|4$n+QZr)1Ll9>`LdZ%|7>d9#l-aF!> zC?Tba`hDvw%(rSbuz6U2@osoE&8s+pG~Y(c22*@l&)&Hu1(eoo6e|6CsG=Pwy;0(` z#I9-ZPbl!EG*!SCZ~0U5mZxw9*KX@n z$p;vzg-{3D6}J&G3?}c8FOJd0GH(&-cQF30@gtqv#lYBi)9lWevT z0(OX~jfC3GyV;0`<9ASJbj@#OhWNm(tJWn#DhZYt8jgq507)RL@RW6MM@f)kQp6{1 z>4-Lztp3ALOtgP%&h|*mC#P~ZbWMxrAdGEetAvL4j-^eS5iG`0?v-qggfYo*Rw;_v zfSgwlD%z@vsK%my3;P6U7b8K-?!}G^F-(>0HUpD{{H+Ts0t}_iiStiZT?uZiMa+jK zgJ0%`^}$E%5Nk=WIYR1@knxR$~ZsdVP<-V@Ef zv7@SdE12}SKVJevuKG{{40Oz0L$B5M7&0)7^v6+HIcl3G1su~ubEoWSZ z_I21w3;R=l>LYYh7IEK?KQ>oqfAvWfkQbu(o#?_s_X8Lrz9J|Gp=q7 z61mbOeh_B{%liOY&oLY!9hkTAds%ybX9cL~L!YgKFVk>vfb8WlCTgMt z>R)1n8_vvJ?%X<9hKBhTnJ>gxb*@X(RWb?sgYCtu^gQd>%0jrFj#j&@dI#t*`LDsC zy`R1YTiltoX;#=Z3Y2B4&!oSNNKPq~6rHES1 z5e_eZU9+hd&N?nURNeKYTV?Z-7$zEA zPjvqjp;GCEghqwt-~6=7MNTjuwj17<7@s@o4j*qH3Z_8niFF^=M+uD+zXavUz?_FB zJ09eyclj`JVN}2&&Z^!hroLeQRlo8r_3~bWjxI z@7ZdyG7zO*Fwz^$bJW^q{Qz_qwYJp#74~4*+h4@x-D6~J^~6~lAonFtzK%J<*St1= zWsPvssI*~U+wp0uI0)aQsLw64@}62q!_9BuyQtlf!u{^J92ueY-o17K86P3>L=#Lf zweSC=mkxwz*Yc1w!gaC;coU_}&urHgne{<94SdEQG^8O|6eRjL-2>=riZG>R@wFfF zYEB)eKhnpV*U*fqwv;|xx*NvZ39W?ZO-v(PF#IYKmKw?XhMiaa-tFs-~tqBJ@J zvNVnnF~-%DVZdwqGy7QG*2Nf7?s=!bdorSo=+y4fDrk{~P%6=%VW_IK1LW;qkPU)s zt8c)D5ZnY+qb=U!Uk3OnHm}R_SfG$6`{q6e7Y&xa{G>pThF4k8P_|$a_BfM&?GV(v z;z4U-F4M37P4uqDGM-7%ACl14eSoi=z4-3(Cds>v#mlLZB19j97IX%bK8-M$#0w3P z^b!e`NERB$wQ9}_8bIoq+_Lt#$wtr@eit)(&QP(Raj}?W`35foQ^|B!**fe zIv1gR!O~`I|9elWn?#{OZSbmpz@@E2ua=)rViTmH*xgI9=mXV>@$_NVV32rf>W=sg z@4@3OM^-CAaey?3?dt)5+UyxDNG34`?{H#80P&OJR|yJHiet}njaj`X;KBo03C`FD zl=V-{+lh9H?+H3B;p_f2JAzwIPlKj>74B1ZFGB!T%qH7gepZ(3k4GSXvi%V@CChJ0 zIfYRsB>kYOYYwTmK|191Db^a#llZc{C|_Y%)70HVrtCVYgx1>$^SS=f`t{8k;j)Qz zWw7`RmRl9R!@e(`V5)aFzCklI_u~@W zVlZ|Ze}qmzScDBVsub;i<3!0Q+?7H%)bw5I{1^eZ6bhcwwK~oTzzsRf%muCzhk|~0 z{aC5^ycH7|=9qAaf?16#o8`T?imWeo_#(s)Nn;d9Z(*nNde!keQ%8Q4x`9}gs;XYR zZc=8-n9*0}8#2<3EH?VfXM{hSE&#q(4e5nT{D}UaVMmH+7i-QJ$|=&zPR? z35L9$`{}35?a2jF6cAN=Wg)&~+gtxje3T3iSNb8Hp(=p*j2$LvOt(j#$ZX^;v4OA~ z?U)}2M>*Zu+&l7rW_aZ8Trq(cR=&hp1}C5c%~jK~w1*Arc20wc?|6X+JMj$uw+*lE zL>L08E9Lh}g>tcCKP>Q0n`O7O^O|QE7Pf>Y3Nbh?B(to(Fy4*f9(l*uo5yYI2GZO? zY={iTd1lAiTtV-`qw+_bbLOfyJ=JRZ~cUVV%bc+W!Cx))uDO?f2UE`>S zqunJ||1JjD-CCzVg|Q4Ud)(gwp115DfVHYxoFIl-E0e*2y|~SAzh4-S^h}jndwLED zzuGEi5hXh+t%e#_;l!|kbgsW4%Nc1y24$o0GOEE?EQo+A{nIp3YIR6dx6A4tNB^Q|u?ro4c)(iu%D9A1$}P!ciNk0y?tqMXt>%RK<_o0VlKc^~YNCnvTto?K>u`9dfNE|$xlhPGmnhar@PDI=o&zF0-Vr6c8C=@2+7zBPqrjsYuKtvp~|)=%$p zxmr*|xd0AJzMdNm!YvWo2m)g;#y-@@(CqAiQt=G`-H-`|45gDLUBKi!mNA}b&)Q=9HZ1pBf-1jr4G9=q`#$3(oS7*+_kPpG>csj`>) z{Wuc2YwoHozfezOS!io^32fwHe9M;8)axXQCws7FsniIRSzDCDiC5>IO43_hoG7dg z)qH<{rSIf#@HBiBtc-j>`C?w|IwdfExLYG>icz`GO>r^`jGvc4Gi2QM8x3XS; zLf^bSbC67UDPq3Z%== zn|PXrL9!Z3sr_g*Lx3O~%QoVF{lE1Jz%URtmL8X}$hd6-G%kKh*zGUp?uYR45iWd7 z4!-?TA}?~R`iwvbsojnyk-UN4@sFtRbZOn`LMhWAE(_Omvk<`}xlq|Ut@>`og(>Z} z+{|<500f^$ML9MJopX>U!Otj!N)xosxBq6RD4-dDE#KJn5_12Y+9fZ4k{ywnO(ehv zM@osg#MS9&W|5clCL(x0m>5@CYE@k(J+zlb^1ez(e$wueQ8B|%BM4^c+J1;)yy$0Y z6h`sV^E4rFWL)Sngc)Xmn60R>dge(^TvfPF#s+X~bu+UTf;eJ@PO1{9jJ{Npc#ynf z7X*_eKdN*@#FPe3C_v1AYqI1Y)kpo70RmSIbAqq8HMpuo!J$rN^b;YYAxx0t(w(TU zT3HVaLA|6OSMea=1d4)FzEUOuKsA7h;KY#gUo8eCF3C|cWc_*O&)3Y8wYaY9?H$)D zR95R@=&`C5v~2RBRdRRW=pLIRcqX&W{Z>YBjsiX9W+8+y#Cy0-*TUyOFI#12Fl|;l5n94ZNn39B17f=ddffoU;3v`zR z;LZ+p+4W%qlc}PQE>fnl&k~K5wTgWW?bQ^5`-u&g582;pkscn$0r8tv#R zP1jJYnMnLOb|S@pDGdtmU`78ArP40~MGD|(S&3|@-z8S*q_gTnpjei`09s}fp-t~c zDDTn^)}3>>T71Jwicc7eIRE}rTG8$GaG%ck2%7X*X5MUr9z{@|YzA7a7B6qrK<9IP zYy*FBJup_YEw1?g&!2!h?o!*wZ=lUbkJ=V&9}@(GL;BS+^zEG5_R-h(cvBqZV3xGdK$QAXIeADJ$ejP3$5U4X$`6%{uXvi zOeCX`gqt;6*Ej%?Kj2!A60!gLX%OSxOq=g*UVQ%=;vH#kVpUr++ z4E5_?bgWgX_QoMyQOm~;aw@*nGOK8DR;7u5o$G^yWpwNly-LSi+G5i&A6$t@^162ce9L$wPku z0Hlqgz0UQBqFMW@|KZ<%Pk(iQ&ZFa-bM5lj`roorkKIcqqyavPsI0tos-fRG^LH(O z`4L)W&}Mjs(_^(#)R1ZDVZ&c{lXH?AIG!xHLR`QFVOtXboA)BVA&6gyb7?DnCwoi& zRz#~BS2r%N^hJc?TRFV^cJWDX9;H%v$plBfYX@#&rV8gASUNrJC#&&Qy8Td3 zMx&wYR*qv9j1-a~H7o<_Z&3Y=8y1p0!t#4Wuy}$DM(DIw$5>jvReJj>saMpfl4Mb9 z!u1SP0%(YjK2m&MnI+|G{dF!};lm7RFOi2~A z_6)-NiA|^qw3aCfgNmgm^W8{th&>s7`_C<@&-dq%-Gzw~AXxX&w(g${54$ol+_&)Q+P{@7S3(5-8yClN*da^QHpwubYl|TY4t~&RD zyp__}-+j|Sp_jo4iZ+kbg?&XU>Og2$MLG_o-C!e$8U%r=sed5b$bAo_E(6ED^UIsk zw`cry(J44oYO-@8oyY^cMB&O!UoQ4~9M)s!)1abNdEa0{$SeeZH1+520#H)WxH&j3 zG7%+xm8Dis1+d{1!DZw;IjlAZPf7?)LyPWIYk+Kd=`>S7N*6pRdqP}-MjQQu&*UnA zUh6}q+b6qpbcDF?G1wAJ*rZY!B`gagw;N7EzcPt=kOw%?a(D-PxND%D7~f+jgfq-c z1Fni=k4Rj*lB6tuzf6|c2!bFQ(TR%fD$5!C8tk>pUK2N~L#N+ts7qrr{r=J=REG0r zBXmtN90+cr%YY>ISA5v>?A;PU(s;1r#h9>%EvTdJcw*I01Y;bY4~;e%U=5DCVogvU@f23qXc7+)BtFm~vOXN%H*jd%=Pz+Uy$5g|2f5TTI@ zc2%0otz2Q}U>k>Bj+^lip+cgLnebwEp0HtveL_PShnopnT(*2L#qZrb{dygebgL3L zE+ctX|m@{0WSjM|`q>mRePs2eKu32n-LI}OA>i{|8>Z<{nWdnG0 z9fMDXFd|}q4ru2G%#Jt0!&Oz7gixv|&<%ewK^vx0Gsue7#?S@SYc*9=Rtn6itkV?CM9td&xL23i_Dh#z0?5G~M^HU{j zrNs)?1v=|rl$neov738V-~XxvO^PPvbh#_&Brd2d2&wA4Zye5P9?{T9|G$DOk3yX) zjZi+~kS5AfJkTEf$P4$ue4y;yuy-SohbPBtx!}+lo(*$@z)&kh2W(qEHN(yDsl&%p zn*^eN3xUn|M8H%pi5!FI5M@|8TuSSs^3+VoX0jSTXb0n5daRXZ4p-=i@F-3MBvWVcBPY?QTwqOj+zbcR^tWs*nd-D(g zRWHwKqVVe<_~(+9FXrvkTd}ByrQxzW0~6SPN>eP*aITEOzC7C03S|_R)t4xb0b}$B zv~!no(Zt+^HM28yi0%eVtv#B3F_DKF%th0Bh;r9-s-oBn6O2n>paR|EDnn$r{^Wbi3kXpKZ%TUw?^?A}QvavN zaK_FHN`<@4R}_^7yQ>Nb0xK$i4>1nJK4-ofaK-NIMNY{HM5Uv+FvoSCuSQK}%m?M1 zZiwRens;srOvZ$KmdIIp&N+sCywXm+beui4>bMgdm{SpATsPO*C_u^Ke`m0M_$AX9 zFUWZ16nR;|Umqsgj7-#wX@d-Sg?+G&G=g*}RNqJDSNQu-_$|(wr`-vERJp;G(Xv{} z=fq)$CX0m2_O`ZXolW@q7+Y0v7@8TUxwefCPP+dPrB`I!FY#4g$(@Uus1Qm-hGDhFd>~ zAaN!*TV}jCjUWKt2P({ebtEwn^oub;MEg>C?29U}^Y{#7pqi^7DFO>> z@tp!3?fI0r9?J_V+gT zwmT%KV?lj`arp5?r1%{V#si3PAUi?1+QteuFCyzfEdf4tK`SSXmh$)DARv;s<3R%# zJ3sl`&gFN5KoM>Shl%)a312wUUr6e;B~ce~m=KDcP@z(Z)PzjNrAGv7h2P3|B;@0( z>IC0)AiPe5tSOIwWE!EBEpJ!lhC<@%O|q$Gwp9)3)|gn#zcZ*08CokL*eewuH-@cr zLFy$(Laszo{ZJoJX22zB;-y0CQj>tx--+(8{|}ehQ(kHEsZOgUQiy<%@SiRkLKOVw z0!M5!Y=U&b2jT#A)yKr30X)5cXq)q4P*3MEcFVip@j;4Ors7Tif(&R4R{YJ z9U5)YDjsR#;LlXX?I4j7cj2DYFKyO~xi;?So8)DG7AfGNX~t>vvKhy_7CI-W%g;^6aI@&9T0E5EU61m*TiRwnhW1mpP(9)ZO8tZ`>* ze~sLKMFR-ScM)P#>Coj@rF>`mYcS_y$9H#*nS2JKdx4tiQiOKmB4YhmQud&D^wFTY zsPHs^(Sey4N>u6xK>x-WcjfK2_u}jvjYu$fWf>D)NU?D;yEAi!uaX1b4fUXoOD;Da zPT&&Vm~_PYIal?r_lwA-L4BYQYhV{`;6sIf3EMe3*IaLK2P0Ks^mhLG;!L993$HX1 z!z^%{KA)FNa-Of9?BA*qxhn#-W#)l>OgNGr;idC3L%oRdo-@mx1_VkZ{@4Kt zWC$(`3YcQZ3*U|_a^f9?J|0QH0h@LSBj%rm;@OlkJFIF3Dbz!`B!Vn9iKsY&W$$i( z4dQ+9_UMW40A-OmH>e}J0Hij=joSL@fU_4{_lHnLut9n6>*r6F0u~0cPUJNNUMM=y(~6KeP`3H%I3EEWicCmck}L#g*FfN z!Lmad5S9TufWdD{fXq(~l>#%l2uhzMCU_xG`JP(3GmROYm@b7gn_eCyhH~bBDkE64 zoT?p#H&%$ZQD;mh)&L|-j2>&&xw0!Z5t615fHTEV-3CTBXI-`oRx+TBCq=V=t`=od zn~?!Y{nfUAvI#V{q9u9~%phW9^$>%ZTs7(q!{}NbIiWja*0owg>oJpy3=L7_+lz>Z zuIz5YHPZSijBffE|Nd2DvpHQhJ*ehg9#@#u&bhR zmqo5e-z=A!QlFW8(Gte(3b0XsMt5w0j#BCO2QSl<+Ykiv0OLs!Wd)oGt)%gSANox< z!RER8bURBc#dD|6c*Y>0BqcEOg5&yvQNSzeO5!SlYI)1so21I-%WcU(2%;rRR(cnP zYNO9{VGc>uf{U7~>jB0niC^+x$%{K`JW;_ot441aRBuYHf=7xlUO2;l(m^$^0789@ z`jKlnbhA~o`ZzBDYI7@qsJHahinA;u@sxfhq=veE^MRpx%t^P-;s|}9*$SMA7XuP1 z%GRjV+qL!^w_KRu6}k5ZR83OKk-GML`+xor{9G#qD1U~Y(Wc_%w~`#o9eDjJ(@4Kz123t`sENjGkqWo>&79o zq5e8L7q23J^Y#bawG-;DA(4ZZo=uwu87Am5kDrmD|f!P%@ z3#*P`qF922X+$G_adReQ9pERwl*FcyKT>IZW>rswAAg*N*ho3{$%r}uWTjXvrFE@hLxk9H0i!9c{pJaWD!n!MF zf55W1LZ(MtWU88+tjwAXfj+ApC*ri9rs6q^T7=d>`9!7T*^aMTL2!7N4c zmE6gr=&;U`2X^HBFpV+vRw{T7t!8|YErpBj!4McDcg5y`nRdmpfOZv#yfxH+MHjA6 z`!qr|sG1_yk^P8@iyKyKj!NI$8s+3WfAI$#aOldpt^a)J{3xSyFJz16!NhR%_FmRo z6pA*X0eeFgjgcr=qsOFa>WAHiQrQEw#y_qb!y?-sh;{JCQ~HC6l%{;!0ux}m#6h5# z2Yz@R2WN)A$6~`rVYSG_qQclS8HMk3?OfZ@Jny-SCAqOo`fBESz z2l9c*U^<3xp>t4J0?<^NacF@EU0`1~iR$rb@Z81C(mPG^LqT(Ah!DVh$vZl}qV{;{ zd=6ZpR1J1)yTE>!1+=&ce9dZ;H93Eow`&Vw#vC<8(&_*&5I(b1R>zM`&&X&7rKT#6 z!Cq`WH{q7411nD8`0+;Ia>EDQe|}aQEx=huqq+aa|IKsG9indT%*_CtGkAT;Hhi-h zhy2;OB>w#dWe)((2B08Hsks8ZCpZNn-dph@#xB^z2-2)veW16<7fM|&s;4f|;;nmE z4c;L6x>);&`d6sc-p#51arbWB?{qghGGSG*_Gf6b;%%Yv#!m1piSj~#C%#=3Pw*xv j{P)7x(S`$^@b2IKEO$Jbab^EsB9mCSD?&>HH={06&M0qNOTS;pwis|D)`u>!;&CKfJBk`8o49x zM@uyv5r~PTEkzMnsetbLXA&#$$!zCN%qSRr2h810F93ROOwqhbdSTA(&XIpbfLqWsb0{JJRK+J6XF zS;Q|!qX?oUMo~`j7N$)GzI(OH%2~?_IPH3U!aBSWtTE5zGq$k*{A9c+Yh%q5F&G zMsA=cO|=}aOkVx-$tGyI(5ZB;2wsl<>2Ol6aZ!vT{HGZlOlNs9wdA4nDqRrNWRFg+ z{p=jjzJNRh0)ep9vP?0lj;klNuTKeSNP zG%VT)fe#@*3?-jr-5MlO*`%EW$d)gPq%&iZSP)i z<17)(A>Bbq4xR6bI+J%6Lak5-01Tyhx86f}t&XRYJw>AayGGl{M{S_Ag%CH3hLN5X ze@mDOwk%R-l4rDk#q;yBqM;MR$r<02I;sAsZTm*cB3ovjTphmOFZ>=!a&<1xzzDj2(?T+0N9d%6dR%T zjI~xojufQ-?>EF!*nZYWp`)jxMF47fL4U_cU0ztTSCegjt=%70p7!Zrt*1Q(mwH=o zEsw?AtP^WX@bgdU52aNLrL)Z#6!kA^vHKF4l&A#UT{Iv_Q!oG^EI1?)2}LrYNR_1@ zB;tid6hn*%0#bq?a0UxONJxf8V1}U(AOZp$F@&TkYLEct!RyD;kZdEkH8>o-t5a|? zBFMlk^Iofe==CJcUZpOYGzErs*yp2XXG5VF`>>LU>ty13NzlA4otNyT!y2+E4x^ZD zQAGSG1&?bA(_f-H^rc5W&#GIcb`1YoS1Cv!r4tZXL8iH>F$2s$&8+j^S+#x8Acf&9 zO!*UaL+O}+E=Gim@a2rd3u%ZYl(L=E^`R3Q9%tr%%@c-7xG6Z9iY|-}X@L`4LbOAr zkm=-)7DovP(!3>}okIU5sS5vs73(zP=~eM168ITey+cHD(pTXh5uX*+aQ{kdp$&xz z((sXRU~5xnU7u6tb4mi8;{DqsD%eKjuS)BqJjF=boCaK+5WK=kr;_eL8Sik0nFLg> z*zl--64ohN;33c5AO*&OBU0H$r@j@N!rLtVi$sbc5*aB*N@A5Xo5567rw8xmk-A*5 ztc`_V2f27mFZYAS-DfroK0upxUyKm(Pn=>i5aH%V&p7X|;N0Y&?b@GA45qrzea1Kk zVtR_2Ae~7;pmrB~9eQ~2_={@rC*l%H=ItJ#i*BPhBTDw26;=U+< z=LVB_{xt;chl{iwv`W1DZNePx79{0hrniU9&_Q)4sbe4_yaKr0oqtVv;0f z?Hbd?7Wk%k&;V@Y4)v_^M9?Vx89ItO!;OF)Z&-`y1`Msv`3y-wuhwJq0Mtd=R?_rl zLg`EXqqNBeAcM#zfGd@%@97ot;qpy?_r}}V5toLMyj5T}`X{KctIkmIruajH9ivu* z+Qb7IcyO&y7^|=6tL&(W&N%G7119lPhtQ!*j2?z^j|C0(Yn|BE6q>?oYCkyHqOEVt zZ}tCQA_*HzsezLhcNkyNcR3(yCo??ujc%yUjQNc0f)RCY>ADIWhqXB8>-WHak{kL& z1$<3Qn(gDVh3RU>pX4Cb7;1}Nvj>-`YJUtJ4Bn4c;vXfkfrh05J}Cpq3j#uDW$%dK zEUC2%kh8lBTUBBf7G*&WuPx((Bbg;018{-W0uq~24|)@6fqprV7b?}xyNyXW{aJOZ zLd_wZGX)tA_o{)3T+!x@7mx*iHU#fhIQe7jv-oNgSZivKIxkS-eyZOpLw~HLa!ub* z@E0Wr^%9PqsCY!2e=e7hFgfo2E`{tii>aWBV~&~X%TwrCgT3#?534XM#$ZI@pCE(s zgk#f0o`_6lDFm(Zw?S-=^)K!*@!6r)7T__cAiui(gv#xaLJ-vKm`u}u{RQywkQXV9 z%DJdt7ScO22BX1MFwyWvUsaE89c3Z400@9I^OOpcOg{?S|3jD?bX>9j!otOq#MI}b zCd%3pe7UQDx`q6oII7?L)O`haUJ>O=-Qa@w%<){veOH7?9_KpnPsh9hT?lLlC4L>8 z3gag{=DD&rP*N9#VJdrn3hr{n$Qf1;D7(;jtlw(aC~Xn^1phOKUR<3QDGP@Q|9n&2 z{84c&Y$J4$F9aUvw>gl2{i3_?Zn2=W^+EwUpCx{OvB5FarHxTiTn7zJq#>ZDXFHwe zFYB?I27+$BW;cizSs;8y9VY{m^uknJlF$pfN4sofFWx<1EHNg38*tp+$4cga3Ed?j z_^%y}tIq9EFIiQ$x!U@q0f}uHnGJff)peRmT_JieHh3h+h2g03x%SHiDH!05niu7Q zcoH}zKHBVris(cWFa3I>ghp`H5C@Sx=?y?(Q%)GaaHs&t3#4gn5Mk#;VxW(#>fK(S z1OD_3z;I?gX?7<#{Y z16xnR4O*U7TSsEinXIdsxHJmJDl$)|7Bw|<1v3gThHU*GKWxDNLsOqh8pIL}+?1Eqm}h7#iZtM}~G|3z*Kr z|IF%X+0oZF#CMAVJ*QXMq6=qm}L)l6AwZ5sZ;@1si67rEy&N7;p7GsA9R{-c9WwA!Hvn^z)L}0+<(0+C_ zEmIb(l4H-!4Z1OLuPF$JJcD2P*CPtB{|=}%SBw$0kh*}uIbL2p72#MDvPU`9tZ%1; zY33cer-QGchSwojGC)iGYy93KC>)9%Do!6&!c5zL-~bqJ-@dk$%FN5<%$^fpOp5`j zj!tjLvN!>l?N;W6fzJrI*Smztw&)NJFU1aY+(A}y#(RF5s4!GEccAPRBJn4#=E-tU zJ(s#?Caq`lZcc-t*H0=qTvGzwE*0(B5LPcACrHj%!ccR#X_MpXQob1jl>uQk=|;7` zxjSEfLzCyh*-F#ww)_T9gQ$pVhsW=;be^@qcG01)c&Ch)!~@)XznDr8xxvaev%QmQ zo20{FQ{KRQH_mRRIaNXV4WkzOLHm?768!wpfAqk~Fg1I=E)A@5i04UbYu=e{=sdFF zSla=BLBel=X)2WK%;AbOjFXz;lgb&>Y$2zAx0F+hO2E1=Qjt-mF4AfZMXC34_y%BzMQvLwu@tb z82Z0e2~~3(;bD5(fUGv{|1$)A7)9JGhZ)WJdcr`sFe(gOC*f;QR*)XFDI~hd-A+>U zSMI^3VvmtNUNJ9R14p|z=!sIfJUXEagto|Fad6ct5>I4eDNu!5gtd!oApw9&UzAMs zKoRY)9xyt}hzeEq3Ncv(yjH%>QlET(dU7x4Qi&(I$> zaK<55WlF^Wt&p>F&dSho<3z1niFsvzs|CWOo4H(sFB*XfhuOWPyHbBt;)=R|+|YE6 z?&OtF)Z&_?mPQ?7yrfz(yjD3ggDK7DbtLbk#Wu@+Lg&oXXQS19~~`_dNxv&DglpPCP5JvvD3|= zTa(zQQAcbAMO}dU>^s8Mw0&TIx#QT_X=Bxf&%J$)@717@Co8p#Fa@VW;$WVZFntC~ z{(zPjgjPga6mPM4)I?`MHy5RW2CPMLY`@a$l3o%@`SgDoQkH_TlT`@#>v@Oi0&c82 z`BAq&n`FaHcW?v?Iy%LdF(A%cp<;|Ol7kai^~H)(jcziO(N@036rd}Ao^sbvbSrx( zunD@DFq;(YFE*_;0xel&+3uF_t!PIJS6n%~a(OhjOzpLQe#X(Sw##7BV;s7MVn|q? zj$s)cnxa-z3u`a)WCdb@PYf|~9C&BH%#6_K`?+%4C_()6c%N(yt9lis4 zLFp>cg1hHj8-q<3 z&Sgmv5-rU5kmM9}i|6TOKrFBwzu=<6OKXT_`4z6QXFtT6#YF&2Pf}iBIi>%W=U3j& zq3BkLTicxCB{|p$%4S!NK?Er!^drzUj2PYZ=<2T%<1LYJlTJu~gO5P#Un$87V6Q3# z{-}hz@d54#1zSw4c58~i|@hTR(3=b}cc*bm?XvNllG_5O%ilpw zL1niL8JT{0EOJKvFmO^n)LRo%EF%kN2UFrM7rmQIw5{@g=!10-W6e|zI-!O>F$7mi z#q%mtVy~XLZd(w)z$-WaFXh}P?x!1N&;>zSinKL1w!`EUWH3>cZeBoPwm~l)f7rGM z>f5~s-=M%YmW3&jTLvE++tr1bMzn5FZqq~{dyD;2SVI@Fe>2nKst~pRJ4NhcF$ zQh<-!&STsKbh=>L3ypWEfl$z|!bLe<|#K1f%6db6WP>4!&wq-VWRcw{?FP$Mg~?u!k5tvM#+U?LsYU zKKL?!d)QDyt*k4C!78@#b6FKe1QCd#P81eF+~`U|NHV9C7VvuRBQ)YL>viEtXodfQ z5#72J5CO)c%oM70%qqc#lr9rh>W}KK@>vIj?Phuv4h1NU?D}kIfcyl)QODNle{qw%8Ie z;L~jh1Bk0X{cu5CJJuDxi7zOQ{jc<(?j?-z=s}RiPLTP)5!D7U;o@-btI<%`ILm4o z)50`vBy}D&_}{)nzDv+_3D64Rsi%R2HYlu+opW$s?CdhhxMezXYX&yLQJ?uj6dPk|J#L>Z)k06w&gfGf2>g6{KSq>g`HBSbWnK2pk_o-7;e?+E>3$qk48Z1Hp`% znv8NCO0o8`p% zLx=G;Bo|~ufwOO-Tku;HHH`yel-yl^4Yb&fy~-AIgZG_1*7hyVv5fkXlWQ9AMoj?6 zK$U>6{yEE!pFpu!r~`YJq_&k|@+*661}ZVM`lTx^=Ec$QQqo^64KS385mL}=7i6jt zukyw&w1sQ~s-28GxI&w3oXn-kQfr6iHg$~ZjFwW-Lb2HrjxrtQFw-Fd*$8-lQDv1f zuu5koX+Mof0~Xt~%VbnZ=)e%AjM}XD!`X$I@6Q167ZBJ12`h+II21O>MXwNvG@ho` z1ph3}?0|x(E7U&xYM;+Ii@tw=bZ0`Yx8F4*4T>vOEuJMnL>pDH2#)`es5EMn29`uQiCM83O9a_vSXX{Ab za5;{1yA-GB-?)R`$gd3U%_V(4$1f6gY&AKQhdZzme``KLF!#to2IeP!p011~o++zT zCN{%3Kz~_iNIvxT^JXSM=WNYM+}vvPk~2x7Tl09SYv#K&_ng=@;-7Kc;RR*7G?%@f zOI<*C&||wCm#)%?m;HrNzonIg-O?=NinGHwGFzH3*YWsXZnac|+^3~pF&{1c{Nc3- z+!LKJK5fS>r0SHdj<-l{Nn2M{9qZIys7B#X=ESjO`8tN9t zXRBOVN5yq|@Aw$i0*6KpAwE%`Eg0ola^cxa-W@FV9lJZ=GT`9JdZr@?U&AaX1agL9 zC(Q_k!!thtwseTEfUJN7RKN_%DD>_3BMOV6!6XyX5{*>|*@Px#8Rla$1@K(IZff(3 zl%+x=enWc15+$U6VFnYWS0_v3JY)HC%=!``s!v{0nyR)W15fcOLMx~Q7gPK14?cUK zD&8=0(McW?Ls04=&~g@fV830sFhF)KB81+K8D?<3h|NCtKbn|ShuGgv5QPFHrO6CL z6flMe?i@NEc1}ROtzNMN=?qJzh_LxL+Drih3L)Oic7)!6z-g;)VWgqdNcX0s%< z388o29?GN^GiU|2cC`(sj&=vHHe)e6XrAH0Jq7oF3LBZ-*R0iMJ|ly2@gYtTOH_>| z{KD97jN{^ezEFDjAn2Scs8e76z4avZt88Wy(;prJUFxNFEkQc}j#*TezRZvURt4dM zjF=9h8G-I*L~Up68{UEe1YVDKo2KqbI?B{C^;jd*q<;6M?F9rTT6+yZ{-;OFvb|m; zR)B)|1Wy|09{M^*Ee)K8QLB+^NrD&3Wx;Z8cXDagBoIyT!#tDFA;r!%# z3HbM}(d-;3r$h(%#Tqq#3KHEJ1>p#1$gorkuM7r$S>*oGWq;Tr1i}z3szN{@__S+# zZNx^J1^_2du}R3j(sGa-7Zt&uV6(Ytq~vrQfj|ZymS#zY^Nfy_McAm{a8h`hR#=b_ zr;|f}>d#(F(6TNlK?JUnq{SRDv$qYAAt*xj|)+j(7^v+kYz0JN?hMPcW#P z<8v0EQ9+QMbjqfeDeEH6)g2DwbVy9-%>zzOCqkHms5|S&VQL-AVQ>x?jl9TB5S0KpsU9NGR_xnpoG( z)CVz~7&w_J55+;&@;Z+)`*kg-xArk;BAJ=oH$<9rJHsURM6JFhZ#I+RQN zEeYVN9%5Qzki;t)<1vXq1gb`TF5Rks;||&rHNmHSQnU*}hQjkx`Uq>l^uN`8`=rLp zC#{!tNq>o3=*M*7R{5PiFWWzXF2DDQ;xb!>VaNkzIEw(~pw(4a5R9uRdL_nk;auR; zE>PKkEPA^!!d^wjfA@17zAGGN2guhK&}Rc$LZ-tqfs}54IK(z(F1jN_w}{f_1qTx_VmTN*fJ{SsDn~#7 z5E0@5Y32VBR%uyY;4XF=#L%U2z|geW66R^+8pZKMw2Fexr2IkCI4*3s)Y94P`>l=v zyoM!A6B?<&^hwztO8sA$+M29?&+R+ALe);T%aiL0;-Z+o8zc9*ZsWHF#Z5&AM=g&E zTmiS+v>%sJY`HQKQxhm;r$*tqV|14B`PiQ%z$Bf~bX^!X6SZn5Z4_00GzvTaOHTPv|XcmvJor1}}K2N3&CZxkSyYk8qXu ziqr2AaQLa+BEnvedXoGFt)+XTMkGhw&QB<)AYggxhCdOM)lt^U}Hg!Ph15~N| zm=H4@p0R`69QveXpQ9i_$-m*rCQ z22-XPh08UmCBZqu7Zu0b@SP*eQ)qyjFlP)UrA#iMGN7!V^oy`0riu91?IHOfkF&( zC3~QO=`3I2!bkvrL#kzB|5I02pc{)36SxHNow;Ex@DV%2+EunlK720MMoC=EJXFys zbj8X`;6lhqu*F%*7*^Bu1NSYmw@DR6M&u!c)UQSuj4;NG2%3Hd#$`am{=$CaZ-alJ zXuv*1!{ZeQ=`K~9I-#%1b;QCtvw`f1B;J^4RsH}>x{<$sH31}7JsbcA8|Lz&N9cRJ z7{DVb#Zmk?0!fn+glS%@?&<9qOd)xVwbqj|*#EKl&~?=q#O&ybIiqIaTnyT<o=Z3(-7mCdqw7X}9fi`u#FToE+msORgVG&imt9%HH|kU;%ajG)_pnHlg-a0iQxFyAuZg&ez+>vp;T zC#*l%S!^3Ue*jw}1l=*yYA01v0HG(3G#J+#&exoaJF_;;3cE&uvSZbj^!JM-GKDHZ ztArsl5Mw1l1=wPP3oeD~{F&LUNu!`_Q>%Hv;r(hhodJ3kPF`|iEA5^mIQE&&SKgX> zB*!9uZ#KU1Pjn-M4%ULo*q?L=;V<6cM1!}8G@rsF6;ep@sZjgdK31XCVCGhvs~f{L zb0^K=%iD*5DM&c69)r~^33S4fpi~(c>ClAAgOuxCey_N&4LI_%mTr{Y%DK2q;sd-! z=wCl^Xaql=8wz8NW9RLUU&3Nn_6P-sf&uS;OFL@>#KJl@vk=nj5OGGtcFXDZG0^w4 zy<-%Py=nqB2X_-BD+K<&^)*F^?!k$4P%yLiEHzot384VKb)}hbB`h z+*|EiLog{qBHQzPRtDCO5wY1m6Gk@64^mFzu_z?HQT2Ha@oZ%s0`aMTryB2;__n;L zSz%_=bpNqccCoF5*76DST>sJfL2Q8V^od}~;#RqH$`~Whk{u}UlgD0&fjY8z^flL3 zILq$pdr8NRTNS^nmHu}ORg+J=@v0dQI zjqch!jwbIa-zrXjBi?j#mAF%g*Jn@Kh4Tb{dICOCd&OC?Tf_K&w0GIQ>O3zPCma{a z_37NP9Rt!DFsp4~Au`UkrvLqV zlqnBv`r(S9>VSC9o+hbMx5u((HWHfHK&Xv&6cGo9Pj|@fT@5q5u)AC_!6jA>#aV`v z!hz;Q(-N$ghjn{@QGqB!1F^U#zv#hW{z>QrWc~@~HmpAut4tP;_>y2Ab<^N4*22bwd zIkDa5Yqt9`A|kq-2FHovz%>`HuEkxn^o^q>5UZaSn{3B_TBm}9u{fAL{ciz>TapjJ zC{-;;koZ{FseQoS_RXMvzjlr^NQGQ`X@`Uj+A4!nB0EP~y)&$eMKA;DTzn(T8PJ9d zVypKu8l16sgT#~0-?Y5cR7jMn%gQ~DvvJ;0%ezRizMYX{{d#4DLni@Cif1Wf;g~r< zDm#v*#rn*D3#9UrhKO1JqKWrij0gzW1Lnv$)*~=w{)?!h~5New#h6V98H@UjHrl^yV5zOIsflA*nKkTny-CH)dN^wh}7Ag}fT+SH5GxYg{bZ4)^pkJLa2)>G0~-)29vtUgjqHdS9CUiVhkY2=*Y1w30pjk1k2 zF>(e(8rlJk=?TLCo-zOOed= zpu$7)*}r{;UiW9ssY&0h*AeI;633ZkWgQ27%ck`;TZj|TH;d5o``QYI!azSfW}k+C zrFW1+Z1jHQz*6a`E`U3NW4Hf;ACnO$MvqCK?8x&70!{96IPI8 zA1xT%HET}|xvF=tIzkUp^uBS^C(#UlG+?$u>_xB^)Tf>MCoL9}sz~Oepe1PEFWMYv zY%<`yY;4=R92*xbt=)@i$|}dRb%nq&DM6Y*WCp0bCwCM3Az&UYbo_A3Uy>Q$I?{RMmvGr4uoh3br~` zjB)Pg$)Toq4M)*~SjjrH!|_22t~di2(&g$+JlbI_vKm;a{g{&>P?PP@HsZfuECt{M zkTk}L%O)}g+CTsozf0JsE>QP>>+w$r7yg{v+w61k>I&pSiep;zo+9Wm39YgCIfSz#pe-Xj`@bpLr6vNa}J>}30mRX zN3+u+Xc4ewVeE>e+zY7o*B22)r1%nP@sZY(J$H6>an4-N%Ulz1yzd-;jDgAlL zu+9_@riKR#G;@&F)1=ao9Zimr!f>u3Xc<)fY2&^Wu7_@ z+Be5Uwh{ZnIB86CgqjP;1hAMFq1OfCPeO5Lhbr6kw}FXP(H9q~mDy^EzRIeLeI4!9 z9KrpR4VQ20Z78Yu`nyQq#SE>0`@SNYMb=*Cs-%-j5*fSP$06!KaEU zB5W{s8)QP$PBKn*^Cn034&Ywu2>#|)(5`Rk1OZXOna^H-l$eW^o%WV(b|@*g?Jx=r z5jOXtRH$$C6hkb5Kaa^fJ2!0|YXc&M)CU@Vj#iPX*kR7`!7L zduN$}!xrO>@V^M@A8p`VY~!REDF8X({cXfJcHZXTBFR*OVPaR6&@0i=s6|iwXfP(w z;poHRo}X!d%?Ru0F;%U#f-9m~sDjU1*ez_LL?f^ zabnya_#?IeoAc#CA8cfQv0Y`IFDa&Zo4-w znvibZ;GE3F4dD(W6RLG6>sN+z3+@{0dxZ6&w96wRJualP94G4{Cp$lDI- zT3vO)0QoJg4nvSPFvxF4>I{wPVFCEu)AVm0d(qfeY4o4yacxHtdjKT0LOJ(>SxotJ zHVIdMF%iSWjpXtQ0A+8|U|`0Ml@xpypw~d~9)x?>x@xJzrVznPiHmHLE)bur=>r)N z`#S_dOSR2|(o{$bc>qJ+aHwOzwe3Hvep-;~_qKRNt91I!VY;H`$CBk#Os{oZ;pnU` z6Fa~Msmi3-M|&X;vuduPUr07!cOm}+47;L#CZ)+moa`_tE8?al#Nv*%2JQ)bXilkl z9CHi~+k>!n4r0#*Mbe*bO%H2``<$Ia(L z#A7?WWsx4S*AYldemJ5EduyZy-#PAHK=Nz0O5ru-8C%cTO2Hx1Zo`J)+)ctsZuWS8 zGOG%4g$qWuCO>Q*iukr5e&NlYtq`63CHb=yEofYwe0gOT5xsMvnHqT3ps@0>!5fPD zX>RaeT!(HR4i6amZj~dfE7wY(miUw=29)`y-|hglYEF~%M3F+ON$&6N zOQd^lrw6@%n75LrCgfY=qkv3oDwR()ULAL!1#gxO5?!~^>B9H$ma|6k|rQdQ!-7H_hBg~Q6))N6X%QE`s7yBP&9U$Q|iwGq8Oo>@%+yY~NNPiSm z&{!&%0VI;XywWu4Q4@b_E~3dLOd#YL#NvCyk0L+q@myT48B(%pCBuZ}c2QFoOx6!G zZ21&?Zo-RR3sEv?S{f`<0k*0U#mXN2+g_5*1Oh&*LVWmSVhi88F0>}FuHHp6y}>4x zLQ7CM+payog_$Ot<5J1%aX49jjZ*27gnCMioUR+%j$bgPFox8%v@a zxO&@Gtq~h`PCx0D%o@pCN1pV)Cv-}#pmon6s-Ip%)upve?HE+LJsH7&jh92L+i32e zx1^YTe-peW1W53~x{M9${>eP-?2-A1VL(BQqmDu1_yIRMH@gsxgR|kZLs&&B@&(on z>H;cgT2Gxx;i<(`P)NU2=JAe4q5S+h z2~1FyjouF$^!tkg2tJHBQkSR(r4RZ^;7{HPBSilBlMGlZF5kk1a2C3 z)T;Ui7@9$+nI$Ar@Ve{?ixVU__@7HA%K=DQAFkX!Sy4yFx%>V^da%qURl_KOv@min zJ_&8fByxj@zzO!lyU}6#2HJ^9zmQO$VP>Z0>JfWHVzw(uGW;@KiH(Q>(a@QwAgzkY z0B!hNyHuaJku?H;{q{m#7BYAiS3S;SshJK$>B-C$dt1>#H!9YAee0|S_jo6LK{9lctnHegGr;m76H6_Gz;~MG_UmZDH+NlUrUX3c;Y~0H6b`HLOaoC05jPw{Pr0bX=FJb2i z471oLJeKjfnNY=L{s$v|%gxhQ+#$_fNq|G11@RaJ@fOB-xTFi)W_Je0flC`pzgtKB zxIo4>98{5OhSw)V&`WF`2tizNgt1Q90G{sd#akha?3e@Es{=-bjqvcRLQF!1wG!w~ zv&sMtgPTl$X~mklTA@1|zWLz-_kPz@^H|I)Ba@lexm9`j!5c@$Y1L{=*wRCw@Pq*^}CF`uk%A#X`IqTIF_>8)++s3SJ`Bmvjil*yy z$yY{6T>LCxsOqyfdda~W;h{0le+69~h1Mzw;d6vc8m>}2Jsz#g+xsDMVAr`f>qaFW zp0@vUVcQv=0&@fUP+O-CC}4fy48!2-gzZ!71V9S`rSGNiR4*<$DA9t#TbiPj;~~Xb z=wDEOg9|C(gSHJjngBm%U5(@D9Qn#}f^kx77}tRIY89Qm$pjV#iN z;Teon@fUkRom=vfh$VL}%Go1ySF!{IyY$iLv5P~wFloCuHkU%eIlGJ^S0%H5oGLC3 zYfWbE3WF;E5s0O~5n?%Y4NsblDR+YAFkzbB}H^9N^Sy*t=T?Ugxz zre4_Qn}k({?cZfoHuYJ--;nxXo^0_weFCF$=oXz2_>*r!3Xj0UzLi)~8KR?uK95%c zb5ijOx8!jbsLXME`rVw7-tE321%4)fzy)Ke^MuETj)1zXe&8PS?3&DhJIz_XVLoHz zBnE_Lg=ww_=k;U=f6k<#Sq*o!ehU>DQBz89oOorQ*Ny2nI7%Jqkq=$5HBKsSTlzM zCvHbW0_F*br-CN3KvY39Dy=D=>_X&^kDqU5JkdPG(+=bUxC{DA*Ix@GF+idw=D?%- zn-DXgYHah??V`*1Y17&kdzjERGC^UC1Y}?r_VIOC z5a>e*^nHxI*1wNQ-{SZ==T3;_MpYS2;R;1g98swJs8|+21Nn$2(cUS4i7{QUjlI-Q zP^k;Tzk_?l?k!M4;CB;tu7&2MQtOgdbVM!JIDAOe6n2q@sbWoKanIMlry(;9|4DQI==EiYQXi8^OV!nNdO z`E^iIO5{99f=G31w+z~eA&DSXhNFo2-qzmwg#>XGv~MtlAM-?i*uLX|F}OJn^ON7$xd?6$Cc^FDKoI{a;R{VVGm^Tt zEb5X96GF2SN?0ldHKC3;^oabeG+6l^36*?Bm*6rEMBs@KF3RJRM#$9iCaB!tNUGjk zHb9weHBGur6TAC=cP#axY-=SBd!^mu8nKm*l6nh-kSpy}KQsdd6L9IBcnRCOG(RA< z6Vbi!|FL8CbYGe**J(?Lv>_n1_)nt^AvwP}X(Q+i8{n<_fjB_D?lC23054j=lq#Ro zJ%CTCI^z{?L&xvvm;AUFx4%Z8PW+z@PbN?TX9 zC%M{NbK7DQz|l z8EoA1Hdx3k0+@%U9;Z>p{?1e_)SaMSJM(D9Z6AMs)8Oo7+63xbm%{F3iiojexwQu+=%e*?QSj+&MzPGi+EFPwfL`}!+^@G=>%}}A z4Nc(j$`TM=ImJfX?Cv8NUP^j=8|wiam%QBgw!kIgG>Hf6hnUryWG^DB0QCVxtYKdC zjgLJhY=3mFAm88wM&oC6CjP48M50Iwjyn>6d0Aj4em-8A1fTD(>Krn*WMVm2r57T^DWg*E{DKxBu=5SB!C(134AAlOeVl>%@U2}%V> zOrkx~)QF(rlfn_i42_To?jRR*zcIaMAVZ>-1HO=m1E77a*1jDFXwOJ&zZ zBGgSGKW9=yx-mwE&bn+E!ablINs1p`19*a7d1A+;3{TFFcw zsHNWzybMilF%V4woMTFq6}TpUd?XPC)Avi+1asvas@uV>$lRUk$CFP$BPoIH3y$&^ z3>&YAKoVCOs%2N6WYSZ1EVpR}f)MpARFXm{QyUh~r8^`!7rfZqeh-49cC#gsl1Fva zcr^>gq#D&=Q0+`5T&=t?TH?-)T;7A5 zB_$c@YW}q9dx3k=Ad(gG4EtvqvBO652eG>9L6H;Kh{?p-=^cSeRM7UGbVIp&t+-c>PDJlHUD1A(fYZv1C~%7~O4I)LBaWZs=mg_PiBDO~)t+4;3mv1%7&=hOAQ(?at~xS_nVwceB*d;& zmHAoqVTR7d5Mjh8Ico~;E)I(tBvk?-;+yxZ&e1^gBF~TU`Da1k~6W-_b;LaN{vR7#ik;WAu>gtgJKfO5@V%D^@QUK(e;{ zatAI6$ZnL^5dS-m+K|1U$_Exq(C*w^?{j&B0);#k+>dYj7_zRrn`B4VF}vlPh^m$40pm;87%MthY~30M7_7dnDr8ZA+zWKVAE-FZ{#;H(&u7qJ zMEWNG@Pg_CkQRXu?rp8R@7DN@eUUD9ogkmQ&A5;?*dKY|@nn{9pjlEbnS+1+L#pk0 zItTN+v8TGfsfY7rQE%mru-+bmXKFr)VI6iTJglgr6Tqu3KJ`ITJ6+x7BXUHiifJKYn!tk!jtW*bEIzybW?|LQ6BNsmFr{n+5N_^Ox)9RYgUJ58 zf*w}@J3qsEFqoT?ySQBfj5wf5ZVemAEjoXmLlqa|kYTQQ?~26lyZZ)=6*vgEVYmWT^O?(s8Gi#Y8H*g1 zPnzO?)+Zs%N-eU_-LUtCiMmQNBQUG+ z`A|HrupXLw@WBtrSd~bv*XVyup)Bm4D*BE$8l6!>0?*V0q?)VA2(OIMKT$t*-iyPs zXl1x?2af{`8rs8F%$@|kus(dU#Z+xFeFS`| z>-<#!sI4Sc8m6b+(~Dj9Ps%4XI?ihTQrs)pwVTKGGgFXm=B7>@G73zB9P`}8z%h%~ z;Hu<6lkB>?2Z_<)!#5Q{!TQ4QnJcHH=BHdsn~V zb&SXSZ5QKxMk!bA&Mr7$dsqtNBsj(wJy21OQ({w*WI8X-qr_f(Qu`z6bY=k5&)5Ih zbv56_YdFZ=f+{y;S1*MQ3N~M?jz8%!7x%s^;{bbfU6_)%=wLNDf|=!I0)34;yu{f! ze+`l6NNN#^FxA7Bj&VBe5A-c~5ugbvu-hA4@|z8XDVO(Q?-G?c1c zs1^TlO&S*2enG6`JCM@fN$fO5+Lo7y=@J+Mg+=nr>o7cf{B?s3&xegKCL9&U@@$A_ zUMtNdZfH|+*sJzkz}N&A;naN9r3^o4ag>kya-a`P5Yusd13w3XB``sy8J!ngf9L`c zx~-_5n+nd|+bliPq&5^ZdWHxaCSl%5!5g*5oz4fw`byQKSKA%z2jD;pSm4W38z0H} zgTGzo5Z24lB$8HIctP^Xq_Q}EY)M8&kSHan;vVeZ@wv&ROdTQO1dc0j1a5kKz>#Q$ z*#aEeAf5YfE8aW;yT{??&OaD{f72J9RkHbQhUVx#<9On0pStWC;OrgBtdxqXfPI3i z3F5sWA7Uc}!!Uw?%2fk;hP&rLM%zlEW8VE6n`Ky!41Qc`|4XD1 I3wH>2X_5==ssI20 diff --git a/providers/lock.json b/providers/lock.json index b13c89e3..b5cffdf2 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -6,21 +6,21 @@ "appleAppStore": true, "version": "0.2.15", "archive": "bundled/spool.jellyfin-0.2.15.szo", - "sha256": "b7ab008c791cc8f4c1eb4aaf9a4806c9484f0f7925168ee74c5779381168ae09" + "sha256": "65efc71022a12f02caf19560c53f6404d2e49037c4b8bf29a6a73dcd233823a6" }, { "id": "spool.emby", "appleAppStore": true, "version": "0.1.10", "archive": "bundled/spool.emby-0.1.10.szo", - "sha256": "65d01078277127dde86aed58cd36fe58a69982857684c8366409b2311bd63dbd" + "sha256": "ee2ab45c03e1dd94011fcaefddae6030baf1c46e2554d2974a69f79227363a6b" }, { "id": "spool.plex", "appleAppStore": true, "version": "0.1.12", "archive": "bundled/spool.plex-0.1.12.szo", - "sha256": "10c055f0342aff065033cae9e2c3a34d460dd06ec30b9696524ceb6baa2a5a7f" + "sha256": "b2219089f0726d74e824a5fdf89315f6993749b4d0c81ad42bb5d67d3b2b53b6" }, { "id": "spool.stremio", From b5b44acae442c72609d4612c8d7278666eeb3182 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 03:55:32 +1100 Subject: [PATCH 76/78] fix(providers): return speed test samples as a real array Speed tests now return their per-concurrency samples, but Qt hands a QVariantList to script as a sequence wrapper, so a provider returning the result unchanged sent back an object keyed "0", "1", "2" and the samples the network profiles learn from were lost. The result is built from plain script arrays and objects, as LAN probe results already are. --- src/provider/ScriptBridge.cpp | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/provider/ScriptBridge.cpp b/src/provider/ScriptBridge.cpp index b435bcaa..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)) }); @@ -459,7 +481,7 @@ void ScriptRequests::speedTest(const QVariantMap& options, QJSValue resolve, QJS reject.call({ engine->toScriptValue(error) }); return; } - resolve.call({ engine->toScriptValue(result) }); + resolve.call({ plainScriptValue(engine, result) }); }, this); m_speedTest = test; From 531826fd04fa2e70fbd2e9fd4452ce247d19883f Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 03:58:15 +1100 Subject: [PATCH 77/78] fix(player): keep the requested start state when adopting a prepared core A prepared idle mpv core reports its own pause state as soon as it is observed, and that report arrived while setup waited for the video surface, overwriting a requested paused start (or a pause pressed during setup) just before the file loaded. Pause reports are taken only once a file has loaded; before that mpv's pause is only ever what was set. --- src/player/PlayerController.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/player/PlayerController.cpp b/src/player/PlayerController.cpp index 236783a9..54ce7497 100644 --- a/src/player/PlayerController.cpp +++ b/src/player/PlayerController.cpp @@ -2910,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; From 8a0338ea96a0cd3be9565f07ab1119d760ebe88d Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 22 Oct 2026 04:03:19 +1100 Subject: [PATCH 78/78] chore(release): prepare v0.9.2 Sparse large-library browsing with an alphabet index and type-to-seek, throughput-aware artwork downloads, one-step accounts with sensible defaults and Plex servers from an existing sign-in, a sideloadable iOS build with a SideStore/AltStore source, and the published Plex 0.1.12, Jellyfin 0.2.15 and Emby 0.1.10 providers. --- VERSION | 2 +- app/com.sachk.spool.metainfo.xml | 3 +++ packaging/aur/.SRCINFO | 6 +++--- packaging/aur/PKGBUILD | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index f374f666..2003b639 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.1 +0.9.2 diff --git a/app/com.sachk.spool.metainfo.xml b/app/com.sachk.spool.metainfo.xml index 0467d73b..80be6265 100644 --- a/app/com.sachk.spool.metainfo.xml +++ b/app/com.sachk.spool.metainfo.xml @@ -21,6 +21,9 @@ Spool + +