mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
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:
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
@@ -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'
|
||||||
|
|||||||
Reference in new issue
Block a user