diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index be8fc621..e9fd89d3 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -681,8 +681,17 @@ final class PlayerService { watchHistorySyncService?.beginSession(video: video) } + // The end of the previous watch left the player set up for a video that + // is over: no save timer, and the natural-end flag that tells play() the + // final save is already done. A second watch has to undo both, in the + // order play() does it — the flag before the seek, the timer only once + // playback is running again, so a seek that never gets there cannot + // leave a timer saving progress for an ended video. + videoEndedNaturally = false + await seek(to: 0) resume() + startProgressSaveTimer() } /// Stops playback and clears the player. diff --git a/Yattee/Views/Home/HistoryListView.swift b/Yattee/Views/Home/HistoryListView.swift index 1d38d1f6..63272457 100644 --- a/Yattee/Views/Home/HistoryListView.swift +++ b/Yattee/Views/Home/HistoryListView.swift @@ -63,10 +63,25 @@ struct HistoryListView: View { /// scrolls in and out again does not ask twice — including the ones whose /// fetch failed, which would otherwise retry on every pass. @State private var hydrationAttempted: Set = [] + /// The metadata fetches in flight, by video ID. + /// + /// Held on to so a reload can cancel them: without that, switching accounts + /// mid-scroll leaves a whole load's worth of requests running against an + /// instance whose history is already off screen, and starts a second set + /// beside them. + @State private var hydrationTasks: [String: Task] = [:] + /// The rows waiting for a fetch slot, in the order they asked for one. + @State private var hydrationQueue: [String] = [] /// How many IDs one page of server history asks for. private static let serverPageSize = 60 + /// How many metadata fetches a load may have in flight at once. + /// + /// A page is 60 rows and a search hydrates all of them at once, which + /// unbounded would be 60 simultaneous requests to a public instance. + private static let hydrationConcurrency = 6 + /// The page to ask the server for next. /// /// Read from the rows that are loaded rather than counted up from the last @@ -587,6 +602,7 @@ struct HistoryListView: View { private func startLoad() { loadTask?.cancel() loadTask = nil + cancelHydration() guard let account = beginLoad() else { return } beginPageLoad(1, account: account) @@ -768,35 +784,69 @@ struct HistoryListView: View { } } - /// Fetches one row's metadata, as the row appears. + /// Asks for one row's metadata, as the row appears. /// - /// Through the account's own instance: every ID the server returns is a - /// YouTube ID, and the account's instance is the one that has it. + /// The request itself waits its turn: the row joins the queue, and the + /// fetches are started a few at a time from there. private func hydrate(_ row: HistoryRow, account: Account) { guard !row.isHydrated, !hydrationAttempted.contains(row.videoID), let appEnvironment, - let instance = appEnvironment.accountsManager.instance(for: account) + appEnvironment.accountsManager.instance(for: account) != nil else { return } hydrationAttempted.insert(row.videoID) + hydrationQueue.append(row.videoID) + pumpHydration(account: account) + } + + /// Starts as many queued fetches as there are free slots. + /// + /// Through the account's own instance: every ID the server returns is a + /// YouTube ID, and the account's instance is the one that has it. + private func pumpHydration(account: Account) { + guard let appEnvironment, + let instance = appEnvironment.accountsManager.instance(for: account) + else { return } - let videoID = row.videoID let generation = loadGeneration - Task { - do { - let video = try await appEnvironment.contentService.video(id: videoID, instance: instance) + while hydrationTasks.count < Self.hydrationConcurrency, !hydrationQueue.isEmpty { + let videoID = hydrationQueue.removeFirst() + + hydrationTasks[videoID] = Task { + do { + let video = try await appEnvironment.contentService.video(id: videoID, instance: instance) + guard generation == loadGeneration else { return } + apply(video, to: videoID) + } catch { + // The row keeps its placeholder. A video the account watched + // is part of its history whether or not this instance can + // describe it right now. + } + + // A finished fetch frees its slot for the next row — but only + // for the load it belongs to. A later one has already emptied + // this queue and started its own, and giving back a slot there + // would drop a task that is still running. guard generation == loadGeneration else { return } - apply(video, to: videoID) - } catch { - // The row keeps its placeholder. A video the account watched is - // part of its history whether or not this instance can describe - // it right now. + hydrationTasks[videoID] = nil + pumpHydration(account: account) } } } + /// Drops everything the current load was still going to fetch, and stops + /// what it had already started. + private func cancelHydration() { + for task in hydrationTasks.values { + task.cancel() + } + + hydrationTasks.removeAll() + hydrationQueue.removeAll() + } + private func apply(_ video: Video, to videoID: String) { guard let index = rows.firstIndex(where: { $0.videoID == videoID }) else { return }