fix(tv): playback

This commit is contained in:
edde746
2026-02-12 03:52:51 +01:00
parent 403b1f6776
commit a1d78562d6
6 changed files with 193 additions and 65 deletions
+24 -5
View File
@@ -704,6 +704,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Skip play queue in offline mode (requires server connection)
if (widget.isOffline) return;
// Skip play queue for live TV (would interfere with tuner session)
if (widget.isLive) return;
// Only create play queues for episodes
if (!widget.metadata.isEpisode) {
return;
@@ -839,12 +842,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
try {
_hasFirstFrame.value = false;
await player!.requestAudioFocus();
final client = widget.liveClient ?? _getClientForMetadata(context);
final plexHeaders = client.config.headers;
await _setLiveStreamOptions();
await player!.open(
Media(widget.liveStreamUrl!, headers: plexHeaders),
Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}),
play: true,
);
@@ -1618,6 +1619,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isSwitchingChannel = false;
/// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous)
/// Configure MPV/FFmpeg options for live streaming resilience.
/// Enables automatic reconnection on EOF and network errors.
Future<void> _setLiveStreamOptions() async {
final p = player!;
// FFmpeg HTTP protocol reconnection
await p.setProperty('stream-lavf-o-append', 'reconnect=1');
await p.setProperty('stream-lavf-o-append', 'reconnect_at_eof=1');
await p.setProperty('stream-lavf-o-append', 'reconnect_streamed=1');
await p.setProperty('stream-lavf-o-append', 'reconnect_on_network_error=1');
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');
}
Future<void> _switchLiveChannel(int delta) async {
final channels = widget.liveChannels;
if (channels == null || channels.isEmpty) return;
@@ -1651,8 +1669,9 @@ 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: client.config.headers),
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
play: true,
);
+105 -15
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
@@ -2038,31 +2039,120 @@ class PlexClient {
);
}
/// Tune to a live TV channel. Returns metadata and the stream URL path.
/// Generate 24-char random alphanumeric string (matching official client format)
static String _generateSessionIdentifier() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
final rand = Random();
return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join();
}
/// 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 {
try {
final sessionIdentifier = _generateSessionIdentifier();
final response = await _dio.post(
'/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune',
queryParameters: {'X-Plex-Session-Identifier': sessionIdentifier},
);
final metadataJson = _getFirstMetadataJson(response);
if (metadataJson == null) return null;
if (response.statusCode != null && response.statusCode! >= 400) {
appLogger.w('Tune channel returned status ${response.statusCode}');
return null;
}
final container = _getMediaContainer(response);
if (container == null) return null;
// Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata
Map<String, dynamic>? metadataJson;
final subscriptions = container['MediaSubscription'] as List?;
if (subscriptions != null && subscriptions.isNotEmpty) {
final sub = subscriptions[0] as Map<String, dynamic>;
final ops = sub['MediaGrabOperation'] as List?;
if (ops != null && ops.isNotEmpty) {
final op = ops[0] as Map<String, dynamic>;
final nested = op['Metadata'];
if (nested is Map<String, dynamic>) {
metadataJson = nested;
}
}
}
metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map<String, dynamic>?;
if (metadataJson == null) {
appLogger.w('Tune channel: no metadata in response');
return null;
}
final metadata = _createTaggedMetadata(metadataJson);
// Extract stream path from Media[0].Part[0].key
String? streamPath;
final mediaList = metadataJson['Media'] as List?;
if (mediaList != null && mediaList.isNotEmpty) {
final parts = (mediaList[0] as Map<String, dynamic>)['Part'] as List?;
if (parts != null && parts.isNotEmpty) {
streamPath = (parts[0] as Map<String, dynamic>)['key'] as String?;
}
final sessionPath = metadataJson['key'] as String?;
if (sessionPath == null) {
appLogger.w('Tune channel: no session path in metadata key');
return null;
}
if (streamPath == null) return null;
return (metadata: metadata, streamPath: streamPath);
} catch (e) {
appLogger.e('Failed to tune channel', error: e);
// All identity goes in query params; the only HTTP header is Accept-Language
// (matching the official Plex client behaviour).
final allParams = <String, String>{
'hasMDE': '1',
'path': sessionPath,
'mediaIndex': '0',
'partIndex': '0',
'protocol': 'http',
'fastSeek': '1',
'directPlay': '0',
'directStream': '1',
'subtitleSize': '100',
'audioBoost': '100',
'location': 'lan',
'addDebugOverlay': '0',
'autoAdjustQuality': '0',
'directStreamAudio': '1',
'advancedSubtitles': 'text',
'mediaBufferSize': '157286',
'session': _generateSessionIdentifier(),
'subtitles': 'auto',
'copyts': '0',
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Chunked': '1',
'X-Plex-Incomplete-Segments': '1',
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
'X-Plex-Platform': config.platform,
'X-Plex-Client-Profile-Name': 'Plex Desktop',
if (config.token != null) 'X-Plex-Token': config.token!,
};
// Manual query encoding — Dio encodes spaces as '+' but Plex requires '%20'.
final queryString = allParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
// Decision — bare Dio so no default X-Plex-* HTTP headers leak through.
final decisionDio = Dio(BaseOptions(headers: {'Accept-Language': 'en'}));
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl));
if (decisionResponse.statusCode != 200) {
appLogger.w('Decision returned ${decisionResponse.statusCode}');
return null;
}
// Token is added by the caller via .withPlexToken()
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
final startQuery = startParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return (metadata: metadata, streamPath: '/video/:/transcode/universal/start?$startQuery');
} catch (e, st) {
appLogger.e('Failed to tune channel', error: e, stackTrace: st);
return null;
}
}
@@ -570,51 +570,54 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
semanticLabel: t.videoControls.nextButton,
),
),
// Finish time (hidden when too narrow to fit)
Expanded(
child: StreamBuilder<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, posSnap) {
return StreamBuilder<Duration>(
stream: widget.player.streams.duration,
initialData: widget.player.state.duration,
builder: (context, durSnap) {
return StreamBuilder<double>(
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, rateSnap) {
final position = posSnap.data ?? Duration.zero;
final duration = durSnap.data ?? Duration.zero;
final remaining = duration - position;
final rate = rateSnap.data ?? 1.0;
if (remaining.inSeconds <= 0) return const SizedBox.shrink();
// Finish time (hidden for live TV and when too narrow to fit)
if (widget.isLive)
const Spacer()
else
Expanded(
child: StreamBuilder<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, posSnap) {
return StreamBuilder<Duration>(
stream: widget.player.streams.duration,
initialData: widget.player.state.duration,
builder: (context, durSnap) {
return StreamBuilder<double>(
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, rateSnap) {
final position = posSnap.data ?? Duration.zero;
final duration = durSnap.data ?? Duration.zero;
final remaining = duration - position;
final rate = rateSnap.data ?? 1.0;
if (remaining.inSeconds <= 0) return const SizedBox.shrink();
final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate));
const style = TextStyle(color: Colors.white70, fontSize: 13);
final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate));
const style = TextStyle(color: Colors.white70, fontSize: 13);
return LayoutBuilder(
builder: (context, constraints) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
)..layout();
final textWidth = tp.width + 8;
tp.dispose();
if (textWidth > constraints.maxWidth) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(text, style: style),
);
},
);
},
);
},
);
},
return LayoutBuilder(
builder: (context, constraints) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
)..layout();
final textWidth = tp.width + 8;
tp.dispose();
if (textWidth > constraints.maxWidth) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(text, style: style),
);
},
);
},
);
},
);
},
),
),
),
// Volume control
VolumeControl(
player: widget.player,
@@ -652,6 +655,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume,
canControl: widget.canControl,
isLive: widget.isLive,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
),
@@ -77,6 +77,9 @@ class VideoSettingsSheet extends StatefulWidget {
/// Whether the user can control playback (false hides speed option in host-only mode).
final bool canControl;
/// Whether this is a live TV stream (hides speed settings).
final bool isLive;
/// Optional shader service for MPV shader control
final ShaderService? shaderService;
@@ -89,6 +92,7 @@ class VideoSettingsSheet extends StatefulWidget {
required this.audioSyncOffset,
required this.subtitleSyncOffset,
this.canControl = true,
this.isLive = false,
this.shaderService,
this.onShaderChanged,
});
@@ -101,6 +105,7 @@ class VideoSettingsSheet extends StatefulWidget {
VoidCallback? onOpen,
VoidCallback? onClose,
bool canControl = true,
bool isLive = false,
ShaderService? shaderService,
VoidCallback? onShaderChanged,
}) {
@@ -113,6 +118,7 @@ class VideoSettingsSheet extends StatefulWidget {
audioSyncOffset: audioSyncOffset,
subtitleSyncOffset: subtitleSyncOffset,
canControl: canControl,
isLive: isLive,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
),
@@ -253,8 +259,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return ListView(
children: [
// Playback Speed - only show if user can control playback
if (widget.canControl)
// Playback Speed - hidden for live TV and when user cannot control playback
if (widget.canControl && !widget.isLive)
StreamBuilder<double>(
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
@@ -792,6 +792,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
Future<void> _loadPlaybackExtras() async {
// Live TV metadata uses EPG rating keys, not library items
if (widget.isLive) return;
try {
appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}');
final client = _getClientForMetadata();
@@ -902,6 +905,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onStartAutoHide: _startHideTimer,
serverId: widget.metadata.serverId ?? '',
canControl: widget.canControl,
isLive: widget.isLive,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
);
@@ -1167,7 +1171,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
/// Handle long-press start - activate 2x speed
void _handleLongPressStart() {
if (!widget.canControl) return; // Respect Watch Together permissions
if (!widget.canControl || widget.isLive) return;
setState(() {
_isLongPressing = true;
@@ -60,6 +60,9 @@ class TrackChapterControls extends StatelessWidget {
/// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl;
/// Whether this is a live TV stream (hides speed settings).
final bool isLive;
const TrackChapterControls({
super.key,
required this.player,
@@ -89,6 +92,7 @@ class TrackChapterControls extends StatelessWidget {
this.onFocusChange,
this.onNavigateLeft,
this.canControl = true,
this.isLive = false,
this.shaderService,
this.onShaderChanged,
});
@@ -194,6 +198,7 @@ class TrackChapterControls extends StatelessWidget {
onOpen: onCancelAutoHide,
onClose: onStartAutoHide,
canControl: canControl,
isLive: isLive,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
);