From 2d6def1f915476a9385132caca18192da2370cff Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 6 Dec 2025 23:51:43 +0100 Subject: [PATCH] feat: add IPTV data models and application color palette --- lib/core/models/iptv_models.dart | 14 +++- lib/core/theme/app_colors.dart | 4 +- lib/features/iptv/widgets/channel_card.dart | 81 +++++++++++++++------ lib/features/iptv/widgets/live_tv_tab.dart | 2 + 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/lib/core/models/iptv_models.dart b/lib/core/models/iptv_models.dart index 5115c8f..86e7630 100644 --- a/lib/core/models/iptv_models.dart +++ b/lib/core/models/iptv_models.dart @@ -159,15 +159,25 @@ class EpgEntry { return EpgEntry( id: json['id']?.toString() ?? '', epgId: json['epg_id']?.toString() ?? '', - title: json['title']?.toString() ?? '', + title: _decodeBase64(json['title']?.toString()), lang: json['lang']?.toString() ?? '', start: json['start']?.toString() ?? '', end: json['end']?.toString() ?? '', - description: json['description']?.toString() ?? '', + description: _decodeBase64(json['description']?.toString()), channelId: json['channel_id']?.toString() ?? '', ); } + static String _decodeBase64(String? text) { + if (text == null || text.isEmpty) return ''; + try { + final bytes = base64Decode(text); + return utf8.decode(bytes, allowMalformed: true); + } catch (_) { + return text; + } + } + /// Calculate progress percentage for current program double getProgress() { try { diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 1f1c49a..fe056e1 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -15,10 +15,10 @@ class AppColors { static const Color background = Color(0xFF000000); /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey) - static const Color surface = Color(0xFF1C1C1E); + static const Color surface = Color(0xFF2C2C2E); /// Slightly lighter grey for secondary surfaces or hover states - static const Color surfaceVariant = Color(0xFF2C2C2E); + static const Color surfaceVariant = Color(0xFF3A3A3C); /// Focused element background (often white in tvOS for text, or bright accent) static const Color focusColor = Color(0xFFFFFFFF); diff --git a/lib/features/iptv/widgets/channel_card.dart b/lib/features/iptv/widgets/channel_card.dart index c19d37b..b639888 100644 --- a/lib/features/iptv/widgets/channel_card.dart +++ b/lib/features/iptv/widgets/channel_card.dart @@ -1,17 +1,8 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; -import '../providers/favorites_provider.dart'; +import '../../../core/models/playlist_config.dart'; +import '../../iptv/providers/xtream_provider.dart'; -/// Apple TV Style Channel Card -/// -/// "Pop-out" effect on focus: -/// - Scales up (1.0 -> 1.1) -/// - Throws a large soft shadow -/// - Adds a thick white border (Focus Ring) -/// - Title is typically BELOW the card in tvOS, but we'll overlay it subtly or keep below. class ChannelCard extends ConsumerStatefulWidget { final String streamId; final String name; @@ -21,11 +12,13 @@ class ChannelCard extends ConsumerStatefulWidget { final VoidCallback? onTap; final double? width; final double? height; + final PlaylistConfig playlist; const ChannelCard({ super.key, required this.streamId, required this.name, + required this.playlist, this.iconUrl, this.currentProgram, this.isLive = true, @@ -42,7 +35,11 @@ class _ChannelCardState extends ConsumerState with SingleTickerProviderStateMixin { bool _isHovered = false; late AnimationController _pulseController; - // late Animation _pulseAnimation; // Unused for now, focus is static + + // EPG State + String? _epgNow; + bool _epgLoaded = false; + @override void initState() { @@ -68,8 +65,54 @@ class _ChannelCardState extends ConsumerState // In valid tvOS, the layout reserves space, so we scale the content but not the layout bounds to avoid reflow. // However, simplest way here is just Transform.scale. + Future _fetchEpg() async { + if (_epgLoaded) return; + + try { + // Don't modify state if unmounted + if (!mounted) return; + + final service = ref.read(xtreamServiceProvider(widget.playlist)); + // We use the short EPG endpoint for quick access + final entries = await service.getShortEpg(widget.streamId); + + if (mounted && entries.isNotEmpty) { + final now = DateTime.now(); + // Find current program + final current = entries.firstWhere( + (e) { + try { + final start = DateTime.parse(e.start); + final end = DateTime.parse(e.end); + return now.isAfter(start) && now.isBefore(end); + } catch (_) { + return false; + } + }, + orElse: () => entries.first, + ); + + setState(() { + _epgNow = current.title; + _epgLoaded = true; + }); + } + } catch (_) { + // Fail silently + if (mounted) setState(() => _epgLoaded = true); + } + } + + @override + Widget build(BuildContext context) { + final favorites = ref.watch(favoritesProvider); + final isFavorite = favorites.contains(widget.streamId); + return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), + onEnter: (_) { + setState(() => _isHovered = true); + if (!_epgLoaded) _fetchEpg(); + }, onExit: (_) => setState(() => _isHovered = false), cursor: SystemMouseCursors.click, child: GestureDetector( @@ -120,15 +163,13 @@ class _ChannelCardState extends ConsumerState // 1. Background Image / Logo - EDGE TO EDGE _buildChannelImage(), - // 2. Subtle Gradient (Always visible to make white logos pop on white background? No, black bg handling) - // Only show gradient if we have text to show ON the card, but for tvOS text is often below. - // Let's keep text overlay for now as requested "Minimalist" often implies overlays. + // 2. Gradient Overlay (Subtle) if (_isHovered) Positioned.fill( child: Container( decoration: BoxDecoration( gradient: LinearGradient( - colors: [Colors.transparent, Colors.black.withOpacity(0.8)], + colors: [Colors.transparent, Colors.black.withOpacity(0.4)], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), @@ -174,8 +215,6 @@ class _ChannelCardState extends ConsumerState const SizedBox(height: 8), // Text separates from card (Classic tvOS look) - // Only show text prominently if hovered? Or always? - // "Minimalist" -> Always show but keep it subtle. White when focused. SizedBox( width: widget.width ?? 180, child: Column( @@ -193,10 +232,10 @@ class _ChannelCardState extends ConsumerState fontSize: 13, ), ), - if (widget.currentProgram != null && _isHovered) ...[ + if ((widget.currentProgram != null || _epgNow != null) && _isHovered) ...[ const SizedBox(height: 2), Text( - widget.currentProgram!, + _epgNow ?? widget.currentProgram ?? '', textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index b6deef8..60771ea 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -332,6 +332,7 @@ class _LiveTVTabState extends ConsumerState name: channel.name, iconUrl: _getProxiedIconUrl(channel.streamIcon), currentProgram: null, + playlist: widget.playlist, onTap: () => _playChannel(channel), ); }, @@ -354,6 +355,7 @@ class _LiveTVTabState extends ConsumerState iconUrl: _getProxiedIconUrl(channel.streamIcon), height: 70, width: double.infinity, + playlist: widget.playlist, onTap: () => _playChannel(channel), ), );