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 '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<LitePlayerView> {
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<LitePlayerView> {
deviceOrientationsAfterFullScreen: [
DeviceOrientation.portraitUp,
],
systemOverlaysOnEnterFullScreen: [], // Full immersive
systemOverlaysAfterFullScreen: SystemUiOverlay.values,
// Custom Controls to include Zapping
additionalOptions: (context) {
@@ -158,35 +161,68 @@ class _LitePlayerViewState extends ConsumerState<LitePlayerView> {
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',
),
],
),
),
],
),
),
),
],
);
}
@@ -12,6 +12,7 @@ class MobilePlayerScreen extends ConsumerWidget {
final StreamType streamType;
final String containerExtension;
final PlaylistConfig playlist;
final List<dynamic>? 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,
),
),
);
@@ -245,7 +245,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
return _MobileChannelTile(
channel: channel,
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(
MaterialPageRoute(
builder: (context) => MobilePlayerScreen(
@@ -266,6 +268,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
title: channel.name,
playlist: widget.playlist,
streamType: StreamType.live,
channels: channels,
),
),
);
@@ -139,7 +139,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return originalUrl;
}
void _playMovie(Movie movie) {
void _playMovie(Movie movie, List<Movie> movies) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push(
@@ -151,6 +151,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
playlist: widget.playlist,
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
channels: movies, // Pass list for zapping
),
),
);
@@ -180,7 +181,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
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<MobileMoviesTab> {
onTap: (item) {
final movie = _movies
.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);
return GestureDetector(
onTap: () => _playMovie(movie),
onTap: () => _playMovie(movie, displayMovies),
onLongPress: () {
ref
.read(watchHistoryProvider.notifier)