feat: implement mobile live TV tab with channel browsing, search, and a dedicated player screen supporting channel zapping.

This commit is contained in:
Michael committed 2026-03-09 14:32:41 +01:00
1 parent 12b799a7fd
commit 4d1e08148b
4 files changed
+136 -32

No files matched your search

+61 -25
View File
@@ -5,6 +5,7 @@ import 'package:chewie/chewie.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async'; import 'dart:async';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
class LitePlayerView extends ConsumerStatefulWidget { class LitePlayerView extends ConsumerStatefulWidget {
final String streamUrl; final String streamUrl;
@@ -66,10 +67,14 @@ class _LitePlayerViewState extends ConsumerState<LitePlayerView> {
bufferedColor: Colors.white54, bufferedColor: Colors.white54,
), ),
// Fullscreen configuration for Mobile // Fullscreen configuration for Web/Mobile
allowFullScreen: true, allowFullScreen: true,
allowMuting: true, allowMuting: true,
showControls: 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: [ deviceOrientationsOnEnterFullScreen: [
DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight, DeviceOrientation.landscapeRight,
@@ -77,8 +82,6 @@ class _LitePlayerViewState extends ConsumerState<LitePlayerView> {
deviceOrientationsAfterFullScreen: [ deviceOrientationsAfterFullScreen: [
DeviceOrientation.portraitUp, DeviceOrientation.portraitUp,
], ],
systemOverlaysOnEnterFullScreen: [], // Full immersive
systemOverlaysAfterFullScreen: SystemUiOverlay.values,
// Custom Controls to include Zapping // Custom Controls to include Zapping
additionalOptions: (context) { additionalOptions: (context) {
@@ -158,35 +161,68 @@ class _LitePlayerViewState extends ConsumerState<LitePlayerView> {
children: [ children: [
Chewie(controller: _chewieController!), Chewie(controller: _chewieController!),
// Overlay Zapping Controls for easy access (since Chewie hides controls) // Right-Side Overlays (Zapping + Fullscreen)
// Hide them during fullscreen to avoid clutter Positioned(
if ((widget.onPrevious != null || widget.onNext != null) && right: 16,
!(_chewieController?.isFullScreen ?? false)) top: 0,
Positioned( bottom: 0,
bottom: 20, child: Center(
right: 80, child: Column(
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.onPrevious != null) ...[ // Fullscreen Toggle
IconButton( IconButton(
onPressed: widget.onPrevious, onPressed: () {
icon: const Icon(Icons.navigate_before, if (_chewieController?.isFullScreen == true) {
color: Colors.white, size: 32), _chewieController?.exitFullScreen();
tooltip: 'Previous Channel', } 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), ),
], const SizedBox(height: 24),
if (widget.onNext != null)
IconButton( // Zapping Controls
onPressed: widget.onNext, if (widget.onPrevious != null || widget.onNext != null)
icon: const Icon(Icons.navigate_next, GlassContainer(
color: Colors.white, size: 32), borderRadius: 16,
tooltip: 'Next Channel', 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',
),
],
),
), ),
], ],
), ),
), ),
),
], ],
); );
} }
@@ -12,6 +12,7 @@ class MobilePlayerScreen extends ConsumerWidget {
final StreamType streamType; final StreamType streamType;
final String containerExtension; final String containerExtension;
final PlaylistConfig playlist; final PlaylistConfig playlist;
final List<dynamic>? channels; // New: List of channels for zapping
const MobilePlayerScreen({ const MobilePlayerScreen({
super.key, super.key,
@@ -20,6 +21,7 @@ class MobilePlayerScreen extends ConsumerWidget {
required this.playlist, required this.playlist,
this.streamType = StreamType.live, this.streamType = StreamType.live,
this.containerExtension = 'mp4', this.containerExtension = 'mp4',
this.channels,
}); });
@override @override
@@ -35,12 +37,72 @@ class MobilePlayerScreen extends ConsumerWidget {
streamUrl = service.getSeriesStreamUrl(streamId, containerExtension); 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( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
title: Text(title, style: const TextStyle(color: Colors.white)), title: Text(title,
style: const TextStyle(color: Colors.white, fontSize: 16)),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -50,6 +112,8 @@ class MobilePlayerScreen extends ConsumerWidget {
child: LitePlayerView( child: LitePlayerView(
streamUrl: streamUrl, streamUrl: streamUrl,
isLive: streamType == StreamType.live, isLive: streamType == StreamType.live,
onNext: onNext,
onPrevious: onPrevious,
), ),
), ),
); );
@@ -245,7 +245,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
return _MobileChannelTile( return _MobileChannelTile(
channel: channel, channel: channel,
playlist: widget.playlist, playlist: widget.playlist,
onTap: () => _playChannel(context, channel), onTap: () => _playChannel(
context, channel, displayedChannels),
); );
}, },
), ),
@@ -258,7 +259,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
); );
} }
void _playChannel(BuildContext context, Channel channel) { void _playChannel(
BuildContext context, Channel channel, List<Channel> channels) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MobilePlayerScreen( builder: (context) => MobilePlayerScreen(
@@ -266,6 +268,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
title: channel.name, title: channel.name,
playlist: widget.playlist, playlist: widget.playlist,
streamType: StreamType.live, streamType: StreamType.live,
channels: channels,
), ),
), ),
); );
@@ -139,7 +139,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return originalUrl; return originalUrl;
} }
void _playMovie(Movie movie) { void _playMovie(Movie movie, List<Movie> movies) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push( Navigator.push(
@@ -151,6 +151,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
playlist: widget.playlist, playlist: widget.playlist,
streamType: StreamType.vod, streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4', containerExtension: movie.containerExtension ?? 'mp4',
channels: movies, // Pass list for zapping
), ),
), ),
); );
@@ -180,7 +181,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
title: m.name, title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon, ref), imageUrl: _getProxiedImageUrl(m.streamIcon, ref),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () => _playMovie(m), onMoreInfo: () => _playMovie(m, displayMovies),
), ),
) )
.toList(); .toList();
@@ -259,7 +260,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
onTap: (item) { onTap: (item) {
final movie = _movies final movie = _movies
.firstWhere((element) => element.streamId == item.id); .firstWhere((element) => element.streamId == item.id);
_playMovie(movie); _playMovie(movie, _movies);
}, },
), ),
), ),
@@ -283,7 +284,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
final isWatched = watchHistory.isMovieWatched(movie.streamId); final isWatched = watchHistory.isMovieWatched(movie.streamId);
return GestureDetector( return GestureDetector(
onTap: () => _playMovie(movie), onTap: () => _playMovie(movie, displayMovies),
onLongPress: () { onLongPress: () {
ref ref
.read(watchHistoryProvider.notifier) .read(watchHistoryProvider.notifier)