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 bb1a9ec28f
commit d0dda60f8c
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); setState(() => _isFullscreen = !_isFullscreen);
} }
Future<void> _initializePlayer({double? startTimeOverride}) async { void _nextChannel() {
// ... (Keep existing initialization logic, effectively unchanged) if (widget.channels == null || widget.channels!.isEmpty) return;
// Generating ViewFactory...
setState(() { setState(() {
_isLoading = true; _currentIndex = (_currentIndex + 1) % widget.channels!.length;
_statusMessage = 'Loading...'; _isInitialized = false; // Trigger reload
}); });
// Simulate init for UI demo purposes (User already has logic) // Update player for new stream
// in real implementation, copy full logic from previous file or assume context. // We need to re-initialize with the new stream ID from the list
_viewId = 'iptv-player-${widget.streamId}-${DateTime.now().millisecondsSinceEpoch}'; _initializePlayer(isChannelSwitch: true);
}
// ... 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;
});
setState(() { void _previousChannel() {
_isInitialized = true; if (widget.channels == null || widget.channels!.isEmpty) return;
_isLoading = false;
}); 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 @override
@@ -192,7 +245,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: MouseRegion( child: MouseRegion(
onHover: (_) => _onHover(), onHover: (_) => _onHover(),
child: GestureDetector( child: GestureDetector(
onTap: () => setState(() => _showControls = !_showControls), onTap: () {
if (_showControls) {
setState(() => _showControls = false);
} else {
_onHover(); // Shows controls and starts timer
}
},
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent), 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 // Bottom Control Bar
Positioned( Positioned(
bottom: 40, bottom: 40,
left: 40, left: 40,
right: 40, right: 40,
child: GlassContainer( child: GlassContainer(
borderRadius: 24, borderRadius: 24,
opacity: 0.8, opacity: 0.8,
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -292,53 +364,46 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [ children: [
// Progress Bar (if not live) // Progress Bar (if not live)
if (widget.streamType != StreamType.live) if (widget.streamType != StreamType.live)
Row( Padding(
children: [ padding: const EdgeInsets.only(bottom: 16),
Text(_formatDuration(Duration(seconds: _currentPosition.toInt())), style: const TextStyle(color: Colors.white70)), child: Row(
const SizedBox(width: 16), children: [
Expanded( Text(_formatDuration(Duration(seconds: _currentPosition.toInt())), style: const TextStyle(color: Colors.white70)),
child: SliderTheme( const SizedBox(width: 16),
data: SliderThemeData( Expanded(
trackHeight: 4, child: SliderTheme(
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), data: SliderThemeData(
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), trackHeight: 4,
activeTrackColor: AppColors.primary, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
inactiveTrackColor: Colors.white24, overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
thumbColor: Colors.white, activeTrackColor: AppColors.primary,
), inactiveTrackColor: Colors.white24,
child: Slider( thumbColor: Colors.white,
value: _currentPosition, ),
min: 0, child: Slider(
max: _totalDuration, value: _currentPosition,
onChanged: (v) { min: 0,
// Seek logic max: _totalDuration,
_sendMessage({'type': 'seek', 'value': v}); onChanged: (v) {
}, _sendMessage({'type': 'seek', 'value': v});
},
),
), ),
), ),
), const SizedBox(width: 16),
const SizedBox(width: 16), Text(_formatDuration(Duration(seconds: _totalDuration.toInt())), style: const TextStyle(color: Colors.white70)),
Text(_formatDuration(Duration(seconds: _totalDuration.toInt())), style: const TextStyle(color: Colors.white70)), ],
], ),
), ),
const SizedBox(height: 16),
// Controls Row // Controls Row
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
_buildTvControl(Icons.skip_previous_rounded, () {}), // Prev _buildTvControl(Icons.skip_previous_rounded, _previousChannel),
const SizedBox(width: 24), const SizedBox(width: 32), // Increased spacing
_buildTvControl(Icons.replay_10_rounded, () { _buildTvControl(Icons.skip_next_rounded, _nextChannel),
_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
const Spacer(), const Spacer(),
_buildTvControl(Icons.subtitles_rounded, () {}), _buildTvControl(Icons.subtitles_rounded, () {}),
const SizedBox(width: 16), const SizedBox(width: 16),
+23 -3
View File
@@ -368,9 +368,29 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text( const SizedBox(height: 4),
'Channel #${channel.num}', Consumer(
style: GoogleFonts.inter(color: Colors.white54, fontSize: 10), 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), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.white.withOpacity(0.1), color: isSelected ? Colors.white : Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
@@ -199,6 +199,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
final channel = displayedChannels[index]; final channel = displayedChannels[index];
return _MobileChannelTile( return _MobileChannelTile(
channel: channel, channel: channel,
playlist: widget.playlist,
onTap: () => _playChannel(context, channel), 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 Channel channel;
final PlaylistConfig playlist;
final VoidCallback onTap; final VoidCallback onTap;
const _MobileChannelTile({required this.channel, required this.onTap}); const _MobileChannelTile({
required this.channel,
required this.playlist,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http') final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}' ? '/api/xtream/${channel.streamIcon}'
: null; : null;
// Fetch EPG
final epgAsync = ref.watch(epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: channel.streamId)
));
return GlassContainer( return GlassContainer(
borderRadius: 12, borderRadius: 12,
opacity: 0.15, // Slightly more visible opacity: 0.15,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
@@ -280,12 +291,33 @@ class _MobileChannelTile extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text(
'#${channel.num}', // EPG Only (Replaces Channel Number)
style: GoogleFonts.inter( epgAsync.when(
color: Colors.white38, data: (epgList) {
fontSize: 12, 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)),
), ),
], ],
), ),