Files
plezy/lib/screens/video_player/parts/watch_together.dart
T

219 lines
9.1 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
/// Whether an active Watch Together session owns playback starts: media is
/// opened paused and the sync layer coordinates the (group) start.
bool _watchTogetherOwnsPlaybackStart() {
if (_isOfflinePlayback || widget.isLive) return false;
return _activeWatchTogetherSession() != null;
}
/// Attach player to Watch Together session for playback sync.
///
/// [startupHold] delays sync readiness until platform startup gates (e.g.
/// the Android frame-rate switch) release.
void _attachToWatchTogetherSession({Future<void>? startupHold}) {
try {
final watchTogether = context.read<WatchTogetherProvider>();
_watchTogetherProvider = watchTogether; // Store reference for use in dispose
final serverId = _currentMetadata.serverId;
if (watchTogether.isInSession && player != null && serverId != null) {
watchTogether.attachPlayer(
player!,
ratingKey: _currentMetadata.id,
serverId: serverId,
mediaTitle: _currentMetadata.displayTitle,
hasFirstFrame: _hasFirstFrame.value,
startupHold: startupHold,
// Sync-issued seeks ride the screen's seek path so Plex transcode
// restarts keep working for out-of-buffer targets.
remoteSeek: _seekPlayback,
);
appLogger.d('WatchTogether: Player attached for sync');
// If guest, handle mediaSwitch internally for proper navigation context
if (!watchTogether.isHost) {
watchTogether.onPlayerMediaSwitched = _handlePlayerMediaSwitch;
}
}
} catch (e) {
// Watch together provider not available or not in session - non-critical
appLogger.d('Could not attach player to watch together', error: e);
}
}
/// Detach player from Watch Together session (the user is leaving the
/// player, which ends the shared media epoch).
void _detachFromWatchTogetherSession() {
try {
final watchTogether = _watchTogetherProvider ?? context.read<WatchTogetherProvider>();
if (watchTogether.isInSession) {
watchTogether.detachPlayer(exiting: true);
appLogger.d('WatchTogether: Player detached');
}
watchTogether.onPlayerMediaSwitched = null; // Always clear player callback
} catch (e) {
// Non-critical
appLogger.d('Could not detach player from watch together', error: e);
}
}
/// The active Watch Together session, or null when not in one (or the
/// provider is unavailable).
WatchTogetherProvider? _activeWatchTogetherSession() {
try {
final watchTogether = _watchTogetherProvider ?? context.read<WatchTogetherProvider>();
return watchTogether.isInSession ? watchTogether : null;
} catch (_) {
return null;
}
}
/// Playback intent is guest-controllable only when the active room permits
/// it. Outside a room, the local screen remains authoritative.
bool _canControlPlayback() => _activeWatchTogetherSession()?.canControl() ?? true;
/// Choosing another queue item or episode is host-only in every room mode.
bool _canNavigateMediaItems() => _activeWatchTogetherSession()?.isHost ?? true;
/// Notify watch together session of current media change (host only)
/// If [metadata] is provided, uses that instead of _currentMetadata (for episode navigation)
void _notifyWatchTogetherMediaChange({MediaItem? metadata}) {
final targetMetadata = metadata ?? _currentMetadata;
try {
final watchTogether = context.read<WatchTogetherProvider>();
if (watchTogether.isHost && watchTogether.isInSession) {
watchTogether.setCurrentMedia(
ratingKey: targetMetadata.id,
serverId: ServerId(targetMetadata.serverId!),
mediaTitle: targetMetadata.displayTitle,
);
}
} catch (e) {
// Watch together provider not available or not in session - non-critical
appLogger.d('Could not notify watch together of media change', error: e);
}
}
void _notifyWatchTogetherSeek(Duration position) {
try {
final watchTogether = context.read<WatchTogetherProvider>();
if (watchTogether.isInSession) {
// Sync manager applies canControl checks; matching play/pause avoids timing gaps.
watchTogether.onLocalSeek(position);
}
} catch (e) {
appLogger.d('Could not notify watch together of seek', error: e);
}
}
/// Handle media switch from host (guest only) using the in-place reload
/// path. Returns whether the switch was handled; unhandled switches are
/// re-dispatched on the host's next state heartbeat.
Future<bool> _handlePlayerMediaSwitch(String ratingKey, ServerId serverId, String title) async {
if (!mounted) return false;
final switchKey = '$serverId:$ratingKey';
// Idempotent retry: already on the target with a settled player. Don't
// test identity mid-transition — _currentMetadata is set eagerly at
// reload start and can roll back on failure.
if (_playbackTransition == _PlaybackTransition.idle &&
player != null &&
_currentMetadata.id == ratingKey &&
_currentMetadata.serverId == serverId) {
_wtSwitchToastShownForKey = null;
return true;
}
appLogger.d('WatchTogether: Guest handling media switch to $title');
// Fetch metadata for the new episode. WatchTogether's sync transport is
// backend-neutral (sync_message.dart carries `ratingKey` + `serverId`
// over WebRTC); resolving the item is just a `fetchItem` on whichever
// backend the guest has registered for [serverId].
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(serverId);
if (client == null) {
appLogger.w('WatchTogether: Server $serverId not found for media switch');
_showSwitchFailureToastOnce(switchKey, t.watchTogether.guestSwitchUnavailable);
return false;
}
MediaItem? metadata;
try {
metadata = await client.fetchItem(ratingKey);
} catch (e, stackTrace) {
appLogger.w('WatchTogether: Could not fetch metadata for $ratingKey', error: e, stackTrace: stackTrace);
}
if (!mounted) return false;
if (metadata == null) {
appLogger.w('WatchTogether: Could not fetch metadata for $ratingKey');
_showSwitchFailureToastOnce(switchKey, t.watchTogether.guestSwitchFailed);
return false;
}
// The fetch can outlive the dispatch that requested it (slow server,
// host switching again, dispatcher timeout); reloading then would swap
// the live screen to stale media. Unhandled: the current key rides the
// next heartbeat.
final watchTogether = _activeWatchTogetherSession();
if (watchTogether == null ||
watchTogether.currentMediaRatingKey != ratingKey ||
watchTogether.currentMediaServerId != serverId) {
appLogger.d('WatchTogether: Skipping stale media switch to $ratingKey');
return false;
}
if (player == null || widget.isLive) {
// Route replacement: report handled at initiation — the navigation
// future only completes when the pushed route pops.
unawaited(_replaceScreenWithPlayer(metadata));
return true;
}
// fetchItem populates mediaVersions, so the saved preference resolves to
// a verified index/id here rather than a raw stored index.
final savedVersion = await resolveSavedMediaVersionFor(metadata);
final outcome = await _reloadMediaInPlace(
metadata: metadata,
selectedMediaIndex: savedVersion?.index ?? 0,
selectedMediaSourceId: savedVersion?.sourceId,
preferredVersionSignature: savedVersion?.signature,
qualityPreset: _selectedQualityPreset,
preserveCurrentTrackSelection: false,
useCurrentAudioStreamSelection: false,
showErrorUi: false, // the retry loop owns user feedback (once per key)
reason: 'watch together media switch',
);
if (!mounted) return false;
if (outcome == _MediaReloadOutcome.rejected) {
if (player == null) {
unawaited(_replaceScreenWithPlayer(metadata));
return true;
}
// Busy transition (e.g. auto-advance racing the host switch) — not an
// error; the next heartbeat re-dispatches and converges once idle.
return false;
}
// failed (after rollback) and superseded land here too — trust only the
// committed identity.
final onTarget = _currentMetadata.id == ratingKey && _currentMetadata.serverId == serverId;
if (onTarget) {
// A success ends the failure episode for this key; a later failure to
// switch back here must toast again.
_wtSwitchToastShownForKey = null;
} else {
_showSwitchFailureToastOnce(switchKey, t.watchTogether.guestSwitchFailed);
}
return onTarget;
}
/// Toast a Watch Together switch failure at most once per media key (the
/// heartbeat retry loop calls the handler every few seconds).
void _showSwitchFailureToastOnce(String switchKey, String message) {
if (_wtSwitchToastShownForKey == switchKey) return;
_wtSwitchToastShownForKey = switchKey;
if (mounted) showAppSnackBar(context, message);
}
}