414 lines
14 KiB
Go
414 lines
14 KiB
Go
package nakama
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"seanime/internal/mediaplayers/mediaplayer"
|
|
"time"
|
|
)
|
|
|
|
// handleWatchPartyPlaybackStatusEvent is called when the host sends a playback status.
|
|
//
|
|
// We check if the peer is a participant in the session.
|
|
// If yes, we will update the playback status and sync the playback position.
|
|
func (wpm *WatchPartyManager) handleWatchPartyPlaybackStatusEvent(payload *WatchPartyPlaybackStatusPayload) {
|
|
if wpm.manager.IsHost() {
|
|
return
|
|
}
|
|
|
|
// wpm.logger.Debug().Msg("nakama: Received playback status from watch party")
|
|
|
|
wpm.mu.Lock()
|
|
defer wpm.mu.Unlock()
|
|
|
|
session, ok := wpm.currentSession.Get()
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
hostConn, ok := wpm.manager.GetHostConnection()
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if participant, isParticipant := session.Participants[hostConn.PeerId]; !isParticipant || participant.IsRelayOrigin {
|
|
return
|
|
}
|
|
|
|
payloadStatus := payload.PlaybackStatus
|
|
|
|
// If the peer's session doesn't have a media info, do nothing
|
|
if session.CurrentMediaInfo == nil {
|
|
return
|
|
}
|
|
|
|
// If the playback manager doesn't have a status, do nothing
|
|
playbackStatus, ok := wpm.manager.playbackManager.PullStatus()
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Check if the message is too old to prevent acting on stale data
|
|
wpm.sequenceMu.Lock()
|
|
isStale := payload.SequenceNumber != 0 && payload.SequenceNumber <= wpm.lastRxSequence
|
|
if payload.SequenceNumber > wpm.lastRxSequence {
|
|
wpm.lastRxSequence = payload.SequenceNumber
|
|
}
|
|
wpm.sequenceMu.Unlock()
|
|
|
|
if isStale {
|
|
wpm.logger.Debug().Uint64("messageSeq", payload.SequenceNumber).Uint64("lastSeq", wpm.lastRxSequence).Msg("nakama: Ignoring stale playback status message (old sequence)")
|
|
return
|
|
}
|
|
|
|
now := time.Now().UnixNano()
|
|
driftNs := now - payload.Timestamp
|
|
timeSinceMessage := float64(driftNs) / 1e9 // Convert to seconds
|
|
if timeSinceMessage > 5 { // Clamp to a reasonable maximum delay
|
|
timeSinceMessage = 0 // If it's more than 5 seconds, treat it as no delay
|
|
}
|
|
|
|
// Handle play/pause state changes
|
|
if payloadStatus.Playing != playbackStatus.Playing {
|
|
if payloadStatus.Playing {
|
|
// Cancel any ongoing catch-up operation
|
|
wpm.cancelCatchUp()
|
|
|
|
// When host resumes, sync position before resuming if there's significant drift
|
|
// Calculate where the host should be NOW, not when they resumed
|
|
hostCurrentPosition := payloadStatus.CurrentTimeInSeconds + timeSinceMessage
|
|
positionDrift := hostCurrentPosition - playbackStatus.CurrentTimeInSeconds
|
|
|
|
// Check if we need to seek
|
|
shouldSeek := false
|
|
if positionDrift < 0 {
|
|
// Peer is behind, always seek if beyond threshold
|
|
shouldSeek = math.Abs(positionDrift) > ResumePositionDriftThreshold
|
|
} else {
|
|
// Peer is ahead, only seek backward if significantly ahead to prevent jitter
|
|
// This prevents backward seeks when peer is slightly ahead due to pause message delay
|
|
shouldSeek = positionDrift > ResumeAheadTolerance
|
|
}
|
|
|
|
if shouldSeek {
|
|
// Calculate dynamic seek delay based on message timing
|
|
dynamicDelay := time.Duration(timeSinceMessage*1000) * time.Millisecond
|
|
if dynamicDelay < MinSeekDelay {
|
|
dynamicDelay = MinSeekDelay
|
|
}
|
|
if dynamicDelay > MaxDynamicDelay {
|
|
dynamicDelay = MaxDynamicDelay
|
|
}
|
|
|
|
// Predict where host will be when our seek takes effect
|
|
seekPosition := hostCurrentPosition + dynamicDelay.Seconds()
|
|
|
|
wpm.logger.Debug().
|
|
Float64("positionDrift", positionDrift).
|
|
Float64("hostCurrentPosition", hostCurrentPosition).
|
|
Float64("seekPosition", seekPosition).
|
|
Float64("peerPosition", playbackStatus.CurrentTimeInSeconds).
|
|
Float64("dynamicDelay", dynamicDelay.Seconds()).
|
|
Bool("peerAhead", positionDrift > 0).
|
|
Msg("nakama: Host resumed, syncing position before resume")
|
|
|
|
// Track pending seek
|
|
now := time.Now()
|
|
wpm.seekMu.Lock()
|
|
wpm.pendingSeekTime = now
|
|
wpm.pendingSeekPosition = seekPosition
|
|
wpm.seekMu.Unlock()
|
|
|
|
_ = wpm.manager.playbackManager.Seek(seekPosition)
|
|
} else if positionDrift > 0 && positionDrift <= ResumeAheadTolerance {
|
|
wpm.logger.Debug().
|
|
Float64("positionDrift", positionDrift).
|
|
Float64("hostCurrentPosition", hostCurrentPosition).
|
|
Float64("peerPosition", playbackStatus.CurrentTimeInSeconds).
|
|
Msg("nakama: Host resumed, peer slightly ahead, not seeking yet")
|
|
}
|
|
|
|
wpm.logger.Debug().Msg("nakama: Host resumed, resuming peer playback")
|
|
_ = wpm.manager.playbackManager.Resume()
|
|
} else {
|
|
wpm.logger.Debug().Msg("nakama: Host paused, handling peer pause")
|
|
wpm.handleHostPause(payloadStatus, *playbackStatus, timeSinceMessage)
|
|
}
|
|
}
|
|
|
|
// Handle position sync for different state combinations
|
|
if payloadStatus.Playing == playbackStatus.Playing {
|
|
// Both in same state, use normal sync
|
|
wpm.syncPlaybackPosition(payloadStatus, *playbackStatus, timeSinceMessage, session)
|
|
} else if payloadStatus.Playing && !playbackStatus.Playing {
|
|
// Host playing, peer paused, sync position and resume
|
|
hostExpectedPosition := payloadStatus.CurrentTimeInSeconds + timeSinceMessage
|
|
|
|
wpm.logger.Debug().
|
|
Float64("hostPosition", hostExpectedPosition).
|
|
Float64("peerPosition", playbackStatus.CurrentTimeInSeconds).
|
|
Msg("nakama: Host is playing but peer is paused, syncing and resuming")
|
|
|
|
// Resume and sync to host position
|
|
_ = wpm.manager.playbackManager.Resume()
|
|
|
|
// Track pending seek
|
|
now := time.Now()
|
|
wpm.seekMu.Lock()
|
|
wpm.pendingSeekTime = now
|
|
wpm.pendingSeekPosition = hostExpectedPosition
|
|
wpm.seekMu.Unlock()
|
|
|
|
_ = wpm.manager.playbackManager.Seek(hostExpectedPosition)
|
|
} else if !payloadStatus.Playing && playbackStatus.Playing {
|
|
// Host paused, peer playing, pause immediately
|
|
wpm.logger.Debug().Msg("nakama: Host is paused but peer is playing, pausing immediately")
|
|
|
|
// Cancel catch-up and pause
|
|
wpm.cancelCatchUp()
|
|
wpm.handleHostPause(payloadStatus, *playbackStatus, timeSinceMessage)
|
|
}
|
|
}
|
|
|
|
// handleHostPause handles when the host pauses playback
|
|
func (wpm *WatchPartyManager) handleHostPause(hostStatus mediaplayer.PlaybackStatus, peerStatus mediaplayer.PlaybackStatus, timeSinceMessage float64) {
|
|
// Cancel any ongoing catch-up operation
|
|
wpm.cancelCatchUp()
|
|
|
|
now := time.Now()
|
|
|
|
// Calculate where the host actually paused based on dynamic timing
|
|
hostActualPausePosition := hostStatus.CurrentTimeInSeconds
|
|
// Don't add time compensation for pause position, the host has already paused
|
|
|
|
// Calculate time difference considering message delay
|
|
timeDifference := hostActualPausePosition - peerStatus.CurrentTimeInSeconds
|
|
|
|
// If peer is significantly behind the host, let it catch up before pausing
|
|
if timeDifference > CatchUpBehindThreshold {
|
|
wpm.logger.Debug().Msgf("nakama: Host paused, peer behind by %.2f seconds, catching up", timeDifference)
|
|
wpm.startCatchUp(hostActualPausePosition, timeSinceMessage)
|
|
} else {
|
|
// Peer is close enough or ahead, pause immediately with position correction
|
|
// Use more aggressive sync threshold for pause operations
|
|
if math.Abs(timeDifference) > PausePositionSyncThreshold {
|
|
wpm.logger.Debug().
|
|
Float64("hostPausePosition", hostActualPausePosition).
|
|
Float64("peerPosition", peerStatus.CurrentTimeInSeconds).
|
|
Float64("timeDifference", timeDifference).
|
|
Float64("timeSinceMessage", timeSinceMessage).
|
|
Msg("nakama: Host paused, syncing position before pause")
|
|
|
|
// Track pending seek
|
|
wpm.seekMu.Lock()
|
|
wpm.pendingSeekTime = now
|
|
wpm.pendingSeekPosition = hostActualPausePosition
|
|
wpm.seekMu.Unlock()
|
|
|
|
_ = wpm.manager.playbackManager.Seek(hostActualPausePosition)
|
|
}
|
|
_ = wpm.manager.playbackManager.Pause()
|
|
wpm.logger.Debug().Msgf("nakama: Host paused, peer paused immediately (diff: %.2f)", timeDifference)
|
|
}
|
|
}
|
|
|
|
// startCatchUp starts a catch-up operation to sync with the host's pause position
|
|
func (wpm *WatchPartyManager) startCatchUp(hostPausePosition float64, timeSinceMessage float64) {
|
|
wpm.catchUpMu.Lock()
|
|
defer wpm.catchUpMu.Unlock()
|
|
|
|
// Cancel any existing catch-up
|
|
if wpm.catchUpCancel != nil {
|
|
wpm.catchUpCancel()
|
|
}
|
|
|
|
// Create a new context for this catch-up operation
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
wpm.catchUpCancel = cancel
|
|
|
|
go func() {
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(CatchUpTickInterval)
|
|
defer ticker.Stop()
|
|
|
|
maxCatchUpTime := MaxCatchUpDuration
|
|
startTime := time.Now()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
// If catch-up is taking too long, force sync to host position
|
|
if time.Since(startTime) > maxCatchUpTime {
|
|
wpm.logger.Debug().Msg("nakama: Catch-up timeout, seeking to host position and pausing")
|
|
|
|
// Seek to host position and pause
|
|
now := time.Now()
|
|
wpm.seekMu.Lock()
|
|
wpm.pendingSeekTime = now
|
|
wpm.pendingSeekPosition = hostPausePosition
|
|
wpm.seekMu.Unlock()
|
|
|
|
_ = wpm.manager.playbackManager.Seek(hostPausePosition)
|
|
_ = wpm.manager.playbackManager.Pause()
|
|
return
|
|
}
|
|
|
|
// Get current playback status
|
|
currentStatus, ok := wpm.manager.playbackManager.PullStatus()
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Check if we've reached or passed the host's pause position (with tighter tolerance)
|
|
positionDiff := hostPausePosition - currentStatus.CurrentTimeInSeconds
|
|
if positionDiff <= CatchUpToleranceThreshold {
|
|
wpm.logger.Debug().Msgf("nakama: Caught up to host position %.2f (current: %.2f), pausing", hostPausePosition, currentStatus.CurrentTimeInSeconds)
|
|
|
|
// Track pending seek
|
|
now := time.Now()
|
|
wpm.seekMu.Lock()
|
|
wpm.pendingSeekTime = now
|
|
wpm.pendingSeekPosition = hostPausePosition
|
|
wpm.seekMu.Unlock()
|
|
|
|
_ = wpm.manager.playbackManager.Seek(hostPausePosition)
|
|
_ = wpm.manager.playbackManager.Pause()
|
|
return
|
|
}
|
|
|
|
// Continue trying to catch up to host position
|
|
|
|
wpm.logger.Debug().
|
|
Float64("positionDiff", positionDiff).
|
|
Float64("currentPosition", currentStatus.CurrentTimeInSeconds).
|
|
Float64("hostPausePosition", hostPausePosition).
|
|
Msg("nakama: Still catching up to host pause position")
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// cancelCatchUp cancels any ongoing catch-up operation
|
|
func (wpm *WatchPartyManager) cancelCatchUp() {
|
|
wpm.catchUpMu.Lock()
|
|
defer wpm.catchUpMu.Unlock()
|
|
|
|
if wpm.catchUpCancel != nil {
|
|
wpm.catchUpCancel()
|
|
wpm.catchUpCancel = nil
|
|
}
|
|
}
|
|
|
|
// syncPlaybackPosition synchronizes playback position when both host and peer are in the same play/pause state
|
|
func (wpm *WatchPartyManager) syncPlaybackPosition(hostStatus mediaplayer.PlaybackStatus, peerStatus mediaplayer.PlaybackStatus, timeSinceMessage float64, session *WatchPartySession) {
|
|
now := time.Now()
|
|
|
|
// Ignore very old messages to prevent stale syncing
|
|
if timeSinceMessage > MaxMessageAge {
|
|
return
|
|
}
|
|
|
|
// Check if we have a pending seek operation, use dynamic compensation
|
|
wpm.seekMu.Lock()
|
|
hasPendingSeek := !wpm.pendingSeekTime.IsZero()
|
|
timeSincePendingSeek := now.Sub(wpm.pendingSeekTime)
|
|
pendingSeekPosition := wpm.pendingSeekPosition
|
|
wpm.seekMu.Unlock()
|
|
|
|
// Use dynamic compensation, if we have a pending seek, wait for at least the message delay time
|
|
dynamicSeekDelay := time.Duration(timeSinceMessage*1000) * time.Millisecond
|
|
if dynamicSeekDelay < MinSeekDelay {
|
|
dynamicSeekDelay = MinSeekDelay // Minimum delay
|
|
}
|
|
if dynamicSeekDelay > MaxSeekDelay {
|
|
dynamicSeekDelay = MaxSeekDelay // Maximum delay
|
|
}
|
|
|
|
// If we have a pending seek that's still in progress, don't sync
|
|
if hasPendingSeek && timeSincePendingSeek < dynamicSeekDelay {
|
|
wpm.logger.Debug().
|
|
Float64("timeSincePendingSeek", timeSincePendingSeek.Seconds()).
|
|
Float64("dynamicSeekDelay", dynamicSeekDelay.Seconds()).
|
|
Float64("pendingSeekPosition", pendingSeekPosition).
|
|
Msg("nakama: Ignoring sync, pending seek in progress")
|
|
return
|
|
}
|
|
|
|
// Clear pending seek if it's been long enough
|
|
if hasPendingSeek && timeSincePendingSeek >= dynamicSeekDelay {
|
|
wpm.seekMu.Lock()
|
|
wpm.pendingSeekTime = time.Time{}
|
|
wpm.pendingSeekPosition = 0
|
|
wpm.seekMu.Unlock()
|
|
}
|
|
|
|
// Dynamic compensation: Calculate where the host should be NOW based on their timestamp
|
|
hostCurrentPosition := hostStatus.CurrentTimeInSeconds
|
|
if hostStatus.Playing {
|
|
// Add the exact time that has passed since the host's status was captured
|
|
hostCurrentPosition += timeSinceMessage
|
|
}
|
|
|
|
// Calculate drift between peer and host's current position
|
|
drift := hostCurrentPosition - peerStatus.CurrentTimeInSeconds
|
|
driftAbs := drift
|
|
if driftAbs < 0 {
|
|
driftAbs = -driftAbs
|
|
}
|
|
|
|
// Get sync threshold from session settings
|
|
syncThreshold := session.Settings.SyncThreshold
|
|
// Clamp
|
|
if syncThreshold < MinSyncThreshold {
|
|
syncThreshold = MinSyncThreshold
|
|
} else if syncThreshold > MaxSyncThreshold {
|
|
syncThreshold = MaxSyncThreshold
|
|
}
|
|
|
|
// Check if we're in seek cooldown period
|
|
timeSinceLastSeek := now.Sub(wpm.lastSeekTime)
|
|
inCooldown := timeSinceLastSeek < wpm.seekCooldown
|
|
|
|
// Use more aggressive thresholds for different drift ranges
|
|
effectiveThreshold := syncThreshold
|
|
if driftAbs > 3.0 { // Large drift - be very aggressive
|
|
effectiveThreshold = syncThreshold * AggressiveSyncMultiplier
|
|
} else if driftAbs > 1.5 { // Medium drift - be more aggressive
|
|
effectiveThreshold = syncThreshold * ModerateSyncMultiplier
|
|
}
|
|
|
|
// Only sync if drift exceeds threshold and we're not in cooldown
|
|
if driftAbs > effectiveThreshold && !inCooldown {
|
|
// For the seek position, predict where the host will be when our seek takes effect
|
|
// Use the dynamic delay we calculated based on actual network conditions
|
|
seekPosition := hostCurrentPosition
|
|
if hostStatus.Playing {
|
|
// Add compensation for the time it will take for our seek to take effect
|
|
seekPosition += dynamicSeekDelay.Seconds()
|
|
}
|
|
|
|
wpm.logger.Debug().
|
|
Float64("drift", drift).
|
|
Float64("hostOriginalPosition", hostStatus.CurrentTimeInSeconds).
|
|
Float64("hostCurrentPosition", hostCurrentPosition).
|
|
Float64("seekPosition", seekPosition).
|
|
Float64("peerPosition", peerStatus.CurrentTimeInSeconds).
|
|
Float64("timeSinceMessage", timeSinceMessage).
|
|
Float64("dynamicSeekDelay", dynamicSeekDelay.Seconds()).
|
|
Float64("effectiveThreshold", effectiveThreshold).
|
|
Msg("nakama: Syncing playback position with dynamic compensation")
|
|
|
|
// Track pending seek
|
|
wpm.seekMu.Lock()
|
|
wpm.pendingSeekTime = now
|
|
wpm.pendingSeekPosition = seekPosition
|
|
wpm.seekMu.Unlock()
|
|
|
|
_ = wpm.manager.playbackManager.Seek(seekPosition)
|
|
wpm.lastSeekTime = now
|
|
}
|
|
}
|