From 18dc184388c06e15a2b49053a38fb0d0c2309e3a Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 21 Dec 2025 10:18:04 +0100 Subject: [PATCH] feat: add lite and native IPTV player screens for video playback --- .../iptv/screens/lite_player_screen.dart | 96 ++++++++++++++----- .../iptv/screens/native_player_screen.dart | 78 +++++++++------ 2 files changed, 118 insertions(+), 56 deletions(-) diff --git a/lib/mobile/features/iptv/screens/lite_player_screen.dart b/lib/mobile/features/iptv/screens/lite_player_screen.dart index 448a9b2..92af70c 100644 --- a/lib/mobile/features/iptv/screens/lite_player_screen.dart +++ b/lib/mobile/features/iptv/screens/lite_player_screen.dart @@ -70,6 +70,8 @@ class _LitePlayerScreenState extends ConsumerState Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _isSeeking = false; + bool _isStabilizing = false; // Flag to mask initial glitches + Timer? _stabilizationTimer; // Focus Nodes final FocusNode _playPauseFocusNode = FocusNode(); @@ -183,9 +185,16 @@ class _LitePlayerScreenState extends ConsumerState return; } + // Buffer Optimization: Wait for a pre-roll delay for Live TV + // to let the internal buffer fill before playback starts. + if (widget.streamType == StreamType.live) { + debugPrint('[LitePlayer] Pre-roll buffer delay (3.5s)...'); + await Future.delayed(const Duration(milliseconds: 3500)); + } + await controller.play(); setState(() { - _isLoading = false; + _isStabilizing = true; _isPlaying = true; _duration = controller.value.duration; }); @@ -206,14 +215,42 @@ class _LitePlayerScreenState extends ConsumerState void _videoListener() { if (_controller == null || !mounted) return; - final isPlaying = _controller!.value.isPlaying; + final value = _controller!.value; + + final isPlaying = value.isPlaying; if (isPlaying != _isPlaying) { setState(() => _isPlaying = isPlaying); } + // --- STABILIZATION MASKING LOGIC --- + // Show spinner if buffering OR if we are in the initial stabilization phase + final isBuffering = value.isBuffering; + final shouldShowLoading = isBuffering || _isStabilizing; + + if (shouldShowLoading != _isLoading) { + setState(() => _isLoading = shouldShowLoading); + } + + // If we are playing smoothly and stabilizing, start/restart the timer + if (isPlaying && !isBuffering && _isStabilizing) { + _stabilizationTimer?.cancel(); + _stabilizationTimer = Timer(const Duration(milliseconds: 1500), () { + if (mounted) { + setState(() { + _isStabilizing = false; + _isLoading = false; + }); + } + }); + } else if (isBuffering && _isStabilizing) { + // If we hit a buffer during stabilization, reset the timer + _stabilizationTimer?.cancel(); + } + // ------------------------------------ + if (!_isSeeking) { - final pos = _controller!.value.position; - final dur = _controller!.value.duration; + final pos = value.position; + final dur = value.duration; if (pos != _position || dur != _duration) { setState(() { _position = pos; @@ -222,8 +259,8 @@ class _LitePlayerScreenState extends ConsumerState } } - if (_controller!.value.hasError) { - setState(() => _errorMessage = _controller!.value.errorDescription); + if (value.hasError) { + setState(() => _errorMessage = value.errorDescription); } } @@ -732,27 +769,34 @@ class _LitePlayerScreenState extends ConsumerState style: const TextStyle(color: Colors.white), ), Expanded( - child: Slider( - value: _position.inSeconds - .toDouble() - .clamp(0, _duration.inSeconds.toDouble()), - min: 0, - max: _duration.inSeconds.toDouble() > 0 - ? _duration.inSeconds.toDouble() - : 1.0, - activeColor: AppColors.primary, - inactiveColor: Colors.white24, - onChanged: (val) { - setState(() { - _position = Duration(seconds: val.toInt()); - _isSeeking = true; - }); - }, - onChangeEnd: (val) { - _controller?.seekTo(Duration(seconds: val.toInt())); - _isSeeking = false; - _onUserInteraction(); + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowUp): + const DirectionalFocusIntent(TraversalDirection.up), + LogicalKeySet(LogicalKeyboardKey.arrowDown): + const DirectionalFocusIntent( + TraversalDirection.down), }, + child: Slider( + value: _position.inSeconds + .toDouble() + .clamp(0, _duration.inSeconds.toDouble()), + min: 0, + max: _duration.inSeconds.toDouble(), + activeColor: AppColors.primary, + inactiveColor: Colors.white24, + onChanged: (val) { + setState(() { + _position = Duration(seconds: val.toInt()); + _isSeeking = true; + }); + }, + onChangeEnd: (val) { + _controller?.seekTo(Duration(seconds: val.toInt())); + _isSeeking = false; + _onUserInteraction(); + }, + ), ), ), Text( diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index ad2a95e..f5b2c54 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -147,18 +147,22 @@ class _NativePlayerScreenState extends ConsumerState (_player.platform as dynamic)?.setProperty('cache', 'yes'); if (widget.streamType == StreamType.live) { - // LIVE PROFILE: Faster startup, lower latency, smaller buffer - debugPrint('MediaKitPlayer: Applying LIVE optimization profile'); - (_player.platform as dynamic)?.setProperty('cache-secs', '30'); + // LIVE PROFILE: Increased stability, no longer low-latency (prevents cuts) + debugPrint( + 'MediaKitPlayer: Applying LIVE optimization profile (STABILITY)'); (_player.platform as dynamic) - ?.setProperty('demuxer-max-bytes', '32000000'); // 32MB + ?.setProperty('cache-secs', '15'); // 15s cache (_player.platform as dynamic) - ?.setProperty('demuxer-readahead-secs', '20'); + ?.setProperty('demuxer-max-bytes', '64000000'); // 64MB (_player.platform as dynamic) - ?.setProperty('demuxer-max-back-bytes', '10000000'); // 10MB back + ?.setProperty('demuxer-readahead-secs', '15'); + (_player.platform as dynamic) + ?.setProperty('demuxer-max-back-bytes', '10000000'); - // Start playing as soon as possible, don't wait for cache fill - (_player.platform as dynamic)?.setProperty('cache-pause-initial', 'no'); + // ALWAYS wait for buffer fill to prevent glitches + (_player.platform as dynamic)?.setProperty('cache-pause-initial', 'yes'); + (_player.platform as dynamic) + ?.setProperty('cache-pause-wait', '3'); // Wait 3s max } else { // VOD PROFILE: Max stability, large buffer debugPrint('MediaKitPlayer: Applying VOD optimization profile'); @@ -213,9 +217,12 @@ class _NativePlayerScreenState extends ConsumerState ?.setProperty('vd-lavc-skiploopfilter', 'nonref'); // Balanced stability (_player.platform as dynamic)?.setProperty('framedrop', 'vo'); + // Low-latency intentionally removed for stability + /* if (widget.streamType == StreamType.live) { (_player.platform as dynamic)?.setProperty('profile', 'low-latency'); } + */ // ============ AUDIO CODEC SUPPORT FOR VOD ============ // V3: Explicit LAVC Downmix & Audiotrack @@ -1336,29 +1343,40 @@ class _NativePlayerScreenState extends ConsumerState ), ), Expanded( - child: Slider( - value: _position.inSeconds - .toDouble() - .clamp(0, _duration.inSeconds.toDouble()), - min: 0, - max: _duration.inSeconds.toDouble(), - // Add divisions for precise seeking (1 step = 10 seconds) - divisions: _duration.inSeconds > 0 - ? (_duration.inSeconds / 10).ceil() - : null, - activeColor: AppColors.primary, - inactiveColor: Colors.white24, - onChangeStart: (_) => _isSeeking = true, - onChangeEnd: (value) async { - await _player - .seek(Duration(seconds: value.toInt())); - _isSeeking = false; - }, - onChanged: (value) { - setState(() { - _position = Duration(seconds: value.toInt()); - }); + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowUp): + const DirectionalFocusIntent( + TraversalDirection.up), + LogicalKeySet(LogicalKeyboardKey.arrowDown): + const DirectionalFocusIntent( + TraversalDirection.down), }, + child: Slider( + value: _position.inSeconds + .toDouble() + .clamp(0, _duration.inSeconds.toDouble()), + min: 0, + max: _duration.inSeconds.toDouble(), + // Add divisions for precise seeking (1 step = 10 seconds) + divisions: _duration.inSeconds > 0 + ? (_duration.inSeconds / 10).ceil() + : null, + activeColor: AppColors.primary, + inactiveColor: Colors.white24, + onChangeStart: (_) => _isSeeking = true, + onChangeEnd: (value) async { + await _player + .seek(Duration(seconds: value.toInt())); + _isSeeking = false; + }, + onChanged: (value) { + setState(() { + _position = + Duration(seconds: value.toInt()); + }); + }, + ), ), ), Text(