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"
}