feat: add mobile live TV tab with channel listing, search, category filtering, and favorites display.

This commit is contained in:
Michael committed 2025-12-13 10:03:58 +01:00
1 parent bc615c1681
commit dd95ceb893
3 files changed
+201 -84

No files matched your search

+135 -70
View File
@@ -143,36 +143,89 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
setState(() => _isFullscreen = !_isFullscreen);
}
Future<void> _initializePlayer({double? startTimeOverride}) async {
// ... (Keep existing initialization logic, effectively unchanged)
// Generating ViewFactory...
setState(() {
_isLoading = true;
_statusMessage = 'Loading...';
});
// Simulate init for UI demo purposes (User already has logic)
// in real implementation, copy full logic from previous file or assume context.
_viewId = 'iptv-player-${widget.streamId}-${DateTime.now().millisecondsSinceEpoch}';
// ... Logic to build URL ...
final settings = ref.read(iptvSettingsProvider);
final baseUrl = html.window.location.origin;
// ... (Simplify for this rewrite to focus on UI)
ui_web.platformViewRegistry.registerViewFactory(_viewId, (int viewId) {
final iframe = html.IFrameElement()
..id = _viewId
..src = 'player.html' // simplified
..style.border = 'none'
..allow = 'autoplay; fullscreen; picture-in-picture';
return iframe;
});
void _nextChannel() {
if (widget.channels == null || widget.channels!.isEmpty) return;
setState(() {
_currentIndex = (_currentIndex + 1) % widget.channels!.length;
_isInitialized = false; // Trigger reload
});
// Update player for new stream
// We need to re-initialize with the new stream ID from the list
_initializePlayer(isChannelSwitch: true);
}
setState(() {
_isInitialized = true;
_isLoading = false;
});
void _previousChannel() {
if (widget.channels == null || widget.channels!.isEmpty) return;
setState(() {
_currentIndex = (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length;
_isInitialized = false;
});
_initializePlayer(isChannelSwitch: true);
}
Future<void> _initializePlayer({double? startTimeOverride, bool isChannelSwitch = false}) async {
setState(() {
_isLoading = true;
_statusMessage = 'Loading...';
});
try {
// Determine Stream ID (Initial or Current Index)
final currentStreamId = isChannelSwitch && widget.channels != null
? widget.channels![_currentIndex].streamId
: widget.streamId;
_viewId = 'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}';
final service = ref.read(xtreamServiceProvider(widget.playlist));
String streamUrl = '';
// Generate Stream URL based on type
if (widget.streamType == StreamType.live) {
streamUrl = service.getLiveStreamUrl(currentStreamId);
} else if (widget.streamType == StreamType.vod) {
streamUrl = service.getVodStreamUrl(currentStreamId, widget.containerExtension);
} else if (widget.streamType == StreamType.series) {
streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension);
}
final encodedUrl = Uri.encodeComponent(streamUrl);
var playerSrc = 'player.html?url=$encodedUrl';
if (startTimeOverride != null) {
playerSrc += '&t=$startTimeOverride';
}
// Register View Factory
ui_web.platformViewRegistry.registerViewFactory(_viewId, (int viewId) {
final iframe = html.IFrameElement()
..id = _viewId
..src = playerSrc
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..allow = 'autoplay; fullscreen; picture-in-picture; encrypted-media';
return iframe;
});
if (mounted) {
setState(() {
_isInitialized = true;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Failed to load stream: $e';
_isLoading = false;
});
}
}
}
@override
@@ -192,7 +245,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: MouseRegion(
onHover: (_) => _onHover(),
child: GestureDetector(
onTap: () => setState(() => _showControls = !_showControls),
onTap: () {
if (_showControls) {
setState(() => _showControls = false);
} else {
_onHover(); // Shows controls and starts timer
}
},
behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent),
),
@@ -278,12 +337,25 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
),
),
// EPG Overlay (Moved out of controls box)
if (widget.streamType == StreamType.live)
Positioned(
bottom: 140, // Positioned above the controls bar
left: 40,
child: EpgOverlay(
streamId: widget.channels != null
? widget.channels![_currentIndex].streamId
: widget.streamId,
playlist: widget.playlist,
),
),
// Bottom Control Bar
Positioned(
bottom: 40,
left: 40,
right: 40,
child: GlassContainer(
child: GlassContainer(
borderRadius: 24,
opacity: 0.8,
padding: const EdgeInsets.all(24),
@@ -292,53 +364,46 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [
// Progress Bar (if not live)
if (widget.streamType != StreamType.live)
Row(
children: [
Text(_formatDuration(Duration(seconds: _currentPosition.toInt())), style: const TextStyle(color: Colors.white70)),
const SizedBox(width: 16),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
activeTrackColor: AppColors.primary,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
),
child: Slider(
value: _currentPosition,
min: 0,
max: _totalDuration,
onChanged: (v) {
// Seek logic
_sendMessage({'type': 'seek', 'value': v});
},
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Text(_formatDuration(Duration(seconds: _currentPosition.toInt())), style: const TextStyle(color: Colors.white70)),
const SizedBox(width: 16),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
activeTrackColor: AppColors.primary,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
),
child: Slider(
value: _currentPosition,
min: 0,
max: _totalDuration,
onChanged: (v) {
_sendMessage({'type': 'seek', 'value': v});
},
),
),
),
),
const SizedBox(width: 16),
Text(_formatDuration(Duration(seconds: _totalDuration.toInt())), style: const TextStyle(color: Colors.white70)),
],
const SizedBox(width: 16),
Text(_formatDuration(Duration(seconds: _totalDuration.toInt())), style: const TextStyle(color: Colors.white70)),
],
),
),
const SizedBox(height: 16),
// Controls Row
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildTvControl(Icons.skip_previous_rounded, () {}), // Prev
const SizedBox(width: 24),
_buildTvControl(Icons.replay_10_rounded, () {
_sendMessage({'type': 'seek', 'value': _currentPosition - 10});
}),
const SizedBox(width: 24),
_buildTvControl(Icons.forward_10_rounded, () {
_sendMessage({'type': 'seek', 'value': _currentPosition + 10});
}),
const SizedBox(width: 24),
_buildTvControl(Icons.skip_next_rounded, () {}), // Next
_buildTvControl(Icons.skip_previous_rounded, _previousChannel),
const SizedBox(width: 32), // Increased spacing
_buildTvControl(Icons.skip_next_rounded, _nextChannel),
const Spacer(),
_buildTvControl(Icons.subtitles_rounded, () {}),
const SizedBox(width: 16),
+23 -3
View File
@@ -368,9 +368,29 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'Channel #${channel.num}',
style: GoogleFonts.inter(color: Colors.white54, fontSize: 10),
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', style: GoogleFonts.inter(color: Colors.white54, fontSize: 10));
}
return Text(
epgList.first.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)),
);
}
),
],
),
@@ -122,7 +122,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.white.withOpacity(0.1),
color: isSelected ? Colors.white : Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(20),
),
child: Text(
@@ -199,6 +199,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
final channel = displayedChannels[index];
return _MobileChannelTile(
channel: channel,
playlist: widget.playlist,
onTap: () => _playChannel(context, channel),
);
},
@@ -226,21 +227,31 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
}
class _MobileChannelTile extends StatelessWidget {
class _MobileChannelTile extends ConsumerWidget {
final Channel channel;
final PlaylistConfig playlist;
final VoidCallback onTap;
const _MobileChannelTile({required this.channel, required this.onTap});
const _MobileChannelTile({
required this.channel,
required this.playlist,
required this.onTap,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
: null;
// Fetch EPG
final epgAsync = ref.watch(epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: channel.streamId)
));
return GlassContainer(
borderRadius: 12,
opacity: 0.15, // Slightly more visible
opacity: 0.15,
padding: EdgeInsets.zero,
child: InkWell(
onTap: onTap,
@@ -280,12 +291,33 @@ class _MobileChannelTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'#${channel.num}',
style: GoogleFonts.inter(
color: Colors.white38,
fontSize: 12,
),
// EPG Only (Replaces Channel Number)
epgAsync.when(
data: (epgList) {
if (epgList.isEmpty) {
return Text(
'No Info',
style: GoogleFonts.inter(
color: Colors.white38,
fontSize: 12,
),
);
}
final current = epgList.first;
return Text(
current.title,
style: GoogleFonts.inter(
color: const Color(0xFFFFD700), // Gold/Amber for visibility check
fontSize: 12,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
loading: () => Text('...', style: TextStyle(color: Colors.white38, fontSize: 12)),
error: (err, stack) => Text('Err', style: TextStyle(color: Colors.red, fontSize: 10)),
),
],
),