feat: add mobile-optimized IPTV player with HLS and MpegTS support

This commit is contained in:
Michael committed 2026-03-29 10:19:36 +02:00
1 parent 3c9a9c7be7
commit fa6ae90d8d
38 files changed
+1280 -895

No files matched your search

+51 -47
View File
@@ -1,6 +1,7 @@
/// Application optimization configuration
///
///
/// This file contains performance tuning settings for XtremFlow
library;
class OptimizationConfig {
// ============================================
@@ -26,7 +27,8 @@ class OptimizationConfig {
static const bool enableHlsCompression = true;
/// FFmpeg preset for live transcoding
static const String ffmpegLivePreset = 'medium'; // ultrafast, fast, medium, slow
static const String ffmpegLivePreset =
'medium'; // ultrafast, fast, medium, slow
/// FFmpeg preset for VOD transcoding
static const String ffmpegVodPreset = 'medium';
@@ -175,64 +177,64 @@ class OptimizationConfig {
/// Get optimized image cache settings
static Map<String, dynamic> getImageCacheSettings() => {
'maxSize': maxImageCacheMb * 1024 * 1024,
'maxItems': maxCachedImages,
'enableAnimation': enableImageFadeAnimation,
'fadeAnimationMs': imageFadeAnimationMs,
};
'maxSize': maxImageCacheMb * 1024 * 1024,
'maxItems': maxCachedImages,
'enableAnimation': enableImageFadeAnimation,
'fadeAnimationMs': imageFadeAnimationMs,
};
/// Get streaming configuration
static Map<String, dynamic> getStreamingConfig() => {
'enableAdaptiveBitrate': enableAdaptiveBitrate,
'defaultQuality': defaultQualityResolution,
'maxBuffer': maxBufferSeconds,
'minBuffer': minBufferSeconds,
'targetBuffer': targetBufferSeconds,
'enableHlsCompression': enableHlsCompression,
'ffmpegPreset': ffmpegLivePreset,
'enableGpu': enableNvidiaGpu,
};
'enableAdaptiveBitrate': enableAdaptiveBitrate,
'defaultQuality': defaultQualityResolution,
'maxBuffer': maxBufferSeconds,
'minBuffer': minBufferSeconds,
'targetBuffer': targetBufferSeconds,
'enableHlsCompression': enableHlsCompression,
'ffmpegPreset': ffmpegLivePreset,
'enableGpu': enableNvidiaGpu,
};
/// Get network configuration
static Map<String, dynamic> getNetworkConfig() => {
'connectTimeout': connectionTimeoutSeconds,
'receiveTimeout': receiveTimeoutSeconds,
'enableAutoRetry': enableAutoRetry,
'maxRetries': maxRetryAttempts,
'enableGzip': enableGzipCompression,
'enableCache': enableNetworkCache,
'cacheRetentionDays': networkCacheRetentionDays,
};
'connectTimeout': connectionTimeoutSeconds,
'receiveTimeout': receiveTimeoutSeconds,
'enableAutoRetry': enableAutoRetry,
'maxRetries': maxRetryAttempts,
'enableGzip': enableGzipCompression,
'enableCache': enableNetworkCache,
'cacheRetentionDays': networkCacheRetentionDays,
};
/// Get UI performance settings
static Map<String, dynamic> getUiPerformanceSettings() => {
'itemsPerPage': itemsPerPage,
'liveItemsPerPage': liveItemsPerPage,
'enableLazyLoading': enableLazyLoading,
'enableSmoothScroll': enableSmoothScroll,
'maxTexturePoolSize': videoTexturePoolSize,
};
'itemsPerPage': itemsPerPage,
'liveItemsPerPage': liveItemsPerPage,
'enableLazyLoading': enableLazyLoading,
'enableSmoothScroll': enableSmoothScroll,
'maxTexturePoolSize': videoTexturePoolSize,
};
/// Get memory optimization settings
static Map<String, dynamic> getMemoryOptimizationSettings() => {
'enableProfiling': enableMemoryProfiling,
'profilingInterval': memoryProfilingIntervalSeconds,
'aggressiveCleanup': enableMemoryCleanupOnBackground,
'maxImageCacheMb': maxImageCacheMb,
'maxMemoryCacheMb': maxMemoryCacheMb,
};
'enableProfiling': enableMemoryProfiling,
'profilingInterval': memoryProfilingIntervalSeconds,
'aggressiveCleanup': enableMemoryCleanupOnBackground,
'maxImageCacheMb': maxImageCacheMb,
'maxMemoryCacheMb': maxMemoryCacheMb,
};
/// Get all enabled features
static List<String> getEnabledFeatures() => [
if (enableEpg) 'EPG',
if (enableOfflineDownloads) 'OfflineDownloads',
if (enableContinueWatching) 'ContinueWatching',
if (enableTrending) 'Trending',
if (enableQualitySelector) 'QualitySelector',
if (enableSubtitles) 'Subtitles',
if (enableWatchHistory) 'WatchHistory',
if (enableFavorites) 'Favorites',
];
if (enableEpg) 'EPG',
if (enableOfflineDownloads) 'OfflineDownloads',
if (enableContinueWatching) 'ContinueWatching',
if (enableTrending) 'Trending',
if (enableQualitySelector) 'QualitySelector',
if (enableSubtitles) 'Subtitles',
if (enableWatchHistory) 'WatchHistory',
if (enableFavorites) 'Favorites',
];
/// Print configuration summary
static void printSummary() {
@@ -285,13 +287,15 @@ class RuntimeOptimizations {
// Detect low storage
if (storageFreeMb < 1000) {
print(
'[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.');
'[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.',
);
}
// High performance device detection
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
print('[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB');
print(
'[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB');
}
/// Get dynamic cache size based on available memory
+26 -25
View File
@@ -1,10 +1,8 @@
/// Adaptive Bitrate (ABR) Streaming Handler for HLS
///
///
/// Supports multi-bitrate HLS playlists for adaptive streaming
/// with fallback options for poor network conditions
import 'dart:async';
import 'dart:io';
library;
/// Represents a video quality level for adaptive bitrate streaming
class QualityLevel {
@@ -108,23 +106,23 @@ class QualityProfiles {
/// Get default profiles for balanced streaming
static List<QualityLevel> getBalancedProfiles() => [
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
];
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
];
/// Get all available profiles
static List<QualityLevel> getAllProfiles() => [
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
uhd2k,
uhd4k,
];
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
uhd2k,
uhd4k,
];
/// Get profiles suitable for streaming type
static List<QualityLevel> getProfiles({
@@ -159,8 +157,8 @@ class QualityProfiles {
/// Bandwidth detector for adaptive bitrate selection
class BandwidthDetector {
final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded
int _totalBytesLastPeriod = 0;
Duration _sampleWindow = const Duration(seconds: 10);
final int _totalBytesLastPeriod = 0;
final Duration _sampleWindow = const Duration(seconds: 10);
/// Record bytes downloaded at current time
void recordBytes(int bytes) {
@@ -177,7 +175,9 @@ class BandwidthDetector {
if (_samples.isEmpty) return 0;
int totalBytes = 0;
_samples.values.forEach((bytes) => totalBytes += bytes);
for (var bytes in _samples.values) {
totalBytes += bytes;
}
final timeSpan = DateTime.now()
.difference(
@@ -236,10 +236,10 @@ class HlsMasterPlaylistGenerator {
// Variant streams (quality levels)
for (final quality in qualityLevels) {
buffer.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
'RESOLUTION=${quality.width}x${quality.height}');
buffer
.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
'RESOLUTION=${quality.width}x${quality.height}');
buffer.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
}
return buffer.toString();
@@ -317,7 +317,8 @@ class QualitySelector {
// Only switch if significantly different from current
final overhead = _currentQuality.bitrateBps * 1.2;
if (bandwidthBps > overhead || bandwidthBps < _currentQuality.bitrateBps * 0.5) {
if (bandwidthBps > overhead ||
bandwidthBps < _currentQuality.bitrateBps * 0.5) {
final bestQuality =
BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles);
setQuality(bestQuality);
+17 -18
View File
@@ -12,8 +12,8 @@ class CacheEntry<T> {
required this.data,
required this.sizeEstimate,
}) : timestamp = DateTime.now(),
lastAccess = DateTime.now(),
accessCount = 0;
lastAccess = DateTime.now(),
accessCount = 0;
bool isExpired({Duration ttl = const Duration(hours: 24)}) {
return DateTime.now().difference(timestamp) > ttl;
@@ -52,7 +52,7 @@ class CacheService {
/// Get cached data
T? get<T>(String key) {
final entry = _cache[key] as CacheEntry?;
final entry = _cache[key];
if (entry != null && !entry.isExpired()) {
entry.markAccess();
return entry.data as T;
@@ -68,11 +68,12 @@ class CacheService {
/// Get cache stats
Map<String, dynamic> getStats() => {
'totalSize': _totalSizeBytes,
'totalSizeMb': _totalSizeBytes / (1024 * 1024),
'itemCount': _cache.length,
'averageSizePerItem': _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
};
'totalSize': _totalSizeBytes,
'totalSizeMb': _totalSizeBytes / (1024 * 1024),
'itemCount': _cache.length,
'averageSizePerItem':
_cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
};
/// Clear all cache
void clear() {
@@ -96,7 +97,7 @@ class CacheService {
/// Trim cache by removing least recently used entries
void _trimCacheIfNeeded() {
final maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
const maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
if (_totalSizeBytes > maxSize) {
// Remove least recently accessed entries
final sorted = _cache.entries.toList()
@@ -173,10 +174,10 @@ class ImageCacheManager {
/// Get cache stats
Map<String, dynamic> getStats() => {
'totalMemoryMb': _totalMemoryBytes / (1024 * 1024),
'cachedImages': _imageCache.length,
'maxImages': _maxImages,
};
'totalMemoryMb': _totalMemoryBytes / (1024 * 1024),
'cachedImages': _imageCache.length,
'maxImages': _maxImages,
};
/// Clear all image cache
void clear() {
@@ -196,9 +197,8 @@ final imageCacheManagerProvider = Provider<ImageCacheManager>((ref) {
});
/// Provider for cache statistics
final cacheStatsProvider = StateNotifierProvider<
CacheStatsNotifier,
Map<String, dynamic>>((ref) {
final cacheStatsProvider =
StateNotifierProvider<CacheStatsNotifier, Map<String, dynamic>>((ref) {
final cacheService = ref.watch(cacheServiceProvider);
return CacheStatsNotifier(cacheService);
});
@@ -206,8 +206,7 @@ final cacheStatsProvider = StateNotifierProvider<
class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> {
final CacheService _cacheService;
CacheStatsNotifier(this._cacheService)
: super(_cacheService.getStats());
CacheStatsNotifier(this._cacheService) : super(_cacheService.getStats());
void refreshStats() {
state = _cacheService.getStats();
+8 -4
View File
@@ -203,7 +203,9 @@ class _RetryInterceptor extends Interceptor {
@override
Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
DioException err,
ErrorInterceptorHandler handler,
) async {
// Only retry on timeout or connection errors
if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout) {
@@ -248,9 +250,11 @@ final networkConfigProvider =
class NetworkConfigNotifier extends StateNotifier<NetworkConfig> {
NetworkConfigNotifier()
: super(const NetworkConfig(
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
));
: super(
const NetworkConfig(
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
),
);
void setProxy(String? proxyUrl) {
state = state.copyWith(proxyUrl: proxyUrl);
+13 -15
View File
@@ -23,10 +23,7 @@ class StreamingMetrics {
});
double get totalPlaybackSeconds =>
(endTime ?? DateTime.now())
.difference(startTime)
.inSeconds
.toDouble();
(endTime ?? DateTime.now()).difference(startTime).inSeconds.toDouble();
double get qualityScore {
// Score based on metrics
@@ -67,7 +64,7 @@ class StreamingOptimizer {
StreamController.broadcast();
late Timer _metricsTimer;
int _segmentStartTime = 0;
final int _segmentStartTime = 0;
int _totalBytesDownloaded = 0;
int _rebufferCount = 0;
int _lastBufferNotificationTime = 0;
@@ -98,8 +95,7 @@ class StreamingOptimizer {
final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000;
_currentMetrics = StreamingMetrics(
averageNetworkBitrate: _totalBytesDownloaded > 0
? (_totalBytesDownloaded * 8 /
_currentMetrics.totalPlaybackSeconds)
? (_totalBytesDownloaded * 8 / _currentMetrics.totalPlaybackSeconds)
: 0,
currentNetworkBitrate: bandwidthBps,
bufferDurationMs: _currentMetrics.bufferDurationMs,
@@ -235,14 +231,16 @@ class StreamingOptimizerNotifier extends StateNotifier<StreamingMetrics> {
late StreamingOptimizer _optimizer;
StreamingOptimizerNotifier(this._qualitySelector)
: super(StreamingMetrics(
averageNetworkBitrate: 0,
currentNetworkBitrate: 0,
bufferDurationMs: 0,
segmentDownloadTimeMs: 0,
rebufferCount: 0,
startTime: DateTime.now(),
)) {
: super(
StreamingMetrics(
averageNetworkBitrate: 0,
currentNetworkBitrate: 0,
bufferDurationMs: 0,
segmentDownloadTimeMs: 0,
rebufferCount: 0,
startTime: DateTime.now(),
),
) {
_optimizer = StreamingOptimizer(qualitySelector: _qualitySelector);
// Listen to metrics updates
+31 -32
View File
@@ -23,9 +23,9 @@ class AppTheme {
static const double spacing24 = 24.0;
static const double spacing32 = 32.0;
static const double spacing40 = 40.0;
static const double spacing48 = 48.0; // TV minimum safe area
static const double spacing48 = 48.0; // TV minimum safe area
static const double spacing56 = 56.0;
static const double spacing64 = 64.0; // Hero section padding
static const double spacing64 = 64.0; // Hero section padding
// ============ RADIUS (modern, organic curves) ============
static const double radiusXs = 4.0;
@@ -114,7 +114,6 @@ class AppTheme {
final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith(
useMaterial3: true,
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background,
@@ -387,7 +386,7 @@ class AppTheme {
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: BorderSide(
side: const BorderSide(
color: AppColors.border,
width: 1,
),
@@ -400,7 +399,7 @@ class AppTheme {
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl),
side: BorderSide(
side: const BorderSide(
color: AppColors.border,
width: 1,
),
@@ -498,14 +497,14 @@ class AppTheme {
),
// ============ NAVIGATION RAIL (Side menu) ============
navigationRailTheme: NavigationRailThemeData(
navigationRailTheme: const NavigationRailThemeData(
backgroundColor: AppColors.surface,
elevation: 0,
selectedIconTheme: const IconThemeData(
selectedIconTheme: IconThemeData(
color: AppColors.primary,
size: 28,
),
unselectedIconTheme: const IconThemeData(
unselectedIconTheme: IconThemeData(
color: AppColors.textSecondary,
size: 24,
),
@@ -538,7 +537,7 @@ class AppTheme {
static const double buttonHeightSm = 36.0;
static const double buttonHeightMd = 44.0;
static const double buttonHeightLg = 56.0; // TV-comfortable tap target
static const double buttonHeightLg = 56.0; // TV-comfortable tap target
// ============ ASPECT RATIOS ============
static const double posterAspectRatio = 2 / 3;
@@ -564,29 +563,29 @@ class _AppThemeExtension extends ThemeExtension<_AppThemeExtension> {
final List<BoxShadow>? elevatedShadow;
static _AppThemeExtension get dark => _AppThemeExtension(
glassShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 16,
spreadRadius: -4,
),
],
focusShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.5),
blurRadius: 20,
spreadRadius: 4,
),
],
elevatedShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 12,
spreadRadius: 0,
offset: const Offset(0, 4),
),
],
);
glassShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 16,
spreadRadius: -4,
),
],
focusShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.5),
blurRadius: 20,
spreadRadius: 4,
),
],
elevatedShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 12,
spreadRadius: 0,
offset: const Offset(0, 4),
),
],
);
@override
ThemeExtension<_AppThemeExtension> copyWith({
+17 -13
View File
@@ -4,10 +4,10 @@ import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
/// Modern Apple TV Glass Container
///
///
/// Sophisticated glassmorphism with premium blur effects,
/// configurable transparency, and elegant borders.
///
///
/// Features:
/// - Backdrop blur (iOS-style glass effect)
/// - Subtle gradient overlay
@@ -151,11 +151,11 @@ class _GlassCardState extends State<GlassCard>
duration: AppTheme.durationMd,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04)
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate(
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8)
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
}
@override
@@ -189,12 +189,16 @@ class _GlassCardState extends State<GlassCard>
child: GestureDetector(
onTap: widget.onTap,
onLongPress: widget.onLongPress,
onTapDown: (_) => _onPointerDown(PointerDownEvent(
position: Offset.zero,
)),
onTapUp: (_) => _onPointerUp(PointerUpEvent(
position: Offset.zero,
)),
onTapDown: (_) => _onPointerDown(
const PointerDownEvent(
position: Offset.zero,
),
),
onTapUp: (_) => _onPointerUp(
const PointerUpEvent(
position: Offset.zero,
),
),
onTapCancel: () => _controller.reverse(),
child: ScaleTransition(
scale: _scaleAnimation,
@@ -211,7 +215,7 @@ class _GlassCardState extends State<GlassCard>
content = Stack(
children: [
Opacity(opacity: 0.5, child: content),
Center(
const Center(
child: SizedBox(
width: 40,
height: 40,
+8 -6
View File
@@ -285,7 +285,8 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_arrow, color: AppColors.textPrimary),
const Icon(Icons.play_arrow,
color: AppColors.textPrimary),
const SizedBox(width: 8),
Text(
'Play',
@@ -320,8 +321,10 @@ class _HeroCarouselState extends State<HeroCarousel>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline,
color: AppColors.textPrimary),
const Icon(
Icons.info_outline,
color: AppColors.textPrimary,
),
const SizedBox(width: 8),
Text(
'More Info',
@@ -361,9 +364,8 @@ class _HeroCarouselState extends State<HeroCarousel>
width: _currentIndex == index ? 32 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentIndex == index
? AppColors.primary
: AppColors.border,
color:
_currentIndex == index ? AppColors.primary : AppColors.border,
borderRadius: BorderRadius.circular(4),
),
),
+3 -3
View File
@@ -17,15 +17,15 @@ class ThemedLoading extends StatelessWidget {
],
),
),
child: Center(
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(
CircularProgressIndicator(
color: AppColors.primary,
strokeWidth: 3,
),
const SizedBox(height: 16),
SizedBox(height: 16),
Text(
'Loading...',
style: TextStyle(
+3 -3
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
/// Apple TV Modern Channel Grid
///
///
/// Responsive grid layout for channels with:
/// - Flexible column count based on screen size
/// - Smooth animations
@@ -57,7 +56,8 @@ class TvChannelGrid extends StatelessWidget {
// Compute item width, clamped to avoid negative/NaN values.
final totalSpacing = horizontalSpacing * (columnCount - 1);
final availableWidth = screenWidth - horizontalPadding - totalSpacing;
final itemWidth = (availableWidth / columnCount).clamp(1.0, double.infinity);
final itemWidth =
(availableWidth / columnCount).clamp(1.0, double.infinity);
return SingleChildScrollView(
controller: scrollController,
+12 -10
View File
@@ -6,7 +6,7 @@ import '../theme/app_theme.dart';
import 'glass_container.dart';
/// Apple TV Modern Content Card
///
///
/// Premium card for displaying movies, shows, or other content with:
/// - Poster image with skeleton loading
/// - Title and metadata
@@ -149,19 +149,19 @@ class _TvModernCardState extends State<TvModernCard>
right: 0,
child: Container(
height: 3,
decoration: BoxDecoration(
decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg),
),
),
child: ClipRRect(
borderRadius: BorderRadius.vertical(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg),
),
child: LinearProgressIndicator(
value: widget.progress,
backgroundColor: AppColors.surfaceVariant,
valueColor: AlwaysStoppedAnimation(
valueColor: const AlwaysStoppedAnimation(
AppColors.primary,
),
minHeight: 3,
@@ -171,7 +171,8 @@ class _TvModernCardState extends State<TvModernCard>
),
// Action buttons (on hover)
if (_isHovered && (widget.onPlayTap != null || widget.onMoreTap != null))
if (_isHovered &&
(widget.onPlayTap != null || widget.onMoreTap != null))
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -182,7 +183,8 @@ class _TvModernCardState extends State<TvModernCard>
label: 'Play',
onTap: widget.onPlayTap!,
),
if (widget.onPlayTap != null && widget.onMoreTap != null)
if (widget.onPlayTap != null &&
widget.onMoreTap != null)
const SizedBox(width: 16),
if (widget.onMoreTap != null)
_buildActionButton(
@@ -219,7 +221,7 @@ class _TvModernCardState extends State<TvModernCard>
Row(
children: [
if (widget.rating != null) ...[
Icon(
const Icon(
Icons.star,
size: 14,
color: AppColors.ratingGold,
@@ -258,7 +260,7 @@ class _TvModernCardState extends State<TvModernCard>
if (widget.imageUrl == null || widget.imageUrl!.isEmpty) {
return Container(
color: AppColors.surface,
child: Center(
child: const Center(
child: Icon(
Icons.image_outlined,
color: AppColors.textSecondary,
@@ -273,7 +275,7 @@ class _TvModernCardState extends State<TvModernCard>
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: AppColors.shimmer,
child: Center(
child: const Center(
child: SizedBox(
width: 40,
height: 40,
@@ -286,7 +288,7 @@ class _TvModernCardState extends State<TvModernCard>
),
errorWidget: (context, url, error) => Container(
color: AppColors.surface,
child: Center(
child: const Center(
child: Icon(
Icons.broken_image_outlined,
color: AppColors.textSecondary,