mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-21 05:58:05 +02:00
feat: add IPTV player screen supporting live, VOD, and series streams via HTML iframe, and update dependencies.
This commit is contained in:
1 parent
d056877b9e
commit
2e2bd2b0ad
3 files changed
+201
-22
No files matched your search
@@ -11,7 +11,9 @@ import '../providers/settings_provider.dart';
|
|||||||
import '../widgets/epg_overlay.dart';
|
import '../widgets/epg_overlay.dart';
|
||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../../../core/models/iptv_models.dart';
|
import '../../../core/models/iptv_models.dart';
|
||||||
|
|
||||||
import '../../../core/widgets/themed_loading_screen.dart';
|
import '../../../core/widgets/themed_loading_screen.dart';
|
||||||
|
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||||
|
|
||||||
/// Stream type enum for player
|
/// Stream type enum for player
|
||||||
enum StreamType { live, vod, series }
|
enum StreamType { live, vod, series }
|
||||||
@@ -53,6 +55,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
bool _showControls = false;
|
bool _showControls = false;
|
||||||
Timer? _controlsTimer;
|
Timer? _controlsTimer;
|
||||||
StreamSubscription? _messageSubscription;
|
StreamSubscription? _messageSubscription;
|
||||||
|
|
||||||
|
// Premium Features State
|
||||||
|
List<Map<String, dynamic>> _audioTracks = [];
|
||||||
|
String _aspectRatio = 'contain';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -78,9 +84,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
_contentId, currentTime, duration
|
_contentId, currentTime, duration
|
||||||
);
|
);
|
||||||
} else if (type == 'playback_ended' && _contentId.isNotEmpty) {
|
} else if (type == 'playback_ended' && _contentId.isNotEmpty) {
|
||||||
ref.read(playbackPositionsProvider.notifier).clearPosition(_contentId);
|
|
||||||
} else if (type == 'user_activity') {
|
} else if (type == 'user_activity') {
|
||||||
_onHover();
|
_onHover();
|
||||||
|
} else if (type == 'audio_tracks') {
|
||||||
|
final tracks = List<Map<String, dynamic>>.from(data['tracks']);
|
||||||
|
setState(() {
|
||||||
|
_audioTracks = tracks;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -89,6 +99,24 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _sendMessage(Map<String, dynamic> message) {
|
||||||
|
// Helper to send message to iframe
|
||||||
|
final iframe = html.document.getElementById(_viewId) as html.IFrameElement?;
|
||||||
|
iframe?.contentWindow?.postMessage(message, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setAspectRatio(String mode) {
|
||||||
|
setState(() {
|
||||||
|
_aspectRatio = mode;
|
||||||
|
});
|
||||||
|
_sendMessage({'type': 'set_aspect_ratio', 'value': mode});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setAudioTrack(int index) {
|
||||||
|
_sendMessage({'type': 'set_audio_track', 'index': index});
|
||||||
|
Navigator.pop(context); // Close settings
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_messageSubscription?.cancel();
|
_messageSubscription?.cancel();
|
||||||
@@ -262,6 +290,75 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showSettingsDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => PointerInterceptor(
|
||||||
|
child: AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1E1E1E),
|
||||||
|
title: const Text('Réglages du Lecteur', style: TextStyle(color: Colors.white)),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Format d\'image', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
_buildAspectRatioChip('Original', 'contain'),
|
||||||
|
_buildAspectRatioChip('Remplir', 'cover'),
|
||||||
|
_buildAspectRatioChip('Étirer', 'fill'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_audioTracks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text('Pistes Audio', style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: _audioTracks.map((track) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white)),
|
||||||
|
subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54)),
|
||||||
|
onTap: () => _setAudioTrack(track['id']),
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.audiotrack, color: Colors.white54),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAspectRatioChip(String label, String value) {
|
||||||
|
final isSelected = _aspectRatio == value;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (_) => _setAspectRatio(value),
|
||||||
|
backgroundColor: Colors.black26,
|
||||||
|
selectedColor: Theme.of(context).primaryColor,
|
||||||
|
labelStyle: TextStyle(color: isSelected ? Colors.white : Colors.white70),
|
||||||
|
side: BorderSide.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -272,6 +369,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
PointerInterceptor(
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
onPressed: _showSettingsDialog,
|
||||||
|
tooltip: 'Réglages',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
) : null,
|
) : null,
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -312,34 +419,40 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
|
|
||||||
// Hover Controls Layer
|
// Hover Controls Layer
|
||||||
if (_showControls) ...[
|
if (_showControls) ...[
|
||||||
// Previous Channel (Left Zone)
|
// Previous Channel (Left Top Zone)
|
||||||
if (widget.channels != null && widget.channels!.isNotEmpty)
|
if (widget.channels != null && widget.channels!.isNotEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 80, // Moved to top
|
||||||
bottom: 0,
|
bottom: null,
|
||||||
|
height: 200,
|
||||||
width: 100,
|
width: 100,
|
||||||
child: InkWell(
|
child: PointerInterceptor(
|
||||||
onTap: _playPrevious,
|
child: InkWell(
|
||||||
hoverColor: Colors.black12,
|
onTap: _playPrevious,
|
||||||
child: const Center(
|
hoverColor: Colors.black12,
|
||||||
child: Icon(Icons.arrow_back_ios, color: Colors.white, size: 48),
|
child: const Center(
|
||||||
|
child: Icon(Icons.arrow_back_ios, color: Colors.white, size: 48),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Next Channel (Right Zone)
|
// Next Channel (Right Top Zone)
|
||||||
if (widget.channels != null && widget.channels!.isNotEmpty)
|
if (widget.channels != null && widget.channels!.isNotEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 80, // Moved to top
|
||||||
bottom: 0,
|
bottom: null,
|
||||||
|
height: 200,
|
||||||
width: 100,
|
width: 100,
|
||||||
child: InkWell(
|
child: PointerInterceptor(
|
||||||
onTap: _playNext,
|
child: InkWell(
|
||||||
hoverColor: Colors.black12,
|
onTap: _playNext,
|
||||||
child: const Center(
|
hoverColor: Colors.black12,
|
||||||
child: Icon(Icons.arrow_forward_ios, color: Colors.white, size: 48),
|
child: const Center(
|
||||||
|
child: Icon(Icons.arrow_forward_ios, color: Colors.white, size: 48),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -359,11 +472,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
colors: [Colors.black87, Colors.transparent],
|
colors: [Colors.black87, Colors.transparent],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: EpgOverlay(
|
child: PointerInterceptor(
|
||||||
streamId: widget.channels != null
|
child: EpgOverlay(
|
||||||
? widget.channels![_currentIndex].streamId
|
streamId: widget.channels != null
|
||||||
: widget.streamId,
|
? widget.channels![_currentIndex].streamId
|
||||||
playlist: widget.playlist,
|
: widget.streamId,
|
||||||
|
playlist: widget.playlist,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies:
|
|||||||
# UI Components
|
# UI Components
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
|
pointer_interceptor: ^0.10.1
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
|
|||||||
@@ -166,6 +166,67 @@
|
|||||||
}, '*');
|
}, '*');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for commands from parent (Flutter)
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
if (data.type === 'set_aspect_ratio') {
|
||||||
|
log('Setting aspect ratio: ' + data.value);
|
||||||
|
// value: 'contain', 'cover', 'fill' (fill = 100% 100%)
|
||||||
|
if (data.value === 'fill') {
|
||||||
|
video.style.objectFit = 'fill';
|
||||||
|
} else if (data.value === 'cover') {
|
||||||
|
video.style.objectFit = 'cover';
|
||||||
|
} else {
|
||||||
|
video.style.objectFit = 'contain';
|
||||||
|
}
|
||||||
|
} else if (data.type === 'set_audio_track') {
|
||||||
|
// Not standard in HTML5 Video API across all browsers universally for MP4
|
||||||
|
// But HLS.js and mpegts.js have specific APIs.
|
||||||
|
// For native "video.audioTracks", it is experimental.
|
||||||
|
// We will try to handle HLS.js switching if active.
|
||||||
|
log('Switching audio track index: ' + data.index);
|
||||||
|
if (window.hlsInstance) {
|
||||||
|
window.hlsInstance.audioTrack = data.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Report available audio tracks
|
||||||
|
function reportAudioTracks() {
|
||||||
|
let tracks = [];
|
||||||
|
if (window.hlsInstance && window.hlsInstance.audioTracks) {
|
||||||
|
tracks = window.hlsInstance.audioTracks.map((t, i) => ({
|
||||||
|
id: i,
|
||||||
|
label: t.name || t.lang || ('Track ' + (i + 1)),
|
||||||
|
lang: t.lang || ''
|
||||||
|
}));
|
||||||
|
} else if (video.audioTracks) {
|
||||||
|
// Standard HTML5 (Safari/experimental)
|
||||||
|
for (let i = 0; i < video.audioTracks.length; i++) {
|
||||||
|
const t = video.audioTracks[i];
|
||||||
|
tracks.push({
|
||||||
|
id: i,
|
||||||
|
label: t.label || t.language || ('Track ' + (i + 1)),
|
||||||
|
lang: t.language || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'audio_tracks',
|
||||||
|
tracks: tracks
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook reporting into playback start
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
setTimeout(reportAudioTracks, 1000); // Wait a bit for tracks to parse
|
||||||
|
});
|
||||||
|
|
||||||
// Report user activity (mouse movement) to parent
|
// Report user activity (mouse movement) to parent
|
||||||
let lastActivityTime = 0;
|
let lastActivityTime = 0;
|
||||||
document.addEventListener('mousemove', () => {
|
document.addEventListener('mousemove', () => {
|
||||||
@@ -259,6 +320,8 @@
|
|||||||
xhrSetup: function (xhr, url) { }
|
xhrSetup: function (xhr, url) { }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.hlsInstance = hls; // Expose for external control (audio tracks)
|
||||||
|
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user