Files
xtremflow/lib/features/iptv/screens/player_screen.dart
T
MichaelandClaude Opus 5 f490e96448 feat(player): bouton plein écran et reconnexion du proxy live
Plein écran — le bouton n'existait que dans les contrôles VOD, sous une icône
aspect_ratio peu parlante, et les contrôles Live TV n'en avaient aucun. Ajout
du bouton côté Live TV et passage aux icônes fullscreen / fullscreen_exit.
L'état suit désormais l'évènement fullscreenchange du document : sortir par la
touche Échap laissait l'icône désynchronisée.

Proxy live — la route /api/live/<id>.ts relayait la source sans aucune reprise.
Les panneaux Xtream ferment régulièrement la connexion en cours de route, et le
MediaSource du navigateur recevait alors un sourceEnded : la lecture s'arrêtait
net au bout de quelques dizaines de secondes. Le proxy rouvre maintenant la
source tant que le client écoute, avec quatre tentatives et une seconde
d'attente entre chaque, le temps que le panneau libère le slot de connexion.
Le compteur repart de zéro dès qu'une connexion a tenu plus d'une minute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:31:13 +02:00

1222 lines
45 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;
import 'dart:async';
import 'package:google_fonts/google_fonts.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import '../providers/xtream_provider.dart';
import '../providers/playback_positions_provider.dart';
import '../widgets/quality_selector_widget.dart';
// ... (existing imports)
// ... (existing imports)
import '../../../core/models/playlist_config.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/theme/app_colors.dart';
enum StreamType { live, vod, series, recording }
class PlayerScreen extends ConsumerStatefulWidget {
final String streamId;
final String title;
final PlaylistConfig playlist;
final StreamType streamType;
final String containerExtension;
final List<dynamic>? channels;
final double? startTime;
final Duration? duration;
const PlayerScreen({
super.key,
required this.streamId,
required this.title,
required this.playlist,
this.streamType = StreamType.live,
this.containerExtension = 'mp4',
this.channels,
this.startTime,
this.duration,
});
@override
ConsumerState<PlayerScreen> createState() => _PlayerScreenState();
}
class _PlayerScreenState extends ConsumerState<PlayerScreen> {
bool _isPlaying = true;
bool _showControls = false;
Timer? _controlsTimer;
double _currentPosition = 0;
double _totalDuration = 1;
int _currentIndex = 0;
bool _isInitialized = false;
bool _isLoading = true;
StreamSubscription? _messageSubscription;
bool _isSeeking = false;
late final String _viewIdPrefix =
DateTime.now().millisecondsSinceEpoch.toString();
String _viewId = 'iptv-player';
bool _isMuted = false;
bool _ignoreStatusUpdates = false;
/// Reflète l'état plein écran du document. Le navigateur peut en sortir
/// sans passer par notre bouton (touche Échap), d'où l'écoute de
/// `fullscreenchange` plutôt qu'un simple booléen basculé au clic.
bool _isFullscreen = false;
StreamSubscription<html.Event>? _fullscreenSubscription;
/// Server-side transcoding quality. Live TV defaults to `source`
/// (direct MPEG-TS proxy, zero transcoding); VOD defaults to `high`.
late StreamQuality _quality = widget.streamType == StreamType.live
? StreamQuality.source
: StreamQuality.high;
@override
void initState() {
super.initState();
if (widget.channels != null) {
_currentIndex = widget.channels!.indexWhere((c) {
try {
return (c.streamId ?? c.id ?? '').toString() == widget.streamId;
} catch (_) {
return false;
}
});
if (_currentIndex == -1) _currentIndex = 0;
}
// Show controls at start, then auto-hide after timeout
_showControls = true;
_onHover(); // Start the auto-hide timer
_initializePlayer(startTimeOverride: widget.startTime);
_setupMessageListener();
_fullscreenSubscription =
html.document.onFullscreenChange.listen((_) => _syncFullscreenState());
}
@override
void dispose() {
_messageSubscription?.cancel();
_fullscreenSubscription?.cancel();
_controlsTimer?.cancel();
super.dispose();
}
bool get _documentIsFullscreen {
final doc = html.document;
return doc.fullscreenElement != null ||
(doc as dynamic).webkitFullscreenElement != null ||
(doc as dynamic).mozFullScreenElement != null;
}
void _syncFullscreenState() {
final value = _documentIsFullscreen;
if (mounted && value != _isFullscreen) {
setState(() => _isFullscreen = value);
}
}
Future<void> _initializePlayer({
double? startTimeOverride,
bool isChannelSwitch = false,
}) async {
setState(() {
_isLoading = true;
});
try {
// Determine Stream ID (Initial or Current Index)
String currentStreamId = widget.streamId;
if (isChannelSwitch && widget.channels != null) {
try {
final item = widget.channels![_currentIndex];
currentStreamId = (item.streamId ?? item.id ?? '').toString();
} catch (_) {}
}
// Stable view ID for the instance to prevent iframe reload on rebuild
// (orientation change). Includes quality so a quality switch forces a
// fresh iframe.
_viewId = 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix';
final service = ref.read(xtreamServiceProvider(widget.playlist));
String streamUrl = '';
// Generate Stream URL based on type
// NOTE: Logic is now handled by the backend routes /api/live/ and /api/vod/
if (widget.streamType == StreamType.live) {
streamUrl = service.getLiveStreamUrl(
currentStreamId,
quality: _quality.value,
);
} else if (widget.streamType == StreamType.vod) {
streamUrl = service.getVodStreamUrl(
currentStreamId,
widget.containerExtension,
quality: _quality.value,
);
} else if (widget.streamType == StreamType.series) {
streamUrl = service.getSeriesStreamUrl(
currentStreamId,
widget.containerExtension,
quality: _quality.value,
);
} else if (widget.streamType == StreamType.recording) {
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8';
}
// Store URL for Lite Player
final isMobile = MediaQuery.of(context).size.width < 600;
final isLiveTV = widget.streamType == StreamType.live;
// TURBO-START: in `source` quality, Live TV on desktop uses the direct
// MPEG-TS proxy (mpegts.js) for instant zapping with zero transcoding.
if (isLiveTV && !isMobile && _quality == StreamQuality.source) {
streamUrl = service.getLiveStreamUrlTs(currentStreamId);
}
// STABILIZATION: Lock background requests during stream init
service.isPlaybackLoading = true;
Future.delayed(const Duration(seconds: 10), () {
service.isPlaybackLoading = false;
});
final encodedUrl = Uri.encodeComponent(streamUrl);
// Force player choice based on stream type:
// - Live TV: Player Lite (simple TS playback with mpegts.js)
// - VOD/Series: Player Standard (fuller controls, HLS support)
// Cache buster to force reload of updated player.html
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
final streamTypeParam =
widget.streamType == StreamType.live ? 'live' : 'vod';
final playerFile = isMobile
? 'player_mobile.html'
: (isLiveTV ? 'player_lite.html' : 'player.html');
// Inject Turbo-Start flag
var playerSrc =
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
if (widget.streamType == StreamType.recording) {
playerSrc += '&is_recording=true';
}
if (startTimeOverride != null) {
playerSrc += '&t=$startTimeOverride';
}
if (widget.duration != null) {
playerSrc += '&duration=${widget.duration!.inSeconds}';
}
// Register View Factory (Only needed for Standard Player, but safe to do always or conditionally)
ui_web.platformViewRegistry.registerViewFactory(_viewId, (int viewId) {
final iframe = html.IFrameElement()
..id = _viewId
..src = playerSrc
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..allow = 'autoplay; fullscreen; picture-in-picture; encrypted-media';
return iframe;
});
if (mounted) {
setState(() {
_isInitialized = true;
_isLoading = false;
});
// Force unmute on load (after a short delay to ensure iframe is ready)
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_sendMessage({'type': 'set_volume', 'value': 1.0});
}
});
}
} catch (e) {
print('[PlayerScreen] Failed to load stream: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
void _setupMessageListener() {
_messageSubscription = html.window.onMessage.listen((event) {
// The player iframes are same-origin; drop messages from anywhere else.
if (event.origin != html.window.location.origin) return;
final data = event.data;
if (data == null) return;
final type = data['type'];
if (type == 'playback_position') {
final currentTime = (data['currentTime'] as num).toDouble();
final rawDuration = (data['duration'] as num).toDouble();
final duration = rawDuration.isFinite ? rawDuration : 0.0;
// Only update position if user is NOT dragging the slider
if (!_isSeeking) {
setState(() {
_currentPosition = currentTime;
_totalDuration = duration > 0 ? duration : 1;
});
}
// Update watch history if relevant
if (currentTime > 0) {
ref.read(playbackPositionsProvider.notifier).savePosition(
widget.streamId,
currentTime,
duration,
);
}
} else if (type == 'playback_status') {
if (!_ignoreStatusUpdates) {
setState(() => _isPlaying = data['status'] == 'playing');
}
} else if (type == 'user_activity') {
_onHover();
}
});
}
void _sendMessage(Map<String, dynamic> message) {
if (!_isInitialized) return;
// Try finding by ID first
var iframe = html.document.getElementById(_viewId) as html.IFrameElement?;
// If not found, try finding by src pattern (fallback for Shadow DOM or ID issues)
if (iframe == null) {
final iframes = html.document.getElementsByTagName('iframe');
for (final frame in iframes) {
if (frame is html.IFrameElement &&
(frame.src?.contains('player_lite.html') == true ||
frame.src?.contains('player.html') == true ||
frame.src?.contains('player_mobile.html') == true)) {
iframe = frame;
break;
}
}
}
if (iframe != null) {
iframe.contentWindow
?.postMessage(message, html.window.location.origin);
} else {
print(
'[PlayerScreen] Error: Could not find iframe to send message: $message',
);
}
}
void _onHover() {
setState(() => _showControls = true);
_controlsTimer?.cancel();
_controlsTimer = Timer(const Duration(seconds: 4), () {
if (mounted && _isPlaying) setState(() => _showControls = false);
});
}
void _togglePlayPause() {
// Optimistic update to make UI responsive immediately
setState(() => _isPlaying = !_isPlaying);
// Ignore incoming status updates for 2 seconds to prevent fighting
_ignoreStatusUpdates = true;
Future.delayed(const Duration(seconds: 2), () {
if (mounted) _ignoreStatusUpdates = false;
});
if (_isPlaying) {
_sendMessage({'type': 'play'});
} else {
_sendMessage({'type': 'pause'});
}
}
void _toggleFullscreen() {
try {
if (_documentIsFullscreen) {
html.document.exitFullscreen();
} else {
final element = html.document.documentElement;
if (element != null) {
element.requestFullscreen();
}
}
} catch (e) {
print('[PlayerScreen] Fullscreen error: $e');
_sendMessage({'type': 'request_fullscreen'});
}
// `fullscreenchange` arrive de façon asynchrone : relire l'état juste
// après l'appel évite que l'icône reste figée si l'évènement est perdu.
Future.delayed(
const Duration(milliseconds: 150),
_syncFullscreenState,
);
}
void _toggleMute() {
setState(() => _isMuted = !_isMuted);
_sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0});
}
void _changeQuality(StreamQuality quality) {
if (quality == _quality) return;
setState(() => _quality = quality);
// Rebuild the iframe with the new quality; VOD resumes at the
// current position, live restarts at the live edge.
final resumeAt =
widget.streamType == StreamType.live ? null : _currentPosition;
_initializePlayer(startTimeOverride: resumeAt, isChannelSwitch: true);
}
void _previousChannel() {
if (widget.channels != null && widget.channels!.isNotEmpty) {
setState(() {
if (_currentIndex > 0) {
_currentIndex--;
} else {
_currentIndex = widget.channels!.length - 1;
}
});
_initializePlayer(isChannelSwitch: true);
}
}
void _nextChannel() {
if (widget.channels != null && widget.channels!.isNotEmpty) {
setState(() {
if (_currentIndex < widget.channels!.length - 1) {
_currentIndex++;
} else {
_currentIndex = 0;
}
});
_initializePlayer(isChannelSwitch: true);
}
}
/// Keyboard / TV remote controls:
/// space-enter = play/pause, ←/→ = seek ±10s (VOD) or zap (live),
/// ↑/↓ = zap (live), M = mute, Esc = exit player.
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final isLive = widget.streamType == StreamType.live;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.mediaPlayPause) {
_togglePlayPause();
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.escape) {
Navigator.pop(context);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyM) {
_toggleMute();
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
if (isLive) {
_previousChannel();
} else {
_sendMessage({
'type': 'seek',
'value': (_currentPosition - 10).clamp(0, _totalDuration),
});
}
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
if (isLive) {
_nextChannel();
} else {
_sendMessage({
'type': 'seek',
'value': (_currentPosition + 10).clamp(0, _totalDuration),
});
}
_onHover();
return KeyEventResult.handled;
}
if (isLive && key == LogicalKeyboardKey.arrowUp) {
_nextChannel();
_onHover();
return KeyEventResult.handled;
}
if (isLive && key == LogicalKeyboardKey.arrowDown) {
_previousChannel();
_onHover();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final hours = twoDigits(d.inHours);
final minutes = twoDigits(d.inMinutes.remainder(60));
final seconds = twoDigits(d.inSeconds.remainder(60));
return d.inHours > 0 ? '$hours:$minutes:$seconds' : '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
final isLiveTV = widget.streamType == StreamType.live;
// LITE PLAYER MODE for Live TV - Simplified UI (no BackdropFilter)
if (isLiveTV) {
return Focus(
autofocus: true,
onKeyEvent: _onKeyEvent,
child: Scaffold(
backgroundColor: AppColors.background,
body: Stack(
fit: StackFit.expand,
children: [
// Video Player
if (_isInitialized) HtmlElementView(viewType: _viewId),
// Control Overlay (sits on top of video, catches all pointer events)
Positioned.fill(
child: PointerInterceptor(
child: MouseRegion(
onHover: (_) => _onHover(),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (_showControls) {
setState(() => _showControls = false);
} else {
_onHover();
}
},
child: Container(
color: Colors.transparent,
child: Stack(
children: [
// Top Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
top: _showControls ? 0 : -100,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.background.withOpacity(0.8),
Colors.transparent,
],
),
),
child: SafeArea(
bottom: false,
child: Row(
children: [
_buildSimpleIconButton(
icon: Icons.arrow_back_rounded,
onTap: () => Navigator.pop(context),
tooltip: 'Retour',
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.channels != null
? (widget
.channels![
_currentIndex]
.name ??
widget
.channels![
_currentIndex]
.title ??
'Unknown')
.toString()
: widget.title,
style: GoogleFonts.fraunces(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
Text(
'Live TV',
style: GoogleFonts.karla(
color: AppColors.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
// Top right mute button removed
],
),
),
),
),
// Unified Bottom Bar - EPG + Controls in one bar
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
bottom: _showControls
? 24
: -150, // More offset for stacked layout
left: 16,
right: 16,
child: LayoutBuilder(
builder: (context, constraints) {
final isSmallScreen =
constraints.maxWidth < 600;
return Container(
padding: EdgeInsets.symmetric(
horizontal: isSmallScreen ? 16 : 20,
vertical: isSmallScreen ? 12 : 16,
),
decoration: BoxDecoration(
color: AppColors.surface.withOpacity(0.95),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: AppColors.background
.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 2,
),
],
border: Border.all(
color: AppColors.textPrimary
.withOpacity(0.15),
),
),
child: isSmallScreen
? Column(
mainAxisSize: MainAxisSize.min,
children: [
// EPG Info (top in mobile)
_buildInlineEpg(),
const SizedBox(height: 12),
const Divider(
color: AppColors.onSurface06,
),
const SizedBox(height: 8),
// Control Buttons
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: _buildControlButtons(
isSmallScreen,
),
),
],
)
: Row(
children: [
// EPG Info (left side)
Expanded(
child: _buildInlineEpg(),
),
const SizedBox(width: 24),
// Control Buttons (right side)
Row(
mainAxisSize: MainAxisSize.min,
children: _buildControlButtons(
isSmallScreen,
),
),
],
),
);
},
),
),
],
),
),
),
),
),
),
],
),
),
);
}
// STANDARD PLAYER MODE (VOD/Series)
return Focus(
autofocus: true,
onKeyEvent: _onKeyEvent,
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
if (_isInitialized) HtmlElementView(viewType: _viewId),
// User interaction area
Positioned.fill(
child: PointerInterceptor(
child: MouseRegion(
onHover: (_) => _onHover(),
child: GestureDetector(
onTap: () {
if (_showControls) {
setState(() => _showControls = false);
} else {
_onHover();
}
},
behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent),
),
),
),
),
// Controls Overlay
if (_showControls || _isLoading)
Positioned.fill(
child: PointerInterceptor(
child: Container(
color: Colors.black.withOpacity(0.4),
child: Stack(
children: [
// Top Bar
Positioned(
top: 24,
left: 24,
right: 24,
child: GlassContainer.glass(
height: 72,
borderRadius: 24,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_buildGlassIconButton(
icon: Icons.arrow_back_rounded,
onTap: () => Navigator.pop(context),
tooltip: 'Retour',
),
const SizedBox(width: 16),
Expanded(
child: Text(
widget.title,
style: GoogleFonts.fraunces(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
),
],
),
),
),
// Center Loading Indicator (only show when loading)
if (_isLoading)
const Center(
child: CircularProgressIndicator(
color: AppColors.primary,
),
),
// Bottom Controls
Positioned(
bottom: 40,
left: 40,
right: 40,
child: GlassContainer.glass(
borderRadius: 24,
borderColor: AppColors.onSurface.withOpacity(0.1),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Progress
Row(
children: [
Text(
_formatDuration(
Duration(
seconds: _currentPosition.toInt(),
),
),
style: GoogleFonts.karla(
color: AppColors.onSurfaceVariant,
),
),
const SizedBox(width: 16),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: AppColors.primary,
inactiveTrackColor:
AppColors.onSurface.withOpacity(0.2),
thumbColor: AppColors.onSurface,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 8,
),
overlayColor:
AppColors.primary.withOpacity(0.2),
),
child: Slider(
value: _currentPosition,
min: 0,
max: _totalDuration,
onChanged: (val) => setState(
() => _currentPosition = val,
),
onChangeStart: (_) =>
setState(() => _isSeeking = true),
onChangeEnd: (val) {
_sendMessage(
{'type': 'seek', 'value': val},
);
Future.delayed(
const Duration(milliseconds: 500),
() {
if (mounted) {
setState(
() => _isSeeking = false,
);
}
});
},
),
),
),
const SizedBox(width: 16),
Text(
_formatDuration(
Duration(
seconds: _totalDuration.toInt(),
),
),
style: GoogleFonts.karla(
color: AppColors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
// Buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildGlassIconButton(
icon: Icons.replay_10_rounded,
onTap: () => _sendMessage({
'type': 'seek',
'value': (_currentPosition - 10)
.clamp(0, _totalDuration),
}),
transparent: true,
tooltip: 'Reculer de 10 s',
),
const SizedBox(width: 24),
_buildGlassIconButton(
icon: _isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
onTap: _togglePlayPause,
size: 56,
iconSize: 32,
tooltip: _isPlaying ? 'Pause' : 'Lecture',
),
const SizedBox(width: 24),
_buildGlassIconButton(
icon: Icons.forward_10_rounded,
onTap: () => _sendMessage({
'type': 'seek',
'value': (_currentPosition + 10)
.clamp(0, _totalDuration),
}),
transparent: true,
tooltip: 'Avancer de 10 s',
),
const Spacer(),
QualitySelectorButton(
current: _quality,
onSelected: _changeQuality,
),
const SizedBox(width: 16),
_buildGlassIconButton(
icon: _isMuted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
onTap: _toggleMute,
transparent: true,
tooltip: _isMuted
? 'Activer le son'
: 'Couper le son',
),
const SizedBox(width: 16),
_buildGlassIconButton(
icon: _isFullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
onTap: _toggleFullscreen,
transparent: true,
tooltip: _isFullscreen
? 'Quitter le plein écran'
: 'Plein écran',
),
],
),
],
),
),
),
],
),
),
),
),
],
),
),
);
}
Widget _buildGlassIconButton({
required IconData icon,
required VoidCallback onTap,
bool transparent = false,
double size = 48,
double iconSize = 24,
String? tooltip,
}) {
// Button with direct Material/InkWell - PointerInterceptor is on outer container
final button = Material(
color: transparent ? Colors.transparent : AppColors.onSurface.withOpacity(0.1),
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: transparent
? null
: Border.all(color: AppColors.onSurface.withOpacity(0.1)),
),
child: Icon(icon, color: AppColors.onSurface, size: iconSize),
),
),
);
return tooltip != null ? Tooltip(message: tooltip, child: button) : button;
}
List<Widget> _buildControlButtons(bool isSmallScreen) {
final buttonSize = isSmallScreen ? 40.0 : 44.0;
final playButtonSize = isSmallScreen ? 48.0 : 52.0;
final spacing = isSmallScreen ? 8.0 : 16.0;
return [
// Previous Channel
if (widget.channels != null && widget.channels!.length > 1)
_buildSimpleIconButton(
icon: Icons.skip_previous_rounded,
onTap: _previousChannel,
size: buttonSize,
tooltip: 'Chaîne précédente',
),
if (widget.channels != null && widget.channels!.length > 1)
SizedBox(width: spacing),
// Play/Pause (Large)
_buildSimpleIconButton(
icon: _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
onTap: _togglePlayPause,
size: playButtonSize,
iconSize: isSmallScreen ? 24 : 28,
highlighted: true,
tooltip: _isPlaying ? 'Pause' : 'Lecture',
),
if (widget.channels != null && widget.channels!.length > 1)
SizedBox(width: spacing),
// Next Channel
if (widget.channels != null && widget.channels!.length > 1)
_buildSimpleIconButton(
icon: Icons.skip_next_rounded,
onTap: _nextChannel,
size: buttonSize,
tooltip: 'Chaîne suivante',
),
SizedBox(width: spacing),
// Mute button
_buildSimpleIconButton(
icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
onTap: _toggleMute,
size: buttonSize,
tooltip: _isMuted ? 'Activer le son' : 'Couper le son',
),
SizedBox(width: spacing),
// Quality selector
QualitySelectorButton(
current: _quality,
onSelected: _changeQuality,
size: buttonSize,
),
SizedBox(width: spacing),
// Fullscreen
_buildSimpleIconButton(
icon: _isFullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
onTap: _toggleFullscreen,
size: buttonSize,
tooltip: _isFullscreen ? 'Quitter le plein écran' : 'Plein écran',
),
];
}
// Simple Icon Button for Live TV player (no BackdropFilter issues)
Widget _buildSimpleIconButton({
required IconData icon,
required VoidCallback onTap,
double size = 48,
double iconSize = 24,
bool highlighted = false,
String? tooltip,
}) {
final button = Material(
color: highlighted
? AppColors.primary.withOpacity(0.2)
: AppColors.onSurface.withOpacity(0.1),
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
splashColor: AppColors.primary.withOpacity(0.3),
highlightColor: AppColors.primary.withOpacity(0.1),
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: highlighted
? AppColors.primary.withOpacity(0.5)
: AppColors.onSurface.withOpacity(0.2),
width: highlighted ? 2 : 1,
),
),
child: Icon(
icon,
color: highlighted ? AppColors.primary : AppColors.onSurface,
size: iconSize,
),
),
),
);
return tooltip != null ? Tooltip(message: tooltip, child: button) : button;
}
/// Build inline EPG info for the unified control bar
Widget _buildInlineEpg() {
final currentStreamId = widget.channels != null
? widget.channels![_currentIndex].streamId
: widget.streamId;
return Consumer(
builder: (context, ref, child) {
final epgAsync = ref.watch(
epgByPlaylistProvider(
EpgRequestKey(playlist: widget.playlist, streamId: currentStreamId),
),
);
return epgAsync.when(
data: (epgEntries) {
if (epgEntries.isEmpty) {
// No EPG data - show channel name only
return Row(
children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.live,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.channels != null
? widget.channels![_currentIndex].name
: widget.title,
style: GoogleFonts.fraunces(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
// Get current program
final now = DateTime.now();
final currentProgram = epgEntries.firstWhere(
(entry) {
final start = DateTime.parse(entry.start);
final end = DateTime.parse(entry.end);
return now.isAfter(start) && now.isBefore(end);
},
orElse: () => epgEntries.first,
);
return Row(
children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.live,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
currentProgram.title,
style: GoogleFonts.fraunces(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (currentProgram.description.isNotEmpty)
Text(
currentProgram.description,
style: GoogleFonts.karla(
color: AppColors.onSurface54,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
},
loading: () => Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.live,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Text(
widget.channels != null
? widget.channels![_currentIndex].name
: widget.title,
style: GoogleFonts.fraunces(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
error: (_, __) => Text(
widget.channels != null
? widget.channels![_currentIndex].name
: widget.title,
style: GoogleFonts.fraunces(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
);
},
);
}
}