feat: introduce IPTV data models and initial Live TV UI, including EPG overlay.

This commit is contained in:
Michael committed 2026-03-09 10:12:55 +01:00
1 parent 37945b513e
commit fa88746db6
5 files changed
+212 -180

No files matched your search

+4 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../providers/xtream_provider.dart';
/// Minimal EPG overlay - shows program title and info
@@ -32,8 +33,9 @@ class EpgOverlay extends ConsumerWidget {
final now = DateTime.now();
final currentProgram = epgEntries.firstWhere(
(entry) {
final start = DateTime.parse(entry.start);
final end = DateTime.parse(entry.end);
final start = EpgEntry.parseDateTime(entry.start);
final end = EpgEntry.parseDateTime(entry.end);
if (start == null || end == null) return false;
return now.isAfter(start) && now.isBefore(end);
},
orElse: () => epgEntries.first,
+112 -108
View File
@@ -73,7 +73,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
}
categories.sort();
// REMOVED: categories.sort(); - Respect API/Settings order
List<Channel> displayedChannels = [];
bool showingCategoryGrid = false;
@@ -387,125 +387,129 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Icon Area
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white
.withOpacity(0.05), // Subtle transparent background
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
padding: const EdgeInsets.all(16),
child: Center(
child: channel.streamIcon.isNotEmpty
? Image.network(
_getProxiedIconUrl(channel.streamIcon)!,
errorBuilder: (_, __, ___) => Icon(
Icons.tv,
color: Colors.white.withOpacity(0.3),
size: 40,
),
)
: Text(
channel.name.characters.first.toUpperCase(),
style: GoogleFonts.outfit(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Colors.white24,
),
),
),
),
),
// Footer
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: Colors.black54,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
channel.name,
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
decoration: BoxDecoration(
color: Colors.white.withOpacity(
0.05), // Subtle transparent background
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12)),
),
padding: const EdgeInsets.all(16),
child: Center(
child: channel.streamIcon.isNotEmpty
? Image.network(
_getProxiedIconUrl(channel.streamIcon)!,
errorBuilder: (_, __, ___) => Icon(
Icons.tv,
color: Colors.white.withOpacity(0.3),
size: 40,
),
)
: Text(
channel.name.characters.first.toUpperCase(),
style: GoogleFonts.outfit(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Colors.white24,
),
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Consumer(
builder: (context, ref, _) {
final epgAsync = ref.watch(
epgByPlaylistProvider(
EpgRequestKey(
playlist: widget.playlist,
streamId: channel.streamId,
),
),
// Footer
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
color: Colors.black54,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
channel.name,
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
);
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Consumer(
builder: (context, ref, _) {
final epgAsync = ref.watch(
epgByPlaylistProvider(
EpgRequestKey(
playlist: widget.playlist,
streamId: channel.streamId,
),
),
);
return epgAsync.when(
data: (epgList) {
if (epgList.isEmpty) {
return Text(
'No Info',
return epgAsync.when(
data: (epgList) {
if (epgList.isEmpty) {
return Text(
'No Info',
style: GoogleFonts.inter(
color: Colors.white54,
fontSize: 10,
),
);
}
final now = DateTime.now();
final currentProgram = epgList.firstWhere(
(entry) {
try {
final start =
EpgEntry.parseDateTime(entry.start);
final end =
EpgEntry.parseDateTime(entry.end);
if (start == null || end == null)
return false;
return now.isAfter(start) &&
now.isBefore(end);
} catch (e) {
return false;
}
},
orElse: () => epgList.first,
);
return Text(
currentProgram.title,
style: GoogleFonts.inter(
color: const Color(0xFFFFD700),
fontSize: 10,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
loading: () => Text(
'...',
style: GoogleFonts.inter(
color: Colors.white54,
color: Colors.white24,
fontSize: 10,
),
),
error: (_, __) => Text(
'Err',
style: GoogleFonts.inter(
color: Colors.red,
fontSize: 10,
),
);
}
final now = DateTime.now();
final currentProgram = epgList.firstWhere(
(entry) {
try {
final start = DateTime.parse(entry.start);
final end = DateTime.parse(entry.end);
return now.isAfter(start) &&
now.isBefore(end);
} catch (e) {
return false;
}
},
orElse: () => epgList.first,
);
return Text(
currentProgram.title,
style: GoogleFonts.inter(
color: const Color(0xFFFFD700),
fontSize: 10,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
loading: () => Text(
'...',
style: GoogleFonts.inter(
color: Colors.white24,
fontSize: 10,
),
),
error: (_, __) => Text(
'Err',
style: GoogleFonts.inter(
color: Colors.red,
fontSize: 10,
),
),
);
},
),
],
),
],
),
),
],
),
],
),
// Record Button Icon Overlay (Positioned top right)
Positioned(
// Record Button Icon Overlay (Positioned top right)
Positioned(
top: 8,
right: 8,
child: TvFocusableCard(