feat: Add initial IPTV support with a dedicated player, series detail screen, Xtream API service, and a streaming API handler.

This commit is contained in:
Michael committed 2025-12-15 22:09:21 +01:00
1 parent d1401711af
commit d6405f3471
4 files changed
+68 -15

No files matched your search

+27 -11
View File
@@ -149,10 +149,11 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
}
// Build Upstream URL (Try MKV first, then generic)
// Note: We don't know the extension here easily unless passed.
// Xtream usually allows access via streamId.mkv or streamId.mp4 regardless of actual file
// We'll trust the ID.
// Build Upstream URL based on content type
// Check for type parameter in request URL (?type=series or ?type=movie)
final contentType = request.url.queryParameters['type'] ?? 'movie';
final basePath = contentType == 'series' ? 'series' : 'movie';
// Only start FFmpeg if not already running
if (!_vodProcesses.containsKey(streamId)) {
// Prepare directory (Only for new session)
@@ -161,13 +162,23 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
}
streamDir.createSync(recursive: true);
final targetUrl = '${playlist.dns}/movie/${playlist.username}/${playlist.password}/$streamId.mkv';
print('[VOD] Starting Transcode: $targetUrl');
final targetUrl = '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv';
print('[VOD] Starting Transcode ($contentType): $targetUrl');
// Start FFmpeg
final ffmpegArgs = [
'-hide_banner', '-loglevel', 'error',
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
// Robustness for network streams
'-reconnect', '1',
'-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-rw_timeout', '15000000', // 15s timeout for read/write
'-analyzeduration', '10000000', // Increase probe duration
'-probesize', '10000000',
'-i', targetUrl,
// Video: H.264 Ultrafast (Low CPU)
@@ -182,9 +193,14 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
// Audio: AAC (Browser compatible, improved quality)
'-c:a', 'aac',
'-b:a', '192k', // Higher bitrate for better quality
'-ar', '48000', // Standard sample rate
'-ac', '2',
'-b:a', '192k',
'-ar', '48000',
// Advanced Audio Filtering:
// 1. "pan": Downmix 5.1/7.1 to Stereo with center mix (dialogue) boosted.
// 2. "dynaudnorm": Dynamic Audio Normalizer to boost quiet dialogue and limit loud explosions (Night Mode effect).
'-af', 'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15',
'-f', 'hls',
'-hls_time', '4',
@@ -215,10 +231,10 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
});
}
// Wait for playlist to appear (max 10s)
// Wait for playlist to appear (max 30s)
final playlistFile = File('${streamDir.path}/playlist.m3u8');
int retries = 0;
while (!playlistFile.existsSync() && retries < 20) {
while (!playlistFile.existsSync() && retries < 60) {
await Future.delayed(const Duration(milliseconds: 500));
retries++;
}
@@ -345,6 +345,22 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
overflow: TextOverflow.ellipsis,
),
),
// Mute button for Live TV
Material(
color: Colors.black54,
shape: const CircleBorder(),
child: InkWell(
onTap: _toggleMute,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
_isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
color: Colors.white
),
),
),
),
],
),
),
@@ -616,6 +632,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
],
const Spacer(),
// Volume/Mute button
_buildTvControl(
_isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
_toggleMute
),
const SizedBox(width: 16),
_buildTvControl(Icons.subtitles_rounded, () {}),
const SizedBox(width: 16),
_buildTvControl(Icons.aspect_ratio_rounded, _toggleFullscreen),
@@ -267,10 +267,25 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
color: Colors.grey.shade800,
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () {
onTap: () async {
// Mark as watched when playing
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
// Fetch actual duration from API if not available
Duration? episodeDuration;
if (episode.durationSecs != null && episode.durationSecs! > 0) {
episodeDuration = Duration(seconds: episode.durationSecs!);
} else {
// Try to fetch from API using getVodDuration (episodes are often queryable as VODs)
final service = ref.read(xtreamServiceProvider(widget.playlist));
final durationSecs = await service.getVodDuration(episode.id);
if (durationSecs != null && durationSecs > 0) {
episodeDuration = Duration(seconds: durationSecs);
}
}
if (!context.mounted) return;
Navigator.push(
context,
MaterialPageRoute(
@@ -280,7 +295,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
duration: episode.durationSecs != null ? Duration(seconds: episode.durationSecs!) : null,
duration: episodeDuration,
),
),
);
@@ -79,9 +79,9 @@ class XtreamService {
String getSeriesStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
// Point to new dedicated VOD API (HLS playlist)
// Point to new dedicated VOD API (HLS playlist) with series type
final baseUrl = html.window.location.origin;
return '$baseUrl/api/vod/$streamId/playlist.m3u8';
return '$baseUrl/api/vod/$streamId/playlist.m3u8?type=series';
}
/// Authenticate and get server info