feat: Implement TV recording management with API endpoints, a data model, and a scheduling service that uses FFmpeg to record streams.

This commit is contained in:
Michael committed 2026-03-09 16:46:45 +01:00
1 parent 33c6385d7e
commit 13647a35da
6 files changed
+70 -347

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ ENV DART_VM_OPTIONS="--old_gen_heap_size=4096"
RUN flutter config --enable-web
# Force cache invalidation when source changes (update this value to force rebuild)
ARG CACHEBUST=2026-03-09-epg-v1
ARG CACHEBUST=2026-03-09-mobile-v1
# Copy dependency files first for better caching
COPY pubspec.yaml ./
+38 -15
View File
@@ -25,7 +25,7 @@ class PlayerScreen extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
final StreamType streamType;
final String containerExtension;
final List<Channel>? channels;
final List<dynamic>? channels;
final double? startTime;
final Duration? duration;
@@ -57,6 +57,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
StreamSubscription? _messageSubscription;
final String _aspectRatio = 'contain';
bool _isSeeking = false;
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString();
String _viewId = 'iptv-player';
String? _currentStreamUrl;
String _statusMessage = 'Loading...';
@@ -68,8 +69,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void initState() {
super.initState();
if (widget.channels != null) {
_currentIndex =
widget.channels!.indexWhere((c) => c.streamId == widget.streamId);
_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
@@ -97,12 +103,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
try {
// Determine Stream ID (Initial or Current Index)
final currentStreamId = isChannelSwitch && widget.channels != null
? widget.channels![_currentIndex].streamId
: widget.streamId;
String currentStreamId = widget.streamId;
if (isChannelSwitch && widget.channels != null) {
try {
final item = widget.channels![_currentIndex];
currentStreamId = (item.streamId ?? item.id ?? '').toString();
} catch (_) {}
}
_viewId =
'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}';
// Stable view ID for the instance to prevent iframe reload on rebuild (orientation change)
_viewId = 'iptv-player-$currentStreamId-$_viewIdPrefix';
final service = ref.read(xtreamServiceProvider(widget.playlist));
String streamUrl = '';
@@ -274,10 +284,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}
void _toggleFullscreen() {
if (html.document.fullscreenElement != null) {
html.document.exitFullscreen();
} else {
html.document.documentElement?.requestFullscreen();
try {
if (html.document.fullscreenElement != null) {
html.document.exitFullscreen();
} else {
final element = html.document.documentElement;
if (element != null) {
element.requestFullscreen();
}
}
} catch (e) {
print('[PlayerScreen] Fullscreen error: $e');
// Fallback: Message the player to try internal fullscreen
_sendMessage({'type': 'request_fullscreen'});
}
}
@@ -390,9 +409,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [
Text(
widget.channels != null
? widget
.channels![_currentIndex]
.name
? (widget.channels![_currentIndex]
.name ??
widget.channels![
_currentIndex]
.title ??
'Unknown')
.toString()
: widget.title,
style: GoogleFonts.outfit(
color: Colors.white,
@@ -1,238 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
class LitePlayerView extends ConsumerStatefulWidget {
final String streamUrl;
final bool isLive;
final VoidCallback? onNext;
final VoidCallback? onPrevious;
const LitePlayerView({
super.key,
required this.streamUrl,
required this.isLive,
this.onNext,
this.onPrevious,
});
@override
ConsumerState<LitePlayerView> createState() => _LitePlayerViewState();
}
class _LitePlayerViewState extends ConsumerState<LitePlayerView> {
late VideoPlayerController _videoController;
ChewieController? _chewieController;
bool _error = false;
@override
void initState() {
super.initState();
_initializePlayer();
}
@override
void didUpdateWidget(LitePlayerView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.streamUrl != widget.streamUrl) {
_disposeControllers();
_initializePlayer();
}
}
Future<void> _initializePlayer() async {
try {
setState(() => _error = false);
_videoController =
VideoPlayerController.networkUrl(Uri.parse(widget.streamUrl));
await _videoController.initialize();
_chewieController = ChewieController(
videoPlayerController: _videoController,
autoPlay: true,
looping: false,
isLive: widget.isLive,
aspectRatio: _videoController.value.aspectRatio > 0
? _videoController.value.aspectRatio
: 16 / 9,
materialProgressColors: ChewieProgressColors(
playedColor: AppColors.primary,
handleColor: Colors.white,
backgroundColor: Colors.white24,
bufferedColor: Colors.white54,
),
// Fullscreen configuration for Web/Mobile
allowFullScreen: true,
allowMuting: true,
showControls: true,
useRootNavigator: true, // Crucial for escaping layout constraints
// Orientation settings are often ignored or cause issues on web
// but we keep them for native mobile if needed.
// On web, the browser handles this.
deviceOrientationsOnEnterFullScreen: [
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
],
deviceOrientationsAfterFullScreen: [
DeviceOrientation.portraitUp,
],
// Custom Controls to include Zapping
additionalOptions: (context) {
return <OptionItem>[
if (widget.onNext != null)
OptionItem(
onTap: (context) {
widget.onNext!();
Navigator.of(context).pop(); // Close options menu
},
iconData: Icons.skip_next,
title: 'Next Channel',
),
if (widget.onPrevious != null)
OptionItem(
onTap: (context) {
widget.onPrevious!();
Navigator.of(context).pop(); // Close options menu
},
iconData: Icons.skip_previous,
title: 'Previous Channel',
),
];
},
);
_chewieController!.addListener(() {
if (mounted) setState(() {});
});
setState(() {});
} catch (e) {
debugPrint('Error initializing Lite Player: $e');
setState(() => _error = true);
}
}
void _disposeControllers() {
_videoController.dispose();
_chewieController?.dispose();
_chewieController = null;
}
@override
void dispose() {
_disposeControllers();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
const Text('Error loading video',
style: TextStyle(color: Colors.white)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
_disposeControllers();
_initializePlayer();
},
child: const Text('Retry'),
),
],
),
);
}
if (_chewieController != null &&
_chewieController!.videoPlayerController.value.isInitialized) {
return Stack(
children: [
Chewie(controller: _chewieController!),
// Right-Side Overlays (Zapping + Fullscreen)
Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Fullscreen Toggle
IconButton(
onPressed: () {
// For Web, ensure we use the built-in fullscreen transition
// which is more compatible with Safari/Chrome security
if (_chewieController?.isFullScreen == true) {
_chewieController?.exitFullScreen();
} else {
// On web, sometimes we need to toggle more than once
// to ensure the browser captures the intent
_chewieController?.enterFullScreen();
}
},
icon: Icon(
_chewieController?.isFullScreen == true
? Icons.fullscreen_exit
: Icons.fullscreen,
color: Colors.white,
size: 32),
style: IconButton.styleFrom(
backgroundColor: Colors.black87,
padding: const EdgeInsets.all(12),
),
),
const SizedBox(height: 24),
// Zapping Controls
if (widget.onPrevious != null || widget.onNext != null)
GlassContainer(
borderRadius: 16,
opacity: 0.3,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.onPrevious != null)
IconButton(
onPressed: widget.onPrevious,
icon: const Icon(Icons.keyboard_arrow_up,
color: Colors.white, size: 36),
tooltip: 'Previous Channel',
),
const SizedBox(height: 8),
if (widget.onNext != null)
IconButton(
onPressed: widget.onNext,
icon: const Icon(Icons.keyboard_arrow_down,
color: Colors.white, size: 36),
tooltip: 'Next Channel',
),
],
),
),
],
),
),
),
],
);
}
return const Center(
child: CircularProgressIndicator(color: AppColors.primary));
}
}
@@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../features/iptv/screens/player_screen.dart';
export '../../../../features/iptv/screens/player_screen.dart' show StreamType;
import '../../../../core/models/playlist_config.dart';
import '../../../../features/iptv/widgets/lite_player_view.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
class MobilePlayerScreen extends ConsumerWidget {
final String streamId;
@@ -12,7 +10,8 @@ class MobilePlayerScreen extends ConsumerWidget {
final StreamType streamType;
final String containerExtension;
final PlaylistConfig playlist;
final List<dynamic>? channels; // New: List of channels for zapping
final List<dynamic>? channels;
final double? startTime;
const MobilePlayerScreen({
super.key,
@@ -22,100 +21,22 @@ class MobilePlayerScreen extends ConsumerWidget {
this.streamType = StreamType.live,
this.containerExtension = 'mp4',
this.channels,
this.startTime,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final service = ref.read(xtreamServiceProvider(playlist));
String streamUrl = '';
if (streamType == StreamType.live) {
streamUrl = service.getLiveStreamUrl(streamId);
} else if (streamType == StreamType.vod) {
streamUrl = service.getVodStreamUrl(streamId, containerExtension);
} else if (streamType == StreamType.series) {
streamUrl = service.getSeriesStreamUrl(streamId, containerExtension);
}
// Zapping logic
VoidCallback? onNext;
VoidCallback? onPrevious;
if (channels != null && channels!.isNotEmpty) {
final currentIndex = channels!.indexWhere((c) {
try {
// Supports Channel/Movie (.streamId) and Episode (.id)
final cId = (c.streamId ?? c.id).toString();
return cId == streamId;
} catch (_) {
return false;
}
});
if (currentIndex != -1) {
void navigateTo(int index) {
final item = channels![index % channels!.length];
String nId = '';
String nTitle = '';
String nExt = containerExtension;
try {
// ID Extraction
nId = (item.streamId ?? item.id).toString();
// Title Extraction (name for channels/movies, title for episodes)
nTitle = item.name ?? item.title ?? 'Unknown';
// Format series title predictably
if (streamType == StreamType.series && title.contains(' - ')) {
final prefix = title.split(' - ').first;
nTitle = '$prefix - Episode ${item.episodeNum ?? ""}';
}
nExt = item.containerExtension ?? containerExtension;
} catch (_) {}
if (nId.isEmpty) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MobilePlayerScreen(
streamId: nId,
title: nTitle,
playlist: playlist,
streamType: streamType,
containerExtension: nExt,
channels: channels,
),
),
);
}
onNext = () => navigateTo(currentIndex + 1);
onPrevious = () => navigateTo(currentIndex - 1 + channels!.length);
}
}
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(title,
style: const TextStyle(color: Colors.white, fontSize: 16)),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
body: SizedBox.expand(
child: LitePlayerView(
streamUrl: streamUrl,
isLive: streamType == StreamType.live,
onNext: onNext,
onPrevious: onPrevious,
),
),
// Use the premium PlayerScreen which handles IFrames and custom overlays
// This removes the redundant AppBar and fixes full-screen/controls issues
return PlayerScreen(
key: ValueKey('player-$streamId'),
streamId: streamId,
title: title,
playlist: playlist,
streamType: streamType,
containerExtension: containerExtension,
channels: channels,
startTime: startTime,
);
}
}
+9
View File
@@ -432,6 +432,15 @@
video.muted = vol === 0;
}
break;
case 'request_fullscreen':
if (video.requestFullscreen) {
video.requestFullscreen();
} else if (video.webkitRequestFullscreen) {
video.webkitRequestFullscreen();
} else if (video.msRequestFullscreen) {
video.msRequestFullscreen();
}
break;
}
});
+8
View File
@@ -499,6 +499,14 @@
if (isFinite(video.duration)) {
video.currentTime = parseFloat(data.value);
}
} else if (data.type === 'request_fullscreen') {
if (video.requestFullscreen) {
video.requestFullscreen();
} else if (video.webkitRequestFullscreen) {
video.webkitRequestFullscreen();
} else if (video.msRequestFullscreen) {
video.msRequestFullscreen();
}
}
});