mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
Porting the palette was not enough: the Warm Cinema charter only reached the widgets that read from AppColors. Nineteen screen and widget files still painted Apple TV values inline, so the app kept showing blue — most visibly on the selected navigation pill. Swept out: - 0xFF0A84FF (Apple blue) on the selected nav pill, category card icons and the "add playlist" button - 0xFF4FC3F7 and 0xFF1565C0 in the player overlays - Colors.blueAccent / Colors.blue in dialogs - Apple greys 1C1C1E / 2C2C2E / 3A3A3C / 8E8E93 -> warm charcoals - 128 Colors.white* -> the onSurfaceNN cream ladder (a pure white on a warm ground reads blue, which is the charter's whole point) - Colors.red / green / amber / yellow / grey* -> error, success, ratingGold, outlineVariant - pure black ink on light fills -> onPrimaryFixed, a warm near-black - the white focus glow -> an ember glow Deliberately left true black: the video surface backgrounds and the scrims over posters and video, where a warm tint would clash with the picture. Adds the onPrimaryFixed / primaryFixed / onSurface70 tokens the sweep needed. Verified: no Material or Apple colour literal remains outside lib/core/theme; flutter analyze reports no errors; 19/19 tests pass; release APK builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
778 lines
25 KiB
Dart
778 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
|
import 'package:xtremobile/core/models/playlist_config.dart';
|
|
import 'package:xtremobile/features/iptv/services/xtream_service_mobile.dart';
|
|
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
|
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
|
|
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
|
|
|
class LitePlayerScreen extends ConsumerStatefulWidget {
|
|
final String streamId;
|
|
final String title;
|
|
final model.StreamType streamType;
|
|
final String? containerExtension;
|
|
final PlaylistConfig playlist;
|
|
final List<model.Channel>? channels;
|
|
final int initialIndex;
|
|
|
|
// For series episodes
|
|
final dynamic seriesId;
|
|
final int? season;
|
|
final int? episodeNum;
|
|
|
|
const LitePlayerScreen({
|
|
super.key,
|
|
required this.streamId,
|
|
required this.title,
|
|
required this.playlist,
|
|
required this.streamType,
|
|
this.containerExtension,
|
|
this.channels,
|
|
this.initialIndex = 0,
|
|
this.seriesId,
|
|
this.season,
|
|
this.episodeNum,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<LitePlayerScreen> createState() => _LitePlayerScreenState();
|
|
}
|
|
|
|
class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|
with WidgetsBindingObserver {
|
|
VideoPlayerController? _controller;
|
|
XtreamServiceMobile? _xtreamService;
|
|
|
|
bool _isLoading = true;
|
|
String? _errorMessage;
|
|
late int _currentIndex;
|
|
bool _showControls = true;
|
|
bool _isPlaying = false;
|
|
|
|
// EPG & Clock
|
|
model.ShortEPG? _epg;
|
|
Timer? _epgTimer;
|
|
String _currentTime = "";
|
|
Timer? _clockTimer;
|
|
|
|
// Progress & Buffering
|
|
Duration _position = Duration.zero;
|
|
Duration _duration = Duration.zero;
|
|
bool _isSeeking = false;
|
|
bool _isStabilizing = false;
|
|
Timer? _stabilizationTimer;
|
|
Timer? _controlsTimer;
|
|
int _loadId = 0;
|
|
|
|
/// Set once the app is backgrounded, so a queued reload cannot restart the
|
|
/// stream behind the user.
|
|
bool _isReleased = false;
|
|
|
|
// Focus
|
|
final FocusNode _playPauseFocusNode = FocusNode();
|
|
final FocusNode _prevFocusNode = FocusNode();
|
|
final FocusNode _nextFocusNode = FocusNode();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
|
WakelockPlus.enable();
|
|
|
|
_currentIndex = widget.initialIndex;
|
|
_startClock();
|
|
|
|
SystemChrome.setPreferredOrientations([
|
|
DeviceOrientation.landscapeLeft,
|
|
DeviceOrientation.landscapeRight,
|
|
]);
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
|
|
_initializePlayback();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
// `inactive` is deliberately NOT handled: on Android it fires for transient
|
|
// interruptions (notification shade, call banner, permission dialog).
|
|
final isBackgrounded = state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.hidden ||
|
|
state == AppLifecycleState.detached;
|
|
if (!isBackgrounded) return;
|
|
|
|
_releasePlayback();
|
|
|
|
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
/// Stop playback and release the decoder when the app leaves the foreground.
|
|
///
|
|
/// `pause()` alone is not enough: ExoPlayer keeps filling its buffer, so the
|
|
/// stream would still be pulled (and metered) in the background.
|
|
void _releasePlayback() {
|
|
if (_isReleased) return;
|
|
_isReleased = true;
|
|
|
|
_clockTimer?.cancel();
|
|
_clockTimer = null;
|
|
_epgTimer?.cancel();
|
|
_epgTimer = null;
|
|
_controlsTimer?.cancel();
|
|
_controlsTimer = null;
|
|
_stabilizationTimer?.cancel();
|
|
_stabilizationTimer = null;
|
|
|
|
final controller = _controller;
|
|
_controller = null;
|
|
controller?.pause();
|
|
controller?.dispose();
|
|
|
|
WakelockPlus.disable();
|
|
}
|
|
|
|
void _startClock() {
|
|
_clockTimer?.cancel();
|
|
_updateTime();
|
|
_clockTimer = Timer.periodic(
|
|
const Duration(seconds: 1),
|
|
(_) => _updateTime(),
|
|
);
|
|
}
|
|
|
|
void _updateTime() {
|
|
final now = DateTime.now();
|
|
final timeStr =
|
|
"${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}";
|
|
if (mounted && _currentTime != timeStr) {
|
|
setState(() => _currentTime = timeStr);
|
|
}
|
|
}
|
|
|
|
Future<void> _initializePlayback() async {
|
|
try {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
_xtreamService = XtreamServiceMobile(dir.path);
|
|
await _xtreamService!.setPlaylistAsync(widget.playlist);
|
|
|
|
// Warm DNS + TCP/TLS to the panel host before the first request.
|
|
await _xtreamService!.prewarmHost().timeout(
|
|
const Duration(milliseconds: 1500),
|
|
onTimeout: () {},
|
|
);
|
|
|
|
_loadStream();
|
|
|
|
if (widget.streamType == model.StreamType.live) {
|
|
_updateEPG();
|
|
_epgTimer = Timer.periodic(
|
|
const Duration(minutes: 5),
|
|
(_) => _updateEPG(),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) setState(() => _errorMessage = "Initialization Error: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _updateEPG() async {
|
|
if (widget.streamType != model.StreamType.live || _xtreamService == null) {
|
|
return;
|
|
}
|
|
try {
|
|
final currentChannelId =
|
|
widget.channels?[_currentIndex].streamId ?? widget.streamId;
|
|
final epgData = await _xtreamService!.getShortEPG(currentChannelId);
|
|
if (mounted) setState(() => _epg = epgData);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _loadStream() async {
|
|
if (_xtreamService == null) return;
|
|
// Backgrounded while a (re)load was queued — do not resurrect the stream.
|
|
if (_isReleased || !mounted) return;
|
|
|
|
await _controller?.dispose();
|
|
_controller = null;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
_isStabilizing = true;
|
|
_loadId++;
|
|
});
|
|
|
|
final currentLoadId = _loadId;
|
|
|
|
try {
|
|
final currentStreamId =
|
|
widget.channels != null && widget.channels!.isNotEmpty
|
|
? widget.channels![_currentIndex].streamId
|
|
: widget.streamId;
|
|
|
|
if (widget.streamType == model.StreamType.live) _updateEPG();
|
|
|
|
final streamUrl = widget.streamType == model.StreamType.live
|
|
? _xtreamService!.getLiveStreamUrl(currentStreamId)
|
|
: (widget.streamType == model.StreamType.vod
|
|
? _xtreamService!.getVodStreamUrl(
|
|
currentStreamId,
|
|
widget.containerExtension ?? 'mp4',
|
|
)
|
|
: _xtreamService!.getSeriesStreamUrl(
|
|
currentStreamId,
|
|
widget.containerExtension ?? 'mp4',
|
|
));
|
|
|
|
final controller = VideoPlayerController.networkUrl(
|
|
Uri.parse(streamUrl),
|
|
httpHeaders: {'User-Agent': 'XtremFlow/1.0'},
|
|
);
|
|
|
|
_controller = controller;
|
|
await controller.initialize().timeout(
|
|
const Duration(seconds: 15),
|
|
onTimeout: () {
|
|
throw Exception('Connection timed out');
|
|
},
|
|
);
|
|
|
|
if (!mounted) {
|
|
await controller.dispose();
|
|
return;
|
|
}
|
|
|
|
// Buffer Optimization: Pre-roll delay for TS streams
|
|
if (widget.streamType == model.StreamType.live) {
|
|
await Future.delayed(const Duration(milliseconds: 2000));
|
|
}
|
|
|
|
await controller.play();
|
|
setState(() {
|
|
_isPlaying = true;
|
|
_duration = controller.value.duration;
|
|
});
|
|
_resetControlsTimer();
|
|
controller.addListener(_videoListener);
|
|
} catch (e) {
|
|
if (mounted && currentLoadId == _loadId) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_errorMessage = "Playback Error: $e";
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _videoListener() {
|
|
if (_controller == null || !mounted) return;
|
|
final value = _controller!.value;
|
|
|
|
if (value.isPlaying != _isPlaying) {
|
|
setState(() => _isPlaying = value.isPlaying);
|
|
}
|
|
|
|
// Stabilization Masking: spinner stays until 1.5s of smooth play
|
|
// Fix: If playing, ignore isBuffering to prevent infinite spinner on some TS streams
|
|
// Force hide loader immediately when playing
|
|
if (value.isPlaying) {
|
|
if (_isLoading) setState(() => _isLoading = false);
|
|
if (_errorMessage != null) setState(() => _errorMessage = null);
|
|
if (_isStabilizing) _isStabilizing = false;
|
|
_stabilizationTimer?.cancel();
|
|
} else {
|
|
final shouldShow = value.isBuffering || _isStabilizing;
|
|
if (shouldShow != _isLoading) setState(() => _isLoading = shouldShow);
|
|
}
|
|
|
|
if (!_isSeeking) {
|
|
setState(() {
|
|
_position = value.position;
|
|
_duration = value.duration;
|
|
});
|
|
}
|
|
|
|
if (value.hasError) setState(() => _errorMessage = value.errorDescription);
|
|
}
|
|
|
|
void _onUserInteraction() {
|
|
if (!mounted) return;
|
|
final wasShowing = _showControls;
|
|
setState(() => _showControls = true);
|
|
_resetControlsTimer();
|
|
|
|
// Auto-focus the play/pause button when OSD appears to ensure OK key works instantly
|
|
if (!wasShowing) {
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
if (mounted && _showControls) {
|
|
_playPauseFocusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void _resetControlsTimer() {
|
|
_controlsTimer?.cancel();
|
|
if (_showControls && _isPlaying) {
|
|
_controlsTimer = Timer(const Duration(seconds: 12), () {
|
|
if (mounted) setState(() => _showControls = false);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _togglePlayPause() {
|
|
if (_controller == null) return;
|
|
_controller!.value.isPlaying ? _controller!.pause() : _controller!.play();
|
|
_onUserInteraction();
|
|
}
|
|
|
|
void _switchChannel(int index) {
|
|
if (widget.channels == null) return;
|
|
setState(() => _currentIndex = index);
|
|
_onUserInteraction(); // Ensure OSD shows up and timer resets on channel change
|
|
_loadStream();
|
|
}
|
|
|
|
bool _handleKeyEvent(KeyEvent event) {
|
|
if (event is! KeyDownEvent) return false;
|
|
final key = event.logicalKey;
|
|
|
|
if (key == LogicalKeyboardKey.channelUp) {
|
|
if (widget.channels != null) {
|
|
_switchChannel((_currentIndex + 1) % widget.channels!.length);
|
|
}
|
|
return true;
|
|
}
|
|
if (key == LogicalKeyboardKey.channelDown) {
|
|
if (widget.channels != null) {
|
|
_switchChannel(
|
|
(_currentIndex - 1 + widget.channels!.length) %
|
|
widget.channels!.length,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (!_showControls) {
|
|
if (key == LogicalKeyboardKey.select ||
|
|
key == LogicalKeyboardKey.enter ||
|
|
key == LogicalKeyboardKey.arrowUp ||
|
|
key == LogicalKeyboardKey.arrowDown ||
|
|
key == LogicalKeyboardKey.arrowLeft ||
|
|
key == LogicalKeyboardKey.arrowRight) {
|
|
_onUserInteraction();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.goBack) {
|
|
if (_showControls) {
|
|
setState(() => _showControls = false);
|
|
return true;
|
|
}
|
|
Navigator.pop(context);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
|
WakelockPlus.disable();
|
|
_clockTimer?.cancel();
|
|
_epgTimer?.cancel();
|
|
_controlsTimer?.cancel();
|
|
_stabilizationTimer?.cancel();
|
|
_controller?.pause(); // Force stop audio immediately
|
|
_controller?.dispose();
|
|
_xtreamService?.dispose();
|
|
|
|
_playPauseFocusNode.dispose();
|
|
_prevFocusNode.dispose();
|
|
_nextFocusNode.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settings = ref.watch(mobileSettingsProvider);
|
|
final title = widget.channels != null
|
|
? widget.channels![_currentIndex].name
|
|
: widget.title;
|
|
|
|
return Scaffold(
|
|
// True black behind the video surface, never the warm ink.
|
|
backgroundColor: Colors.black,
|
|
body: GestureDetector(
|
|
onTap: _onUserInteraction,
|
|
child: Stack(
|
|
children: [
|
|
// Video Surface
|
|
Center(
|
|
child: _controller != null && _controller!.value.isInitialized
|
|
? _buildAspectRatioWrapper()
|
|
: const CircularProgressIndicator(color: AppColors.primary),
|
|
),
|
|
|
|
// Top Bar (Back Button + Clock)
|
|
if (_showControls)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: SafeArea(
|
|
child: Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Back Button
|
|
TVFocusable(
|
|
onPressed: () => Navigator.pop(context),
|
|
borderRadius: BorderRadius.circular(50),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.black45,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.arrow_back,
|
|
color: AppColors.onSurface,
|
|
size: 28,
|
|
),
|
|
),
|
|
),
|
|
// Clock
|
|
if (settings.showClock)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black45,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
_currentTime,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Error Overlay
|
|
if (_errorMessage != null)
|
|
Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black87,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
_errorMessage!,
|
|
style: const TextStyle(color: AppColors.error),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Loading Spinner
|
|
if (_isLoading)
|
|
const Center(
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
),
|
|
|
|
// OSD (EPG + Controls)
|
|
if (_showControls) _buildOSD(title),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAspectRatioWrapper() {
|
|
final mode = ref.read(mobileSettingsProvider).aspectRatioMode;
|
|
if (mode == 'fill') {
|
|
return SizedBox.expand(child: VideoPlayer(_controller!));
|
|
}
|
|
if (mode == 'cover') {
|
|
return SizedBox.expand(
|
|
child: FittedBox(
|
|
fit: BoxFit.cover,
|
|
child: SizedBox(
|
|
width: _controller!.value.size.width,
|
|
height: _controller!.value.size.height,
|
|
child: VideoPlayer(_controller!),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return AspectRatio(
|
|
aspectRatio: _controller!.value.aspectRatio,
|
|
child: VideoPlayer(_controller!),
|
|
);
|
|
}
|
|
|
|
Widget _buildOSD(String title) {
|
|
return Positioned(
|
|
bottom: 20,
|
|
left: 20,
|
|
right: 20,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Seeker (VOD)
|
|
if (widget.streamType != model.StreamType.live) _buildSeeker(),
|
|
|
|
// Unified Box: EPG & Controls
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.7),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppColors.onSurface12),
|
|
),
|
|
child: IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// EPG Info (Left)
|
|
Expanded(child: _buildEPGBox(title)),
|
|
const SizedBox(width: 24),
|
|
// Controls Continuity (Right)
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (widget.streamType == model.StreamType.live)
|
|
TVFocusable(
|
|
focusNode: _prevFocusNode,
|
|
onPressed: () => _switchChannel(
|
|
(_currentIndex -
|
|
1 +
|
|
(widget.channels?.length ?? 0)) %
|
|
(widget.channels?.length ?? 1),
|
|
),
|
|
child: const Icon(
|
|
Icons.skip_previous,
|
|
color: AppColors.onSurface,
|
|
size: 36,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
TVFocusable(
|
|
focusNode: _playPauseFocusNode,
|
|
onPressed: _togglePlayPause,
|
|
child: Icon(
|
|
_isPlaying
|
|
? Icons.pause_circle_filled
|
|
: Icons.play_circle_filled,
|
|
color: AppColors.onSurface,
|
|
size: 56,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
if (widget.streamType == model.StreamType.live)
|
|
TVFocusable(
|
|
focusNode: _nextFocusNode,
|
|
onPressed: () => _switchChannel(
|
|
(_currentIndex + 1) %
|
|
(widget.channels?.length ?? 1),
|
|
),
|
|
child: const Icon(
|
|
Icons.skip_next,
|
|
color: AppColors.onSurface,
|
|
size: 36,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
const SizedBox(width: 16),
|
|
// Deinterlace Button (Switches to Native Player with forced Deinterlace)
|
|
TVFocusable(
|
|
onPressed: () {
|
|
// Save preference using streamId
|
|
final streamId =
|
|
widget.channels?[_currentIndex].streamId ??
|
|
widget.streamId;
|
|
ref
|
|
.read(mobileSettingsProvider.notifier)
|
|
.toggleChannelDeinterlace(streamId);
|
|
|
|
// Switch to NativePlayer with Deinterlace ON
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => NativePlayerScreen(
|
|
streamId: streamId,
|
|
title: widget.channels?[_currentIndex].name ??
|
|
widget.title,
|
|
playlist: widget.playlist,
|
|
streamType: widget.streamType,
|
|
channels: widget.channels,
|
|
initialIndex: _currentIndex,
|
|
forceDeinterlace: true,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: const Icon(
|
|
Icons
|
|
.grid_on, // Icon representing interlaced grid/mesh
|
|
color: AppColors.onSurface,
|
|
size: 32,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEPGBox(String title) {
|
|
String program = widget.streamType == model.StreamType.live
|
|
? (_epg?.nowPlaying ?? "Pas d'infos EPG")
|
|
: title;
|
|
String next =
|
|
_epg?.nextPlaying != null ? "Suivant: ${_epg!.nextPlaying}" : "";
|
|
double progress = _epg?.progress ?? 0.0;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (widget.channels != null &&
|
|
widget.channels![_currentIndex].streamIcon.isNotEmpty)
|
|
Container(
|
|
margin: const EdgeInsets.only(right: 12),
|
|
width: 40,
|
|
height: 40,
|
|
child: CachedNetworkImage(
|
|
imageUrl: widget.channels![_currentIndex].streamIcon,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
program,
|
|
style: const TextStyle(
|
|
color: AppColors.primary,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (widget.streamType == model.StreamType.live &&
|
|
_epg?.nowPlaying != null) ...[
|
|
const SizedBox(height: 6),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: LinearProgressIndicator(
|
|
value: progress,
|
|
backgroundColor: AppColors.onSurface12,
|
|
color: AppColors.primary,
|
|
minHeight: 4,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
next,
|
|
style: const TextStyle(color: AppColors.onSurface54, fontSize: 12),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSeeker() {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Actions(
|
|
actions: {
|
|
DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>(
|
|
onInvoke: (intent) {
|
|
if (intent.direction == TraversalDirection.up ||
|
|
intent.direction == TraversalDirection.down) {
|
|
FocusScope.of(context).focusInDirection(intent.direction);
|
|
return null;
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
},
|
|
child: Shortcuts(
|
|
shortcuts: {
|
|
LogicalKeySet(LogicalKeyboardKey.arrowUp):
|
|
const DirectionalFocusIntent(TraversalDirection.up),
|
|
LogicalKeySet(LogicalKeyboardKey.arrowDown):
|
|
const DirectionalFocusIntent(TraversalDirection.down),
|
|
},
|
|
child: Slider(
|
|
value: _position.inSeconds.toDouble().clamp(
|
|
0,
|
|
_duration.inSeconds.toDouble(),
|
|
),
|
|
min: 0,
|
|
max: _duration.inSeconds.toDouble(),
|
|
activeColor: AppColors.primary,
|
|
inactiveColor: AppColors.onSurface24,
|
|
onChanged: (v) => setState(() {
|
|
_position = Duration(seconds: v.toInt());
|
|
_isSeeking = true;
|
|
}),
|
|
onChangeEnd: (v) {
|
|
_controller?.seekTo(Duration(seconds: v.toInt()));
|
|
_isSeeking = false;
|
|
_onUserInteraction();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|