fix(tv): playback stopping
This commit is contained in:
@@ -76,6 +76,8 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
final int? liveCurrentChannelIndex;
|
||||
final String? liveDvrKey;
|
||||
final PlexClient? liveClient;
|
||||
final String? liveSessionIdentifier;
|
||||
final String? liveSessionPath;
|
||||
|
||||
const VideoPlayerScreen({
|
||||
super.key,
|
||||
@@ -91,6 +93,8 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
this.liveCurrentChannelIndex,
|
||||
this.liveDvrKey,
|
||||
this.liveClient,
|
||||
this.liveSessionIdentifier,
|
||||
this.liveSessionPath,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -132,6 +136,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Live TV channel navigation
|
||||
int _liveChannelIndex = -1;
|
||||
String? _liveChannelName;
|
||||
String? _liveSessionIdentifier;
|
||||
String? _liveSessionPath;
|
||||
Timer? _liveTimelineTimer;
|
||||
|
||||
// Auto-play next episode
|
||||
Timer? _autoPlayTimer;
|
||||
@@ -195,6 +202,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Initialize live TV channel tracking
|
||||
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
|
||||
_liveChannelName = widget.liveChannelName;
|
||||
_liveSessionIdentifier = widget.liveSessionIdentifier;
|
||||
_liveSessionPath = widget.liveSessionPath;
|
||||
|
||||
// Initialize Play Next dialog focus nodes
|
||||
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
||||
@@ -592,8 +601,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void> _initializeServices() async {
|
||||
if (!mounted || player == null) return;
|
||||
|
||||
// Skip progress tracking for live TV
|
||||
if (widget.isLive) return;
|
||||
// Live TV: send timeline heartbeats to keep transcode session alive
|
||||
if (widget.isLive) {
|
||||
_startLiveTimelineUpdates();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get client (null in offline mode)
|
||||
final client = widget.isOffline ? null : _getClientForMetadata(context);
|
||||
@@ -844,10 +856,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
await player!.requestAudioFocus();
|
||||
await _setLiveStreamOptions();
|
||||
|
||||
await player!.open(
|
||||
Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}),
|
||||
play: true,
|
||||
);
|
||||
await player!.open(Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), play: true);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -1258,10 +1267,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Store provider reference for use in dispose and notify remote
|
||||
try {
|
||||
_companionRemoteProvider = context.read<CompanionRemoteProvider>();
|
||||
_companionRemoteProvider!.sendCommand(
|
||||
RemoteCommandType.syncState,
|
||||
data: {'playerActive': true},
|
||||
);
|
||||
_companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -1282,10 +1288,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_savedOnHome = null;
|
||||
|
||||
// Notify remote that player is no longer active
|
||||
_companionRemoteProvider?.sendCommand(
|
||||
RemoteCommandType.syncState,
|
||||
data: {'playerActive': false},
|
||||
);
|
||||
_companionRemoteProvider?.sendCommand(RemoteCommandType.syncState, data: {'playerActive': false});
|
||||
_companionRemoteProvider = null;
|
||||
}
|
||||
|
||||
@@ -1323,7 +1326,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_onAudioTrackChanged(next);
|
||||
|
||||
if (mounted) {
|
||||
final label = 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}';
|
||||
final label =
|
||||
'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}';
|
||||
showAppSnackBar(context, label, duration: const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
@@ -1418,6 +1422,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_progressTracker?.sendProgress('stopped');
|
||||
_progressTracker?.stopTracking();
|
||||
_progressTracker?.dispose();
|
||||
_sendLiveTimeline('stopped');
|
||||
_stopLiveTimelineUpdates();
|
||||
|
||||
// Remove PiP state listener, clear callback, and dispose video filter manager
|
||||
_videoPIPManager?.isPipActive.removeListener(_onPipStateChanged);
|
||||
@@ -1619,6 +1625,45 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _isSwitchingChannel = false;
|
||||
|
||||
/// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous)
|
||||
/// Start periodic timeline heartbeats for live TV transcode session.
|
||||
void _startLiveTimelineUpdates() {
|
||||
_liveTimelineTimer?.cancel();
|
||||
_liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
_sendLiveTimeline('playing');
|
||||
});
|
||||
// Send initial heartbeat immediately
|
||||
_sendLiveTimeline('playing');
|
||||
}
|
||||
|
||||
void _stopLiveTimelineUpdates() {
|
||||
_liveTimelineTimer?.cancel();
|
||||
_liveTimelineTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _sendLiveTimeline(String state) async {
|
||||
final sessionId = _liveSessionIdentifier;
|
||||
final sessionPath = _liveSessionPath;
|
||||
if (sessionId == null || sessionPath == null) return;
|
||||
|
||||
final client = widget.liveClient;
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
final position = player?.state.position ?? Duration.zero;
|
||||
final duration = player?.state.duration ?? Duration.zero;
|
||||
await client.updateLiveTimeline(
|
||||
ratingKey: widget.metadata.ratingKey,
|
||||
sessionPath: sessionPath,
|
||||
sessionIdentifier: sessionId,
|
||||
state: state,
|
||||
time: position.inMilliseconds,
|
||||
duration: duration.inMilliseconds,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.d('Live timeline update failed', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure MPV/FFmpeg options for live streaming resilience.
|
||||
/// Enables automatic reconnection on EOF and network errors.
|
||||
Future<void> _setLiveStreamOptions() async {
|
||||
@@ -1631,8 +1676,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
await p.setProperty('stream-lavf-o-append', 'reconnect_delay_max=30');
|
||||
// Demuxer: retry up to 1000 times on stream reload failures
|
||||
await p.setProperty('demuxer-lavf-o', 'max_reload=1000');
|
||||
// Re-open the stream URL when EOF is reached
|
||||
await p.setProperty('loop-playlist', 'force');
|
||||
await p.setProperty('force-seekable', 'no');
|
||||
}
|
||||
|
||||
@@ -1655,9 +1698,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
try {
|
||||
// Look up the correct client/DVR for this channel's server
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final serverInfo = multiServer.liveTvServers.where(
|
||||
(s) => s.serverId == channel.serverId,
|
||||
).firstOrNull ?? multiServer.liveTvServers.firstOrNull;
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
|
||||
if (serverInfo == null) return;
|
||||
|
||||
@@ -1670,15 +1713,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token);
|
||||
|
||||
await _setLiveStreamOptions();
|
||||
await player!.open(
|
||||
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||
play: true,
|
||||
);
|
||||
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true);
|
||||
|
||||
setState(() {
|
||||
_liveChannelIndex = newIndex;
|
||||
_liveChannelName = channel.displayName;
|
||||
_liveSessionIdentifier = result.sessionIdentifier;
|
||||
_liveSessionPath = result.sessionPath;
|
||||
});
|
||||
|
||||
// Restart timeline heartbeats for the new session
|
||||
_startLiveTimelineUpdates();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to switch channel', error: e);
|
||||
} finally {
|
||||
@@ -1692,10 +1737,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_liveChannelIndex >= 0 &&
|
||||
_liveChannelIndex < (widget.liveChannels!.length - 1);
|
||||
|
||||
bool get _hasPreviousChannel =>
|
||||
widget.isLive &&
|
||||
widget.liveChannels != null &&
|
||||
_liveChannelIndex > 0;
|
||||
bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0;
|
||||
|
||||
void _startAutoPlayTimer() {
|
||||
_autoPlayTimer?.cancel();
|
||||
@@ -2408,7 +2450,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(t.watchTogether.reconnectingToHost, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
Text(
|
||||
t.watchTogether.reconnectingToHost,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1130,6 +1130,29 @@ class PlexClient {
|
||||
);
|
||||
}
|
||||
|
||||
/// Send a live TV timeline heartbeat to keep the transcode session alive.
|
||||
Future<void> updateLiveTimeline({
|
||||
required String ratingKey,
|
||||
required String sessionPath,
|
||||
required String sessionIdentifier,
|
||||
required String state,
|
||||
required int time,
|
||||
required int duration,
|
||||
}) async {
|
||||
await _dio.post(
|
||||
'/:/timeline',
|
||||
queryParameters: {
|
||||
'ratingKey': ratingKey,
|
||||
'key': sessionPath,
|
||||
'state': state,
|
||||
'hasMDE': '1',
|
||||
'time': time,
|
||||
'duration': duration,
|
||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from Continue Watching (On Deck) without affecting watch status or progress
|
||||
/// This uses the same endpoint Plex Web uses to hide items from Continue Watching
|
||||
Future<void> removeFromOnDeck(String ratingKey) async {
|
||||
@@ -2049,7 +2072,7 @@ class PlexClient {
|
||||
/// Tune to a live TV channel and set up the transcode session.
|
||||
///
|
||||
/// Flow: tune → decision → return /start path (MKV-over-HTTP).
|
||||
Future<({PlexMetadata metadata, String streamPath})?> tuneChannel(String dvrKey, String channelIdentifier) async {
|
||||
Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(String dvrKey, String channelIdentifier) async {
|
||||
try {
|
||||
final sessionIdentifier = _generateSessionIdentifier();
|
||||
|
||||
@@ -2150,7 +2173,12 @@ class PlexClient {
|
||||
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
|
||||
return (metadata: metadata, streamPath: '/video/:/transcode/universal/start?$startQuery');
|
||||
return (
|
||||
metadata: metadata,
|
||||
streamPath: '/video/:/transcode/universal/start?$startQuery',
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
sessionPath: sessionPath,
|
||||
);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to tune channel', error: e, stackTrace: st);
|
||||
return null;
|
||||
|
||||
@@ -56,6 +56,8 @@ Future<void> navigateToLiveTv(
|
||||
),
|
||||
liveDvrKey: dvrKey,
|
||||
liveClient: client,
|
||||
liveSessionIdentifier: result.sessionIdentifier,
|
||||
liveSessionPath: result.sessionPath,
|
||||
),
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
|
||||
Reference in New Issue
Block a user