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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EG1frWE7suYF7hp4q2sDiR
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-09-01 08:05:34 +02:00
1 parent c096532fec
commit 3c37c6a6e6
3 files changed
+386 -19

No files matched your search

@@ -80,6 +80,16 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
final FocusNode _playPauseFocusNode = FocusNode(); final FocusNode _playPauseFocusNode = FocusNode();
final FocusNode _prevFocusNode = FocusNode(); final FocusNode _prevFocusNode = FocusNode();
final FocusNode _nextFocusNode = 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 @override
void initState() { void initState() {
@@ -132,6 +142,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_controlsTimer = null; _controlsTimer = null;
_stabilizationTimer?.cancel(); _stabilizationTimer?.cancel();
_stabilizationTimer = null; _stabilizationTimer = null;
_channelListTimer?.cancel();
_channelListTimer = null;
final controller = _controller; final controller = _controller;
_controller = null; _controller = null;
@@ -339,15 +351,95 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
void _switchChannel(int index) { void _switchChannel(int index) {
if (widget.channels == null) return; 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 _onUserInteraction(); // Ensure OSD shows up and timer resets on channel change
_loadStream(); _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) { bool _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return false; if (event is! KeyDownEvent) return false;
final key = event.logicalKey; 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 (key == LogicalKeyboardKey.channelUp) {
if (widget.channels != null) { if (widget.channels != null) {
_switchChannel((_currentIndex + 1) % widget.channels!.length); _switchChannel((_currentIndex + 1) % widget.channels!.length);
@@ -397,6 +489,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_epgTimer?.cancel(); _epgTimer?.cancel();
_controlsTimer?.cancel(); _controlsTimer?.cancel();
_stabilizationTimer?.cancel(); _stabilizationTimer?.cancel();
_channelListTimer?.cancel();
_channelListScrollController.dispose();
_controller?.pause(); // Force stop audio immediately _controller?.pause(); // Force stop audio immediately
_controller?.dispose(); _controller?.dispose();
_xtreamService?.dispose(); _xtreamService?.dispose();
@@ -404,6 +498,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_playPauseFocusNode.dispose(); _playPauseFocusNode.dispose();
_prevFocusNode.dispose(); _prevFocusNode.dispose();
_nextFocusNode.dispose(); _nextFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
super.dispose(); super.dispose();
} }
@@ -419,7 +515,23 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
// True black behind the video surface, never the warm ink. // True black behind the video surface, never the warm ink.
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: GestureDetector( 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( child: Stack(
children: [ children: [
// Video Surface // Video Surface
@@ -509,6 +621,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
// OSD (EPG + Controls) // OSD (EPG + Controls)
if (_showControls) _buildOSD(title), 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<LitePlayerScreen>
), ),
), ),
const SizedBox(width: 16), 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), const SizedBox(width: 16),
// Deinterlace Button (Switches to Native Player with forced Deinterlace) // Deinterlace Button (Switches to Native Player with forced Deinterlace)
TVFocusable( TVFocusable(
@@ -774,4 +907,178 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
), ),
); );
} }
/// [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<ScrollNotification>(
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,
),
],
),
),
);
},
),
),
),
],
),
),
),
);
}
} }
@@ -73,6 +73,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
final FocusNode _sliderFocusNode = FocusNode(); final FocusNode _sliderFocusNode = FocusNode();
final FocusNode _backFocusNode = FocusNode(); final FocusNode _backFocusNode = FocusNode();
final FocusNode _audioFocusNode = 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 _isLoading = true;
bool _isFirstLoad = true; // true = black overlay; false = silent spinner bool _isFirstLoad = true; // true = black overlay; false = silent spinner
@@ -605,6 +610,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_sliderFocusNode.dispose(); _sliderFocusNode.dispose();
_backFocusNode.dispose(); _backFocusNode.dispose();
_audioFocusNode.dispose(); _audioFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
// Restore normal orientation // Restore normal orientation
SystemChrome.setPreferredOrientations([ SystemChrome.setPreferredOrientations([
@@ -1054,15 +1061,36 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
setState(() => _showChannelList = !_showChannelList); setState(() => _showChannelList = !_showChannelList);
if (_showChannelList) { if (_showChannelList) {
_scrollToCurrentChannel(); _scrollToCurrentChannel();
_channelListTimer?.cancel(); _restartChannelListTimer();
_channelListTimer = Timer(const Duration(seconds: 6), () { // Focus direct sur la chaîne en cours : la télécommande navigue dans la
if (mounted) setState(() => _showChannelList = false); // 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 { } else {
_channelListTimer?.cancel(); _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 /// Scroll channel list to keep current channel visible
void _scrollToCurrentChannel() { void _scrollToCurrentChannel() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -1111,6 +1139,21 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
try { try {
final key = event.logicalKey; 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 // Explicit Play
if (key == LogicalKeyboardKey.mediaPlay) { if (key == LogicalKeyboardKey.mediaPlay) {
if (!_isPlaying) _player.play(); if (!_isPlaying) _player.play();
@@ -1224,16 +1267,6 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
} }
} }
// 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 // Menu key or 'M' → toggle channel list sidebar
if (key == LogicalKeyboardKey.contextMenu || if (key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) { key == LogicalKeyboardKey.keyM) {
@@ -1597,6 +1630,19 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
), ),
const SizedBox(width: 16), 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 // Disable Deinterlace Button
if (widget.forceDeinterlace) ...[ if (widget.forceDeinterlace) ...[
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -1945,15 +1991,28 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
), ),
// Channel list // Channel list
Expanded( Expanded(
child: ListView.builder( child: NotificationListener<ScrollNotification>(
onNotification: (_) {
_restartChannelListTimer();
return false;
},
child: ListView.builder(
controller: _channelListScrollController, controller: _channelListScrollController,
itemCount: channels.length, itemCount: channels.length,
itemExtent: 64.0, itemExtent: 64.0,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final ch = channels[index]; final ch = channels[index];
final isActive = index == _currentIndex; final isActive = index == _currentIndex;
return GestureDetector( return TVFocusable(
onTap: () { // 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(); _channelListTimer?.cancel();
_switchChannel(index); _switchChannel(index);
}, },
@@ -2041,6 +2100,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
), ),
); );
}, },
),
), ),
), ),
], ],
+1 -1
View File
@@ -1,7 +1,7 @@
name: xtremobile name: xtremobile
description: High-performance IPTV Android Application with Xtream Codes API description: High-performance IPTV Android Application with Xtream Codes API
publish_to: 'none' publish_to: 'none'
version: 1.7.0+14 version: 1.8.0+15
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'