feat: add mobile-optimized IPTV player with HLS and MpegTS support

This commit is contained in:
Michael committed 2026-03-29 10:19:36 +02:00
1 parent 3c9a9c7be7
commit fa6ae90d8d
38 files changed
+1280 -895

No files matched your search

@@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
@@ -25,7 +24,13 @@ class DashboardScreen extends ConsumerStatefulWidget {
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
int _selectedIndex = 0;
final List<String> _tabs = ['Live TV', 'Movies', 'Series', 'Recordings', 'Settings'];
final List<String> _tabs = [
'Live TV',
'Movies',
'Series',
'Recordings',
'Settings'
];
final List<IconData> _icons = [
Icons.live_tv_rounded,
Icons.movie_rounded,
+30 -21
View File
@@ -30,7 +30,7 @@ class EpgProgram {
});
Duration get duration => endTime.difference(startTime);
double get progress {
if (!isNow) return 0;
final now = DateTime.now();
@@ -45,9 +45,9 @@ class EpgGridScreen extends ConsumerStatefulWidget {
final Playlist playlist;
const EpgGridScreen({
Key? key,
super.key,
required this.playlist,
}) : super(key: key);
});
@override
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
@@ -112,7 +112,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
}
List<EpgProgram> _parseEpgPrograms(
Map<String, dynamic> epgData, String channelId) {
Map<String, dynamic> epgData,
String channelId,
) {
final programs = <EpgProgram>[];
final now = DateTime.now();
@@ -130,17 +132,19 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
final start = _parseEpgTime(startStr);
final end = _parseEpgTime(endStr);
programs.add(EpgProgram(
id: '${channelId}_${start.millisecondsSinceEpoch}',
channelId: channelId,
title: title,
description: desc,
startTime: start,
endTime: end,
isNow: now.isAfter(start) && now.isBefore(end),
isNext: start.isAfter(now) &&
start.difference(now).inMinutes < 60,
));
programs.add(
EpgProgram(
id: '${channelId}_${start.millisecondsSinceEpoch}',
channelId: channelId,
title: title,
description: desc,
startTime: start,
endTime: end,
isNow: now.isAfter(start) && now.isBefore(end),
isNext:
start.isAfter(now) && start.difference(now).inMinutes < 60,
),
);
}
} catch (e) {
continue;
@@ -192,7 +196,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(day, style: const TextStyle(fontSize: 11)),
Text(dateNum, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
Text(dateNum,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.bold)),
],
),
);
@@ -250,7 +256,8 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
child: Center(
child: Text(
'$hour:00',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.bold),
),
),
);
@@ -335,7 +342,8 @@ class _EpgChannelRow extends StatelessWidget {
// Programs
...programs.map((program) {
final duration = program.duration.inMinutes;
final width = (duration / 60 * 100).clamp(40, double.infinity).toDouble();
final width =
(duration / 60 * 100).clamp(40, double.infinity).toDouble();
return GestureDetector(
onTap: () => onProgramTap(program),
@@ -397,8 +405,7 @@ class _EpgChannelRow extends StatelessWidget {
value: program.progress / 100,
minHeight: 3,
backgroundColor: Colors.white24,
valueColor:
const AlwaysStoppedAnimation(Colors.red),
valueColor: const AlwaysStoppedAnimation(Colors.red),
),
),
),
@@ -455,7 +462,9 @@ class _ProgramDetailSheet extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
program.description.isEmpty ? 'No description available' : program.description,
program.description.isEmpty
? 'No description available'
: program.description,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 24),
+37 -41
View File
@@ -11,11 +11,8 @@ import '../providers/playback_positions_provider.dart';
// ... (existing imports)
// ... (existing imports)
import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/theme/app_colors.dart';
import '../widgets/epg_overlay.dart';
enum StreamType { live, vod, series }
@@ -57,7 +54,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
StreamSubscription? _messageSubscription;
final String _aspectRatio = 'contain';
bool _isSeeking = false;
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString();
late final String _viewIdPrefix =
DateTime.now().millisecondsSinceEpoch.toString();
String _viewId = 'iptv-player';
String? _currentStreamUrl;
String _statusMessage = 'Loading...';
@@ -134,7 +132,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Store URL for Lite Player
final isMobile = MediaQuery.of(context).size.width < 600;
final isLiveTV = widget.streamType == StreamType.live;
// TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping
if (isLiveTV && !isMobile) {
streamUrl = service.getLiveStreamUrlTs(currentStreamId);
@@ -152,15 +150,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// 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');
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';
var playerSrc =
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
if (startTimeOverride != null) {
playerSrc += '&t=$startTimeOverride';
@@ -300,31 +302,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void _toggleFullscreen() {
try {
final doc = html.document;
final isFullscreen = doc.fullscreenElement != null ||
(doc as dynamic).webkitFullscreenElement != null ||
(doc as dynamic).mozFullScreenElement != null;
final isFullscreen = doc.fullscreenElement != null ||
(doc as dynamic).webkitFullscreenElement != null ||
(doc as dynamic).mozFullScreenElement != null;
if (isFullscreen) {
if (doc.exitFullscreen != null) {
doc.exitFullscreen();
} else if ((doc as dynamic).webkitExitFullscreen != null) {
(doc as dynamic).webkitExitFullscreen();
} else if ((doc as dynamic).mozCancelFullScreen != null) {
(doc as dynamic).mozCancelFullScreen();
}
doc.exitFullscreen();
} else {
final element = doc.documentElement;
if (element != null) {
if (element.requestFullscreen != null) {
element.requestFullscreen();
} else if ((element as dynamic).webkitRequestFullscreen != null) {
(element as dynamic).webkitRequestFullscreen();
} else if ((element as dynamic).mozRequestFullScreen != null) {
(element as dynamic).mozRequestFullScreen();
} else {
// Fallback: Message the player if browser API fails
_sendMessage({'type': 'request_fullscreen'});
}
element.requestFullscreen();
}
}
} catch (e) {
@@ -442,9 +429,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [
Text(
widget.channels != null
? (widget.channels![_currentIndex]
? (widget
.channels![
_currentIndex]
.name ??
widget.channels![
widget
.channels![
_currentIndex]
.title ??
'Unknown')
@@ -478,12 +468,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Unified Bottom Bar - EPG + Controls in one bar
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
bottom: _showControls ? 24 : -150, // More offset for stacked layout
bottom: _showControls
? 24
: -150, // More offset for stacked layout
left: 16,
right: 16,
child: LayoutBuilder(
builder: (context, constraints) {
final isSmallScreen = constraints.maxWidth < 600;
final isSmallScreen =
constraints.maxWidth < 600;
return Container(
padding: EdgeInsets.symmetric(
horizontal: isSmallScreen ? 16 : 20,
@@ -494,13 +487,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: AppColors.background.withOpacity(0.3),
color: AppColors.background
.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 2,
),
],
border: Border.all(
color: AppColors.textPrimary.withOpacity(0.15),
color: AppColors.textPrimary
.withOpacity(0.15),
),
),
child: isSmallScreen
@@ -510,14 +505,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// EPG Info (top in mobile)
_buildInlineEpg(),
const SizedBox(height: 12),
const Divider(color: Colors.white10),
const Divider(
color: Colors.white10),
const SizedBox(height: 8),
// Control Buttons
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: _buildControlButtons(
isSmallScreen),
isSmallScreen,
),
),
],
)
@@ -532,7 +529,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Row(
mainAxisSize: MainAxisSize.min,
children: _buildControlButtons(
isSmallScreen),
isSmallScreen,
),
),
],
),
@@ -851,7 +849,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
onTap: _toggleMute,
size: buttonSize,
),
];
}
@@ -1059,5 +1056,4 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
},
);
}
}
@@ -37,8 +37,10 @@ class PlaylistSelectionScreen extends ConsumerWidget {
actions: [
if (currentUser?.isAdmin ?? false)
IconButton(
icon: const Icon(Icons.admin_panel_settings,
color: AppColors.textSecondary),
icon: const Icon(
Icons.admin_panel_settings,
color: AppColors.textSecondary,
),
onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel',
),
@@ -75,8 +77,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
!constraints.maxHeight.isFinite ||
constraints.maxHeight <= 0) {
return const Center(
child:
CircularProgressIndicator(color: AppColors.primary));
child: CircularProgressIndicator(color: AppColors.primary),
);
}
return playlistsAsync.when(
@@ -87,8 +89,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 64, color: AppColors.error),
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
Text(
'Error loading playlists',