diff --git a/iosApp/DownloadsActivity/DownloadsLiveActivity.swift b/iosApp/DownloadsActivity/DownloadsLiveActivity.swift index 378cc108e..d51192dc5 100644 --- a/iosApp/DownloadsActivity/DownloadsLiveActivity.swift +++ b/iosApp/DownloadsActivity/DownloadsLiveActivity.swift @@ -20,13 +20,16 @@ struct DownloadsLiveActivity: Widget { ActivityConfiguration(for: DownloadActivityAttributes.self) { context in DownloadsLockScreenView(state: context.state, isStale: context.isStale) .padding(16) + // The extension has no accent asset, so `.tint` would fall + // back to system blue; follow the lock screen's text color. + .tint(.primary) .widgetURL(Self.deepLink) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { Image(systemName: context.state.phase.symbolName) .font(.title2) - .foregroundStyle(.tint) + .foregroundStyle(.white) .padding(.leading, 4) } DynamicIslandExpandedRegion(.trailing) { @@ -52,6 +55,7 @@ struct DownloadsLiveActivity: Widget { VStack(alignment: .leading, spacing: 4) { ProgressView(value: context.state.fraction) .progressViewStyle(.linear) + .tint(.white) Text(context.state.statusText(isStale: context.isStale)) .font(.caption) .foregroundStyle(.secondary) @@ -61,15 +65,15 @@ struct DownloadsLiveActivity: Widget { } } compactLeading: { Image(systemName: context.state.phase.symbolName) - .foregroundStyle(.tint) + .foregroundStyle(.white) } compactTrailing: { ProgressView(value: context.state.fraction) .progressViewStyle(.circular) - .tint(.blue) + .tint(.white) } minimal: { ProgressView(value: context.state.fraction) .progressViewStyle(.circular) - .tint(.blue) + .tint(.white) } .widgetURL(Self.deepLink) } diff --git a/iosApp/Tests/CalendarFilterTests.swift b/iosApp/Tests/CalendarFilterTests.swift new file mode 100644 index 000000000..2b8503791 --- /dev/null +++ b/iosApp/Tests/CalendarFilterTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import Prairie + +/// An empty calendar week links to the other two views, never the one on +/// screen (issue #513, matching the Web empty state). +final class CalendarFilterTests: XCTestCase { + func testEmptyStateLinksToTheOtherTwoViews() { + XCTAssertEqual(CalendarFilter.following.emptyStateLinks, [.trending, .everything]) + XCTAssertEqual(CalendarFilter.trending.emptyStateLinks, [.following, .everything]) + XCTAssertEqual(CalendarFilter.everything.emptyStateLinks, [.following, .trending]) + } +} diff --git a/iosApp/Tests/DetailVersionSelectionTests.swift b/iosApp/Tests/DetailVersionSelectionTests.swift index 0fb010e86..c174a672e 100644 --- a/iosApp/Tests/DetailVersionSelectionTests.swift +++ b/iosApp/Tests/DetailVersionSelectionTests.swift @@ -37,6 +37,47 @@ final class DetailVersionSelectionTests: XCTestCase { ) } + #if !os(tvOS) + func testAutoDownloadPinsTheDisplayedLastPlayedVersion() { + // Auto displays the last-played 720p file (the one with the sidecar + // subtitles); sending nil let the server pick the 1080p file instead. + let versions = [ + version(fileId: 1, resolution: "720p"), + version(fileId: 2, resolution: "1080p") + ] + + XCTAssertEqual(DownloadRequestOptions.fileId( + versions: versions, + selectedFileId: nil, + lastFileId: 1, + preferredQualityId: nil + ), 1) + } + + func testDownloadPinsAnExplicitVersionOverLastPlayed() { + let versions = [ + version(fileId: 1, resolution: "720p"), + version(fileId: 2, resolution: "1080p") + ] + + XCTAssertEqual(DownloadRequestOptions.fileId( + versions: versions, + selectedFileId: 2, + lastFileId: 1, + preferredQualityId: nil + ), 2) + } + + func testDownloadLeavesTheFileToTheServerWithoutVersions() { + XCTAssertNil(DownloadRequestOptions.fileId( + versions: [], + selectedFileId: nil, + lastFileId: 1, + preferredQualityId: nil + )) + } + #endif + func testVersionSelectorUsesRichPrePlaySummary() { let version = decodedVersions(""" [ diff --git a/iosApp/Tests/DownloadPipelineReliabilityTests.swift b/iosApp/Tests/DownloadPipelineReliabilityTests.swift new file mode 100644 index 000000000..d73042b0b --- /dev/null +++ b/iosApp/Tests/DownloadPipelineReliabilityTests.swift @@ -0,0 +1,254 @@ +import Foundation +import XCTest +@testable import Prairie + +/// Capping pipelines and transfers, starting each transfer exactly once, +/// retrying requests that never got an answer, and a transfer speed that +/// reflects what is moving now. +final class DownloadPipelineReliabilityTests: XCTestCase { + private typealias Plan = DownloadManager.ReconnectPlan + + private func plan( + _ status: LocalDownloadStatus, + task: Int? = nil, + taskBeforeRead: Int? = nil, + live: Set = [], + pausing: Bool = false, + restartOwned: Bool = false + ) -> Plan { + DownloadManager.reconnectPlan( + status: status, + taskIdentifier: task, + taskIdentifierBeforeRead: taskBeforeRead, + liveTaskIds: live, + pausing: pausing, + restartOwned: restartOwned + ) + } + + // MARK: Queue + + func testOnlyRunningPipelinesHoldQueueSlots() { + var owners = DownloadRestartOwners() + _ = owners.claimPipeline("a") + _ = owners.claimPipeline("b") + // A retry waiting out its back-off doesn't keep the queue from moving. + owners.retryScheduled("c", firesAt: Date()) + XCTAssertEqual(owners.pipelineCount, 2) + XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: owners.pipelineCount, transferring: 0, limit: 6).pipelines, 1) + XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 5, transferring: 0, limit: 6).pipelines, 0) + } + + func testTransfersStayWithinTheSimultaneousDownloadsLimit() { + // Two transferring and two about to: the next waits for one to finish. + XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 2, transferring: 2, limit: 4).transfers, 0) + XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 0, transferring: 1, limit: 4).transfers, 3) + // A whole series handed over at once is what this prevents. + XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 0, transferring: 53, limit: 4).transfers, 0) + } + + private func record( + _ status: LocalDownloadStatus, task: Int? = nil, id: String = "d1", bytes: Int64 = 0, age: TimeInterval = 0 + ) -> DownloadRecord { + DownloadRecord( + id: id, contentId: "c1", episodeId: nil, batchId: nil, mediaFileId: "0", format: "original", + serverStatus: "ready", localStatus: status, fileSize: 1_000, bytesDownloaded: bytes, + mediaFilename: nil, manifestFilename: nil, posterFilename: nil, backdropFilename: nil, + logoFilename: nil, subtitleFilenames: [:], title: "Title", subtitle: nil, type: "movie", + seriesId: nil, seriesTitle: nil, posterThumbhash: nil, container: nil, stableIdentity: nil, + registeredAt: Date(timeIntervalSinceReferenceDate: 1_000 - age), downloadedAt: nil, lastError: nil, + retryCount: 0, taskIdentifier: task + ) + } + + func testInProgressListLeadsWithWhatIsTransferring() { + let sorted = DownloadManager.sortedByActivity([ + record(.queued, id: "queued-old", age: 50), + record(.paused, id: "paused", age: 40), + record(.queued, id: "queued-new", age: 1), + record(.downloading, task: 2, id: "handed-off", age: 5), + record(.preparing, id: "preparing", age: 30), + record(.downloading, task: 1, id: "moving", bytes: 500, age: 10), + record(.fetchingAssets, id: "fetching", age: 20), + ]) + XCTAssertEqual(sorted.map(\.id), + ["moving", "fetching", "handed-off", "preparing", "queued-old", "queued-new", "paused"]) + } + + func testRecordsLeftMidPipelineGoBackInTheQueueWhenTheStoreLoads() { + XCTAssertTrue(DownloadManager.isOrphaned(record(.fetchingAssets))) + // Was waiting to restart; the retry didn't survive the process. + XCTAssertTrue(DownloadManager.isOrphaned(record(.downloading))) + // Reconnect decides whether this task still runs. + XCTAssertFalse(DownloadManager.isOrphaned(record(.downloading, task: 4))) + XCTAssertFalse(DownloadManager.isOrphaned(record(.queued))) + XCTAssertFalse(DownloadManager.isOrphaned(record(.paused))) + } + + // MARK: Transfer failures + + func testClosingPrairieFromTheAppSwitcherResumesWithoutUsingARetry() { + XCTAssertEqual(DownloadManager.mediaFailureAction(statusCode: nil, retryCount: 4, message: "cancelled", cause: .forceQuit), + .retry(keepResumeData: true, refreshToken: false)) + } + + func testFullStorageFailsWithoutRetrying() { + XCTAssertEqual(DownloadManager.mediaFailureAction(statusCode: nil, retryCount: 0, message: "write", cause: .storageFull), + .fail("storage_full")) + XCTAssertTrue(DownloadSessionDelegate.isOutOfSpace(NSError(domain: NSCocoaErrorDomain, code: NSFileWriteOutOfSpaceError))) + XCTAssertTrue(DownloadSessionDelegate.isOutOfSpace(URLError(.cannotWriteToFile, + userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC))]))) + // A write failure alone could be anything; it gets the usual retries. + XCTAssertFalse(DownloadSessionDelegate.isOutOfSpace(URLError(.cannotWriteToFile))) + XCTAssertTrue(DownloadSessionDelegate.isOutOfSpace(NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError, + userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC))]))) + XCTAssertFalse(DownloadSessionDelegate.isOutOfSpace(URLError(.networkConnectionLost))) + } + + // MARK: Restart ownership + + func testSupersededPipelineCanNeitherStartNorReleaseTheRecord() { + var owners = DownloadRestartOwners() + let first = owners.claimPipeline("d1") + let second = owners.claimPipeline("d1") + XCTAssertFalse(owners.ownsPipeline("d1", first)) + // The first pipeline finishing must not free the record from the second. + owners.releasePipeline("d1", first) + XCTAssertTrue(owners.ownsPipeline("d1", second)) + XCTAssertTrue(owners.ownsRestart("d1")) + owners.releasePipeline("d1", second) + XCTAssertFalse(owners.ownsRestart("d1")) + } + + func testAbandonedPipelineLosesTheRecord() { + var owners = DownloadRestartOwners() + let token = owners.claimPipeline("d1") + owners.abandonPipeline("d1") + XCTAssertFalse(owners.ownsPipeline("d1", token)) + XCTAssertFalse(owners.ownsRestart("d1")) + } + + func testScheduledRetryOwnsTheRestartUntilItEnds() { + var owners = DownloadRestartOwners() + owners.retryScheduled("d1", firesAt: Date()) + XCTAssertTrue(owners.ownsRestart("d1")) + XCTAssertFalse(owners.hasPipeline("d1")) + owners.retryEnded("d1") + XCTAssertFalse(owners.ownsRestart("d1")) + } + + func testBackgroundWakeWaitsForPipelinesAndRetriesDueBeforeItsDeadline() { + let now = Date() + var owners = DownloadRestartOwners() + XCTAssertFalse(owners.handoffPending(by: now.addingTimeInterval(20))) + owners.retryScheduled("d1", firesAt: now.addingTimeInterval(40)) + XCTAssertFalse(owners.handoffPending(by: now.addingTimeInterval(20))) + owners.retryScheduled("d2", firesAt: now.addingTimeInterval(10)) + XCTAssertTrue(owners.handoffPending(by: now.addingTimeInterval(20))) + owners.retryEnded("d2") + _ = owners.claimPipeline("d3") + XCTAssertTrue(owners.handoffPending(by: now)) + } + + // MARK: Reconnect + + func testPipelineStillRunningInThisProcessIsNotStartedAgain() { + XCTAssertEqual(plan(.fetchingAssets, restartOwned: true), Plan(dropTask: false, requeue: false)) + // After a relaunch nothing owns it, so it starts again. + XCTAssertEqual(plan(.fetchingAssets), Plan(dropTask: false, requeue: true)) + } + + func testTransferStartedDuringTheLiveTaskReadIsKept() { + // The read can't see a task created after it began. + XCTAssertEqual(plan(.downloading, task: 7, taskBeforeRead: nil, live: []), Plan(dropTask: false, requeue: false)) + XCTAssertEqual(plan(.downloading, task: 7, taskBeforeRead: 3, live: [3]), Plan(dropTask: false, requeue: false)) + } + + func testLostTransferIsDroppedAndStartedAgain() { + XCTAssertEqual(plan(.downloading, task: 3, taskBeforeRead: 3, live: [5]), Plan(dropTask: true, requeue: true)) + XCTAssertEqual(plan(.downloading, task: 3, taskBeforeRead: 3, live: [3]), Plan(dropTask: false, requeue: false)) + } + + func testRetryWaitingOutItsBackOffKeepsTheRecord() { + XCTAssertEqual(plan(.downloading, restartOwned: true), Plan(dropTask: false, requeue: false)) + XCTAssertEqual(plan(.downloading), Plan(dropTask: false, requeue: true)) + } + + func testPauseRoundTripKeepsItsTask() { + XCTAssertEqual(plan(.paused, task: 3, taskBeforeRead: 3, live: [], pausing: true), + Plan(dropTask: false, requeue: false)) + XCTAssertEqual(plan(.paused, task: 3, taskBeforeRead: 3, live: []), Plan(dropTask: true, requeue: false)) + } + + // MARK: Pipeline failures + + func testRequestWithoutAnAnswerIsRetried() { + XCTAssertTrue(DownloadManager.isTransientPipelineFailure(HTTPError.network(underlying: URLError(.networkConnectionLost)))) + XCTAssertTrue(DownloadManager.isTransientPipelineFailure(HTTPError.network(underlying: URLError(.timedOut)))) + XCTAssertTrue(DownloadManager.isTransientPipelineFailure(URLError(.notConnectedToInternet))) + XCTAssertFalse(DownloadManager.isTransientPipelineFailure(HTTPError.network(underlying: URLError(.cancelled)))) + XCTAssertFalse(DownloadManager.isTransientPipelineFailure(APIv2Error.httpStatus(500))) + XCTAssertFalse(DownloadManager.isTransientPipelineFailure(HTTPError.requestIdentityChanged)) + } + + // MARK: Transfer rate + + private let start = Date(timeIntervalSinceReferenceDate: 1_000) + + private func sample(_ bytes: Int64, at seconds: TimeInterval) -> DownloadManager.TransferRateSample { + DownloadManager.TransferRateSample(bytes: bytes, at: start.addingTimeInterval(seconds)) + } + + func testRateComesFromBytesMovedSinceTheLastSample() throws { + let first = DownloadManager.nextTransferRate(sample: sample(0, at: 0), rate: nil, + bytes: 10_000_000, now: start.addingTimeInterval(1)) + XCTAssertEqual(try XCTUnwrap(first.rate), 10_000_000, accuracy: 1) + XCTAssertEqual(first.sample, sample(10_000_000, at: 1)) + } + + func testRateStartsOverAfterAGap() { + // 60 MB over a 30 s suspension would read as 2 MB/s whatever the + // transfer is doing now. + let next = DownloadManager.nextTransferRate(sample: sample(0, at: 0), rate: 20_000_000, + bytes: 60_000_000, now: start.addingTimeInterval(30)) + XCTAssertNil(next.rate) + XCTAssertEqual(next.sample, sample(60_000_000, at: 30)) + } + + func testRateResetsWhenAResumeReportsFewerBytes() { + let next = DownloadManager.nextTransferRate(sample: sample(50_000_000, at: 0), rate: 8_000_000, + bytes: 1_000_000, now: start.addingTimeInterval(1)) + XCTAssertNil(next.rate) + } + + func testCallbacksCloserThanTheSampleIntervalKeepTheWindow() { + let next = DownloadManager.nextTransferRate(sample: sample(0, at: 0), rate: 5_000_000, + bytes: 1_000_000, now: start.addingTimeInterval(0.1)) + XCTAssertEqual(next.sample, sample(0, at: 0)) + XCTAssertEqual(next.rate, 5_000_000) + } + + func testRetriesResetOnlyOnceATransferPassesItsFailedPeak() { + let mib: Int64 = 1 << 20 + // An attempt failed at 100 MiB; the next one moves 2 MiB per sample. + var state = (retryCount: 4, furthest: 100 * mib) + var written = 100 * mib + for _ in 0..<4 { + written += 2 * mib + state = DownloadManager.recoveryProgress(retryCount: state.retryCount, furthest: state.furthest, written: written) + } + XCTAssertEqual(state.retryCount, 4, "8 MiB past the peak isn't recovery yet") + XCTAssertEqual(state.furthest, 100 * mib) + written += 2 * mib + state = DownloadManager.recoveryProgress(retryCount: state.retryCount, furthest: state.furthest, written: written) + XCTAssertEqual(state.retryCount, 0) + XCTAssertEqual(state.furthest, written) + } + + func testRestartFromZeroKeepsItsRetriesUntilItPassesThePeak() { + let mib: Int64 = 1 << 20 + let state = DownloadManager.recoveryProgress(retryCount: 2, furthest: 500 * mib, written: 400 * mib) + XCTAssertEqual(state.retryCount, 2) + XCTAssertEqual(state.furthest, 500 * mib) + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json index 244aacf10..999251636 100644 --- a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.898", - "green" : "0.533", - "red" : "0.118" + "blue" : "0.929", + "green" : "0.929", + "red" : "0.929" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.898", - "green" : "0.533", - "red" : "0.118" + "blue" : "0.929", + "green" : "0.929", + "red" : "0.929" } }, "idiom" : "universal" diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index bd2bd4a4b..ffdb3479a 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -391,6 +391,7 @@ struct ContentView: View { switch newPhase { case .active: prairieControl.appDidBecomeActive() + DownloadManager.shared.sceneDidBecomeActive() case .background: prairieControl.appDidEnterBackground() // Keep series monitoring alive while backgrounded; only @@ -2694,8 +2695,8 @@ struct MainTabView: View { .scrollBounceBehavior(.basedOnSize) #if os(iOS) // The shell's near-white tint would fill the selected row under the - // system's white text. Use the app accent and let iPadOS pick colors. - .tint(Color("AccentColor")) + // system's white text, so the selected row gets a graphite fill. + .tint(Color.prairieIconTile) #endif } diff --git a/iosApp/iosApp/Control/tvOS/TVControlStandbyView.swift b/iosApp/iosApp/Control/tvOS/TVControlStandbyView.swift index 71ec6a8d4..4e2ff5a98 100644 --- a/iosApp/iosApp/Control/tvOS/TVControlStandbyView.swift +++ b/iosApp/iosApp/Control/tvOS/TVControlStandbyView.swift @@ -42,6 +42,7 @@ struct TVControlStandbyView: View { receiver.disconnectRemoteControl() } .buttonStyle(.borderedProminent) + .foregroundStyle(Color.prairieBackground) .controlSize(.large) .focused($isDisconnectFocused) } diff --git a/iosApp/iosApp/Downloads/DownloadActionButton.swift b/iosApp/iosApp/Downloads/DownloadActionButton.swift index 0514fefc7..96ee7f18e 100644 --- a/iosApp/iosApp/Downloads/DownloadActionButton.swift +++ b/iosApp/iosApp/Downloads/DownloadActionButton.swift @@ -265,13 +265,14 @@ struct DownloadActionButton: View { // MARK: - Actions /// One-tap entry: start immediately with defaults unless the size that - /// would land on disk (max of the Auto range, or the exact size of the - /// selected version) warrants confirming first. + /// would land on disk (the displayed version's size, or the max across + /// candidates when that size is unknown) warrants confirming first. private func handleDownloadTap() { guard !isRegistrationPending, record == nil else { return } let estimate = versions.isEmpty ? DownloadSizeEstimate.estimate(fileSizes: candidateFileSizes) - : DownloadSizeEstimate.estimate(versions: versions, fileId: selectedVersionFileId) + : (DownloadSizeEstimate.estimate(versions: versions, fileId: displayedVersionFileId) + ?? DownloadSizeEstimate.estimate(fileSizes: candidateFileSizes)) let available = DownloadFilePaths.deviceStorage().available if let warning = estimate?.warningMessage(availableBytes: available) { largeDownloadWarning = warning @@ -280,12 +281,12 @@ struct DownloadActionButton: View { startWithDefaults() } - /// The version picked on the detail screen (Auto when none — matching - /// what the options sheet preselects) + the global Downloads quality - /// preference, clamped to what the server currently offers. + /// The version the detail screen displays, with Auto resolved the same + /// way the selector shows it, + the global Downloads quality preference, + /// clamped to what the server currently offers. private func startWithDefaults() { startDownload(DownloadRequestOptions( - fileId: selectedVersionFileId, + fileId: displayedVersionFileId, quality: DownloadSettings.shared.resolvedFormat( allowedFormats: manager.capability?.qualityPresets ?? [] ) @@ -355,6 +356,17 @@ struct DownloadActionButton: View { } } + /// Nil only in compact style, which has no version metadata; the + /// server then picks the file. + private var displayedVersionFileId: Int? { + DownloadRequestOptions.fileId( + versions: versions, + selectedFileId: selectedVersionFileId, + lastFileId: lastVersionFileId, + preferredQualityId: PlayerSettings.shared.preferredQuality + ) + } + private func pause() { if let id = record?.id { manager.pauseDownload(id: id) } } diff --git a/iosApp/iosApp/Downloads/DownloadContinuedProcessing.swift b/iosApp/iosApp/Downloads/DownloadContinuedProcessing.swift new file mode 100644 index 000000000..ef50cda11 --- /dev/null +++ b/iosApp/iosApp/Downloads/DownloadContinuedProcessing.swift @@ -0,0 +1,430 @@ +#if os(iOS) +import BackgroundTasks +import Foundation +import Observation +import OSLog + +/// On iOS 26 and later, keeps Prairie running while downloads the user started +/// are in progress, through a continued processing task. iOS draws its own +/// Live Activity for the task (title, subtitle, a progress ring, and a stop +/// button), so the downloads' progress stays live after Prairie leaves the +/// screen and the queue keeps moving without waiting for a background wake. +/// +/// The transfers themselves stay on the background `URLSession`. When the +/// task ends early, downloads keep going: only the live progress goes away. +/// iOS expires a task whose progress stops advancing for about 30 seconds and +/// then labels it failed, so the reported progress only ever grows, and Prairie +/// ends the task itself first whenever nothing is moving (waiting for Wi-Fi, +/// a connection, or the server), saying what the downloads wait for. +@MainActor +@Observable +final class DownloadContinuedProcessing { + static let shared = DownloadContinuedProcessing() + private init() {} + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "org.prairieserver.prairie", + category: "Downloads" + ) + + /// Each request needs a unique identifier under the wildcard listed in + /// `BGTaskSchedulerPermittedIdentifiers` (iosApp/Info.plist). + private static var identifierPrefix: String { + (Bundle.main.bundleIdentifier ?? "org.prairieserver.prairie") + ".downloads.continued" + } + + /// The progress total until the first byte counts arrive. + private static let progressUnits: Int64 = 1_000_000 + /// A submitted request iOS hasn't started by now never will be; iOS can + /// accept a request and then drop it without reporting an error. + private static let startDeadline: Duration = .seconds(5) + /// Prairie ends the task after this long with no download moving at all, + /// before iOS's own stall check marks it failed. While anything moves, + /// the task keeps running and iOS alone decides. + private static let stallLimit: TimeInterval = 20 + /// A named file slower than this, as a fraction of its size per second + /// (1.5% per 30 seconds), hands the title to a faster one, so the pill + /// names a download that is visibly moving. + private static let minFractionPerSecond = 0.0005 + + /// How the queue stood when Prairie ended the task. + enum Outcome { + /// Every download finished. + case completed + /// Every download is paused. + case paused + /// Nothing left to download that finished (deleted, failed, or a + /// registration that added nothing). + case emptied + } + + private enum State { + case idle + /// Submitted; iOS hasn't handed over the task yet. + case submitted(identifier: String) + /// Holds the `BGContinuedProcessingTask`. + case running(AnyObject, identifier: String) + /// The user stopped the task. Nothing shows the queue's progress + /// until it empties, Prairie comes back on screen, or the user starts + /// another download. + case dismissed + } + + private var state: State = .idle + private var latest: DownloadActivityAttributes.ContentState? + private var latestHeadline: DownloadRecord? + /// The download the task's text names; see `pickReported`. + private var reportedId: String? + /// Bytes downloaded, across every download, since the task started. + /// Only ever grows: iOS counts progress only once it passes its previous + /// high, so a ring that steps back (a new file starting, a finished one + /// leaving the queue) reads as a stall and the task expires. + private var sessionBytes: Int64 = 0 + /// Each download's bytes at the last update, to count what arrived since. + private var lastBytes: [String: Int64] = [:] + private var remainingBytes: Int64 = 0 + private var transferredBytes: Int64 = 0 + /// When the queue last moved (new bytes or a finished download). + private var lastMovedAt = Date() + private var stallWatch: Task? + /// Completes the running task exactly once, from any thread. + private var completion: TaskCompletion? + private var lastSubtitleUpdate = Date.distantPast + + /// Whether this owns the queue's live progress, so Prairie's own Live + /// Activity stays out of the way. + var ownsProgress: Bool { + if case .idle = state { return false } + return true + } + + /// Starts the task for a download the user just started. Must run in + /// the foreground, in direct response to the user's action. Does nothing + /// while a task is already running or starting. + func begin(title: String) { + guard #available(iOS 26, *) else { return } + switch state { + case .submitted, .running: + return + case .idle, .dismissed: + break + } + let identifier = "\(Self.identifierPrefix).\(UUID().uuidString)" + // Each identifier is registered once, just before its request: + // registering the wildcard itself doesn't match submitted requests. + let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: .main) { task in + MainActor.assumeIsolated { DownloadContinuedProcessing.shared.started(task, identifier: identifier) } + } + guard registered else { + Self.logger.warning("Continued processing identifier not permitted") + state = .idle + return + } + let request = BGContinuedProcessingTaskRequest(identifier: identifier, title: title, subtitle: "Starting…") + // Run now or not at all; Prairie's own Live Activity covers the rest. + request.strategy = .fail + state = .submitted(identifier: identifier) + #if compiler(>=6.4) + // `submitTaskRequest` ships in the iOS 27 SDK (Xcode 27). + if #available(iOS 27, *) { + // Reports errors the older call can't, and must not run on main. + nonisolated(unsafe) let request = request + DispatchQueue.global(qos: .userInitiated).async { + BGTaskScheduler.shared.submitTaskRequest(request) { error in + guard let error else { return } + DispatchQueue.main.async { + MainActor.assumeIsolated { + DownloadContinuedProcessing.shared.submitFailed(identifier: identifier, error: error) + } + } + } + } + } else if !submit(request, identifier: identifier) { + return + } + #else + if !submit(request, identifier: identifier) { return } + #endif + Task { @MainActor [weak self] in + try? await Task.sleep(for: Self.startDeadline) + self?.abandonIfNotStarted(identifier: identifier) + } + } + + /// Mirrors the queue into the running task. `transferredBytes` counts + /// every active download, including those of unknown size, to tell a + /// moving queue from a stalled one. + func update( + _ content: DownloadActivityAttributes.ContentState, + transferredBytes: Int64, + headline fallback: DownloadRecord?, + active: [DownloadRecord], + rates: [String: Double] + ) { + if transferredBytes > self.transferredBytes + || content.completedCount > (latest?.completedCount ?? content.completedCount) { + lastMovedAt = Date() + } + let headline = pickReported(active: active, rates: rates) ?? fallback + if case .running = state { accumulate(active) } + latest = content + latestHeadline = headline + self.transferredBytes = transferredBytes + guard #available(iOS 26, *), case .running(let object, _) = state, + let task = object as? BGContinuedProcessingTask else { return } + apply(content, headline: headline, to: task) + } + + /// Ends the task on Prairie's terms. A stop the user made stays in place + /// while the queue still has downloads. + func finish(_ outcome: Outcome) { + switch state { + case .idle: + return + case .dismissed: + if outcome != .paused { state = .idle } + return + case .submitted: + // Not cancelled: iOS shows a cancelled request as failed. If it + // still starts, `started` completes it quietly. + break + case .running(let object, _): + if #available(iOS 26, *), let task = object as? BGContinuedProcessingTask { + switch outcome { + case .completed: + task.progress.completedUnitCount = task.progress.totalUnitCount + case .paused: + task.updateTitle(task.title, subtitle: "Paused") + case .emptied: + break + } + completion?(success: true) + } + } + endRun() + } + + // MARK: - Task lifecycle + + @available(iOS 26, *) + private func started(_ task: BGTask, identifier: String) { + guard let task = task as? BGContinuedProcessingTask, + case .submitted(let submitted) = state, submitted == identifier else { + // Superseded: the queue finished, or the start came too late. + // Complete it quietly rather than as a failure. + task.setTaskCompleted(success: true) + return + } + state = .running(task, identifier: identifier) + lastMovedAt = Date() + let completion = TaskCompletion { task.setTaskCompleted(success: $0) } + self.completion = completion + task.progress.totalUnitCount = Self.progressUnits + task.expirationHandler = { + // At once, on whatever thread iOS calls from: a busy main thread + // must not delay it, or iOS ends the app too. + completion(success: false) + if Thread.isMainThread { + MainActor.assumeIsolated { DownloadContinuedProcessing.shared.expired(identifier: identifier) } + } else { + DispatchQueue.main.async { + MainActor.assumeIsolated { DownloadContinuedProcessing.shared.expired(identifier: identifier) } + } + } + } + if let latest { apply(latest, headline: latestHeadline, to: task) } + stallWatch?.cancel() + stallWatch = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + self?.endIfStalled() + } + } + } + + /// iOS ended the task: the user tapped stop, or iOS reclaimed it (the + /// handler can't tell which). While the queue was moving, that's taken + /// as the user's stop; otherwise the queue had stalled and nothing is + /// hidden on purpose. + private func expired(identifier: String) { + guard case .running(_, let current) = state, current == identifier else { return } + let wasMoving = Date().timeIntervalSince(lastMovedAt) < Self.stallLimit + endRun() + if wasMoving { + Self.logger.notice("Continued processing stopped; downloads continue in the background") + state = .dismissed + } + } + + private func endIfStalled() { + guard #available(iOS 26, *), case .running(let object, _) = state, + let task = object as? BGContinuedProcessingTask, + Date().timeIntervalSince(lastMovedAt) >= Self.stallLimit else { return } + // Read now: a lost network changes no download, so nothing pushed a + // newer reason. + let reason = DownloadManager.shared.currentWaitingReason() ?? "Waiting to continue" + task.updateTitle(task.title, subtitle: "\(reason) · Downloads resume on their own") + completion?(success: true) + endRun() + // Prairie's own Live Activity takes over while the app is in front. + DownloadManager.shared.refreshLiveProgress() + } + + private func submitFailed(identifier: String, error: Error) { + guard case .submitted(let submitted) = state, submitted == identifier else { return } + Self.logger.notice("Continued processing not started: \(String(describing: error), privacy: .public)") + state = .idle + DownloadManager.shared.refreshLiveProgress() + } + + /// Submits on the main thread. Returns false when iOS refuses the request. + @available(iOS 26, *) + private func submit(_ request: BGContinuedProcessingTaskRequest, identifier: String) -> Bool { + do { + try BGTaskScheduler.shared.submit(request) + return true + } catch { + submitFailed(identifier: identifier, error: error) + return false + } + } + + private func abandonIfNotStarted(identifier: String) { + guard case .submitted(let submitted) = state, submitted == identifier else { return } + Self.logger.notice("Continued processing request never started") + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier) + state = .idle + DownloadManager.shared.refreshLiveProgress() + } + + /// Prairie is on screen, where its own Live Activity can show the queue. + func clearDismissal() { + if case .dismissed = state { state = .idle } + } + + private func endRun() { + stallWatch?.cancel() + stallWatch = nil + completion = nil + state = .idle + latest = nil + latestHeadline = nil + reportedId = nil + transferredBytes = 0 + sessionBytes = 0 + lastBytes = [:] + remainingBytes = 0 + } + + private func accumulate(_ active: [DownloadRecord]) { + var remaining: Int64 = 0 + for record in active { + let bytes = max(record.bytesDownloaded, 0) + // A download seen for the first time, or one that restarted from + // zero, only sets its baseline. + if let last = lastBytes[record.id], bytes > last { sessionBytes += bytes - last } + lastBytes[record.id] = bytes + if record.fileSize > bytes { remaining += record.fileSize - bytes } + } + remainingBytes = remaining + } + + /// The transfer the task's text names: the one moving fastest for its + /// size. The choice sticks while its file keeps moving fast enough, so + /// the pill doesn't flip between titles. + private func pickReported(active: [DownloadRecord], rates: [String: Double]) -> DownloadRecord? { + let transferring = active.filter { + $0.localStatus == .downloading && $0.taskIdentifier != nil && $0.fileSize > 0 + } + func speed(_ record: DownloadRecord) -> Double { + (rates[record.id] ?? 0) / Double(record.fileSize) + } + let current = transferring.first { $0.id == reportedId } + let chosen: DownloadRecord? + if let current, speed(current) >= Self.minFractionPerSecond { + chosen = current + } else if let fastest = transferring.max(by: { speed($0) < speed($1) }), speed(fastest) > 0 { + chosen = fastest + } else { + chosen = current + } + reportedId = chosen?.id + return chosen + } + + /// Reports the queue's bytes since the task started (`sessionBytes`) as + /// the task's progress, and names the headline file with its own byte + /// count in the text. + @available(iOS 26, *) + private func apply( + _ content: DownloadActivityAttributes.ContentState, headline: DownloadRecord?, to task: BGContinuedProcessingTask + ) { + // Bytes since the task started, out of those plus what's left. + let progress = task.progress + let total = max(sessionBytes + remainingBytes, sessionBytes + 1) + if progress.totalUnitCount != total { progress.totalUnitCount = total } + if progress.completedUnitCount != sessionBytes { progress.completedUnitCount = sessionBytes } + // Each title change crosses to the system; once a second is plenty + // for a byte count. + let subtitle = Self.subtitle(for: content, headline: headline) + let title = headline.map(Self.title(for:)) ?? content.title + if task.title != title + || (task.subtitle != subtitle && Date().timeIntervalSince(lastSubtitleUpdate) >= 1) { + task.updateTitle(title, subtitle: subtitle) + lastSubtitleUpdate = Date() + } + } + + nonisolated static func title(for record: DownloadRecord) -> String { + if record.type == "episode", let episode = record.subtitle, !episode.isEmpty { + return "\(record.seriesTitle ?? record.title ?? "Episode") · \(episode)" + } + return record.title ?? record.seriesTitle ?? "Download" + } + + /// The headline file's "427.29 MB / 1.42 GB", led by "2 of 5 done" when + /// several downloads are in the queue. + nonisolated static func subtitle( + for content: DownloadActivityAttributes.ContentState, headline: DownloadRecord? + ) -> String { + var parts: [String] = [] + if content.totalCount > 1 { + parts.append("\(content.completedCount) of \(content.totalCount) done") + } + switch content.phase { + case .preparing: + parts.append("Preparing on server…") + case .paused: + parts.append("Paused") + case .downloading, .completed: + if let headline, headline.fileSize > 0 { + parts.append(headline.bytesDownloaded.formatted(.byteCount(style: .file)) + + " / " + headline.fileSize.formatted(.byteCount(style: .file))) + } else { + parts.append("Starting…") + } + } + return parts.joined(separator: " · ") + } +} + +/// Completes a continued processing task at most once, whichever of the +/// expiration handler (any thread) and Prairie (main actor) gets there first. +private final class TaskCompletion: @unchecked Sendable { + private let lock = NSLock() + private var done = false + private let complete: (Bool) -> Void + + init(_ complete: @escaping (Bool) -> Void) { + self.complete = complete + } + + func callAsFunction(success: Bool) { + lock.lock() + let first = !done + done = true + lock.unlock() + if first { complete(success) } + } +} +#endif diff --git a/iosApp/iosApp/Downloads/DownloadFilePaths.swift b/iosApp/iosApp/Downloads/DownloadFilePaths.swift index 68a2dbbd5..d710ede74 100644 --- a/iosApp/iosApp/Downloads/DownloadFilePaths.swift +++ b/iosApp/iosApp/Downloads/DownloadFilePaths.swift @@ -63,6 +63,27 @@ enum DownloadFilePaths { return dir.appendingPathComponent("task-\(taskIdentifier).bin", isDirectory: false) } + /// Removes staged files older than `age`. A file is staged and moved + /// within moments, so an old one was left by a process that ended in + /// between, and nothing will claim it. Returns the bytes freed. + static func removeStaleStagingFiles(olderThan age: TimeInterval, root: URL = rootDirectory()) -> Int64 { + let dir = root.appendingPathComponent("staging", isDirectory: true) + let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey] + guard let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles] + ) else { return 0 } + let cutoff = Date().addingTimeInterval(-age) + var freed: Int64 = 0 + for file in files { + guard let values = try? file.resourceValues(forKeys: Set(keys)), + let modified = values.contentModificationDate, modified < cutoff else { continue } + if (try? FileManager.default.removeItem(at: file)) != nil { + freed += Int64(values.fileSize ?? 0) + } + } + return freed + } + static func downloadDirectory(serverId: String, profileId: String, downloadId: String) -> URL { let dir = scopeDirectory(serverId: serverId, profileId: profileId) .appendingPathComponent(sanitize(downloadId), isDirectory: true) diff --git a/iosApp/iosApp/Downloads/DownloadLiveActivity.swift b/iosApp/iosApp/Downloads/DownloadLiveActivity.swift index c00197a8d..cddd23a37 100644 --- a/iosApp/iosApp/Downloads/DownloadLiveActivity.swift +++ b/iosApp/iosApp/Downloads/DownloadLiveActivity.swift @@ -41,20 +41,44 @@ final class DownloadLiveActivityController { /// How long the final "complete" card lingers on the lock screen. private static let completedLinger: TimeInterval = 240 + /// `awaitingRegistration` means a download the user started hasn't + /// reached the queue yet, so an empty queue isn't finished. + /// `transferredBytes` totals every active download's bytes, including + /// those of unknown size. func sync( activeRecords: [DownloadRecord], completedRecordIds: Set, - totalBytesPerSecond: Double + totalBytesPerSecond: Double, + awaitingRegistration: Bool, + transferredBytes: Int64, + rates: [String: Double] ) { adoptExistingActivityIfNeeded() sessionCompletedIds.formUnion(trackedActiveIds.intersection(completedRecordIds)) trackedActiveIds.formUnion(activeRecords.map(\.id)) + let continued = DownloadContinuedProcessing.shared guard !activeRecords.isEmpty else { + if !awaitingRegistration { continued.finish(sessionCompletedIds.isEmpty ? .emptied : .completed) } finishActivity() return } let state = makeState(activeRecords: activeRecords, totalBytesPerSecond: totalBytesPerSecond) + // A paused queue makes no progress, and iOS expires a continued + // task that looks stalled; end it on Prairie's terms instead. + if state.phase == .paused { continued.finish(.paused) } + if continued.ownsProgress { + // iOS shows the task's own Live Activity; don't add a second one. + continued.update( + state, + transferredBytes: transferredBytes, + headline: Self.headline(of: activeRecords), + active: activeRecords, + rates: rates + ) + yieldToContinuedProcessing() + return + } guard state != lastState else { return } if let activity { lastState = state @@ -132,6 +156,19 @@ final class DownloadLiveActivityController { } } + /// Ends Prairie's own activity at once while the system shows the continued + /// processing task's. + private func yieldToContinuedProcessing() { + lastState = nil + guard let activity else { return } + self.activity = nil + let previous = activityChain + activityChain = Task { + await previous?.value + await activity.end(nil, dismissalPolicy: .immediate) + } + } + /// A relaunch can find the previous run's activity still on the lock /// screen. Adopt it instead of stacking a duplicate; fold any extras /// (there should only ever be one). @@ -148,15 +185,19 @@ final class DownloadLiveActivityController { // MARK: - State + /// The transfer the user has been waiting on longest: `activeRecords` + /// arrives newest-first. + static func headline(of activeRecords: [DownloadRecord]) -> DownloadRecord? { + activeRecords.reversed().first { + $0.localStatus == .downloading || $0.localStatus == .fetchingAssets + } ?? activeRecords.last + } + private func makeState( activeRecords: [DownloadRecord], totalBytesPerSecond: Double ) -> DownloadActivityAttributes.ContentState { - // `activeRecords` arrives newest-first; headline the transfer the - // user has been waiting on longest. - let headline = activeRecords.reversed().first { - $0.localStatus == .downloading || $0.localStatus == .fetchingAssets - } ?? activeRecords.last + let headline = Self.headline(of: activeRecords) let completedCount = sessionCompletedIds.count let totalCount = completedCount + activeRecords.count diff --git a/iosApp/iosApp/Downloads/DownloadManager.swift b/iosApp/iosApp/Downloads/DownloadManager.swift index 1046a4bd9..f5e320c0a 100644 --- a/iosApp/iosApp/Downloads/DownloadManager.swift +++ b/iosApp/iosApp/Downloads/DownloadManager.swift @@ -1,6 +1,9 @@ import Foundation import Observation import OSLog +#if canImport(UIKit) +import UIKit +#endif enum DownloadError: LocalizedError { case unavailable @@ -52,7 +55,9 @@ final class DownloadManager { category: "Downloads" ) - private static let maxConcurrentTransfers = 3 + /// Records fetching their manifest at once. Running transfers are capped + /// separately, by the Simultaneous Downloads setting. + nonisolated private static let maxConcurrentPipelines = 3 nonisolated private static let maxRetries = 4 /// Bounds one flush at 10,000 queued items (100 per batch). private static let maxProgressBatchesPerFlush = 100 @@ -96,11 +101,20 @@ final class DownloadManager { /// held here and replayed by `releaseHeldSessionEvents()`. private var pendingSessionEvents: [DownloadSessionEvent] = [] private var sessionEventsHeld = true - /// In-flight back-off timers keyed by record id, tracked so a foreground - /// reconcile doesn't re-queue a record that already has a scheduled - /// restart (double-starting the transfer) and so pause/delete can abort - /// the timer instead of leaving it to fire against a dead record. + /// In-flight back-off timers keyed by record id, tracked so pause/delete + /// can abort the timer instead of leaving it to fire against a dead + /// record. `restartOwners` is what keeps a reconcile off their records. private var retryTasks: [String: Task] = [:] + /// The pipelines and retries that may still start a record's transfer. + /// A pipeline that lost ownership (superseded, deleted, or reset by a new + /// revision) stops before it can start a second transfer of the file. + private var restartOwners = DownloadRestartOwners() + #if canImport(UIKit) + /// Keeps the app running while a transfer is on its way to the background + /// session. Without it, iOS suspends the app mid-pipeline and the record + /// waits for the next launch. + private var handoffBackgroundTask: UIBackgroundTaskIdentifier = .invalid + #endif /// Records whose pause is still waiting on the resume-data capture /// round-trip. A resume tapped inside that window is deferred to /// `finishPause` (via `pendingResumeIds`) so the captured data isn't @@ -140,14 +154,32 @@ final class DownloadManager { /// from progress deltas so the UI never needs its own timer competing /// with the `@Observable` update path. private(set) var transferRates: [String: Double] = [:] - private var rateSamples: [String: (bytes: Int64, at: Date)] = [:] - private static let rateSampleInterval: TimeInterval = 0.5 - private static let rateSmoothing = 0.3 + private var rateSamples: [String: TransferRateSample] = [:] + nonisolated private static let rateSampleInterval: TimeInterval = 0.5 + nonisolated private static let rateSmoothing = 0.3 + /// Longer than this between progress callbacks means the app was + /// suspended or the transfer stalled. The bytes of that stretch say + /// nothing about the current speed, so the rate starts over. + nonisolated private static let rateMaxSampleGap: TimeInterval = 3 + /// How long the background session's completion handler waits for this + /// wake's pipelines and retries to hand off their transfers. iOS gives a + /// background wake roughly 30 seconds. + private static let backgroundHandoffTimeout: TimeInterval = 20 + /// Background time left for the system completion handler to run before + /// iOS suspends the app. + private static let backgroundTimeMargin: TimeInterval = 5 /// Last time each record's byte counter was published into the /// `@Observable` `file` blob. Delegate callbacks arrive many times per /// second; UI counters should tick at a readable cadence instead. private var lastProgressPublish: [String: Date] = [:] private static let progressPublishInterval: TimeInterval = 1.0 + /// The most bytes each record's transfers have reached in this process, + /// so a restart from zero isn't mistaken for recovery. + private var progressHighWater: [String: Int64] = [:] + private var staleStagingSwept = false + /// How far past its earlier best a transfer must get before its retries + /// reset. + nonisolated private static let recoveredProgressBytes: Int64 = 8 << 20 /// The one-shot removal of downloads saved by earlier versions. Every /// scope activation waits for it, so nothing reads or writes the store /// before it has run. @@ -177,6 +209,19 @@ final class DownloadManager { self.handleSessionEvent(event) } } + observeConnectivity() + } + + /// Starts the queue again whenever the device regains a network path. + /// Offline, `processQueue` leaves queued downloads waiting rather than + /// spending their retries on requests that can't be sent. + private func observeConnectivity() { + let online = withObservationTracking { + ConnectionMonitor.shared.isDeviceOnline + } onChange: { + Task { @MainActor [weak self] in self?.observeConnectivity() } + } + if online { processQueue() } } // MARK: - Observable surface @@ -216,6 +261,35 @@ final class DownloadManager { } var activeRecords: [DownloadRecord] { records.filter { $0.localStatus.isActive } } + + /// Active records as the In Progress list shows them: what is moving + /// first, then what is about to, then the queue, then paused downloads. + var inProgressRecords: [DownloadRecord] { Self.sortedByActivity(activeRecords) } + + /// Orders active records by how close they are to moving bytes, and in + /// queue order within each group, so a long queue never buries the + /// downloads actually transferring. + nonisolated static func sortedByActivity(_ records: [DownloadRecord]) -> [DownloadRecord] { + func rank(_ record: DownloadRecord) -> Int { + switch record.localStatus { + case .downloading: + if record.taskIdentifier == nil { return 2 } // waiting to restart + return record.bytesDownloaded > 0 ? 0 : 1 + case .fetchingAssets: return 1 + case .registering, .preparing: return 3 + case .queued: return 4 + case .paused: return 5 + case .completed, .failed, .revoked: return 6 + } + } + return records.sorted { + let (a, b) = (rank($0), rank($1)) + if a != b { return a < b } + if $0.registeredAt != $1.registeredAt { return $0.registeredAt < $1.registeredAt } + return $0.id < $1.id + } + } + var subscriptions: [DownloadSubscription] { file.subscriptions } var totalBytesUsed: Int64 { storageBytesUsed } @@ -298,8 +372,33 @@ final class DownloadManager { record.posterFilename.flatMap { absoluteFileURL(for: record, filename: $0) } } - func transferRate(id: String) -> Double? { - transferRates[id] + /// On-disk backdrop, fetched by the same asset pass as the poster; nil + /// when the server's bundle carried none or the file is missing. + func backdropImageURL(for record: DownloadRecord) -> URL? { + existingFileURL(for: record, filename: record.backdropFilename) + } + + /// On-disk title logo, when the server's bundle carried one and the file + /// exists. + func logoImageURL(for record: DownloadRecord) -> URL? { + existingFileURL(for: record, filename: record.logoFilename) + } + + /// Older builds recorded artwork filenames even when the write failed, so + /// a recorded name alone does not prove the file is there. + private func existingFileURL(for record: DownloadRecord, filename: String?) -> URL? { + guard let url = filename.flatMap({ absoluteFileURL(for: record, filename: $0) }), + FileManager.default.fileExists(atPath: url.path) else { return nil } + return url + } + + /// The record's current speed, or nil once its progress callbacks have + /// stopped for longer than `rateMaxSampleGap` (a stalled or not yet + /// started transfer), so the UI never keeps showing an old speed. + func transferRate(id: String, at now: Date = Date()) -> Double? { + guard let sample = rateSamples[id], + now.timeIntervalSince(sample.at) <= Self.rateMaxSampleGap else { return nil } + return transferRates[id] } /// Decode the on-disk offline manifest for a completed download. The file @@ -389,15 +488,9 @@ final class DownloadManager { func deleteDownloads(ids: [String]) { var removedIds: [String] = [] for id in ids { - guard let record = file.records[id] else { continue } - if let taskId = record.taskIdentifier { - intentionalCancels.insert(taskId) - sessionDelegate.cancel(taskId: taskId) - } - retryTasks[id]?.cancel() - retryTasks[id] = nil + guard var record = file.records[id] else { continue } + stopActiveWork(on: &record) file.records.removeValue(forKey: id) - clearTransferRate(recordId: id) removedIds.append(id) } guard !removedIds.isEmpty else { return } @@ -500,6 +593,7 @@ final class DownloadManager { fileServerId = serverId fileProfileId = profileId adoptLegacySessionTasksIfNeeded() + requeueOrphanedRecords() if let released = releasedProgressClaims.removeValue(forKey: Self.progressClaimKey(serverId, profileId)), OfflineProgressQueue.releaseClaims(&file.progressQueue, ids: released) { persist() @@ -510,11 +604,58 @@ final class DownloadManager { scopeLoadProfileId = "" } releaseHeldSessionEvents() + // Downloads re-queued at install start now, even in a background wake. + processQueue() + removeStaleStagingFilesOnce() refreshStorageUsage() await backfillEpisodeMetadataIfNeeded() return true } + /// Once per process, after held events have claimed their staged files. + private func removeStaleStagingFilesOnce() { + guard !staleStagingSwept else { return } + staleStagingSwept = true + Task { @MainActor in + let freed = await Task.detached(priority: .utility) { + DownloadFilePaths.removeStaleStagingFiles(olderThan: 60 * 60) + }.value + if freed > 0 { + Self.logger.notice("Removed \(freed, privacy: .public) bytes of abandoned staged downloads") + } + } + } + + /// A freshly installed store has no pipeline or retry running for any of + /// its records: owners left from the previous scope are dropped, and + /// records a pipeline or retry was working on when the process ended go + /// back in the queue. Without this they would hold nothing but still + /// wait for the next foreground reconcile. + private func requeueOrphanedRecords() { + abandonAllRestarts() + var records = file.records + var changed = false + for (id, record) in records where Self.isOrphaned(record) { + records[id]?.localStatus = .queued + changed = true + } + if changed { + file.records = records + persist() + } + } + + /// Mid-pipeline, or waiting to restart a transfer, with nothing live. + /// A record with a task identifier is left to `reconnectActiveTasks`, + /// which knows whether the task still runs. + nonisolated static func isOrphaned(_ record: DownloadRecord) -> Bool { + switch record.localStatus { + case .fetchingAssets: return true + case .downloading: return record.taskIdentifier == nil + default: return false + } + } + /// Stops the pre-rename background session before any scope can start a /// transfer in the current one. Once per process; the session itself is /// drained once per install. @@ -591,6 +732,7 @@ final class DownloadManager { /// whether the Downloads tab exists should not wait for the rest. func onAppActive(onCapabilityRefreshed: (() -> Void)? = nil) async { guard await activateScopeIfNeeded() else { return } + sessionDelegate.refreshProgressDelivery() await refreshCapability() onCapabilityRefreshed?() guard downloadsEnabled else { return } @@ -608,8 +750,8 @@ final class DownloadManager { private func deactivate() { pollTask?.cancel() pollTask = nil - for task in retryTasks.values { task.cancel() } - retryTasks.removeAll() + abandonAllRestarts() + progressHighWater.removeAll() pendingPauseIds.removeAll() pendingResumeIds.removeAll() invalidatePendingRegistrations() @@ -735,11 +877,15 @@ final class DownloadManager { let capturedScopeGeneration = registrationScopeGeneration pendingRegistrationTokens[registrationContentId] = registrationToken pendingRegistrationContentIds.insert(registrationContentId) + beginContinuedProcessing(title: displayTitle) defer { finishPendingRegistration( contentId: registrationContentId, token: registrationToken ) + // A registration that added nothing leaves the queue as it was; + // let the live progress settle on that. + syncLiveActivity() } guard let owner = await captureScopeOwner(), owner.generation == capturedScopeGeneration else { @@ -916,8 +1062,7 @@ final class DownloadManager { // No live task: the record is waiting out a retry back-off. // Abort the timer and park the record so the pause control isn't // dead during the window; resume re-queues from scratch. - retryTasks[id]?.cancel() - retryTasks[id] = nil + cancelRetry(recordId: id) record.localStatus = .paused file.records[id] = record clearTransferRate(recordId: id) @@ -946,6 +1091,7 @@ final class DownloadManager { /// unreadable, or was never produced. func resumeDownload(id: String) { guard var record = file.records[id], record.localStatus == .paused else { return } + beginContinuedProcessing(title: record.title) // The pause's resume-data capture is still in flight — flag the // intent and let `finishPause` re-queue with the data instead of // discarding the partial transfer. @@ -984,6 +1130,7 @@ final class DownloadManager { func retryDownload(id: String) { guard var record = file.records[id], record.localStatus == .failed else { return } + beginContinuedProcessing(title: record.title) record.localStatus = .queued record.retryCount = 0 record.lastError = nil @@ -995,31 +1142,89 @@ final class DownloadManager { // MARK: - Pipeline private func processQueue() { - let activeCount = file.records.values.filter { - $0.localStatus == .downloading || $0.localStatus == .fetchingAssets + guard ConnectionMonitor.shared.isDeviceOnline else { return } + let transferring = file.records.values.filter { + $0.localStatus == .downloading && $0.taskIdentifier != nil }.count - var slots = max(0, Self.maxConcurrentTransfers - activeCount) - guard slots > 0 else { return } + var slots = Self.queueSlots( + runningPipelines: restartOwners.pipelineCount, + transferring: transferring, + limit: DownloadSettings.shared.simultaneousDownloads + ) + guard slots.pipelines > 0, slots.transfers > 0 else { return } let queued = file.records.values .filter { $0.localStatus == .queued } .sorted { $0.registeredAt < $1.registeredAt } - for record in queued where slots > 0 { - // Reserve the slot synchronously so a second pass doesn't pick + for record in queued where slots.pipelines > 0 && slots.transfers > 0 { + // Reserve the slots synchronously so a second pass doesn't pick // the same record before its async pipeline flips the status. guard !exceedsStorageCap(for: record) else { continue } - slots -= 1 - startQueuedRecord(record) + if startQueuedRecord(record) { slots.pipelines -= 1 } + slots.transfers -= 1 + } + } + + /// How many more queued records may start now: a pipeline slot for the + /// manifest fetch, and room among the `limit` transfers the session runs + /// at once (counting pipelines about to start one). More transfers would + /// split the bandwidth so thinly that every file crawls, and their + /// progress callbacks alone keep the app busy. A retry waiting out its + /// back-off holds neither slot. + nonisolated static func queueSlots( + runningPipelines: Int, transferring: Int, limit: Int + ) -> (pipelines: Int, transfers: Int) { + (max(0, maxConcurrentPipelines - runningPipelines), + max(0, limit - transferring - runningPipelines)) + } + + /// Puts transfers beyond the simultaneous-downloads limit back in the + /// queue, newest first, keeping their resume data; they continue as the + /// others finish. Then starts whatever the limit allows. + func applyTransferLimit() { + let limit = DownloadSettings.shared.simultaneousDownloads + let transferring = file.records.values + .filter { $0.localStatus == .downloading && $0.taskIdentifier != nil } + .sorted { $0.registeredAt < $1.registeredAt } + for record in transferring.dropFirst(limit) { + // A pause whose resume was already requested lands back in the + // queue with its data (`finishPause`). + pendingResumeIds.insert(record.id) + pauseDownload(id: record.id) + } + // A running pipeline starts a transfer when it finishes, so it + // counts against the limit too (`queueSlots`). + let preparing = file.records.values + .filter { $0.localStatus == .fetchingAssets && restartOwners.hasPipeline($0.id) } + .sorted { $0.registeredAt < $1.registeredAt } + for record in preparing.dropFirst(max(0, limit - transferring.count)) { + abandonPipeline(recordId: record.id) + setLocalStatus(.queued, id: record.id) } + processQueue() } /// Start one queued record, preferring its captured resume data (a /// paused transfer) so completed byte ranges aren't refetched; missing /// or unreadable data, or data for a retired file URL, falls back to the /// full pipeline restart. - private func startQueuedRecord(_ record: DownloadRecord) { + /// Returns whether the record took a pipeline slot; a resume from + /// captured data goes straight to the session. + private func startQueuedRecord(_ record: DownloadRecord) -> Bool { var record = record + // Whatever was working on this record before is superseded. + abandonPipeline(recordId: record.id) + if let staleTask = record.taskIdentifier { + // A task still recorded for a queued record is stale. Stop it + // before starting the replacement so the two never both write. + // Its failure event then matches no record. The ID isn't marked + // as an intentional cancel: after a relaunch it can belong to + // another record's task, whose real failure must still count. + sessionDelegate.cancel(taskId: staleTask, ifDownloading: record.id) + record.taskIdentifier = nil + file.records[record.id] = record + } if let filename = record.resumeDataFilename, let url = absoluteFileURL(for: record, filename: filename) { let resumeData = try? Data(contentsOf: url) @@ -1030,55 +1235,95 @@ final class DownloadManager { record.localStatus = .downloading file.records[record.id] = record persist() - return + return false } record.bytesDownloaded = 0 file.records[record.id] = record } setLocalStatus(.fetchingAssets, id: record.id) - Task { await self.startMediaPipeline(recordId: record.id) } + let token = claimPipeline(recordId: record.id) + Task { await self.startMediaPipeline(recordId: record.id, token: token) } + return true } - /// Fetches the manifest and its assets, then starts the file transfer, - /// all under the scope owner captured here. Once that scope is gone, - /// nothing a request returns is applied: the record belongs to a store - /// that is no longer loaded. - private func startMediaPipeline(recordId: String) async { - guard file.records[recordId] != nil else { return } + /// Fetches the manifest and starts the file transfer, under the scope + /// owner captured here. Once that scope is gone, nothing a request + /// returns is applied: the record belongs to a store that is no longer + /// loaded. The pipeline runs only while it holds the record's `token`, + /// and releases it (and its queue slot) once the transfer starts; the + /// artwork and subtitles follow under their own claim. + private func startMediaPipeline(recordId: String, token: UUID) async { + var parked = false + defer { + releasePipeline(recordId: recordId, token: token) + // A pipeline that parked its own record (no usable session, or + // no network) leaves it for the next activation or reconnect: + // starting the queue now would pick the same record right back up. + if !parked { processQueue() } + } + guard ownsPipeline(recordId, token), file.records[recordId] != nil else { return } guard let owner = await captureScopeOwner() else { // No usable session for this scope right now. Park the record; // the next queue pass or reconcile starts it again. - if file.records[recordId]?.localStatus == .fetchingAssets { setLocalStatus(.queued, id: recordId) } + if ownsPipeline(recordId, token), file.records[recordId]?.localStatus == .fetchingAssets { + setLocalStatus(.queued, id: recordId) + parked = true + } return } + let manifest: OfflineManifest do { - let manifest = try await PrairieAPI.shared.apiV2Client.downloadManifest(id: recordId, auth: owner.auth) - guard isCurrent(owner) else { return } - await persistManifest(manifest, recordId: recordId) - guard isCurrent(owner) else { return } - applyManifestDisplay(manifest, recordId: recordId) - await fetchArtwork(manifest, recordId: recordId, owner: owner) - await fetchSubtitles(manifest, recordId: recordId, owner: owner) - guard isCurrent(owner) else { return } - await startMediaTransfer(recordId: recordId, owner: owner) + manifest = try await PrairieAPI.shared.apiV2Client.downloadManifest(id: recordId, auth: owner.auth) } catch { - guard isCurrent(owner) else { return } - handlePipelineError(error, recordId: recordId) + // A reconcile may have failed or revoked the record meanwhile. + guard pipelineIsCurrent(recordId, token, owner), + file.records[recordId]?.localStatus == .fetchingAssets else { return } + parked = handlePipelineError(error, recordId: recordId) + return } - } - - private func startMediaTransfer(recordId: String, owner: ScopeOwner) async { + guard pipelineIsCurrent(recordId, token, owner) else { return } + await persistManifest(manifest, recordId: recordId, token: token) + guard pipelineIsCurrent(recordId, token, owner) else { return } + applyManifestDisplay(manifest, recordId: recordId) + // The media transfer first, so bytes start moving at once; artwork + // and subtitles are small and follow while it runs. + switch await startMediaTransfer(recordId: recordId, owner: owner, token: token) { + case .parked: + parked = true + case .stopped: + break + case .started: + // Claimed before the pipeline lets go, so a retry or a resume from + // saved data can't cut the assets off, and a fast transfer that + // finishes first still gets them. + let assets = restartOwners.claimAssets(recordId) + updateHandoffBackgroundTask() + Task { await self.fetchAssets(manifest, recordId: recordId, owner: owner, assets: assets) } + } + } + + /// How a pipeline's attempt to start the file transfer ended. + private enum TransferStart { + case started + /// Back in the queue until a session or the network returns. + case parked + /// Superseded, failed, or retrying. + case stopped + } + + private func startMediaTransfer(recordId: String, owner: ScopeOwner, token: UUID) async -> TransferStart { // The owner's current credentials: a token rotated since the capture // is used, a different owner is not. let auth = await TokenStore.shared.currentOrdinaryRequestAuth(matchingIdentityOf: owner.auth) - guard isCurrent(owner), var record = file.records[recordId] else { return } + // Only the record's own pipeline, and only while the record still + // waits for it: a reconcile may have revoked or failed it meanwhile. + guard pipelineIsCurrent(recordId, token, owner), var record = file.records[recordId], + record.localStatus == .fetchingAssets, record.taskIdentifier == nil else { return .stopped } guard let auth else { - handlePipelineError(HTTPError.requestIdentityChanged, recordId: recordId) - return + return handlePipelineError(HTTPError.requestIdentityChanged, recordId: recordId) ? .parked : .stopped } guard let fileURL = APIv2Client.downloadFileURL(id: recordId, serverURL: auth.account.serverURL) else { - handlePipelineError(DownloadError.fileURLUnavailable, recordId: recordId) - return + return handlePipelineError(DownloadError.fileURLUnavailable, recordId: recordId) ? .parked : .stopped } let request = DownloadAuthHeaders.authorizedRequest( url: fileURL, @@ -1092,11 +1337,14 @@ final class DownloadManager { file.records[recordId] = record persist() reportPendingStatusEvents() + return .started } - private func persistManifest(_ manifest: OfflineManifest, recordId: String) async { + private func persistManifest(_ manifest: OfflineManifest, recordId: String, token: UUID) async { guard let url = absoluteFileURLForNewAsset(recordId: recordId, filename: "manifest.json") else { return } await DownloadStore.shared.saveManifest(manifest, to: url) + // A delete or new revision during the save owns the record now. + guard ownsPipeline(recordId, token) else { return } if var record = file.records[recordId] { record.manifestFilename = "manifest.json" file.records[recordId] = record @@ -1129,11 +1377,27 @@ final class DownloadManager { } } if record.fileSize <= 0, let size = manifest.fileSize { record.fileSize = size } + record.expectedBytes = manifest.integrity?.expectedBytes file.records[recordId] = record persist() } - private func fetchArtwork(_ manifest: OfflineManifest, recordId: String, owner: ScopeOwner) async { + /// Saves the manifest's artwork and subtitles while the transfer runs. + /// A delete, revoke, new revision, or scope change stops it saving. + private func fetchAssets(_ manifest: OfflineManifest, recordId: String, owner: ScopeOwner, assets: UUID) async { + defer { + restartOwners.releaseAssets(recordId, assets) + updateHandoffBackgroundTask() + } + await fetchArtwork(manifest, recordId: recordId, owner: owner, assets: assets) + await fetchSubtitles(manifest, recordId: recordId, owner: owner, assets: assets) + } + + private func assetsAreCurrent(_ recordId: String, _ assets: UUID, _ owner: ScopeOwner) -> Bool { + restartOwners.ownsAssets(recordId, assets) && isCurrent(owner) + } + + private func fetchArtwork(_ manifest: OfflineManifest, recordId: String, owner: ScopeOwner, assets: UUID) async { let kinds: [(kind: String, path: String?, filename: String)] = [ ("poster", manifest.artworkUrls?.poster, "poster.jpg"), ("backdrop", manifest.artworkUrls?.backdrop, "backdrop.jpg"), @@ -1152,12 +1416,19 @@ final class DownloadManager { Self.logger.warning("download artwork fetch failed: \(String(describing: error), privacy: .public)") continue } - guard isCurrent(owner) else { return } + guard assetsAreCurrent(recordId, assets, owner) else { return } guard !data.isEmpty, let url = absoluteFileURLForNewAsset(recordId: recordId, filename: entry.filename) else { continue } - try? data.write(to: url, options: .atomic) + // Record the file only once it is on disk: the offline detail page + // shows a recorded logo in place of the title text. + do { + try data.write(to: url, options: .atomic) + } catch { + Self.logger.warning("download artwork write failed") + continue + } guard var record = file.records[recordId] else { continue } switch entry.kind { case "poster": record.posterFilename = entry.filename @@ -1170,7 +1441,7 @@ final class DownloadManager { persist() } - private func fetchSubtitles(_ manifest: OfflineManifest, recordId: String, owner: ScopeOwner) async { + private func fetchSubtitles(_ manifest: OfflineManifest, recordId: String, owner: ScopeOwner, assets: UUID) async { guard let subtitles = manifest.subtitles, !subtitles.isEmpty else { return } for (index, subtitle) in subtitles.enumerated() { let ext = (subtitle.format ?? "srt").lowercased() @@ -1183,7 +1454,7 @@ final class DownloadManager { Self.logger.warning("download subtitle fetch failed: \(String(describing: error), privacy: .public)") continue } - guard isCurrent(owner) else { return } + guard assetsAreCurrent(recordId, assets, owner) else { return } guard !data.isEmpty, let url = absoluteFileURLForNewAsset(recordId: recordId, filename: filename) else { continue @@ -1196,8 +1467,10 @@ final class DownloadManager { persist() } - private func handlePipelineError(_ error: Error, recordId: String) { - guard var record = file.records[recordId] else { return } + /// Returns whether the record was parked back in the queue to wait for + /// a usable session or the network, rather than retried or failed. + private func handlePipelineError(_ error: Error, recordId: String) -> Bool { + guard var record = file.records[recordId] else { return false } record.taskIdentifier = nil if case HTTPError.requestIdentityChanged = error { // The session changed under the request; nothing was applied. @@ -1205,7 +1478,7 @@ final class DownloadManager { record.localStatus = .queued file.records[recordId] = record persist() - return + return true } if let statusCode = Self.pipelineStatus(error) { switch statusCode { @@ -1225,7 +1498,7 @@ final class DownloadManager { if record.retryCount < Self.maxRetries { record.retryCount += 1 file.records[recordId] = record - scheduleRetry(recordId: recordId, resumeData: nil, refreshToken: false) + scheduleRetry(recordId: recordId, refreshToken: false) } else { record.localStatus = .failed record.lastError = "http_\(statusCode)" @@ -1233,11 +1506,25 @@ final class DownloadManager { persist() processQueue() } - return + return false default: record.localStatus = .failed record.lastError = "http_\(statusCode)" } + } else if Self.isTransientPipelineFailure(error), !ConnectionMonitor.shared.isDeviceOnline { + // No network: wait in the queue without spending a retry. The + // queue starts again when the device is back online. + record.localStatus = .queued + file.records[recordId] = record + persist() + return true + } else if Self.isTransientPipelineFailure(error), record.retryCount < Self.maxRetries { + // Same bounded back-off as a 5xx: the request never got an + // answer, often because iOS suspended the app mid-request. + record.retryCount += 1 + file.records[recordId] = record + scheduleRetry(recordId: recordId, refreshToken: false) + return false } else { record.localStatus = .failed record.lastError = error.localizedDescription @@ -1248,6 +1535,7 @@ final class DownloadManager { notifyTerminalFailure(record) } processQueue() + return false } /// The HTTP status of a failed manifest request, or nil when it never got @@ -1261,6 +1549,29 @@ final class DownloadManager { } } + /// Past the furthest point any earlier attempt reached, a transfer has + /// recovered, so its earlier failures stop counting against the retry + /// limit. A restart from zero doesn't qualify until it gets further than + /// before. While retries are counted, that point stays where the failed + /// attempt left it; otherwise it follows the transfer. + nonisolated static func recoveryProgress( + retryCount: Int, furthest: Int64, written: Int64 + ) -> (retryCount: Int, furthest: Int64) { + if retryCount > 0, written <= furthest + recoveredProgressBytes { + return (retryCount, furthest) + } + return (0, max(furthest, written)) + } + + /// Whether a manifest request failed in transport (the connection dropped, + /// timed out, or was never made) rather than being answered or cancelled. + nonisolated static func isTransientPipelineFailure(_ error: Error) -> Bool { + var transport = error + if case HTTPError.network(let underlying) = error { transport = underlying } + guard let urlError = transport as? URLError else { return false } + return urlError.code != .cancelled + } + /// Mirror the active queue into the lock-screen Live Activity. Hooked /// into `file`'s `didSet` so every mutation flows through — including /// scope deactivation (empty blob ends the activity). The controller @@ -1273,14 +1584,111 @@ final class DownloadManager { .filter { $0.localStatus == .completed } .map(\.id) ) + let active = activeRecords + // Stalled transfers have no rate, so they add nothing. + let rates = active.reduce(into: [String: Double]()) { result, record in + if let rate = transferRate(id: record.id) { result[record.id] = rate } + } DownloadLiveActivityController.shared.sync( - activeRecords: activeRecords, + activeRecords: active, completedRecordIds: completedIds, - totalBytesPerSecond: transferRates.values.reduce(0, +) + totalBytesPerSecond: active.compactMap { rates[$0.id] }.reduce(0, +), + awaitingRegistration: !pendingRegistrationContentIds.isEmpty, + // Downloads of unknown size move too. + transferredBytes: active.reduce(Int64(0)) { $0 + $1.bytesDownloaded }, + rates: rates ) #endif } + /// Re-evaluates the live progress display after it changed hands. + func refreshLiveProgress() { + syncLiveActivity() + } + + /// What the active downloads wait for right now, if anything, in the + /// system progress. + func currentWaitingReason() -> String? { + if let wait = networkWait() { return wait.label } + if activeRecords.contains(where: { $0.localStatus == .preparing || $0.localStatus == .registering }) { + return "Preparing on server" + } + return nil + } + + #if os(iOS) + /// Whether the Downloads tab should offer to show live progress on the + /// Lock Screen: transfers are running and nothing shows them there. + var canShowProgressOnLockScreen: Bool { + guard #available(iOS 26, *) else { return false } + return !DownloadContinuedProcessing.shared.ownsProgress + && activeRecords.contains { $0.localStatus == .downloading } + } + + /// Starts the system's live progress for the running downloads. Call + /// only from the user's tap. + func showProgressOnLockScreen() { + let headline = DownloadLiveActivityController.headline(of: activeRecords) + DownloadContinuedProcessing.shared.begin(title: headline?.title ?? "Downloads") + syncLiveActivity() + } + #endif + + /// Prairie is on screen: progress the user hid from the Lock Screen comes + /// back as Prairie's own Live Activity, which only the foreground can start. + func sceneDidBecomeActive() { + #if os(iOS) + DownloadContinuedProcessing.shared.clearDismissal() + syncLiveActivity() + #endif + } + + /// Why a record isn't moving, when that is something the user can see + /// and act on. + enum Wait: Equatable { + /// The device has no network. + case connection + /// Wi-Fi only is on and the device isn't on Wi-Fi. + case wifi + /// The series' storage limit holds this download back. + case storageLimit + + var label: String { + switch self { + case .connection: return "Waiting for a connection" + case .wifi: return "Waiting for Wi-Fi" + case .storageLimit: return "Series storage limit reached" + } + } + } + + func wait(for record: DownloadRecord) -> Wait? { + switch record.localStatus { + case .queued: + if exceedsStorageCap(for: record) { return .storageLimit } + return networkWait() + case .downloading, .fetchingAssets: + return networkWait() + default: + return nil + } + } + + private func networkWait() -> Wait? { + let monitor = ConnectionMonitor.shared + if !monitor.isDeviceOnline { return .connection } + if DownloadSettings.shared.wifiOnly, !monitor.isOnWiFiOrWired { return .wifi } + return nil + } + + /// Keeps Prairie running with live system progress for a download the user + /// just started (iOS 26 and later). Call only from the user's action. + private func beginContinuedProcessing(title: String?) { + #if os(iOS) + DownloadContinuedProcessing.shared.begin(title: title ?? "Downloads") + #endif + } + /// Notify only for transfer/pipeline failures the user would otherwise /// discover much later. Reconcile-driven failures (rows revoked or /// removed server-side) stay silent — they can arrive in bulk during a @@ -1304,9 +1712,9 @@ final class DownloadManager { return } switch event { - case let .progress(taskId, written, total): + case let .progress(taskId, written, total, at): guard var record = recordByTask(taskId) else { return } - updateTransferRate(recordId: record.id, bytes: written) + updateTransferRate(recordId: record.id, bytes: written, at: at) // Publish to the observable blob at a readable cadence — the raw // callbacks fire many times per second and each reassignment // redraws every byte counter "live". Skipped ticks lose nothing: @@ -1315,6 +1723,13 @@ final class DownloadManager { guard now.timeIntervalSince(lastProgressPublish[record.id] ?? .distantPast) >= Self.progressPublishInterval else { return } lastProgressPublish[record.id] = now + let recovery = Self.recoveryProgress( + retryCount: record.retryCount, + furthest: progressHighWater[record.id] ?? record.bytesDownloaded, + written: written + ) + record.retryCount = recovery.retryCount + progressHighWater[record.id] = recovery.furthest record.bytesDownloaded = written if total > 0 { record.fileSize = total } file.records[record.id] = record @@ -1323,19 +1738,22 @@ final class DownloadManager { case let .finished(taskId, stagedURL, _): handleMediaFinished(taskId: taskId, stagedURL: stagedURL) - case let .failed(taskId, statusCode, resumeData, message): - handleMediaFailure(taskId: taskId, statusCode: statusCode, resumeData: resumeData, message: message) + case let .failed(taskId, statusCode, resumeData, message, cause): + handleMediaFailure(taskId: taskId, statusCode: statusCode, resumeData: resumeData, message: message, + cause: cause) case .allEventsDelivered: - // Flush queued store writes before handing control back — iOS - // can suspend the process as soon as the completion handler - // runs, and the `.finished`/`.failed` records handled above are - // still on the async save chain. + // iOS can suspend the process as soon as the completion handler + // runs. First let the pipelines and imminent retries this wake + // started (the next queued downloads) hand their transfers to the + // session, or they stop mid-pipeline until the app is next + // opened. Then flush the store writes still on the async save + // chain. guard let handler = sessionDelegate.backgroundCompletionHandler else { return } sessionDelegate.backgroundCompletionHandler = nil - let pendingSave = saveChain Task { @MainActor in - await pendingSave?.value + await self.waitForTransferHandoff(timeout: Self.backgroundHandoffTimeout) + await self.saveChain?.value handler() } } @@ -1347,8 +1765,10 @@ final class DownloadManager { private func releaseHeldSessionEvents() { guard sessionEventsHeld else { return } sessionEventsHeld = false - while !pendingSessionEvents.isEmpty { - handleSessionEvent(pendingSessionEvents.removeFirst()) + let held = pendingSessionEvents + pendingSessionEvents = [] + for event in held { + handleSessionEvent(event) } } @@ -1359,6 +1779,31 @@ final class DownloadManager { return } clearTransferRate(recordId: record.id) + if let expected = record.expectedBytes, expected > 0 { + let actual = fileSizeOnDisk(stagedURL) + // Only the manifest vouches for the file. A source the server + // replaced gets a new revision, and the retry below fetches the + // manifest again before the next transfer. + if actual != expected { + Self.logger.error("Finished download is \(actual, privacy: .public) bytes, expected \(expected, privacy: .public); downloading again") + try? FileManager.default.removeItem(at: stagedURL) + record.taskIdentifier = nil + record.bytesDownloaded = 0 + if record.retryCount < Self.maxRetries { + record.retryCount += 1 + file.records[record.id] = record + scheduleRetry(recordId: record.id, refreshToken: false) + } else { + record.localStatus = .failed + record.lastError = "size_mismatch" + file.records[record.id] = record + persist() + notifyTerminalFailure(record) + processQueue() + } + return + } + } let ext = mediaExtension(for: record) let filename = "media.\(ext)" guard let destination = absoluteFileURLForNewAsset(recordId: record.id, filename: filename) else { @@ -1370,14 +1815,16 @@ final class DownloadManager { try FileManager.default.moveItem(at: stagedURL, to: destination) } catch { Self.logger.error("Failed to move finished media: \(String(describing: error), privacy: .private)") + try? FileManager.default.removeItem(at: stagedURL) record.localStatus = .failed - record.lastError = "move_failed" + record.lastError = DownloadSessionDelegate.isOutOfSpace(error) ? "storage_full" : "move_failed" record.taskIdentifier = nil file.records[record.id] = record persist() processQueue() return } + progressHighWater[record.id] = nil record.mediaFilename = filename record.localStatus = .completed record.downloadedAt = Date() @@ -1400,13 +1847,31 @@ final class DownloadManager { // watched episode right after it reads the watch state in full. } - private func handleMediaFailure(taskId: Int, statusCode: Int?, resumeData: Data?, message: String) { + private func handleMediaFailure( + taskId: Int, statusCode: Int?, resumeData: Data?, message: String, cause: DownloadFailureCause + ) { if intentionalCancels.remove(taskId) != nil { return } guard var record = recordByTask(taskId) else { return } record.taskIdentifier = nil clearTransferRate(recordId: record.id) - switch Self.mediaFailureAction(statusCode: statusCode, retryCount: record.retryCount, message: message) { + let action = Self.mediaFailureAction(statusCode: statusCode, retryCount: record.retryCount, message: message, + cause: cause) + // Kept on disk rather than in memory, so a pause, a scope switch, or + // the process ending before the retry doesn't restart from zero. + let keepsPartialFile: Bool + switch action { + case let .retry(keepResumeData, _): keepsPartialFile = keepResumeData + case .fail: keepsPartialFile = cause == .storageFull + case .revoke: keepsPartialFile = false + } + if keepsPartialFile, let resumeData, + let url = absoluteFileURLForNewAsset(recordId: record.id, filename: "resume.bin"), + (try? resumeData.write(to: url, options: .atomic)) != nil { + record.resumeDataFilename = "resume.bin" + } + + switch action { case .revoke: record.localStatus = .revoked record.serverStatus = "revoked" @@ -1421,11 +1886,19 @@ final class DownloadManager { notifyTerminalFailure(record) processQueue() case let .retry(keepResumeData, refreshToken): - record.retryCount += 1 if !keepResumeData { record.bytesDownloaded = 0 } + if cause == .forceQuit { + // Closing Prairie from the app switcher cancels every transfer; + // that isn't the download failing. Resume it now. + record.localStatus = .queued + file.records[record.id] = record + persist() + processQueue() + return + } + record.retryCount += 1 file.records[record.id] = record - scheduleRetry(recordId: record.id, resumeData: keepResumeData ? resumeData : nil, - refreshToken: refreshToken) + scheduleRetry(recordId: record.id, refreshToken: refreshToken) } } @@ -1438,8 +1911,16 @@ final class DownloadManager { case retry(keepResumeData: Bool, refreshToken: Bool) } - /// Every retry is bounded by `maxRetries`, after which the record fails. - nonisolated static func mediaFailureAction(statusCode: Int?, retryCount: Int, message: String) -> MediaFailureAction { + /// Every retry is bounded by `maxRetries`, after which the record fails, + /// except a force-quit cancellation, which resumes without counting. + nonisolated static func mediaFailureAction( + statusCode: Int?, retryCount: Int, message: String, cause: DownloadFailureCause = .other + ) -> MediaFailureAction { + switch cause { + case .forceQuit: return .retry(keepResumeData: true, refreshToken: false) + case .storageFull: return .fail("storage_full") + case .other: break + } let canRetry = retryCount < maxRetries switch statusCode { case 409: @@ -1462,41 +1943,162 @@ final class DownloadManager { } } - private func scheduleRetry(recordId: String, resumeData: Data?, refreshToken: Bool) { + private func scheduleRetry(recordId: String, refreshToken: Bool) { + // The caller's retry count survives the process ending in the back-off. + persist() let attempt = file.records[recordId]?.retryCount ?? 1 let delaySeconds = min(120, Int(pow(2.0, Double(attempt))) * 5) retryTasks[recordId]?.cancel() + restartOwners.retryScheduled(recordId, firesAt: Date().addingTimeInterval(TimeInterval(delaySeconds))) + updateHandoffBackgroundTask() retryTasks[recordId] = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000) guard !Task.isCancelled, let self, !self.scopeServerId.isEmpty else { return } self.retryTasks[recordId] = nil + self.restartOwners.retryEnded(recordId) // Fire only while the record still looks like the failure this - // retry was scheduled for — a pause, delete, revoke, or re-queue - // that landed during the back-off owns the record now, and - // restarting on top of it would run two transfers of one file. - guard let record = self.file.records[recordId], - record.taskIdentifier == nil, - record.localStatus == .downloading || record.localStatus == .fetchingAssets else { return } + // retry was scheduled for — a pause, delete, revoke, re-queue, or + // running pipeline owns the record now, and restarting on top of + // it would run two transfers of one file. + guard self.awaitsRestart(recordId), !self.restartOwners.hasPipeline(recordId) else { + self.updateHandoffBackgroundTask() + return + } + // The restart owns the record from here, so a reconcile during + // the token refresh below can't start a second transfer. + let token = self.claimPipeline(recordId: recordId) if refreshToken { // Any authenticated v2 read runs HTTPClient's single-flight // 401 refresh, so the next background request carries a // fresh token. await self.refreshCapability() + guard self.ownsPipeline(recordId, token), self.awaitsRestart(recordId) else { + self.releasePipeline(recordId: recordId, token: token) + return + } } - if let resumeData, let taskId = self.sessionDelegate.resume(data: resumeData) { - guard var rec = self.file.records[recordId] else { return } - rec.taskIdentifier = taskId - rec.localStatus = .downloading - self.file.records[recordId] = rec - self.persist() - } else { - // Restart from the manifest step — a pipeline failure may have - // been in the manifest/asset fetch, not the media transfer. - self.setLocalStatus(.fetchingAssets, id: recordId) - await self.startMediaPipeline(recordId: recordId) + // Restart from the manifest step (a pipeline failure may have + // been in the manifest/asset fetch, not the media transfer), + // through the queue so restarts share the pipeline cap. Resume + // data saved for the record is used from there. + self.releasePipeline(recordId: recordId, token: token) + self.setLocalStatus(.queued, id: recordId) + self.processQueue() + } + } + + /// Stops the transfer, pipeline, and retry working on a record being + /// deleted or that the server no longer wants downloaded, so none of + /// them brings it back. + private func stopActiveWork(on record: inout DownloadRecord) { + if let taskId = record.taskIdentifier { + // Only this record's task: a stale ID can name another record's + // transfer after a relaunch. With the ID cleared, the cancelled + // task's failure event matches no record. + sessionDelegate.cancel(taskId: taskId, ifDownloading: record.id) + record.taskIdentifier = nil + } + abandonPipeline(recordId: record.id) + restartOwners.abandonAssets(record.id) + cancelRetry(recordId: record.id) + clearTransferRate(recordId: record.id) + progressHighWater[record.id] = nil + } + + /// Whether a record is still the failed transfer a retry was scheduled + /// for: nothing is transferring it, and it wasn't paused, deleted, + /// revoked, or re-queued since. + private func awaitsRestart(_ recordId: String) -> Bool { + guard let record = file.records[recordId], record.taskIdentifier == nil else { return false } + return record.localStatus == .downloading || record.localStatus == .fetchingAssets + } + + private func cancelRetry(recordId: String) { + retryTasks.removeValue(forKey: recordId)?.cancel() + restartOwners.retryEnded(recordId) + updateHandoffBackgroundTask() + } + + /// Cancels every scheduled retry and takes every record away from its + /// pipeline and asset fetch. + private func abandonAllRestarts() { + for task in retryTasks.values { task.cancel() } + retryTasks.removeAll() + restartOwners.removeAll() + updateHandoffBackgroundTask() + } + + // MARK: - Transfer handoff + + private func claimPipeline(recordId: String) -> UUID { + let token = restartOwners.claimPipeline(recordId) + updateHandoffBackgroundTask() + return token + } + + private func ownsPipeline(_ recordId: String, _ token: UUID) -> Bool { + restartOwners.ownsPipeline(recordId, token) + } + + private func pipelineIsCurrent(_ recordId: String, _ token: UUID, _ owner: ScopeOwner) -> Bool { + ownsPipeline(recordId, token) && isCurrent(owner) + } + + private func releasePipeline(recordId: String, token: UUID) { + restartOwners.releasePipeline(recordId, token) + updateHandoffBackgroundTask() + } + + /// Takes the record away from its running pipeline, which stops at its + /// next check without starting a transfer. + private func abandonPipeline(recordId: String) { + restartOwners.abandonPipeline(recordId) + updateHandoffBackgroundTask() + } + + /// Waits, at most `timeout` and never into the last of the app's + /// background time, until no pipeline or imminent retry is still working + /// toward its transfer and no asset fetch is still running. + private func waitForTransferHandoff(timeout: TimeInterval) async { + let deadline = Date().addingTimeInterval(timeout) + while restartOwners.handoffPending(by: deadline), Date() < deadline, hasBackgroundTimeLeft() { + try? await Task.sleep(nanoseconds: 250_000_000) + } + } + + private func hasBackgroundTimeLeft() -> Bool { + #if canImport(UIKit) + // Effectively unlimited while the app is in the foreground. + return UIApplication.shared.backgroundTimeRemaining > Self.backgroundTimeMargin + #else + return true + #endif + } + + /// Holds one background task while a transfer is on its way to the + /// session or an asset fetch runs, ended when none is or when iOS + /// reclaims the time. + private func updateHandoffBackgroundTask() { + #if canImport(UIKit) + let deadline = Date().addingTimeInterval(Self.backgroundHandoffTimeout) + if !restartOwners.handoffPending(by: deadline) { + endHandoffBackgroundTask() + } else if handoffBackgroundTask == .invalid { + handoffBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PrairieDownloadHandoff") { + // iOS calls this on the main thread. + MainActor.assumeIsolated { DownloadManager.shared.endHandoffBackgroundTask() } } } + #endif + } + + #if canImport(UIKit) + private func endHandoffBackgroundTask() { + guard handoffBackgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(handoffBackgroundTask) + handoffBackgroundTask = .invalid } + #endif // MARK: - Polling (preparing → ready) @@ -1536,7 +2138,10 @@ final class DownloadManager { let rows = listed.filter { !pendingDeletes.contains($0.id) } let byId = Dictionary(rows.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - for (id, original) in file.records { + // One write for the whole pass: every write to `file` rebuilds indexes + // and the Live Activity, which is costly per record in a large store. + var records = file.records + for (id, original) in records { if let row = byId[id] { var record = mergeExistingRecord(original, with: row) switch row.status { @@ -1548,24 +2153,28 @@ final class DownloadManager { if record.localStatus == .completed { record.localStatus = .revoked } else if record.localStatus.isActive { + stopActiveWork(on: &record) record.localStatus = .revoked } case "failed": if record.localStatus != .completed { + if record.localStatus.isActive { stopActiveWork(on: &record) } record.localStatus = .failed record.lastError = "server_failed" } default: break } - file.records[id] = record + records[id] = record } else if original.localStatus.isActive { var record = original + stopActiveWork(on: &record) record.localStatus = .failed record.lastError = "removed_on_server" - file.records[id] = record + records[id] = record } } + file.records = records // Pick up rows registered out-of-band (e.g. subscription sync). var legacyRowIds: [String] = [] @@ -1592,7 +2201,7 @@ final class DownloadManager { await reconnectActiveTasks() if triggerPipeline { - processQueue() + applyTransferLimit() ensurePolling() } } @@ -1806,44 +2415,71 @@ final class DownloadManager { /// cancelled and its download re-queued, so it restarts from a fresh /// manifest instead of ending in a 410. private func reconnectActiveTasks() async { + // A transfer started while the live-task read is in flight may be + // missing from it; only identifiers held before the read are judged. + let taskIdsBeforeRead = file.records.compactMapValues(\.taskIdentifier) let (active, retired) = await sessionDelegate.liveTasks() for taskId in retired { intentionalCancels.insert(taskId) sessionDelegate.cancel(taskId: taskId) } for (id, record) in file.records { - var record = record - // Task identifiers are only unique within one URLSession - // instance — a recreated session hands the same small integers - // to new tasks, so a persisted id with no live task must be - // dropped before it can match (and misroute) another record's - // transfer. Pause round-trips keep theirs: the cancelled task - // may still deliver a final event that must find this record. - if let taskId = record.taskIdentifier, - !active.contains(taskId), - !pendingPauseIds.contains(id) { + let plan = Self.reconnectPlan( + status: record.localStatus, + taskIdentifier: record.taskIdentifier, + taskIdentifierBeforeRead: taskIdsBeforeRead[id], + liveTaskIds: active, + pausing: pendingPauseIds.contains(id), + restartOwned: restartOwners.ownsRestart(id) + ) + if plan.dropTask { + var record = record record.taskIdentifier = nil file.records[id] = record } - // Records with a live back-off timer are owned by the retry; - // re-queuing them here would double-start the transfer when it - // fires. - guard retryTasks[id] == nil else { continue } - guard record.localStatus == .downloading || record.localStatus == .fetchingAssets else { - continue + if plan.requeue { + setLocalStatus(.queued, id: id) } - // `.fetchingAssets` records were mid-pipeline in a detached Task - // that did not survive the relaunch; re-queue them too so they - // aren't wedged (and don't keep occupying a concurrency slot - // forever). - if record.localStatus == .downloading, - let taskId = record.taskIdentifier, active.contains(taskId) { - continue - } - setLocalStatus(.queued, id: id) } } + /// What reconnect does with one record. + struct ReconnectPlan: Equatable { + /// Forget the record's task identifier. + var dropTask: Bool + /// Put the record back in the queue to start its transfer again. + var requeue: Bool + } + + /// Task identifiers are only unique within one URLSession instance — a + /// recreated session hands the same small integers to new tasks — so an + /// identifier with no live task is dropped before it can match (and + /// misroute) another record's transfer. Pause round-trips keep theirs: + /// the cancelled task may still deliver a final event for this record. + /// + /// A downloading record without a live task, or a `.fetchingAssets` one + /// whose pipeline didn't survive a relaunch, is re-queued. A record whose + /// retry or pipeline is still running in this process is left to it: + /// re-queuing would start a second transfer of the same file. + nonisolated static func reconnectPlan( + status: LocalDownloadStatus, + taskIdentifier: Int?, + taskIdentifierBeforeRead: Int?, + liveTaskIds: Set, + pausing: Bool, + restartOwned: Bool + ) -> ReconnectPlan { + if let taskIdentifier, taskIdentifier != taskIdentifierBeforeRead { + // Started after the read, so the read says nothing about it. + return ReconnectPlan(dropTask: false, requeue: false) + } + let taskIsLive = taskIdentifier.map(liveTaskIds.contains) ?? false + let dropTask = taskIdentifier != nil && !taskIsLive && !pausing + let requeue = !restartOwned + && (status == .fetchingAssets || (status == .downloading && !taskIsLive)) + return ReconnectPlan(dropTask: dropTask, requeue: requeue) + } + // MARK: - Series monitoring /// Starts monitoring a series, then registers the episodes it puts in @@ -2583,6 +3219,11 @@ final class DownloadManager { intentionalCancels.insert(taskId) sessionDelegate.cancel(taskId: taskId) } + // A running pipeline would start the replaced revision's transfer, + // and an asset fetch would save the replaced revision's files. + abandonPipeline(recordId: record.id) + restartOwners.abandonAssets(record.id) + progressHighWater[record.id] = nil guard !scopeServerId.isEmpty else { return } DownloadFilePaths.removeDownloadDirectory( serverId: scopeServerId, @@ -2809,31 +3450,44 @@ final class DownloadManager { // MARK: - Transfer rate + /// The start of a record's current rate window. + struct TransferRateSample: Equatable { + var bytes: Int64 + var at: Date + } + + private func updateTransferRate(recordId: String, bytes: Int64, at: Date) { + let next = Self.nextTransferRate( + sample: rateSamples[recordId], + rate: transferRates[recordId], + bytes: bytes, + now: at + ) + if next.sample != rateSamples[recordId] { rateSamples[recordId] = next.sample } + if next.rate != transferRates[recordId] { transferRates[recordId] = next.rate } + } + /// Exponentially-smoothed rate from progress deltas. Samples at least /// `rateSampleInterval` apart so the burst-y delegate callbacks don't - /// produce jittery instantaneous rates. - private func updateTransferRate(recordId: String, bytes: Int64) { - let now = Date() - guard let sample = rateSamples[recordId] else { - rateSamples[recordId] = (bytes, now) - return - } + /// produce jittery instantaneous rates. Returns the window to keep and + /// the rate to publish (nil clears it). + nonisolated static func nextTransferRate( + sample: TransferRateSample?, + rate: Double?, + bytes: Int64, + now: Date + ) -> (sample: TransferRateSample, rate: Double?) { + let fresh = TransferRateSample(bytes: bytes, at: now) + guard let sample else { return (fresh, rate) } let elapsed = now.timeIntervalSince(sample.at) - guard elapsed >= Self.rateSampleInterval else { return } - // Resume-data restarts can report fewer bytes than the last sample; - // reset the window instead of publishing a negative rate. - guard bytes >= sample.bytes else { - rateSamples[recordId] = (bytes, now) - transferRates.removeValue(forKey: recordId) - return - } + guard elapsed >= rateSampleInterval else { return (sample, rate) } + // After a gap, the bytes would be averaged over time the transfer + // may have spent waiting; resume-data restarts can report fewer bytes + // than the last sample. Either way, start a fresh window instead of + // publishing a misleading (or negative) rate. + guard elapsed <= rateMaxSampleGap, bytes >= sample.bytes else { return (fresh, nil) } let instant = Double(bytes - sample.bytes) / elapsed - if let previous = transferRates[recordId] { - transferRates[recordId] = previous + Self.rateSmoothing * (instant - previous) - } else { - transferRates[recordId] = instant - } - rateSamples[recordId] = (bytes, now) + return (fresh, rate.map { $0 + rateSmoothing * (instant - $0) } ?? instant) } private func clearTransferRate(recordId: String) { diff --git a/iosApp/iosApp/Downloads/DownloadModels.swift b/iosApp/iosApp/Downloads/DownloadModels.swift index d7bfcc69a..2256a8f4d 100644 --- a/iosApp/iosApp/Downloads/DownloadModels.swift +++ b/iosApp/iosApp/Downloads/DownloadModels.swift @@ -480,7 +480,9 @@ enum LocalDownloadStatus: String, Codable, Sendable { case registering /// Server is producing a remux/transcode artifact (`preparing`). case preparing - /// Server row is `ready`; queued behind the concurrency cap. + /// Server row is `ready`; waiting for a pipeline slot, a place under the + /// Simultaneous Downloads limit, a network connection, or room under the + /// series storage limit. case queued /// Background `URLSession` task is transferring the media file. case downloading @@ -490,7 +492,7 @@ enum LocalDownloadStatus: String, Codable, Sendable { /// Only an explicit user resume leaves this state — the pipeline and /// server reconciliation must never auto-restart it. case paused - /// Media file is local; fetching manifest/artwork/subtitles. + /// Fetching the manifest; the media transfer starts once it arrives. case fetchingAssets /// Everything is on disk and playable offline. case completed @@ -573,6 +575,10 @@ struct DownloadRecord: Codable, Identifiable, Hashable, Sendable { /// The latest local status event the server has not answered yet. A /// retry resends exactly this event. var pendingStatusEvent: DownloadStatusEvent? = nil + /// The media file's exact size from the manifest's integrity block, when + /// the server sends one. A finished transfer of any other size is + /// discarded and downloaded again. + var expectedBytes: Int64? = nil var isPlayableOffline: Bool { (localStatus == .completed || localStatus == .revoked) && mediaFilename != nil @@ -586,6 +592,21 @@ struct DownloadRecord: Codable, Identifiable, Hashable, Sendable { guard fileSize > 0 else { return 0 } return min(1, max(0, Double(bytesDownloaded) / Double(fileSize))) } + + /// Why a failed download failed, in words the user can act on. + var failureReason: String { + switch lastError { + case "storage_full": return "Not enough storage on this device" + case "size_mismatch": return "The file arrived incomplete" + case "move_failed": return "Couldn't save the file" + case "not_found", "removed_on_server": return "No longer available on the server" + case "forbidden": return "Not available to this profile" + case "unauthorized": return "Sign-in expired" + case "server_failed": return "The server couldn't prepare this download" + case let code? where code.hasPrefix("http_5") || code == "http_429": return "The server had a problem" + default: return "Download failed" + } + } } /// A locally-mirrored subscription with the series title cached for diff --git a/iosApp/iosApp/Downloads/DownloadNotifications.swift b/iosApp/iosApp/Downloads/DownloadNotifications.swift index f10e1fc60..a8daad44f 100644 --- a/iosApp/iosApp/Downloads/DownloadNotifications.swift +++ b/iosApp/iosApp/Downloads/DownloadNotifications.swift @@ -27,7 +27,7 @@ enum DownloadNotifier { static func downloadFailed(_ record: DownloadRecord) { post( id: "download-failed-\(record.id)", - body: "Download failed: \(displayName(for: record))" + body: "\(displayName(for: record)) didn't download: \(record.failureReason)" ) } diff --git a/iosApp/iosApp/Downloads/DownloadOptionsSheet.swift b/iosApp/iosApp/Downloads/DownloadOptionsSheet.swift index 12255cc30..c51a0db59 100644 --- a/iosApp/iosApp/Downloads/DownloadOptionsSheet.swift +++ b/iosApp/iosApp/Downloads/DownloadOptionsSheet.swift @@ -4,6 +4,26 @@ import SwiftUI struct DownloadRequestOptions: Hashable { let fileId: Int? let quality: String + + /// The file a download pins: the version the detail screen displays. + /// Auto resolves the way the version selector shows it (last played, + /// then quality preference), so the saved file and the sidecar + /// subtitles that travel with it match what the user saw. Leaving Auto + /// to the server let it pick its own default, a different file. Nil + /// only when the item has no version metadata. + static func fileId( + versions: [FileVersion], + selectedFileId: Int?, + lastFileId: Int?, + preferredQualityId: String? + ) -> Int? { + DetailVersionSelection.displayVersion( + versions: versions, + selectedFileId: selectedFileId, + lastFileId: lastFileId, + preferredQualityId: preferredQualityId + )?.fileId + } } struct DownloadOptionsSheet: View { @@ -100,7 +120,9 @@ struct DownloadOptionsSheet: View { } ToolbarItem(placement: .confirmationAction) { Button("Download", systemImage: "arrow.down.circle") { - onStart(DownloadRequestOptions(fileId: fileId, quality: quality)) + // Auto sends the version this sheet summarizes, not + // nil, so the server can't substitute another file. + onStart(DownloadRequestOptions(fileId: effectiveVersion?.fileId, quality: quality)) dismiss() } } @@ -136,20 +158,19 @@ struct DownloadOptionsSheet: View { private var summaryDetail: String { let qualityLabel = DownloadFormat(rawValue: quality)?.displayName ?? quality - let versionLabel = fileId == nil - ? "Auto version" - : (effectiveVersion.map(DetailPlaybackFormatting.versionPrimaryText) ?? "Selected version") + let versionLabel = effectiveVersion.map(DetailPlaybackFormatting.versionPrimaryText) + ?? (fileId == nil ? "Auto version" : "Selected version") var parts = [versionLabel, qualityLabel] if let estimate = selectionEstimate { - parts.append(estimate.isRange ? "\(estimate.sizeLabel) depending on server choice" : estimate.sizeLabel) + parts.append(estimate.sizeLabel) } return parts.joined(separator: " · ") } - /// Size expectation for what the current selection would download: - /// candidate range for Auto, exact size for a chosen version. + /// Exact size of the file the current selection downloads; Auto + /// resolves to a concrete version before the request is sent. private var selectionEstimate: DownloadSizeEstimate? { - DownloadSizeEstimate.estimate(versions: versions, fileId: fileId) + DownloadSizeEstimate.estimate(versions: versions, fileId: effectiveVersion?.fileId) } /// Over-threshold / insufficient-space caveat for the current selection, @@ -247,16 +268,22 @@ struct DownloadOptionsSheet: View { return "File \(version.fileId)" } - /// Auto spans every version the server might pick, so disclose the full - /// candidate range rather than pretending the size is unknown. + /// Names the version Auto resolves to, the one the detail screen shows, + /// so choosing Auto never hides which file will be saved. private var autoVersionDetail: String { - guard let estimate = DownloadSizeEstimate.estimate(versions: versions, fileId: nil) else { + guard let autoVersion = DetailVersionSelection.displayVersion( + versions: versions, + selectedFileId: nil, + lastFileId: lastVersionFileId, + preferredQualityId: PlayerSettings.shared.preferredQuality + ) else { return "Let the server choose the file" } - if estimate.isRange { - return "\(estimate.sizeLabel) depending on server choice" + let label = DetailPlaybackFormatting.versionPrimaryText(autoVersion) + guard let estimate = DownloadSizeEstimate.estimate(versions: versions, fileId: autoVersion.fileId) else { + return label } - return "\(estimate.sizeLabel) · Let the server choose the file" + return "\(label) · \(estimate.sizeLabel)" } private var qualitySection: some View { diff --git a/iosApp/iosApp/Downloads/DownloadRestartOwners.swift b/iosApp/iosApp/Downloads/DownloadRestartOwners.swift new file mode 100644 index 000000000..bc80ae436 --- /dev/null +++ b/iosApp/iosApp/Downloads/DownloadRestartOwners.swift @@ -0,0 +1,94 @@ +import Foundation + +/// What in this process may still start each record's transfer: a pipeline +/// fetching the manifest, or a retry waiting out its back-off. Either one +/// survives an app suspension, so a reconcile must leave its record alone; +/// re-queuing it would start a second transfer of the file. Also tracks the +/// artwork and subtitle fetch that follows a started transfer. +struct DownloadRestartOwners: Equatable { + /// Record id → token of the one pipeline allowed to start its transfer. + private var pipelines: [String: UUID] = [:] + /// Record id → when its scheduled retry fires. + private var retries: [String: Date] = [:] + /// Record id → token of the one fetch allowed to save its artwork and + /// subtitles. It runs alongside the transfer and holds no queue slot. + private var assets: [String: UUID] = [:] + + /// Hands the record to a new pipeline. Any pipeline already running for + /// it loses ownership and stops at its next check. + mutating func claimPipeline(_ recordId: String) -> UUID { + let token = UUID() + pipelines[recordId] = token + return token + } + + func ownsPipeline(_ recordId: String, _ token: UUID) -> Bool { + pipelines[recordId] == token + } + + func hasPipeline(_ recordId: String) -> Bool { + pipelines[recordId] != nil + } + + /// Pipelines running now. A retry waiting out its back-off isn't one. + var pipelineCount: Int { pipelines.count } + + /// Ends the pipeline holding `token`. A superseded pipeline releases + /// nothing, so it can't free the record from its replacement. + mutating func releasePipeline(_ recordId: String, _ token: UUID) { + if pipelines[recordId] == token { pipelines[recordId] = nil } + } + + /// Takes the record away from its running pipeline. + mutating func abandonPipeline(_ recordId: String) { + pipelines[recordId] = nil + } + + mutating func retryScheduled(_ recordId: String, firesAt date: Date) { + retries[recordId] = date + } + + /// The retry fired or was cancelled. + mutating func retryEnded(_ recordId: String) { + retries[recordId] = nil + } + + /// Whether a pipeline or retry in this process owns the record's restart. + func ownsRestart(_ recordId: String) -> Bool { + pipelines[recordId] != nil || retries[recordId] != nil + } + + /// Hands the record's artwork and subtitles to a new fetch. One already + /// running stops saving at its next check. + mutating func claimAssets(_ recordId: String) -> UUID { + let token = UUID() + assets[recordId] = token + return token + } + + func ownsAssets(_ recordId: String, _ token: UUID) -> Bool { + assets[recordId] == token + } + + mutating func releaseAssets(_ recordId: String, _ token: UUID) { + if assets[recordId] == token { assets[recordId] = nil } + } + + /// Stops the record's asset fetch from saving anything more. + mutating func abandonAssets(_ recordId: String) { + assets[recordId] = nil + } + + /// Whether work in this process may still need the network by + /// `deadline`: a pipeline or asset fetch is running, or a retry fires + /// by then. + func handoffPending(by deadline: Date) -> Bool { + !pipelines.isEmpty || !assets.isEmpty || retries.values.contains { $0 <= deadline } + } + + mutating func removeAll() { + pipelines.removeAll() + retries.removeAll() + assets.removeAll() + } +} diff --git a/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift b/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift index 5be88fd54..555cde278 100644 --- a/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift +++ b/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift @@ -4,19 +4,32 @@ import OSLog /// Events surfaced by the background download session, consumed by /// `DownloadManager` on the MainActor via an `AsyncStream`. enum DownloadSessionEvent: Sendable { - case progress(taskId: Int, bytesWritten: Int64, totalExpected: Int64) + /// `at` is when the delegate saw the bytes. Events can wait in the stream + /// (a busy main actor, or a cold launch holding them until the store + /// loads), so rates must use this time rather than the time they're handled. + case progress(taskId: Int, bytesWritten: Int64, totalExpected: Int64, at: Date) /// Media transfer succeeded (HTTP 2xx). `stagedURL` is a stable file in /// the staging directory — the volatile temp file has already been /// moved there synchronously inside the delegate callback. case finished(taskId: Int, stagedURL: URL, statusCode: Int) /// Transfer ended without a usable file: a network error, a /// cancellation, or a non-2xx server response (e.g. 409 revoked). - case failed(taskId: Int, statusCode: Int?, resumeData: Data?, message: String) + case failed(taskId: Int, statusCode: Int?, resumeData: Data?, message: String, cause: DownloadFailureCause) /// All background events for this launch have been delivered; the app /// may call the system-provided completion handler. case allEventsDelivered } +/// Why a transfer ended without a file, where that changes what happens next. +enum DownloadFailureCause: Sendable, Equatable { + case other + /// The user closed Prairie from the app switcher, which cancels every + /// background transfer. Not the transfer's fault. + case forceQuit + /// The device ran out of space for the file. Retrying won't help. + case storageFull +} + /// Owns the app's single background `URLSession` used to transfer media /// files. A background session continues across suspension/termination and /// resumes via HTTP Range, so this is an `NSObject` delegate (background @@ -36,6 +49,13 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch ) private let continuation: AsyncStream.Continuation + /// When each task's progress was last passed on. Touched only on the + /// session's serial delegate queue. + private var lastProgressYield: [Int: Date] = [:] + /// Progress callbacks arrive many times a second per task; totals are + /// cumulative, so passing on the latest every half second loses nothing + /// and keeps a long queue from flooding the main actor. + private static let progressInterval: TimeInterval = 0.5 let events: AsyncStream /// Set when iOS relaunches the app to deliver background events; called @@ -56,6 +76,11 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch config.isDiscretionary = false config.allowsCellularAccess = true config.httpMaximumConnectionsPerHost = 4 + // The user asked for these files and watches them arrive. Without + // this, iOS carries background-session transfers in its background + // traffic class, whose receive-side LEDBAT keeps the window so small + // that a 70 ms path tops out near 1 MB/s even on a fast network. + config.networkServiceType = .responsiveData return URLSession(configuration: config, delegate: self, delegateQueue: nil) }() @@ -98,6 +123,18 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch } } + /// Cancels the task only if it is transferring `downloadId`'s file. An + /// identifier kept on a record can belong to another download's task + /// once a relaunched session has reused it. + func cancel(taskId: Int, ifDownloading downloadId: String) { + session.getAllTasks { tasks in + guard let task = tasks.first(where: { $0.taskIdentifier == taskId }), + APIv2Client.downloadFileID(task.originalRequest?.url ?? task.currentRequest?.url) == downloadId + else { return } + task.cancel() + } + } + /// Cancel every task in the session, including ones an earlier app /// version started that the system reattached on launch. Returns once /// the cancels are issued; their final events still arrive later. @@ -200,10 +237,19 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { + let now = Date() + // Always pass on the last write of a file of known length. + let finished = totalBytesExpectedToWrite > 0 && totalBytesWritten >= totalBytesExpectedToWrite + if !finished, + now.timeIntervalSince(lastProgressYield[downloadTask.taskIdentifier] ?? .distantPast) < Self.progressInterval { + return + } + lastProgressYield[downloadTask.taskIdentifier] = now continuation.yield(.progress( taskId: downloadTask.taskIdentifier, bytesWritten: totalBytesWritten, - totalExpected: totalBytesExpectedToWrite + totalExpected: totalBytesExpectedToWrite, + at: now )) } @@ -222,7 +268,8 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch taskId: taskId, statusCode: statusCode, resumeData: nil, - message: "HTTP \(statusCode)" + message: "HTTP \(statusCode)", + cause: .other )) return } @@ -233,6 +280,9 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch try? FileManager.default.removeItem(at: staged) do { try FileManager.default.moveItem(at: location, to: staged) + // The move keeps the temp file's date; the stale-staging sweep + // must see when it was staged. + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: staged.path) continuation.yield(.finished(taskId: taskId, stagedURL: staged, statusCode: statusCode)) } catch { Self.logger.error("Failed to stage finished download \(taskId): \(String(describing: error), privacy: .public)") @@ -240,7 +290,8 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch taskId: taskId, statusCode: statusCode, resumeData: nil, - message: "stage_failed" + message: "stage_failed", + cause: Self.isOutOfSpace(error) ? .storageFull : .other )) } } @@ -250,25 +301,58 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch task: URLSessionTask, didCompleteWithError error: Error? ) { + // Every task ends here, including one that finished downloading. + lastProgressYield[task.taskIdentifier] = nil // Success path is handled in didFinishDownloadingTo. Only act on a // real transport error / cancellation here. guard let error else { return } let nsError = error as NSError let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data let statusCode = (task.response as? HTTPURLResponse)?.statusCode + let cause: DownloadFailureCause + if (nsError.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? Int) + == NSURLErrorCancelledReasonUserForceQuitApplication { + cause = .forceQuit + } else if Self.isOutOfSpace(error) { + cause = .storageFull + } else { + cause = .other + } // A user-initiated cancel still surfaces here; the manager checks // its own intent and ignores cancellations it requested. continuation.yield(.failed( taskId: task.taskIdentifier, statusCode: statusCode, resumeData: resumeData, - message: error.localizedDescription + message: error.localizedDescription, + cause: cause )) } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { continuation.yield(.allEventsDelivered) } + + /// Whether a transfer or file move failed because the device is full. + static func isOutOfSpace(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain, nsError.code == NSFileWriteOutOfSpaceError { return true } + if nsError.domain == NSPOSIXErrorDomain, nsError.code == Int(ENOSPC) { return true } + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? Error, isOutOfSpace(underlying) { return true } + return false + } + + /// Background sessions can stop delivering progress for running tasks + /// after the app comes back from the background or is relaunched + /// (Apple bug r. 32247561). Resuming a running task is harmless and, + /// per Apple, restores the callbacks. + func refreshProgressDelivery() { + session.getAllTasks { tasks in + for task in tasks where task.state == .running { + task.resume() + } + } + } } /// Builds an authenticated `URLRequest` for the background download @@ -281,6 +365,7 @@ enum DownloadAuthHeaders { var request = URLRequest(url: url) request.httpMethod = "GET" request.allowsCellularAccess = allowsCellular + request.networkServiceType = .responsiveData if let token = auth.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") diff --git a/iosApp/iosApp/Downloads/DownloadSettings.swift b/iosApp/iosApp/Downloads/DownloadSettings.swift index d671d7fee..6633c568a 100644 --- a/iosApp/iosApp/Downloads/DownloadSettings.swift +++ b/iosApp/iosApp/Downloads/DownloadSettings.swift @@ -41,6 +41,15 @@ final class DownloadSettings { didSet { defaults.set(keepWatchedDownloads, forKey: Keys.keepWatchedDownloads) } } + /// How many downloads transfer at once. Two keep a typical connection + /// busy while each file, and the first episode of a season, still + /// finishes soon; more split the bandwidth without adding much. + var simultaneousDownloads: Int { + didSet { defaults.set(simultaneousDownloads, forKey: Keys.simultaneousDownloads) } + } + + static let simultaneousDownloadChoices = [1, 2, 3, 4] + private let defaults: UserDefaults /// Internal so tests can verify the contract-known local preferences in an @@ -54,6 +63,7 @@ final class DownloadSettings { Keys.defaultMaxStorageGB: 0, Keys.sortOption: DownloadSortOption.largestFirst.rawValue, Keys.keepWatchedDownloads: false, + Keys.simultaneousDownloads: 2, ]) preferredFormat = defaults.string(forKey: Keys.preferredFormat) ?? DownloadFormat.original.rawValue wifiOnly = defaults.bool(forKey: Keys.wifiOnly) @@ -62,6 +72,10 @@ final class DownloadSettings { sortOption = defaults.string(forKey: Keys.sortOption) .flatMap(DownloadSortOption.init(rawValue:)) ?? .largestFirst keepWatchedDownloads = defaults.bool(forKey: Keys.keepWatchedDownloads) + simultaneousDownloads = min( + max(1, defaults.integer(forKey: Keys.simultaneousDownloads)), + Self.simultaneousDownloadChoices.last ?? 4 + ) } /// The quality to actually request, given what the server offers right @@ -83,5 +97,6 @@ final class DownloadSettings { static let defaultMaxStorageGB = "downloads.defaultMaxStorageGB" static let sortOption = "downloads.sortOption" static let keepWatchedDownloads = "downloads.keepWatchedDownloads" + static let simultaneousDownloads = "downloads.simultaneousDownloads" } } diff --git a/iosApp/iosApp/Downloads/DownloadSizeEstimate.swift b/iosApp/iosApp/Downloads/DownloadSizeEstimate.swift index 055c7e482..9731ff7dc 100644 --- a/iosApp/iosApp/Downloads/DownloadSizeEstimate.swift +++ b/iosApp/iosApp/Downloads/DownloadSizeEstimate.swift @@ -1,11 +1,11 @@ #if !os(tvOS) import Foundation -/// Pre-flight size expectation for a download. When the user picks Auto the -/// server resolves the actual file, so the only honest disclosure is the -/// min–max range across the item's candidate versions; a chosen version is -/// exact. Sizes come from the catalog's original-file metadata, so for -/// transcoded qualities the estimate is an upper bound. +/// Pre-flight size expectation for a download. A known version is exact; +/// when the server picks the file (episode cards carry no versions) the only +/// honest disclosure is the min–max range across the candidate files. Sizes +/// come from the catalog's original-file metadata, so for transcoded +/// qualities the estimate is an upper bound. struct DownloadSizeEstimate { /// Downloads past this point warrant an explicit confirmation before the /// one-tap path proceeds. Decimal bytes so the threshold lines up with diff --git a/iosApp/iosApp/Downloads/DownloadsSettingsView.swift b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift index 3c96a1689..060e53372 100644 --- a/iosApp/iosApp/Downloads/DownloadsSettingsView.swift +++ b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift @@ -22,16 +22,17 @@ struct DownloadsSettingsView: View { var body: some View { Form { - SettingsPageHeader( - title: "Downloads", - subtitle: "Offline quality, cleanup, and storage preferences.", - systemImage: "arrow.down.circle.fill" - ) - .settingsPageHeaderRow() - Section { Toggle("Download over Wi-Fi only", isOn: $settings.wifiOnly) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) + Picker("Simultaneous Downloads", selection: $settings.simultaneousDownloads) { + ForEach(DownloadSettings.simultaneousDownloadChoices, id: \.self) { count in + Text("\(count)").tag(count) + } + } + .onChange(of: settings.simultaneousDownloads) { + manager.applyTransferLimit() + } if formats.count > 1 { Picker("Quality", selection: $settings.preferredFormat) { ForEach(formats, id: \.self) { format in @@ -49,11 +50,11 @@ struct DownloadsSettingsView: View { Text("Original prefers source quality and may prepare a compatibility file if this device needs one. Bitrate presets are prepared on the server before download starts.") } } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) Section("Series Monitoring Defaults") { Toggle("Delete watched episodes", isOn: $settings.defaultDeleteWatched) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Stepper( settings.defaultMaxStorageGB == 0 ? "Storage limit: Unlimited" @@ -63,17 +64,17 @@ struct DownloadsSettingsView: View { step: 5 ) } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) Section { Toggle("Keep watched downloads", isOn: $settings.keepWatchedDownloads) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } header: { Text("Cleanup") } footer: { Text("When off, the Downloads tab suggests freeing up space by removing items you've finished watching.") } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) Section("Storage") { HStack { @@ -90,7 +91,7 @@ struct DownloadsSettingsView: View { } } } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) if manager.heldProgressCount > 0 { Section { @@ -102,10 +103,10 @@ struct DownloadsSettingsView: View { } footer: { Text(heldProgressFooter) } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) } } - .navigationTitle("") + .navigationTitle("Downloads") .task { // The quality picker is hidden when the cached capability only // offers one preset; re-fetch so permission changes show up here @@ -117,10 +118,11 @@ struct DownloadsSettingsView: View { #endif .settingsListChrome() .prairieToolbarColorSchemeDark() - .confirmationDialog( + // Centered alert: a confirmation dialog anchors to the whole form + // and appears at the top of the page. + .alert( "Remove all downloaded files?", - isPresented: $showDeleteAllConfirm, - titleVisibility: .visible + isPresented: $showDeleteAllConfirm ) { Button("Remove All", systemImage: "trash", role: .destructive) { manager.deleteDownloads(ids: manager.records.map(\.id)) diff --git a/iosApp/iosApp/Downloads/DownloadsView.swift b/iosApp/iosApp/Downloads/DownloadsView.swift index b196e29e6..94af4fc37 100644 --- a/iosApp/iosApp/Downloads/DownloadsView.swift +++ b/iosApp/iosApp/Downloads/DownloadsView.swift @@ -13,6 +13,9 @@ struct DownloadsView: View { @State private var isSelecting = false @State private var selection: Set = [] + /// Selected in-progress downloads, by record id; `selection` holds the + /// finished list's items. + @State private var activeSelection: Set = [] @State private var showReclaim = false /// Confirmation gate for the bulk/context-menu deletes — downloads are /// costly to re-fetch, so a stray tap must not remove them outright. @@ -20,8 +23,16 @@ struct DownloadsView: View { private struct PendingDeletion: Identifiable { let id = UUID() + /// Finished downloads. let ids: [String] + /// Downloads in progress, cancelled only if they still are when the + /// user confirms. + var activeIds: [String] = [] let endsSelection: Bool + /// Every id is a download still in progress. + var inProgressOnly = false + + var count: Int { ids.count + activeIds.count } } var body: some View { @@ -38,31 +49,36 @@ struct DownloadsView: View { content } } - .prairiePageBackground() - .navigationTitle(isSelecting ? "\(selection.count) Selected" : "Downloads") + .background(Color.prairieBackground.ignoresSafeArea()) + .navigationTitle(isSelecting ? "\(selectedCount) Selected" : "Downloads") #if os(iOS) .navigationBarTitleDisplayMode(.large) #endif .toolbar { toolbarContent } .safeAreaInset(edge: .bottom) { bottomBar } .sheet(isPresented: $showReclaim) { DownloadReclaimSheet() } - .confirmationDialog( - "Delete downloaded files?", + // An alert, not a confirmation dialog: on iPhone the dialog anchors + // to this whole page and appears at its top, far from the row or + // bottom bar that asked for it. + .alert( + pendingDeletion?.inProgressOnly == true ? "Cancel downloads?" : "Delete downloaded files?", isPresented: Binding( get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } } ), - titleVisibility: .visible, presenting: pendingDeletion ) { pending in + let verb = pending.inProgressOnly ? "Cancel" : "Delete" Button( - pending.ids.count == 1 ? "Delete Download" : "Delete \(pending.ids.count) Downloads", + pending.count == 1 ? "\(verb) Download" : "\(verb) \(pending.count) Downloads", role: .destructive ) { - manager.deleteDownloads(ids: pending.ids) + // One that finished while the dialog was open keeps its file. + let stillActive = Set(manager.activeRecords.map(\.id)) + manager.deleteDownloads(ids: pending.ids + pending.activeIds.filter(stillActive.contains)) if pending.endsSelection { exitSelectMode() } } - Button("Cancel", role: .cancel) {} + Button("Keep", role: .cancel) {} } .prairieToolbarColorSchemeDark() } @@ -110,12 +126,13 @@ struct DownloadsView: View { private var content: some View { ScrollView { - LazyVStack(spacing: 10) { + LazyVStack(spacing: 0) { DownloadsStorageHeader( used: manager.totalBytesUsed, breakdown: manager.storageBreakdown, activeCount: manager.activeRecords.count ) + .downloadGroupedRow(showReclaimBanner ? .first : .only) .padding(.top, 6) if showReclaimBanner { @@ -123,41 +140,95 @@ struct DownloadsView: View { episodeCount: manager.reclaimableRecords.count, bytes: manager.reclaimableBytes ) { showReclaim = true } + .downloadGroupedRow(.last, separatorInset: 16) } if !manager.activeRecords.isEmpty { - sectionLabel("In Progress", count: manager.activeRecords.count) - ForEach(manager.activeRecords) { record in - DownloadActiveRow( - record: record, - bytesPerSecond: manager.transferRate(id: record.id), - onPauseResume: { - if record.localStatus == .paused { manager.resumeDownload(id: record.id) } - else { manager.pauseDownload(id: record.id) } - }, - onCancel: { manager.deleteDownload(id: record.id) } - ) + DownloadSectionHeader(title: "Downloading", count: manager.activeRecords.count) + if isSelecting { + Button(allActiveSelected ? "Clear In Progress" : "Select All In Progress") { + if allActiveSelected { activeSelection.removeAll() } + else { activeSelection = Set(manager.activeRecords.map(\.id)) } + } + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + .padding(.bottom, 8) + } + #if os(iOS) + if manager.canShowProgressOnLockScreen, !isSelecting { + Button { + manager.showProgressOnLockScreen() + } label: { + Label("Show Progress on Lock Screen", systemImage: "lock.iphone") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.prairieOnSurface) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Color.prairieChromeSelectedFill)) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, 10) } + #endif + let inProgress = manager.inProgressRecords + ForEach(Array(inProgress.enumerated()), id: \.element.id) { index, record in + // Re-reads the rate each second: a stalled transfer + // sends no progress that would otherwise redraw the + // row and clear its last speed. + TimelineView(.periodic(from: .now, by: 1)) { context in + DownloadActiveRow( + record: record, + bytesPerSecond: manager.transferRate(id: record.id, at: context.date), + wait: manager.wait(for: record), + selecting: isSelecting, + selected: activeSelection.contains(record.id), + groupPosition: DownloadGroupPosition(index: index, count: inProgress.count), + onSelectToggle: { toggleActive(record.id) }, + onPauseResume: { + if record.localStatus == .paused { manager.resumeDownload(id: record.id) } + else { manager.pauseDownload(id: record.id) } + }, + onCancel: { manager.deleteDownload(id: record.id) } + ) + } + .downloadGroupInset() + } + #if os(iOS) + if !isSelecting { + Text("Downloads keep going when you leave Prairie or lock your phone. Closing Prairie from the app switcher pauses them until you open it again.") + .font(.footnote) + .foregroundColor(.prairieSecondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + .padding(.top, 7) + } + #endif } let failed = manager.records.filter { $0.localStatus == .failed } if !failed.isEmpty { - sectionLabel("Needs Attention", count: failed.count) - ForEach(failed) { record in + DownloadSectionHeader(title: "Needs attention", count: failed.count) + ForEach(Array(failed.enumerated()), id: \.element.id) { index, record in DownloadAttentionRow( record: record, onRetry: { manager.retryDownload(id: record.id) }, onDelete: { manager.deleteDownload(id: record.id) } ) + .downloadGroupedRow(DownloadGroupPosition(index: index, count: failed.count)) } } - if !listItems.isEmpty { - DownloadSortControl(option: $settings.sortOption, itemCount: listItems.count) + let items = listItems + if !items.isEmpty { + DownloadSortControl(option: $settings.sortOption, itemCount: items.count) } - ForEach(listItems) { item in - row(for: item) + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + row(for: item, position: DownloadGroupPosition(index: index, count: items.count)) + .downloadGroupInset() } monitoredOnlySection @@ -169,8 +240,10 @@ struct DownloadsView: View { } } + /// One list row, drawn as its slice of the group inside the context menu + /// so the lifted preview keeps the cell's shape. @ViewBuilder - private func row(for item: DownloadListItem) -> some View { + private func row(for item: DownloadListItem, position: DownloadGroupPosition) -> some View { switch item { case .series(let group): DownloadSeriesRow( @@ -183,6 +256,7 @@ struct DownloadsView: View { onPlayEpisode: { play($0) }, onDeleteEpisode: { manager.deleteDownload(id: $0.id) } ) + .downloadGroupSlice(position) .contextMenu { if !isSelecting { Button(role: .destructive) { @@ -206,6 +280,7 @@ struct DownloadsView: View { else { router.navigate(to: .offlineDownloadDetail(downloadId: record.id)) } } ) + .downloadGroupSlice(position) .contextMenu { if !isSelecting { Button(role: .destructive) { @@ -225,14 +300,18 @@ struct DownloadsView: View { let groupedSeriesIds = Set(manager.seriesGroups.map(\.seriesId)) let pending = manager.subscriptions.filter { !groupedSeriesIds.contains($0.seriesId) } if !pending.isEmpty { - sectionLabel("Monitoring", count: pending.count) - ForEach(pending) { subscription in - monitoredRow(subscription) + DownloadSectionHeader(title: "Monitoring", count: pending.count) + ForEach(Array(pending.enumerated()), id: \.element.id) { index, subscription in + monitoredRow(subscription, position: DownloadGroupPosition(index: index, count: pending.count)) + .downloadGroupInset() } } } - private func monitoredRow(_ subscription: DownloadSubscription) -> some View { + private func monitoredRow( + _ subscription: DownloadSubscription, + position: DownloadGroupPosition + ) -> some View { HStack(spacing: 12) { Image(systemName: "antenna.radiowaves.left.and.right") .font(.system(size: 17)) @@ -240,26 +319,18 @@ struct DownloadsView: View { .frame(width: 40, height: 40) VStack(alignment: .leading, spacing: 3) { Text(subscription.seriesTitle ?? subscription.seriesId) - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundColor(.prairieOnSurface) .lineLimit(1) Text(SubscriptionMode(rawValue: subscription.mode)?.displayName ?? subscription.mode) - .font(.system(size: 12)) + .font(.subheadline) .foregroundColor(.prairieSecondaryText) } Spacer(minLength: 8) } .padding(.horizontal, 16) .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.prairieSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.prairieOutline, lineWidth: 1) - ) - ) - .padding(.horizontal, 16) + .downloadGroupSlice(position) .contextMenu { Button(role: .destructive) { Task { await manager.deleteSubscription(id: subscription.id) } @@ -269,22 +340,6 @@ struct DownloadsView: View { } } - private func sectionLabel(_ text: String, count: Int) -> some View { - HStack { - Text(text.uppercased()) - .font(.system(size: 12.5, weight: .semibold)) - .tracking(0.3) - .foregroundColor(.prairieSecondaryText) - Spacer() - Text("\(count)") - .font(.system(size: 12.5)) - .foregroundColor(.prairieOnSurface.opacity(0.38)) - } - .padding(.horizontal, 22) - .padding(.top, 14) - .padding(.bottom, 2) - } - // MARK: - Toolbar & select mode @ToolbarContentBuilder @@ -302,7 +357,7 @@ struct DownloadsView: View { ToolbarItem(placement: .primaryAction) { Button("Done", systemImage: "checkmark") { exitSelectMode() } } - } else if !listItems.isEmpty { + } else if !listItems.isEmpty || !manager.activeRecords.isEmpty { ToolbarItem(placement: .primaryAction) { Button("Select", systemImage: "checkmark.circle") { isSelecting = true } } @@ -311,13 +366,18 @@ struct DownloadsView: View { @ViewBuilder private var bottomBar: some View { - if isSelecting && !selection.isEmpty { + if isSelecting && selectedCount > 0 { Button { - pendingDeletion = PendingDeletion(ids: selectedDownloadIds, endsSelection: true) + pendingDeletion = PendingDeletion( + ids: selectedDownloadIds, + activeIds: Array(liveActiveSelection), + endsSelection: true, + inProgressOnly: selection.isEmpty + ) } label: { HStack(spacing: 9) { - Image(systemName: "trash") - Text("Delete \(selection.count) · Free \(DownloadFormatting.bytes(selectedBytes))") + Image(systemName: selection.isEmpty ? "xmark.circle" : "trash") + Text(bottomBarTitle) .fontWeight(.bold) } .font(.system(size: 15)) @@ -340,7 +400,33 @@ struct DownloadsView: View { } private var allSelected: Bool { - !listItems.isEmpty && Set(listItems.map(\.id)).isSubset(of: selection) + guard !listItems.isEmpty || !manager.activeRecords.isEmpty else { return false } + return Set(listItems.map(\.id)).isSubset(of: selection) && allActiveSelected + } + + private var selectedCount: Int { + selection.count + liveActiveSelection.count + } + + /// Selected downloads that are still in progress. One that finished or + /// failed after it was selected drops out, so cancelling never deletes a + /// finished file. + private var liveActiveSelection: Set { + activeSelection.intersection(manager.activeRecords.map(\.id)) + } + + private var allActiveSelected: Bool { + Set(manager.activeRecords.map(\.id)).isSubset(of: activeSelection) + } + + private var bottomBarTitle: String { + if selection.isEmpty { + return selectedCount == 1 ? "Cancel 1 Download" : "Cancel \(selectedCount) Downloads" + } + let partialBytes = manager.activeRecords + .filter { activeSelection.contains($0.id) } + .reduce(Int64(0)) { $0 + $1.bytesDownloaded } + return "Delete \(selectedCount) · Free \(DownloadFormatting.bytes(selectedBytes + partialBytes))" } private var selectedItems: [DownloadListItem] { @@ -359,14 +445,24 @@ struct DownloadsView: View { if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } } + private func toggleActive(_ id: String) { + if activeSelection.contains(id) { activeSelection.remove(id) } else { activeSelection.insert(id) } + } + private func toggleSelectAll() { - if allSelected { selection.removeAll() } - else { selection = Set(listItems.map(\.id)) } + if allSelected { + selection.removeAll() + activeSelection.removeAll() + } else { + selection = Set(listItems.map(\.id)) + activeSelection = Set(manager.activeRecords.map(\.id)) + } } private func exitSelectMode() { isSelecting = false selection.removeAll() + activeSelection.removeAll() } // MARK: - Playback diff --git a/iosApp/iosApp/Downloads/SeriesDownloadControls.swift b/iosApp/iosApp/Downloads/SeriesDownloadControls.swift index 63a8cc011..236c4cd97 100644 --- a/iosApp/iosApp/Downloads/SeriesDownloadControls.swift +++ b/iosApp/iosApp/Downloads/SeriesDownloadControls.swift @@ -1,6 +1,28 @@ #if !os(tvOS) import SwiftUI +/// The episode highlighted on the series page with the version state its +/// selector shows, so downloading that one episode saves the displayed file. +struct SeriesEpisodeDownloadTarget { + let episode: EpisodeListItem + let versions: [FileVersion] + let selectedFileId: Int? + let lastFileId: Int? + + var title: String { episode.title ?? "Episode \(episode.episodeNumber)" } + var code: String { "S\(episode.seasonNumber)·E\(episode.episodeNumber)" } + var displaySubtitle: String { "S\(episode.seasonNumber) · E\(episode.episodeNumber)" } + + var displayedVersion: FileVersion? { + DetailVersionSelection.displayVersion( + versions: versions, + selectedFileId: selectedFileId, + lastFileId: lastFileId, + preferredQualityId: PlayerSettings.shared.preferredQuality + ) + } +} + /// Series-level download + monitoring control for the series/season detail /// action row. A circle menu offering whole-season / whole-series download /// and a series-monitoring subscription editor. Reads @@ -12,10 +34,20 @@ struct SeriesDownloadMenuButton: View { let selectedSeason: Season? let episodes: [EpisodeListItem] let episodesBySeason: [Int: [EpisodeListItem]] + var episodeTarget: SeriesEpisodeDownloadTarget? = nil private var manager: DownloadManager { DownloadManager.shared } @State private var activeSheet: SeriesDownloadSheet? - @State private var pendingMonitorSheet = false + @State private var pendingSheet: SeriesDownloadSheet? + /// Captured when Episode Options is chosen, so a reload of the page's + /// highlighted episode cannot blank the sheet or retarget its download. + @State private var optionsTarget: SeriesEpisodeDownloadTarget? + @State private var episodeErrorMessage: String? + /// True from presenting a sheet until its dismissal finishes. An alert + /// raised while a sheet is still animating away can be dropped, so a + /// failure in that window waits in `pendingEpisodeError`. + @State private var sheetOnScreen = false + @State private var pendingEpisodeError: String? /// Presentation of the trigger. `labeled` matches the detail page's named /// action row; `circle` is the original chrome, still used elsewhere. @@ -87,9 +119,13 @@ struct SeriesDownloadMenuButton: View { // fails to present when teardown runs long (slow device, // accessibility animations, low power). .sheet(item: $activeSheet, onDismiss: { - if pendingMonitorSheet { - pendingMonitorSheet = false - activeSheet = .monitor + sheetOnScreen = false + if let pendingSheet { + self.pendingSheet = nil + activeSheet = pendingSheet + } else if let pendingEpisodeError { + self.pendingEpisodeError = nil + episodeErrorMessage = pendingEpisodeError } }) { sheet in switch sheet { @@ -104,23 +140,80 @@ struct SeriesDownloadMenuButton: View { canDownloadSeason: manager.canDownloadSeason, canMonitorSeries: manager.canMonitorSeries, isMonitored: isMonitored, - onMonitor: { pendingMonitorSheet = true } + episodeTarget: episodeTarget, + onEpisodeOptions: { target in + optionsTarget = target + pendingSheet = .episodeOptions + }, + onMonitor: { pendingSheet = .monitor } ) .environment(\.browseLibraryId, libraryId) + case .episodeOptions: + if let target = optionsTarget { + DownloadOptionsSheet( + title: "\(target.code) · \(target.title)", + versions: target.versions, + selectedVersionFileId: target.selectedFileId, + lastVersionFileId: target.lastFileId, + onStart: { startEpisodeDownload($0, target: target) } + ) + } case .monitor: SeriesMonitorSheet(seriesId: seriesId, seriesTitle: detail.title, seasons: seasons) } } + .onChange(of: activeSheet) { _, sheet in + if sheet != nil { sheetOnScreen = true } + } + // The options sheet dismisses as soon as it hands off, so a failed + // registration has to surface from here. + .alert( + "Download Failed", + isPresented: Binding( + get: { episodeErrorMessage != nil }, + set: { if !$0 { episodeErrorMessage = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(episodeErrorMessage ?? "") + } + } + + private func startEpisodeDownload(_ options: DownloadRequestOptions, target: SeriesEpisodeDownloadTarget) { + Task { + do { + try await manager.downloadEpisode( + seriesId: seriesId, + episodeId: target.episode.contentId, + displayTitle: target.title, + displaySubtitle: target.displaySubtitle, + posterThumbhash: detail.posterThumbhash, + fileId: options.fileId, + quality: options.quality + ) + } catch DownloadError.registrationAlreadyInFlight { + // The original request owns the Preparing state. + } catch { + if sheetOnScreen { + pendingEpisodeError = error.localizedDescription + } else { + episodeErrorMessage = error.localizedDescription + } + } + } } } private enum SeriesDownloadSheet: Identifiable { case downloadOptions + case episodeOptions case monitor var id: String { switch self { case .downloadOptions: return "downloadOptions" + case .episodeOptions: return "episodeOptions" case .monitor: return "monitor" } } @@ -136,16 +229,26 @@ private struct SeriesDownloadOptionsSheet: View { let canDownloadSeason: Bool let canMonitorSeries: Bool let isMonitored: Bool + let episodeTarget: SeriesEpisodeDownloadTarget? + let onEpisodeOptions: (SeriesEpisodeDownloadTarget) -> Void let onMonitor: () -> Void @Environment(\.dismiss) private var dismiss private var manager: DownloadManager { DownloadManager.shared } @State private var errorMessage: String? - @State private var isWorking = false + /// The option being registered, which shows a spinner in its row. + @State private var workingOption: String? + @State private var finishedCount = 0 + + private var isWorking: Bool { workingOption != nil } var body: some View { NavigationStack { Form { + if let episodeTarget { + episodeSection(episodeTarget) + } + Section { if !availableSeasons.isEmpty { NavigationLink { @@ -157,21 +260,25 @@ private struct SeriesDownloadOptionsSheet: View { posterThumbhash: posterThumbhash ) } label: { + // The navigation link draws its own chevron. optionLabel( title: "Choose Episodes", detail: "Open a season and select episodes", - icon: "checklist" + icon: "checklist", + showsChevron: false ) } + .disabled(isWorking) } if canDownloadSeason, let selectedSeason { optionButton( title: "Download Season \(selectedSeason.seasonNumber)", detail: "Original quality · \(selectedSeason.episodeCount) episode\(selectedSeason.episodeCount == 1 ? "" : "s")", - icon: "arrow.down.square.on.square" + icon: "square.and.arrow.down.on.square", + option: "season" ) { - startDownload { + startDownload(option: "season") { try await manager.downloadSeason(seriesId: seriesId, seasonNumber: selectedSeason.seasonNumber) } } @@ -180,9 +287,10 @@ private struct SeriesDownloadOptionsSheet: View { optionButton( title: "Download All Episodes", detail: "Original quality", - icon: "arrow.down.circle" + icon: "arrow.down.circle", + option: "series" ) { - startDownload { + startDownload(option: "series") { try await manager.downloadSeries(seriesId: seriesId) } } @@ -244,6 +352,72 @@ private struct SeriesDownloadOptionsSheet: View { } } + /// The highlighted episode, downloaded with the version its selector + /// shows. Batch rows below cannot carry a version, so this is the one + /// place a series-page version choice reaches a download. + @ViewBuilder + private func episodeSection(_ target: SeriesEpisodeDownloadTarget) -> some View { + let isRegistering = manager.isRegistering(contentId: target.episode.contentId) + let record = manager.record(forContentId: target.episode.contentId) + // A failed entry holds no playable bytes, so it offers a retry like + // the episode card instead of claiming the episode is downloaded. + let failed = record?.localStatus == .failed + Section { + if (record != nil && !failed) || isRegistering { + optionLabel( + title: target.code, + detail: "Already in Downloads", + icon: "checkmark.circle", + showsChevron: false + ) + } else { + optionButton( + title: failed ? "Retry \(target.code)" : "Download \(target.code)", + detail: episodeVersionDetail(target), + icon: failed ? "arrow.clockwise" : "arrow.down.to.line", + option: "episode" + ) { + startDownload(option: "episode") { + try await manager.downloadEpisode( + seriesId: seriesId, + episodeId: target.episode.contentId, + displayTitle: target.title, + displaySubtitle: target.displaySubtitle, + posterThumbhash: posterThumbhash, + fileId: target.displayedVersion?.fileId, + quality: DownloadSettings.shared.resolvedFormat( + allowedFormats: manager.capability?.qualityPresets ?? [] + ) + ) + } + } + } + // Stays available once downloaded, like the movie page's + // Download Options, so a different version replaces the entry. + if !isRegistering { + optionButton( + title: "Episode Options…", + detail: "Choose the version and quality", + icon: "slider.horizontal.3" + ) { + dismiss() + onEpisodeOptions(target) + } + } + } header: { + Text("Episode") + } + } + + private func episodeVersionDetail(_ target: SeriesEpisodeDownloadTarget) -> String { + guard let version = target.displayedVersion else { return target.title } + let label = DetailPlaybackFormatting.versionPrimaryText(version) + guard let estimate = DownloadSizeEstimate.estimate(versions: target.versions, fileId: version.fileId) else { + return label + } + return "\(label) · \(estimate.sizeLabel)" + } + private var availableSeasons: [Season] { let sorted = seasons.sortedForDisplay() if sorted.isEmpty, let selectedSeason { return [selectedSeason] } @@ -252,17 +426,18 @@ private struct SeriesDownloadOptionsSheet: View { /// Run a download request, dismissing only on success — a silent /// `try?` here made an offline/unauthenticated tap look like it worked. - private func startDownload(_ work: @escaping () async throws -> Void) { + private func startDownload(option: String, _ work: @escaping () async throws -> Void) { guard !isWorking else { return } - isWorking = true + workingOption = option Task { do { try await work() + finishedCount += 1 dismiss() } catch { errorMessage = error.localizedDescription } - isWorking = false + workingOption = nil } } @@ -270,15 +445,20 @@ private struct SeriesDownloadOptionsSheet: View { title: String, detail: String, icon: String, + option: String? = nil, action: @escaping () -> Void ) -> some View { Button(action: action) { - optionLabel(title: title, detail: detail, icon: icon) + optionLabel(title: title, detail: detail, icon: icon, showsSpinner: option != nil && workingOption == option) } .buttonStyle(.plain) + .disabled(isWorking) + .sensoryFeedback(.success, trigger: finishedCount) } - private func optionLabel(title: String, detail: String, icon: String) -> some View { + private func optionLabel( + title: String, detail: String, icon: String, showsSpinner: Bool = false, showsChevron: Bool = true + ) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 17, weight: .semibold)) @@ -293,11 +473,17 @@ private struct SeriesDownloadOptionsSheet: View { .foregroundColor(.prairieSecondaryText) } Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(.prairieSecondaryText) + if showsSpinner { + ProgressView() + } else if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.prairieSecondaryText) + } } .padding(.vertical, 4) + // The whole row answers a tap, not just its text and icons. + .contentShape(Rectangle()) } } @@ -728,13 +914,13 @@ struct SeriesMonitorSheet: View { else { selectedSeasons.remove(season.seasonNumber) } } )) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } } } Section("Storage") { Toggle("Delete watched episodes", isOn: $deleteWatched) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Picker("Limit", selection: $maxStorageGB) { ForEach(storageLimitOptionsGB, id: \.self) { gb in Text(gb == 0 ? "Unlimited" : "\(gb) GB").tag(gb) diff --git a/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift b/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift index e967d2989..18c979bc9 100644 --- a/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift +++ b/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift @@ -10,20 +10,39 @@ import SwiftUI struct DownloadActiveRow: View { let record: DownloadRecord /// Smoothed transfer rate from the manager; nil until enough progress - /// deltas have landed for the estimate to be meaningful. + /// deltas have landed for the estimate to be meaningful, and again once + /// progress stops arriving. var bytesPerSecond: Double? = nil + /// What the download waits for, when it can't move right now. + var wait: DownloadManager.Wait? = nil + var selecting: Bool = false + var selected: Bool = false + /// This row's place in the Downloading group; drawn inside the context + /// menu so the lifted preview keeps the cell's shape. + var groupPosition: DownloadGroupPosition = .only + var onSelectToggle: () -> Void = {} var onPauseResume: () -> Void = {} var onCancel: () -> Void = {} @State private var confirmingCancel = false var body: some View { + if selecting { + Button(action: onSelectToggle) { card } + .buttonStyle(.plain) + .downloadGroupSlice(groupPosition) + } else { + actionableCard + } + } + + private var actionableCard: some View { DownloadSwipeRevealContainer(actionLabel: "Cancel") { confirmingCancel = true } content: { card } - .padding(.horizontal, 16) + .downloadGroupSlice(groupPosition) .contextMenu { menuItems } .confirmationDialog( cancelPrompt, @@ -37,6 +56,7 @@ struct DownloadActiveRow: View { private var card: some View { HStack(spacing: 12) { + if selecting { DownloadSelectionCircle(selected: selected) } DownloadPosterThumb( thumbhash: record.posterThumbhash, fileURL: DownloadManager.shared.posterImageURL(for: record), @@ -55,18 +75,13 @@ struct DownloadActiveRow: View { } Spacer(minLength: 8) - progressRing + if !selecting { progressRing } } .padding(.horizontal, 16) .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.prairieSurfaceElevated) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.prairieOutline, lineWidth: 1) - ) - ) + .contentShape(Rectangle()) + // Opaque so the swipe-revealed Cancel action stays hidden underneath. + .background(Color.prairieGroupedCell) } @ViewBuilder private var menuItems: some View { @@ -103,18 +118,28 @@ struct DownloadActiveRow: View { private var statusLine: String { switch record.localStatus { case .downloading: + if rateParts.isEmpty { + if let waitText { + return record.bytesDownloaded > 0 ? "\(waitText) · \(percentText)" : waitText + } + // Handed to iOS, which hasn't started the transfer yet. + if record.bytesDownloaded == 0 { return "Waiting…" } + } return ([percentText, sizeText] + rateParts).joined(separator: " · ") case .paused: return "Paused · \(percentText) · \(sizeText)" - case .registering, .queued: return "Queued" + case .registering, .queued: return waitText ?? "Queued" case .preparing: return "Preparing on server…" - case .fetchingAssets: return "Finishing…" + // Fetching the manifest, before the transfer starts. + case .fetchingAssets: return waitText ?? "Starting…" case .completed: return DownloadFormatting.bytes(record.fileSize) case .failed: return "Failed" case .revoked: return "No longer available" } } + private var waitText: String? { wait?.label } + private var percentText: String { "\(Int((record.progressFraction * 100).rounded()))%" } @@ -210,12 +235,9 @@ struct DownloadSwipeRevealContainer: View { .font(.system(size: 11.5, weight: .semibold)) } .foregroundColor(.white) - .frame(width: revealWidth - 8) + .frame(width: revealWidth) .frame(maxHeight: .infinity) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.prairieError) - ) + .background(Color.prairieError) } .buttonStyle(.plain) .opacity(offset < -8 ? 1 : 0) @@ -269,9 +291,10 @@ struct DownloadAttentionRow: View { .font(.system(size: 15, weight: .semibold)) .foregroundColor(.prairieOnSurface) .lineLimit(1) - Text("Download failed") + Text(record.failureReason) .font(.system(size: 12.5)) .foregroundColor(.prairieError) + .lineLimit(2) } Spacer(minLength: 8) Button(action: onRetry) { @@ -291,15 +314,6 @@ struct DownloadAttentionRow: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.prairieSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.prairieOutline, lineWidth: 1) - ) - ) - .padding(.horizontal, 16) } } @@ -321,27 +335,19 @@ struct DownloadMovieRow: View { fileURL: DownloadManager.shared.posterImageURL(for: record), width: 40 ) - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 7) { - Text(record.title ?? record.contentId) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.prairieOnSurface) - .lineLimit(1) - DownloadKindChip(text: "Movie") - } - if !meta.isEmpty { - Text(meta) - .font(.system(size: 12)) - .foregroundColor(.prairieSecondaryText) - .lineLimit(1) - } + VStack(alignment: .leading, spacing: 3) { + Text(record.title ?? record.contentId) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.prairieOnSurface) + .lineLimit(1) + Text(meta) + .font(.subheadline) + .foregroundColor(.prairieSecondaryText) + .lineLimit(1) } Spacer(minLength: 8) - Text(DownloadFormatting.bytes(record.fileSize)) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.prairieOnSurface) } - .padding(.horizontal, 18) + .padding(.horizontal, 16) .padding(.vertical, 10) .contentShape(Rectangle()) } @@ -351,6 +357,7 @@ struct DownloadMovieRow: View { private var meta: String { var parts: [String] = [] if let sub = record.subtitle, !sub.isEmpty { parts.append(sub) } + parts.append(DownloadFormatting.bytes(record.fileSize)) if watched { parts.append("watched") } return parts.joined(separator: " · ") } @@ -392,20 +399,10 @@ struct DownloadSeriesRow: View { } } } - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(expanded ? Color.prairieSurfaceElevated : Color.prairieSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.prairieOutline, lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .padding(.horizontal, 16) } private var header: some View { - HStack(spacing: 13) { + HStack(spacing: 12) { if selecting { DownloadSelectionCircle(selected: selected) } posterStack VStack(alignment: .leading, spacing: 4) { @@ -414,17 +411,15 @@ struct DownloadSeriesRow: View { .font(.system(size: 16, weight: .semibold)) .foregroundColor(.prairieOnSurface) .lineLimit(1) + .layoutPriority(1) if group.isMonitored { monitorBadge } } Text(subtitleLine) - .font(.system(size: 12.5)) + .font(.subheadline) .foregroundColor(.prairieSecondaryText) .lineLimit(1) } Spacer(minLength: 6) - Text(DownloadFormatting.bytes(group.totalBytes)) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.prairieOnSurface) if !selecting { Button { withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } @@ -439,7 +434,9 @@ struct DownloadSeriesRow: View { .accessibilityLabel(expanded ? "Collapse episodes" : "Expand episodes") } } - .padding(12) + .padding(.leading, 16) + .padding(.trailing, 10) + .padding(.vertical, 10) .contentShape(Rectangle()) .onTapGesture(perform: headerTap) } @@ -454,25 +451,25 @@ struct DownloadSeriesRow: View { } } + /// The series poster with two cards peeking out behind it, marking the + /// row as a group of episodes. private var posterStack: some View { ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.prairieSurfaceVariant) - .frame(width: 46, height: 62) - .offset(x: 9) - .opacity(0.45) + .fill(Color.white.opacity(0.10)) + .frame(width: 40, height: 54) + .offset(x: 7) RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.prairieSurface) - .frame(width: 46, height: 64) - .offset(x: 4) - .opacity(0.7) + .fill(Color.white.opacity(0.18)) + .frame(width: 40, height: 57) + .offset(x: 3.5) DownloadPosterThumb( thumbhash: group.posterThumbhash, fileURL: posterFileURL, - width: 46 + width: 40 ) } - .frame(width: 59, height: 66, alignment: .leading) + .frame(width: 47, height: 60, alignment: .leading) } /// First on-disk poster among the group's episodes — every episode of a @@ -483,24 +480,13 @@ struct DownloadSeriesRow: View { .first } + /// Antenna glyph after the title of a series with an active monitoring + /// subscription — the same glyph as the Monitoring section. private var monitorBadge: some View { - HStack(spacing: 4) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 9, weight: .semibold)) - Text("Monitoring") - .font(.system(size: 9.5, weight: .semibold)) - } - .foregroundColor(.prairieOnSurface) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.prairieChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.prairieChromeRestingBorder, lineWidth: 1) - ) - ) + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.footnote.weight(.semibold)) + .foregroundColor(.prairieSecondaryText) + .accessibilityLabel("Monitoring") } private var subtitleLine: String { @@ -510,6 +496,7 @@ struct DownloadSeriesRow: View { : (group.seasons.first.map { $0.isSpecials ? "Specials" : "Season \($0.seasonNumber)" } ?? "") var line = "\(group.episodeCount) episode\(group.episodeCount == 1 ? "" : "s")" if !seasonPart.isEmpty { line += " · \(seasonPart)" } + line += " · \(DownloadFormatting.bytes(group.totalBytes))" if group.allWatched { line += " · all watched" } else if group.watchedCount > 0 { @@ -528,7 +515,7 @@ struct DownloadSeriesRow: View { .font(.system(size: 11.5)) .foregroundColor(.prairieSecondaryText) } - .padding(.horizontal, 14) + .padding(.horizontal, 16) .padding(.top, 9) .padding(.bottom, 5) } diff --git a/iosApp/iosApp/Downloads/Views/DownloadsManagerComponents.swift b/iosApp/iosApp/Downloads/Views/DownloadsManagerComponents.swift index 256cc3563..ce9778ebb 100644 --- a/iosApp/iosApp/Downloads/Views/DownloadsManagerComponents.swift +++ b/iosApp/iosApp/Downloads/Views/DownloadsManagerComponents.swift @@ -1,11 +1,117 @@ #if !os(tvOS) import SwiftUI -// MARK: - Storage hero +// MARK: - Inset-grouped rows -/// The storage hero at the top of the Downloads Manager: a big "used of -/// device" figure with a typed breakdown bar (series / movies / in -/// progress / other). +/// Where a row sits in an inset-grouped block of the Manager. Rows stay +/// direct children of the Manager's `LazyVStack`, so long lists stay lazy, +/// and each draws its own slice of the group: rounded outer corners on the +/// ends and a hairline separator above every row but the first. +enum DownloadGroupPosition { + case only, first, middle, last + + init(index: Int, count: Int) { + switch (index, count) { + case (_, 1): self = .only + case (0, _): self = .first + case (count - 1, _): self = .last + default: self = .middle + } + } + + var roundsTop: Bool { self == .only || self == .first } + var roundsBottom: Bool { self == .only || self == .last } + var hasSeparator: Bool { self == .middle || self == .last } +} + +private struct DownloadGroupedRowModifier: ViewModifier { + let position: DownloadGroupPosition + let separatorInset: CGFloat + @Environment(\.displayScale) private var displayScale + + private static let cornerRadius: CGFloat = 26 + + func body(content: Content) -> some View { + let shape = UnevenRoundedRectangle( + topLeadingRadius: position.roundsTop ? Self.cornerRadius : 0, + bottomLeadingRadius: position.roundsBottom ? Self.cornerRadius : 0, + bottomTrailingRadius: position.roundsBottom ? Self.cornerRadius : 0, + topTrailingRadius: position.roundsTop ? Self.cornerRadius : 0, + style: .continuous + ) + content + .background(Color.prairieGroupedCell) + .overlay(alignment: .top) { + if position.hasSeparator { + Rectangle() + .fill(Color.prairieDivider) + .frame(height: 1 / displayScale) + .padding(.leading, separatorInset) + } + } + .clipShape(shape) + #if os(iOS) + .contentShape(.contextMenuPreview, shape) + #endif + } +} + +extension View { + /// Draws this row as one slice of an inset-grouped block: background, + /// rounded ends, and separator. Apply it inside any `.contextMenu` so the + /// lifted preview keeps the cell's shape, then inset the result with + /// `downloadGroupInset()`. The default separator inset lines up with the + /// text beside a 40-point poster. + func downloadGroupSlice( + _ position: DownloadGroupPosition, + separatorInset: CGFloat = 68 + ) -> some View { + modifier(DownloadGroupedRowModifier(position: position, separatorInset: separatorInset)) + } + + /// Horizontal margin between an inset group and the screen edges. + func downloadGroupInset() -> some View { + padding(.horizontal, 16) + } + + /// `downloadGroupSlice` plus the group margin, for rows without a + /// context menu. + func downloadGroupedRow( + _ position: DownloadGroupPosition, + separatorInset: CGFloat = 68 + ) -> some View { + downloadGroupSlice(position, separatorInset: separatorInset) + .downloadGroupInset() + } +} + +/// Sentence-case header above an inset group, with an optional trailing +/// count, matching the system grouped-list headers in Settings. +struct DownloadSectionHeader: View { + let title: String + var count: Int? = nil + + var body: some View { + HStack { + Text(title) + Spacer() + if let count { + Text("\(count)") + } + } + .font(.subheadline.weight(.medium)) + .foregroundColor(.prairieSecondaryText) + .padding(.horizontal, 32) + .padding(.top, 22) + .padding(.bottom, 7) + } +} + +// MARK: - Storage summary + +/// The storage card at the top of the Downloads Manager: a big "used of +/// device" figure over a breakdown bar in Prairie palette colors +/// (series / movies / in progress / other). struct DownloadsStorageHeader: View { let used: Int64 let breakdown: DownloadStorageBreakdown @@ -13,20 +119,25 @@ struct DownloadsStorageHeader: View { @State private var device = DownloadFilePaths.deviceStorage() + private static let seriesColor = Color.prairieBrandBlue + private static let moviesColor = Color.prairieBrandRed + private static let inProgressColor = Color.prairieBrandOrange + private static let otherColor = Color.prairieOnSurface.opacity(0.3) + var body: some View { - VStack(alignment: .leading, spacing: 13) { + VStack(alignment: .leading, spacing: 12) { ( Text(DownloadFormatting.bytes(used)) - .font(.system(size: 26, weight: .bold)) + .font(.title2.bold()) .foregroundColor(.prairieOnSurface) + Text(contextSuffix) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(.prairieSecondaryText) ) if activeCount > 0 { Text(inProgressLine) - .font(.system(size: 12.5)) + .font(.footnote) .foregroundColor(.prairieSecondaryText) } @@ -37,15 +148,6 @@ struct DownloadsStorageHeader: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color.prairieSurfaceElevated) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .stroke(Color.prairieOutline, lineWidth: 1) - ) - ) - .padding(.horizontal, 16) } private var contextSuffix: String { @@ -70,50 +172,58 @@ struct DownloadsStorageHeader: View { let total = max(CGFloat(breakdown.total), 1) let width = geo.size.width HStack(spacing: 2) { - segment(width: width * CGFloat(breakdown.series) / total, opacity: 1) - segment(width: width * CGFloat(breakdown.movies) / total, opacity: 0.52) - segment(width: width * CGFloat(breakdown.inProgress) / total, opacity: 0.34) - segment(width: width * CGFloat(breakdown.other) / total, opacity: 0.18) + segment(width: width * CGFloat(breakdown.series) / total, color: Self.seriesColor) + segment(width: width * CGFloat(breakdown.movies) / total, color: Self.moviesColor) + segment(width: width * CGFloat(breakdown.inProgress) / total, color: Self.inProgressColor) + segment(width: width * CGFloat(breakdown.other) / total, color: Self.otherColor) } } - .frame(height: 10) - .clipShape(Capsule()) + .frame(height: 18) + .background(Color.prairieChromeRestingFill) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } - private func segment(width: CGFloat, opacity: Double) -> some View { - Color.prairieOnSurface.opacity(opacity).frame(width: max(0, width)) + private func segment(width: CGFloat, color: Color) -> some View { + color.frame(width: max(0, width)) } - @ViewBuilder private var legend: some View { - HStack(spacing: 16) { - legendItem(opacity: 1, bytes: breakdown.series, label: "Series") - legendItem(opacity: 0.52, bytes: breakdown.movies, label: "Movies") - legendItem(opacity: 0.34, bytes: breakdown.inProgress, label: "In progress") - legendItem(opacity: 0.18, bytes: breakdown.other, label: "Other") + /// Two columns, so four categories fit a phone width without wrapping + /// inside a label. + private var legend: some View { + LazyVGrid( + columns: [GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .leading)], + alignment: .leading, + spacing: 6 + ) { + legendItem(color: Self.seriesColor, bytes: breakdown.series, label: "Series") + legendItem(color: Self.moviesColor, bytes: breakdown.movies, label: "Movies") + legendItem(color: Self.inProgressColor, bytes: breakdown.inProgress, label: "In progress") + legendItem(color: Self.otherColor, bytes: breakdown.other, label: "Other") } } - @ViewBuilder private func legendItem(opacity: Double, bytes: Int64, label: String) -> some View { + @ViewBuilder private func legendItem(color: Color, bytes: Int64, label: String) -> some View { if bytes > 0 { HStack(spacing: 6) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.prairieOnSurface.opacity(opacity)) - .frame(width: 9, height: 9) - Text(DownloadFormatting.bytes(bytes)) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.prairieOnSurface) + Circle() + .fill(color) + .frame(width: 8, height: 8) Text(label) - .font(.system(size: 12)) .foregroundColor(.prairieSecondaryText) + Text(DownloadFormatting.bytes(bytes)) + .fontWeight(.semibold) + .foregroundColor(.prairieOnSurface) } + .font(.footnote) + .lineLimit(1) } } } -// MARK: - Reclaim banner +// MARK: - Reclaim suggestion -/// "Free up X — N watched episodes" suggestion. Tapping opens the reclaim -/// review sheet. +/// "Free up X · N watched" row under the storage summary. Tapping opens the +/// reclaim review sheet. struct DownloadReclaimBanner: View { let episodeCount: Int let bytes: Int64 @@ -122,59 +232,36 @@ struct DownloadReclaimBanner: View { var body: some View { Button(action: onReview) { HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.prairieChromeSelectedFill) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.prairieChromeSelectedBorder, lineWidth: 1) - ) - .frame(width: 34, height: 34) - .overlay( - Image(systemName: "sparkles") - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.prairieOnSurface) - ) - - VStack(alignment: .leading, spacing: 2) { + ( Text("Free up \(DownloadFormatting.bytes(bytes))") - .font(.system(size: 14, weight: .semibold)) .foregroundColor(.prairieOnSurface) - Text("\(episodeCount) item\(episodeCount == 1 ? "" : "s") you've finished") - .font(.system(size: 12.5)) + + Text(" · \(episodeCount) watched") .foregroundColor(.prairieSecondaryText) - } + ) + .font(.subheadline) + .lineLimit(1) Spacer(minLength: 8) Text("Review") - .font(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(.prairieOnSurface) - .padding(.horizontal, 13) + .padding(.horizontal, 14) .padding(.vertical, 7) - .background( - Capsule().fill(Color.prairieChromeSelectedFill) - .overlay(Capsule().stroke(Color.prairieChromeSelectedBorder, lineWidth: 1)) - ) + .background(Capsule().fill(Color.prairieChromeSelectedFill)) } - .padding(13) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.prairieSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.prairieChromeSelectedBorder, lineWidth: 1) - ) - ) + .padding(.horizontal, 16) + .padding(.vertical, 10) .contentShape(Rectangle()) } .buttonStyle(.plain) - .padding(.horizontal, 16) + .accessibilityHint("Reviews \(episodeCount) item\(episodeCount == 1 ? "" : "s") you've finished") } } // MARK: - Sort control -/// "Sort: Largest first ▾ N items" row above the Manager list. +/// "Largest First ▾ N items" row above the Manager list. struct DownloadSortControl: View { @Binding var option: DownloadSortOption let itemCount: Int @@ -190,9 +277,9 @@ struct DownloadSortControl: View { } label: { HStack(spacing: 5) { Text(option.displayName) - .font(.system(size: 13.5, weight: .semibold)) + .font(.subheadline.weight(.semibold)) Image(systemName: "chevron.down") - .font(.system(size: 11, weight: .semibold)) + .font(.caption.weight(.semibold)) } .foregroundColor(.prairieOnSurface) } @@ -200,12 +287,12 @@ struct DownloadSortControl: View { Spacer() Text("\(itemCount) item\(itemCount == 1 ? "" : "s")") - .font(.system(size: 12.5)) - .foregroundColor(.prairieOnSurface.opacity(0.38)) + .font(.footnote) + .foregroundColor(.prairieSecondaryText) } - .padding(.horizontal, 22) - .padding(.top, 8) - .padding(.bottom, 2) + .padding(.horizontal, 32) + .padding(.top, 22) + .padding(.bottom, 8) } } @@ -260,26 +347,4 @@ struct DownloadPosterThumb: View { .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) } } - -/// "MOVIE" / "SERIES" capsule chip used in Manager rows. -struct DownloadKindChip: View { - let text: String - - var body: some View { - Text(text.uppercased()) - .font(.system(size: 9.5, weight: .semibold)) - .tracking(0.4) - .foregroundColor(.prairieSecondaryText) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(Color.prairieChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .stroke(Color.prairieChromeRestingBorder, lineWidth: 1) - ) - ) - } -} #endif diff --git a/iosApp/iosApp/Downloads/Views/OfflineBrowse.swift b/iosApp/iosApp/Downloads/Views/OfflineBrowse.swift index 278a2215b..24fed4568 100644 --- a/iosApp/iosApp/Downloads/Views/OfflineBrowse.swift +++ b/iosApp/iosApp/Downloads/Views/OfflineBrowse.swift @@ -186,9 +186,10 @@ struct OfflineSeriesBrowseView: View { // MARK: - Leaf detail (movie or episode) -/// Offline leaf detail for one downloaded movie or episode: synopsis, -/// resume, the audio/subtitle/quality baked into the stored manifest, and -/// a single-item delete. +/// Offline leaf detail for one downloaded movie or episode, built from the +/// same hero as the online detail page: the downloaded backdrop (or poster) +/// and title logo, metadata, Play/Resume, then what this download contains +/// and a confirmed delete. Everything comes from the download bundle on disk. struct OfflineDownloadDetailView: View { let downloadId: String @@ -197,6 +198,7 @@ struct OfflineDownloadDetailView: View { private var manager: DownloadManager { DownloadManager.shared } @State private var manifest: OfflineManifest? + @State private var confirmingDelete = false private var record: DownloadRecord? { manager.record(id: downloadId) } @@ -206,13 +208,14 @@ struct OfflineDownloadDetailView: View { content(record) } else { EmptyStateView(icon: "arrow.down.circle", title: "Download Removed", subtitle: nil) + .prairiePageBackground() } } - .prairiePageBackground() .navigationTitle("") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .prairieNavigationBarBackgroundHidden() .task { if manifest == nil, let record { manifest = await manager.loadManifest(for: record) } } @@ -220,210 +223,148 @@ struct OfflineDownloadDetailView: View { } private func content(_ record: DownloadRecord) -> some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - still(record) - - VStack(alignment: .leading, spacing: 0) { - if record.type == "episode" { - Text(episodeEyebrow(record)) - .font(.system(size: 12, weight: .semibold)) - .tracking(0.4) - .foregroundColor(.prairieSecondaryText) - .padding(.bottom, 5) - } - Text(record.title ?? record.contentId) - .font(.system(size: 21, weight: .bold)) - .foregroundColor(.prairieOnSurface) - Text(metaLine(record)) - .font(.system(size: 12)) - .foregroundColor(.prairieSecondaryText) - .padding(.top, 4) - - availabilityChip(record) - .padding(.top, 13) - - playRow(record) - .padding(.top, 14) - - if let overview = manifest?.overview, !overview.isEmpty { - Text(overview) - .font(.system(size: 13)) - .foregroundColor(.prairieSecondaryText) - .lineSpacing(2) - .padding(.top, 16) - } - - facts(record) - .padding(.top, 16) - - deleteButton(record) - .padding(.top, 16) + let backdrop = manager.backdropImageURL(for: record)?.absoluteString + let poster = manager.posterImageURL(for: record)?.absoluteString + return PhoneDetailPageSurface( + backdropURL: backdrop ?? poster, + backdropThumbhash: backdrop != nil ? manifest?.backdropThumbhash : record.posterThumbhash, + enablesArtworkGlass: true + ) { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 32) { + hero(record, backdrop: backdrop, poster: poster) + downloadSection(record) + .padding(.horizontal, PrairieTheme.safePadding) } - .padding(.horizontal, 20) - .padding(.top, 16) - - Color.clear.frame(height: 30) + .padding(.bottom, 40) } + .ignoresSafeArea(edges: .top) + .coordinateSpace(name: PhoneDetailScrollCoordinateSpace.name) } - } - - private func still(_ record: DownloadRecord) -> some View { - Button { play(record) } label: { - ZStack { - LinearGradient( - colors: [Color.prairieSurfaceElevated, Color.prairieBackground], - startPoint: .top, - endPoint: .bottom - ) - Image(systemName: "play.circle.fill") - .font(.system(size: 50)) - .foregroundColor(.white.opacity(0.92)) - if let fraction = resumeFraction(record) { - VStack { - Spacer() - GeometryReader { geo in - ZStack(alignment: .leading) { - Color.prairieOnSurface.opacity(0.22) - Color.prairieOnSurface.frame(width: geo.size.width * fraction) - } - } - .frame(height: 4) - } - } + .alert("Delete this download?", isPresented: $confirmingDelete) { + Button("Delete", role: .destructive) { + manager.deleteDownload(id: record.id) + dismiss() } - .frame(height: 190) - .clipped() - .contentShape(Rectangle()) + Button("Cancel", role: .cancel) {} + } message: { + Text("This frees \(DownloadFormatting.bytes(record.fileSize)) on this device.") } - .buttonStyle(.plain) - .accessibilityLabel("Play") } - private func availabilityChip(_ record: DownloadRecord) -> some View { - HStack(spacing: 7) { - Image(systemName: "checkmark.circle") - .font(.system(size: 12, weight: .semibold)) - Text(availabilityText(record)) - .font(.system(size: 11.5, weight: .semibold)) - } - .foregroundColor(.prairieOnSurface) - .padding(.horizontal, 11) - .padding(.vertical, 6) - .background( - Capsule() - .fill(Color.prairieChromeSelectedFill) - .overlay(Capsule().stroke(Color.prairieChromeSelectedBorder, lineWidth: 1)) + private func hero(_ record: DownloadRecord, backdrop: String?, poster: String?) -> some View { + PhoneDetailHero( + title: record.title ?? record.contentId, + // A series logo would misname an episode, so only movies use one. + logoUrl: record.type == "episode" ? nil : manager.logoImageURL(for: record)?.absoluteString, + posterUrl: poster, + posterThumbhash: record.posterThumbhash, + backdropUrl: backdrop, + backdropThumbhash: manifest?.backdropThumbhash, + // The series and episode number already lead the metadata line. + eyebrow: nil, + sourceTokens: sourceTokens(record), + ratingChip: ratingChip, + overview: manifest?.overview, + factsLine: factsLine(record), + enablesArtworkParallax: true, + actions: { actions(record) }, + belowOverview: { EmptyView() } ) } - private func playRow(_ record: DownloadRecord) -> some View { - HStack(spacing: 11) { - Button { play(record) } label: { - HStack(spacing: 8) { - Image(systemName: "play.fill") - Text(playLabel(record)).fontWeight(.bold) - } - .font(.system(size: 14.5)) - .frame(maxWidth: .infinity) - .frame(height: 46) - .background(Color.prairieOnSurface) - .foregroundColor(.black) - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - } - .buttonStyle(.plain) + private func actions(_ record: DownloadRecord) -> some View { + VStack(spacing: 14) { + PhonePrimaryPillButton( + icon: "play.fill", + title: playLabel(record), + action: { play(record) }, + fullWidth: true, + progress: resumeFraction(record) + ) - if resumeFraction(record) != nil { - Button { playFromStart(record) } label: { - Image(systemName: "gobackward") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.prairieOnSurface) - .frame(width: 46, height: 46) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(Color.prairieChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(Color.prairieChromeRestingBorder, lineWidth: 1) - ) - ) + PhoneLabeledActionRow { + if resumeFraction(record) != nil { + PhoneLabeledAction(icon: "gobackward", label: "Start Over", isToggle: false) { + playFromStart(record) + } + } + PhoneLabeledAction(icon: "trash", label: "Delete", isToggle: false) { + confirmingDelete = true } - .buttonStyle(.plain) - .accessibilityLabel("Restart from beginning") } } + .frame(maxWidth: .infinity) } - private func facts(_ record: DownloadRecord) -> some View { - VStack(spacing: 0) { - ForEach(factRows(record), id: \.0) { key, value in - HStack { - Text(key) - .font(.system(size: 12.5)) - .foregroundColor(.prairieSecondaryText) - Spacer() - Text(value) - .font(.system(size: 12.5, weight: .medium)) - .foregroundColor(.prairieOnSurface) + /// What this copy on the device contains, in the online page's + /// Details layout. + private func downloadSection(_ record: DownloadRecord) -> some View { + VStack(alignment: .leading, spacing: 14) { + PhoneSectionHeader(title: "Download") + VStack(spacing: 0) { + ForEach(Array(factRows(record).enumerated()), id: \.element.0) { index, row in + if index > 0 { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(height: 1) + } + HStack(alignment: .top, spacing: 16) { + Text(row.0.uppercased()) + .font(.system(size: 11, weight: .bold)) + .tracking(1.2) + .foregroundColor(.prairieOnSurface.opacity(0.5)) + .frame(width: 100, alignment: .leading) + Text(row.1) + .font(.system(size: 14)) + .foregroundColor(.prairieOnSurface) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 12) } - .padding(.vertical, 11) - Divider().overlay(Color.prairieDivider) } } - .overlay(Divider().overlay(Color.prairieDivider), alignment: .top) } - private func deleteButton(_ record: DownloadRecord) -> some View { - Button { - manager.deleteDownload(id: record.id) - dismiss() - } label: { - HStack(spacing: 8) { - Image(systemName: "trash") - Text("Delete download · Free \(DownloadFormatting.bytes(record.fileSize))") - .fontWeight(.semibold) + // MARK: - Hero metadata + + /// Episodes lead with their episode number, then the series, so the + /// hero's two-line metadata never truncates what identifies them. + private func factsLine(_ record: DownloadRecord) -> [PhoneHeroFactToken] { + var tokens: [PhoneHeroFactToken] = [] + if record.type == "episode" { + let tag = [record.seasonNumber.map { "S\($0)" }, record.episodeNumber.map { "E\($0)" }] + .compactMap { $0 } + .joined(separator: " ") + if !tag.isEmpty { tokens.append(.text(tag)) } + if let series = record.seriesTitle ?? manifest?.seriesTitle, !series.isEmpty { + tokens.append(.text(series)) } - .font(.system(size: 13.5)) - .frame(maxWidth: .infinity) - .frame(height: 44) - .foregroundColor(.prairieError) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(Color.prairieChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(Color.prairieChromeRestingBorder, lineWidth: 1) - ) - ) } - .buttonStyle(.plain) + if let year = manifest?.year, year > 0 { tokens.append(.text(String(year))) } + if let runtime = manifest?.runtime, runtime > 0 { + tokens.append(.text(PhoneHeroMetadata.formatRuntime(runtime))) + } + if let resolution = manifest?.resolution, !resolution.isEmpty { tokens.append(.text(resolution)) } + if manifest?.hdr == true { tokens.append(.text("HDR")) } + return tokens } - // MARK: - Derived text - - private func episodeEyebrow(_ record: DownloadRecord) -> String { - let series = (record.seriesTitle ?? manifest?.seriesTitle ?? "").uppercased() - let tag = [record.seasonNumber.map { "S\($0)" }, record.episodeNumber.map { "E\($0)" }] - .compactMap { $0 } - .joined(separator: " · ") - return [series, tag].filter { !$0.isEmpty }.joined(separator: " · ") + private func sourceTokens(_ record: DownloadRecord) -> [String] { + guard record.type != "episode", let genres = manifest?.genres, !genres.isEmpty else { return [] } + return [genres.prefix(2).joined(separator: ", ")] } - private func metaLine(_ record: DownloadRecord) -> String { - var parts: [String] = [] - if let runtime = manifest?.runtime, runtime > 0 { parts.append("\(runtime) min") } - if let year = manifest?.year { parts.append(String(year)) } - return parts.joined(separator: " · ") + private var ratingChip: String? { + guard let rating = manifest?.contentRating?.trimmingCharacters(in: .whitespaces), + !rating.isEmpty else { return nil } + return rating } - private func availabilityText(_ record: DownloadRecord) -> String { - var parts = ["Downloaded", DownloadFormatting.bytes(record.fileSize)] - if let resolution = manifest?.resolution, !resolution.isEmpty { parts.append(resolution) } - return parts.joined(separator: " · ") - } + // MARK: - Derived text private func factRows(_ record: DownloadRecord) -> [(String, String)] { - var rows: [(String, String)] = [] + var rows: [(String, String)] = [("Size", DownloadFormatting.bytes(record.fileSize))] if let audio = manifest?.codecAudio, !audio.isEmpty { rows.append(("Audio", audio.uppercased())) } diff --git a/iosApp/iosApp/Extensions/ViewExtensions.swift b/iosApp/iosApp/Extensions/ViewExtensions.swift index 245517aff..b02f24a25 100644 --- a/iosApp/iosApp/Extensions/ViewExtensions.swift +++ b/iosApp/iosApp/Extensions/ViewExtensions.swift @@ -533,12 +533,14 @@ extension View { // only way a modifier can: disable the button and overlay a spinner. self .buttonStyle(.glassProminent) - .tint(.prairieAccent) + .tint(.prairieOnSurface) + .foregroundStyle(Color.prairieBackground) .disabled(isLoading) .overlay { if isLoading { ProgressView() .controlSize(.small) + .tint(Color.prairieBackground) } } } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index c4816210b..2e5314f36 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -6,6 +6,7 @@ org.prairieserver.prairie.downloads-refresh com.continuum.play.downloads-refresh + $(PRODUCT_BUNDLE_IDENTIFIER).downloads.continued.* CFBundleDevelopmentRegion en @@ -77,6 +78,7 @@ audio fetch + processing remote-notification UILaunchScreen diff --git a/iosApp/iosApp/Networking/ConnectionMonitor.swift b/iosApp/iosApp/Networking/ConnectionMonitor.swift index f94665ae6..b62266de8 100644 --- a/iosApp/iosApp/Networking/ConnectionMonitor.swift +++ b/iosApp/iosApp/Networking/ConnectionMonitor.swift @@ -38,6 +38,10 @@ final class ConnectionMonitor { /// Device has a usable network path (Wi-Fi/cellular/wired). private(set) var isDeviceOnline = true + /// The path runs over Wi-Fi or wired Ethernet, which a Wi-Fi-only + /// download may use. Kept in memory only: like every property of the + /// path it can describe where the user is, so it's never logged. + private(set) var isOnWiFiOrWired = true private(set) var serverStatus: ServerStatus = .unknown private(set) var contractStatus: ServerContractStatus = .unknown /// Registry id of the server `contractStatus` describes. A verdict is @@ -158,7 +162,9 @@ final class ConnectionMonitor { private init() { pathMonitor.pathUpdateHandler = { [weak self] path in let online = path.status == .satisfied + let wifiOrWired = online && (path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet)) Task { @MainActor in + if self?.isOnWiFiOrWired != wifiOrWired { self?.isOnWiFiOrWired = wifiOrWired } self?.applyPathUpdate(online: online) } } diff --git a/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift b/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift index b18c3ae43..9bb964b07 100644 --- a/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift +++ b/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift @@ -13,10 +13,6 @@ struct CompanionPairingCard: View { /// mid-flow retry lives INSIDE the card ("Try Again" on the error step). var onDismiss: () -> Void - /// System blue matches the native pairing look; the app's global tint is - /// near-white, which would wash out the primary button. - private let accent = Color.blue - @State private var coordinator: CompanionPairingCoordinator? @State private var startupTask: Task? @State private var selection: Set = [] @@ -136,7 +132,7 @@ struct CompanionPairingCard: View { Image(systemName: "server.rack") .font(.system(size: 16, weight: .semibold)) .frame(width: 34, height: 34) - .background(accent, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .background(Color.prairieIconTile, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) .foregroundStyle(.white) VStack(alignment: .leading, spacing: 2) { Text(server.displayName) @@ -154,7 +150,7 @@ struct CompanionPairingCard: View { .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: isOn ? "checkmark.circle.fill" : "circle") .font(.system(size: 22)) - .foregroundStyle(isOn ? accent : Color.secondary) + .foregroundStyle(isOn ? Color.prairieOnSurface : Color.secondary) } .padding(.horizontal, 14) .padding(.vertical, 12) @@ -284,7 +280,9 @@ struct CompanionPairingCard: View { } .buttonStyle(.borderedProminent) .controlSize(.large) - .tint(accent) + // White fill with dark text, like the app's other primary buttons. + .tint(.prairieOnSurface) + .foregroundStyle(Color.prairieBackground) } private func tertiaryButton( @@ -305,7 +303,7 @@ struct CompanionPairingCard: View { .padding(.vertical, 8) } .buttonStyle(.plain) - .foregroundStyle(accent) + .foregroundStyle(Color.prairieSecondaryText) } private func cancelButton() -> some View { diff --git a/iosApp/iosApp/Screens/Browse/FilterView.swift b/iosApp/iosApp/Screens/Browse/FilterView.swift index c7d603e3f..075962fc4 100644 --- a/iosApp/iosApp/Screens/Browse/FilterView.swift +++ b/iosApp/iosApp/Screens/Browse/FilterView.swift @@ -195,7 +195,7 @@ struct FilterView: View { .font(.prairieBody) .foregroundColor(.prairieOnSurface) } - .tint(Color.prairieAccent) + .tint(.prairieSwitchOn) .listRowBackground(Color.clear) .filterListRowSeparatorHidden() .onChange(of: preserve) { _, newValue in diff --git a/iosApp/iosApp/Screens/Calendar/CalendarDayShelf.swift b/iosApp/iosApp/Screens/Calendar/CalendarDayShelf.swift index d9ce7e2ea..894892709 100644 --- a/iosApp/iosApp/Screens/Calendar/CalendarDayShelf.swift +++ b/iosApp/iosApp/Screens/Calendar/CalendarDayShelf.swift @@ -15,16 +15,13 @@ struct CalendarDayShelf: View { /// the week strip's day selection to hand focus down to the row it /// just scrolled to. var focusRequest: Int = 0 - /// tvOS: called when the focus engine can't resolve an up-move out of - /// this shelf natively — which happens when the days above are empty - /// "Nothing scheduled" stubs (nothing focusable) and the week strip has - /// scrolled off-screen. The host hands focus back up explicitly. - var onMoveUp: (() -> Void)? = nil + /// tvOS: called when focus enters this shelf from outside it. + var onFocusGained: (() -> Void)? = nil @FocusState private var focusedItemId: String? #if os(tvOS) /// Each kick token claims focus exactly once, so `onAppear` re-fires - /// (this shelf lives in a `LazyVStack`) can't yank focus back to a + /// (returning from a detail page) can't yank focus back to a /// previously-selected row while the user is browsing elsewhere. @State private var lastAppliedFocusRequest = 0 #endif @@ -45,11 +42,13 @@ struct CalendarDayShelf: View { .frame(maxWidth: .infinity, alignment: .leading) #if os(tvOS) .focusSection() - .modifier(TVShelfMoveHandler(onMoveUp: onMoveUp)) - // `onAppear` covers the shelf being created lazily by the - // scroll-to-day jump; `onChange` covers shelves already realized. + // `onAppear` covers a shelf created after the request (a new + // week's shelves); `onChange` covers shelves already mounted. .onAppear { applyFocusRequest(focusRequest) } .onChange(of: focusRequest) { _, request in applyFocusRequest(request) } + .onChange(of: focusedItemId) { oldId, newId in + if oldId == nil, newId != nil { onFocusGained?() } + } #endif } @@ -141,26 +140,6 @@ struct CalendarDayShelf: View { } #if os(tvOS) -/// Attaches an `onMoveCommand` only when a handler is supplied, so shelves -/// that don't need the boundary hook never sit in the focus engine's way — -/// same pattern as `MediaRow`'s `TVRowMoveHandler`. Bubbled moves in other -/// directions are dead ends the engine already failed to resolve, so -/// consuming them changes nothing. -private struct TVShelfMoveHandler: ViewModifier { - let onMoveUp: (() -> Void)? - - @ViewBuilder - func body(content: Content) -> some View { - if let onMoveUp { - content.onMoveCommand { direction in - if direction == .up { onMoveUp() } - } - } else { - content - } - } -} - private extension View { /// Routes both initial and d-pad-entry focus to the shelf's first /// card — same `.userInitiated` defaultFocus pattern as `MediaRow`. diff --git a/iosApp/iosApp/Screens/Calendar/CalendarFilterBar.swift b/iosApp/iosApp/Screens/Calendar/CalendarFilterBar.swift index a957665ad..d02a7f640 100644 --- a/iosApp/iosApp/Screens/Calendar/CalendarFilterBar.swift +++ b/iosApp/iosApp/Screens/Calendar/CalendarFilterBar.swift @@ -91,7 +91,15 @@ struct CalendarFilterBar: View { } .padding(containerPadding) .prairieGlass(in: .capsule) + // Full-width focus section: the capsule only occupies the leading + // corner, and a narrow section can't catch up-moves from day + // buttons on the right side of the strip below — the focus engine + // would skip past it to the (full-width) top menu. + .frame(maxWidth: .infinity, alignment: .leading) .focusSection() + // Entering from the week strip lands on the active scope, not the + // segment nearest the focused day. + .defaultFocus($focusedFilter, selected, priority: .userInitiated) .onMoveCommand { direction in if direction == .up { onMoveUp?() diff --git a/iosApp/iosApp/Screens/Calendar/CalendarModels.swift b/iosApp/iosApp/Screens/Calendar/CalendarModels.swift index 988457d01..b5e7d19cd 100644 --- a/iosApp/iosApp/Screens/Calendar/CalendarModels.swift +++ b/iosApp/iosApp/Screens/Calendar/CalendarModels.swift @@ -18,6 +18,11 @@ enum CalendarFilter: String, CaseIterable, Identifiable { case .everything: return "All" } } + + /// Presets an empty week links to: the other two, in filter-bar order. + var emptyStateLinks: [CalendarFilter] { + Self.allCases.filter { $0 != self } + } } // MARK: - Wire types diff --git a/iosApp/iosApp/Screens/Calendar/CalendarView.swift b/iosApp/iosApp/Screens/Calendar/CalendarView.swift index c7c0916eb..d97aa7c6b 100644 --- a/iosApp/iosApp/Screens/Calendar/CalendarView.swift +++ b/iosApp/iosApp/Screens/Calendar/CalendarView.swift @@ -19,11 +19,9 @@ struct CalendarView: View { /// scrolls to that day's shelf and kicks focus onto its first card. @State private var shelfFocusRequest = 0 @State private var shelfFocusDay: Date? - /// Focus hand-back for the boundary up-move: a shelf whose up-press the - /// focus engine couldn't resolve (empty days above, strip off-screen) - /// asks the week strip to reclaim focus. - @State private var stripFocusRequest = 0 - /// Scroll target for the ride home to the strip/filter area. + /// Day of the shelf that last took focus. + @State private var focusedShelfDay: Date? + /// Scroll target for the page's opening position. private static let topContentId = "calendar-top" #endif @@ -189,27 +187,17 @@ struct CalendarView: View { private var tvContent: some View { ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { - LazyVStack(alignment: .leading, spacing: 30) { - // Full-width focus section: the filter capsule only - // occupies the leading corner, and a narrow section - // can't catch up-moves from day buttons on the right - // side of the strip below — the focus engine would - // skip past it to the (full-width) top menu. The - // spacer stretches the section across the row so - // every up-move from the strip lands here first. - HStack(spacing: 0) { - CalendarFilterBar( - selected: viewModel.filter, - onSelect: { viewModel.select(filter: $0) }, - focusRequest: entryFocusRequest, - onMoveUp: onTopMenuFocusRequest - ) - - Spacer(minLength: 0) - } + // Not lazy: a recycled filter bar or shelf would lose its + // applied focus token and replay it from `onAppear`, and the + // focus engine can only move to views that are mounted. + VStack(alignment: .leading, spacing: 30) { + CalendarFilterBar( + selected: viewModel.filter, + onSelect: { viewModel.select(filter: $0) }, + focusRequest: entryFocusRequest, + onMoveUp: onTopMenuFocusRequest + ) .padding(.horizontal, PrairieTheme.safePadding) - .focusSection() - .id(Self.topContentId) CalendarWeekStrip( week: viewModel.week, @@ -221,13 +209,14 @@ struct CalendarView: View { onPreviousWeek: { viewModel.goToPreviousWeek() }, onNextWeek: { viewModel.goToNextWeek() }, onToday: { Task { await viewModel.goToToday() } }, - focusRequest: stripFocusRequest + onFocusGained: { returnToTop(proxy: proxy) } ) shelfArea(proxy: proxy) } .padding(.top, TVTopMenuLayout.contentTopInset) .padding(.bottom, PrairieTheme.largePadding) + .id(Self.topContentId) } .modifier(TVMenuEntryScroll(request: focusRequest, isTopMenuFocused: isTopMenuFocused) { entryFocusRequest = $0 }) } @@ -246,7 +235,6 @@ struct CalendarView: View { } else if viewModel.isEmpty { emptyState } else { - let firstNonEmptyDay = viewModel.week.days.first { viewModel.hasEvents(on: $0) } ForEach(viewModel.week.days, id: \.self) { day in CalendarDayShelf( heading: viewModel.sectionHeading(for: day), @@ -254,9 +242,13 @@ struct CalendarView: View { onEventTap: { event in router.navigate(to: event.detailRoute) }, - prefersDefaultFocusOnFirstItem: day == firstNonEmptyDay, + // Every shelf, not just the first: the focus engine + // otherwise keeps the horizontal position focus had + // before a default-focus redirect, so Down from a + // shelf's first card can land mid-row in the next one. + prefersDefaultFocusOnFirstItem: true, focusRequest: shelfFocusRequest(for: day), - onMoveUp: shelfMoveUpHandler(for: day, proxy: proxy) + onFocusGained: shelfFocusHandler(for: day, proxy: proxy) ) .id(day) .padding(.bottom, shelfBottomPadding) @@ -292,47 +284,57 @@ struct CalendarView: View { .multilineTextAlignment(.center) .padding(.horizontal, PrairieTheme.largePadding) - if viewModel.filter != .everything { - Button("Show Everything", systemImage: "line.3.horizontal.decrease.circle") { - viewModel.select(filter: .everything) - } - .prairiePrimaryButton() - .frame(width: emptyButtonWidth) - .padding(.top, PrairieTheme.smallPadding) - } else { - // tvOS focus needs at least one target below the filter - // bar so d-pad down from it doesn't dead-end (see - // RecommendationsView's empty state). - #if os(tvOS) - Button("Refresh", systemImage: "arrow.clockwise") { - Task { await viewModel.refresh() } + // Every view links to the other two, which also gives tvOS + // focus a target below the week strip so d-pad down from it + // doesn't dead-end (see RecommendationsView's empty state). + HStack(spacing: PrairieTheme.padding) { + ForEach(viewModel.filter.emptyStateLinks) { filter in + Button { + viewModel.select(filter: filter) + } label: { + // Fill the fixed width so iOS 26 glass pills match. + Text(filter.displayLabel) + .frame(maxWidth: .infinity) + } + .prairiePrimaryButton() + .frame(width: emptyButtonWidth) + .accessibilityLabel("Show \(filter.displayLabel)") } - .buttonStyle(PrairiePrimaryButtonStyle()) - .frame(width: emptyButtonWidth) - .padding(.top, PrairieTheme.smallPadding) - #endif } + .padding(.top, PrairieTheme.smallPadding) } .frame(maxWidth: .infinity, minHeight: 320) + #if os(tvOS) + // Full-width section so down from any day in the strip reaches + // the centered buttons, not just from the days above them. + .focusSection() + #endif } private var emptyTitle: String { - viewModel.filter == .following - ? "Nothing from shows you follow" - : "Nothing scheduled this week" + switch viewModel.filter { + case .following: return "Nothing from shows you follow" + case .trending: return "Nothing trending this week" + case .everything: return "Nothing scheduled this week" + } } private var emptySubtitle: String { - viewModel.filter == .following - ? "No upcoming releases this week from shows you watch, favorite, or watchlist." - : "No movie releases or episode airings in this week." + switch viewModel.filter { + case .following: + return "No upcoming releases this week from shows you watch, favorite, or watchlist." + case .trending: + return "No trending movie releases or episode airings in this week." + case .everything: + return "No movie releases or episode airings in this week." + } } private var emptyButtonWidth: CGFloat { #if os(tvOS) - return 360 + return 300 #else - return 220 + return 150 #endif } @@ -354,15 +356,26 @@ struct CalendarView: View { #if os(tvOS) // Hand focus to the selected day's shelf so the remote lands on // its first card instead of staying parked in the week strip. - // Day-less shelves have nothing to focus — leave focus on the - // strip so the user can pick another day. - if viewModel.hasEvents(on: day) { - shelfFocusDay = day + // A day-less shelf has nothing to focus, and the strip scrolls + // off-screen, so focus the nearest day with events instead. + if let target = shelfFocusTarget(forSelected: day) { + focusedShelfDay = target + shelfFocusDay = target shelfFocusRequest += 1 } #endif } + #if os(tvOS) + /// The selected day if it has events, otherwise the next day with + /// events, otherwise the previous one. + private func shelfFocusTarget(forSelected day: Date) -> Date? { + let days = viewModel.week.days.filter { viewModel.hasEvents(on: $0) } + if days.contains(day) { return day } + return days.first { $0 > day } ?? days.last { $0 < day } + } + #endif + /// Per-shelf kick token: only the most recently selected day sees a /// non-zero, changing value, so exactly one shelf claims focus. private func shelfFocusRequest(for day: Date) -> Int { @@ -373,38 +386,40 @@ struct CalendarView: View { #endif } - /// tvOS: boundary up-move escape hatch. When the focus engine can't - /// resolve an up-press out of a shelf natively — the days above are - /// empty "Nothing scheduled" stubs and the week strip has scrolled - /// off-screen — the press bubbles to the shelf's `onMoveCommand` and - /// lands here: hop to the previous day with events, or ride home to - /// the week strip when nothing focusable is above. - private func shelfMoveUpHandler(for day: Date, proxy: ScrollViewProxy) -> (() -> Void)? { + private func shelfFocusHandler(for day: Date, proxy: ScrollViewProxy) -> (() -> Void)? { #if os(tvOS) - guard viewModel.hasEvents(on: day) else { return nil } - return { handleShelfMoveUp(from: day, proxy: proxy) } + return { shelfGainedFocus(day, proxy: proxy) } #else return nil #endif } #if os(tvOS) - private func handleShelfMoveUp(from day: Date, proxy: ScrollViewProxy) { - if let previous = viewModel.week.days.last(where: { - $0 < day && viewModel.hasEvents(on: $0) - }) { - withAnimation(PrairieTheme.springAnimation) { - proxy.scrollTo(previous, anchor: .top) + /// The focus engine reveals a shelf with the least scrolling. Moving up, + /// or down onto the last shelf, that leaves the heading under the top + /// menu. Frame every shelf-to-shelf move where the engine places rows + /// on the way down: bottom-aligned. + private func shelfGainedFocus(_ day: Date, proxy: ScrollViewProxy) { + defer { focusedShelfDay = day } + guard let previous = focusedShelfDay, day != previous else { return } + DispatchQueue.main.async { + withAnimation(Self.focusScrollAnimation) { + proxy.scrollTo(day, anchor: .bottom) } - shelfFocusDay = previous - shelfFocusRequest += 1 - } else { - withAnimation(PrairieTheme.springAnimation) { + } + } + + /// Focus reaching the week strip restores the opening scroll position. + private func returnToTop(proxy: ScrollViewProxy) { + focusedShelfDay = nil + DispatchQueue.main.async { + withAnimation(Self.focusScrollAnimation) { proxy.scrollTo(Self.topContentId, anchor: .top) } - stripFocusRequest += 1 } } - #endif + /// Match the pace of the focus engine's own reveal scrolls. + private static let focusScrollAnimation = Animation.easeInOut(duration: 0.45) + #endif } diff --git a/iosApp/iosApp/Screens/Calendar/CalendarWeekStrip.swift b/iosApp/iosApp/Screens/Calendar/CalendarWeekStrip.swift index bf4a0ade1..8705a48e1 100644 --- a/iosApp/iosApp/Screens/Calendar/CalendarWeekStrip.swift +++ b/iosApp/iosApp/Screens/Calendar/CalendarWeekStrip.swift @@ -19,16 +19,11 @@ struct CalendarWeekStrip: View { let onPreviousWeek: () -> Void let onNextWeek: () -> Void let onToday: () -> Void - /// tvOS: programmatic focus kick — when this changes to a new non-zero - /// token, focus rides home onto the selected day's button. Used by the - /// shelf area's boundary up-move when nothing focusable sits between a - /// shelf and the strip. - var focusRequest: Int = 0 + /// tvOS: called when focus enters the strip from outside it. + var onFocusGained: (() -> Void)? = nil #if os(tvOS) @FocusState private var focusedControl: StripControl? - /// Each kick token claims focus exactly once (see `CalendarDayShelf`). - @State private var lastAppliedFocusRequest = 0 #endif var body: some View { @@ -137,36 +132,18 @@ struct CalendarWeekStrip: View { } .padding(.horizontal, PrairieTheme.safePadding) .focusSection() + // Entering the strip from the filter bar or the first shelf lands + // on the selected day, not the day button nearest the old focus. + .defaultFocus($focusedControl, .day(selectedStripDay), priority: .userInitiated) .animation(PrairieTheme.springAnimation, value: isCurrentWeek) - .onChange(of: focusRequest) { _, request in applyFocusRequest(request) } - .onAppear { applyFocusRequest(focusRequest) } - } - - private func applyFocusRequest(_ request: Int) { - guard request > 0, request != lastAppliedFocusRequest else { return } - lastAppliedFocusRequest = request - // The kick arrives together with a scroll-to-top; the strip may - // still be sliding into view, and the focus engine silently drops - // writes to off-screen targets. Claim, verify, re-claim until the - // scroll settles — same pattern as `MediaRow.claimFirstItemFocus`. - let target = StripControl.day(selectedStripDay) - DispatchQueue.main.async { - claimFocus(on: target) - } - } - - private func claimFocus(on control: StripControl, attempt: Int = 0) { - focusedControl = control - guard attempt < 8 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { - guard focusedControl != control else { return } - claimFocus(on: control, attempt: attempt + 1) + .onChange(of: focusedControl) { oldControl, newControl in + if oldControl == nil, newControl != nil { onFocusGained?() } } } /// The strip's day-button focus values are the exact `week.days` dates; /// `selectedDay` is same-day but not guaranteed same-instant, so resolve - /// it back to the matching strip date before claiming focus. + /// it back to the matching strip date for default focus. private var selectedStripDay: Date { week.days.first { Calendar.current.isDate($0, inSameDayAs: selectedDay) } ?? week.days.first diff --git a/iosApp/iosApp/Screens/Detail/Phone/PhoneDetailActionRow.swift b/iosApp/iosApp/Screens/Detail/Phone/PhoneDetailActionRow.swift index c838c780c..19ee34cb2 100644 --- a/iosApp/iosApp/Screens/Detail/Phone/PhoneDetailActionRow.swift +++ b/iosApp/iosApp/Screens/Detail/Phone/PhoneDetailActionRow.swift @@ -22,6 +22,9 @@ struct PhoneLabeledAction: View { /// reads the same in both states tells a VoiceOver user neither what is /// true now nor what activating will do. var accessibilityLabelOverride: String? = nil + /// False for one-shot commands (Start Over, Delete), which have no + /// on/off state for VoiceOver to announce. + var isToggle = true let action: () -> Void private var resolvedIcon: String { @@ -51,7 +54,7 @@ struct PhoneLabeledAction: View { } .buttonStyle(.plain) .accessibilityLabel(accessibilityLabelOverride ?? label) - .accessibilityValue(isActive ? "On" : "Off") + .accessibilityValue(isToggle ? (isActive ? "On" : "Off") : "") .accessibilityAddTraits(isActive ? [.isSelected] : []) } } diff --git a/iosApp/iosApp/Screens/Detail/SeriesDetailContent.swift b/iosApp/iosApp/Screens/Detail/SeriesDetailContent.swift index ecc3c60e5..04f2f5cf8 100644 --- a/iosApp/iosApp/Screens/Detail/SeriesDetailContent.swift +++ b/iosApp/iosApp/Screens/Detail/SeriesDetailContent.swift @@ -238,6 +238,7 @@ struct SeriesDetailContent: View { selectedSeason: selectedSeason, episodes: episodes, episodesBySeason: episodesBySeason, + episodeTarget: episodeDownloadTarget, style: .labeled ) } @@ -436,6 +437,21 @@ struct SeriesDetailContent: View { return selectedNextUpFileId } + /// Nil until the highlighted episode's own versions have loaded, so the + /// menu never offers a download labeled with another episode's version. + private var episodeDownloadTarget: SeriesEpisodeDownloadTarget? { + guard let episode = nextUpEpisode, + let watchDetail = nextUpWatchDetail, + watchDetail.contentId == episode.contentId, + !watchDetail.versions.isEmpty else { return nil } + return SeriesEpisodeDownloadTarget( + episode: episode, + versions: watchDetail.versions, + selectedFileId: selectedNextUpFileId, + lastFileId: watchDetail.userData?.lastFileId + ) + } + private var nextUpVersions: [FileVersion] { nextUpWatchDetail?.versions ?? [] } diff --git a/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift b/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift index 30ffe7fcc..caf366e38 100644 --- a/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift +++ b/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift @@ -176,6 +176,8 @@ struct OnboardingTourView: View { ) .font(.prairieBody) .foregroundStyle(Color.auroraInk) + // The accent is near-white, which would hide an on switch's knob. + .tint(.prairieSwitchOn) .disabled(viewModel.isSaving) .padding(14) .auroraGlass(cornerRadius: 20) diff --git a/iosApp/iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift b/iosApp/iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift index 0ef3d5609..5eacead49 100644 --- a/iosApp/iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift +++ b/iosApp/iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift @@ -242,7 +242,7 @@ struct PlayerSettingsSheet: View { viewModel.setSubtitleMatchesSystemAppearance(enabled) } )) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Toggle("Save for this device and profile", isOn: Binding( get: { viewModel.settings.subtitleUsesDeviceAppearanceOverride }, @@ -250,7 +250,7 @@ struct PlayerSettingsSheet: View { Task { await viewModel.setSubtitleDeviceOverrideEnabled(enabled) } } )) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) .disabled(matchesSystem) } footer: { Text(matchesSystem @@ -278,7 +278,7 @@ struct PlayerSettingsSheet: View { } Toggle("Text outline", isOn: appearanceBoolBinding(\.textOutline)) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Picker("Outline color", selection: appearanceStringBinding(\.textOutlineColor)) { ForEach(SubtitleAppearance.outlineColors, id: \.hex) { color in @@ -341,7 +341,7 @@ struct PlayerSettingsSheet: View { next.backgroundOpacity = percent Task { await viewModel.setSubtitleAppearance(next) } } - .tint(.prairieAccent) + .tint(.prairieOnSurface) Text("\(Int(draftOpacity ?? committed))%") .monospacedDigit() .foregroundStyle(.secondary) @@ -384,7 +384,7 @@ struct PlayerSettingsSheet: View { get: { viewModel.settings.autoPlayNextEpisode }, set: { viewModel.settings.setAutoPlayNextEpisode($0) } )) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } } } @@ -400,7 +400,7 @@ struct PlayerSettingsSheet: View { .foregroundStyle(.secondary) } } - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } NavigationLink { @@ -567,7 +567,7 @@ struct PlayerSettingsSheet: View { get: { viewModel.settings.autoPlayNextEpisode }, set: { viewModel.settings.setAutoPlayNextEpisode($0) } )) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } } } @@ -624,7 +624,7 @@ struct PlayerSettingsSheet: View { viewModel.setSubtitleMatchesSystemAppearance(enabled) } )) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Toggle("Save for this device and profile", isOn: Binding( get: { viewModel.settings.subtitleUsesDeviceAppearanceOverride }, @@ -632,7 +632,7 @@ struct PlayerSettingsSheet: View { Task { await viewModel.setSubtitleDeviceOverrideEnabled(enabled) } } )) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) .disabled(matchesSystem) Group { @@ -655,7 +655,7 @@ struct PlayerSettingsSheet: View { } Toggle("Text outline", isOn: appearanceBoolBinding(\.textOutline)) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Picker("Outline color", selection: appearanceStringBinding(\.textOutlineColor)) { ForEach(SubtitleAppearance.outlineColors, id: \.hex) { color in diff --git a/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift b/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift index 4305d4ce7..2f7acbd0b 100644 --- a/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift +++ b/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift @@ -653,7 +653,7 @@ struct MobilePlayerControls: View { return menu .buttonStyle(MobilePlayerGlassButtonStyle( - tint: viewModel.activeQualityId == ApplePlaybackQuality.autoId ? nil : .accentColor + tint: viewModel.activeQualityId == ApplePlaybackQuality.autoId ? nil : Color.white.opacity(0.22) )) .accessibilityLabel("Playback Quality") .accessibilityValue(qualityValueText) diff --git a/iosApp/iosApp/Screens/Profiles/CreateProfileView.swift b/iosApp/iosApp/Screens/Profiles/CreateProfileView.swift index f8b6f3012..37684e5b3 100644 --- a/iosApp/iosApp/Screens/Profiles/CreateProfileView.swift +++ b/iosApp/iosApp/Screens/Profiles/CreateProfileView.swift @@ -365,7 +365,7 @@ struct CreateProfileView: View { .foregroundColor(.prairieSecondaryText) } } - .tint(.prairieAccent) + .tint(.prairieSwitchOn) if isChild { childAccessControls @@ -456,7 +456,7 @@ struct CreateProfileView: View { } Toggle("Restrict libraries", isOn: $libraryRestrictionsEnabled) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) if libraryRestrictionsEnabled { if case .loading = libraryLoad { @@ -507,7 +507,7 @@ struct CreateProfileView: View { } ) ) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } } } diff --git a/iosApp/iosApp/Screens/Servers/ServerListView.swift b/iosApp/iosApp/Screens/Servers/ServerListView.swift index 5c074dec8..86779bb54 100644 --- a/iosApp/iosApp/Screens/Servers/ServerListView.swift +++ b/iosApp/iosApp/Screens/Servers/ServerListView.swift @@ -41,7 +41,7 @@ struct ServerListView: View { #else contentList .disabled(isResolvingServer) - .navigationTitle("") + .navigationTitle("Servers") .prairieNavigationTitleDisplayMode(.inline) .alert( "Remove this server?", @@ -176,14 +176,6 @@ struct ServerListView: View { #if !os(tvOS) private var contentList: some View { List { - SettingsPageHeader( - title: "Servers", - subtitle: "Manage saved Prairie connections for this device.", - systemImage: "server.rack", - tint: .teal - ) - .settingsPageHeaderRow() - Section { ForEach(registry.sortedEntries) { entry in row(for: entry) @@ -192,7 +184,7 @@ struct ServerListView: View { Text("Saved servers") .foregroundColor(.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) Section { Button { @@ -202,7 +194,7 @@ struct ServerListView: View { .foregroundColor(.prairieOnSurface) } } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } .settingsListChrome() } diff --git a/iosApp/iosApp/Screens/Settings/DiagnosticsSettingsView.swift b/iosApp/iosApp/Screens/Settings/DiagnosticsSettingsView.swift index bac0f2e7a..6c67031c3 100644 --- a/iosApp/iosApp/Screens/Settings/DiagnosticsSettingsView.swift +++ b/iosApp/iosApp/Screens/Settings/DiagnosticsSettingsView.swift @@ -12,14 +12,6 @@ struct DiagnosticsSettingsView: View { var body: some View { List { - SettingsPageHeader( - title: "Diagnostics", - subtitle: "Capture, review, and securely send diagnostic reports.", - systemImage: "stethoscope", - tint: .orange - ) - .settingsPageHeaderRow() - availabilitySection preferencesSection pendingSection @@ -27,7 +19,7 @@ struct DiagnosticsSettingsView: View { sentHistorySection } .settingsListChrome() - .navigationTitle("") + .navigationTitle("Diagnostics") .prairieNavigationTitleDisplayMode(.inline) .prairieToolbarColorSchemeDark() .task { @@ -54,7 +46,7 @@ struct DiagnosticsSettingsView: View { Text("Showing the last known diagnostics state. Reports stay on this device while the server is offline.") } } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) } private var preferencesSection: some View { @@ -69,7 +61,7 @@ struct DiagnosticsSettingsView: View { } Toggle("Debug Logging", isOn: $model.debugLoggingEnabled) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Picker("Crash Reports", selection: $selectedMode) { Text("Ask").tag(DiagnosticsConsentChoice.ask) @@ -116,7 +108,7 @@ struct DiagnosticsSettingsView: View { Text("Crash report consent is tied to this server account. Debug logging is a setting for this device.") } } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) } private var pendingSection: some View { @@ -141,7 +133,7 @@ struct DiagnosticsSettingsView: View { } header: { Text("Pending Reports (\(model.pendingReports.count))") } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) } private var manualSection: some View { @@ -177,7 +169,7 @@ struct DiagnosticsSettingsView: View { Text("A manual report includes device capability details, recent playback session identifiers, and recent diagnostic logs for this server.") } } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) } private var sentHistorySection: some View { @@ -202,7 +194,7 @@ struct DiagnosticsSettingsView: View { } } } - .listRowBackground(Color.prairieSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.prairieGroupedCell) } private func requestModeChange(_ mode: DiagnosticsConsentChoice) { diff --git a/iosApp/iosApp/Screens/Settings/GeneralSettingsView.swift b/iosApp/iosApp/Screens/Settings/GeneralSettingsView.swift index 19e2a167f..24e7a40c9 100644 --- a/iosApp/iosApp/Screens/Settings/GeneralSettingsView.swift +++ b/iosApp/iosApp/Screens/Settings/GeneralSettingsView.swift @@ -8,18 +8,10 @@ struct GeneralSettingsView: View { var body: some View { List { - SettingsPageHeader( - title: "General", - subtitle: "Choose what happens when you open Prairie on this device.", - systemImage: "gearshape.fill", - tint: .purple - ) - .settingsPageHeaderRow() - profileSection } .settingsListChrome() - .navigationTitle("") + .navigationTitle("General") .prairieNavigationTitleDisplayMode(.inline) .prairieToolbarColorSchemeDark() } @@ -48,7 +40,7 @@ struct GeneralSettingsView: View { Text(launchPreferences.behavior.standardDescription) .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } } #endif diff --git a/iosApp/iosApp/Screens/Settings/HeldSettingChangesSection.swift b/iosApp/iosApp/Screens/Settings/HeldSettingChangesSection.swift index 2d5b4d6c7..fa9ef3721 100644 --- a/iosApp/iosApp/Screens/Settings/HeldSettingChangesSection.swift +++ b/iosApp/iosApp/Screens/Settings/HeldSettingChangesSection.swift @@ -24,7 +24,7 @@ struct HeldSettingChangesSection: View { Text(message) .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } } #endif diff --git a/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift b/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift index 658511790..6bd538cae 100644 --- a/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift +++ b/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift @@ -1,9 +1,10 @@ #if os(iOS) import SwiftUI -/// Searchable, card-based iOS Settings overview. Its information hierarchy -/// intentionally mirrors the web client while navigation and controls remain -/// native SwiftUI for Dynamic Type, VoiceOver, and predictable gestures. +/// Searchable iOS Settings overview in the system Settings idiom: an +/// inset-grouped list of one-line rows with graphite icon tiles. Search +/// still matches each row's longer description, which VoiceOver reads as +/// the row's hint. struct IOSSettingsOverview: View { @Bindable var viewModel: SettingsViewModel @Bindable var diagnosticsModel: DiagnosticsViewModel @@ -17,90 +18,69 @@ struct IOSSettingsOverview: View { @State private var searchText = "" var body: some View { - ZStack { - SettingsBackdrop() - - ScrollView { - LazyVStack(alignment: .leading, spacing: 24) { - pageHeader - - SettingsAccountCard( - avatar: viewModel.activeProfile?.avatarEmoji, - avatarImageUrl: viewModel.activeProfile?.avatarImageUrl, - name: displayName, - subtitle: subtitleLine, - isAdministrator: viewModel.userInfo?.isAdmin == true, - action: switchProfile - ) - - SettingsSearchField(text: $searchText) + List { + Section { + SettingsAccountCard( + avatar: viewModel.activeProfile?.avatarEmoji, + avatarImageUrl: viewModel.activeProfile?.avatarImageUrl, + name: displayName, + subtitle: subtitleLine, + isAdministrator: viewModel.userInfo?.isAdmin == true, + action: switchProfile + ) + } - if hasSearchResults { - preferencesSection - playbackSection + SettingsSearchField(text: $searchText) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) - if diagnosticsModel.shouldShowSettings && matchesDiagnostics { - diagnosticsSection - } + if hasSearchResults { + preferencesSection + playbackSection - if matchesLibrarySection { - librarySection - } + if diagnosticsModel.shouldShowSettings && matchesDiagnostics { + diagnosticsSection + } - if matchesConnectionSection { - connectionSection - } + if matchesConnectionSection { + connectionSection + } - if matchesExperimentalSection { - experimentalSection - } + if matchesExperimentalSection { + experimentalSection + } - if matchesAboutSection { - aboutSection - } + if matchesAboutSection { + aboutSection + } - if matchesSignOut { - signOutButton - } - } else { - ContentUnavailableView.search - .frame(maxWidth: .infinity) - .padding(.vertical, 36) - } + if matchesSignOut { + signOutSection } - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 36) - .frame(maxWidth: 760, alignment: .leading) - .frame(maxWidth: .infinity) + } else { + ContentUnavailableView.search + .frame(maxWidth: .infinity) + .padding(.vertical, 36) + .listRowBackground(Color.clear) } - .scrollDismissesKeyboard(.interactively) } - .navigationTitle("") - .prairieNavigationTitleDisplayMode(.inline) - .prairieNavigationBarBackgroundHidden() + .prairieGroupedListStyle() + .prairieScrollContentBackgroundHidden() + .scrollDismissesKeyboard(.interactively) + .frame(maxWidth: 760) + .frame(maxWidth: .infinity) + .background(Color.prairieBackground.ignoresSafeArea()) + .navigationTitle("Settings") + .prairieNavigationTitleDisplayMode(.large) .prairieToolbarColorSchemeDark() .onAppear(perform: navPrefs.refresh) } - private var pageHeader: some View { - VStack(alignment: .leading, spacing: 5) { - Text("Settings") - .font(.largeTitle) - .bold() - .foregroundStyle(Color.prairieOnSurface) - - Text("Make Prairie work the way you like.") - .font(.subheadline) - .foregroundStyle(Color.prairieSecondaryText) - } - .accessibilityElement(children: .combine) - } - @ViewBuilder private var preferencesSection: some View { if matchesGeneral || matchesInterface { - SettingsOverviewSection("Preferences") { + Section("Preferences") { if matchesGeneral { NavigationLink { GeneralSettingsView() @@ -109,15 +89,9 @@ struct IOSSettingsOverview: View { title: "General", subtitle: "Profile selection and app startup", systemImage: "gearshape.fill", - tint: .purple, value: launchPreferences.behavior.title ) } - .buttonStyle(.plain) - } - - if matchesGeneral && matchesInterface { - SettingsOverviewDivider() } if matchesInterface { @@ -128,11 +102,9 @@ struct IOSSettingsOverview: View { title: "Interface", subtitle: "Navigation, cards, and poster presentation", systemImage: "rectangle.3.group.fill", - tint: .indigo, value: uiCustomization.cardPresentation.preset?.title ?? "Custom" ) } - .buttonStyle(.plain) } } } @@ -141,8 +113,8 @@ struct IOSSettingsOverview: View { @ViewBuilder private var playbackSection: some View { if matchesPlaybackSection { - SettingsOverviewSection("Playback") { - if matches("playback", "quality", "audio", "dolby vision", "episodes", "skipping", "skip interval", "rewind", "fast forward", "audiobooks") { + Section("Playback") { + if matchesPlayback { NavigationLink { PlaybackSettingsView(viewModel: viewModel) } label: { @@ -153,11 +125,6 @@ struct IOSSettingsOverview: View { value: viewModel.preferredQualityLabel ) } - .buttonStyle(.plain) - } - - if matchesPlayback && matchesSubtitles { - SettingsOverviewDivider() } if matchesSubtitles { @@ -168,18 +135,12 @@ struct IOSSettingsOverview: View { title: "Subtitles", subtitle: "Language, behavior, and appearance", systemImage: "captions.bubble.fill", - tint: .pink, value: subtitleLanguageName(viewModel.prefs.subtitleLanguage) ) } - .buttonStyle(.plain) } if matchesDownloads { - if matchesPlayback || matchesSubtitles { - SettingsOverviewDivider() - } - NavigationLink { DownloadsSettingsView() } label: { @@ -189,14 +150,13 @@ struct IOSSettingsOverview: View { systemImage: "arrow.down.circle.fill" ) } - .buttonStyle(.plain) } } } } private var diagnosticsSection: some View { - SettingsOverviewSection("Support") { + Section("Support") { NavigationLink { DiagnosticsSettingsView( model: diagnosticsModel, @@ -207,31 +167,14 @@ struct IOSSettingsOverview: View { title: "Diagnostics", subtitle: "Capture, review, and send support reports", systemImage: "stethoscope", - tint: .orange, value: diagnosticsModel.featureState.title ) } - .buttonStyle(.plain) - } - } - - private var librarySection: some View { - SettingsOverviewSection("Library & Data") { - SettingsOverviewToggleRow( - title: "Show Audiobooks", - subtitle: "Add Audiobooks to the main navigation", - systemImage: "book.closed.fill", - tint: .indigo, - isOn: Binding( - get: { navPrefs.showAudiobooks }, - set: { navPrefs.setShowAudiobooks($0) } - ) - ) } } private var connectionSection: some View { - SettingsOverviewSection("Connection") { + Section("Connection") { Button { router.navigate(to: .serverList) } label: { @@ -239,38 +182,30 @@ struct IOSSettingsOverview: View { title: "Server", subtitle: "Manage this device's Prairie connection", systemImage: "server.rack", - tint: .teal, - value: viewModel.serverDisplayName + value: viewModel.serverDisplayName, + showsChevron: true ) } - .buttonStyle(.plain) } } private var aboutSection: some View { - SettingsOverviewSection("About") { + Section("About") { SettingsOverviewRow( title: "Version", subtitle: "Installed Prairie app version", systemImage: "info.circle.fill", - tint: .gray, - value: versionString, - showsChevron: false + value: versionString ) - SettingsOverviewDivider() - Link(destination: PrairieLegalLinks.privacyPolicy) { SettingsOverviewRow( title: "Privacy Policy", subtitle: "Learn how Prairie handles your information", systemImage: "hand.raised.fill", - tint: .teal + showsChevron: true ) } - .buttonStyle(.plain) - - SettingsOverviewDivider() NavigationLink { OpenSourceAcknowledgementsView() @@ -278,25 +213,35 @@ struct IOSSettingsOverview: View { SettingsOverviewRow( title: "Open Source Licenses", subtitle: "Acknowledgements, licenses, and exact source revisions", - systemImage: "curlybraces", - tint: .indigo + systemImage: "curlybraces" ) } - .buttonStyle(.plain) } } private var experimentalSection: some View { - SettingsOverviewSection("Experimental") { - ForEach(Array(ExperimentalFeature.allCases.enumerated()), id: \.element) { index, feature in - if index > 0 { - SettingsOverviewDivider() - } + // A query naming the section shows every row; otherwise each row + // appears only for its own terms. + Section("Experimental") { + if matchesExperimentalName || matchesAudiobooks { + SettingsOverviewToggleRow( + title: "Show Audiobooks", + subtitle: "Add Audiobooks to the main navigation", + systemImage: "book.closed.fill", + isOn: Binding( + get: { navPrefs.showAudiobooks }, + set: { navPrefs.setShowAudiobooks($0) } + ) + ) + } + + ForEach(ExperimentalFeature.allCases.filter { + matchesExperimentalName || matches($0.title, $0.subtitle) + }, id: \.self) { feature in SettingsOverviewToggleRow( title: feature.title, subtitle: feature.subtitle, systemImage: feature.systemImage, - tint: .pink, isOn: Binding( get: { experimental.isEnabled(feature) }, set: { feature.setEnabled($0) } @@ -306,21 +251,13 @@ struct IOSSettingsOverview: View { } } - private var signOutButton: some View { - Button(role: .destructive) { - showSignOutConfirm = true - } label: { - Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 50) - } - .buttonStyle(.plain) - .foregroundStyle(Color.red) - .background(Color.red.opacity(0.09)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .overlay { - RoundedRectangle(cornerRadius: 16) - .strokeBorder(Color.red.opacity(0.18), lineWidth: 1) + private var signOutSection: some View { + Section { + Button("Sign Out", role: .destructive) { + showSignOutConfirm = true + } + .frame(maxWidth: .infinity) + .foregroundStyle(Color.red) } } @@ -423,8 +360,13 @@ struct IOSSettingsOverview: View { ) } - private var matchesExperimentalSection: Bool { + private var matchesExperimentalName: Bool { matches("experimental", "beta", "testing") + } + + private var matchesExperimentalSection: Bool { + matchesExperimentalName + || matchesAudiobooks || ExperimentalFeature.allCases.contains { matches($0.title, $0.subtitle) } } @@ -436,16 +378,11 @@ struct IOSSettingsOverview: View { matchesPlayback || matchesSubtitles || matchesDownloads } - private var matchesLibrarySection: Bool { - matchesAudiobooks - } - private var hasSearchResults: Bool { matchesGeneral || matchesInterface || matchesPlaybackSection || (diagnosticsModel.shouldShowSettings && matchesDiagnostics) - || matchesLibrarySection || matchesConnectionSection || matchesExperimentalSection || matchesAboutSection diff --git a/iosApp/iosApp/Screens/Settings/OpenSourceAcknowledgementsView.swift b/iosApp/iosApp/Screens/Settings/OpenSourceAcknowledgementsView.swift index a951be647..e2eb2857f 100644 --- a/iosApp/iosApp/Screens/Settings/OpenSourceAcknowledgementsView.swift +++ b/iosApp/iosApp/Screens/Settings/OpenSourceAcknowledgementsView.swift @@ -94,7 +94,7 @@ struct TVOpenSourceAcknowledgementsOverlay: View { Text("OPEN SOURCE") .font(.system(size: 15, weight: .semibold, design: .monospaced)) .tracking(2) - .foregroundStyle(Color.prairieAccent) + .foregroundStyle(Color.prairieSecondaryText) Text("Licenses & Acknowledgements") .font(.system(size: 38, weight: .semibold)) diff --git a/iosApp/iosApp/Screens/Settings/PlaybackSettingsView.swift b/iosApp/iosApp/Screens/Settings/PlaybackSettingsView.swift index c26417e2a..c35775031 100644 --- a/iosApp/iosApp/Screens/Settings/PlaybackSettingsView.swift +++ b/iosApp/iosApp/Screens/Settings/PlaybackSettingsView.swift @@ -9,13 +9,6 @@ struct PlaybackSettingsView: View { var body: some View { List { - SettingsPageHeader( - title: "Playback", - subtitle: "Quality, language, and episode behavior for this device.", - systemImage: "play.fill" - ) - .settingsPageHeaderRow() - if viewModel.hasHeldPlaybackChanges { HeldSettingChangesSection( retry: { await viewModel.retryHeldPlaybackChanges() }, @@ -32,7 +25,7 @@ struct PlaybackSettingsView: View { resetSection } .settingsListChrome() - .navigationTitle("") + .navigationTitle("Playback") .prairieNavigationTitleDisplayMode(.inline) .prairieToolbarColorSchemeDark() } @@ -97,7 +90,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Toggle("Seek Cache", isOn: Binding( get: { viewModel.seekCacheEnabled }, @@ -107,7 +100,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Picker("Buffer Ahead", selection: Binding( get: { viewModel.bufferAhead }, @@ -135,7 +128,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Toggle("TrueHD Atmos", isOn: Binding( get: { viewModel.trueHDAtmosEnabled }, @@ -145,7 +138,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Picker("Deinterlacing", selection: Binding( get: { viewModel.deinterlaceMode }, @@ -195,7 +188,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) #endif } header: { Text("Streaming") @@ -204,25 +197,18 @@ struct PlaybackSettingsView: View { Text(streamingFooterText) .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } private var streamingFooterText: String { // Leads with what the chosen quality actually means, since the preset // labels ("1080p High") name a tier without stating its bitrate. - var text = "\(viewModel.preferredQualityLabel). " + var text = "\(viewModel.preferredQualityLabel)." if let preset = PrairieQualityPresets.preset(id: viewModel.preferredQualityPresetId) { - text = "\(preset.description) " + text = preset.description } - text += "Turn off Dolby Vision to play Dolby Vision titles as HDR10 instead. Profile 5 titles have no HDR10-compatible layer and always play in Dolby Vision." - text += " Seek Cache keeps recently streamed video in temporary storage during playback so skipping forward and back is instant; it is cleared when playback ends." - text += " Buffer Ahead controls how much video is downloaded ahead of the playhead; longer windows ride out network dropouts, and Unlimited buffers as much as fits in temporary storage, which is cleared when playback ends." - text += " Lossless Multichannel Audio delivers TrueHD and DTS-HD audio as lossless multichannel PCM, and needs a receiver or soundbar that accepts multichannel PCM over eARC. If surround plays as stereo, turn it off to use a surround-compatible Dolby Digital Plus bridge instead. For TrueHD tracks that carry Atmos, the TrueHD Atmos setting takes precedence." - text += " TrueHD Atmos converts TrueHD Atmos tracks so their height channels play: Prairie decodes the track's Atmos objects, mixes them into a 7.1.4 speaker layout and plays that as Spatial Audio on AirPods and the built-in speakers. This is Prairie's own conversion, not the original Atmos stream and not Dolby's decoder, and the result is compressed audio, so these tracks are no longer lossless. Turn it off to play them without heights, as lossless 7.1 when Lossless Multichannel Audio is on." - text += " Deinterlacing applies to interlaced sources such as DVDs and broadcast recordings; Automatic uses this device's hardware deinterlacer and falls back to software, while Software always deinterlaces on the CPU. Field Rate applies to the hardware deinterlacer only: Full Motion doubles the frame rate (50/60 fps), and Film keeps one frame per field pair." - #if os(iOS) - text += " Background Playback continues audio when the app moves to the background, including Picture in Picture; turning it off stops playback when you leave the app. Audiobooks always keep playing in the background." - #endif + text += " If surround plays as stereo, turn off Lossless Multichannel Audio." + text += " TrueHD Atmos adds height channels but plays those tracks as compressed audio." return text } @@ -238,7 +224,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Picker("Show Next Up", selection: Binding( get: { viewModel.nextUpPromptSeconds }, @@ -286,12 +272,12 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } header: { Text("Episodes") .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } // MARK: - Refused change @@ -308,7 +294,7 @@ struct PlaybackSettingsView: View { Text(SettingsViewModel.rejectedPlaybackChangeMessage) .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } // MARK: - Reset @@ -322,7 +308,7 @@ struct PlaybackSettingsView: View { Text("Resets playback choices for this device and profile back to the server fallback.") .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } // MARK: - Options diff --git a/iosApp/iosApp/Screens/Settings/SeekIntervalSettingsSections.swift b/iosApp/iosApp/Screens/Settings/SeekIntervalSettingsSections.swift index 1923349e4..5213ed40f 100644 --- a/iosApp/iosApp/Screens/Settings/SeekIntervalSettingsSections.swift +++ b/iosApp/iosApp/Screens/Settings/SeekIntervalSettingsSections.swift @@ -44,7 +44,7 @@ struct SeekIntervalSettingsSections: View { Text(footerText(footer, media: media)) .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } private func picker( diff --git a/iosApp/iosApp/Screens/Settings/SettingsAccountCard.swift b/iosApp/iosApp/Screens/Settings/SettingsAccountCard.swift index ab17579f2..6922c1088 100644 --- a/iosApp/iosApp/Screens/Settings/SettingsAccountCard.swift +++ b/iosApp/iosApp/Screens/Settings/SettingsAccountCard.swift @@ -1,6 +1,8 @@ #if os(iOS) import SwiftUI +/// The active profile at the top of the Settings list; tapping it opens the +/// profile switcher. struct SettingsAccountCard: View { let avatar: String? /// Server-resolved avatar image URL (`avatar_url`), preferred over the @@ -14,20 +16,16 @@ struct SettingsAccountCard: View { var body: some View { Button(action: action) { HStack(spacing: 14) { - ProfileAvatarView(avatar: avatar, imageUrl: avatarImageUrl, name: name, size: 54) - - VStack(alignment: .leading, spacing: 3) { - Text("Current profile") - .font(.caption) - .foregroundStyle(Color.prairieSecondaryText) + ProfileAvatarView(avatar: avatar, imageUrl: avatarImageUrl, name: name, size: 56) + VStack(alignment: .leading, spacing: 2) { Text(name) - .font(.headline) + .font(.title3.weight(.semibold)) .foregroundStyle(Color.prairieOnSurface) .lineLimit(1) Text(subtitle) - .font(.footnote) + .font(.subheadline) .foregroundStyle(Color.prairieSecondaryText) .lineLimit(1) } @@ -36,31 +34,18 @@ struct SettingsAccountCard: View { if isAdministrator { Text("Admin") - .font(.caption) - .bold() - .foregroundStyle(Color.prairieAccent) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.prairieSecondaryText) .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.prairieAccent.opacity(0.12), in: Capsule()) + .padding(.vertical, 4) + .background(Color.prairieChromeSelectedFill, in: Capsule()) } - Image(systemName: "chevron.right") - .font(.footnote) - .bold() - .foregroundStyle(Color.prairieSecondaryText) - .accessibilityHidden(true) + SettingsRowChevron() } - .padding(15) - .frame(maxWidth: .infinity, minHeight: 80, alignment: .leading) + .padding(.vertical, 6) .contentShape(Rectangle()) } - .buttonStyle(.plain) - .background(Color.prairieSurfaceElevated.opacity(0.9)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - .overlay { - RoundedRectangle(cornerRadius: 18) - .strokeBorder(Color.prairieOutline, lineWidth: 1) - } .accessibilityHint("Switches to a different profile") } } diff --git a/iosApp/iosApp/Screens/Settings/SettingsBackdrop.swift b/iosApp/iosApp/Screens/Settings/SettingsBackdrop.swift index 66f3ae8ad..496520453 100644 --- a/iosApp/iosApp/Screens/Settings/SettingsBackdrop.swift +++ b/iosApp/iosApp/Screens/Settings/SettingsBackdrop.swift @@ -1,42 +1,12 @@ import SwiftUI -/// Quiet atmospheric backdrop shared by the native Settings experiences. -/// It mirrors the web app's dark canvas and restrained blue signal glow -/// without competing with controls or reducing text contrast. +/// Plain black canvas shared by the native Settings experiences, so grouped +/// rows and focus platters carry all of the contrast. struct SettingsBackdrop: View { var body: some View { - ZStack { - Color.prairieBackground - - RadialGradient( - colors: [ - Color.prairieAccent.opacity(0.14), - Color(hex: "#162235").opacity(0.07), - .clear, - ], - center: UnitPoint(x: 0.82, y: 0.04), - startRadius: 0, - endRadius: 620 - ) - - RadialGradient( - colors: [ - Color.prairieBrandOrange.opacity(0.045), - .clear, - ], - center: UnitPoint(x: 0.08, y: 0.72), - startRadius: 0, - endRadius: 440 - ) - - LinearGradient( - colors: [.clear, Color.black.opacity(0.4)], - startPoint: .top, - endPoint: .bottom - ) - } - .ignoresSafeArea() - .allowsHitTesting(false) - .accessibilityHidden(true) + Color.prairieBackground + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) } } diff --git a/iosApp/iosApp/Screens/Settings/SettingsListChrome.swift b/iosApp/iosApp/Screens/Settings/SettingsListChrome.swift new file mode 100644 index 000000000..98c50384a --- /dev/null +++ b/iosApp/iosApp/Screens/Settings/SettingsListChrome.swift @@ -0,0 +1,11 @@ +#if !os(tvOS) +import SwiftUI + +extension View { + func settingsListChrome() -> some View { + prairieGroupedListStyle() + .prairieScrollContentBackgroundHidden() + .background(SettingsBackdrop()) + } +} +#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsOverviewDivider.swift b/iosApp/iosApp/Screens/Settings/SettingsOverviewDivider.swift deleted file mode 100644 index 8c5aeac50..000000000 --- a/iosApp/iosApp/Screens/Settings/SettingsOverviewDivider.swift +++ /dev/null @@ -1,11 +0,0 @@ -#if os(iOS) -import SwiftUI - -struct SettingsOverviewDivider: View { - var body: some View { - Divider() - .overlay(Color.prairieDivider) - .padding(.leading, 66) - } -} -#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsOverviewRow.swift b/iosApp/iosApp/Screens/Settings/SettingsOverviewRow.swift index e3859061a..d01ab9173 100644 --- a/iosApp/iosApp/Screens/Settings/SettingsOverviewRow.swift +++ b/iosApp/iosApp/Screens/Settings/SettingsOverviewRow.swift @@ -1,64 +1,60 @@ #if os(iOS) import SwiftUI -/// Web-style Settings destination row with a descriptive second line and an -/// optional current value. The containing NavigationLink or Button owns the -/// interaction so the full row remains a native 44-point target. +/// One-line Settings destination row in the system Settings idiom: icon +/// tile, title, and the current value. The longer description is spoken as +/// the accessibility hint instead of printed. The containing NavigationLink +/// supplies its own chevron; plain buttons ask for one with `showsChevron`. struct SettingsOverviewRow: View { let title: String let subtitle: String let systemImage: String - var tint: Color = .prairieAccent var value: String? = nil - var showsChevron = true + var showsChevron = false var body: some View { - HStack(spacing: 13) { - Image(systemName: systemImage) - .font(.body) - .foregroundStyle(tint) - .frame(width: 38, height: 38) - .background(tint.opacity(0.12), in: Circle()) - .overlay { - Circle() - .strokeBorder(tint.opacity(0.18), lineWidth: 1) - } - .accessibilityHidden(true) + HStack(spacing: 14) { + SettingsIconTile(systemImage: systemImage) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.headline) - .foregroundStyle(Color.prairieOnSurface) - - Text(subtitle) - .font(.footnote) - .foregroundStyle(Color.prairieSecondaryText) - .lineLimit(2) - } + Text(title) + .foregroundStyle(Color.prairieOnSurface) Spacer(minLength: 8) if let value { Text(value) - .font(.subheadline) .foregroundStyle(Color.prairieSecondaryText) .lineLimit(1) .minimumScaleFactor(0.8) } if showsChevron { - Image(systemName: "chevron.right") - .font(.footnote) - .bold() - .foregroundStyle(Color.prairieSecondaryText) - .accessibilityHidden(true) + SettingsRowChevron() } } - .padding(.horizontal, 15) - .padding(.vertical, 13) - .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .accessibilityElement(children: .combine) + .accessibilityHint(subtitle) + } +} + +/// White glyph on a graphite rounded square — the leading icon of every +/// Settings overview row. +struct SettingsIconTile: View { + let systemImage: String + @ScaledMetric(relativeTo: .body) private var size: CGFloat = 30 + + var body: some View { + Image(systemName: systemImage) + .font(.system(size: size * 0.53, weight: .medium)) + .foregroundStyle(.white) + .frame(width: size, height: size) + .background( + Color.prairieIconTile, + in: RoundedRectangle(cornerRadius: size * 0.24, style: .continuous) + ) + .accessibilityHidden(true) } } #endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsOverviewSection.swift b/iosApp/iosApp/Screens/Settings/SettingsOverviewSection.swift deleted file mode 100644 index 7f0d6d35d..000000000 --- a/iosApp/iosApp/Screens/Settings/SettingsOverviewSection.swift +++ /dev/null @@ -1,36 +0,0 @@ -#if os(iOS) -import SwiftUI - -/// Labeled card grouping related Settings destinations, matching the web -/// app's section hierarchy while keeping native controls inside the card. -struct SettingsOverviewSection: View { - let title: String - @ViewBuilder let content: Content - - init(_ title: String, @ViewBuilder content: () -> Content) { - self.title = title - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 9) { - Text(title.uppercased()) - .font(.caption) - .bold() - .tracking(1.5) - .foregroundStyle(Color.prairieSecondaryText) - .padding(.horizontal, 4) - - VStack(spacing: 0) { - content - } - .background(Color.prairieSurfaceElevated.opacity(0.88)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - .overlay { - RoundedRectangle(cornerRadius: 18) - .strokeBorder(Color.prairieOutline, lineWidth: 1) - } - } - } -} -#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsOverviewToggleRow.swift b/iosApp/iosApp/Screens/Settings/SettingsOverviewToggleRow.swift index 6e5c37fe4..161c4af03 100644 --- a/iosApp/iosApp/Screens/Settings/SettingsOverviewToggleRow.swift +++ b/iosApp/iosApp/Screens/Settings/SettingsOverviewToggleRow.swift @@ -5,38 +5,18 @@ struct SettingsOverviewToggleRow: View { let title: String let subtitle: String let systemImage: String - var tint: Color = .prairieAccent @Binding var isOn: Bool var body: some View { Toggle(isOn: $isOn) { - HStack(spacing: 13) { - Image(systemName: systemImage) - .font(.body) - .foregroundStyle(tint) - .frame(width: 38, height: 38) - .background(tint.opacity(0.12), in: Circle()) - .overlay { - Circle() - .strokeBorder(tint.opacity(0.18), lineWidth: 1) - } - .accessibilityHidden(true) + HStack(spacing: 14) { + SettingsIconTile(systemImage: systemImage) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.headline) - .foregroundStyle(Color.prairieOnSurface) - - Text(subtitle) - .font(.footnote) - .foregroundStyle(Color.prairieSecondaryText) - } + Text(title) + .foregroundStyle(Color.prairieOnSurface) } } - .tint(.prairieAccent) - .padding(.horizontal, 15) - .padding(.vertical, 13) - .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + .tint(.prairieSwitchOn) .accessibilityLabel(title) .accessibilityHint(subtitle) } diff --git a/iosApp/iosApp/Screens/Settings/SettingsPageHeader.swift b/iosApp/iosApp/Screens/Settings/SettingsPageHeader.swift deleted file mode 100644 index 314b92ee9..000000000 --- a/iosApp/iosApp/Screens/Settings/SettingsPageHeader.swift +++ /dev/null @@ -1,55 +0,0 @@ -#if !os(tvOS) -import SwiftUI - -/// Consistent introduction for Settings detail pages, matching the web -/// client's icon, title, and concise explanatory copy. -struct SettingsPageHeader: View { - let title: String - let subtitle: String - let systemImage: String - var tint: Color = .prairieAccent - - var body: some View { - HStack(alignment: .center, spacing: 14) { - Image(systemName: systemImage) - .font(.title3) - .foregroundStyle(tint) - .frame(width: 44, height: 44) - .background(tint.opacity(0.13), in: Circle()) - .overlay { - Circle() - .strokeBorder(tint.opacity(0.2), lineWidth: 1) - } - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.title2) - .bold() - .foregroundStyle(Color.prairieOnSurface) - - Text(subtitle) - .font(.subheadline) - .foregroundStyle(Color.prairieSecondaryText) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - } -} - -extension View { - func settingsPageHeaderRow() -> some View { - listRowInsets(EdgeInsets(top: 14, leading: 20, bottom: 10, trailing: 20)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - - func settingsListChrome() -> some View { - prairieGroupedListStyle() - .prairieScrollContentBackgroundHidden() - .background(SettingsBackdrop()) - } -} -#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsSearchField.swift b/iosApp/iosApp/Screens/Settings/SettingsSearchField.swift index e68a5dcba..c58941a86 100644 --- a/iosApp/iosApp/Screens/Settings/SettingsSearchField.swift +++ b/iosApp/iosApp/Screens/Settings/SettingsSearchField.swift @@ -1,37 +1,36 @@ #if os(iOS) import SwiftUI +/// Capsule search field in the system search-bar style, placed in the +/// Settings list between the profile and the first section. struct SettingsSearchField: View { @Binding var text: String var body: some View { - HStack(spacing: 10) { + HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(Color.prairieSecondaryText) .accessibilityHidden(true) - TextField("Search settings", text: $text) + TextField("Search", text: $text) .textInputAutocapitalization(.never) .autocorrectionDisabled() .foregroundStyle(Color.prairieOnSurface) + .accessibilityLabel("Search settings") if !text.isEmpty { Button("Clear search", systemImage: "xmark.circle.fill") { text = "" } .labelStyle(.iconOnly) + .buttonStyle(.plain) .foregroundStyle(Color.prairieSecondaryText) .frame(width: 44, height: 44) } } - .padding(.horizontal, 15) - .frame(minHeight: 48) - .background(Color.prairieSurfaceElevated.opacity(0.78)) - .clipShape(RoundedRectangle(cornerRadius: 15)) - .overlay { - RoundedRectangle(cornerRadius: 15) - .strokeBorder(Color.prairieOutline, lineWidth: 1) - } + .padding(.horizontal, 14) + .frame(minHeight: 44) + .background(Color.prairieGroupedCell, in: Capsule()) } } #endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsView.swift b/iosApp/iosApp/Screens/Settings/SettingsView.swift index 85e3d9c07..d1bea61f7 100644 --- a/iosApp/iosApp/Screens/Settings/SettingsView.swift +++ b/iosApp/iosApp/Screens/Settings/SettingsView.swift @@ -2,8 +2,8 @@ import SwiftUI /// App settings screen. /// -/// iOS: a searchable, card-based overview aligned with the web app's -/// information hierarchy. macOS retains the compact native Settings list. +/// iOS: a searchable, inset-grouped overview in the system Settings idiom. +/// macOS retains the compact native Settings list. /// /// On tvOS this view delegates to ``TVSettingsView``, a root-menu Form /// with drill-in sub-screens tuned for the 10-foot experience. @@ -331,7 +331,9 @@ struct SettingsRowLabel: View { .accessibilityElement(children: .combine) } } +#endif +#if !os(tvOS) /// Disclosure chevron for `Button` rows that act like navigation rows /// (`NavigationLink` rows draw their own). struct SettingsRowChevron: View { @@ -342,5 +344,4 @@ struct SettingsRowChevron: View { .accessibilityHidden(true) } } - #endif diff --git a/iosApp/iosApp/Screens/Settings/SubtitleSettingsView.swift b/iosApp/iosApp/Screens/Settings/SubtitleSettingsView.swift index ceda80594..4da8404f8 100644 --- a/iosApp/iosApp/Screens/Settings/SubtitleSettingsView.swift +++ b/iosApp/iosApp/Screens/Settings/SubtitleSettingsView.swift @@ -13,14 +13,6 @@ struct SubtitleSettingsView: View { var body: some View { List { - SettingsPageHeader( - title: "Subtitles", - subtitle: "Language, behavior, and on-screen appearance.", - systemImage: "captions.bubble.fill", - tint: .pink - ) - .settingsPageHeaderRow() - profileBackedSection if AICapabilities.shared.metadataEnabled { metadataLanguageSection @@ -34,7 +26,7 @@ struct SubtitleSettingsView: View { appearanceSection } .settingsListChrome() - .navigationTitle("") + .navigationTitle("Subtitles") .prairieNavigationTitleDisplayMode(.inline) .prairieToolbarColorSchemeDark() .onChange(of: viewModel.prefs.subtitleLanguage) { _, _ in @@ -80,7 +72,7 @@ struct SubtitleSettingsView: View { .foregroundStyle(Color.prairieSecondaryText) } .disabled(viewModel.prefs.serverUpgradeRequired) - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } // MARK: - Profile prefs (server-backed) @@ -125,7 +117,7 @@ struct SubtitleSettingsView: View { ) ) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) } header: { Text("Profile") .foregroundStyle(Color.prairieSecondaryText) @@ -151,7 +143,7 @@ struct SubtitleSettingsView: View { .foregroundStyle(Color.prairieSecondaryText) } .disabled(viewModel.prefs.serverUpgradeRequired || viewModel.subtitleMatchesSystemAppearance) - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) } // MARK: - Appearance (per-device override) @@ -174,7 +166,7 @@ struct SubtitleSettingsView: View { .foregroundStyle(Color.prairieError) } } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) Section { Toggle( @@ -187,7 +179,7 @@ struct SubtitleSettingsView: View { ) ) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) Toggle( "Custom Appearance", @@ -199,7 +191,7 @@ struct SubtitleSettingsView: View { ) ) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) .disabled(manualEditingDisabled) } footer: { VStack(alignment: .leading, spacing: 6) { @@ -218,7 +210,7 @@ struct SubtitleSettingsView: View { } .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) Section { Picker("Font Size", selection: appearanceBinding(\.fontSize)) { @@ -253,7 +245,7 @@ struct SubtitleSettingsView: View { Toggle("Text Outline", isOn: appearanceBinding(\.textOutline)) .foregroundStyle(Color.prairieOnSurface) - .tint(.prairieAccent) + .tint(.prairieSwitchOn) ColorChoicePicker( title: "Outline Color", @@ -266,7 +258,7 @@ struct SubtitleSettingsView: View { Text("Text") .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) .disabled(manualEditingDisabled) .opacity(manualEditingDisabled ? 0.45 : 1) @@ -298,7 +290,7 @@ struct SubtitleSettingsView: View { Text("Background") .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) .disabled(manualEditingDisabled) .opacity(manualEditingDisabled ? 0.45 : 1) @@ -318,7 +310,7 @@ struct SubtitleSettingsView: View { Text("Layout") .foregroundStyle(Color.prairieSecondaryText) } - .listRowBackground(Color.prairieSurfaceElevated) + .listRowBackground(Color.prairieGroupedCell) .disabled(manualEditingDisabled) .opacity(manualEditingDisabled ? 0.45 : 1) } @@ -361,7 +353,7 @@ struct SubtitleSettingsView: View { next.backgroundOpacity = percent Task { await viewModel.setSubtitleAppearance(next) } } - .tint(.prairieAccent) + .tint(.prairieOnSurface) Text("\(Int(draftOpacity ?? committed))%") .monospacedDigit() .foregroundStyle(Color.prairieSecondaryText) diff --git a/iosApp/iosApp/Theme/Colors.swift b/iosApp/iosApp/Theme/Colors.swift index 3c1b8fc4a..cf7694b0e 100644 --- a/iosApp/iosApp/Theme/Colors.swift +++ b/iosApp/iosApp/Theme/Colors.swift @@ -36,6 +36,23 @@ extension Color { /// Muted/secondary text (#9AA3B2) static let prairieSecondaryText = Color(hex: "#9AA3B2") + /// Track of an on switch. Prairie keeps its amber accent on switches + /// (upstream uses the system green because its palette is monochrome). + static let prairieSwitchOn = Color.prairieAccent + + /// Row background of an inset-grouped list, shared by Settings and + /// Downloads — Prairie's elevated slate surface. + static let prairieGroupedCell = Color(hex: "#1C222C") + + /// Fill behind Settings row icons. + static let prairieIconTile = Color(hex: "#222B38") + + /// Storage-breakdown series color (Prairie sky). + static let prairieBrandBlue = Color(hex: "#38BDF8") + + /// Storage-breakdown movies color (Prairie rose). + static let prairieBrandRed = Color(hex: "#FB7185") + /// Error red (#B00020) static let prairieError = Color(hex: "#B00020") diff --git a/iosApp/iosApp/WatchParty/WatchPartySession.swift b/iosApp/iosApp/WatchParty/WatchPartySession.swift index 4045222cf..9071fcf7a 100644 --- a/iosApp/iosApp/WatchParty/WatchPartySession.swift +++ b/iosApp/iosApp/WatchParty/WatchPartySession.swift @@ -522,6 +522,19 @@ final class WatchPartySession { if code == "connection_replaced" { terminate("This profile joined the party on another device. Rejoin here to take over playback.", replaced: true) } + else if code == "bad_request" { + // The server refused a message this client sent. While a player + // waits for its attachment, the only message it sends is the + // attach, so a refusal then is most likely the attach's and + // explains why the party is not syncing. (Errors carry no + // request id, so a late refusal of an earlier report can land + // here too.) Any other refusal, such as a report that raced the + // room back to its lobby, gives the viewer nothing to act on + // and would stay until the socket next reconnects, so it is only + // traced, as the web client only logs it. + if adapter != nil && !attachmentConfirmed { errorMessage = message } + else { trace("server rejected a message: \(message)") } + } else { errorMessage = message } case .unknown: break } diff --git a/iosApp/iosApp/macOS/PlayerView.swift b/iosApp/iosApp/macOS/PlayerView.swift index 08e7e2414..1863689f6 100644 --- a/iosApp/iosApp/macOS/PlayerView.swift +++ b/iosApp/iosApp/macOS/PlayerView.swift @@ -239,6 +239,8 @@ struct PlayerView: View { viewModel.retry() } .buttonStyle(.borderedProminent) + .tint(.prairieOnSurface) + .foregroundStyle(Color.prairieBackground) Button("Close", systemImage: "xmark") { dismiss() diff --git a/iosApp/iosApp/tvOS/Screens/Settings/TVGeneralSettingsView.swift b/iosApp/iosApp/tvOS/Screens/Settings/TVGeneralSettingsView.swift index 952d6b9d4..8755557a7 100644 --- a/iosApp/iosApp/tvOS/Screens/Settings/TVGeneralSettingsView.swift +++ b/iosApp/iosApp/tvOS/Screens/Settings/TVGeneralSettingsView.swift @@ -639,8 +639,9 @@ private struct TVHomeSectionsControlButtonBody: View { } .scaleEffect(configuration.isPressed ? 0.97 : (isFocused ? 1.04 : 1)) .shadow( - color: isFocused ? Color.prairieAccent.opacity(0.16) : .clear, - radius: 16 + color: isFocused ? Color.black.opacity(0.5) : .clear, + radius: 16, + y: 6 ) .focusEffectDisabled() .animation(.easeOut(duration: PrairieTheme.fastDuration), value: isFocused) diff --git a/iosApp/iosApp/tvOS/Screens/Settings/TVPlaybackSettingsView.swift b/iosApp/iosApp/tvOS/Screens/Settings/TVPlaybackSettingsView.swift index 651b7d4c0..d57c4de34 100644 --- a/iosApp/iosApp/tvOS/Screens/Settings/TVPlaybackSettingsView.swift +++ b/iosApp/iosApp/tvOS/Screens/Settings/TVPlaybackSettingsView.swift @@ -112,16 +112,12 @@ struct TVPlaybackSettingsPane: View { private var streamingFooterText: String { // Leads with what the chosen quality actually means, since the preset // labels ("1080p High") name a tier without stating its bitrate. - var text = "\(viewModel.preferredQualityLabel). " + var text = "\(viewModel.preferredQualityLabel)." if let preset = PrairieQualityPresets.preset(id: viewModel.preferredQualityPresetId) { - text = "\(preset.description) " + text = preset.description } - text += "Turn off Dolby Vision to play Dolby Vision titles as HDR10 instead. Profile 5 titles have no HDR10-compatible layer and always play in Dolby Vision." - text += " Seek Cache keeps recently streamed video in temporary storage during playback so skipping forward and back is instant." - text += " Buffer Ahead controls how much video is downloaded ahead of the playhead; longer windows ride out network dropouts, and Unlimited buffers as much as fits in temporary storage, which is cleared when playback ends." - text += " Lossless Multichannel Audio delivers TrueHD and DTS-HD audio as lossless multichannel PCM, and needs a receiver or soundbar that accepts multichannel PCM over eARC. If surround plays as stereo, turn it off to use a surround-compatible Dolby Digital Plus bridge instead. For TrueHD tracks that carry Atmos, the TrueHD Atmos setting takes precedence." - text += " TrueHD Atmos converts TrueHD Atmos tracks so their height channels play: when this Apple TV is connected to a Dolby Atmos receiver or soundbar, Prairie decodes the track's Atmos objects, mixes them into a 7.1.4 speaker layout and sends that through the Apple TV's Atmos output, which is why your system shows Dolby Atmos. This is Prairie's own conversion, not the original Atmos stream and not Dolby's decoder, and the result is compressed audio, so these tracks are no longer lossless. Turn it off to play them without heights, as lossless 7.1 when Lossless Multichannel Audio is on." - text += " Deinterlacing applies to interlaced sources such as DVDs and broadcast recordings; Automatic uses this Apple TV's hardware deinterlacer and falls back to software, while Software always deinterlaces on the CPU. Field Rate applies to the hardware deinterlacer only: Full Motion doubles the frame rate (50/60 fps), and Film keeps one frame per field pair." + text += " If surround plays as stereo, turn off Lossless Multichannel Audio." + text += " TrueHD Atmos adds height channels but plays those tracks as compressed audio." return text } diff --git a/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsComponents.swift b/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsComponents.swift index 7cb58a786..d1b9b7893 100644 --- a/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsComponents.swift +++ b/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsComponents.swift @@ -170,26 +170,11 @@ private struct TVSettingsRailRowBody: View { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(fill) ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - isSelected && !isFocused - ? Color.prairieChromeSelectedBorder - : Color.clear, - lineWidth: 1 - ) - ) - .overlay(alignment: .leading) { - Capsule() - .fill(Color.prairieAccent) - .frame(width: 4) - .padding(.vertical, 12) - .opacity(isSelected && !isFocused ? 1 : 0) - } .scaleEffect(configuration.isPressed ? 0.98 : (isFocused ? 1.012 : 1)) .shadow( - color: isFocused ? Color.prairieAccent.opacity(0.14) : .clear, - radius: 18 + color: isFocused ? Color.black.opacity(0.5) : .clear, + radius: 18, + y: 8 ) .animation(.easeOut(duration: PrairieTheme.fastDuration), value: isFocused) } @@ -204,7 +189,7 @@ private struct TVSettingsRailRowBody: View { private var fill: Color { if isDestructive && isFocused { return .prairieError } if isFocused { return .prairieOnSurface } - if isSelected { return .prairieSurfaceElevated.opacity(0.92) } + if isSelected { return .prairieChromeSelectedFill } return .clear } } @@ -251,8 +236,9 @@ private struct TVSettingsPaneRowBody: View { ) .scaleEffect(configuration.isPressed ? 0.98 : (isFocused ? 1.012 : 1)) .shadow( - color: isFocused ? Color.prairieAccent.opacity(0.16) : .clear, - radius: 18 + color: isFocused ? Color.black.opacity(0.5) : .clear, + radius: 18, + y: 8 ) .focusEffectDisabled() .animation(.easeOut(duration: PrairieTheme.fastDuration), value: isFocused) @@ -268,7 +254,7 @@ private struct TVSettingsPaneRowBody: View { private var backgroundFill: Color { if isFocused { return .prairieOnSurface } if isSelected { return .prairieChromeSelectedFill } - return .prairieSurfaceElevated.opacity(0.84) + return .prairieChromeRestingFill } private var borderColor: Color { @@ -401,8 +387,7 @@ struct TVSettingsInfoRow: View { // MARK: - Section header / footer -/// Mono uppercase section eyebrow, matching the Skyline dropdown and -/// filter-panel header grammar. +/// Uppercase gray section header in the system grouped-list style. struct TVSettingsSectionHeader: View { let title: String @@ -410,9 +395,9 @@ struct TVSettingsSectionHeader: View { var body: some View { Text(title) - .font(.system(size: 15, weight: .semibold, design: .monospaced)) - .tracking(2) - .foregroundStyle(Color.prairieAccent.opacity(0.86)) + .font(.system(size: 19, weight: .semibold)) + .tracking(1.2) + .foregroundStyle(Color.prairieSecondaryText) .padding(.horizontal, 24) .padding(.top, 26) .padding(.bottom, 6) @@ -580,7 +565,7 @@ struct TVPrivacyPolicyOverlay: View { VStack(alignment: .leading, spacing: 22) { Image(systemName: "hand.raised.fill") .font(.system(size: 38, weight: .semibold)) - .foregroundStyle(Color.prairieAccent) + .foregroundStyle(Color.prairieOnSurface) .accessibilityHidden(true) Text("Privacy Policy") @@ -594,7 +579,7 @@ struct TVPrivacyPolicyOverlay: View { Text(PrairieLegalLinks.privacyPolicy.absoluteString) .font(.system(size: 20, weight: .medium, design: .monospaced)) - .foregroundStyle(Color.prairieAccent) + .foregroundStyle(Color.prairieSecondaryText) .accessibilityLabel("Privacy policy URL") .accessibilityValue(PrairieLegalLinks.privacyPolicy.absoluteString) @@ -654,17 +639,6 @@ struct TVSettingsPickerSheet: View { Color.prairieBackground.opacity(0.88) .ignoresSafeArea() - RadialGradient( - colors: [ - Color.prairieAccent.opacity(0.08), - Color.clear, - ], - center: .center, - startRadius: 40, - endRadius: 680 - ) - .ignoresSafeArea() - pickerCard( width: min(760, geometry.size.width - 240), height: min( diff --git a/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsView.swift b/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsView.swift index 895aba354..5fef7fcd5 100644 --- a/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsView.swift +++ b/iosApp/iosApp/tvOS/Screens/Settings/TVSettingsView.swift @@ -174,15 +174,7 @@ struct TVSettingsView: View { private var settingsContent: some View { HStack(alignment: .top, spacing: 52) { rail - .padding(24) - .background( - RoundedRectangle(cornerRadius: 26) - .fill(Color.prairieSurface.opacity(0.74)) - ) - .overlay { - RoundedRectangle(cornerRadius: 26) - .strokeBorder(Color.prairieOutline, lineWidth: 1) - } + .padding(.vertical, 24) .frame(width: 490) .disabled( showSignOutConfirm @@ -231,17 +223,11 @@ struct TVSettingsView: View { private var rail: some View { VStack(alignment: .leading, spacing: 6) { - VStack(alignment: .leading, spacing: 5) { - Text("Settings") - .font(.system(size: 42, weight: .bold)) - .foregroundStyle(Color.prairieOnSurface) - - Text("Make Prairie work the way you like.") - .font(.system(size: 18)) - .foregroundStyle(Color.prairieSecondaryText) - } - .padding(.horizontal, 20) - .padding(.bottom, 20) + Text("Settings") + .font(.system(size: 48, weight: .bold)) + .foregroundStyle(Color.prairieOnSurface) + .padding(.horizontal, 20) + .padding(.bottom, 20) profileRow .padding(.bottom, 22) @@ -255,8 +241,7 @@ struct TVSettingsView: View { signOutRow Text("Prairie \(viewModel.appVersionDisplay)") - .font(.system(size: 16, weight: .medium, design: .monospaced)) - .tracking(1) + .font(.system(size: 18)) .foregroundColor(.prairieSecondaryText.opacity(0.7)) .padding(.leading, 20) .padding(.top, 10) @@ -547,24 +532,18 @@ struct TVSettingsView: View { private var paneHeader: some View { HStack(alignment: .center, spacing: 20) { Image(systemName: selectedCategory.icon) - .font(.system(size: 27, weight: .medium)) - .foregroundStyle(selectedCategory.tint) - .frame(width: 62, height: 62) - .background(selectedCategory.tint.opacity(0.13), in: Circle()) - .overlay { - Circle() - .strokeBorder(selectedCategory.tint.opacity(0.22), lineWidth: 1) - } + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 72, height: 72) + .background( + Color.prairieIconTile, + in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 7) { - Text(selectedCategory.eyebrow) - .font(.system(size: 15, weight: .semibold, design: .monospaced)) - .tracking(2) - .foregroundStyle(selectedCategory.tint) - + VStack(alignment: .leading, spacing: 5) { Text(selectedCategory.title) - .font(.system(size: 38, weight: .semibold)) + .font(.system(size: 42, weight: .bold)) .foregroundStyle(Color.prairieOnSurface) Text(selectedCategory.blurb) @@ -803,14 +782,6 @@ enum TVSettingsCategory: String, CaseIterable, Identifiable { } } - var eyebrow: String { - switch self { - case .general, .playback, .subtitles: return "PREFERENCES" - case .diagnostics: return "SUPPORT" - case .server: return "CONNECTION" - } - } - var blurb: String { switch self { case .general: @@ -836,15 +807,5 @@ enum TVSettingsCategory: String, CaseIterable, Identifiable { } } - var tint: Color { - switch self { - case .general, .playback, .subtitles: - return .prairieAccent - case .diagnostics: - return .orange - case .server: - return .teal - } - } } #endif