feat: Implement MobileLiveTVTab for displaying live IPTV channels with search, categories, and favorites.

This commit is contained in:
Michael committed 2025-12-11 23:02:14 +01:00
1 parent c2961ef7b9
commit 57ced00738
4 files changed
+109 -74

No files matched your search

@@ -36,8 +36,20 @@ class XtreamServiceMobile {
_cacheOptions = CacheOptions( _cacheOptions = CacheOptions(
store: _cacheStore, store: _cacheStore,
policy: CachePolicy.forceCache, policy: CachePolicy.forceCache,
maxStale: const Duration(hours: 24), // Cache for 24h as requested maxStale: const Duration(hours: 24),
priority: CachePriority.high, priority: CachePriority.high,
keyBuilder: (request) {
// Use the Original Host header if available to generate a stable cache key
// regardless of the resolved IP address used for the connection
if (request.headers.containsKey('Host')) {
final host = request.headers['Host'] as String;
final uri = request.uri;
// Reconstruct URI with the original hostname
final stableUri = uri.replace(host: host);
return stableUri.toString();
}
return request.uri.toString();
},
); );
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
@@ -343,13 +343,18 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with Automati
); );
} }
return ListView.separated( return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
childAspectRatio: 0.85,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: channels.length, itemCount: channels.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final channel = channels[index]; final channel = channels[index];
return _MobileChannelTile( return _MobileChannelCard(
channel: channel, channel: channel,
playlist: widget.playlist, playlist: widget.playlist,
onTap: () => _playChannel(context, channel, channels, widget.playlist, index), onTap: () => _playChannel(context, channel, channels, widget.playlist, index),
@@ -398,22 +403,22 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> with Automati
} }
} }
class _MobileChannelTile extends ConsumerStatefulWidget { class _MobileChannelCard extends ConsumerStatefulWidget {
final Channel channel; final Channel channel;
final VoidCallback onTap; final VoidCallback onTap;
final PlaylistConfig playlist; final PlaylistConfig playlist;
const _MobileChannelTile({ const _MobileChannelCard({
required this.channel, required this.channel,
required this.onTap, required this.onTap,
required this.playlist, required this.playlist,
}); });
@override @override
ConsumerState<_MobileChannelTile> createState() => _MobileChannelTileState(); ConsumerState<_MobileChannelCard> createState() => _MobileChannelCardState();
} }
class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
String? _epgTitle; String? _epgTitle;
@override @override
@@ -425,7 +430,6 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> {
Future<void> _loadEpg() async { Future<void> _loadEpg() async {
try { try {
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
// Fetch current program
final epg = await service.getShortEPG(widget.channel.streamId); final epg = await service.getShortEPG(widget.channel.streamId);
if (mounted && epg.nowPlaying != null && epg.nowPlaying!.isNotEmpty) { if (mounted && epg.nowPlaying != null && epg.nowPlaying!.isNotEmpty) {
setState(() { setState(() {
@@ -439,12 +443,10 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// On mobile, use direct URL (no proxy needed)
final iconUrl = widget.channel.streamIcon.isNotEmpty && widget.channel.streamIcon.startsWith('http') final iconUrl = widget.channel.streamIcon.isNotEmpty && widget.channel.streamIcon.startsWith('http')
? widget.channel.streamIcon ? widget.channel.streamIcon
: null; : null;
// Watch favorites state
final favorites = ref.watch(mobileFavoritesProvider); final favorites = ref.watch(mobileFavoritesProvider);
final isFavorite = favorites.contains(widget.channel.streamId); final isFavorite = favorites.contains(widget.channel.streamId);
@@ -455,71 +457,89 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> {
}, },
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Icon // Channel Logo
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: iconUrl != null
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.contain,
errorWidget: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24),
placeholder: (_, __) => const Icon(Icons.tv, color: Colors.white24),
)
: const Icon(Icons.tv, color: Colors.white24),
),
const SizedBox(width: 12),
// Name and EPG
Expanded( Expanded(
child: Column( flex: 3,
crossAxisAlignment: CrossAxisAlignment.start, child: Container(
mainAxisSize: MainAxisSize.min, width: double.infinity,
children: [ decoration: BoxDecoration(
Text( color: Colors.black26,
widget.channel.name, borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
style: const TextStyle( ),
color: AppColors.textPrimary, child: Stack(
fontWeight: FontWeight.w500, children: [
fontSize: 14, // Logo
Center(
child: iconUrl != null
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.contain,
errorWidget: (_, __, ___) => const Icon(Icons.tv, color: Colors.white38, size: 40),
placeholder: (_, __) => const Icon(Icons.tv, color: Colors.white24, size: 40),
)
: const Icon(Icons.tv, color: Colors.white38, size: 40),
), ),
maxLines: 1, // Favorite badge
overflow: TextOverflow.ellipsis, if (isFavorite)
), Positioned(
if (_epgTitle != null) top: 4,
Padding( right: 4,
padding: const EdgeInsets.only(top: 4), child: Container(
child: Text( padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.favorite, color: Colors.white, size: 12),
),
),
],
),
),
),
// Channel Name
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.channel.name,
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (_epgTitle != null) ...[
const SizedBox(height: 2),
Text(
_epgTitle!, _epgTitle!,
style: const TextStyle( style: const TextStyle(
color: AppColors.textSecondary, color: AppColors.textSecondary,
fontSize: 12, fontSize: 9,
), ),
textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ],
], ],
),
), ),
), ),
// Favorite Indicator (no button, just icon)
if (isFavorite)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.favorite, color: Colors.red, size: 20),
),
// Play Icon
const Icon(Icons.play_circle_outline, color: AppColors.primary),
], ],
), ),
), ),
@@ -96,7 +96,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
), ),
), ),
Text( Text(
'Version 1.2.0', 'Version 1.2.3',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 13, fontSize: 13,
color: AppColors.textSecondary, color: AppColors.textSecondary,
+15 -12
View File
@@ -116,10 +116,9 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Desktop style: selected = white bg + black text, unselected = white70 text // Selected = white bg + black text, Focused = gray border + white text, Normal = white70 text
final bool showWhiteBg = widget.isSelected || _isFocused; final Color textColor = widget.isSelected ? Colors.black : (_isFocused ? Colors.white : Colors.white70);
final Color textColor = showWhiteBg ? Colors.black : Colors.white70; final Color iconColor = widget.isSelected ? Colors.black : (_isFocused ? Colors.white : Colors.white70);
final Color iconColor = showWhiteBg ? Colors.black : Colors.white70;
return Focus( return Focus(
focusNode: _focusNode, focusNode: _focusNode,
@@ -142,16 +141,20 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
// White pill background when selected OR focused // White pill background when selected, transparent otherwise
color: showWhiteBg ? Colors.white : Colors.transparent, color: widget.isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(25),
// Subtle glow when focused but not selected // White border when focused
boxShadow: _isFocused && !widget.isSelected border: _isFocused
? Border.all(color: Colors.white70, width: 2)
: null,
// Gray shadow when focused (for both selected and not selected)
boxShadow: _isFocused
? [ ? [
BoxShadow( BoxShadow(
color: Colors.white.withOpacity(0.3), color: Colors.white.withOpacity(0.4),
blurRadius: 8, blurRadius: 12,
spreadRadius: 1, spreadRadius: 2,
), ),
] ]
: null, : null,
@@ -165,7 +168,7 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
widget.label, widget.label,
style: TextStyle( style: TextStyle(
color: textColor, color: textColor,
fontWeight: showWhiteBg ? FontWeight.w700 : FontWeight.w400, fontWeight: widget.isSelected ? FontWeight.w700 : FontWeight.w400,
fontSize: 13, fontSize: 13,
), ),
), ),