From 3c37c6a6e6d29747c4169ef28313b77533fc925d Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 1 Sep 2026 08:05:34 +0200 Subject: [PATCH] feat(player): channel list sidebar accessible from the live OSD Add a visible playlist button in both players (native + lite) that opens the TiviMate-style channel sidebar (channels of the current category). The sidebar existed in the native player but was only reachable via an edge swipe or the M/Menu key; the lite player had none. - New OSD button next to channel zapping (live only) - Lite player: full sidebar port (swipe from right edge, M/Menu key) - D-pad support: focus lands on the current channel when the list opens, arrows navigate, OK switches channel, Back/Menu closes - Auto-hide timer (6s) restarted on D-pad focus and touch scroll - Bump version to 1.8.0+15 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EG1frWE7suYF7hp4q2sDiR --- .../iptv/screens/lite_player_screen.dart | 311 +++++++++++++++++- .../iptv/screens/native_player_screen.dart | 92 +++++- pubspec.yaml | 2 +- 3 files changed, 386 insertions(+), 19 deletions(-) diff --git a/lib/features/iptv/screens/lite_player_screen.dart b/lib/features/iptv/screens/lite_player_screen.dart index c303fc3..b702b62 100644 --- a/lib/features/iptv/screens/lite_player_screen.dart +++ b/lib/features/iptv/screens/lite_player_screen.dart @@ -80,6 +80,16 @@ class _LitePlayerScreenState extends ConsumerState final FocusNode _playPauseFocusNode = FocusNode(); final FocusNode _prevFocusNode = FocusNode(); final FocusNode _nextFocusNode = FocusNode(); + final FocusNode _channelListButtonFocusNode = FocusNode(); + + /// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir + /// directement dessus à l'ouverture de la liste. + final FocusNode _channelListItemFocusNode = FocusNode(); + + // [TiviMate] Channel List Sidebar + bool _showChannelList = false; + Timer? _channelListTimer; + final ScrollController _channelListScrollController = ScrollController(); @override void initState() { @@ -132,6 +142,8 @@ class _LitePlayerScreenState extends ConsumerState _controlsTimer = null; _stabilizationTimer?.cancel(); _stabilizationTimer = null; + _channelListTimer?.cancel(); + _channelListTimer = null; final controller = _controller; _controller = null; @@ -339,15 +351,95 @@ class _LitePlayerScreenState extends ConsumerState void _switchChannel(int index) { if (widget.channels == null) return; - setState(() => _currentIndex = index); + setState(() { + _currentIndex = index; + _showChannelList = false; // close sidebar on switch + }); + _channelListTimer?.cancel(); _onUserInteraction(); // Ensure OSD shows up and timer resets on channel change _loadStream(); } + /// Open/close the channel list sidebar + void _toggleChannelList() { + setState(() => _showChannelList = !_showChannelList); + if (_showChannelList) { + _scrollToCurrentChannel(); + _restartChannelListTimer(); + // Focus direct sur la chaîne en cours : la télécommande navigue dans la + // liste sans étape intermédiaire. Second essai après l'animation de + // scroll (300 ms), l'item actif pouvant ne pas encore être construit. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _showChannelList) { + _channelListItemFocusNode.requestFocus(); + } + }); + Future.delayed(const Duration(milliseconds: 400), () { + if (mounted && + _showChannelList && + !_channelListItemFocusNode.hasFocus) { + _channelListItemFocusNode.requestFocus(); + } + }); + } else { + _channelListTimer?.cancel(); + } + } + + /// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction + /// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts. + void _restartChannelListTimer() { + _channelListTimer?.cancel(); + _channelListTimer = Timer(const Duration(seconds: 6), () { + if (mounted) setState(() => _showChannelList = false); + }); + } + + /// Scroll channel list to keep current channel visible + void _scrollToCurrentChannel() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_channelListScrollController.hasClients) return; + const itemHeight = 64.0; + final offset = (_currentIndex * itemHeight) - + (_channelListScrollController.position.viewportDimension / 2) + + (itemHeight / 2); + _channelListScrollController.animateTo( + offset.clamp(0, _channelListScrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + bool _handleKeyEvent(KeyEvent event) { if (event is! KeyDownEvent) return false; final key = event.logicalKey; + // Sidebar ouverte : Back/Menu la ferment, le reste part au système de + // Focus (navigation + OK sur un item). On relance juste l'auto-fermeture. + if (_showChannelList) { + if (key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.contextMenu || + key == LogicalKeyboardKey.keyM) { + _channelListTimer?.cancel(); + setState(() => _showChannelList = false); + return true; + } + _restartChannelListTimer(); + return false; + } + + // Menu key or 'M' → toggle channel list sidebar + if (key == LogicalKeyboardKey.contextMenu || + key == LogicalKeyboardKey.keyM) { + if (widget.streamType == model.StreamType.live && + widget.channels != null) { + _toggleChannelList(); + return true; + } + } + if (key == LogicalKeyboardKey.channelUp) { if (widget.channels != null) { _switchChannel((_currentIndex + 1) % widget.channels!.length); @@ -397,6 +489,8 @@ class _LitePlayerScreenState extends ConsumerState _epgTimer?.cancel(); _controlsTimer?.cancel(); _stabilizationTimer?.cancel(); + _channelListTimer?.cancel(); + _channelListScrollController.dispose(); _controller?.pause(); // Force stop audio immediately _controller?.dispose(); _xtreamService?.dispose(); @@ -404,6 +498,8 @@ class _LitePlayerScreenState extends ConsumerState _playPauseFocusNode.dispose(); _prevFocusNode.dispose(); _nextFocusNode.dispose(); + _channelListButtonFocusNode.dispose(); + _channelListItemFocusNode.dispose(); super.dispose(); } @@ -419,7 +515,23 @@ class _LitePlayerScreenState extends ConsumerState // True black behind the video surface, never the warm ink. backgroundColor: Colors.black, body: GestureDetector( - onTap: _onUserInteraction, + onTap: () { + if (_showChannelList) { + _channelListTimer?.cancel(); + setState(() => _showChannelList = false); + } else { + _onUserInteraction(); + } + }, + // Swipe from right edge → open channel list + onHorizontalDragEnd: (details) { + if (widget.streamType == model.StreamType.live && + widget.channels != null && + details.primaryVelocity != null && + details.primaryVelocity! < -300) { + _toggleChannelList(); + } + }, child: Stack( children: [ // Video Surface @@ -509,6 +621,13 @@ class _LitePlayerScreenState extends ConsumerState // OSD (EPG + Controls) if (_showControls) _buildOSD(title), + + // [TiviMate] Channel list sidebar + if (_showChannelList && + widget.streamType == model.StreamType.live && + widget.channels != null && + widget.channels!.isNotEmpty) + _buildChannelListSidebar(), ], ), ), @@ -611,6 +730,20 @@ class _LitePlayerScreenState extends ConsumerState ), ), const SizedBox(width: 16), + + // Channel list sidebar (liste des chaînes de la catégorie) + if (widget.streamType == model.StreamType.live && + widget.channels != null && + widget.channels!.isNotEmpty) + TVFocusable( + focusNode: _channelListButtonFocusNode, + onPressed: _toggleChannelList, + child: const Icon( + Icons.playlist_play_rounded, + color: AppColors.onSurface, + size: 36, + ), + ), const SizedBox(width: 16), // Deinterlace Button (Switches to Native Player with forced Deinterlace) TVFocusable( @@ -774,4 +907,178 @@ class _LitePlayerScreenState extends ConsumerState ), ); } + + /// [TiviMate] Channel list sidebar — slide-in panel on the right + Widget _buildChannelListSidebar() { + final channels = widget.channels!; + return Positioned( + top: 0, + bottom: 0, + right: 0, + child: Container( + width: 280, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [Color(0xE6000000), Color(0x99000000)], + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: AppColors.onSurface12), + ), + ), + child: Row( + children: [ + const Icon(Icons.list, + color: AppColors.onSurface70, size: 18), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Chaînes', + style: TextStyle( + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + GestureDetector( + onTap: () { + _channelListTimer?.cancel(); + setState(() => _showChannelList = false); + }, + child: const Icon(Icons.close, + color: AppColors.onSurface54, size: 18), + ), + ], + ), + ), + // Channel list + Expanded( + child: NotificationListener( + onNotification: (_) { + _restartChannelListTimer(); + return false; + }, + child: ListView.builder( + controller: _channelListScrollController, + itemCount: channels.length, + itemExtent: 64.0, + itemBuilder: (context, index) { + final ch = channels[index]; + final isActive = index == _currentIndex; + return TVFocusable( + // Le focusNode externe n'est posé que sur la chaîne + // active ; la clé force un remontage quand isActive + // change (TVFocusable ne gère pas didUpdateWidget). + key: ValueKey('sidebar-ch-$index-$isActive'), + focusNode: isActive ? _channelListItemFocusNode : null, + scale: 1.0, + borderRadius: BorderRadius.zero, + onFocus: _restartChannelListTimer, + onPressed: () { + _channelListTimer?.cancel(); + _switchChannel(index); + }, + child: Container( + decoration: BoxDecoration( + color: isActive + ? AppColors.primaryFill.withValues(alpha: 0.6) + : Colors.transparent, + border: isActive + ? const Border( + left: BorderSide( + color: AppColors.primary, + width: 3, + ), + ) + : null, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + // Logo + Container( + width: 40, + height: 40, + margin: const EdgeInsets.only(right: 10), + decoration: BoxDecoration( + color: AppColors.onSurface12, + borderRadius: BorderRadius.circular(6), + ), + child: ch.streamIcon.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: ch.streamIcon, + fit: BoxFit.contain, + errorWidget: (_, __, ___) => + const Icon(Icons.tv, + color: AppColors.onSurface38, + size: 20), + ), + ) + : const Icon(Icons.tv, + color: AppColors.onSurface38, size: 20), + ), + // Name + num + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + ch.name, + style: TextStyle( + color: isActive + ? AppColors.onSurface + : AppColors.onSurface70, + fontSize: 13, + fontWeight: isActive + ? FontWeight.bold + : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + 'CH ${index + 1}', + style: const TextStyle( + color: AppColors.onSurface38, + fontSize: 11, + ), + ), + ], + ), + ), + // Active indicator + if (isActive) + const Icon( + Icons.play_arrow, + color: AppColors.primary, + size: 18, + ), + ], + ), + ), + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/features/iptv/screens/native_player_screen.dart b/lib/features/iptv/screens/native_player_screen.dart index 5babfef..8f551be 100644 --- a/lib/features/iptv/screens/native_player_screen.dart +++ b/lib/features/iptv/screens/native_player_screen.dart @@ -73,6 +73,11 @@ class _NativePlayerScreenState extends ConsumerState final FocusNode _sliderFocusNode = FocusNode(); final FocusNode _backFocusNode = FocusNode(); final FocusNode _audioFocusNode = FocusNode(); + final FocusNode _channelListButtonFocusNode = FocusNode(); + + /// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir + /// directement dessus à l'ouverture de la liste. + final FocusNode _channelListItemFocusNode = FocusNode(); bool _isLoading = true; bool _isFirstLoad = true; // true = black overlay; false = silent spinner @@ -605,6 +610,8 @@ class _NativePlayerScreenState extends ConsumerState _sliderFocusNode.dispose(); _backFocusNode.dispose(); _audioFocusNode.dispose(); + _channelListButtonFocusNode.dispose(); + _channelListItemFocusNode.dispose(); // Restore normal orientation SystemChrome.setPreferredOrientations([ @@ -1054,15 +1061,36 @@ class _NativePlayerScreenState extends ConsumerState setState(() => _showChannelList = !_showChannelList); if (_showChannelList) { _scrollToCurrentChannel(); - _channelListTimer?.cancel(); - _channelListTimer = Timer(const Duration(seconds: 6), () { - if (mounted) setState(() => _showChannelList = false); + _restartChannelListTimer(); + // Focus direct sur la chaîne en cours : la télécommande navigue dans la + // liste sans étape intermédiaire. Second essai après l'animation de + // scroll (300 ms), l'item actif pouvant ne pas encore être construit. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _showChannelList) { + _channelListItemFocusNode.requestFocus(); + } + }); + Future.delayed(const Duration(milliseconds: 400), () { + if (mounted && + _showChannelList && + !_channelListItemFocusNode.hasFocus) { + _channelListItemFocusNode.requestFocus(); + } }); } else { _channelListTimer?.cancel(); } } + /// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction + /// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts. + void _restartChannelListTimer() { + _channelListTimer?.cancel(); + _channelListTimer = Timer(const Duration(seconds: 6), () { + if (mounted) setState(() => _showChannelList = false); + }); + } + /// Scroll channel list to keep current channel visible void _scrollToCurrentChannel() { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1111,6 +1139,21 @@ class _NativePlayerScreenState extends ConsumerState try { final key = event.logicalKey; + // Sidebar ouverte : Back/Menu la ferment, le reste part au système de + // Focus (navigation + OK sur un item). On relance juste l'auto-fermeture. + if (_showChannelList) { + if (key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.contextMenu || + key == LogicalKeyboardKey.keyM) { + _channelListTimer?.cancel(); + setState(() => _showChannelList = false); + return true; + } + _restartChannelListTimer(); + return false; + } + // Explicit Play if (key == LogicalKeyboardKey.mediaPlay) { if (!_isPlaying) _player.play(); @@ -1224,16 +1267,6 @@ class _NativePlayerScreenState extends ConsumerState } } - // OK / Select when channel list is open → switch to focused channel - if (_showChannelList && - (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.select || - key == LogicalKeyboardKey.numpadEnter)) { - // Channel list handles its own tap, this closes it - setState(() => _showChannelList = false); - return true; - } - // Menu key or 'M' → toggle channel list sidebar if (key == LogicalKeyboardKey.contextMenu || key == LogicalKeyboardKey.keyM) { @@ -1597,6 +1630,19 @@ class _NativePlayerScreenState extends ConsumerState ), const SizedBox(width: 16), + // Channel list sidebar (liste des chaînes de la catégorie) + if (widget.channels != null && widget.channels!.isNotEmpty) + TVFocusable( + focusNode: _channelListButtonFocusNode, + onPressed: _toggleChannelList, + onFocus: () => _noteFocus(_channelListButtonFocusNode), + child: const Icon( + Icons.playlist_play_rounded, + color: AppColors.onSurface, + size: 36, + ), + ), + // Disable Deinterlace Button if (widget.forceDeinterlace) ...[ const SizedBox(width: 16), @@ -1945,15 +1991,28 @@ class _NativePlayerScreenState extends ConsumerState ), // Channel list Expanded( - child: ListView.builder( + child: NotificationListener( + onNotification: (_) { + _restartChannelListTimer(); + return false; + }, + child: ListView.builder( controller: _channelListScrollController, itemCount: channels.length, itemExtent: 64.0, itemBuilder: (context, index) { final ch = channels[index]; final isActive = index == _currentIndex; - return GestureDetector( - onTap: () { + return TVFocusable( + // Le focusNode externe n'est posé que sur la chaîne + // active ; la clé force un remontage quand isActive + // change (TVFocusable ne gère pas didUpdateWidget). + key: ValueKey('sidebar-ch-$index-$isActive'), + focusNode: isActive ? _channelListItemFocusNode : null, + scale: 1.0, + borderRadius: BorderRadius.zero, + onFocus: _restartChannelListTimer, + onPressed: () { _channelListTimer?.cancel(); _switchChannel(index); }, @@ -2041,6 +2100,7 @@ class _NativePlayerScreenState extends ConsumerState ), ); }, + ), ), ), ], diff --git a/pubspec.yaml b/pubspec.yaml index e78e466..5100d33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: xtremobile description: High-performance IPTV Android Application with Xtream Codes API publish_to: 'none' -version: 1.7.0+14 +version: 1.8.0+15 environment: sdk: '>=3.0.0 <4.0.0'