219 lines
9.1 KiB
Dart
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);
|
|
}
|
|
}
|