Files
xtremflow/lib/features/iptv/screens/player_screen.dart
T
MichaelandClaude Fable 5 969d40dec5 fix(ci): exclude bin/ from root analyzer and clean remaining warnings
The frontend CI job ran `flutter analyze` from the repo root, which also
analyzed the bin/ server package without its dependencies resolved
(shelf_router, sqlite3, bcrypt, test), producing hundreds of
uri_does_not_exist errors. bin/ is a standalone package covered by the
backend job, so it is now excluded from root analysis.

Also:
- Remove all unused fields/variables flagged as analyzer warnings
  (api_client, cache_service, player_screen, subtitle_service,
  live_tv_tab, mobile screens)
- Run `flutter analyze --no-fatal-infos` in CI: pre-existing deprecation
  infos (withOpacity, dart:html) stay non-fatal while errors and warnings
  still fail the build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:17:40 +02:00

1182 lines
44 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;
/// 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();
}
@override
void dispose() {
_messageSubscription?.cancel();
_controlsTimer?.cancel();
super.dispose();
}
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 {
final doc = html.document;
final isFullscreen = doc.fullscreenElement != null ||
(doc as dynamic).webkitFullscreenElement != null ||
(doc as dynamic).mozFullScreenElement != null;
if (isFullscreen) {
doc.exitFullscreen();
} else {
final element = doc.documentElement;
if (element != null) {
element.requestFullscreen();
}
}
} catch (e) {
print('[PlayerScreen] Fullscreen error: $e');
_sendMessage({'type': 'request_fullscreen'});
}
}
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.outfit(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
Text(
'Live TV',
style: GoogleFonts.inter(
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: Colors.white10,
),
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.outfit(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
),
),
// 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: Colors.white.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.inter(
color: Colors.white70,
),
),
const SizedBox(width: 16),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: AppColors.primary,
inactiveTrackColor:
Colors.white.withOpacity(0.2),
thumbColor: Colors.white,
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.inter(
color: Colors.white70,
),
),
],
),
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: Icons.aspect_ratio_rounded,
onTap: _toggleFullscreen,
transparent: true,
tooltip: '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 : Colors.white.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: Colors.white.withOpacity(0.1)),
),
child: Icon(icon, color: Colors.white, 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,
),
];
}
// 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)
: Colors.white.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)
: Colors.white.withOpacity(0.2),
width: highlighted ? 2 : 1,
),
),
child: Icon(
icon,
color: highlighted ? AppColors.primary : Colors.white,
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: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.channels != null
? widget.channels![_currentIndex].name
: widget.title,
style: GoogleFonts.outfit(
color: Colors.white,
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: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
currentProgram.title,
style: GoogleFonts.outfit(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (currentProgram.description.isNotEmpty)
Text(
currentProgram.description,
style: GoogleFonts.inter(
color: Colors.white60,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
},
loading: () => Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Text(
widget.channels != null
? widget.channels![_currentIndex].name
: widget.title,
style: GoogleFonts.outfit(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
error: (_, __) => Text(
widget.channels != null
? widget.channels![_currentIndex].name
: widget.title,
style: GoogleFonts.outfit(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
);
},
);
}
}