From b6ac94d0765ec3ed9334af8e33c2c2dc0590b718 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:39:54 -0400 Subject: [PATCH 1/8] fix(ios): keep offline downloads fast and moving in the background (#538) * fix(ios): keep offline downloads fast and moving in the background iOS ran the background session at the background traffic class, whose receive-side LEDBAT capped transfers near 1-2 MB/s. A reconcile after a background wake could also start a second pipeline for one record, and transfers created in the background waited for iOS to schedule them. - Use the responsive-data service type for the session and requests. - Track which pipeline or retry owns each record's restart, and cap pipelines at 3 and transfers at a new Simultaneous Downloads setting. - Hold the queue while offline; retry transient manifest failures; resume force-quit transfers without spending a retry; fail on a full disk; check the finished file's size. - On iOS 26+, show system progress through a continued processing task. - Sort In Progress by activity, bulk cancel in-progress downloads, show wait and failure reasons, and give the series sheet tap feedback. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ios): build the continued-processing submit with the Xcode 26 SDK submitTaskRequest(_:completionHandler:) ships in the iOS 27 SDK, so CI's Xcode 26.3 couldn't compile it. Use it only when the compiler is Xcode 27's; older toolchains submit on the main thread as before. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ios): address review findings on download ownership and integrity - Release the pipeline's slot once the transfer starts; artwork and subtitles continue under their own claim, which only a delete, revoke, new revision, or scope change ends. A retry no longer waits on a pipeline forever, the transfer isn't counted twice, and a resume or a fast finish no longer drops subtitles. - Track explicitly when a pipeline parks its record instead of reading the record's status, so a user's resume isn't left in the queue. - Require the finished file to match the manifest's expected bytes. - Keep the failed attempt's peak fixed until a transfer passes it, so retries reset on real recovery. - Cancel only a task that belongs to the record, without marking a possibly reused task ID as an intentional cancel. - Drop in-progress downloads that finished while the cancel dialog was open, and show the wait reason while the manifest is fetched. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor(ios): simplify the download pipeline and its UI - Delete through stopActiveWork, share one helper for dropping every restart, and fold the single-use waiting-reason helper into its caller. - Give Wait one label that the row and the system progress both use. - Save the retry count inside scheduleRetry; three callers skipped it. - Clear a record's retry baseline when it's deleted, replaced, completed, or signed out. - Remove continued-processing state and availability checks that can't matter, and comments that described earlier designs. - Derive the series sheet's busy flag from the option being registered. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../DownloadPipelineReliabilityTests.swift | 254 +++++ iosApp/iosApp/ContentView.swift | 1 + .../DownloadContinuedProcessing.swift | 430 ++++++++ .../iosApp/Downloads/DownloadFilePaths.swift | 21 + .../Downloads/DownloadLiveActivity.swift | 53 +- iosApp/iosApp/Downloads/DownloadManager.swift | 945 +++++++++++++++--- iosApp/iosApp/Downloads/DownloadModels.swift | 25 +- .../Downloads/DownloadNotifications.swift | 2 +- .../Downloads/DownloadRestartOwners.swift | 94 ++ .../Downloads/DownloadSessionDelegate.swift | 97 +- .../iosApp/Downloads/DownloadSettings.swift | 15 + .../Downloads/DownloadsSettingsView.swift | 8 + iosApp/iosApp/Downloads/DownloadsView.swift | 146 ++- .../Downloads/SeriesDownloadControls.swift | 49 +- .../Downloads/Views/DownloadManagerRows.swift | 39 +- iosApp/iosApp/Info.plist | 2 + .../iosApp/Networking/ConnectionMonitor.swift | 6 + 17 files changed, 1971 insertions(+), 216 deletions(-) create mode 100644 iosApp/Tests/DownloadPipelineReliabilityTests.swift create mode 100644 iosApp/iosApp/Downloads/DownloadContinuedProcessing.swift create mode 100644 iosApp/iosApp/Downloads/DownloadRestartOwners.swift diff --git a/iosApp/Tests/DownloadPipelineReliabilityTests.swift b/iosApp/Tests/DownloadPipelineReliabilityTests.swift new file mode 100644 index 000000000..aa84c996b --- /dev/null +++ b/iosApp/Tests/DownloadPipelineReliabilityTests.swift @@ -0,0 +1,254 @@ +import Foundation +import XCTest +@testable import Silo + +/// 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 testClosingSiloFromTheAppSwitcherResumesWithoutUsingARetry() { + 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/ContentView.swift b/iosApp/iosApp/ContentView.swift index 1a5459cd2..01aed08d7 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -390,6 +390,7 @@ struct ContentView: View { switch newPhase { case .active: siloControl.appDidBecomeActive() + DownloadManager.shared.sceneDidBecomeActive() case .background: siloControl.appDidEnterBackground() // Keep series monitoring alive while backgrounded; only diff --git a/iosApp/iosApp/Downloads/DownloadContinuedProcessing.swift b/iosApp/iosApp/Downloads/DownloadContinuedProcessing.swift new file mode 100644 index 000000000..96270d42d --- /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 Silo 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 Silo 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 Silo +/// 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.siloserver.silo", + 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.siloserver.silo") + ".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) + /// Silo 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 Silo 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, Silo 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 Silo'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; Silo'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 Silo'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() + // Silo'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() + } + + /// Silo 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 Silo (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 cfceb6ec7..5fcdadb5b 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..20a3dfc86 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 Silo'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 Silo'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 c3f0c8c25..cdd23c7a8 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,13 @@ final class DownloadManager { record.posterFilename.flatMap { absoluteFileURL(for: record, filename: $0) } } - func transferRate(id: String) -> Double? { - transferRates[id] + /// 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 +468,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 +573,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 +584,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 +712,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 +730,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 +857,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 +1042,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 +1071,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 +1110,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 +1122,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 +1215,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 SiloAPI.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 SiloAPI.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 +1317,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 +1357,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,7 +1396,7 @@ 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 @@ -1170,7 +1414,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 +1427,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 +1440,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 +1451,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 +1471,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 +1479,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 +1508,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 +1522,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 +1557,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 + + /// Silo is on screen: progress the user hid from the Lock Screen comes + /// back as Silo'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 Silo 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 +1685,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 +1696,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 +1711,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 +1738,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 +1752,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 +1788,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 +1820,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 +1859,19 @@ final class DownloadManager { notifyTerminalFailure(record) processQueue() case let .retry(keepResumeData, refreshToken): - record.retryCount += 1 if !keepResumeData { record.bytesDownloaded = 0 } + if cause == .forceQuit { + // Closing Silo 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 +1884,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,42 +1916,163 @@ 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: "SiloDownloadHandoff") { + // 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) private func ensurePolling() { @@ -1536,7 +2111,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 +2126,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 +2174,7 @@ final class DownloadManager { await reconnectActiveTasks() if triggerPipeline { - processQueue() + applyTransferLimit() ensurePolling() } } @@ -1806,44 +2388,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 +3192,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 +3423,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 93c7e6097..743f5b684 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 e51c77849..de6bfad5a 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/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 386517b42..2b4b73deb 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 Silo 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/DownloadsSettingsView.swift b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift index c019e0421..605a999c4 100644 --- a/iosApp/iosApp/Downloads/DownloadsSettingsView.swift +++ b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift @@ -32,6 +32,14 @@ struct DownloadsSettingsView: View { Section { Toggle("Download over Wi-Fi only", isOn: $settings.wifiOnly) .tint(.siloAccent) + 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 diff --git a/iosApp/iosApp/Downloads/DownloadsView.swift b/iosApp/iosApp/Downloads/DownloadsView.swift index 27d029c37..008cd68d1 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 { @@ -39,7 +50,7 @@ struct DownloadsView: View { } } .siloPageBackground() - .navigationTitle(isSelecting ? "\(selection.count) Selected" : "Downloads") + .navigationTitle(isSelecting ? "\(selectedCount) Selected" : "Downloads") #if os(iOS) .navigationBarTitleDisplayMode(.large) #endif @@ -47,7 +58,7 @@ struct DownloadsView: View { .safeAreaInset(edge: .bottom) { bottomBar } .sheet(isPresented: $showReclaim) { DownloadReclaimSheet() } .confirmationDialog( - "Delete downloaded files?", + pendingDeletion?.inProgressOnly == true ? "Cancel downloads?" : "Delete downloaded files?", isPresented: Binding( get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } } @@ -55,14 +66,17 @@ struct DownloadsView: View { 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) {} } .siloToolbarColorSchemeDark() } @@ -127,17 +141,62 @@ struct DownloadsView: View { 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) } - ) + if isSelecting { + Button(allActiveSelected ? "Clear In Progress" : "Select All In Progress") { + if allActiveSelected { activeSelection.removeAll() } + else { activeSelection = Set(manager.activeRecords.map(\.id)) } + } + .font(.system(size: 13, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 22) + } + #if os(iOS) + if manager.canShowProgressOnLockScreen, !isSelecting { + Button { + manager.showProgressOnLockScreen() + } label: { + Label("Show Progress on Lock Screen", systemImage: "lock.iphone") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.siloOnSurface) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Color.siloChromeSelectedFill)) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + } + #endif + ForEach(manager.inProgressRecords) { 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), + 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) } + ) + } } + #if os(iOS) + if !isSelecting { + Text("Downloads keep going when you leave Silo or lock your phone. Closing Silo from the app switcher pauses them until you open it again.") + .font(.system(size: 12)) + .foregroundColor(.siloSecondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 2) + } + #endif } let failed = manager.records.filter { $0.localStatus == .failed } @@ -302,7 +361,7 @@ struct DownloadsView: View { ToolbarItem(placement: .primaryAction) { Button("Done") { exitSelectMode() } } - } else if !listItems.isEmpty { + } else if !listItems.isEmpty || !manager.activeRecords.isEmpty { ToolbarItem(placement: .primaryAction) { Button("Select") { isSelecting = true } } @@ -311,13 +370,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 +404,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 +449,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 cd861df9a..d3552f617 100644 --- a/iosApp/iosApp/Downloads/SeriesDownloadControls.swift +++ b/iosApp/iosApp/Downloads/SeriesDownloadControls.swift @@ -141,7 +141,11 @@ private struct SeriesDownloadOptionsSheet: View { @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 { @@ -157,21 +161,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 +188,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) } } @@ -252,17 +261,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 +280,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 +308,17 @@ private struct SeriesDownloadOptionsSheet: View { .foregroundColor(.siloSecondaryText) } Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(.siloSecondaryText) + if showsSpinner { + ProgressView() + } else if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.siloSecondaryText) + } } .padding(.vertical, 4) + // The whole row answers a tap, not just its text and icons. + .contentShape(Rectangle()) } } diff --git a/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift b/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift index 2d1447284..2b04a45ee 100644 --- a/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift +++ b/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift @@ -10,14 +10,30 @@ 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 + 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) + .padding(.horizontal, 16) + } else { + actionableCard + } + } + + private var actionableCard: some View { DownloadSwipeRevealContainer(actionLabel: "Cancel") { confirmingCancel = true } content: { @@ -37,6 +53,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,10 +72,11 @@ struct DownloadActiveRow: View { } Spacer(minLength: 8) - progressRing + if !selecting { progressRing } } .padding(.horizontal, 16) .padding(.vertical, 12) + .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.siloSurfaceElevated) @@ -103,18 +121,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()))%" } @@ -269,9 +297,10 @@ struct DownloadAttentionRow: View { .font(.system(size: 15, weight: .semibold)) .foregroundColor(.siloOnSurface) .lineLimit(1) - Text("Download failed") + Text(record.failureReason) .font(.system(size: 12.5)) .foregroundColor(.siloError) + .lineLimit(2) } Spacer(minLength: 8) Button(action: onRetry) { diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 36b5aad09..1d1c1d78c 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -6,6 +6,7 @@ org.siloserver.silo.downloads-refresh com.continuum.play.downloads-refresh + $(PRODUCT_BUNDLE_IDENTIFIER).downloads.continued.* CFBundleDevelopmentRegion en @@ -75,6 +76,7 @@ audio fetch + processing remote-notification UILaunchScreen diff --git a/iosApp/iosApp/Networking/ConnectionMonitor.swift b/iosApp/iosApp/Networking/ConnectionMonitor.swift index cf0f0430b..e405fd47e 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) } } From e620bfb0a04842f51448bb88faf4581ad45ea677 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:17:58 -0400 Subject: [PATCH 2/8] fix(calendar): let the focus engine own tvOS Calendar up/down focus (#527) The shelf's Up handler ran after the focus engine had already moved focus, on every Up press. It re-scrolled each row to the top edge under the menu and re-claimed focus, and at the first row it started the week strip's retry claim. The lazy stack also recreated the filter bar on scroll, which replayed the page-entry focus request and pulled focus off the first row. - Remove the shelf Up handler and the strip's retry claim. - Use a non-lazy stack on tvOS so the filter bar, strip, and shelves stay mounted. - Default focus to the selected day, the active filter, and each shelf's first card on entry. - Frame shelf-to-shelf moves bottom-aligned and return to the opening position when the strip gains focus. - Selecting an empty day focuses the nearest day with events. Closes #514 Co-authored-by: Claude Opus 5.5 (1M context) --- .../Screens/Calendar/CalendarDayShelf.swift | 37 ++---- .../Screens/Calendar/CalendarFilterBar.swift | 8 ++ .../Screens/Calendar/CalendarView.swift | 113 +++++++++--------- .../Screens/Calendar/CalendarWeekStrip.swift | 39 ++---- 4 files changed, 83 insertions(+), 114 deletions(-) diff --git a/iosApp/iosApp/Screens/Calendar/CalendarDayShelf.swift b/iosApp/iosApp/Screens/Calendar/CalendarDayShelf.swift index edd30b2b1..084242af3 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 544ccb481..4f5aad039 100644 --- a/iosApp/iosApp/Screens/Calendar/CalendarFilterBar.swift +++ b/iosApp/iosApp/Screens/Calendar/CalendarFilterBar.swift @@ -91,7 +91,15 @@ struct CalendarFilterBar: View { } .padding(containerPadding) .siloGlass(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/CalendarView.swift b/iosApp/iosApp/Screens/Calendar/CalendarView.swift index de2c3935b..bb888a174 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, SiloTheme.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, SiloTheme.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) @@ -354,15 +346,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 +376,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(SiloTheme.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(SiloTheme.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 259e9fcab..90cf9bf79 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, SiloTheme.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(SiloTheme.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 From ce17a1863cfd1c26024c593d184ff8fe17853f40 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:18:02 -0400 Subject: [PATCH 3/8] fix(calendar): link an empty week to the other two views (#525) An empty Calendar week offered only Show Everything in Following and Trending, and nothing in All (Refresh on tvOS). The empty state now links to the other two views, never the current one, and the title names the view ("Nothing trending this week"). On tvOS the empty state is a full-width focus section so Down from any day in the week strip reaches the buttons. Closes #513 Co-authored-by: Claude Opus 5.5 (1M context) --- iosApp/Tests/CalendarFilterTests.swift | 12 ++++ .../Screens/Calendar/CalendarModels.swift | 5 ++ .../Screens/Calendar/CalendarView.swift | 62 +++++++++++-------- 3 files changed, 53 insertions(+), 26 deletions(-) create mode 100644 iosApp/Tests/CalendarFilterTests.swift diff --git a/iosApp/Tests/CalendarFilterTests.swift b/iosApp/Tests/CalendarFilterTests.swift new file mode 100644 index 000000000..58b62bc39 --- /dev/null +++ b/iosApp/Tests/CalendarFilterTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import Silo + +/// 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/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 bb888a174..be9e131f4 100644 --- a/iosApp/iosApp/Screens/Calendar/CalendarView.swift +++ b/iosApp/iosApp/Screens/Calendar/CalendarView.swift @@ -284,47 +284,57 @@ struct CalendarView: View { .multilineTextAlignment(.center) .padding(.horizontal, SiloTheme.largePadding) - if viewModel.filter != .everything { - Button("Show Everything") { - viewModel.select(filter: .everything) + // 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: SiloTheme.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) + } + .siloPrimaryButton() + .frame(width: emptyButtonWidth) + .accessibilityLabel("Show \(filter.displayLabel)") } - .siloPrimaryButton() - .frame(width: emptyButtonWidth) - .padding(.top, SiloTheme.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") { - Task { await viewModel.refresh() } - } - .buttonStyle(SiloPrimaryButtonStyle()) - .frame(width: emptyButtonWidth) - .padding(.top, SiloTheme.smallPadding) - #endif } + .padding(.top, SiloTheme.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 } From 316a6f85a095726efae767f4a779207ec8094e49 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 29 Sep 2026 00:18:20 +0200 Subject: [PATCH 4/8] fix(downloads): download the version the screen shows (#536) * fix(downloads): download the version the screen shows With the version selector on Auto, the movie page showed and played one version (the last-played file, then the quality preference) but the download sent no media_file_id, so the server saved its own pick, the highest-resolution file. A version whose sidecar .srt the user relied on could be swapped for one without subtitles. Episodes had no way to carry a version into a download at all. - Resolve Auto to the displayed version for one-tap downloads, the size guard, and the Download Options sheet, which now names that version instead of "Let the server choose the file". - Add an Episode section to the series Download menu: download the highlighted episode with the version its selector shows, retry a failed download, or open Download Options for that episode. * fix(downloads): keep the size warning and episode errors from getting lost - One-tap Download falls back to the candidate size range when the displayed version has no size, so the large-download and free-space warning still runs. - A failed episode download started from Download Options waits until the sheet has finished dismissing before its alert is shown, so the alert is not dropped mid-animation. --- .../Tests/DetailVersionSelectionTests.swift | 41 ++++ .../Downloads/DownloadActionButton.swift | 26 ++- .../Downloads/DownloadOptionsSheet.swift | 55 ++++-- .../Downloads/DownloadSizeEstimate.swift | 10 +- .../Downloads/SeriesDownloadControls.swift | 175 +++++++++++++++++- .../Screens/Detail/SeriesDetailContent.swift | 16 ++ 6 files changed, 292 insertions(+), 31 deletions(-) diff --git a/iosApp/Tests/DetailVersionSelectionTests.swift b/iosApp/Tests/DetailVersionSelectionTests.swift index 698d9aa1d..964ccaf52 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/iosApp/Downloads/DownloadActionButton.swift b/iosApp/iosApp/Downloads/DownloadActionButton.swift index 0e4ec2b92..ea2f49c71 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/DownloadOptionsSheet.swift b/iosApp/iosApp/Downloads/DownloadOptionsSheet.swift index 6f3986667..9af5bea1a 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") { - 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/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/SeriesDownloadControls.swift b/iosApp/iosApp/Downloads/SeriesDownloadControls.swift index d3552f617..6433e9301 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,6 +229,8 @@ 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 @@ -150,6 +245,10 @@ private struct SeriesDownloadOptionsSheet: View { var body: some View { NavigationStack { Form { + if let episodeTarget { + episodeSection(episodeTarget) + } + Section { if !availableSeasons.isEmpty { NavigationLink { @@ -253,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] } diff --git a/iosApp/iosApp/Screens/Detail/SeriesDetailContent.swift b/iosApp/iosApp/Screens/Detail/SeriesDetailContent.swift index caba68bf3..4a4c7dc28 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 ?? [] } From 01465d818e36d4aa68d6057f57a61608869adc09 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 17:27:58 -0700 Subject: [PATCH 5/8] fix(watch-party): stop showing refused socket messages as a party error (#542) * fix(watch-party): stop showing refused socket messages as a party error When the room returned to its lobby at the end of an item, a state report already in flight for the finished session came back as a bad_request error ("watch together session is not attached"). The session showed it as the party's error banner, which stayed until the socket next reconnected. Trace bad_request errors instead of showing them. They answer a message this client sent and the viewer cannot act on them; the web client only logs them too. Other error codes still surface. Co-Authored-By: Claude Opus 5.5 (1M context) * style(watch-party): keep the refused-message note inside its branch Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): keep showing a refused attach A player waiting for its attachment only sends attach_session, so a bad_request then means the server keeps refusing the attach, and the party will not sync until it stops. Show that refusal as before; trace the others. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(watch-party): state the attach refusal as an inference Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- iosApp/iosApp/WatchParty/WatchPartySession.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/iosApp/iosApp/WatchParty/WatchPartySession.swift b/iosApp/iosApp/WatchParty/WatchPartySession.swift index 45e605656..0a6f001f4 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 } From 45017a752e23e599f81fa0e71d7e47769747c399 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:49:53 -0400 Subject: [PATCH 6/8] fix(downloads): center delete confirmations (#545) On iPhone, the delete confirmation in Downloads and the Remove All confirmation in Settings > Downloads anchored to the whole page and appeared at its top. Use centered alerts, as the resume prompt already does. Co-authored-by: Claude Opus 5.5 (1M context) --- iosApp/iosApp/Downloads/DownloadsSettingsView.swift | 7 ++++--- iosApp/iosApp/Downloads/DownloadsView.swift | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/iosApp/iosApp/Downloads/DownloadsSettingsView.swift b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift index 605a999c4..8a182b2db 100644 --- a/iosApp/iosApp/Downloads/DownloadsSettingsView.swift +++ b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift @@ -125,10 +125,11 @@ struct DownloadsSettingsView: View { #endif .settingsListChrome() .siloToolbarColorSchemeDark() - .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", role: .destructive) { manager.deleteDownloads(ids: manager.records.map(\.id)) diff --git a/iosApp/iosApp/Downloads/DownloadsView.swift b/iosApp/iosApp/Downloads/DownloadsView.swift index 008cd68d1..aa0d16d17 100644 --- a/iosApp/iosApp/Downloads/DownloadsView.swift +++ b/iosApp/iosApp/Downloads/DownloadsView.swift @@ -57,13 +57,15 @@ struct DownloadsView: View { .toolbar { toolbarContent } .safeAreaInset(edge: .bottom) { bottomBar } .sheet(isPresented: $showReclaim) { DownloadReclaimSheet() } - .confirmationDialog( + // 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" From 5c0a6dd2ee924edb4c8242abf38a169505eb6bc0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:12:44 -0400 Subject: [PATCH 7/8] feat(downloads): give the offline detail page the detail-page hero (#544) * feat(downloads): give the offline detail page the detail-page hero The offline movie/episode page showed a blank gradient where artwork belongs. It now uses the same components as the online detail page: the downloaded backdrop (or poster) and title logo, metadata, Play or Resume with progress, Start Over and a confirmed Delete, then a "Download" section listing size, audio, subtitles, quality and date. Artwork files are recorded only after they are written, so a failed write can no longer leave a movie page with a missing logo and no title. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): ignore missing artwork files and lead episode metadata - Use a recorded backdrop or logo only when its file exists: older builds recorded artwork filenames even after a failed write, which would hide a movie's title behind an empty logo. - Log artwork write failures with private error detail. - Put the series and episode number first in an episode's metadata line so the two-line limit cannot truncate them. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): fixed artwork log message; episode tag before series Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- iosApp/iosApp/Downloads/DownloadManager.swift | 29 +- .../Downloads/Views/OfflineBrowse.swift | 295 +++++++----------- .../Detail/Phone/PhoneDetailActionRow.swift | 5 +- 3 files changed, 150 insertions(+), 179 deletions(-) diff --git a/iosApp/iosApp/Downloads/DownloadManager.swift b/iosApp/iosApp/Downloads/DownloadManager.swift index cdd23c7a8..5f6bca7ae 100644 --- a/iosApp/iosApp/Downloads/DownloadManager.swift +++ b/iosApp/iosApp/Downloads/DownloadManager.swift @@ -372,6 +372,26 @@ final class DownloadManager { record.posterFilename.flatMap { absoluteFileURL(for: record, filename: $0) } } + /// 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. @@ -1401,7 +1421,14 @@ final class DownloadManager { 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 diff --git a/iosApp/iosApp/Downloads/Views/OfflineBrowse.swift b/iosApp/iosApp/Downloads/Views/OfflineBrowse.swift index 6e2d02d60..c588633d0 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) + .siloPageBackground() } } - .siloPageBackground() .navigationTitle("") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .siloNavigationBarBackgroundHidden() .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(.siloSecondaryText) - .padding(.bottom, 5) - } - Text(record.title ?? record.contentId) - .font(.system(size: 21, weight: .bold)) - .foregroundColor(.siloOnSurface) - Text(metaLine(record)) - .font(.system(size: 12)) - .foregroundColor(.siloSecondaryText) - .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(.siloSecondaryText) - .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, SiloTheme.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.siloSurfaceElevated, Color.siloBackground], - 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.siloOnSurface.opacity(0.22) - Color.siloOnSurface.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(.siloOnSurface) - .padding(.horizontal, 11) - .padding(.vertical, 6) - .background( - Capsule() - .fill(Color.siloChromeSelectedFill) - .overlay(Capsule().stroke(Color.siloChromeSelectedBorder, 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.siloOnSurface) - .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(.siloOnSurface) - .frame(width: 46, height: 46) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(Color.siloChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(Color.siloChromeRestingBorder, 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(.siloSecondaryText) - Spacer() - Text(value) - .font(.system(size: 12.5, weight: .medium)) - .foregroundColor(.siloOnSurface) + /// 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(.siloOnSurface.opacity(0.5)) + .frame(width: 100, alignment: .leading) + Text(row.1) + .font(.system(size: 14)) + .foregroundColor(.siloOnSurface) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 12) } - .padding(.vertical, 11) - Divider().overlay(Color.siloDivider) } } - .overlay(Divider().overlay(Color.siloDivider), 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(.siloError) - .background( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(Color.siloChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .stroke(Color.siloChromeRestingBorder, 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/Screens/Detail/Phone/PhoneDetailActionRow.swift b/iosApp/iosApp/Screens/Detail/Phone/PhoneDetailActionRow.swift index f04073ea3..5909583ba 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] : []) } } From 4e7ed670686c804b05eedf422a9e40699e703f73 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:19:25 -0400 Subject: [PATCH 8/8] feat(ui): native Settings and Downloads styling without the blue accent (#546) * feat(ui): native Settings and Downloads styling without the blue accent Remove the blue accent (siloAccent and the AccentColor asset) from the Apple clients. Switches use the system green; everything else is white or gray. - iOS Settings becomes a native inset-grouped List with one-line rows, graphite icon tiles and inline sub-page titles. Show Audiobooks moves into Experimental, and the Playback footer is cut to the quality description plus two short notes. - iOS Downloads rows form inset groups; the storage bar uses the Silo wordmark colors. - tvOS Settings drops the boxed rail, blue markers and blue focus glows. Focus handling is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): filter Experimental rows by their own search terms Moving Show Audiobooks into Experimental made a search for one row show every row in the section. Each row now appears for its own terms; a query naming the section still shows them all. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(onboarding): tint the server-driven onboarding switch With the accent now near-white, an untinted switch in the onboarding tour showed a white knob on a white track. Use the shared switch green. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../DownloadsLiveActivity.swift | 12 +- .../AccentColor.colorset/Contents.json | 12 +- iosApp/iosApp/ContentView.swift | 4 +- .../Control/iOS/SiloControlModeButton.swift | 2 +- .../Control/tvOS/TVControlStandbyView.swift | 1 + .../Downloads/DownloadsSettingsView.swift | 25 +- iosApp/iosApp/Downloads/DownloadsView.swift | 90 +++--- .../Downloads/SeriesDownloadControls.swift | 4 +- .../Downloads/Views/DownloadManagerRows.swift | 130 +++----- .../Views/DownloadsManagerComponents.swift | 277 +++++++++++------- iosApp/iosApp/Extensions/ViewExtensions.swift | 4 +- .../Companion/CompanionPairingCard.swift | 14 +- iosApp/iosApp/Screens/Browse/FilterView.swift | 2 +- .../Onboarding/OnboardingTourView.swift | 2 + .../Player/Sheets/PlayerSettingsSheet.swift | 20 +- .../Player/iOS/MobilePlayerControls.swift | 2 +- .../Screens/Profiles/CreateProfileView.swift | 6 +- .../Screens/Servers/ServerListView.swift | 14 +- .../Settings/DiagnosticsSettingsView.swift | 22 +- .../Settings/GeneralSettingsView.swift | 12 +- .../Settings/HeldSettingChangesSection.swift | 2 +- .../Settings/IOSSettingsOverview.swift | 245 ++++++---------- .../OpenSourceAcknowledgementsView.swift | 2 +- .../Settings/PlaybackSettingsView.swift | 46 +-- .../SeekIntervalSettingsSections.swift | 2 +- .../Settings/SettingsAccountCard.swift | 39 +-- .../Screens/Settings/SettingsBackdrop.swift | 42 +-- .../Screens/Settings/SettingsListChrome.swift | 11 + .../Settings/SettingsOverviewDivider.swift | 11 - .../Settings/SettingsOverviewRow.swift | 66 ++--- .../Settings/SettingsOverviewSection.swift | 36 --- .../Settings/SettingsOverviewToggleRow.swift | 30 +- .../Screens/Settings/SettingsPageHeader.swift | 55 ---- .../Settings/SettingsSearchField.swift | 19 +- .../Screens/Settings/SettingsView.swift | 7 +- .../Settings/SubtitleSettingsView.swift | 34 +-- iosApp/iosApp/Theme/Colors.swift | 23 +- iosApp/iosApp/macOS/PlayerView.swift | 2 + .../Settings/TVGeneralSettingsView.swift | 5 +- .../Settings/TVPlaybackSettingsView.swift | 12 +- .../Settings/TVSettingsComponents.swift | 54 +--- .../Screens/Settings/TVSettingsView.swift | 71 +---- 42 files changed, 582 insertions(+), 887 deletions(-) create mode 100644 iosApp/iosApp/Screens/Settings/SettingsListChrome.swift delete mode 100644 iosApp/iosApp/Screens/Settings/SettingsOverviewDivider.swift delete mode 100644 iosApp/iosApp/Screens/Settings/SettingsOverviewSection.swift delete mode 100644 iosApp/iosApp/Screens/Settings/SettingsPageHeader.swift diff --git a/iosApp/DownloadsActivity/DownloadsLiveActivity.swift b/iosApp/DownloadsActivity/DownloadsLiveActivity.swift index 0c58d16ac..0d9f5620b 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/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 01aed08d7..a88d1f981 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -2676,8 +2676,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.siloIconTile) #endif } diff --git a/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift b/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift index c80ec78ad..ff9dca875 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift @@ -36,7 +36,7 @@ struct SiloControlModeButton: View { private func buttonLabel(isActive: Bool) -> some View { Image(systemName: isActive ? "appletvremote.gen4.fill" : "appletvremote.gen4") .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(isActive ? Color.siloAccent : Color.siloOnSurface) + .foregroundStyle(Color.siloOnSurface) .frame(width: SiloTheme.topBarIconHitSize, height: SiloTheme.topBarIconHitSize) .contentShape(Rectangle()) } diff --git a/iosApp/iosApp/Control/tvOS/TVControlStandbyView.swift b/iosApp/iosApp/Control/tvOS/TVControlStandbyView.swift index 3ce368938..16998ff46 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.siloBackground) .controlSize(.large) .focused($isDisconnectFocused) } diff --git a/iosApp/iosApp/Downloads/DownloadsSettingsView.swift b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift index 8a182b2db..6122d3e84 100644 --- a/iosApp/iosApp/Downloads/DownloadsSettingsView.swift +++ b/iosApp/iosApp/Downloads/DownloadsSettingsView.swift @@ -22,16 +22,9 @@ 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(.siloAccent) + .tint(.siloSwitchOn) Picker("Simultaneous Downloads", selection: $settings.simultaneousDownloads) { ForEach(DownloadSettings.simultaneousDownloadChoices, id: \.self) { count in Text("\(count)").tag(count) @@ -57,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.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) Section("Series Monitoring Defaults") { Toggle("Delete watched episodes", isOn: $settings.defaultDeleteWatched) - .tint(.siloAccent) + .tint(.siloSwitchOn) Stepper( settings.defaultMaxStorageGB == 0 ? "Storage limit: Unlimited" @@ -71,17 +64,17 @@ struct DownloadsSettingsView: View { step: 5 ) } - .listRowBackground(Color.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) Section { Toggle("Keep watched downloads", isOn: $settings.keepWatchedDownloads) - .tint(.siloAccent) + .tint(.siloSwitchOn) } header: { Text("Cleanup") } footer: { Text("When off, the Downloads tab suggests freeing up space by removing items you've finished watching.") } - .listRowBackground(Color.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) Section("Storage") { HStack { @@ -98,7 +91,7 @@ struct DownloadsSettingsView: View { } } } - .listRowBackground(Color.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) if manager.heldProgressCount > 0 { Section { @@ -110,10 +103,10 @@ struct DownloadsSettingsView: View { } footer: { Text(heldProgressFooter) } - .listRowBackground(Color.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) } } - .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 diff --git a/iosApp/iosApp/Downloads/DownloadsView.swift b/iosApp/iosApp/Downloads/DownloadsView.swift index aa0d16d17..42d9a9763 100644 --- a/iosApp/iosApp/Downloads/DownloadsView.swift +++ b/iosApp/iosApp/Downloads/DownloadsView.swift @@ -49,7 +49,7 @@ struct DownloadsView: View { content } } - .siloPageBackground() + .background(Color.siloBackground.ignoresSafeArea()) .navigationTitle(isSelecting ? "\(selectedCount) Selected" : "Downloads") #if os(iOS) .navigationBarTitleDisplayMode(.large) @@ -126,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 { @@ -139,18 +140,20 @@ 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) + 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(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 22) + .padding(.horizontal, 32) + .padding(.bottom, 8) } #if os(iOS) if manager.canShowProgressOnLockScreen, !isSelecting { @@ -158,7 +161,7 @@ struct DownloadsView: View { manager.showProgressOnLockScreen() } label: { Label("Show Progress on Lock Screen", systemImage: "lock.iphone") - .font(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(.siloOnSurface) .padding(.horizontal, 12) .padding(.vertical, 7) @@ -167,9 +170,11 @@ struct DownloadsView: View { .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) + .padding(.bottom, 10) } #endif - ForEach(manager.inProgressRecords) { record in + 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. @@ -180,6 +185,7 @@ struct DownloadsView: View { 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) } @@ -188,37 +194,41 @@ struct DownloadsView: View { onCancel: { manager.deleteDownload(id: record.id) } ) } + .downloadGroupInset() } #if os(iOS) if !isSelecting { Text("Downloads keep going when you leave Silo or lock your phone. Closing Silo from the app switcher pauses them until you open it again.") - .font(.system(size: 12)) + .font(.footnote) .foregroundColor(.siloSecondaryText) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.top, 2) + .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 @@ -230,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( @@ -244,6 +256,7 @@ struct DownloadsView: View { onPlayEpisode: { play($0) }, onDeleteEpisode: { manager.deleteDownload(id: $0.id) } ) + .downloadGroupSlice(position) .contextMenu { if !isSelecting { Button(role: .destructive) { @@ -267,6 +280,7 @@ struct DownloadsView: View { else { router.navigate(to: .offlineDownloadDetail(downloadId: record.id)) } } ) + .downloadGroupSlice(position) .contextMenu { if !isSelecting { Button(role: .destructive) { @@ -286,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)) @@ -301,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(.siloOnSurface) .lineLimit(1) Text(SubscriptionMode(rawValue: subscription.mode)?.displayName ?? subscription.mode) - .font(.system(size: 12)) + .font(.subheadline) .foregroundColor(.siloSecondaryText) } Spacer(minLength: 8) } .padding(.horizontal, 16) .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.siloSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.siloOutline, lineWidth: 1) - ) - ) - .padding(.horizontal, 16) + .downloadGroupSlice(position) .contextMenu { Button(role: .destructive) { Task { await manager.deleteSubscription(id: subscription.id) } @@ -330,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(.siloSecondaryText) - Spacer() - Text("\(count)") - .font(.system(size: 12.5)) - .foregroundColor(.siloOnSurface.opacity(0.38)) - } - .padding(.horizontal, 22) - .padding(.top, 14) - .padding(.bottom, 2) - } - // MARK: - Toolbar & select mode @ToolbarContentBuilder diff --git a/iosApp/iosApp/Downloads/SeriesDownloadControls.swift b/iosApp/iosApp/Downloads/SeriesDownloadControls.swift index 6433e9301..3f2342389 100644 --- a/iosApp/iosApp/Downloads/SeriesDownloadControls.swift +++ b/iosApp/iosApp/Downloads/SeriesDownloadControls.swift @@ -914,13 +914,13 @@ struct SeriesMonitorSheet: View { else { selectedSeasons.remove(season.seasonNumber) } } )) - .tint(.siloAccent) + .tint(.siloSwitchOn) } } } Section("Storage") { Toggle("Delete watched episodes", isOn: $deleteWatched) - .tint(.siloAccent) + .tint(.siloSwitchOn) 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 2b04a45ee..d9ab17e06 100644 --- a/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift +++ b/iosApp/iosApp/Downloads/Views/DownloadManagerRows.swift @@ -17,6 +17,9 @@ struct DownloadActiveRow: View { 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 = {} @@ -27,7 +30,7 @@ struct DownloadActiveRow: View { if selecting { Button(action: onSelectToggle) { card } .buttonStyle(.plain) - .padding(.horizontal, 16) + .downloadGroupSlice(groupPosition) } else { actionableCard } @@ -39,7 +42,7 @@ struct DownloadActiveRow: View { } content: { card } - .padding(.horizontal, 16) + .downloadGroupSlice(groupPosition) .contextMenu { menuItems } .confirmationDialog( cancelPrompt, @@ -77,14 +80,8 @@ struct DownloadActiveRow: View { .padding(.horizontal, 16) .padding(.vertical, 12) .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.siloSurfaceElevated) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.siloOutline, lineWidth: 1) - ) - ) + // Opaque so the swipe-revealed Cancel action stays hidden underneath. + .background(Color.siloGroupedCell) } @ViewBuilder private var menuItems: some View { @@ -238,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.siloError) - ) + .background(Color.siloError) } .buttonStyle(.plain) .opacity(offset < -8 ? 1 : 0) @@ -320,15 +314,6 @@ struct DownloadAttentionRow: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.siloSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.siloOutline, lineWidth: 1) - ) - ) - .padding(.horizontal, 16) } } @@ -350,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(.siloOnSurface) - .lineLimit(1) - DownloadKindChip(text: "Movie") - } - if !meta.isEmpty { - Text(meta) - .font(.system(size: 12)) - .foregroundColor(.siloSecondaryText) - .lineLimit(1) - } + VStack(alignment: .leading, spacing: 3) { + Text(record.title ?? record.contentId) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.siloOnSurface) + .lineLimit(1) + Text(meta) + .font(.subheadline) + .foregroundColor(.siloSecondaryText) + .lineLimit(1) } Spacer(minLength: 8) - Text(DownloadFormatting.bytes(record.fileSize)) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.siloOnSurface) } - .padding(.horizontal, 18) + .padding(.horizontal, 16) .padding(.vertical, 10) .contentShape(Rectangle()) } @@ -380,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: " · ") } @@ -421,20 +399,10 @@ struct DownloadSeriesRow: View { } } } - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(expanded ? Color.siloSurfaceElevated : Color.siloSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.siloOutline, 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) { @@ -443,17 +411,15 @@ struct DownloadSeriesRow: View { .font(.system(size: 16, weight: .semibold)) .foregroundColor(.siloOnSurface) .lineLimit(1) + .layoutPriority(1) if group.isMonitored { monitorBadge } } Text(subtitleLine) - .font(.system(size: 12.5)) + .font(.subheadline) .foregroundColor(.siloSecondaryText) .lineLimit(1) } Spacer(minLength: 6) - Text(DownloadFormatting.bytes(group.totalBytes)) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.siloOnSurface) if !selecting { Button { withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } @@ -468,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) } @@ -483,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.siloSurfaceVariant) - .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.siloSurface) - .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 @@ -512,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(.siloOnSurface) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.siloChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.siloChromeRestingBorder, lineWidth: 1) - ) - ) + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.footnote.weight(.semibold)) + .foregroundColor(.siloSecondaryText) + .accessibilityLabel("Monitoring") } private var subtitleLine: String { @@ -539,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 { @@ -557,7 +515,7 @@ struct DownloadSeriesRow: View { .font(.system(size: 11.5)) .foregroundColor(.siloSecondaryText) } - .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 d04f08e8b..61f690b16 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.siloGroupedCell) + .overlay(alignment: .top) { + if position.hasSeparator { + Rectangle() + .fill(Color.siloDivider) + .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(.siloSecondaryText) + .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 the Silo wordmark's 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.siloBrandBlue + private static let moviesColor = Color.siloBrandRed + private static let inProgressColor = Color.siloBrandOrange + private static let otherColor = Color.siloOnSurface.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(.siloOnSurface) + Text(contextSuffix) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(.siloSecondaryText) ) if activeCount > 0 { Text(inProgressLine) - .font(.system(size: 12.5)) + .font(.footnote) .foregroundColor(.siloSecondaryText) } @@ -37,15 +148,6 @@ struct DownloadsStorageHeader: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color.siloSurfaceElevated) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .stroke(Color.siloOutline, 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.siloChromeRestingFill) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } - private func segment(width: CGFloat, opacity: Double) -> some View { - Color.siloOnSurface.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.siloOnSurface.opacity(opacity)) - .frame(width: 9, height: 9) - Text(DownloadFormatting.bytes(bytes)) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.siloOnSurface) + Circle() + .fill(color) + .frame(width: 8, height: 8) Text(label) - .font(.system(size: 12)) .foregroundColor(.siloSecondaryText) + Text(DownloadFormatting.bytes(bytes)) + .fontWeight(.semibold) + .foregroundColor(.siloOnSurface) } + .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.siloChromeSelectedFill) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.siloChromeSelectedBorder, lineWidth: 1) - ) - .frame(width: 34, height: 34) - .overlay( - Image(systemName: "sparkles") - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.siloOnSurface) - ) - - VStack(alignment: .leading, spacing: 2) { + ( Text("Free up \(DownloadFormatting.bytes(bytes))") - .font(.system(size: 14, weight: .semibold)) .foregroundColor(.siloOnSurface) - Text("\(episodeCount) item\(episodeCount == 1 ? "" : "s") you've finished") - .font(.system(size: 12.5)) + + Text(" · \(episodeCount) watched") .foregroundColor(.siloSecondaryText) - } + ) + .font(.subheadline) + .lineLimit(1) Spacer(minLength: 8) Text("Review") - .font(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(.siloOnSurface) - .padding(.horizontal, 13) + .padding(.horizontal, 14) .padding(.vertical, 7) - .background( - Capsule().fill(Color.siloChromeSelectedFill) - .overlay(Capsule().stroke(Color.siloChromeSelectedBorder, lineWidth: 1)) - ) + .background(Capsule().fill(Color.siloChromeSelectedFill)) } - .padding(13) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.siloSurfaceVariant) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.siloChromeSelectedBorder, 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(.siloOnSurface) } @@ -200,12 +287,12 @@ struct DownloadSortControl: View { Spacer() Text("\(itemCount) item\(itemCount == 1 ? "" : "s")") - .font(.system(size: 12.5)) - .foregroundColor(.siloOnSurface.opacity(0.38)) + .font(.footnote) + .foregroundColor(.siloSecondaryText) } - .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(.siloSecondaryText) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(Color.siloChromeRestingFill) - .overlay( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .stroke(Color.siloChromeRestingBorder, lineWidth: 1) - ) - ) - } -} #endif diff --git a/iosApp/iosApp/Extensions/ViewExtensions.swift b/iosApp/iosApp/Extensions/ViewExtensions.swift index 5074003ac..1693f3f44 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(.siloAccent) + .tint(.siloOnSurface) + .foregroundStyle(Color.siloBackground) .disabled(isLoading) .overlay { if isLoading { ProgressView() .controlSize(.small) + .tint(Color.siloBackground) } } } diff --git a/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift b/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift index 1aa4eb98a..6f9d98566 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.siloIconTile, 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.siloOnSurface : Color.secondary) } .padding(.horizontal, 14) .padding(.vertical, 12) @@ -271,7 +267,9 @@ struct CompanionPairingCard: View { } .buttonStyle(.borderedProminent) .controlSize(.large) - .tint(accent) + // White fill with dark text, like the app's other primary buttons. + .tint(.siloOnSurface) + .foregroundStyle(Color.siloBackground) } private func tertiaryButton(_ title: String, action: @escaping () -> Void) -> some View { @@ -279,7 +277,7 @@ struct CompanionPairingCard: View { Text(title).font(.siloBody).frame(maxWidth: .infinity).padding(.vertical, 8) } .buttonStyle(.plain) - .foregroundStyle(accent) + .foregroundStyle(Color.siloSecondaryText) } private func cancelButton() -> some View { diff --git a/iosApp/iosApp/Screens/Browse/FilterView.swift b/iosApp/iosApp/Screens/Browse/FilterView.swift index 3599495b2..e47460ad9 100644 --- a/iosApp/iosApp/Screens/Browse/FilterView.swift +++ b/iosApp/iosApp/Screens/Browse/FilterView.swift @@ -195,7 +195,7 @@ struct FilterView: View { .font(.siloBody) .foregroundColor(.siloOnSurface) } - .tint(Color.siloAccent) + .tint(.siloSwitchOn) .listRowBackground(Color.clear) .filterListRowSeparatorHidden() .onChange(of: preserve) { _, newValue in diff --git a/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift b/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift index 83f886e97..9c7e26b9e 100644 --- a/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift +++ b/iosApp/iosApp/Screens/Onboarding/OnboardingTourView.swift @@ -176,6 +176,8 @@ struct OnboardingTourView: View { ) .font(.siloBody) .foregroundStyle(Color.auroraInk) + // The accent is near-white, which would hide an on switch's knob. + .tint(.siloSwitchOn) .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 4492c24f7..76be8513a 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(.siloAccent) + .tint(.siloSwitchOn) 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(.siloAccent) + .tint(.siloSwitchOn) .disabled(matchesSystem) } footer: { Text(matchesSystem @@ -278,7 +278,7 @@ struct PlayerSettingsSheet: View { } Toggle("Text outline", isOn: appearanceBoolBinding(\.textOutline)) - .tint(.siloAccent) + .tint(.siloSwitchOn) 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(.siloAccent) + .tint(.siloOnSurface) Text("\(Int(draftOpacity ?? committed))%") .monospacedDigit() .foregroundStyle(.secondary) @@ -384,7 +384,7 @@ struct PlayerSettingsSheet: View { get: { viewModel.settings.autoPlayNextEpisode }, set: { viewModel.settings.setAutoPlayNextEpisode($0) } )) - .tint(.siloAccent) + .tint(.siloSwitchOn) } } } @@ -400,7 +400,7 @@ struct PlayerSettingsSheet: View { .foregroundStyle(.secondary) } } - .tint(.siloAccent) + .tint(.siloSwitchOn) } NavigationLink { @@ -567,7 +567,7 @@ struct PlayerSettingsSheet: View { get: { viewModel.settings.autoPlayNextEpisode }, set: { viewModel.settings.setAutoPlayNextEpisode($0) } )) - .tint(.siloAccent) + .tint(.siloSwitchOn) } } } @@ -624,7 +624,7 @@ struct PlayerSettingsSheet: View { viewModel.setSubtitleMatchesSystemAppearance(enabled) } )) - .tint(.siloAccent) + .tint(.siloSwitchOn) 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(.siloAccent) + .tint(.siloSwitchOn) .disabled(matchesSystem) Group { @@ -655,7 +655,7 @@ struct PlayerSettingsSheet: View { } Toggle("Text outline", isOn: appearanceBoolBinding(\.textOutline)) - .tint(.siloAccent) + .tint(.siloSwitchOn) 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 83883e763..a4a551b2d 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 06c203312..8daabba2f 100644 --- a/iosApp/iosApp/Screens/Profiles/CreateProfileView.swift +++ b/iosApp/iosApp/Screens/Profiles/CreateProfileView.swift @@ -365,7 +365,7 @@ struct CreateProfileView: View { .foregroundColor(.siloSecondaryText) } } - .tint(.siloAccent) + .tint(.siloSwitchOn) if isChild { childAccessControls @@ -456,7 +456,7 @@ struct CreateProfileView: View { } Toggle("Restrict libraries", isOn: $libraryRestrictionsEnabled) - .tint(.siloAccent) + .tint(.siloSwitchOn) if libraryRestrictionsEnabled { if case .loading = libraryLoad { @@ -507,7 +507,7 @@ struct CreateProfileView: View { } ) ) - .tint(.siloAccent) + .tint(.siloSwitchOn) } } } diff --git a/iosApp/iosApp/Screens/Servers/ServerListView.swift b/iosApp/iosApp/Screens/Servers/ServerListView.swift index 752a75594..047b4d8be 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") .siloNavigationTitleDisplayMode(.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 Silo 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(.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) Section { Button { @@ -202,7 +194,7 @@ struct ServerListView: View { .foregroundColor(.siloOnSurface) } } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } .settingsListChrome() } diff --git a/iosApp/iosApp/Screens/Settings/DiagnosticsSettingsView.swift b/iosApp/iosApp/Screens/Settings/DiagnosticsSettingsView.swift index 74c3f2316..fed3858bb 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") .siloNavigationTitleDisplayMode(.inline) .siloToolbarColorSchemeDark() .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.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) } private var preferencesSection: some View { @@ -69,7 +61,7 @@ struct DiagnosticsSettingsView: View { } Toggle("Debug Logging", isOn: $model.debugLoggingEnabled) - .tint(.siloAccent) + .tint(.siloSwitchOn) 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.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) } private var pendingSection: some View { @@ -141,7 +133,7 @@ struct DiagnosticsSettingsView: View { } header: { Text("Pending Reports (\(model.pendingReports.count))") } - .listRowBackground(Color.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) } 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.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) } private var sentHistorySection: some View { @@ -202,7 +194,7 @@ struct DiagnosticsSettingsView: View { } } } - .listRowBackground(Color.siloSurfaceElevated.opacity(0.92)) + .listRowBackground(Color.siloGroupedCell) } private func requestModeChange(_ mode: DiagnosticsConsentChoice) { diff --git a/iosApp/iosApp/Screens/Settings/GeneralSettingsView.swift b/iosApp/iosApp/Screens/Settings/GeneralSettingsView.swift index 57b182555..4b6214005 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 Silo on this device.", - systemImage: "gearshape.fill", - tint: .purple - ) - .settingsPageHeaderRow() - profileSection } .settingsListChrome() - .navigationTitle("") + .navigationTitle("General") .siloNavigationTitleDisplayMode(.inline) .siloToolbarColorSchemeDark() } @@ -48,7 +40,7 @@ struct GeneralSettingsView: View { Text(launchPreferences.behavior.standardDescription) .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } } #endif diff --git a/iosApp/iosApp/Screens/Settings/HeldSettingChangesSection.swift b/iosApp/iosApp/Screens/Settings/HeldSettingChangesSection.swift index f6171d1dd..c0f5c2cbf 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.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } } #endif diff --git a/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift b/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift index 2384357c3..939f24e3b 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("") - .siloNavigationTitleDisplayMode(.inline) - .siloNavigationBarBackgroundHidden() + .siloGroupedListStyle() + .siloScrollContentBackgroundHidden() + .scrollDismissesKeyboard(.interactively) + .frame(maxWidth: 760) + .frame(maxWidth: .infinity) + .background(Color.siloBackground.ignoresSafeArea()) + .navigationTitle("Settings") + .siloNavigationTitleDisplayMode(.large) .siloToolbarColorSchemeDark() .onAppear(perform: navPrefs.refresh) } - private var pageHeader: some View { - VStack(alignment: .leading, spacing: 5) { - Text("Settings") - .font(.largeTitle) - .bold() - .foregroundStyle(Color.siloOnSurface) - - Text("Make Silo work the way you like.") - .font(.subheadline) - .foregroundStyle(Color.siloSecondaryText) - } - .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 Silo 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 Silo app version", systemImage: "info.circle.fill", - tint: .gray, - value: versionString, - showsChevron: false + value: versionString ) - SettingsOverviewDivider() - Link(destination: SiloLegalLinks.privacyPolicy) { SettingsOverviewRow( title: "Privacy Policy", subtitle: "Learn how Silo 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 820e892f4..9fb06c18b 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.siloAccent) + .foregroundStyle(Color.siloSecondaryText) 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 81e80329e..0683b87f4 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") .siloNavigationTitleDisplayMode(.inline) .siloToolbarColorSchemeDark() } @@ -97,7 +90,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) Toggle("Seek Cache", isOn: Binding( get: { viewModel.seekCacheEnabled }, @@ -107,7 +100,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) Picker("Buffer Ahead", selection: Binding( get: { viewModel.bufferAhead }, @@ -135,7 +128,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) Toggle("TrueHD Atmos", isOn: Binding( get: { viewModel.trueHDAtmosEnabled }, @@ -145,7 +138,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) Picker("Deinterlacing", selection: Binding( get: { viewModel.deinterlaceMode }, @@ -195,7 +188,7 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) #endif } header: { Text("Streaming") @@ -204,25 +197,18 @@ struct PlaybackSettingsView: View { Text(streamingFooterText) .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } 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 = SiloQualityPresets.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: Silo 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 Silo'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.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) Picker("Show Next Up", selection: Binding( get: { viewModel.nextUpPromptSeconds }, @@ -286,12 +272,12 @@ struct PlaybackSettingsView: View { } )) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) } header: { Text("Episodes") .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } // MARK: - Refused change @@ -308,7 +294,7 @@ struct PlaybackSettingsView: View { Text(SettingsViewModel.rejectedPlaybackChangeMessage) .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } // 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.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } // MARK: - Options diff --git a/iosApp/iosApp/Screens/Settings/SeekIntervalSettingsSections.swift b/iosApp/iosApp/Screens/Settings/SeekIntervalSettingsSections.swift index 09b669102..972bde8dc 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.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } private func picker( diff --git a/iosApp/iosApp/Screens/Settings/SettingsAccountCard.swift b/iosApp/iosApp/Screens/Settings/SettingsAccountCard.swift index 8250cd8bb..7cca2c127 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.siloSecondaryText) + ProfileAvatarView(avatar: avatar, imageUrl: avatarImageUrl, name: name, size: 56) + VStack(alignment: .leading, spacing: 2) { Text(name) - .font(.headline) + .font(.title3.weight(.semibold)) .foregroundStyle(Color.siloOnSurface) .lineLimit(1) Text(subtitle) - .font(.footnote) + .font(.subheadline) .foregroundStyle(Color.siloSecondaryText) .lineLimit(1) } @@ -36,31 +34,18 @@ struct SettingsAccountCard: View { if isAdministrator { Text("Admin") - .font(.caption) - .bold() - .foregroundStyle(Color.siloAccent) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.siloSecondaryText) .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.siloAccent.opacity(0.12), in: Capsule()) + .padding(.vertical, 4) + .background(Color.siloChromeSelectedFill, in: Capsule()) } - Image(systemName: "chevron.right") - .font(.footnote) - .bold() - .foregroundStyle(Color.siloSecondaryText) - .accessibilityHidden(true) + SettingsRowChevron() } - .padding(15) - .frame(maxWidth: .infinity, minHeight: 80, alignment: .leading) + .padding(.vertical, 6) .contentShape(Rectangle()) } - .buttonStyle(.plain) - .background(Color.siloSurfaceElevated.opacity(0.9)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - .overlay { - RoundedRectangle(cornerRadius: 18) - .strokeBorder(Color.siloOutline, 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 312b2d186..b93f06b5c 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.siloBackground - - RadialGradient( - colors: [ - Color.siloAccent.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.siloBrandOrange.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.siloBackground + .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..b027c566a --- /dev/null +++ b/iosApp/iosApp/Screens/Settings/SettingsListChrome.swift @@ -0,0 +1,11 @@ +#if !os(tvOS) +import SwiftUI + +extension View { + func settingsListChrome() -> some View { + siloGroupedListStyle() + .siloScrollContentBackgroundHidden() + .background(SettingsBackdrop()) + } +} +#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsOverviewDivider.swift b/iosApp/iosApp/Screens/Settings/SettingsOverviewDivider.swift deleted file mode 100644 index 9e3e81b53..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.siloDivider) - .padding(.leading, 66) - } -} -#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsOverviewRow.swift b/iosApp/iosApp/Screens/Settings/SettingsOverviewRow.swift index efffe5184..3249cb870 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 = .siloAccent 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.siloOnSurface) - - Text(subtitle) - .font(.footnote) - .foregroundStyle(Color.siloSecondaryText) - .lineLimit(2) - } + Text(title) + .foregroundStyle(Color.siloOnSurface) Spacer(minLength: 8) if let value { Text(value) - .font(.subheadline) .foregroundStyle(Color.siloSecondaryText) .lineLimit(1) .minimumScaleFactor(0.8) } if showsChevron { - Image(systemName: "chevron.right") - .font(.footnote) - .bold() - .foregroundStyle(Color.siloSecondaryText) - .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.siloIconTile, + 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 07b4f5fe7..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.siloSecondaryText) - .padding(.horizontal, 4) - - VStack(spacing: 0) { - content - } - .background(Color.siloSurfaceElevated.opacity(0.88)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - .overlay { - RoundedRectangle(cornerRadius: 18) - .strokeBorder(Color.siloOutline, lineWidth: 1) - } - } - } -} -#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsOverviewToggleRow.swift b/iosApp/iosApp/Screens/Settings/SettingsOverviewToggleRow.swift index eb5915d1f..71dedd5f6 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 = .siloAccent @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.siloOnSurface) - - Text(subtitle) - .font(.footnote) - .foregroundStyle(Color.siloSecondaryText) - } + Text(title) + .foregroundStyle(Color.siloOnSurface) } } - .tint(.siloAccent) - .padding(.horizontal, 15) - .padding(.vertical, 13) - .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + .tint(.siloSwitchOn) .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 2687ce943..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 = .siloAccent - - 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.siloOnSurface) - - Text(subtitle) - .font(.subheadline) - .foregroundStyle(Color.siloSecondaryText) - .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 { - siloGroupedListStyle() - .siloScrollContentBackgroundHidden() - .background(SettingsBackdrop()) - } -} -#endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsSearchField.swift b/iosApp/iosApp/Screens/Settings/SettingsSearchField.swift index 67f1af4db..0ae395b3d 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.siloSecondaryText) .accessibilityHidden(true) - TextField("Search settings", text: $text) + TextField("Search", text: $text) .textInputAutocapitalization(.never) .autocorrectionDisabled() .foregroundStyle(Color.siloOnSurface) + .accessibilityLabel("Search settings") if !text.isEmpty { Button("Clear search", systemImage: "xmark.circle.fill") { text = "" } .labelStyle(.iconOnly) + .buttonStyle(.plain) .foregroundStyle(Color.siloSecondaryText) .frame(width: 44, height: 44) } } - .padding(.horizontal, 15) - .frame(minHeight: 48) - .background(Color.siloSurfaceElevated.opacity(0.78)) - .clipShape(RoundedRectangle(cornerRadius: 15)) - .overlay { - RoundedRectangle(cornerRadius: 15) - .strokeBorder(Color.siloOutline, lineWidth: 1) - } + .padding(.horizontal, 14) + .frame(minHeight: 44) + .background(Color.siloGroupedCell, in: Capsule()) } } #endif diff --git a/iosApp/iosApp/Screens/Settings/SettingsView.swift b/iosApp/iosApp/Screens/Settings/SettingsView.swift index 87b28c599..6d090a532 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 7ca4fa576..7775a422a 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") .siloNavigationTitleDisplayMode(.inline) .siloToolbarColorSchemeDark() .onChange(of: viewModel.prefs.subtitleLanguage) { _, _ in @@ -80,7 +72,7 @@ struct SubtitleSettingsView: View { .foregroundStyle(Color.siloSecondaryText) } .disabled(viewModel.prefs.serverUpgradeRequired) - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } // MARK: - Profile prefs (server-backed) @@ -125,7 +117,7 @@ struct SubtitleSettingsView: View { ) ) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) } header: { Text("Profile") .foregroundStyle(Color.siloSecondaryText) @@ -151,7 +143,7 @@ struct SubtitleSettingsView: View { .foregroundStyle(Color.siloSecondaryText) } .disabled(viewModel.prefs.serverUpgradeRequired || viewModel.subtitleMatchesSystemAppearance) - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) } // MARK: - Appearance (per-device override) @@ -174,7 +166,7 @@ struct SubtitleSettingsView: View { .foregroundStyle(Color.siloError) } } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) Section { Toggle( @@ -187,7 +179,7 @@ struct SubtitleSettingsView: View { ) ) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) Toggle( "Custom Appearance", @@ -199,7 +191,7 @@ struct SubtitleSettingsView: View { ) ) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) .disabled(manualEditingDisabled) } footer: { VStack(alignment: .leading, spacing: 6) { @@ -218,7 +210,7 @@ struct SubtitleSettingsView: View { } .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) Section { Picker("Font Size", selection: appearanceBinding(\.fontSize)) { @@ -253,7 +245,7 @@ struct SubtitleSettingsView: View { Toggle("Text Outline", isOn: appearanceBinding(\.textOutline)) .foregroundStyle(Color.siloOnSurface) - .tint(.siloAccent) + .tint(.siloSwitchOn) ColorChoicePicker( title: "Outline Color", @@ -266,7 +258,7 @@ struct SubtitleSettingsView: View { Text("Text") .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) .disabled(manualEditingDisabled) .opacity(manualEditingDisabled ? 0.45 : 1) @@ -298,7 +290,7 @@ struct SubtitleSettingsView: View { Text("Background") .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) .disabled(manualEditingDisabled) .opacity(manualEditingDisabled ? 0.45 : 1) @@ -318,7 +310,7 @@ struct SubtitleSettingsView: View { Text("Layout") .foregroundStyle(Color.siloSecondaryText) } - .listRowBackground(Color.siloSurfaceElevated) + .listRowBackground(Color.siloGroupedCell) .disabled(manualEditingDisabled) .opacity(manualEditingDisabled ? 0.45 : 1) } @@ -361,7 +353,7 @@ struct SubtitleSettingsView: View { next.backgroundOpacity = percent Task { await viewModel.setSubtitleAppearance(next) } } - .tint(.siloAccent) + .tint(.siloOnSurface) Text("\(Int(draftOpacity ?? committed))%") .monospacedDigit() .foregroundStyle(Color.siloSecondaryText) diff --git a/iosApp/iosApp/Theme/Colors.swift b/iosApp/iosApp/Theme/Colors.swift index fbe126ad6..ddc89d881 100644 --- a/iosApp/iosApp/Theme/Colors.swift +++ b/iosApp/iosApp/Theme/Colors.swift @@ -21,15 +21,28 @@ extension Color { /// Primary text color (#EDEDED) static let siloOnSurface = Color(hex: "#EDEDED") - /// Accent for enabled control states (toggle tracks, prominent buttons). - /// The monochrome palette made an on toggle a white knob on a white - /// track; this matches the web client's blue-theme primary. - static let siloAccent = Color(hex: "#78AEFC") + /// Track of an on switch. The monochrome palette would make an on switch + /// a white knob on a white track, so switches keep the system's dark-mode + /// green; every other control stays monochrome. + static let siloSwitchOn = Color(hex: "#30D158") + + /// Row background of an inset-grouped list — the system's dark-mode + /// secondary grouped background, shared by Settings and Downloads. + static let siloGroupedCell = Color(hex: "#1C1C1E") + + /// Graphite fill behind Settings row icons. + static let siloIconTile = Color(hex: "#3A3A3C") /// Orange sampled from the canonical Silo wordmark artwork. Reserved for - /// branded moments so ordinary signed-in controls retain `siloAccent`. + /// branded moments such as the storage breakdown. static let siloBrandOrange = Color(hex: "#FD7403") + /// Blue sampled from the Silo wordmark. + static let siloBrandBlue = Color(hex: "#0034FB") + + /// Red sampled from the Silo wordmark. + static let siloBrandRed = Color(hex: "#F50B4F") + /// Muted/secondary text — primary at 60% opacity (#99EDEDED) static let siloSecondaryText = Color(hex: "#99EDEDED") diff --git a/iosApp/iosApp/macOS/PlayerView.swift b/iosApp/iosApp/macOS/PlayerView.swift index 7150377ec..829a06304 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(.siloOnSurface) + .foregroundStyle(Color.siloBackground) Button("Close") { dismiss() diff --git a/iosApp/iosApp/tvOS/Screens/Settings/TVGeneralSettingsView.swift b/iosApp/iosApp/tvOS/Screens/Settings/TVGeneralSettingsView.swift index 852bda3b7..cad51745e 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.siloAccent.opacity(0.16) : .clear, - radius: 16 + color: isFocused ? Color.black.opacity(0.5) : .clear, + radius: 16, + y: 6 ) .focusEffectDisabled() .animation(.easeOut(duration: SiloTheme.fastDuration), value: isFocused) diff --git a/iosApp/iosApp/tvOS/Screens/Settings/TVPlaybackSettingsView.swift b/iosApp/iosApp/tvOS/Screens/Settings/TVPlaybackSettingsView.swift index 71c16ae8d..177a31009 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 = SiloQualityPresets.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, Silo 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 Silo'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 90de7b1d4..2dc0a736c 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.siloChromeSelectedBorder - : Color.clear, - lineWidth: 1 - ) - ) - .overlay(alignment: .leading) { - Capsule() - .fill(Color.siloAccent) - .frame(width: 4) - .padding(.vertical, 12) - .opacity(isSelected && !isFocused ? 1 : 0) - } .scaleEffect(configuration.isPressed ? 0.98 : (isFocused ? 1.012 : 1)) .shadow( - color: isFocused ? Color.siloAccent.opacity(0.14) : .clear, - radius: 18 + color: isFocused ? Color.black.opacity(0.5) : .clear, + radius: 18, + y: 8 ) .animation(.easeOut(duration: SiloTheme.fastDuration), value: isFocused) } @@ -204,7 +189,7 @@ private struct TVSettingsRailRowBody: View { private var fill: Color { if isDestructive && isFocused { return .siloError } if isFocused { return .siloOnSurface } - if isSelected { return .siloSurfaceElevated.opacity(0.92) } + if isSelected { return .siloChromeSelectedFill } return .clear } } @@ -251,8 +236,9 @@ private struct TVSettingsPaneRowBody: View { ) .scaleEffect(configuration.isPressed ? 0.98 : (isFocused ? 1.012 : 1)) .shadow( - color: isFocused ? Color.siloAccent.opacity(0.16) : .clear, - radius: 18 + color: isFocused ? Color.black.opacity(0.5) : .clear, + radius: 18, + y: 8 ) .focusEffectDisabled() .animation(.easeOut(duration: SiloTheme.fastDuration), value: isFocused) @@ -268,7 +254,7 @@ private struct TVSettingsPaneRowBody: View { private var backgroundFill: Color { if isFocused { return .siloOnSurface } if isSelected { return .siloChromeSelectedFill } - return .siloSurfaceElevated.opacity(0.84) + return .siloChromeRestingFill } 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.siloAccent.opacity(0.86)) + .font(.system(size: 19, weight: .semibold)) + .tracking(1.2) + .foregroundStyle(Color.siloSecondaryText) .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.siloAccent) + .foregroundStyle(Color.siloOnSurface) .accessibilityHidden(true) Text("Privacy Policy") @@ -594,7 +579,7 @@ struct TVPrivacyPolicyOverlay: View { Text(SiloLegalLinks.privacyPolicy.absoluteString) .font(.system(size: 20, weight: .medium, design: .monospaced)) - .foregroundStyle(Color.siloAccent) + .foregroundStyle(Color.siloSecondaryText) .accessibilityLabel("Privacy policy URL") .accessibilityValue(SiloLegalLinks.privacyPolicy.absoluteString) @@ -654,17 +639,6 @@ struct TVSettingsPickerSheet: View { Color.siloBackground.opacity(0.88) .ignoresSafeArea() - RadialGradient( - colors: [ - Color.siloAccent.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 e8563827e..a306ab29f 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.siloSurface.opacity(0.74)) - ) - .overlay { - RoundedRectangle(cornerRadius: 26) - .strokeBorder(Color.siloOutline, 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.siloOnSurface) - - Text("Make Silo work the way you like.") - .font(.system(size: 18)) - .foregroundStyle(Color.siloSecondaryText) - } - .padding(.horizontal, 20) - .padding(.bottom, 20) + Text("Settings") + .font(.system(size: 48, weight: .bold)) + .foregroundStyle(Color.siloOnSurface) + .padding(.horizontal, 20) + .padding(.bottom, 20) profileRow .padding(.bottom, 22) @@ -255,8 +241,7 @@ struct TVSettingsView: View { signOutRow Text("Silo \(Self.versionString)") - .font(.system(size: 16, weight: .medium, design: .monospaced)) - .tracking(1) + .font(.system(size: 18)) .foregroundColor(.siloSecondaryText.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.siloIconTile, + 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.siloOnSurface) Text(selectedCategory.blurb) @@ -780,14 +759,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: @@ -813,15 +784,5 @@ enum TVSettingsCategory: String, CaseIterable, Identifiable { } } - var tint: Color { - switch self { - case .general, .playback, .subtitles: - return .siloAccent - case .diagnostics: - return .orange - case .server: - return .teal - } - } } #endif