Start a session where the watch actually begins

This commit is contained in:
Arkadiusz Fal
2026-09-06 22:21:15 +02:00
parent 0e289ae920
commit 5072fb0dce
7 changed files with 463 additions and 27 deletions

View File

@@ -49,8 +49,13 @@ struct HistoryListView: View {
/// looking at any more.
@State private var loadGeneration = 0
@State private var loadTask: Task<Void, Never>?
/// The page to ask for next; 1-based, like the server's own numbering.
@State private var nextPage = 1
/// The lowest page still worth asking for; 1-based, like the server's own
/// numbering.
///
/// Normally the page number comes from the rows themselves. This is the
/// floor under it, raised past any page that turned out to hold nothing new,
/// so a page that cannot move the list on is never asked for twice.
@State private var pageFloor = 1
@State private var hasMorePages = false
@State private var isLoadingPage = false
@State private var serverErrorMessage: String?
@@ -62,6 +67,19 @@ struct HistoryListView: View {
/// How many IDs one page of server history asks for.
private static let serverPageSize = 60
/// The page to ask the server for next.
///
/// Read from the rows that are loaded rather than counted up from the last
/// request, so a deletion — which shifts every server offset after it —
/// cannot make the next request skip the item that moved back across the
/// page boundary.
private var nextServerPage: Int {
max(
ServerHistoryPaging.nextPage(loadedRowCount: rows.count, pageSize: Self.serverPageSize),
pageFloor
)
}
// View options (persisted)
@AppStorage("history.layout") private var layout: VideoListLayout = .list
@AppStorage("history.rowStyle") private var rowStyle: VideoRowStyle = .regular
@@ -347,6 +365,17 @@ struct HistoryListView: View {
.onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in
reloadIfEffectiveContextMoved()
}
// Something was just watched and mirrored to the account whose history
// this is. The server has moved it to the front; so does the list,
// rather than waiting to be left and come back to.
.onReceive(NotificationCenter.default.publisher(for: .serverWatchHistoryDidUpdate)) { notification in
applyServerUpdate(notification)
}
// Searching reads titles, and a server row starts with only an ID.
.onChange(of: searchText) { _, newValue in
guard !newValue.isEmpty, case let .server(account) = displayMode else { return }
hydrateAllLoaded(account: account)
}
}
// MARK: - Content
@@ -454,7 +483,7 @@ struct HistoryListView: View {
private var loadMoreFooter: some View {
if case let .server(account) = displayMode {
LoadMoreTrigger(isLoading: isLoadingPage, hasMore: hasMorePages) {
beginPageLoad(nextPage, account: account)
beginPageLoad(nextServerPage, account: account)
}
}
}
@@ -586,7 +615,7 @@ struct HistoryListView: View {
serverErrorMessage = nil
isLoadingPage = false
hydrationAttempted.removeAll()
nextPage = 1
pageFloor = 1
guard case let .server(account) = displayMode else {
hasMorePages = false
@@ -631,11 +660,24 @@ struct HistoryListView: View {
guard generation == loadGeneration else { return }
append(ids)
nextPage = page + 1
let appended = append(ids)
// A page that held nothing this list did not already have cannot be
// what moves it on, however many times it is asked for.
if appended == 0 {
pageFloor = page + 1
}
// A page the server could not fill is the last one there is; so is
// an empty one, which is what asking past the end returns.
hasMorePages = ids.count >= Self.serverPageSize
// A search is matching on titles, and rows that arrived without one
// would sit out of the results until somebody scrolled them into
// view — which, being filtered out, they never are.
if !searchText.isEmpty {
hydrateAllLoaded(account: account)
}
} catch is CancellationError {
// Another load is taking over — it owns the state now.
} catch let error as APIError where error == .cancelled {
@@ -652,7 +694,9 @@ struct HistoryListView: View {
/// Never re-sorted: the server's order *is* the history, newest first, and
/// the local entries some of these rows have were written at times that say
/// nothing about when the account watched them.
private func append(_ ids: [String]) {
/// - Returns: how many of the IDs were rows this list did not already have.
@discardableResult
private func append(_ ids: [String]) -> Int {
var known = Set(rows.map(\.videoID))
var appended: [HistoryRow] = []
@@ -661,10 +705,11 @@ struct HistoryListView: View {
appended.append(makeRow(id: id))
}
guard !appended.isEmpty else { return }
guard !appended.isEmpty else { return 0 }
rows.append(contentsOf: appended)
rowVideos.append(contentsOf: appended.map(\.video))
return appended.count
}
/// A row for one server-side ID, from local data where there is some.
@@ -710,6 +755,19 @@ struct HistoryListView: View {
hydrate(row, account: account)
}
/// Fetches metadata for every loaded row that has none, whether or not it is
/// on screen.
///
/// For searching, which reads titles: a row still standing as its own video
/// ID matches nothing anybody would type, and the usual trigger — the row
/// appearing — cannot fire for a row the search has filtered out. Bounded by
/// what is loaded, and each row is still only ever asked for once.
private func hydrateAllLoaded(account: Account) {
for row in rows where !row.isHydrated {
hydrate(row, account: account)
}
}
/// Fetches one row's metadata, as the row appears.
///
/// Through the account's own instance: every ID the server returns is a
@@ -750,6 +808,44 @@ struct HistoryListView: View {
}
}
// MARK: - Live updates
/// Moves a video that was just recorded on the server to the front of the
/// list.
///
/// Only for the account the rows on screen were loaded for — `loadedContext`
/// rather than the live one, which is the same guard the generation gives an
/// awaited result: a post that lands as the context moves belongs to the
/// history that is on its way out, not the one arriving.
private func applyServerUpdate(_ notification: Notification) {
guard case let .server(account) = displayMode,
let accountID = notification.userInfo?[ServerWatchHistoryUpdate.accountIDKey] as? UUID,
accountID == account.id,
loadedContext == .account(accountID),
let videoID = notification.userInfo?[ServerWatchHistoryUpdate.videoIDKey] as? String
else { return }
bumpToFront(videoID)
}
/// Puts a video at the top of the rows, wherever it was — or was not.
///
/// What the server does with a repeated post: the video moves to the front
/// of the history rather than appearing twice in it. A video not among the
/// loaded rows is new to the front of the list, and arrives with whatever
/// the device already knows about it, which for something just watched here
/// is everything.
private func bumpToFront(_ videoID: String) {
if let index = rows.firstIndex(where: { $0.videoID == videoID }) {
guard index != 0 else { return }
rows.insert(rows.remove(at: index), at: 0)
} else {
rows.insert(makeRow(id: videoID), at: 0)
}
rowVideos = rows.map(\.video)
}
// MARK: - Reading
private func loadHistory() {

View File

@@ -0,0 +1,34 @@
//
// ServerHistoryPaging.swift
// Yattee
//
// Which page of an account's server history to ask for next.
//
import Foundation
/// Works out the next page number of a server history from what is on screen.
///
/// Its own type so the arithmetic can be reasoned about — and tested — apart
/// from the view that scrolls.
enum ServerHistoryPaging {
/// The page to request next, given how many rows are loaded.
///
/// Derived from the rows rather than counted up from the last request,
/// because the server pages by offset and a deletion shifts every offset
/// after it. Counting up would ask for the page after the one just read and
/// step straight over the item that moved back across the boundary — gone
/// for the rest of the session, one more for every further deletion. Asking
/// for the page the loaded rows actually end in re-reads that boundary
/// instead, and the caller's de-duplication drops what is already there.
///
/// With nothing deleted this is the same number counting up would give.
///
/// - Parameters:
/// - loadedRowCount: how many rows of this account's history are held.
/// - pageSize: how many IDs one request asks for.
static func nextPage(loadedRowCount: Int, pageSize: Int) -> Int {
guard pageSize > 0 else { return 1 }
return max(0, loadedRowCount) / pageSize + 1
}
}

View File

@@ -22,12 +22,14 @@ extension ExpandedPlayerSheet {
}
/// Restarts playback from the beginning.
///
/// The video this offers has finished, so playing it again is a new watch —
/// which is the player's to know about, not the overlay's.
func restartPlayback() {
guard let playerService else { return }
Task {
await playerService.seek(to: 0)
playerService.resume()
await playerService.replayCurrentVideo()
}
}