feat(iptv): introduce web-based video player screen for live, VOD, and series content

This commit is contained in:
Michael committed 2025-12-08 20:10:40 +01:00
1 parent ec9ea0f66b
commit d48d25f65e
6 files changed
+51 -14

No files matched your search

+4 -3
View File
@@ -88,7 +88,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
final type = data['type']; final type = data['type'];
if (type == 'playback_position' && _contentId.isNotEmpty) { if (type == 'playback_position' && _contentId.isNotEmpty) {
final currentTime = (data['currentTime'] as num).toDouble(); final currentTime = (data['currentTime'] as num).toDouble();
final duration = (data['duration'] as num).toDouble(); final rawDuration = (data['duration'] as num).toDouble();
final duration = rawDuration.isFinite ? rawDuration : 0.0;
setState(() { setState(() {
_currentPosition = currentTime; _currentPosition = currentTime;
_totalDuration = duration > 0 ? duration : 1; _totalDuration = duration > 0 ? duration : 1;
@@ -936,11 +937,11 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
_formatDuration(Duration(seconds: _currentPosition.toInt())), _formatDuration(Duration(seconds: _currentPosition.isFinite ? _currentPosition.toInt() : 0)),
style: const TextStyle(color: Colors.white, fontSize: 12), style: const TextStyle(color: Colors.white, fontSize: 12),
), ),
Text( Text(
_formatDuration(Duration(seconds: _totalDuration.toInt())), _formatDuration(Duration(seconds: _totalDuration.isFinite ? _totalDuration.toInt() : 0)),
style: const TextStyle(color: Colors.white, fontSize: 12), style: const TextStyle(color: Colors.white, fontSize: 12),
), ),
], ],
+11 -2
View File
@@ -140,6 +140,15 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String? _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return null;
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _playMovie(Movie movie) { void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
@@ -175,7 +184,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
final heroItems = displayMovies.take(5).map((m) => HeroItem( final heroItems = displayMovies.take(5).map((m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
imageUrl: m.streamIcon ?? '', imageUrl: _getProxiedImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () { onMoreInfo: () {
_playMovie(m); _playMovie(m);
@@ -291,7 +300,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
return MediaCard( return MediaCard(
title: movie.name, title: movie.name,
imageUrl: movie.streamIcon, imageUrl: _getProxiedImageUrl(movie.streamIcon),
subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null, subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null,
rating: _formatRating(movie.rating), rating: _formatRating(movie.rating),
+11 -2
View File
@@ -140,6 +140,15 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String? _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return null;
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _openSeries(Series series) { void _openSeries(Series series) {
Navigator.push( Navigator.push(
context, context,
@@ -173,7 +182,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
final heroItems = displaySeries.take(5).map((s) => HeroItem( final heroItems = displaySeries.take(5).map((s) => HeroItem(
id: s.seriesId.toString(), // ID logic might differ for Series id: s.seriesId.toString(), // ID logic might differ for Series
title: s.name, title: s.name,
imageUrl: s.cover ?? '', imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s), onMoreInfo: () => _openSeries(s),
)).toList(); )).toList();
@@ -281,7 +290,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
final serie = displaySeries[index]; final serie = displaySeries[index];
return MediaCard( return MediaCard(
title: serie.name, title: serie.name,
imageUrl: serie.cover, imageUrl: _getProxiedImageUrl(serie.cover),
subtitle: serie.rating != null ? '${_formatRating(serie.rating)} ★' : null, subtitle: serie.rating != null ? '${_formatRating(serie.rating)} ★' : null,
rating: _formatRating(serie.rating), rating: _formatRating(serie.rating),
@@ -125,6 +125,15 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String? _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return null;
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _playMovie(Movie movie) { void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
@@ -160,7 +169,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
final heroItems = _movies.take(3).map((m) => HeroItem( final heroItems = _movies.take(3).map((m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
imageUrl: m.streamIcon ?? '', imageUrl: _getProxiedImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () => _playMovie(m), onMoreInfo: () => _playMovie(m),
)).toList(); )).toList();
@@ -251,7 +260,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return MediaCard( return MediaCard(
title: movie.name, title: movie.name,
imageUrl: movie.streamIcon, imageUrl: _getProxiedImageUrl(movie.streamIcon),
subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null, subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null,
rating: _formatRating(movie.rating), rating: _formatRating(movie.rating),
@@ -124,6 +124,15 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String? _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return null;
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _openSeries(Series series) { void _openSeries(Series series) {
Navigator.push( Navigator.push(
context, context,
@@ -153,7 +162,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
final heroItems = _series.take(3).map((s) => HeroItem( final heroItems = _series.take(3).map((s) => HeroItem(
id: s.seriesId.toString(), id: s.seriesId.toString(),
title: s.name, title: s.name,
imageUrl: s.cover ?? '', imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s), onMoreInfo: () => _openSeries(s),
)).toList(); )).toList();
@@ -251,7 +260,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
return MediaCard( return MediaCard(
title: series.name, title: series.name,
imageUrl: series.cover, imageUrl: _getProxiedImageUrl(series.cover),
subtitle: series.rating != null ? '${_formatRating(series.rating)} ★' : null, subtitle: series.rating != null ? '${_formatRating(series.rating)} ★' : null,
rating: _formatRating(series.rating), rating: _formatRating(series.rating),
+3 -3
View File
@@ -144,7 +144,7 @@
window.parent.postMessage({ window.parent.postMessage({
type: 'playback_position', type: 'playback_position',
currentTime: video.currentTime, currentTime: video.currentTime,
duration: video.duration || 0 duration: isFinite(video.duration) ? video.duration : 0
}, '*'); }, '*');
} }
} }
@@ -158,7 +158,7 @@
window.parent.postMessage({ window.parent.postMessage({
type: 'playback_position', type: 'playback_position',
currentTime: video.currentTime, currentTime: video.currentTime,
duration: video.duration || 0 duration: isFinite(video.duration) ? video.duration : 0
}, '*'); }, '*');
}); });
@@ -166,7 +166,7 @@
video.addEventListener('ended', () => { video.addEventListener('ended', () => {
window.parent.postMessage({ window.parent.postMessage({
type: 'playback_ended', type: 'playback_ended',
duration: video.duration || 0 duration: isFinite(video.duration) ? video.duration : 0
}, '*'); }, '*');
}); });