fix(tv): playback
This commit is contained in:
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user