From 4d1e08148bbe54051ad82763427d5463ce867b3c Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 9 Mar 2026 14:32:41 +0100 Subject: [PATCH] feat: implement mobile live TV tab with channel browsing, search, and a dedicated player screen supporting channel zapping. --- .../iptv/widgets/lite_player_view.dart | 86 +++++++++++++------ .../iptv/screens/mobile_player_screen.dart | 66 +++++++++++++- .../iptv/widgets/mobile_live_tv_tab.dart | 7 +- .../iptv/widgets/mobile_movies_tab.dart | 9 +- 4 files changed, 136 insertions(+), 32 deletions(-) diff --git a/lib/features/iptv/widgets/lite_player_view.dart b/lib/features/iptv/widgets/lite_player_view.dart index 3974c7d..14da889 100644 --- a/lib/features/iptv/widgets/lite_player_view.dart +++ b/lib/features/iptv/widgets/lite_player_view.dart @@ -5,6 +5,7 @@ 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; @@ -66,10 +67,14 @@ class _LitePlayerViewState extends ConsumerState { bufferedColor: Colors.white54, ), - // Fullscreen configuration for Mobile + // Fullscreen configuration for Web/Mobile allowFullScreen: true, allowMuting: true, showControls: true, + + // 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, @@ -77,8 +82,6 @@ class _LitePlayerViewState extends ConsumerState { deviceOrientationsAfterFullScreen: [ DeviceOrientation.portraitUp, ], - systemOverlaysOnEnterFullScreen: [], // Full immersive - systemOverlaysAfterFullScreen: SystemUiOverlay.values, // Custom Controls to include Zapping additionalOptions: (context) { @@ -158,35 +161,68 @@ class _LitePlayerViewState extends ConsumerState { children: [ Chewie(controller: _chewieController!), - // Overlay Zapping Controls for easy access (since Chewie hides controls) - // Hide them during fullscreen to avoid clutter - if ((widget.onPrevious != null || widget.onNext != null) && - !(_chewieController?.isFullScreen ?? false)) - Positioned( - bottom: 20, - right: 80, - child: Row( + // Right-Side Overlays (Zapping + Fullscreen) + Positioned( + right: 16, + top: 0, + bottom: 0, + child: Center( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (widget.onPrevious != null) ...[ - IconButton( - onPressed: widget.onPrevious, - icon: const Icon(Icons.navigate_before, - color: Colors.white, size: 32), - tooltip: 'Previous Channel', + // Fullscreen Toggle + IconButton( + onPressed: () { + if (_chewieController?.isFullScreen == true) { + _chewieController?.exitFullScreen(); + } else { + _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(width: 16), - ], - if (widget.onNext != null) - IconButton( - onPressed: widget.onNext, - icon: const Icon(Icons.navigate_next, - color: Colors.white, size: 32), - tooltip: 'Next Channel', + ), + 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', + ), + ], + ), ), ], ), ), + ), ], ); } diff --git a/lib/mobile/features/iptv/screens/mobile_player_screen.dart b/lib/mobile/features/iptv/screens/mobile_player_screen.dart index f2fc467..486422c 100644 --- a/lib/mobile/features/iptv/screens/mobile_player_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_player_screen.dart @@ -12,6 +12,7 @@ class MobilePlayerScreen extends ConsumerWidget { final StreamType streamType; final String containerExtension; final PlaylistConfig playlist; + final List? channels; // New: List of channels for zapping const MobilePlayerScreen({ super.key, @@ -20,6 +21,7 @@ class MobilePlayerScreen extends ConsumerWidget { required this.playlist, this.streamType = StreamType.live, this.containerExtension = 'mp4', + this.channels, }); @override @@ -35,12 +37,72 @@ class MobilePlayerScreen extends ConsumerWidget { 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)), + 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), @@ -50,6 +112,8 @@ class MobilePlayerScreen extends ConsumerWidget { child: LitePlayerView( streamUrl: streamUrl, isLive: streamType == StreamType.live, + onNext: onNext, + onPrevious: onPrevious, ), ), ); diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index 9fa8462..c75c5a4 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -245,7 +245,8 @@ class _MobileLiveTVTabState extends ConsumerState { return _MobileChannelTile( channel: channel, playlist: widget.playlist, - onTap: () => _playChannel(context, channel), + onTap: () => _playChannel( + context, channel, displayedChannels), ); }, ), @@ -258,7 +259,8 @@ class _MobileLiveTVTabState extends ConsumerState { ); } - void _playChannel(BuildContext context, Channel channel) { + void _playChannel( + BuildContext context, Channel channel, List channels) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => MobilePlayerScreen( @@ -266,6 +268,7 @@ class _MobileLiveTVTabState extends ConsumerState { title: channel.name, playlist: widget.playlist, streamType: StreamType.live, + channels: channels, ), ), ); diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index d803c0c..71b7400 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -139,7 +139,7 @@ class _MobileMoviesTabState extends ConsumerState { return originalUrl; } - void _playMovie(Movie movie) { + void _playMovie(Movie movie, List movies) { ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); Navigator.push( @@ -151,6 +151,7 @@ class _MobileMoviesTabState extends ConsumerState { playlist: widget.playlist, streamType: StreamType.vod, containerExtension: movie.containerExtension ?? 'mp4', + channels: movies, // Pass list for zapping ), ), ); @@ -180,7 +181,7 @@ class _MobileMoviesTabState extends ConsumerState { title: m.name, imageUrl: _getProxiedImageUrl(m.streamIcon, ref), subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, - onMoreInfo: () => _playMovie(m), + onMoreInfo: () => _playMovie(m, displayMovies), ), ) .toList(); @@ -259,7 +260,7 @@ class _MobileMoviesTabState extends ConsumerState { onTap: (item) { final movie = _movies .firstWhere((element) => element.streamId == item.id); - _playMovie(movie); + _playMovie(movie, _movies); }, ), ), @@ -283,7 +284,7 @@ class _MobileMoviesTabState extends ConsumerState { final isWatched = watchHistory.isMovieWatched(movie.streamId); return GestureDetector( - onTap: () => _playMovie(movie), + onTap: () => _playMovie(movie, displayMovies), onLongPress: () { ref .read(watchHistoryProvider.notifier)