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

@@ -264,13 +264,14 @@ final class PlayerService {
saveProgressAndSync()
}
// Mirror this watch into the active account's server history. Only for
// a genuinely new video: an audio-mode toggle or a quality switch
// reloads the same one, and starting a session for those would post it
// to the server a second time.
if isNewVideo {
watchHistorySyncService?.beginSession(video: video)
}
// Mirror this watch into the active account's server history.
// Unconditional, and the service decides: `isNewVideo` is already false
// for most openings, because openVideo() puts the video into state
// before this task runs. The service compares against the session it is
// holding instead, which keeps a reload of the same video — an
// audio-mode toggle, a quality switch, a stream retry — from posting it
// a second time.
watchHistorySyncService?.beginSessionIfNeeded(video: video)
// Reset flag for the new video
videoEndedNaturally = false
@@ -660,14 +661,30 @@ final class PlayerService {
} else if state.playbackState == .ended {
// Restart from beginning when video has ended
Task {
await seek(to: 0)
resume()
await replayCurrentVideo()
}
} else {
resume()
}
}
/// Plays the current video again from the beginning, as a new watch.
///
/// Every way a *finished* video is started again comes through here: the
/// replay button, the end-of-queue overlay, repeat-one, play/pause on an
/// ended video. Each of those is a second watch of the same video, so the
/// mirroring session is started fresh and the video is posted to the
/// account's history again — which is what moves it back to the front.
/// Seeking to the start mid-video is not this, and does not pass here.
func replayCurrentVideo() async {
if let video = state.currentVideo {
watchHistorySyncService?.beginSession(video: video)
}
await seek(to: 0)
resume()
}
/// Stops playback and clears the player.
func stop() {
sleepPreventionService.allowSleep()
@@ -807,9 +824,8 @@ final class PlayerService {
// Handle queue mode behavior
switch state.queueMode {
case .repeatOne:
// Restart current video
await seek(to: 0)
resume()
// Restart current video — a second watch of it, not a continuation
await replayCurrentVideo()
return
case .repeatAll:

View File

@@ -56,11 +56,25 @@ final class WatchHistorySyncService {
// MARK: - Session lifecycle
/// Starts a mirroring session for a video that is about to play.
/// Starts a mirroring session unless this video is already the one being
/// mirrored.
///
/// Called only for a genuinely new video. Reloads of the same one — the
/// audio-mode toggle, a quality switch, a stream retry — must not reach
/// here, or each of them would post again.
/// What playback calls on every load, because a load is not by itself a new
/// watch: an audio-mode toggle, a quality switch or a stream retry all come
/// through the same path with the same video, and starting a session for
/// each of them would post the video again every time. A session already
/// standing for this video is left exactly as it is — including its
/// `posted` flag, which is what makes the reload free.
func beginSessionIfNeeded(video: Video) {
if let currentSession, currentSession.videoID == video.id.videoID { return }
beginSession(video: video)
}
/// Starts a mirroring session for a video, replacing whatever was running.
///
/// For the moments that are a new watch of a video already on screen —
/// replaying one that finished, repeat-one coming round again — where the
/// point is precisely to post it a second time.
func beginSession(video: Video) {
currentSession = nil
@@ -130,6 +144,7 @@ final class WatchHistorySyncService {
do {
try await accountService.markWatched(videoID: videoID, for: account)
announceUpdate(videoID: videoID, accountID: accountID)
return
} catch is CancellationError {
// The account was discarded or the app is going away. Not a
@@ -144,7 +159,13 @@ final class WatchHistorySyncService {
)
return
} catch {
guard attempt == 0 else {
// Only what a second attempt could plausibly answer differently:
// a timeout, a dropped connection, a rate limit, a server that
// fell over. A 400 for an ID the server will never accept is
// just as wrong the second time.
let isRetryable = (error as? APIError)?.isRetryable == true
guard attempt == 0, isRetryable else {
LoggingService.shared.debug(
"Watch history mirror dropped for \(videoID): \(error)",
category: .api
@@ -155,6 +176,23 @@ final class WatchHistorySyncService {
}
}
/// Tells whoever is showing this account's server history that a video just
/// reached the front of it.
///
/// The view has no other way to know: the local write it also does is not
/// news about the server, and the account's history is only ever read by
/// asking for it.
private func announceUpdate(videoID: String, accountID: UUID) {
NotificationCenter.default.post(
name: .serverWatchHistoryDidUpdate,
object: nil,
userInfo: [
ServerWatchHistoryUpdate.accountIDKey: accountID,
ServerWatchHistoryUpdate.videoIDKey: videoID
]
)
}
// MARK: - Draining
/// Waits for every post still in flight.
@@ -189,3 +227,20 @@ final class WatchHistorySyncService {
}
}
}
// MARK: - Announcement
extension Notification.Name {
/// A video was recorded in an account's server-side history. The userInfo
/// carries which account and which video, so a list showing another
/// account's history can ignore it.
static let serverWatchHistoryDidUpdate = Notification.Name("stream.yattee.serverWatchHistoryDidUpdate")
}
/// The userInfo keys of ``Notification/Name/serverWatchHistoryDidUpdate``.
enum ServerWatchHistoryUpdate {
/// `UUID` — the account whose history gained the video.
static let accountIDKey = "accountID"
/// `String` — the video that was recorded.
static let videoIDKey = "videoID"
}

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()
}
}

View File

@@ -0,0 +1,59 @@
//
// ServerHistoryPagingTests.swift
// YatteeTests
//
// Which page of a server history is asked for next, once rows have been
// deleted out from under the offsets.
//
import Testing
import Foundation
@testable import Yattee
@Suite("Server History Paging")
struct ServerHistoryPagingTests {
private static let pageSize = 60
private static func nextPage(_ loadedRowCount: Int) -> Int {
ServerHistoryPaging.nextPage(loadedRowCount: loadedRowCount, pageSize: pageSize)
}
@Test("Nothing loaded asks for the first page")
func nothingLoadedAsksForTheFirstPage() {
#expect(Self.nextPage(0) == 1)
}
@Test("A full page loaded asks for the second")
func aFullPageAsksForTheSecond() {
#expect(Self.nextPage(Self.pageSize) == 2)
}
@Test("Two full pages loaded ask for the third")
func twoFullPagesAskForTheThird() {
#expect(Self.nextPage(Self.pageSize * 2) == 3)
}
@Test("A row deleted out of the first page puts the boundary back in reach")
func aDeletionRereadsTheBoundary() {
// The server closed the gap: what was the first item of page 2 is now
// the last of page 1, and asking for page 2 would step over it.
#expect(Self.nextPage(Self.pageSize - 1) == 1)
}
@Test("Deletions across two pages come back to the page they left short")
func deletionsAcrossPagesRereadThatPage() {
#expect(Self.nextPage(Self.pageSize * 2 - 1) == 2)
}
@Test("A partly filled page still asks for the page it is in")
func aPartialPageAsksForItself() {
#expect(Self.nextPage(1) == 1)
#expect(Self.nextPage(Self.pageSize + 1) == 2)
}
@Test("Nonsense counts still name a page that exists")
func degenerateInputsStayValid() {
#expect(ServerHistoryPaging.nextPage(loadedRowCount: -5, pageSize: Self.pageSize) == 1)
#expect(ServerHistoryPaging.nextPage(loadedRowCount: 10, pageSize: 0) == 1)
}
}

View File

@@ -102,6 +102,36 @@ private final class Flag {
var isSet = false
}
/// Collects what ``Notification/Name/serverWatchHistoryDidUpdate`` announced.
@MainActor
private final class UpdateAnnouncements {
private(set) var received: [(accountID: UUID, videoID: String)] = []
private var observer: NSObjectProtocol?
init() {
// No queue: delivered synchronously, on the main actor the post is made
// from, so a test can look at it the moment the flush returns.
observer = NotificationCenter.default.addObserver(
forName: .serverWatchHistoryDidUpdate,
object: nil,
queue: nil
) { [weak self] notification in
guard let accountID = notification.userInfo?[ServerWatchHistoryUpdate.accountIDKey] as? UUID,
let videoID = notification.userInfo?[ServerWatchHistoryUpdate.videoIDKey] as? String
else { return }
MainActor.assumeIsolated {
self?.received.append((accountID: accountID, videoID: videoID))
}
}
}
func stop() {
if let observer { NotificationCenter.default.removeObserver(observer) }
observer = nil
}
}
private func makeVideo(_ id: String) -> Video {
Video(
id: .global(id),
@@ -209,6 +239,85 @@ struct WatchHistorySyncTests {
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
}
@Test("A finished video played again is a new watch and posts again")
func aFreshSessionForTheSameVideoPostsAgain() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
// The replay button, or repeat-one coming round: the same video, still
// loaded, watched a second time.
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
}
// MARK: - Reloads of the video already playing
@Test("Reloading the video already playing does not post it again")
func reloadingTheSameVideoKeepsTheSession() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
// An audio-mode toggle, a quality switch, a stream retry: the same
// video loaded again, and the same watch.
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa"])
}
@Test("Moving to another video starts that video's session")
func anotherVideoReplacesTheSession() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
harness.service.beginSessionIfNeeded(video: makeVideo("bbb"))
harness.service.recordProgress(videoID: "bbb")
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa", "bbb"])
}
@Test("The first video of a playback starts a session with nothing to compare to")
func theFirstVideoStartsASession() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa"])
}
@Test("A video opened again after the player stopped is watched again")
func theSameVideoAfterAnEndedSessionPostsAgain() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
harness.service.endSession()
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
}
@Test("Progress for a video the session is not about is ignored")
func progressForAnotherVideoIsIgnored() async throws {
let harness = WatchHistorySyncHarness()
@@ -305,6 +414,37 @@ struct WatchHistorySyncTests {
#expect(await harness.transport.callCount == 2)
}
@Test("A post the server refused on its merits is not tried again")
func aNonRetryableFailureIsNotRetried() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
// An ID this server will not take is not going to take it the second
// time either.
await harness.transport.setError(APIError.httpError(statusCode: 400, message: "Invalid video id."))
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
try await harness.transport.settle()
#expect(await harness.transport.callCount == 1)
}
@Test("A post the transport itself refused is not tried again")
func aServiceFailureFromTheTransportIsNotRetried() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
await harness.transport.setError(AccountServiceError.notSupported)
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
try await harness.transport.settle()
#expect(await harness.transport.callCount == 1)
}
@Test("A post that could not be resolved at all is not retried")
func aResolutionFailureIsNotRetried() async throws {
let harness = WatchHistorySyncHarness()
@@ -337,6 +477,40 @@ struct WatchHistorySyncTests {
#expect(await harness.transport.callCount == 0)
}
// MARK: - Announcing
@Test("A post that landed says so, naming the account and the video")
func aSuccessfulPostIsAnnounced() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
let announcements = UpdateAnnouncements()
defer { announcements.stop() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(announcements.received.count == 1)
#expect(announcements.received.first?.accountID == harness.account.id)
#expect(announcements.received.first?.videoID == "aaa")
}
@Test("A post that never landed announces nothing")
func aFailedPostIsNotAnnounced() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
let announcements = UpdateAnnouncements()
defer { announcements.stop() }
await harness.transport.setError(APIError.timeout)
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
try await harness.transport.settle()
#expect(announcements.received.isEmpty)
}
// MARK: - Draining and disowning
@Test("A flush returns only once the post it is waiting for has landed")