mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-13 18:27:05 +02:00
merge: resolve Dockerfile conflict (kept optimization + nvenc support)
This commit is contained in:
commit
d1933f7500
22 files changed
+1865
-877
No files matched your search
+14
-10
@@ -53,28 +53,32 @@ COPY lib/ ./lib/
|
||||
RUN dart pub get
|
||||
|
||||
# Compile server to native executable
|
||||
# This drastically reduces startup time and memory footprint (vs JIT)
|
||||
RUN dart compile exe bin/server.dart -o bin/server
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Production Runtime
|
||||
# Stage 3: Production Runtime (with NVENC support)
|
||||
# ============================================
|
||||
FROM debian:stable-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
# - ffmpeg: For transcoding
|
||||
# - ca-certificates: For HTTPS requests
|
||||
# - sqlite3: For database
|
||||
# - libsqlite3-0: Shared library for sqlite
|
||||
# - curl: For healthchecks (optional but good practice)
|
||||
# Install runtime dependencies and tools for fetching FFmpeg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
sqlite3 \
|
||||
libsqlite3-0 \
|
||||
curl \
|
||||
wget \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install FFmpeg with NVIDIA NVENC support from BtbN static builds
|
||||
# Falls back gracefully to CPU if GPU not available
|
||||
RUN wget -q https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz \
|
||||
&& tar xf ffmpeg-master-latest-linux64-gpl.tar.xz \
|
||||
&& mv ffmpeg-master-latest-linux64-gpl/bin/ffmpeg /usr/local/bin/ \
|
||||
&& mv ffmpeg-master-latest-linux64-gpl/bin/ffprobe /usr/local/bin/ \
|
||||
&& rm -rf ffmpeg-master-latest-linux64-gpl* \
|
||||
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r xtremuser && useradd -r -g xtremuser -G audio,video xtremuser
|
||||
|
||||
@@ -99,7 +103,7 @@ EXPOSE 8089
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD curl -f http://localhost:8089/index.html || exit 1
|
||||
CMD curl -f http://localhost:8089/index.html || exit 1
|
||||
|
||||
# Start server
|
||||
CMD ["/app/server", "--port", "8089", "--path", "/app/web"]
|
||||
+226
-67
@@ -19,12 +19,36 @@ String _getFFmpegPath() {
|
||||
if (Platform.isWindows) {
|
||||
if (File('ffmpeg.exe').existsSync()) return 'ffmpeg.exe';
|
||||
if (File('bin/ffmpeg.exe').existsSync()) return 'bin/ffmpeg.exe';
|
||||
if (File('ffmpeg/bin/ffmpeg.exe').existsSync())
|
||||
if (File('ffmpeg/bin/ffmpeg.exe').existsSync()) {
|
||||
return 'ffmpeg/bin/ffmpeg.exe';
|
||||
}
|
||||
}
|
||||
return 'ffmpeg'; // Default to PATH
|
||||
}
|
||||
|
||||
/// Check if NVIDIA GPU acceleration is enabled via environment variable
|
||||
bool _isNvidiaGpuEnabled() {
|
||||
final envValue = Platform.environment['NVIDIA_GPU'] ??
|
||||
Platform.environment['nvidia_gpu'] ??
|
||||
'false';
|
||||
return envValue.toLowerCase() == 'true' || envValue == '1';
|
||||
}
|
||||
|
||||
/// Log GPU status on first use
|
||||
bool _gpuStatusLogged = false;
|
||||
void _logGpuStatus() {
|
||||
if (!_gpuStatusLogged) {
|
||||
if (_isNvidiaGpuEnabled()) {
|
||||
print('[FFmpeg] NVIDIA GPU acceleration ENABLED (NVDEC/NVENC)');
|
||||
} else {
|
||||
print(
|
||||
'[FFmpeg] Using CPU processing (set NVIDIA_GPU=true to enable GPU)',
|
||||
);
|
||||
}
|
||||
_gpuStatusLogged = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize streaming subsystem
|
||||
Future<void> initStreaming() async {
|
||||
if (!_hlsTempDir.existsSync()) {
|
||||
@@ -94,7 +118,9 @@ PlaylistConfig? _getCurrentPlaylist(Request request) {
|
||||
// ==========================================
|
||||
|
||||
Handler createLiveStreamHandler(
|
||||
Future<PlaylistConfig?> Function(Request) getPlaylist) {
|
||||
Future<PlaylistConfig?> Function(Request) getPlaylist, {
|
||||
bool Function()? isGpuEnabled,
|
||||
}) {
|
||||
final router = Router();
|
||||
|
||||
// Route: /api/live/{streamId} (Catch all pattern to debug)
|
||||
@@ -128,29 +154,93 @@ Handler createLiveStreamHandler(
|
||||
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
|
||||
print('[Live] Streaming via FFmpeg: $targetUrl');
|
||||
|
||||
// Start FFmpeg to proxy and stabilize the stream
|
||||
// We use "-c copy" to avoid transcoding (Low CPU) but gain FFmpeg's network robustness
|
||||
final ffmpegArgs = [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
// Full headers to mimic a real video player
|
||||
// Check if NVIDIA GPU is enabled (from database setting or env var fallback)
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
|
||||
// Log GPU status
|
||||
if (useNvidiaGpu) {
|
||||
print('[Live] Using NVIDIA GPU acceleration (NVDEC)');
|
||||
}
|
||||
|
||||
// Build FFmpeg arguments
|
||||
final ffmpegArgs = <String>[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning', // Changed from error to see more info
|
||||
];
|
||||
|
||||
// NVIDIA GPU Hardware Acceleration (NVDEC for decoding)
|
||||
if (useNvidiaGpu) {
|
||||
ffmpegArgs.addAll([
|
||||
'-hwaccel', 'cuda', // Use NVIDIA CUDA for decoding
|
||||
// Note: Don't use hwaccel_output_format cuda - it can cause issues with some codecs
|
||||
]);
|
||||
}
|
||||
|
||||
// Input headers and robustness flags
|
||||
ffmpegArgs.addAll([
|
||||
'-headers',
|
||||
'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\nAccept: */*\r\nConnection: keep-alive\r\nReferer: ${playlist.dns}/\r\n',
|
||||
'-reconnect',
|
||||
'1',
|
||||
'-reconnect_at_eof',
|
||||
'1',
|
||||
'-reconnect_streamed',
|
||||
'1',
|
||||
'-reconnect_delay_max',
|
||||
'10', // Increased from 5
|
||||
'-rw_timeout',
|
||||
'30000000', // Increased from 15s to 30s
|
||||
'-timeout',
|
||||
'30000000', // Add explicit timeout
|
||||
'-fflags',
|
||||
'+nobuffer+genpts+discardcorrupt', // Added discardcorrupt for robustness
|
||||
'-analyzeduration',
|
||||
'5000000', // Increased for GPU decode
|
||||
'-probesize',
|
||||
'10000000', // Increased for GPU decode
|
||||
'-i',
|
||||
targetUrl,
|
||||
]);
|
||||
|
||||
// HTTP redirect handling and robustness flags
|
||||
'-reconnect', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '5',
|
||||
'-rw_timeout', '15000000',
|
||||
'-analyzeduration', '5000000', // Faster probe for live
|
||||
'-probesize', '5000000',
|
||||
// Output encoding options
|
||||
if (useNvidiaGpu) {
|
||||
// With GPU: Transcode using NVIDIA NVENC for hardware acceleration
|
||||
print('[Live] Transcoding video with h264_nvenc (GPU)');
|
||||
ffmpegArgs.addAll([
|
||||
// Video encoding
|
||||
'-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder
|
||||
'-preset', 'p1', // Fastest preset for live (p1=fastest)
|
||||
'-tune', 'll', // Low latency tuning for live streams
|
||||
'-rc', 'cbr', // Constant bitrate for stable streaming
|
||||
'-b:v', '4000k', // Fixed video bitrate
|
||||
'-maxrate', '4500k', // Max bitrate
|
||||
'-bufsize', '8000k', // Buffer for stability
|
||||
'-g', '50', // Keyframe every 2 seconds (at 25fps)
|
||||
'-bf', '0', // No B-frames for lower latency
|
||||
// Audio encoding with resampling to fix timestamp gaps
|
||||
'-af',
|
||||
'aresample=async=1000:first_pts=0', // Auto-correct audio timestamps
|
||||
'-c:a', 'aac', // Audio to AAC (browser compatible)
|
||||
'-b:a', '128k', // Audio bitrate
|
||||
'-ac', '2',
|
||||
'-ar', '48000', // 48kHz sample rate
|
||||
// Timestamp handling
|
||||
'-copyts', // Copy timestamps
|
||||
'-start_at_zero', // Start at 0
|
||||
'-max_muxing_queue_size', '4096', // Large muxing queue
|
||||
]);
|
||||
} else {
|
||||
// Without GPU: Standard CPU processing (copy video)
|
||||
ffmpegArgs.addAll([
|
||||
'-c:v', 'copy', // Video: Direct copy (Very low CPU)
|
||||
'-c:a', 'aac', // Audio: Transcode to AAC
|
||||
'-b:a', '192k',
|
||||
'-ac', '2',
|
||||
]);
|
||||
}
|
||||
|
||||
'-i', targetUrl,
|
||||
|
||||
'-c', 'copy', // Direct stream copy (Very low CPU)
|
||||
'-f', 'mpegts',
|
||||
'pipe:1', // Output to stdout
|
||||
];
|
||||
ffmpegArgs.addAll(['-f', 'mpegts', 'pipe:1']);
|
||||
|
||||
try {
|
||||
final ffmpegPath = _getFFmpegPath();
|
||||
@@ -210,15 +300,18 @@ Handler createLiveStreamHandler(
|
||||
final _lastLogTime = <String, int>{};
|
||||
|
||||
Handler createVodStreamHandler(
|
||||
Future<PlaylistConfig?> Function(Request) getPlaylist) {
|
||||
Future<PlaylistConfig?> Function(Request) getPlaylist, {
|
||||
bool Function()? isGpuEnabled,
|
||||
}) {
|
||||
final router = Router();
|
||||
|
||||
// Route: /api/vod/{streamId}/playlist.m3u8
|
||||
router.get('/<streamId>/playlist.m3u8',
|
||||
(Request request, String streamId) async {
|
||||
final playlist = await getPlaylist(request);
|
||||
if (playlist == null)
|
||||
if (playlist == null) {
|
||||
return Response.internalServerError(body: 'No playlist');
|
||||
}
|
||||
|
||||
// Validate streamId to prevent Path Traversal
|
||||
if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(streamId)) {
|
||||
@@ -292,58 +385,123 @@ Handler createVodStreamHandler(
|
||||
'${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv';
|
||||
print('[VOD] Starting Transcode ($contentType): $targetUrl');
|
||||
|
||||
// Start FFmpeg
|
||||
final ffmpegArgs = [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
// Check if NVIDIA GPU is enabled (from database setting or env var fallback)
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
|
||||
// Robustness for network streams
|
||||
'-reconnect', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '5',
|
||||
'-rw_timeout', '15000000',
|
||||
if (useNvidiaGpu) {
|
||||
print('[VOD] Using NVIDIA GPU acceleration (NVENC)');
|
||||
}
|
||||
|
||||
// Build FFmpeg arguments
|
||||
final ffmpegArgs = <String>[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
];
|
||||
|
||||
// NVIDIA GPU Hardware Acceleration
|
||||
if (useNvidiaGpu) {
|
||||
ffmpegArgs.addAll([
|
||||
'-hwaccel', 'cuda',
|
||||
// Note: Don't use hwaccel_output_format cuda - it can cause issues
|
||||
]);
|
||||
}
|
||||
|
||||
// Input and robustness flags
|
||||
ffmpegArgs.addAll([
|
||||
'-headers',
|
||||
'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||
'-reconnect',
|
||||
'1',
|
||||
'-reconnect_at_eof',
|
||||
'1',
|
||||
'-reconnect_streamed',
|
||||
'1',
|
||||
'-reconnect_delay_max',
|
||||
'10',
|
||||
'-rw_timeout',
|
||||
'30000000',
|
||||
'-timeout',
|
||||
'30000000',
|
||||
'-analyzeduration',
|
||||
'2000000', // Reduced from 10M to 2M for faster start
|
||||
'-probesize', '2000000', // Reduced from 10M to 2M for faster start
|
||||
'5000000',
|
||||
'-probesize',
|
||||
'10000000',
|
||||
'-i',
|
||||
targetUrl,
|
||||
]);
|
||||
|
||||
'-i', targetUrl,
|
||||
// Video encoding (GPU or CPU)
|
||||
if (useNvidiaGpu) {
|
||||
// NVIDIA NVENC - Hardware encoding (50x+ faster, much lower CPU)
|
||||
ffmpegArgs.addAll([
|
||||
'-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder
|
||||
'-preset', 'p4', // Good quality/speed balance
|
||||
'-tune', 'hq', // High quality mode
|
||||
'-rc', 'cbr', // Constant bitrate for HLS
|
||||
'-b:v', '4000k', // Target bitrate
|
||||
'-maxrate', '4500k',
|
||||
'-bufsize', '8000k',
|
||||
'-g', '48', // Keyframe every 2 seconds
|
||||
'-bf', '2', // B-frames for quality
|
||||
'-pix_fmt', 'yuv420p',
|
||||
]);
|
||||
} else {
|
||||
// CPU encoding (libx264)
|
||||
ffmpegArgs.addAll([
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-tune',
|
||||
'zerolatency',
|
||||
'-tune',
|
||||
'fastdecode',
|
||||
'-crf',
|
||||
'23',
|
||||
'-maxrate',
|
||||
'3000k',
|
||||
'-bufsize',
|
||||
'6000k',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-threads',
|
||||
'0',
|
||||
]);
|
||||
}
|
||||
|
||||
// Video: H.264 Ultrafast (Low CPU)
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency', // Optimize for low latency startup
|
||||
'-tune', 'fastdecode',
|
||||
'-crf', '23',
|
||||
'-maxrate', '3000k',
|
||||
'-bufsize', '6000k',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-threads', '0',
|
||||
|
||||
// Audio: AAC (Browser compatible, improved quality)
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '192k',
|
||||
'-ar', '48000',
|
||||
|
||||
// Advanced Audio Filtering:
|
||||
// 1. "pan": Downmix 5.1/7.1 to Stereo with center mix (dialogue) boosted.
|
||||
// 2. "dynaudnorm": Dynamic Audio Normalizer to boost quiet dialogue and limit loud explosions (Night Mode effect).
|
||||
// Audio encoding (same for both)
|
||||
ffmpegArgs.addAll([
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'192k',
|
||||
'-ar',
|
||||
'48000',
|
||||
'-af',
|
||||
'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15',
|
||||
]);
|
||||
|
||||
'-f', 'hls',
|
||||
'-hls_time', '4',
|
||||
'-hls_list_size', '0',
|
||||
// HLS output settings
|
||||
ffmpegArgs.addAll([
|
||||
'-f',
|
||||
'hls',
|
||||
'-hls_time',
|
||||
'4',
|
||||
'-hls_list_size',
|
||||
'0',
|
||||
'-hls_playlist_type',
|
||||
'event', // Event type allows immediate playback while transcoding
|
||||
'-hls_allow_cache', '1',
|
||||
'-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', '${streamDir.path}/segment_%03d.ts',
|
||||
'-start_number', '0',
|
||||
|
||||
// Write playlist to stdout or file? File is easier for static serving
|
||||
'event',
|
||||
'-hls_allow_cache',
|
||||
'1',
|
||||
'-hls_segment_type',
|
||||
'mpegts',
|
||||
'-hls_segment_filename',
|
||||
'${streamDir.path}/segment_%03d.ts',
|
||||
'-start_number',
|
||||
'0',
|
||||
'${streamDir.path}/playlist.m3u8',
|
||||
];
|
||||
]);
|
||||
|
||||
final ffmpegPath = _getFFmpegPath();
|
||||
Process.start(ffmpegPath, ffmpegArgs).then((process) {
|
||||
@@ -372,7 +530,8 @@ Handler createVodStreamHandler(
|
||||
|
||||
if (!playlistFile.existsSync()) {
|
||||
return Response.internalServerError(
|
||||
body: 'Timeout waiting for transcoder');
|
||||
body: 'Timeout waiting for transcoder',
|
||||
);
|
||||
}
|
||||
|
||||
return Response.ok(
|
||||
|
||||
@@ -373,6 +373,37 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if NVIDIA GPU is enabled in any user's settings
|
||||
/// This is a server-wide setting that applies to all streaming
|
||||
bool isNvidiaGpuEnabled() {
|
||||
try {
|
||||
// Check all user settings for enable_nvidia_gpu
|
||||
final result = _db.select(
|
||||
'SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1',
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
print('[GPU] No user settings found in database');
|
||||
return false;
|
||||
}
|
||||
|
||||
final settingsJson = result.first['settings_json'] as String;
|
||||
print('[GPU] Settings found: $settingsJson');
|
||||
|
||||
// Parse JSON properly to check the setting
|
||||
// Handle both "enable_nvidia_gpu":true and "enable_nvidia_gpu": true
|
||||
final RegExp gpuRegex =
|
||||
RegExp(r'"enable_nvidia_gpu"\s*:\s*true', caseSensitive: false);
|
||||
final isEnabled = gpuRegex.hasMatch(settingsJson);
|
||||
|
||||
print('[GPU] NVIDIA GPU enabled: $isEnabled');
|
||||
return isEnabled;
|
||||
} catch (e) {
|
||||
print('[GPU] Error checking GPU setting: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
void close() {
|
||||
_db.dispose();
|
||||
|
||||
+16
-3
@@ -181,7 +181,8 @@ void main(List<String> args) async {
|
||||
final user = req.context['user'] as User?;
|
||||
if (user == null || !user.isAdmin) {
|
||||
return Response.forbidden(
|
||||
jsonEncode({'error': 'Admin access required'}));
|
||||
jsonEncode({'error': 'Admin access required'}),
|
||||
);
|
||||
}
|
||||
|
||||
final result = await cleanupService.runCleanup();
|
||||
@@ -231,8 +232,20 @@ void main(List<String> args) async {
|
||||
|
||||
// Create Streaming Router (Mount handlers on correct paths)
|
||||
final streamingRouter = Router()
|
||||
..mount('/api/live', createLiveStreamHandler(getPlaylist))
|
||||
..mount('/api/vod', createVodStreamHandler(getPlaylist));
|
||||
..mount(
|
||||
'/api/live',
|
||||
createLiveStreamHandler(
|
||||
getPlaylist,
|
||||
isGpuEnabled: db.isNvidiaGpuEnabled,
|
||||
),
|
||||
)
|
||||
..mount(
|
||||
'/api/vod',
|
||||
createVodStreamHandler(
|
||||
getPlaylist,
|
||||
isGpuEnabled: db.isNvidiaGpuEnabled,
|
||||
),
|
||||
);
|
||||
|
||||
// Secured Proxy Pipeline
|
||||
// We handle it outside the main router to preserve the '/api/xtream' prefix
|
||||
|
||||
@@ -12,45 +12,58 @@ class AppColors {
|
||||
|
||||
// ============ BACKGROUNDS ============
|
||||
/// Pure black for that infinite depth look (OLED friendly)
|
||||
static const Color background = Color(0xFF000000);
|
||||
static const Color background = Color(0xFF0F1014); // Deepest Blue-Black
|
||||
|
||||
/// 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(0xFF181920);
|
||||
|
||||
/// Slightly lighter grey for secondary surfaces or hover states
|
||||
static const Color surfaceVariant = Color(0xFF3A3A3C);
|
||||
static const Color surfaceVariant = Color(0xFF1E2028);
|
||||
|
||||
static const Color surfaceGlass =
|
||||
Color(0x1AFFFFFF); // 10% White for glass effect
|
||||
|
||||
/// Focused element background (often white in tvOS for text, or bright accent)
|
||||
static const Color focusColor = Color(0xFFFFFFFF);
|
||||
static const Color border = Color(0xFF1F1F1F);
|
||||
static const Color focusBorder = Color(0xFFFFFFFF);
|
||||
|
||||
// ============ ACCENTS ============
|
||||
/// Minimal white accent. In Apple TV, color is used sparingly.
|
||||
/// We keep a subtle blue only for specific indicators if needed.
|
||||
static const Color primary = Color(0xFFFFFFFF);
|
||||
static const Color accent = Color(0xFFE5E5E5);
|
||||
static const Color primary = Color(0xFF6C63FF);
|
||||
static const Color secondary = Color(0xFF00B4D8);
|
||||
static const Color accent = Color(0xFF00B4D8);
|
||||
|
||||
/// Primary Gradient (Subtle silver/white glow)
|
||||
/// Primary Gradient (Electric Violet to Cyan)
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)],
|
||||
colors: [Color(0xFF6C63FF), Color(0xFF00B4D8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
// ============ TEXT ============
|
||||
static const LinearGradient backgroundGradient = LinearGradient(
|
||||
colors: [Color(0xFF0F1014), Color(0xFF131418)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
);
|
||||
|
||||
// Status Colors
|
||||
static const Color success = Color(0xFF00E676);
|
||||
static const Color warning = Color(0xFFFFAB00);
|
||||
static const Color error = Color(0xFFFF5252);
|
||||
static const Color info = Color(0xFF2979FF);
|
||||
|
||||
// Content Colors
|
||||
static const Color textPrimary = Color(0xFFFFFFFF);
|
||||
static const Color textSecondary = Color(0xFF9E9E9E); // Medium Grey
|
||||
static const Color textTertiary = Color(0xFF616161); // Darker Grey
|
||||
static const Color textSecondary = Color(0xFFAAAAAA);
|
||||
static const Color textTertiary = Color(0xFF666666);
|
||||
|
||||
// ============ FUNCTIONAL COLORS ============
|
||||
static const Color success = Color(0xFF4DB6AC); // Teal-ish
|
||||
static const Color warning = Color(0xFFFFB74D);
|
||||
static const Color error = Color(0xFFEF5350);
|
||||
static const Color live = Color(0xFFFF3B30); // Apple Red
|
||||
static const Color ratingGold = Color(0xFFFFD700);
|
||||
static const Color premiumGold = Color(0xFFFFA000);
|
||||
|
||||
// ============ BORDERS ============
|
||||
// Borders are usually hidden until focused
|
||||
static const Color border = Color(0xFF1F1F1F);
|
||||
static const Color focusBorder = Color(0xFFFFFFFF);
|
||||
// Category Colors
|
||||
static const Color live = Color(0xFFFF5252); // Red
|
||||
static const Color movies = Color(0xFF448AFF); // Blue
|
||||
static const Color series = Color(0xFF69F0AE); // Green
|
||||
|
||||
// ============ GLASSMORPHISM ============
|
||||
// Apple TV uses this heavily for headers/overlays
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// XtremFlow Apple TV Theme
|
||||
///
|
||||
///
|
||||
/// Focus-driven, immersive, minimal.
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
@@ -27,27 +27,66 @@ class AppTheme {
|
||||
// ============ ANIMATION ============
|
||||
static const Duration durationFast = Duration(milliseconds: 200);
|
||||
static const Duration durationNormal = Duration(milliseconds: 300);
|
||||
static const Curve curveDefault = Curves.fastOutSlowIn; // Apple-like snappy curve
|
||||
static const Curve curveDefault =
|
||||
Curves.fastOutSlowIn; // Apple-like snappy curve
|
||||
|
||||
// ============ DARK THEME (TV Main) ============
|
||||
static ThemeData get darkTheme {
|
||||
final baseTheme = ThemeData.dark(useMaterial3: true);
|
||||
|
||||
|
||||
return baseTheme.copyWith(
|
||||
colorScheme: AppColors.darkColorScheme,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
|
||||
// Typography: San Francisco style (using Inter as proxy)
|
||||
|
||||
// Typography: "Cinematic Modern" (Outfit for Headings, Inter for UI)
|
||||
textTheme: TextTheme(
|
||||
displayLarge: GoogleFonts.inter(fontSize: 56, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: AppColors.textPrimary),
|
||||
displayMedium: GoogleFonts.inter(fontSize: 48, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: AppColors.textPrimary),
|
||||
displaySmall: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
headlineMedium: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
titleLarge: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
bodyLarge: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
|
||||
bodyMedium: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
|
||||
labelLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
displayLarge: GoogleFonts.outfit(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1.0,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
displayMedium: GoogleFonts.outfit(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
displaySmall: GoogleFonts.outfit(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
headlineMedium: GoogleFonts.outfit(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
titleLarge: GoogleFonts.outfit(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
titleMedium: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
bodyLarge: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
bodyMedium: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
labelLarge: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
// AppBar: Transparent / Glass
|
||||
@@ -57,18 +96,21 @@ class AppTheme {
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
titleTextStyle: GoogleFonts.inter(
|
||||
fontSize: 17,
|
||||
titleTextStyle: GoogleFonts.outfit(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Inputs: Dark, minimal, no borders unless focused
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: spacing24,
|
||||
vertical: spacing16,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(radiusMd),
|
||||
borderSide: BorderSide.none,
|
||||
@@ -83,7 +125,7 @@ class AppTheme {
|
||||
),
|
||||
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
|
||||
),
|
||||
|
||||
|
||||
// Buttons: White pill or minimal text
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -91,17 +133,24 @@ class AppTheme {
|
||||
foregroundColor: Colors.black, // Black text
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)),
|
||||
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radiusMd),
|
||||
),
|
||||
textStyle:
|
||||
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
).copyWith(
|
||||
overlayColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.hovered)) return Colors.black.withOpacity(0.1);
|
||||
if (states.contains(WidgetState.pressed)) return Colors.black.withOpacity(0.2);
|
||||
if (states.contains(WidgetState.hovered)) {
|
||||
return Colors.black.withOpacity(0.1);
|
||||
}
|
||||
if (states.contains(WidgetState.pressed)) {
|
||||
return Colors.black.withOpacity(0.2);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Cards: Transparent by default (content defines look)
|
||||
// cardTheme: const CardTheme(
|
||||
// color: AppColors.surface,
|
||||
@@ -124,8 +173,9 @@ class AppTheme {
|
||||
static const double radiusFull = 999.0;
|
||||
|
||||
// Light theme stub
|
||||
static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith(
|
||||
colorScheme: AppColors.lightColorScheme,
|
||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
|
||||
);
|
||||
static ThemeData get lightTheme =>
|
||||
ThemeData.light(useMaterial3: true).copyWith(
|
||||
colorScheme: AppColors.lightColorScheme,
|
||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
|
||||
);
|
||||
}
|
||||
@@ -35,11 +35,10 @@ class GlassContainer extends StatelessWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withOpacity(opacity),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: border
|
||||
? Border.all(
|
||||
@@ -47,6 +46,21 @@ class GlassContainer extends StatelessWidget {
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.surface.withOpacity(opacity),
|
||||
AppColors.surface.withOpacity(opacity * 0.5),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 20,
|
||||
spreadRadius: -5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'glass_container.dart';
|
||||
import 'tv_focusable_card.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class HeroCarouselItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? imageUrl;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onPlay;
|
||||
|
||||
HeroCarouselItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.imageUrl,
|
||||
this.onTap,
|
||||
this.onPlay,
|
||||
});
|
||||
}
|
||||
|
||||
class HeroCarousel extends StatefulWidget {
|
||||
final List<HeroCarouselItem> items;
|
||||
final bool autoPlay;
|
||||
|
||||
const HeroCarousel({
|
||||
super.key,
|
||||
required this.items,
|
||||
this.autoPlay = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HeroCarousel> createState() => _HeroCarouselState();
|
||||
}
|
||||
|
||||
class _HeroCarouselState extends State<HeroCarousel> {
|
||||
late PageController _pageController;
|
||||
int _currentIndex = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
if (widget.autoPlay) {
|
||||
_startAutoPlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoPlay() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 8), (timer) {
|
||||
if (_currentIndex < widget.items.length - 1) {
|
||||
_currentIndex++;
|
||||
} else {
|
||||
_currentIndex = 0;
|
||||
}
|
||||
if (_pageController.hasClients) {
|
||||
_pageController.animateToPage(
|
||||
_currentIndex,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _getProxiedUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return null;
|
||||
if (url.startsWith('http')) {
|
||||
return '/api/xtream/$url';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return SizedBox(
|
||||
height: 500, // Cinematic height
|
||||
child: Stack(
|
||||
children: [
|
||||
// Carousel
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() => _currentIndex = index);
|
||||
},
|
||||
itemCount: widget.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.items[index];
|
||||
return _buildHeroSlide(item);
|
||||
},
|
||||
),
|
||||
|
||||
// Indicators
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
right: 48,
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
widget.items.length,
|
||||
(index) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: _currentIndex == index ? 24 : 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _currentIndex == index
|
||||
? AppColors.primary
|
||||
: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroSlide(HeroCarouselItem item) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background Image with Ken Burns Effect (Simulated via Scale in future, static for now)
|
||||
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
|
||||
Image.network(
|
||||
_getProxiedUrl(item.imageUrl)!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: AppColors.surface),
|
||||
),
|
||||
|
||||
// Gradient Overlays
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
AppColors.background.withOpacity(0.2),
|
||||
AppColors.background.withOpacity(0.9),
|
||||
AppColors.background,
|
||||
],
|
||||
stops: const [0.0, 0.5, 0.8, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerRight,
|
||||
end: Alignment.centerLeft,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
AppColors.background.withOpacity(0.8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content Info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(48.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Category Tag
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.primary.withOpacity(0.5)),
|
||||
),
|
||||
child: Text(
|
||||
'FEATURED',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Title
|
||||
SizedBox(
|
||||
width: 600,
|
||||
child: Text(
|
||||
item.title,
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
height: 1.1,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (item.subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.subtitle!,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
children: [
|
||||
TvFocusableCard(
|
||||
onTap: item.onPlay,
|
||||
borderRadius: 50,
|
||||
scaleFactor: 1.1,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.4),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'WATCH NOW',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
TvFocusableCard(
|
||||
onTap: item.onTap, // Add to favorites or details
|
||||
borderRadius: 50,
|
||||
child: GlassContainer(
|
||||
borderRadius: 50,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'DETAILS',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 48), // Bottom padding
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,33 @@ class ThemedLoading extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(0, -0.2),
|
||||
radius: 1.5,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF2C2C2E),
|
||||
Color(0xFF000000),
|
||||
Color(0xFF0F1014), // Deep Space Dark
|
||||
Color(0xFF181920), // Soft Eerie Black
|
||||
],
|
||||
stops: [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: Color(0xFF007AFF), // Electric Blue (AppColors.primary)
|
||||
strokeWidth: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Loading...',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Outfit', // Match AppTheme
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -64,50 +64,75 @@ class _TvFocusableCardState extends State<TvFocusableCard>
|
||||
}
|
||||
}
|
||||
|
||||
bool _isHovered = false;
|
||||
|
||||
void _handleHover(bool isHovered) {
|
||||
setState(() {
|
||||
_isHovered = isHovered;
|
||||
});
|
||||
if (isHovered || _isFocused) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
onFocusChange: _handleFocusChange,
|
||||
autofocus: widget.autofocus,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
focusColor: Colors.transparent, // We handle visual focus manually
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
final isActive = _isFocused || _isHovered;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _handleHover(true),
|
||||
onExit: (_) => _handleHover(false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
onFocusChange: _handleFocusChange,
|
||||
autofocus: widget.autofocus,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
border: _isFocused
|
||||
? Border.all(
|
||||
color: widget.focusColor ?? AppColors.focusBorder,
|
||||
width: 3,
|
||||
)
|
||||
: Border.all(color: Colors.transparent, width: 3),
|
||||
boxShadow: _isFocused
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: (widget.focusColor ?? AppColors.focusBorder).withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
focusColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
border: isActive
|
||||
? Border.all(
|
||||
color: widget.focusColor ?? AppColors.focusBorder,
|
||||
width: 2, // Slightly thinner for cleaner look
|
||||
)
|
||||
: Border.all(color: Colors.transparent, width: 2),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
blurRadius: 25,
|
||||
offset: const Offset(0, 10),
|
||||
spreadRadius: 2,
|
||||
),
|
||||
BoxShadow(
|
||||
color: (widget.focusColor ?? AppColors.focusBorder)
|
||||
.withOpacity(0.3),
|
||||
blurRadius: 15,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/services/settings_api_service.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
|
||||
/// Streaming quality presets for Live TV
|
||||
enum StreamQuality {
|
||||
low, // 1.5 Mbps, CRF 26
|
||||
low, // 1.5 Mbps, CRF 26
|
||||
medium, // 3 Mbps, CRF 23
|
||||
high, // 5 Mbps, CRF 20
|
||||
high, // 5 Mbps, CRF 20
|
||||
}
|
||||
|
||||
/// Buffer size presets for Live TV
|
||||
enum BufferSize {
|
||||
low, // 2s segments, 4MB buffer
|
||||
low, // 2s segments, 4MB buffer
|
||||
medium, // 4s segments, 8MB buffer
|
||||
high, // 6s segments, 12MB buffer
|
||||
high, // 6s segments, 12MB buffer
|
||||
}
|
||||
|
||||
/// Connection timeout presets
|
||||
enum ConnectionTimeout {
|
||||
short, // 15 seconds
|
||||
short, // 15 seconds
|
||||
medium, // 30 seconds
|
||||
long, // 60 seconds
|
||||
long, // 60 seconds
|
||||
}
|
||||
|
||||
/// EPG cache duration presets
|
||||
enum EpgCacheDuration {
|
||||
short, // 5 minutes
|
||||
short, // 5 minutes
|
||||
medium, // 15 minutes
|
||||
long, // 60 minutes
|
||||
long, // 60 minutes
|
||||
}
|
||||
|
||||
/// Transcoding mode for Live TV
|
||||
enum TranscodingMode {
|
||||
auto, // Auto-detect best mode
|
||||
forced, // Always transcode
|
||||
auto, // Auto-detect best mode
|
||||
forced, // Always transcode
|
||||
disabled, // Direct stream (no transcoding)
|
||||
}
|
||||
|
||||
/// Player Type Preference
|
||||
enum PlayerType {
|
||||
standard, // Heavily customized HTML5 player (default)
|
||||
lite, // Lightweight native/video_player based
|
||||
lite, // Lightweight native/video_player based
|
||||
}
|
||||
|
||||
/// Keys for API JSON storage
|
||||
@@ -49,7 +50,7 @@ class _SettingsKeys {
|
||||
static const String liveTvFilter = 'filter_live_tv';
|
||||
static const String moviesFilter = 'filter_movies';
|
||||
static const String seriesFilter = 'filter_series';
|
||||
|
||||
|
||||
// Streaming settings
|
||||
static const String streamQuality = 'stream_quality';
|
||||
static const String bufferSize = 'buffer_size';
|
||||
@@ -62,6 +63,8 @@ class _SettingsKeys {
|
||||
static const String showClock = 'show_clock';
|
||||
static const String preferredAspectRatio = 'aspect_ratio';
|
||||
static const String playerType = 'player_type';
|
||||
// Server/GPU Settings
|
||||
static const String enableNvidiaGpu = 'enable_nvidia_gpu';
|
||||
}
|
||||
|
||||
/// Settings state for IPTV preferences with persistence
|
||||
@@ -79,12 +82,14 @@ class IptvSettings {
|
||||
final EpgCacheDuration epgCacheDuration;
|
||||
final TranscodingMode transcodingMode;
|
||||
final bool preferDirectPlay;
|
||||
|
||||
|
||||
// Player Display Settings
|
||||
final bool showClock;
|
||||
final String preferredAspectRatio;
|
||||
final PlayerType playerType;
|
||||
|
||||
|
||||
// Server/GPU Settings
|
||||
final bool enableNvidiaGpu;
|
||||
|
||||
const IptvSettings({
|
||||
// Filters
|
||||
@@ -103,6 +108,8 @@ class IptvSettings {
|
||||
this.showClock = false,
|
||||
this.preferredAspectRatio = 'contain',
|
||||
this.playerType = PlayerType.standard,
|
||||
// Server/GPU defaults
|
||||
this.enableNvidiaGpu = false,
|
||||
});
|
||||
|
||||
IptvSettings copyWith({
|
||||
@@ -119,6 +126,7 @@ class IptvSettings {
|
||||
bool? showClock,
|
||||
String? preferredAspectRatio,
|
||||
PlayerType? playerType,
|
||||
bool? enableNvidiaGpu,
|
||||
}) {
|
||||
return IptvSettings(
|
||||
liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter,
|
||||
@@ -134,6 +142,7 @@ class IptvSettings {
|
||||
showClock: showClock ?? this.showClock,
|
||||
preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio,
|
||||
playerType: playerType ?? this.playerType,
|
||||
enableNvidiaGpu: enableNvidiaGpu ?? this.enableNvidiaGpu,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,63 +151,84 @@ class IptvSettings {
|
||||
/// Get bitrate in kbps based on quality setting
|
||||
int get bitrateKbps {
|
||||
switch (streamQuality) {
|
||||
case StreamQuality.low: return 1500;
|
||||
case StreamQuality.medium: return 3000;
|
||||
case StreamQuality.high: return 5000;
|
||||
case StreamQuality.low:
|
||||
return 1500;
|
||||
case StreamQuality.medium:
|
||||
return 3000;
|
||||
case StreamQuality.high:
|
||||
return 5000;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get CRF value based on quality setting
|
||||
int get crfValue {
|
||||
switch (streamQuality) {
|
||||
case StreamQuality.low: return 26;
|
||||
case StreamQuality.medium: return 23;
|
||||
case StreamQuality.high: return 20;
|
||||
case StreamQuality.low:
|
||||
return 26;
|
||||
case StreamQuality.medium:
|
||||
return 23;
|
||||
case StreamQuality.high:
|
||||
return 20;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get HLS segment duration in seconds
|
||||
int get hlsSegmentDuration {
|
||||
switch (bufferSize) {
|
||||
case BufferSize.low: return 2;
|
||||
case BufferSize.medium: return 4;
|
||||
case BufferSize.high: return 6;
|
||||
case BufferSize.low:
|
||||
return 2;
|
||||
case BufferSize.medium:
|
||||
return 4;
|
||||
case BufferSize.high:
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get buffer size in KB
|
||||
int get bufferSizeKb {
|
||||
switch (bufferSize) {
|
||||
case BufferSize.low: return 4000;
|
||||
case BufferSize.medium: return 8000;
|
||||
case BufferSize.high: return 12000;
|
||||
case BufferSize.low:
|
||||
return 4000;
|
||||
case BufferSize.medium:
|
||||
return 8000;
|
||||
case BufferSize.high:
|
||||
return 12000;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get connection timeout in seconds
|
||||
int get timeoutSeconds {
|
||||
switch (connectionTimeout) {
|
||||
case ConnectionTimeout.short: return 15;
|
||||
case ConnectionTimeout.medium: return 30;
|
||||
case ConnectionTimeout.long: return 60;
|
||||
case ConnectionTimeout.short:
|
||||
return 15;
|
||||
case ConnectionTimeout.medium:
|
||||
return 30;
|
||||
case ConnectionTimeout.long:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get EPG cache duration in minutes
|
||||
int get epgCacheMinutes {
|
||||
switch (epgCacheDuration) {
|
||||
case EpgCacheDuration.short: return 5;
|
||||
case EpgCacheDuration.medium: return 15;
|
||||
case EpgCacheDuration.long: return 60;
|
||||
case EpgCacheDuration.short:
|
||||
return 5;
|
||||
case EpgCacheDuration.medium:
|
||||
return 15;
|
||||
case EpgCacheDuration.long:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get transcoding mode string for FFmpeg URL parameter
|
||||
String get modeString {
|
||||
switch (transcodingMode) {
|
||||
case TranscodingMode.disabled: return 'direct'; // Passthrough, 0% CPU
|
||||
case TranscodingMode.forced: return 'transcode';
|
||||
case TranscodingMode.auto: return 'auto';
|
||||
case TranscodingMode.disabled:
|
||||
return 'direct'; // Passthrough, 0% CPU
|
||||
case TranscodingMode.forced:
|
||||
return 'transcode';
|
||||
case TranscodingMode.auto:
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,31 +312,54 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
||||
liveTvCategoryFilter: getValue<String>(_SettingsKeys.liveTvFilter),
|
||||
moviesCategoryFilter: getValue<String>(_SettingsKeys.moviesFilter),
|
||||
seriesCategoryFilter: getValue<String>(_SettingsKeys.seriesFilter),
|
||||
|
||||
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
|
||||
? StreamQuality.values[remoteSettings[_SettingsKeys.streamQuality] as int] : null,
|
||||
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
|
||||
? StreamQuality
|
||||
.values[remoteSettings[_SettingsKeys.streamQuality] as int]
|
||||
: null,
|
||||
bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null
|
||||
? BufferSize.values[remoteSettings[_SettingsKeys.bufferSize] as int] : null,
|
||||
connectionTimeout: remoteSettings[_SettingsKeys.connectionTimeout] != null
|
||||
? ConnectionTimeout.values[remoteSettings[_SettingsKeys.connectionTimeout] as int] : null,
|
||||
? BufferSize
|
||||
.values[remoteSettings[_SettingsKeys.bufferSize] as int]
|
||||
: null,
|
||||
connectionTimeout:
|
||||
remoteSettings[_SettingsKeys.connectionTimeout] != null
|
||||
? ConnectionTimeout.values[
|
||||
remoteSettings[_SettingsKeys.connectionTimeout] as int]
|
||||
: null,
|
||||
autoReconnect: getValue<bool>(_SettingsKeys.autoReconnect),
|
||||
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != null
|
||||
? EpgCacheDuration.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] : null,
|
||||
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] !=
|
||||
null
|
||||
? EpgCacheDuration
|
||||
.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int]
|
||||
: null,
|
||||
transcodingMode: remoteSettings[_SettingsKeys.transcodingMode] != null
|
||||
? TranscodingMode.values[remoteSettings[_SettingsKeys.transcodingMode] as int] : null,
|
||||
? TranscodingMode
|
||||
.values[remoteSettings[_SettingsKeys.transcodingMode] as int]
|
||||
: null,
|
||||
preferDirectPlay: getValue<bool>(_SettingsKeys.preferDirectPlay),
|
||||
showClock: getValue<bool>(_SettingsKeys.showClock),
|
||||
preferredAspectRatio: getValue<String>(_SettingsKeys.preferredAspectRatio),
|
||||
preferredAspectRatio:
|
||||
getValue<String>(_SettingsKeys.preferredAspectRatio),
|
||||
playerType: remoteSettings[_SettingsKeys.playerType] != null
|
||||
? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null,
|
||||
? PlayerType
|
||||
.values[remoteSettings[_SettingsKeys.playerType] as int]
|
||||
: null,
|
||||
enableNvidiaGpu: getValue<bool>(_SettingsKeys.enableNvidiaGpu),
|
||||
);
|
||||
}
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
print('Error syncing settings from API: $e');
|
||||
print('Error syncing settings from API: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Timer? _saveDebounceTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_saveDebounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveToApi() async {
|
||||
final authState = _ref.read(authProvider);
|
||||
if (!authState.isAuthenticated) {
|
||||
@@ -314,27 +367,33 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create map from state
|
||||
final settingsMap = {
|
||||
_SettingsKeys.liveTvFilter: state.liveTvCategoryFilter,
|
||||
_SettingsKeys.moviesFilter: state.moviesCategoryFilter,
|
||||
_SettingsKeys.seriesFilter: state.seriesCategoryFilter,
|
||||
_SettingsKeys.streamQuality: state.streamQuality.index,
|
||||
_SettingsKeys.bufferSize: state.bufferSize.index,
|
||||
_SettingsKeys.connectionTimeout: state.connectionTimeout.index,
|
||||
_SettingsKeys.autoReconnect: state.autoReconnect,
|
||||
_SettingsKeys.epgCacheDuration: state.epgCacheDuration.index,
|
||||
_SettingsKeys.transcodingMode: state.transcodingMode.index,
|
||||
_SettingsKeys.preferDirectPlay: state.preferDirectPlay,
|
||||
_SettingsKeys.showClock: state.showClock,
|
||||
_SettingsKeys.preferredAspectRatio: state.preferredAspectRatio,
|
||||
_SettingsKeys.playerType: state.playerType.index,
|
||||
_SettingsKeys.playerType: state.playerType.index,
|
||||
};
|
||||
// Cancel pending save
|
||||
_saveDebounceTimer?.cancel();
|
||||
|
||||
print('[SettingsProvider] _saveToApi: Saving settings...');
|
||||
final success = await _apiService.saveSettings(settingsMap);
|
||||
print('[SettingsProvider] _saveToApi: Save result: $success');
|
||||
// Debounce for 1 second to avoid rapid API calls
|
||||
_saveDebounceTimer = Timer(const Duration(seconds: 1), () async {
|
||||
// Create map from state
|
||||
final settingsMap = {
|
||||
_SettingsKeys.liveTvFilter: state.liveTvCategoryFilter,
|
||||
_SettingsKeys.moviesFilter: state.moviesCategoryFilter,
|
||||
_SettingsKeys.seriesFilter: state.seriesCategoryFilter,
|
||||
_SettingsKeys.streamQuality: state.streamQuality.index,
|
||||
_SettingsKeys.bufferSize: state.bufferSize.index,
|
||||
_SettingsKeys.connectionTimeout: state.connectionTimeout.index,
|
||||
_SettingsKeys.autoReconnect: state.autoReconnect,
|
||||
_SettingsKeys.epgCacheDuration: state.epgCacheDuration.index,
|
||||
_SettingsKeys.transcodingMode: state.transcodingMode.index,
|
||||
_SettingsKeys.preferDirectPlay: state.preferDirectPlay,
|
||||
_SettingsKeys.showClock: state.showClock,
|
||||
_SettingsKeys.preferredAspectRatio: state.preferredAspectRatio,
|
||||
_SettingsKeys.playerType: state.playerType.index,
|
||||
_SettingsKeys.enableNvidiaGpu: state.enableNvidiaGpu,
|
||||
};
|
||||
|
||||
print('[SettingsProvider] _saveToApi: Saving settings...');
|
||||
final success = await _apiService.saveSettings(settingsMap);
|
||||
print('[SettingsProvider] _saveToApi: Save result: $success');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Setters with Auto-Save =====
|
||||
@@ -421,6 +480,13 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
||||
_saveToApi();
|
||||
}
|
||||
|
||||
// ===== Server/GPU Setters =====
|
||||
|
||||
void setEnableNvidiaGpu(bool value) {
|
||||
state = state.copyWith(enableNvidiaGpu: value);
|
||||
_saveToApi();
|
||||
}
|
||||
|
||||
// Legacy methods
|
||||
void setCategoryFilter(String filter) => setLiveTvFilter(filter);
|
||||
void clearCategoryFilter() => clearLiveTvFilter();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
import '../../../core/widgets/digital_clock.dart';
|
||||
import '../widgets/live_tv_tab.dart';
|
||||
import '../widgets/movies_tab.dart';
|
||||
import '../widgets/series_tab.dart';
|
||||
@@ -41,113 +39,154 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 1. Global Atmospheric Background
|
||||
// 1. Cinematic Deep Space Background
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(0, -0.4),
|
||||
radius: 1.3,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF252525), // Center light
|
||||
Color(0xFF000000), // Vignet
|
||||
Color(0xFF0F1014), // Deep Space Blue-Grey
|
||||
Color(0xFF181920), // Darker shade
|
||||
],
|
||||
stops: [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Ambient Glow (Subtle accent)
|
||||
Positioned(
|
||||
top: -100,
|
||||
left: -100,
|
||||
child: Container(
|
||||
width: 500,
|
||||
height: 500,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
AppColors.primary.withOpacity(0.2),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Main Content Area
|
||||
Positioned.fill(
|
||||
top: 0, // Content goes behind header
|
||||
left: 100, // Leave space for sidebar
|
||||
child: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: [
|
||||
// Add padding top internally in tabs or here
|
||||
Padding(padding: const EdgeInsets.only(top: 100), child: LiveTVTab(playlist: widget.playlist)),
|
||||
Padding(padding: const EdgeInsets.only(top: 100), child: MoviesTab(playlist: widget.playlist)),
|
||||
Padding(padding: const EdgeInsets.only(top: 100), child: SeriesTab(playlist: widget.playlist)),
|
||||
const Padding(padding: EdgeInsets.only(top: 100), child: SettingsTab()),
|
||||
LiveTVTab(playlist: widget.playlist),
|
||||
MoviesTab(playlist: widget.playlist),
|
||||
SeriesTab(playlist: widget.playlist),
|
||||
const SettingsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 3. Floating Glass Header
|
||||
// 3. Vertical Glass Sidebar
|
||||
Positioned(
|
||||
top: 24, // Floating margin
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
bottom: 24,
|
||||
width: 80,
|
||||
child: GlassContainer(
|
||||
height: 72,
|
||||
borderRadius: 100, // Pill shape
|
||||
opacity: 0.85,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
borderRadius: 24,
|
||||
opacity: 0.1,
|
||||
// blur is hardcoded in GlassContainer (15), so we don't pass it
|
||||
// border is bool, borderColor allows customization
|
||||
border: true,
|
||||
borderColor: Colors.white.withOpacity(0.1),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo
|
||||
_buildLogo(),
|
||||
|
||||
// Logo Icon
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.4),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Navigation Pills
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(_tabs.length, (index) {
|
||||
final isSelected = _selectedIndex == index;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: TvFocusableCard(
|
||||
onTap: () => setState(() => _selectedIndex = index),
|
||||
scaleFactor: 1.1,
|
||||
borderRadius: 100,
|
||||
focusColor: Colors.white,
|
||||
child: AnimatedContainer(
|
||||
duration: AppTheme.durationFast,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_icons[index],
|
||||
size: 18,
|
||||
color: isSelected ? Colors.black : AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_tabs[index],
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected ? Colors.black : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Navigation Icons
|
||||
...List.generate(_tabs.length, (index) {
|
||||
final isSelected = _selectedIndex == index;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: TvFocusableCard(
|
||||
onTap: () => setState(() => _selectedIndex = index),
|
||||
scaleFactor: 1.2,
|
||||
borderRadius: 16,
|
||||
focusColor: AppColors.primary,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary.withOpacity(0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: AppColors.primary.withOpacity(0.5),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Icon(
|
||||
_icons[index],
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Tools
|
||||
Row(
|
||||
children: [
|
||||
_buildIconButton(Icons.search_rounded, () {}),
|
||||
const SizedBox(width: 16),
|
||||
const DigitalClock(), // Kept existing widget
|
||||
const SizedBox(width: 16),
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: Colors.white.withOpacity(0.1),
|
||||
child: const Icon(Icons.person_rounded, size: 20, color: Colors.white),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Settings / Profile
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: TvFocusableCard(
|
||||
onTap: () {},
|
||||
borderRadius: 50,
|
||||
scaleFactor: 1.1,
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.white.withOpacity(0.1),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
color: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -157,47 +196,4 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconButton(IconData icon, VoidCallback onTap) {
|
||||
return TvFocusableCard(
|
||||
onTap: onTap,
|
||||
borderRadius: 50,
|
||||
scaleFactor: 1.15,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogo() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Colors.white, Color(0xFFAAAAAA)]),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.black, size: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'XtremFlow',
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
color: Colors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/services/playlist_api_service.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
|
||||
/// Provider for fetching playlists from API
|
||||
@@ -55,27 +56,29 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment.center,
|
||||
radius: 1.5,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF2C2C2E), // Dark Grey (Apple TV Surface)
|
||||
Color(0xFF000000), // Pure Black
|
||||
Color(0xFF0F1014), // Deep Space Dark
|
||||
Color(0xFF181920), // Soft Eerie Black
|
||||
],
|
||||
stops: [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
child: playlistsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: Colors.red.shade400),
|
||||
const Icon(Icons.error_outline,
|
||||
size: 64, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Error loading playlists',
|
||||
style: GoogleFonts.roboto(
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 18,
|
||||
color: Colors.white70,
|
||||
),
|
||||
@@ -83,7 +86,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.refresh(playlistsProvider),
|
||||
child: const Text('Retry'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text('Retry', style: GoogleFonts.inter()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -102,7 +109,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No playlists available',
|
||||
style: GoogleFonts.roboto(
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 18,
|
||||
color: Colors.white60,
|
||||
),
|
||||
@@ -112,7 +119,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
currentUser?.isAdmin ?? false
|
||||
? 'Add playlists in Admin Panel'
|
||||
: 'Contact administrator',
|
||||
style: GoogleFonts.roboto(
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: Colors.white38,
|
||||
),
|
||||
@@ -124,16 +131,17 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
24,
|
||||
48,
|
||||
100,
|
||||
24,
|
||||
24,
|
||||
), // Top padding for transparent AppBar
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 24,
|
||||
mainAxisSpacing: 24,
|
||||
childAspectRatio: 1.5,
|
||||
48,
|
||||
48,
|
||||
), // More padding for cinematic feel
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
MediaQuery.of(context).size.width > 1200 ? 4 : 3,
|
||||
crossAxisSpacing: 32,
|
||||
mainAxisSpacing: 32,
|
||||
childAspectRatio: 1.4,
|
||||
),
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -181,20 +189,21 @@ class _PlaylistCardState extends State<_PlaylistCard> {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? Colors.white.withOpacity(0.15)
|
||||
: Colors.white.withOpacity(0.05), // Glass effect
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
? Colors.white.withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.03), // Subtle glass
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? Colors.white.withOpacity(0.5)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
? AppColors.primary.withOpacity(0.5)
|
||||
: Colors.white.withOpacity(0.08),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: _isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
color: AppColors.primary.withOpacity(0.2),
|
||||
blurRadius: 30,
|
||||
spreadRadius: -5,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
]
|
||||
@@ -205,37 +214,57 @@ class _PlaylistCardState extends State<_PlaylistCard> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.2), // Accent color tint
|
||||
gradient: _isHovered
|
||||
? AppColors.primaryGradient
|
||||
: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.1),
|
||||
Colors.white.withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: _isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.4),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.playlist_play,
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 32,
|
||||
color: Colors.white, // White icon
|
||||
color: _isHovered ? Colors.white : Colors.white70,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
widget.playlist.name,
|
||||
style: GoogleFonts.outfit(
|
||||
// Using Outfit for modern look
|
||||
fontSize: 20,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
Uri.tryParse(widget.playlist.dns)?.host ?? widget.playlist.dns,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: Colors.white54,
|
||||
color: Colors.white38,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
|
||||
|
||||
/// Minimal EPG overlay - shows program title and info
|
||||
class EpgOverlay extends ConsumerWidget {
|
||||
final String streamId;
|
||||
@@ -17,9 +16,11 @@ class EpgOverlay extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final epgAsync = ref.watch(epgByPlaylistProvider(
|
||||
EpgRequestKey(playlist: playlist, streamId: streamId),
|
||||
),);
|
||||
final epgAsync = ref.watch(
|
||||
epgByPlaylistProvider(
|
||||
EpgRequestKey(playlist: playlist, streamId: streamId),
|
||||
),
|
||||
);
|
||||
|
||||
return epgAsync.when(
|
||||
data: (epgEntries) {
|
||||
@@ -40,67 +41,69 @@ class EpgOverlay extends ConsumerWidget {
|
||||
|
||||
// Enhanced info display
|
||||
return Align(
|
||||
alignment: Alignment.bottomLeft, // Align to bottom left near controls
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5), // Semi-transparent backing for readability
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'LIVE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12, // Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
alignment: Alignment.bottomLeft, // Align to bottom left near controls
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 0, 24, 0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A2E)
|
||||
.withOpacity(0.85), // Match Native player controls
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'LIVE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12, // Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500), // Wider
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500), // Wider
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentProgram.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18, // Much Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (currentProgram.description.isNotEmpty)
|
||||
Text(
|
||||
currentProgram.title,
|
||||
currentProgram.description,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18, // Much Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
|
||||
color: Colors.white70,
|
||||
fontSize: 13, // Larger desc
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (currentProgram.description.isNotEmpty)
|
||||
Text(
|
||||
currentProgram.description,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13, // Larger desc
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
|
||||
@@ -261,8 +261,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
final columns = ResponsiveLayout.value(
|
||||
context,
|
||||
mobile: 2,
|
||||
tablet: 3,
|
||||
desktop: 4,
|
||||
tablet: 4,
|
||||
desktop: 6,
|
||||
);
|
||||
|
||||
return GridView.builder(
|
||||
@@ -353,8 +353,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
final columns = ResponsiveLayout.value(
|
||||
context,
|
||||
mobile: 2,
|
||||
tablet: 4,
|
||||
desktop: 5,
|
||||
tablet: 5,
|
||||
desktop: 8,
|
||||
);
|
||||
|
||||
return GridView.builder(
|
||||
@@ -383,28 +383,29 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
// Icon Area
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white
|
||||
.withOpacity(0.05), // Subtle transparent background
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(12)),
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: channel.streamIcon.isNotEmpty
|
||||
? Image.network(
|
||||
_getProxiedIconUrl(channel.streamIcon)!,
|
||||
errorBuilder: (_, __, ___) => const Icon(
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.tv,
|
||||
color: Colors.grey,
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
size: 40,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
channel.name.characters.first.toUpperCase(),
|
||||
style: GoogleFonts.inter(
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black12,
|
||||
color: Colors.white24,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/utils/responsive_layout.dart';
|
||||
import '../../../core/widgets/components/hero_carousel.dart';
|
||||
import '../../../core/widgets/hero_carousel.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
import '../providers/watch_history_provider.dart';
|
||||
@@ -305,22 +305,18 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
items: displayMovies
|
||||
.take(5)
|
||||
.map(
|
||||
(m) => HeroItem(
|
||||
(m) => HeroCarouselItem(
|
||||
id: m.streamId,
|
||||
title: m.name,
|
||||
imageUrl: _getProxiedImageUrl(m.streamIcon),
|
||||
subtitle: m.rating != null
|
||||
? '${_formatRating(m.rating)} ★'
|
||||
: null,
|
||||
onMoreInfo: () => _playMovie(m),
|
||||
onPlay: () => _playMovie(m),
|
||||
onTap: () => _playMovie(m),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onTap: (item) {
|
||||
final movie = _movies
|
||||
.firstWhere((element) => element.streamId == item.id);
|
||||
_playMovie(movie);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,10 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/responsive_layout.dart';
|
||||
import '../../../core/widgets/components/hero_carousel.dart';
|
||||
import '../../../core/widgets/themed_loading_screen.dart';
|
||||
import '../../../core/widgets/hero_carousel.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
|
||||
@@ -283,14 +281,23 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
items: displaySeries
|
||||
.take(5)
|
||||
.map(
|
||||
(s) => HeroItem(
|
||||
(s) => HeroCarouselItem(
|
||||
id: s.seriesId.toString(),
|
||||
title: s.name,
|
||||
imageUrl: _getProxiedImageUrl(s.cover),
|
||||
subtitle: s.rating != null
|
||||
? '${_formatRating(s.rating)} ★'
|
||||
: null,
|
||||
onMoreInfo: () {
|
||||
onPlay: () {
|
||||
final series = _series.firstWhere(
|
||||
(element) =>
|
||||
element.seriesId.toString() ==
|
||||
s.seriesId.toString(),
|
||||
orElse: () => s,
|
||||
);
|
||||
_openSeries(series);
|
||||
},
|
||||
onTap: () {
|
||||
final series = _series.firstWhere(
|
||||
(element) =>
|
||||
element.seriesId.toString() ==
|
||||
@@ -302,13 +309,6 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onTap: (item) {
|
||||
final series = _series.firstWhere(
|
||||
(element) => element.seriesId.toString() == item.id,
|
||||
orElse: () => _series[0],
|
||||
);
|
||||
_openSeries(series);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -174,6 +174,29 @@ class StreamingSettingsTab extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Server/GPU Section
|
||||
_buildSectionCard(
|
||||
context: context,
|
||||
title: 'Serveur / GPU',
|
||||
icon: Icons.memory,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
'Accélération NVIDIA GPU',
|
||||
style: GoogleFonts.roboto(fontSize: 14, color: Colors.white),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Utiliser NVENC/NVDEC pour le transcodage (requiert GPU NVIDIA)',
|
||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
|
||||
),
|
||||
value: settings.enableNvidiaGpu,
|
||||
activeThumbColor: Colors.green,
|
||||
onChanged: (v) => notifier.setEnableNvidiaGpu(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Info card
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/models/playlist_config.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/components/hero_carousel.dart';
|
||||
import '../../../../core/widgets/glass_container.dart';
|
||||
import '../../../../features/iptv/models/xtream_models.dart';
|
||||
|
||||
+6
-5
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #0A0E1A 0%, #121829 100%);
|
||||
background: linear-gradient(135deg, #0F1014 0%, #181920 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -55,7 +55,7 @@
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #00BCD4 0%, #26C6DA 50%, #00ACC1 100%);
|
||||
background: linear-gradient(135deg, #007AFF 0%, #00C6FF 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@@ -67,16 +67,17 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto 16px;
|
||||
border: 3px solid rgba(0, 188, 212, 0.2);
|
||||
border-top-color: #00BCD4;
|
||||
border: 3px solid rgba(0, 122, 255, 0.2);
|
||||
border-top-color: #007AFF;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #94A3B8;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
|
||||
+34
-3
@@ -157,12 +157,22 @@
|
||||
// Report on pause and end
|
||||
video.addEventListener('pause', () => {
|
||||
window.parent.postMessage({
|
||||
type: 'playback_position',
|
||||
currentTime: video.currentTime,
|
||||
duration: isFinite(video.duration) ? video.duration : 0
|
||||
type: 'playback_status',
|
||||
status: 'paused'
|
||||
}, '*');
|
||||
});
|
||||
|
||||
video.addEventListener('playing', () => {
|
||||
window.parent.postMessage({
|
||||
type: 'playback_status',
|
||||
status: 'playing'
|
||||
}, '*');
|
||||
|
||||
// Also reset reconnect attempts on successful playing
|
||||
reconnectAttempts = 0;
|
||||
hideLoading();
|
||||
});
|
||||
|
||||
video.addEventListener('ended', () => {
|
||||
window.parent.postMessage({
|
||||
type: 'playback_ended',
|
||||
@@ -471,6 +481,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for messages from Flutter
|
||||
window.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (!data) return;
|
||||
|
||||
log('Received message from parent: ' + JSON.stringify(data));
|
||||
|
||||
if (data.type === 'play') {
|
||||
video.play().catch(e => log('Play command failed: ' + e));
|
||||
} else if (data.type === 'pause') {
|
||||
video.pause();
|
||||
} else if (data.type === 'set_volume') {
|
||||
video.volume = Math.max(0, Math.min(1, parseFloat(data.value)));
|
||||
video.muted = video.volume === 0;
|
||||
} else if (data.type === 'seek') {
|
||||
if (isFinite(video.duration)) {
|
||||
video.currentTime = parseFloat(data.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start player
|
||||
initPlayer();
|
||||
</script>
|
||||
|
||||
Reference in new issue
Block a user