mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-24 15:48:14 +02:00
feat: Implement MobileLiveTVTab for displaying live IPTV channels with search, categories, and favorites.
This commit is contained in:
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user