feat: Implement streaming handlers for live MPEG-TS proxy and VOD HLS transcoding via FFmpeg, and add a release build script.

This commit is contained in:
Michael committed 2025-12-16 07:39:33 +01:00
1 parent c7ba52b88d
commit 8128174ac3
26 files changed
+989 -387

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# How to Build XtremFlow as an Executable
To create a standalone `.exe` version of XtremFlow, you need to build it on your machine.
## Prerequisites
Since you are currently using Docker, you likely don't have these installed on your Windows system yet. You must install them to build the Windows executable.
1. **Install Flutter SDK**
* Download from: [flutter.dev/install/windows](https://docs.flutter.dev/get-started/install/windows)
* Extract the zip file to `C:\src\flutter` (for example).
* Add `flutter/bin` to your **System PATH**.
2. **Verify Installation**
* Open a new terminal.
* Run `flutter doctor`.
* Ensure it says "Flutter ... channel stable".
## Building the App
Once Flutter is installed:
1. Open PowerShell in this folder.
2. Run the build script:
```powershell
.\build_release.ps1
```
## Adding FFmpeg
1. After the build finishes, open the new `dist` folder.
2. Go to `dist/ffmpeg/bin`.
3. Download `ffmpeg.exe` (from [ffmpeg.org](https://ffmpeg.org/download.html)) and place it there.
## Running
Double-click `dist/start.bat` to run the server!
+19 -7
View File
@@ -5,13 +5,23 @@ import 'dart:math';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../lib/core/models/playlist_config.dart'; import 'package:xtremflow/core/models/playlist_config.dart';
/// Active FFmpeg processes for VOD transcoding /// Active FFmpeg processes for VOD transcoding
final Map<String, Process> _vodProcesses = {}; final Map<String, Process> _vodProcesses = {};
/// Directory for temporary HLS segments /// Directory for temporary HLS segments
final Directory _hlsTempDir = Directory('/tmp/hls_vod'); final Directory _hlsTempDir = Directory('${Directory.systemTemp.path}/xtremflow_streams');
/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable)
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()) return 'ffmpeg/bin/ffmpeg.exe';
}
return 'ffmpeg'; // Default to PATH
}
/// Initialize streaming subsystem /// Initialize streaming subsystem
Future<void> initStreaming() async { Future<void> initStreaming() async {
@@ -76,11 +86,12 @@ Handler createLiveStreamHandler(Future<PlaylistConfig?> Function(Request) getPla
'-c', 'copy', // Direct stream copy (Very low CPU) '-c', 'copy', // Direct stream copy (Very low CPU)
'-f', 'mpegts', '-f', 'mpegts',
'pipe:1' // Output to stdout 'pipe:1', // Output to stdout
]; ];
try { try {
final process = await Process.start('ffmpeg', ffmpegArgs); final ffmpegPath = _getFFmpegPath();
final process = await Process.start(ffmpegPath, ffmpegArgs);
// Cleanup handling: Kill FFmpeg when client disconnects // Cleanup handling: Kill FFmpeg when client disconnects
final controller = StreamController<List<int>>( final controller = StreamController<List<int>>(
@@ -100,7 +111,7 @@ Handler createLiveStreamHandler(Future<PlaylistConfig?> Function(Request) getPla
onError: (e) { onError: (e) {
print('[Live] FFmpeg stream error: $e'); print('[Live] FFmpeg stream error: $e');
controller.addError(e); controller.addError(e);
} },
); );
// Log errors // Log errors
@@ -257,10 +268,11 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
'-start_number', '0', '-start_number', '0',
// Write playlist to stdout or file? File is easier for static serving // Write playlist to stdout or file? File is easier for static serving
'${streamDir.path}/playlist.m3u8' '${streamDir.path}/playlist.m3u8',
]; ];
Process.start('ffmpeg', ffmpegArgs).then((process) { final ffmpegPath = _getFFmpegPath();
Process.start(ffmpegPath, ffmpegArgs).then((process) {
_vodProcesses[streamId] = process; _vodProcesses[streamId] = process;
process.stderr.transform(utf8.decoder).listen((data) { process.stderr.transform(utf8.decoder).listen((data) {
+37 -16
View File
@@ -12,7 +12,7 @@ class AppDatabase {
/// Initialize database and create tables /// Initialize database and create tables
Future<void> init() async { Future<void> init() async {
final dbPath = '/app/data/xtremflow.db'; const dbPath = '/app/data/xtremflow.db';
// Ensure data directory exists // Ensure data directory exists
final dir = Directory('/app/data'); final dir = Directory('/app/data');
@@ -79,9 +79,12 @@ class AppDatabase {
'''); ''');
// Indexes // Indexes
_db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); _db.execute(
_db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); 'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)');
_db.execute('CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); _db.execute(
'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
_db.execute(
'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)');
} }
/// Seed default admin user if no users exist /// Seed default admin user if no users exist
@@ -93,10 +96,13 @@ class AppDatabase {
final adminId = _uuid.v4(); final adminId = _uuid.v4();
final passwordHash = PasswordHasher.hash('admin'); final passwordHash = PasswordHasher.hash('admin');
_db.execute(''' _db.execute(
'''
INSERT INTO users (id, username, password_hash, is_admin) INSERT INTO users (id, username, password_hash, is_admin)
VALUES (?, ?, ?, 1) VALUES (?, ?, ?, 1)
''', [adminId, 'admin', passwordHash],); ''',
[adminId, 'admin', passwordHash],
);
print('Default admin user created (username: admin, password: admin)'); print('Default admin user created (username: admin, password: admin)');
} }
@@ -148,10 +154,13 @@ class AppDatabase {
final userId = _uuid.v4(); final userId = _uuid.v4();
final passwordHash = PasswordHasher.hash(password); final passwordHash = PasswordHasher.hash(password);
_db.execute(''' _db.execute(
'''
INSERT INTO users (id, username, password_hash, is_admin) INSERT INTO users (id, username, password_hash, is_admin)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', [userId, username, passwordHash, isAdmin ? 1 : 0],); ''',
[userId, username, passwordHash, isAdmin ? 1 : 0],
);
return User( return User(
id: userId, id: userId,
@@ -197,10 +206,13 @@ class AppDatabase {
final token = _uuid.v4(); final token = _uuid.v4();
final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7)); final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7));
_db.execute(''' _db.execute(
'''
INSERT INTO sessions (id, user_id, token, expires_at) INSERT INTO sessions (id, user_id, token, expires_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', [sessionId, userId, token, expiresAt.toIso8601String()],); ''',
[sessionId, userId, token, expiresAt.toIso8601String()],
);
return models.Session( return models.Session(
id: sessionId, id: sessionId,
@@ -279,10 +291,13 @@ class AppDatabase {
final playlistId = _uuid.v4(); final playlistId = _uuid.v4();
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
_db.execute(''' _db.execute(
'''
INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', [playlistId, userId, name, serverUrl, username, password, dns, now, now],); ''',
[playlistId, userId, name, serverUrl, username, password, dns, now, now],
);
return Playlist( return Playlist(
id: playlistId, id: playlistId,
@@ -308,11 +323,14 @@ class AppDatabase {
}) { }) {
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
_db.execute(''' _db.execute(
'''
UPDATE playlists UPDATE playlists
SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ?
WHERE id = ? WHERE id = ?
''', [name, serverUrl, username, password, dns, now, playlistId],); ''',
[name, serverUrl, username, password, dns, now, playlistId],
);
return getPlaylistById(playlistId)!; return getPlaylistById(playlistId)!;
} }
@@ -340,13 +358,16 @@ class AppDatabase {
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
// UPSERT (Insert or Replace) // UPSERT (Insert or Replace)
_db.execute(''' _db.execute(
'''
INSERT INTO user_settings (user_id, settings_json, updated_at) INSERT INTO user_settings (user_id, settings_json, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
settings_json = excluded.settings_json, settings_json = excluded.settings_json,
updated_at = excluded.updated_at updated_at = excluded.updated_at
''', [userId, settingsJson, now]); ''',
[userId, settingsJson, now],
);
} }
/// Close database connection /// Close database connection
+17 -17
View File
@@ -10,7 +10,7 @@ import 'package:args/args.dart';
import 'database/database.dart'; import 'database/database.dart';
import 'models/user.dart'; import 'models/user.dart';
import 'models/playlist.dart'; import 'models/playlist.dart';
import '../lib/core/models/playlist_config.dart'; import 'package:xtremflow/core/models/playlist_config.dart';
import 'api/auth_handler.dart'; import 'api/auth_handler.dart';
import 'api/users_handler.dart'; import 'api/users_handler.dart';
import 'api/playlists_handler.dart'; import 'api/playlists_handler.dart';
@@ -49,17 +49,17 @@ void main(List<String> args) async {
// Auth endpoints // Auth endpoints
..mount('/api/auth', authHandler.router) ..mount('/api/auth', authHandler.router)
// Playlists endpoints // Playlists endpoints
..mount('/api/playlists', Pipeline() ..mount('/api/playlists', const Pipeline()
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler(playlistsHandler.router.call)) .addHandler(playlistsHandler.router.call),)
// Users endpoints // Users endpoints
..mount('/api/users', Pipeline() ..mount('/api/users', const Pipeline()
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler(usersHandler.router.call)) .addHandler(usersHandler.router.call),)
// Settings endpoints // Settings endpoints
..mount('/api/settings', Pipeline() ..mount('/api/settings', const Pipeline()
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler(settingsHandler.router.call)); .addHandler(settingsHandler.router.call),);
// Initialize Cleanup Service // Initialize Cleanup Service
final cleanupService = CleanupService(); final cleanupService = CleanupService();
@@ -70,7 +70,7 @@ void main(List<String> args) async {
cleanupService.start(); cleanupService.start();
// Admin Routes (protected) // Admin Routes (protected)
apiRouter.mount('/api/admin', Pipeline() apiRouter.mount('/api/admin', const Pipeline()
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler((Request request) { .addHandler((Request request) {
final router = Router(); final router = Router();
@@ -90,7 +90,7 @@ void main(List<String> args) async {
}); });
return router(request); return router(request);
})); }),);
// Create static handler // Create static handler
final baseStaticHandler = createStaticHandler( final baseStaticHandler = createStaticHandler(
@@ -100,7 +100,7 @@ void main(List<String> args) async {
); );
// Wrap static handler to enforce cache policies // Wrap static handler to enforce cache policies
Handler staticHandler = (Request request) async { FutureOr<Response> staticHandler(Request request) async {
final response = await baseStaticHandler(request); final response = await baseStaticHandler(request);
// Disable cache for entry points to ensure updates are seen immediately // Disable cache for entry points to ensure updates are seen immediately
@@ -113,17 +113,17 @@ void main(List<String> args) async {
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0', 'Expires': '0',
}); },);
} }
// Allow aggressive caching for hashed assets // Allow aggressive caching for hashed assets
return response.change(headers: { return response.change(headers: {
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'public, max-age=86400',
}); },);
}; }
// Helper to get playlist from request // Helper to get playlist from request
Future<PlaylistConfig?> _getPlaylist(Request request) async { Future<PlaylistConfig?> getPlaylist(Request request) async {
Playlist? playlist; Playlist? playlist;
final user = request.context['user'] as User?; final user = request.context['user'] as User?;
@@ -155,8 +155,8 @@ void main(List<String> args) async {
// Create Streaming Router (Mount handlers on correct paths) // Create Streaming Router (Mount handlers on correct paths)
final streamingRouter = Router() final streamingRouter = Router()
..mount('/api/live', createLiveStreamHandler(_getPlaylist)) ..mount('/api/live', createLiveStreamHandler(getPlaylist))
..mount('/api/vod', createVodStreamHandler(_getPlaylist)); ..mount('/api/vod', createVodStreamHandler(getPlaylist));
// Main handler with API proxy and NEW Streaming Handlers // Main handler with API proxy and NEW Streaming Handlers
final handler = Cascade() final handler = Cascade()
@@ -167,7 +167,7 @@ void main(List<String> args) async {
.handler; .handler;
// Add middleware // Add middleware
final pipeline = Pipeline() final pipeline = const Pipeline()
.addMiddleware(logRequests()) .addMiddleware(logRequests())
.addMiddleware(securityHeadersMiddleware()) .addMiddleware(securityHeadersMiddleware())
.addMiddleware(honeypotMiddleware()) .addMiddleware(honeypotMiddleware())
+45
View File
@@ -0,0 +1,45 @@
$ErrorActionPreference = "Stop"
Write-Host "🚧 Starting XtremFlow Build Process..." -ForegroundColor Cyan
# 1. Clean previous builds
Write-Host "Cleaning previous builds..."
if (Test-Path "dist") { Remove-Item -Recurse -Force "dist" }
if (Test-Path "build") { Remove-Item -Recurse -Force "build" }
New-Item -ItemType Directory -Force "dist"
# 2. Build Flutter Web
Write-Host "📦 Building Flutter Web Frontend..." -ForegroundColor Yellow
flutter build web --release --no-tree-shake-icons
if ($LASTEXITCODE -ne 0) { Write-Error "Flutter build failed"; exit 1 }
# 3. Copy Web Assets to Dist
Write-Host "📂 Copying Web Assets..."
Copy-Item -Recurse "build\web" "dist\web"
# 4. Compile Dart Server
Write-Host "🔨 Compiling Backend Server..." -ForegroundColor Yellow
dart compile exe bin/server.dart -o dist/xtremflow_server.exe
if ($LASTEXITCODE -ne 0) { Write-Error "Dart compile failed"; exit 1 }
# 5. Create Start Script
Write-Host "📝 Creating Launcher Script..."
$startScript = @"
@echo off
echo Starting XtremFlow Server...
echo Open your browser at http://localhost:8089
xtremflow_server.exe --path ./web --port 8089
pause
"@
Set-Content "dist\start.bat" $startScript
# 6. Setup FFmpeg Placeholder
Write-Host "🎥 Setting up FFmpeg folder..."
New-Item -ItemType Directory -Force "dist\ffmpeg\bin"
Set-Content "dist\ffmpeg\README.txt" "Please download ffmpeg.exe and place it in the 'bin' folder here."
# 7. Final Instructions
Write-Host "✅ Build Complete!" -ForegroundColor Green
Write-Host "Output directory: dist/"
Write-Host "IMPORTANT: You must put 'ffmpeg.exe' in dist/ffmpeg/bin/ (or next to the exe)"
+1 -2
View File
@@ -62,7 +62,7 @@ class HiveService {
username: 'admin', username: 'admin',
passwordHash: passwordHash, passwordHash: passwordHash,
isAdmin: true, isAdmin: true,
assignedPlaylistIds: [], assignedPlaylistIds: const [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
@@ -103,7 +103,6 @@ class HiveService {
/// Public method to hash passwords (used by auth) /// Public method to hash passwords (used by auth)
static String hashPassword(String password) => _hashPassword(password); static String hashPassword(String password) => _hashPassword(password);
/// Get users box /// Get users box
static Box<AppUser> get usersBox => Hive.box<AppUser>(_usersBoxName); static Box<AppUser> get usersBox => Hive.box<AppUser>(_usersBoxName);
+2 -3
View File
@@ -62,9 +62,9 @@ class AppColors {
primary: primary, primary: primary,
secondary: accent, secondary: accent,
surface: surface, surface: surface,
background: background,
error: error, error: error,
onPrimary: Colors.black, // White text on buttons -> Black text on White buttons onPrimary: Colors
.black, // White text on buttons -> Black text on White buttons
onSecondary: Colors.black, onSecondary: Colors.black,
onSurface: textPrimary, onSurface: textPrimary,
onError: Colors.white, onError: Colors.white,
@@ -75,7 +75,6 @@ class AppColors {
primary: Colors.black, primary: Colors.black,
secondary: Colors.grey, secondary: Colors.grey,
surface: Color(0xFFF2F2F7), surface: Color(0xFFF2F2F7),
background: Colors.white,
error: error, error: error,
onPrimary: Colors.white, onPrimary: Colors.white,
onSecondary: Colors.white, onSecondary: Colors.white,
-1
View File
@@ -1,7 +1,6 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
class GlassContainer extends StatelessWidget { class GlassContainer extends StatelessWidget {
final Widget child; final Widget child;
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
class ThemedLoading extends StatelessWidget { class ThemedLoading extends StatelessWidget {
const ThemedLoading({super.key}); const ThemedLoading({super.key});
+239 -67
View File
@@ -116,7 +116,8 @@ class _AdminContentState extends ConsumerState<AdminContent>
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref physics:
const NeverScrollableScrollPhysics(), // Custom tabs handle ref
children: const [ children: const [
_PlaylistsTab(), _PlaylistsTab(),
_UsersTab(), _UsersTab(),
@@ -182,7 +183,11 @@ class _PlaylistsTab extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), Text('${playlistsAsync.asData?.value.length ?? 0} Playlists',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70)),
GradientButton( GradientButton(
label: 'Add Playlist', label: 'Add Playlist',
icon: Icons.add, icon: Icons.add,
@@ -194,16 +199,21 @@ class _PlaylistsTab extends ConsumerWidget {
Expanded( Expanded(
child: playlistsAsync.when( child: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), error: (error, stack) => Center(
child: Text('Error: $error',
style: const TextStyle(color: AppColors.error))),
data: (playlists) { data: (playlists) {
if (playlists.isEmpty) { if (playlists.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), Icon(Icons.playlist_remove,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium), Text('No playlists configured',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: () => _showPlaylistDialog(context, ref), onPressed: () => _showPlaylistDialog(context, ref),
@@ -215,7 +225,8 @@ class _PlaylistsTab extends ConsumerWidget {
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: playlists.length, itemCount: playlists.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -231,16 +242,25 @@ class _PlaylistsTab extends ConsumerWidget {
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Icon(Icons.playlist_play, color: AppColors.textPrimary), child: const Icon(Icons.playlist_play,
color: AppColors.textPrimary),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(playlist.name, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), Text(playlist.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), Text(playlist.dns,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60)),
], ],
), ),
), ),
@@ -249,13 +269,15 @@ class _PlaylistsTab extends ConsumerWidget {
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined, size: 20), icon: const Icon(Icons.edit_outlined, size: 20),
color: Colors.white70, color: Colors.white70,
onPressed: () => _showPlaylistDialog(context, ref, playlist), onPressed: () =>
_showPlaylistDialog(context, ref, playlist),
tooltip: 'Edit', tooltip: 'Edit',
), ),
IconButton( IconButton(
icon: const Icon(Icons.delete_outline, size: 20), icon: const Icon(Icons.delete_outline, size: 20),
color: AppColors.error, color: AppColors.error,
onPressed: () => _deletePlaylist(context, ref, playlist), onPressed: () =>
_deletePlaylist(context, ref, playlist),
tooltip: 'Delete', tooltip: 'Delete',
), ),
], ],
@@ -272,7 +294,8 @@ class _PlaylistsTab extends ConsumerWidget {
); );
} }
void _showPlaylistDialog(BuildContext context, WidgetRef ref, [PlaylistConfig? playlist]) { void _showPlaylistDialog(BuildContext context, WidgetRef ref,
[PlaylistConfig? playlist]) {
final nameController = TextEditingController(text: playlist?.name); final nameController = TextEditingController(text: playlist?.name);
final dnsController = TextEditingController(text: playlist?.dns); final dnsController = TextEditingController(text: playlist?.dns);
final usernameController = TextEditingController(text: playlist?.username); final usernameController = TextEditingController(text: playlist?.username);
@@ -284,40 +307,79 @@ class _PlaylistsTab extends ConsumerWidget {
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog( builder: (context, setState) => AlertDialog(
title: Text(playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist'), title: Text(playlist == null
? 'Ajouter une playlist'
: 'Modifier la playlist'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: 'Nom')), TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom')),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: dnsController, decoration: const InputDecoration(labelText: 'URL du serveur', hintText: 'http://server.com:8080')), TextField(
controller: dnsController,
decoration: const InputDecoration(
labelText: 'URL du serveur',
hintText: 'http://server.com:8080')),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Utilisateur')), TextField(
controller: usernameController,
decoration:
const InputDecoration(labelText: 'Utilisateur')),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true), TextField(
controller: passwordController,
decoration:
const InputDecoration(labelText: 'Mot de passe'),
obscureText: true),
], ],
), ),
), ),
actions: [ actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: isLoading ? null : () async { onPressed: isLoading
if (nameController.text.isEmpty || dnsController.text.isEmpty) { ? null
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); : () async {
if (nameController.text.isEmpty ||
dnsController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis')));
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); final dns = dnsController.text
.trim()
.replaceAll(RegExp(r'/$'), '');
if (playlist == null) { if (playlist == null) {
await service.createPlaylist(name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); await service.createPlaylist(
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text);
} else { } else {
await service.updatePlaylist(id: playlist.id, name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); await service.updatePlaylist(
id: playlist.id,
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text);
} }
if (dialogContext.mounted) Navigator.pop(dialogContext); if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(playlistsProvider); ref.invalidate(playlistsProvider);
}, },
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sauvegarder'), child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Sauvegarder'),
), ),
], ],
), ),
@@ -325,14 +387,17 @@ class _PlaylistsTab extends ConsumerWidget {
); );
} }
void _deletePlaylist(BuildContext context, WidgetRef ref, PlaylistConfig playlist) { void _deletePlaylist(
BuildContext context, WidgetRef ref, PlaylistConfig playlist) {
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer la playlist'), title: const Text('Supprimer la playlist'),
content: Text('Supprimer "${playlist.name}" ?'), content: Text('Supprimer "${playlist.name}" ?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: () async { onPressed: () async {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
@@ -363,7 +428,11 @@ class _UsersTab extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), Text('${usersAsync.asData?.value.length ?? 0} Users',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70)),
GradientButton( GradientButton(
label: 'Create User', label: 'Create User',
icon: Icons.person_add, icon: Icons.person_add,
@@ -375,23 +444,29 @@ class _UsersTab extends ConsumerWidget {
Expanded( Expanded(
child: usersAsync.when( child: usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), error: (error, stack) => Center(
child: Text('Error: $error',
style: const TextStyle(color: AppColors.error))),
data: (users) { data: (users) {
if (users.isEmpty) { if (users.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), Icon(Icons.people_outline,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No users found', style: Theme.of(context).textTheme.titleMedium), Text('No users found',
style: Theme.of(context).textTheme.titleMedium),
], ],
), ),
); );
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: users.length, itemCount: users.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -404,12 +479,18 @@ class _UsersTab extends ConsumerWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: user.isAdmin ? AppColors.focusColor : AppColors.surface, color: user.isAdmin
? AppColors.focusColor
: AppColors.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
user.isAdmin ? Icons.admin_panel_settings : Icons.person, user.isAdmin
color: user.isAdmin ? Colors.black : AppColors.textPrimary, ? Icons.admin_panel_settings
: Icons.person,
color: user.isAdmin
? Colors.black
: AppColors.textPrimary,
size: 24, size: 24,
), ),
), ),
@@ -420,38 +501,72 @@ class _UsersTab extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text(user.username, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), Text(user.username,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white)),
if (user.isAdmin) ...[ if (user.isAdmin) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.focusColor.withOpacity(0.2), color: AppColors.focusColor
.withOpacity(0.2),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
), ),
child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)), child: const Text('ADMIN',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: AppColors.focusColor)),
), ),
], ],
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), Text('ID: ${user.id}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60)),
], ],
), ),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white70), icon: const Icon(Icons.more_vert,
color: Colors.white70),
color: AppColors.surface, color: AppColors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
onSelected: (value) { onSelected: (value) {
switch (value) { switch (value) {
case 'password': _showChangePasswordDialog(context, ref, user); break; case 'password':
case 'delete': _deleteUser(context, ref, user); break; _showChangePasswordDialog(context, ref, user);
break;
case 'delete':
_deleteUser(context, ref, user);
break;
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])), const PopupMenuItem(
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])), value: 'password',
child: Row(children: [
Icon(Icons.lock_reset, size: 18),
SizedBox(width: 8),
Text('Change Password')
])),
const PopupMenuItem(
value: 'delete',
child: Row(children: [
Icon(Icons.delete,
color: AppColors.error, size: 18),
SizedBox(width: 8),
Text('Delete',
style: TextStyle(color: AppColors.error))
])),
], ],
), ),
], ],
@@ -480,9 +595,18 @@ class _UsersTab extends ConsumerWidget {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Nom d\'utilisateur', prefixIcon: Icon(Icons.person_outline))), TextField(
controller: usernameController,
decoration: const InputDecoration(
labelText: 'Nom d\'utilisateur',
prefixIcon: Icon(Icons.person_outline))),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline)),
obscureText: true),
const SizedBox(height: 16), const SizedBox(height: 16),
SwitchListTile( SwitchListTile(
title: const Text('Administrateur'), title: const Text('Administrateur'),
@@ -493,22 +617,40 @@ class _UsersTab extends ConsumerWidget {
], ],
), ),
actions: [ actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: isLoading ? null : () async { onPressed: isLoading
if (usernameController.text.isEmpty || passwordController.text.isEmpty) { ? null
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); : () async {
if (usernameController.text.isEmpty ||
passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis')));
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final result = await UserApiService().createUser(username: usernameController.text.trim(), password: passwordController.text, isAdmin: isAdmin); final result = await UserApiService().createUser(
username: usernameController.text.trim(),
password: passwordController.text,
isAdmin: isAdmin);
if (dialogContext.mounted) Navigator.pop(dialogContext); if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(usersProvider); ref.invalidate(usersProvider);
if (!result.success && context.mounted) { if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
} }
}, },
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Créer'), child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Créer'),
), ),
], ],
), ),
@@ -516,7 +658,8 @@ class _UsersTab extends ConsumerWidget {
); );
} }
void _showChangePasswordDialog(BuildContext context, WidgetRef ref, AppUser user) { void _showChangePasswordDialog(
BuildContext context, WidgetRef ref, AppUser user) {
final passwordController = TextEditingController(); final passwordController = TextEditingController();
bool isLoading = false; bool isLoading = false;
@@ -525,25 +668,49 @@ class _UsersTab extends ConsumerWidget {
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog( builder: (context, setState) => AlertDialog(
title: Text('Changer le mot de passe de ${user.username}'), title: Text('Changer le mot de passe de ${user.username}'),
content: TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Nouveau mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), content: TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Nouveau mot de passe',
prefixIcon: Icon(Icons.lock_outline)),
obscureText: true),
actions: [ actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: isLoading ? null : () async { onPressed: isLoading
? null
: () async {
if (passwordController.text.isEmpty) { if (passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mot de passe requis'))); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe requis')));
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final result = await UserApiService().updatePassword(userId: user.id, newPassword: passwordController.text); final result = await UserApiService().updatePassword(
userId: user.id,
newPassword: passwordController.text);
if (dialogContext.mounted) Navigator.pop(dialogContext); if (dialogContext.mounted) Navigator.pop(dialogContext);
if (result.success && context.mounted) { if (result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Mot de passe modifié'), backgroundColor: AppColors.success)); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe modifié'),
backgroundColor: AppColors.success));
} else if (context.mounted) { } else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
} }
}, },
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Changer'), child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Changer'),
), ),
], ],
), ),
@@ -556,16 +723,21 @@ class _UsersTab extends ConsumerWidget {
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer l\'utilisateur'), title: const Text('Supprimer l\'utilisateur'),
content: Text('Supprimer "${user.username}" ? Cette action est irréversible.'), content: Text(
'Supprimer "${user.username}" ? Cette action est irréversible.'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: () async { onPressed: () async {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
final result = await UserApiService().deleteUser(user.id); final result = await UserApiService().deleteUser(user.id);
ref.invalidate(usersProvider); ref.invalidate(usersProvider);
if (!result.success && context.mounted) { if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
} }
}, },
style: FilledButton.styleFrom(backgroundColor: AppColors.error), style: FilledButton.styleFrom(backgroundColor: AppColors.error),
+5 -3
View File
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/tv_focusable_card.dart'; import '../../../core/widgets/tv_focusable_card.dart';
@@ -261,7 +260,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
? const SizedBox( ? const SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.black), child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.black),
) )
: Text( : Text(
'Sign In', 'Sign In',
@@ -332,7 +333,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.focusColor, width: 2), borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
), ),
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
@@ -31,7 +31,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Icons.live_tv_rounded, Icons.live_tv_rounded,
Icons.movie_rounded, Icons.movie_rounded,
Icons.tv_rounded, Icons.tv_rounded,
Icons.settings_rounded Icons.settings_rounded,
]; ];
@override @override
+97 -46
View File
@@ -58,7 +58,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
bool _isInitialized = false; bool _isInitialized = false;
bool _isLoading = true; bool _isLoading = true;
StreamSubscription? _messageSubscription; StreamSubscription? _messageSubscription;
String _aspectRatio = 'contain'; final String _aspectRatio = 'contain';
bool _isSeeking = false; bool _isSeeking = false;
String _viewId = 'iptv-player'; String _viewId = 'iptv-player';
String? _currentStreamUrl; String? _currentStreamUrl;
@@ -70,7 +70,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void initState() { void initState() {
super.initState(); super.initState();
if (widget.channels != null) { if (widget.channels != null) {
_currentIndex = widget.channels!.indexWhere((c) => c.streamId == widget.streamId); _currentIndex =
widget.channels!.indexWhere((c) => c.streamId == widget.streamId);
if (_currentIndex == -1) _currentIndex = 0; if (_currentIndex == -1) _currentIndex = 0;
} }
// Show controls at start, then auto-hide after timeout // Show controls at start, then auto-hide after timeout
@@ -87,7 +88,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
super.dispose(); super.dispose();
} }
Future<void> _initializePlayer({double? startTimeOverride, bool isChannelSwitch = false}) async { Future<void> _initializePlayer(
{double? startTimeOverride, bool isChannelSwitch = false}) async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_statusMessage = 'Loading...'; _statusMessage = 'Loading...';
@@ -99,8 +101,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
? widget.channels![_currentIndex].streamId ? widget.channels![_currentIndex].streamId
: widget.streamId; : widget.streamId;
_viewId = 'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}'; _viewId =
'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}';
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(xtreamServiceProvider(widget.playlist));
String streamUrl = ''; String streamUrl = '';
@@ -110,16 +112,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
if (widget.streamType == StreamType.live) { if (widget.streamType == StreamType.live) {
streamUrl = service.getLiveStreamUrl(currentStreamId); streamUrl = service.getLiveStreamUrl(currentStreamId);
} else if (widget.streamType == StreamType.vod) { } else if (widget.streamType == StreamType.vod) {
streamUrl = service.getVodStreamUrl(currentStreamId, widget.containerExtension); streamUrl =
service.getVodStreamUrl(currentStreamId, widget.containerExtension);
} else if (widget.streamType == StreamType.series) { } else if (widget.streamType == StreamType.series) {
streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension); streamUrl = service.getSeriesStreamUrl(
currentStreamId, widget.containerExtension);
} }
// Store URL for Lite Player // Store URL for Lite Player
_currentStreamUrl = streamUrl; _currentStreamUrl = streamUrl;
final encodedUrl = Uri.encodeComponent(streamUrl); final encodedUrl = Uri.encodeComponent(streamUrl);
final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; final streamTypeParam =
widget.streamType == StreamType.live ? 'live' : 'vod';
// Force player choice based on stream type: // Force player choice based on stream type:
// - Live TV: Player Lite (simple TS playback with mpegts.js) // - Live TV: Player Lite (simple TS playback with mpegts.js)
@@ -199,10 +204,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
ref.read(playbackPositionsProvider.notifier).savePosition( ref.read(playbackPositionsProvider.notifier).savePosition(
widget.streamId, widget.streamId,
currentTime, currentTime,
duration duration,
); );
} }
} else if (type == 'playback_status') { } else if (type == 'playback_status') {
setState(() => _isPlaying = data['status'] == 'playing'); setState(() => _isPlaying = data['status'] == 'playing');
} else if (type == 'user_activity') { } else if (type == 'user_activity') {
@@ -213,7 +217,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void _sendMessage(Map<String, dynamic> message) { void _sendMessage(Map<String, dynamic> message) {
if (_isInitialized) { if (_isInitialized) {
final iframe = html.document.getElementById(_viewId) as html.IFrameElement?; final iframe =
html.document.getElementById(_viewId) as html.IFrameElement?;
iframe?.contentWindow?.postMessage(message, '*'); iframe?.contentWindow?.postMessage(message, '*');
} }
} }
@@ -306,8 +311,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Video Player (iframe with native controls) // Video Player (iframe with native controls)
if (_isInitialized) if (_isInitialized) HtmlElementView(viewType: _viewId),
HtmlElementView(viewType: _viewId),
// Top Bar: Back Button + Title (with auto-hide) // Top Bar: Back Button + Title (with auto-hide)
AnimatedPositioned( AnimatedPositioned(
@@ -326,7 +330,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
child: Icon(Icons.arrow_back_rounded, color: Colors.white), child: Icon(Icons.arrow_back_rounded,
color: Colors.white),
), ),
), ),
), ),
@@ -340,7 +345,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
color: Colors.white, color: Colors.white,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: [Shadow(blurRadius: 4, color: Colors.black)], shadows: [
Shadow(blurRadius: 4, color: Colors.black)
],
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -355,8 +362,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Icon( child: Icon(
_isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, _isMuted
color: Colors.white ? Icons.volume_off_rounded
: Icons.volume_up_rounded,
color: Colors.white,
), ),
), ),
), ),
@@ -385,7 +394,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(14), padding: EdgeInsets.all(14),
child: Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 28), child: Icon(Icons.keyboard_arrow_up,
color: Colors.white, size: 28),
), ),
), ),
), ),
@@ -399,7 +409,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(14), padding: EdgeInsets.all(14),
child: Icon(Icons.keyboard_arrow_down, color: Colors.white, size: 28), child: Icon(Icons.keyboard_arrow_down,
color: Colors.white, size: 28),
), ),
), ),
), ),
@@ -438,8 +449,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// 1. Video Player Layer // 1. Video Player Layer
if (_isInitialized) if (_isInitialized) HtmlElementView(viewType: _viewId),
HtmlElementView(viewType: _viewId),
// 2. Interaction Layer // 2. Interaction Layer
Positioned.fill( Positioned.fill(
@@ -485,7 +495,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
color: Colors.white.withOpacity(0.1), color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.arrow_back_rounded, color: Colors.white), child: const Icon(Icons.arrow_back_rounded,
color: Colors.white),
), ),
), ),
const SizedBox(width: 24), const SizedBox(width: 24),
@@ -503,10 +514,14 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
color: Colors.white, color: Colors.white,
), ),
), ),
if (widget.streamType == StreamType.live && widget.channels != null) if (widget.streamType == StreamType.live &&
widget.channels != null)
Text( Text(
'Live TV', 'Live TV',
style: GoogleFonts.inter(fontSize: 14, color: AppColors.live, fontWeight: FontWeight.bold) style: GoogleFonts.inter(
fontSize: 14,
color: AppColors.live,
fontWeight: FontWeight.bold),
), ),
], ],
), ),
@@ -517,7 +532,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Center Play/Pause (Animated) // Center Play/Pause (Animated)
Center( Center(
child: _isLoading child: _isLoading
? const CircularProgressIndicator(color: Colors.white) ? const CircularProgressIndicator(
color: Colors.white)
: TvFocusableCard( : TvFocusableCard(
onTap: _togglePlayPause, onTap: _togglePlayPause,
borderRadius: 100, borderRadius: 100,
@@ -528,10 +544,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2), color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.3)), border: Border.all(
color: Colors.white.withOpacity(0.3)),
), ),
child: Icon( child: Icon(
_isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, _isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: Colors.white, color: Colors.white,
size: 48, size: 48,
), ),
@@ -570,14 +589,22 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: Row( child: Row(
children: [ children: [
Text(_formatDuration(Duration(seconds: _currentPosition.toInt())), style: const TextStyle(color: Colors.white70)), Text(
_formatDuration(Duration(
seconds:
_currentPosition.toInt())),
style: const TextStyle(
color: Colors.white70)),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: SliderTheme( child: SliderTheme(
data: SliderThemeData( data: const SliderThemeData(
trackHeight: 4, trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), thumbShape: RoundSliderThumbShape(
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), enabledThumbRadius: 8),
overlayShape:
RoundSliderOverlayShape(
overlayRadius: 16),
activeTrackColor: AppColors.primary, activeTrackColor: AppColors.primary,
inactiveTrackColor: Colors.white24, inactiveTrackColor: Colors.white24,
thumbColor: Colors.white, thumbColor: Colors.white,
@@ -591,20 +618,32 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}, },
onChanged: (value) { onChanged: (value) {
// Update UI immediately (optimistic update) // Update UI immediately (optimistic update)
setState(() => _currentPosition = value); setState(() =>
_currentPosition = value);
}, },
onChangeEnd: (value) { onChangeEnd: (value) {
_sendMessage({'type': 'seek', 'value': value}); _sendMessage({
'type': 'seek',
'value': value
});
// Small delay to prevent jitter from incoming messages // Small delay to prevent jitter from incoming messages
Future.delayed(const Duration(milliseconds: 500), () { Future.delayed(
if (mounted) setState(() => _isSeeking = false); const Duration(
milliseconds: 500), () {
if (mounted)
setState(
() => _isSeeking = false);
}); });
}, },
), ),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Text(_formatDuration(Duration(seconds: _totalDuration.toInt())), style: const TextStyle(color: Colors.white70)), Text(
_formatDuration(Duration(
seconds: _totalDuration.toInt())),
style: const TextStyle(
color: Colors.white70)),
], ],
), ),
), ),
@@ -614,10 +653,14 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Channel Zapping for Live TV // Channel Zapping for Live TV
if (widget.streamType == StreamType.live && widget.channels != null) ...[ if (widget.streamType == StreamType.live &&
_buildTvControl(Icons.navigate_before_rounded, _previousChannel), widget.channels != null) ...[
_buildTvControl(
Icons.navigate_before_rounded,
_previousChannel),
const SizedBox(width: 32), const SizedBox(width: 32),
_buildTvControl(Icons.navigate_next_rounded, _nextChannel), _buildTvControl(Icons.navigate_next_rounded,
_nextChannel),
], ],
// Controls for VOD (Keep Skip buttons, they act as Prev/Next track if playlist or maybe seek) // Controls for VOD (Keep Skip buttons, they act as Prev/Next track if playlist or maybe seek)
@@ -625,22 +668,30 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// This implies for VOD they might want seek? But existing buttons were skip_previous/skip_next. // This implies for VOD they might want seek? But existing buttons were skip_previous/skip_next.
// We keep them for VOD if channels list is present, or just hide them if not needed. // We keep them for VOD if channels list is present, or just hide them if not needed.
// For now, I will restore them for VOD as well if channels > 0 (playlist mode) // For now, I will restore them for VOD as well if channels > 0 (playlist mode)
if (widget.streamType != StreamType.live && widget.channels != null && widget.channels!.isNotEmpty) ...[ if (widget.streamType != StreamType.live &&
_buildTvControl(Icons.skip_previous_rounded, _previousChannel), widget.channels != null &&
widget.channels!.isNotEmpty) ...[
_buildTvControl(Icons.skip_previous_rounded,
_previousChannel),
const SizedBox(width: 32), const SizedBox(width: 32),
_buildTvControl(Icons.skip_next_rounded, _nextChannel), _buildTvControl(
Icons.skip_next_rounded, _nextChannel),
], ],
const Spacer(), const Spacer(),
// Volume/Mute button // Volume/Mute button
_buildTvControl( _buildTvControl(
_isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, _isMuted
_toggleMute ? Icons.volume_off_rounded
: Icons.volume_up_rounded,
_toggleMute,
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildTvControl(Icons.subtitles_rounded, () {}), _buildTvControl(
Icons.subtitles_rounded, () {}),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildTvControl(Icons.aspect_ratio_rounded, _toggleFullscreen), _buildTvControl(Icons.aspect_ratio_rounded,
_toggleFullscreen),
], ],
), ),
], ],
@@ -26,14 +26,16 @@ class PlaylistSelectionScreen extends ConsumerWidget {
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
'Select Playlist', 'Select Playlist',
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white),
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
actions: [ actions: [
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false)
IconButton( IconButton(
icon: const Icon(Icons.admin_panel_settings, color: Colors.white70), icon:
const Icon(Icons.admin_panel_settings, color: Colors.white70),
onPressed: () => context.go('/admin'), onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel', tooltip: 'Admin Panel',
), ),
@@ -69,7 +71,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
children: [ children: [
Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), Icon(Icons.error_outline, size: 64, color: Colors.red.shade400),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.roboto(fontSize: 18, color: Colors.white70)), Text('Error loading playlists',
style: GoogleFonts.roboto(
fontSize: 18, color: Colors.white70)),
const SizedBox(height: 8), const SizedBox(height: 8),
ElevatedButton( ElevatedButton(
onPressed: () => ref.refresh(playlistsProvider), onPressed: () => ref.refresh(playlistsProvider),
@@ -84,7 +88,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.playlist_remove, Icons.playlist_remove,
size: 64, size: 64,
color: Colors.white24, color: Colors.white24,
@@ -113,7 +117,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
} }
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.fromLTRB(24, 100, 24, 24), // Top padding for transparent AppBar padding: const EdgeInsets.fromLTRB(
24, 100, 24, 24), // Top padding for transparent AppBar
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 3,
crossAxisSpacing: 24, crossAxisSpacing: 24,
@@ -175,13 +180,15 @@ class _PlaylistCardState extends State<_PlaylistCard> {
: Colors.white.withOpacity(0.1), : Colors.white.withOpacity(0.1),
width: 1.5, width: 1.5,
), ),
boxShadow: _isHovered ? [ boxShadow: _isHovered
? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), color: Colors.black.withOpacity(0.3),
blurRadius: 20, blurRadius: 20,
offset: const Offset(0, 10), offset: const Offset(0, 10),
), ),
] : [], ]
: [],
), ),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@@ -204,7 +211,8 @@ class _PlaylistCardState extends State<_PlaylistCard> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
widget.playlist.name, widget.playlist.name,
style: GoogleFonts.outfit( // Using Outfit for modern look style: GoogleFonts.outfit(
// Using Outfit for modern look
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,
@@ -229,4 +237,3 @@ class _PlaylistCardState extends State<_PlaylistCard> {
); );
} }
} }
@@ -118,7 +118,7 @@ class CategoryCarousel<T> extends StatelessWidget {
height: itemHeight, height: itemHeight,
child: ShaderMask( child: ShaderMask(
shaderCallback: (Rect bounds) { shaderCallback: (Rect bounds) {
return LinearGradient( return const LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.centerLeft,
end: Alignment.centerRight, end: Alignment.centerRight,
colors: [ colors: [
@@ -127,7 +127,7 @@ class CategoryCarousel<T> extends StatelessWidget {
Colors.white, Colors.white,
Colors.transparent, Colors.transparent,
], ],
stops: const [0.0, 0.02, 0.98, 1.0], stops: [0.0, 0.02, 0.98, 1.0],
).createShader(bounds); ).createShader(bounds);
}, },
blendMode: BlendMode.dstIn, blendMode: BlendMode.dstIn,
@@ -181,14 +181,10 @@ class CategoryChipButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: isSelected ? AppColors.primaryGradient : null, gradient: isSelected ? AppColors.primaryGradient : null,
color: isSelected color: isSelected ? null : colorScheme.surfaceContainerHighest,
? null
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: isSelected color: isSelected ? Colors.transparent : colorScheme.outlineVariant,
? Colors.transparent
: colorScheme.outlineVariant,
), ),
), ),
child: Row( child: Row(
@@ -209,9 +205,8 @@ class CategoryChipButton extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isSelected color:
? colorScheme.onPrimary isSelected ? colorScheme.onPrimary : colorScheme.onSurface,
: colorScheme.onSurface,
), ),
), ),
], ],
@@ -1,4 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
@@ -122,7 +122,7 @@ class _LitePlayerViewState extends ConsumerState<LitePlayerView> {
_initializePlayer(); _initializePlayer();
}, },
child: const Text('Retry'), child: const Text('Retry'),
) ),
], ],
), ),
); );
+94 -40
View File
@@ -27,9 +27,8 @@ class LiveTVTab extends ConsumerStatefulWidget {
class _LiveTVTabState extends ConsumerState<LiveTVTab> class _LiveTVTabState extends ConsumerState<LiveTVTab>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
String? _selectedCategory; String? _selectedCategory;
bool _isGridView = true; final bool _isGridView = true;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _showFavoritesOnly = false; bool _showFavoritesOnly = false;
String _searchQuery = ''; String _searchQuery = '';
@@ -57,7 +56,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final channelsAsync =
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider); final favorites = ref.watch(favoritesProvider);
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
@@ -65,11 +65,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: channelsAsync.when( body: channelsAsync.when(
loading: () => const ThemedLoading(), loading: () => const ThemedLoading(),
error: (e, s) => Center(child: Text('Error: $e', style: const TextStyle(color: Colors.white))), error: (e, s) => Center(
child:
Text('Error: $e', style: const TextStyle(color: Colors.white))),
data: (groupedChannels) { data: (groupedChannels) {
var categories = groupedChannels.keys.toList(); var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) { if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
} }
categories.sort(); categories.sort();
@@ -77,11 +81,13 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
bool showingCategoryGrid = false; bool showingCategoryGrid = false;
if (_searchQuery.isNotEmpty) { if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values.expand((l) => l) displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery)) .where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList(); .toList();
} else if (_showFavoritesOnly) { } else if (_showFavoritesOnly) {
displayedChannels = groupedChannels.values.expand((l) => l) displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => favorites.contains(c.streamId)) .where((c) => favorites.contains(c.streamId))
.toList(); .toList();
} else if (_selectedCategory != null) { } else if (_selectedCategory != null) {
@@ -98,10 +104,13 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: GlassContainer( child: GlassContainer(
borderRadius: 16, borderRadius: 16,
opacity: 0.6, opacity: 0.6,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row( child: Row(
children: [ children: [
if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[ if (_selectedCategory != null &&
_searchQuery.isEmpty &&
!_showFavoritesOnly) ...[
TvFocusableCard( TvFocusableCard(
onTap: () { onTap: () {
setState(() { setState(() {
@@ -116,7 +125,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
color: Colors.white.withOpacity(0.1), color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.arrow_back, color: Colors.white), child: const Icon(Icons.arrow_back,
color: Colors.white),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -124,9 +134,11 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Expanded( Expanded(
child: Text( child: Text(
_searchQuery.isNotEmpty ? 'Search Results' : _searchQuery.isNotEmpty
_showFavoritesOnly ? 'Favorites' : ? 'Search Results'
_selectedCategory ?? 'Live TV', : _showFavoritesOnly
? 'Favorites'
: _selectedCategory ?? 'Live TV',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -151,11 +163,16 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search channels...', hintText: 'Search channels...',
hintStyle: GoogleFonts.inter(color: Colors.white54), hintStyle: GoogleFonts.inter(color: Colors.white54),
prefixIcon: const Icon(Icons.search, color: Colors.white54), prefixIcon:
const Icon(Icons.search, color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: _searchQuery.isNotEmpty
? IconButton(icon: const Icon(Icons.clear, color: Colors.white), onPressed: _searchController.clear) ? IconButton(
icon: const Icon(Icons.clear,
color: Colors.white),
onPressed: _searchController.clear)
: null, : null,
), ),
), ),
@@ -175,13 +192,21 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Container( child: Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _showFavoritesOnly ? AppColors.live.withOpacity(0.2) : Colors.white.withOpacity(0.1), color: _showFavoritesOnly
? AppColors.live.withOpacity(0.2)
: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: _showFavoritesOnly ? Border.all(color: AppColors.live) : null, border: _showFavoritesOnly
? Border.all(color: AppColors.live)
: null,
), ),
child: Icon( child: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border, _showFavoritesOnly
color: _showFavoritesOnly ? AppColors.live : Colors.white, ? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.live
: Colors.white,
), ),
), ),
), ),
@@ -199,11 +224,14 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.tv_off, size: 64, color: Colors.white.withOpacity(0.3)), Icon(Icons.tv_off,
size: 64,
color: Colors.white.withOpacity(0.3)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No channels found', 'No channels found',
style: GoogleFonts.inter(color: Colors.white54, fontSize: 18), style: GoogleFonts.inter(
color: Colors.white54, fontSize: 18),
), ),
], ],
), ),
@@ -218,7 +246,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
); );
} }
Widget _buildCategoryGrid(List<String> categories, Map<String, List<Channel>> groupedChannels) { Widget _buildCategoryGrid(
List<String> categories, Map<String, List<Channel>> groupedChannels) {
final columns = ResponsiveLayout.value( final columns = ResponsiveLayout.value(
context, context,
mobile: 2, mobile: 2,
@@ -271,7 +300,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.folder_copy_outlined, color: Colors.white.withOpacity(0.9), size: 32), Icon(Icons.folder_copy_outlined,
color: Colors.white.withOpacity(0.9), size: 32),
const SizedBox(height: 12), const SizedBox(height: 12),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -290,7 +320,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'$count Channels', '$count Channels',
style: GoogleFonts.inter(fontSize: 13, color: Colors.white54), style: GoogleFonts.inter(
fontSize: 13, color: Colors.white54),
), ),
], ],
), ),
@@ -337,48 +368,63 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Icon Area // Icon Area
Expanded( Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), borderRadius:
BorderRadius.vertical(top: Radius.circular(12)),
), ),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Center( child: Center(
child: channel.streamIcon.isNotEmpty child: channel.streamIcon.isNotEmpty
? Image.network( ? Image.network(
_getProxiedIconUrl(channel.streamIcon)!, _getProxiedIconUrl(channel.streamIcon)!,
errorBuilder: (_,__,___) => const Icon(Icons.tv, color: Colors.grey, size: 40), errorBuilder: (_, __, ___) => const Icon(Icons.tv,
color: Colors.grey, size: 40),
) )
: Text( : Text(
channel.name.characters.first.toUpperCase(), channel.name.characters.first.toUpperCase(),
style: GoogleFonts.inter(fontSize: 40, fontWeight: FontWeight.bold, color: Colors.black12), style: GoogleFonts.inter(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Colors.black12),
), ),
), ),
), ),
), ),
// Footer // Footer
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: Colors.black54, color: Colors.black54,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
channel.name, channel.name,
style: GoogleFonts.inter(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500), style: GoogleFonts.inter(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final epgAsync = ref.watch(epgByPlaylistProvider( final epgAsync = ref.watch(
EpgRequestKey(playlist: widget.playlist, streamId: channel.streamId) epgByPlaylistProvider(
)); EpgRequestKey(
playlist: widget.playlist,
streamId: channel.streamId),
),
);
return epgAsync.when( return epgAsync.when(
data: (epgList) { data: (epgList) {
if (epgList.isEmpty) { if (epgList.isEmpty) {
return Text('No Info', style: GoogleFonts.inter(color: Colors.white54, fontSize: 10)); return Text('No Info',
style: GoogleFonts.inter(
color: Colors.white54, fontSize: 10));
} }
final now = DateTime.now(); final now = DateTime.now();
final currentProgram = epgList.firstWhere( final currentProgram = epgList.firstWhere(
@@ -386,7 +432,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
try { try {
final start = DateTime.parse(entry.start); final start = DateTime.parse(entry.start);
final end = DateTime.parse(entry.end); final end = DateTime.parse(entry.end);
return now.isAfter(start) && now.isBefore(end); return now.isAfter(start) &&
now.isBefore(end);
} catch (e) { } catch (e) {
return false; return false;
} }
@@ -396,15 +443,22 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return Text( return Text(
currentProgram.title, currentProgram.title,
style: GoogleFonts.inter(color: const Color(0xFFFFD700), fontSize: 10, fontWeight: FontWeight.w600), style: GoogleFonts.inter(
color: const Color(0xFFFFD700),
fontSize: 10,
fontWeight: FontWeight.w600),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
); );
}, },
loading: () => Text('...', style: GoogleFonts.inter(color: Colors.white24, fontSize: 10)), loading: () => Text('...',
error: (_,__) => Text('Err', style: GoogleFonts.inter(color: Colors.red, fontSize: 10)), style: GoogleFonts.inter(
color: Colors.white24, fontSize: 10)),
error: (_, __) => Text('Err',
style: GoogleFonts.inter(
color: Colors.red, fontSize: 10)),
); );
} },
), ),
], ],
), ),
+71 -26
View File
@@ -193,10 +193,12 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
} else { } else {
displayMovies = settings.moviesKeywords.isEmpty displayMovies = settings.moviesKeywords.isEmpty
? _movies ? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); : _movies
.where((m) => settings.matchesMoviesFilter(m.categoryName))
.toList();
} }
final double gridItemRatio = 0.65; const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value( final int crossAxisCount = ResponsiveLayout.value(
context, context,
mobile: 3, mobile: 3,
@@ -216,7 +218,8 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: GlassContainer( child: GlassContainer(
borderRadius: 100, borderRadius: 100,
opacity: 0.6, opacity: 0.6,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row( child: Row(
children: [ children: [
Text( Text(
@@ -240,31 +243,40 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: Colors.white54), const Icon(Icons.search,
size: 20, color: Colors.white54),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter(fontSize: 14, color: Colors.white), style: GoogleFonts.inter(
fontSize: 14, color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search movies...', hintText: 'Search movies...',
hintStyle: GoogleFonts.inter(color: Colors.white54), hintStyle:
GoogleFonts.inter(color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: const EdgeInsets.only(bottom: 12), contentPadding:
const EdgeInsets.only(bottom: 12),
), ),
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
), ),
), ),
if (_isSearching) if (_isSearching)
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white)),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
child: const Icon(Icons.close, size: 16, color: Colors.white), child: const Icon(Icons.close,
size: 16, color: Colors.white),
), ),
], ],
), ),
@@ -281,15 +293,23 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 32), padding: const EdgeInsets.only(bottom: 32),
child: HeroCarousel( child: HeroCarousel(
items: displayMovies.take(5).map((m) => HeroItem( items: displayMovies
.take(5)
.map(
(m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon), imageUrl: _getProxiedImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, subtitle: m.rating != null
? '${_formatRating(m.rating)} ★'
: null,
onMoreInfo: () => _playMovie(m), onMoreInfo: () => _playMovie(m),
)).toList(), ),
)
.toList(),
onTap: (item) { onTap: (item) {
final movie = _movies.firstWhere((element) => element.streamId == item.id); final movie = _movies
.firstWhere((element) => element.streamId == item.id);
_playMovie(movie); _playMovie(movie);
}, },
), ),
@@ -321,33 +341,44 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
// Poster Image // Poster Image
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: movie.streamIcon != null && movie.streamIcon!.isNotEmpty child: movie.streamIcon != null &&
movie.streamIcon!.isNotEmpty
? Image.network( ? Image.network(
_getProxiedImageUrl(movie.streamIcon), _getProxiedImageUrl(movie.streamIcon),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => Container( errorBuilder: (ctx, err, stack) => Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center(child: Icon(Icons.movie, size: 48, color: Colors.white24)), child: const Center(
child: Icon(Icons.movie,
size: 48, color: Colors.white24)),
), ),
) )
: Container( : Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center(child: Icon(Icons.movie, size: 48, color: Colors.white24)), child: const Center(
child: Icon(Icons.movie,
size: 48, color: Colors.white24)),
), ),
), ),
// Gradient Overlay // Gradient Overlay
Positioned( Positioned(
left: 0, right: 0, bottom: 0, left: 0,
right: 0,
bottom: 0,
height: 80, height: 80,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withOpacity(0.9)], colors: [
Colors.transparent,
Colors.black.withOpacity(0.9)
],
), ),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12)),
), ),
), ),
), ),
@@ -358,7 +389,8 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
top: 8, top: 8,
right: 8, right: 8,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -367,11 +399,15 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, size: 10, color: Colors.amber), const Icon(Icons.star,
size: 10, color: Colors.amber),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(movie.rating)!, _formatRating(movie.rating)!,
style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white), style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white),
), ),
], ],
), ),
@@ -380,7 +416,9 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
// Title // Title
Positioned( Positioned(
left: 12, right: 12, bottom: 12, left: 12,
right: 12,
bottom: 12,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -391,7 +429,10 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
shadows: [const Shadow(color: Colors.black, blurRadius: 4)], shadows: [
const Shadow(
color: Colors.black, blurRadius: 4)
],
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -401,7 +442,10 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
'WATCHED', 'WATCHED',
style: GoogleFonts.inter(color: AppColors.primary, fontSize: 10, fontWeight: FontWeight.bold), style: GoogleFonts.inter(
color: AppColors.primary,
fontSize: 10,
fontWeight: FontWeight.bold),
), ),
), ),
], ],
@@ -421,7 +465,8 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator(color: Colors.white)), child: Center(
child: CircularProgressIndicator(color: Colors.white)),
), ),
), ),
+70 -25
View File
@@ -171,10 +171,12 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
} else { } else {
displaySeries = settings.seriesKeywords.isEmpty displaySeries = settings.seriesKeywords.isEmpty
? _series ? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); : _series
.where((s) => settings.matchesSeriesFilter(s.categoryName))
.toList();
} }
final double gridItemRatio = 0.65; const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value( final int crossAxisCount = ResponsiveLayout.value(
context, context,
mobile: 3, mobile: 3,
@@ -194,7 +196,8 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: GlassContainer( child: GlassContainer(
borderRadius: 100, borderRadius: 100,
opacity: 0.6, opacity: 0.6,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row( child: Row(
children: [ children: [
Text( Text(
@@ -216,31 +219,40 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: Colors.white54), const Icon(Icons.search,
size: 20, color: Colors.white54),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter(fontSize: 14, color: Colors.white), style: GoogleFonts.inter(
fontSize: 14, color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search series...', hintText: 'Search series...',
hintStyle: GoogleFonts.inter(color: Colors.white54), hintStyle:
GoogleFonts.inter(color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: const EdgeInsets.only(bottom: 12), contentPadding:
const EdgeInsets.only(bottom: 12),
), ),
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
), ),
), ),
if (_isSearching) if (_isSearching)
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white)),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
child: const Icon(Icons.close, size: 16, color: Colors.white), child: const Icon(Icons.close,
size: 16, color: Colors.white),
), ),
], ],
), ),
@@ -257,18 +269,31 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 32), padding: const EdgeInsets.only(bottom: 32),
child: HeroCarousel( child: HeroCarousel(
items: displaySeries.take(5).map((s) => HeroItem( items: displaySeries
.take(5)
.map(
(s) => HeroItem(
id: s.seriesId.toString(), id: s.seriesId.toString(),
title: s.name, title: s.name,
imageUrl: _getProxiedImageUrl(s.cover), imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, subtitle: s.rating != null
? '${_formatRating(s.rating)} ★'
: null,
onMoreInfo: () { onMoreInfo: () {
final series = _series.firstWhere((element) => element.seriesId.toString() == s.seriesId.toString(), orElse: () => s); final series = _series.firstWhere(
(element) =>
element.seriesId.toString() ==
s.seriesId.toString(),
orElse: () => s);
_openSeries(series); _openSeries(series);
}, },
)).toList(), ),
)
.toList(),
onTap: (item) { onTap: (item) {
final series = _series.firstWhere((element) => element.seriesId.toString() == item.id, orElse: () => _series[0]); final series = _series.firstWhere(
(element) => element.seriesId.toString() == item.id,
orElse: () => _series[0]);
_openSeries(series); _openSeries(series);
}, },
), ),
@@ -304,41 +329,55 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => Container( errorBuilder: (ctx, err, stack) => Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center(child: Icon(Icons.tv, size: 48, color: Colors.white24)), child: const Center(
child: Icon(Icons.tv,
size: 48, color: Colors.white24)),
), ),
) )
: Container( : Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center(child: Icon(Icons.tv, size: 48, color: Colors.white24)), child: const Center(
child: Icon(Icons.tv,
size: 48, color: Colors.white24)),
), ),
), ),
// Gradient Overlay // Gradient Overlay
Positioned( Positioned(
left: 0, right: 0, bottom: 0, left: 0,
right: 0,
bottom: 0,
height: 80, height: 80,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withOpacity(0.9)], colors: [
Colors.transparent,
Colors.black.withOpacity(0.9)
],
), ),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12)),
), ),
), ),
), ),
// Title // Title
Positioned( Positioned(
left: 12, right: 12, bottom: 12, left: 12,
right: 12,
bottom: 12,
child: Text( child: Text(
serie.name, serie.name,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
shadows: [const Shadow(color: Colors.black, blurRadius: 4)], shadows: [
const Shadow(color: Colors.black, blurRadius: 4)
],
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -351,7 +390,8 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
top: 8, top: 8,
right: 8, right: 8,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -360,11 +400,15 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, size: 10, color: Colors.amber), const Icon(Icons.star,
size: 10, color: Colors.amber),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(serie.rating)!, _formatRating(serie.rating)!,
style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white), style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white),
), ),
], ],
), ),
@@ -384,7 +428,8 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator(color: Colors.white)), child: Center(
child: CircularProgressIndicator(color: Colors.white)),
), ),
), ),
+48 -31
View File
@@ -18,7 +18,8 @@ class SettingsTab extends ConsumerStatefulWidget {
ConsumerState<SettingsTab> createState() => _SettingsTabState(); ConsumerState<SettingsTab> createState() => _SettingsTabState();
} }
class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProviderStateMixin { class _SettingsTabState extends ConsumerState<SettingsTab>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late TextEditingController _liveTvController; late TextEditingController _liveTvController;
late TextEditingController _moviesController; late TextEditingController _moviesController;
@@ -45,13 +46,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
} }
void _syncControllers(IptvSettings settings) { void _syncControllers(IptvSettings settings) {
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) { if (!_initialized ||
_liveTvController.text != settings.liveTvCategoryFilter) {
_liveTvController.text = settings.liveTvCategoryFilter; _liveTvController.text = settings.liveTvCategoryFilter;
} }
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) { if (!_initialized ||
_moviesController.text != settings.moviesCategoryFilter) {
_moviesController.text = settings.moviesCategoryFilter; _moviesController.text = settings.moviesCategoryFilter;
} }
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) { if (!_initialized ||
_seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter; _seriesController.text = settings.seriesCategoryFilter;
} }
_initialized = true; _initialized = true;
@@ -85,13 +89,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
labelColor: Colors.white, labelColor: Colors.white,
unselectedLabelColor: Colors.white54, unselectedLabelColor: Colors.white54,
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
overlayColor: MaterialStateProperty.all(Colors.transparent), overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: [ tabs: [
const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'), const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'),
const Tab(icon: Icon(Icons.stream), text: 'Streaming'), const Tab(icon: Icon(Icons.stream), text: 'Streaming'),
const Tab(icon: Icon(Icons.palette), text: 'Apparence'), const Tab(icon: Icon(Icons.palette), text: 'Apparence'),
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false)
const Tab(icon: Icon(Icons.admin_panel_settings), text: 'Administration'), const Tab(
icon: Icon(Icons.admin_panel_settings),
text: 'Administration'),
], ],
), ),
), ),
@@ -111,8 +117,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
_buildAppearanceTab(context), _buildAppearanceTab(context),
// Admin Tab // Admin Tab
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false) const AdminContent(),
const AdminContent(),
], ],
), ),
), ),
@@ -142,7 +147,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
children: [ children: [
Row( Row(
children: [ children: [
Icon( const Icon(
Icons.dark_mode, Icons.dark_mode,
color: Colors.white, color: Colors.white,
), ),
@@ -206,18 +211,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
title: Text( title: Text(
'Mode actuel', 'Mode actuel',
style: GoogleFonts.inter(fontWeight: FontWeight.w500, color: Colors.white), style: GoogleFonts.inter(
fontWeight: FontWeight.w500, color: Colors.white),
), ),
subtitle: Text( subtitle: Text(
isDark ? 'Thème sombre actif' : 'Thème clair actif', isDark ? 'Thème sombre actif' : 'Thème clair actif',
style: TextStyle(color: Colors.white70), style: const TextStyle(color: Colors.white70),
), ),
trailing: Switch( trailing: Switch(
value: isDark, value: isDark,
onChanged: (_) => themeNotifier.toggleTheme(), onChanged: (_) => themeNotifier.toggleTheme(),
activeColor: AppColors.primary, activeThumbColor: AppColors.primary,
trackColor: MaterialStateProperty.resolveWith( trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected) (states) => states.contains(WidgetState.selected)
? AppColors.primary.withOpacity(0.5) ? AppColors.primary.withOpacity(0.5)
: Colors.grey.shade800, : Colors.grey.shade800,
), ),
@@ -298,8 +304,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
); );
} }
Widget _buildFiltersTab(
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) { BuildContext context, dynamic currentUser, IptvSettings settings) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -319,11 +325,14 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
subtitle: Text( subtitle: Text(
currentUser?.username ?? 'Unknown', currentUser?.username ?? 'Unknown',
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white),
), ),
trailing: currentUser?.isAdmin ?? false trailing: currentUser?.isAdmin ?? false
? Chip( ? Chip(
label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11, color: Colors.black)), label: Text('Admin',
style: GoogleFonts.roboto(
fontSize: 11, color: Colors.black)),
backgroundColor: Colors.white, backgroundColor: Colors.white,
) )
: null, : null,
@@ -398,8 +407,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Change playlist // Change playlist
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -409,7 +416,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
child: ListTile( child: ListTile(
leading: const Icon(Icons.playlist_play, color: Colors.white), leading: const Icon(Icons.playlist_play, color: Colors.white),
title: Text('Changer de Playlist', style: GoogleFonts.roboto(color: Colors.white)), title: Text('Changer de Playlist',
style: GoogleFonts.roboto(color: Colors.white)),
trailing: const Icon(Icons.chevron_right, color: Colors.white54), trailing: const Icon(Icons.chevron_right, color: Colors.white54),
onTap: () => context.go('/playlists'), onTap: () => context.go('/playlists'),
), ),
@@ -425,7 +433,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
child: ListTile( child: ListTile(
leading: const Icon(Icons.info_outline, color: Colors.white), leading: const Icon(Icons.info_outline, color: Colors.white),
title: Text('À propos', style: GoogleFonts.roboto(color: Colors.white)), title: Text('À propos',
style: GoogleFonts.roboto(color: Colors.white)),
subtitle: Text( subtitle: Text(
'XtremFlow IPTV v1.0.0', 'XtremFlow IPTV v1.0.0',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
@@ -443,7 +452,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
child: ListTile( child: ListTile(
leading: const Icon(Icons.cleaning_services, color: Colors.orange), leading: const Icon(Icons.cleaning_services, color: Colors.orange),
title: Text('Maintenance', style: GoogleFonts.roboto(color: Colors.white)), title: Text('Maintenance',
style: GoogleFonts.roboto(color: Colors.white)),
subtitle: Text( subtitle: Text(
'En cas de problèmes d\'affichage ou de mise à jour', 'En cas de problèmes d\'affichage ou de mise à jour',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
@@ -455,7 +465,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
title: const Text('Vider le cache ?', style: TextStyle(color: Colors.white)), title: const Text('Vider le cache ?',
style: TextStyle(color: Colors.white)),
content: const Text( content: const Text(
'Cette action va recharger l\'application et effacer les données temporaires.\n' 'Cette action va recharger l\'application et effacer les données temporaires.\n'
'Vos réglages et favoris sont sauvegardés sur le serveur.', 'Vos réglages et favoris sont sauvegardés sur le serveur.',
@@ -467,7 +478,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
FilledButton( FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.orange), style: FilledButton.styleFrom(
backgroundColor: Colors.orange),
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
@@ -485,7 +497,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
child: const Text('Vider et Recharger'), child: const Text('Vider et Recharger'),
), ),
], ],
) ),
); );
}, },
icon: const Icon(Icons.refresh, size: 16), icon: const Icon(Icons.refresh, size: 16),
@@ -553,7 +565,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Ex: FR,FRANCE,HD,SPORT', hintText: 'Ex: FR,FRANCE,HD,SPORT',
hintStyle: GoogleFonts.roboto(fontSize: 12, color: Colors.white30), hintStyle:
GoogleFonts.roboto(fontSize: 12, color: Colors.white30),
filled: true, filled: true,
fillColor: Colors.black26, fillColor: Colors.black26,
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -567,7 +580,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
suffixIcon: controller.text.isNotEmpty suffixIcon: controller.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear, size: 16, color: Colors.white54), icon: const Icon(Icons.clear,
size: 16, color: Colors.white54),
onPressed: onClear, onPressed: onClear,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
@@ -584,15 +598,18 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
runSpacing: 6, runSpacing: 6,
children: keywords.map((keyword) { children: keywords.map((keyword) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.2), color: Colors.blueAccent.withOpacity(0.2),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
border: Border.all(color: Colors.blueAccent.withOpacity(0.5)), border:
Border.all(color: Colors.blueAccent.withOpacity(0.5)),
), ),
child: Text( child: Text(
keyword, keyword,
style: GoogleFonts.roboto(fontSize: 11, color: Colors.white), style:
GoogleFonts.roboto(fontSize: 11, color: Colors.white),
), ),
); );
}).toList(), }).toList(),
@@ -111,7 +111,7 @@ class StreamingSettingsTab extends ConsumerWidget {
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
), ),
value: settings.autoReconnect, value: settings.autoReconnect,
activeColor: Colors.blueAccent, activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setAutoReconnect(v), onChanged: (v) => notifier.setAutoReconnect(v),
), ),
], ],
@@ -147,10 +147,9 @@ class StreamingSettingsTab extends ConsumerWidget {
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
), ),
value: settings.preferDirectPlay, value: settings.preferDirectPlay,
activeColor: Colors.blueAccent, activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setPreferDirectPlay(v), onChanged: (v) => notifier.setPreferDirectPlay(v),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -253,8 +252,10 @@ class StreamingSettingsTab extends ConsumerWidget {
required String Function(T) labelBuilder, required String Function(T) labelBuilder,
}) { }) {
return ListTile( return ListTile(
title: Text(title, style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), title: Text(title,
subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)),
subtitle: Text(subtitle,
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)),
trailing: DropdownButton<T>( trailing: DropdownButton<T>(
value: value, value: value,
underline: const SizedBox(), underline: const SizedBox(),
@@ -193,7 +193,7 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0, elevation: 0,
).copyWith( ).copyWith(
overlayColor: MaterialStateProperty.all(Colors.black12), overlayColor: WidgetStateProperty.all(Colors.black12),
), ),
child: authState.isLoading child: authState.isLoading
? const SizedBox( ? const SizedBox(
@@ -6,7 +6,7 @@ import '../../../../core/models/playlist_config.dart';
import '../../../../features/iptv/models/xtream_models.dart'; import '../../../../features/iptv/models/xtream_models.dart';
import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/watch_history_provider.dart'; import '../../../../features/iptv/providers/watch_history_provider.dart';
import '../../../../features/iptv/screens/player_screen.dart'; // Explicit import for StreamType // Explicit import for StreamType
import 'mobile_player_screen.dart'; import 'mobile_player_screen.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/glass_container.dart'; import '../../../../core/widgets/glass_container.dart';
@@ -22,10 +22,12 @@ class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
}); });
@override @override
ConsumerState<MobileSeriesDetailScreen> createState() => _MobileSeriesDetailScreenState(); ConsumerState<MobileSeriesDetailScreen> createState() =>
_MobileSeriesDetailScreenState();
} }
class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScreen> { class _MobileSeriesDetailScreenState
extends ConsumerState<MobileSeriesDetailScreen> {
SeriesInfo? _seriesInfo; SeriesInfo? _seriesInfo;
bool _isLoading = true; bool _isLoading = true;
String? _error; String? _error;
@@ -100,10 +102,13 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error), const Icon(Icons.error_outline,
size: 48, color: AppColors.error),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Error: $_error', style: GoogleFonts.inter(color: Colors.white70)), Text('Error: $_error',
TextButton(onPressed: _loadSeriesInfo, child: const Text('Retry')), style: GoogleFonts.inter(color: Colors.white70)),
TextButton(
onPressed: _loadSeriesInfo, child: const Text('Retry')),
], ],
), ),
) )
@@ -145,7 +150,8 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
CachedNetworkImage( CachedNetworkImage(
imageUrl: _seriesInfo!.cover!, imageUrl: _seriesInfo!.cover!,
fit: BoxFit.cover, fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(color: Colors.grey.shade900), errorWidget: (_, __, ___) =>
Container(color: Colors.grey.shade900),
), ),
// Gradient Overlay // Gradient Overlay
Container( Container(
@@ -164,7 +170,9 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
), ),
// Info Overlay // Info Overlay
Positioned( Positioned(
bottom: 16, left: 16, right: 16, bottom: 16,
left: 16,
right: 16,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -181,17 +189,20 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
Row( Row(
children: [ children: [
if (_seriesInfo!.rating != null) ...[ if (_seriesInfo!.rating != null) ...[
const Icon(Icons.star, size: 16, color: Colors.amber), const Icon(Icons.star,
size: 16, color: Colors.amber),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(_seriesInfo!.rating!)!, _formatRating(_seriesInfo!.rating!)!,
style: GoogleFonts.inter(color: Colors.white70, fontSize: 13), style: GoogleFonts.inter(
color: Colors.white70, fontSize: 13),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
Text( Text(
'${_seriesInfo!.episodes.keys.length} Seasons', '${_seriesInfo!.episodes.keys.length} Seasons',
style: GoogleFonts.inter(color: Colors.white70, fontSize: 13), style: GoogleFonts.inter(
color: Colors.white70, fontSize: 13),
), ),
], ],
), ),
@@ -241,7 +252,8 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
color: isSelected ? Colors.white : Colors.white70, color: isSelected ? Colors.white : Colors.white70,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20)),
showCheckmark: false, showCheckmark: false,
side: BorderSide.none, side: BorderSide.none,
); );
@@ -287,7 +299,9 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey); ref
.read(watchHistoryProvider.notifier)
.markEpisodeWatched(episodeKey);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -311,7 +325,9 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isWatched ? AppColors.primary : Colors.white.withOpacity(0.1), color: isWatched
? AppColors.primary
: Colors.white.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
@@ -335,10 +351,12 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (episode.durationSecs != null && episode.durationSecs! > 0) if (episode.durationSecs != null &&
episode.durationSecs! > 0)
Text( Text(
_formatDuration(episode.durationSecs!), _formatDuration(episode.durationSecs!),
style: GoogleFonts.inter(color: Colors.white54, fontSize: 12), style: GoogleFonts.inter(
color: Colors.white54, fontSize: 12),
), ),
], ],
), ),
@@ -34,7 +34,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final channelsAsync =
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider); final favorites = ref.watch(favoritesProvider);
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
@@ -42,21 +43,27 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
backgroundColor: Colors.transparent, // Handled by MobileScaffold backgroundColor: Colors.transparent, // Handled by MobileScaffold
body: channelsAsync.when( body: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(child: Text('Error: $e', style: const TextStyle(color: Colors.white))), error: (e, s) => Center(
child:
Text('Error: $e', style: const TextStyle(color: Colors.white))),
data: (groupedChannels) { data: (groupedChannels) {
var categories = groupedChannels.keys.toList(); var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) { if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
} }
categories.sort(); categories.sort();
List<Channel> displayedChannels = []; List<Channel> displayedChannels = [];
if (_searchQuery.isNotEmpty) { if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values.expand((l) => l) displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery)) .where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList(); .toList();
} else if (_showFavoritesOnly) { } else if (_showFavoritesOnly) {
displayedChannels = groupedChannels.values.expand((l) => l) displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => favorites.contains(c.streamId)) .where((c) => favorites.contains(c.streamId))
.toList(); .toList();
} else { } else {
@@ -78,7 +85,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
child: GlassContainer( child: GlassContainer(
borderRadius: 16, borderRadius: 16,
opacity: 0.1, opacity: 0.1,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -87,9 +95,11 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
style: GoogleFonts.inter(color: Colors.white), style: GoogleFonts.inter(color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search channels...', hintText: 'Search channels...',
hintStyle: GoogleFonts.inter(color: Colors.white54), hintStyle:
GoogleFonts.inter(color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
prefixIcon: const Icon(Icons.search, color: Colors.white54), prefixIcon: const Icon(Icons.search,
color: Colors.white54),
isDense: true, isDense: true,
), ),
), ),
@@ -97,7 +107,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () => _searchController.clear(), onTap: () => _searchController.clear(),
child: const Icon(Icons.close, color: Colors.white54), child:
const Icon(Icons.close, color: Colors.white54),
), ),
], ],
), ),
@@ -117,19 +128,26 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
final category = categories[index]; final category = categories[index];
final isSelected = category == _selectedCategory; final isSelected = category == _selectedCategory;
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _selectedCategory = category), onTap: () =>
setState(() => _selectedCategory = category),
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.black.withOpacity(0.4), color: isSelected
? Colors.white
: Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
category, category,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: isSelected ? Colors.black : Colors.white70, color:
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, isSelected ? Colors.black : Colors.white70,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
fontSize: 13, fontSize: 13,
), ),
), ),
@@ -145,9 +163,11 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
child: Row( child: Row(
children: [ children: [
Text( Text(
_searchQuery.isNotEmpty ? 'Results' : _searchQuery.isNotEmpty
_showFavoritesOnly ? 'Favorites' : ? 'Results'
_selectedCategory ?? 'Channels', : _showFavoritesOnly
? 'Favorites'
: _selectedCategory ?? 'Channels',
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -156,17 +176,26 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
), ),
const Spacer(), const Spacer(),
GestureDetector( GestureDetector(
onTap: () => setState(() => _showFavoritesOnly = !_showFavoritesOnly), onTap: () => setState(
() => _showFavoritesOnly = !_showFavoritesOnly),
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _showFavoritesOnly ? AppColors.live.withOpacity(0.2) : Colors.white.withOpacity(0.1), color: _showFavoritesOnly
? AppColors.live.withOpacity(0.2)
: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: _showFavoritesOnly ? Border.all(color: AppColors.live) : null, border: _showFavoritesOnly
? Border.all(color: AppColors.live)
: null,
), ),
child: Icon( child: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border, _showFavoritesOnly
color: _showFavoritesOnly ? AppColors.live : Colors.white, ? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.live
: Colors.white,
size: 20, size: 20,
), ),
), ),
@@ -182,7 +211,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.tv_off, color: Colors.white24, size: 48), const Icon(Icons.tv_off,
color: Colors.white24, size: 48),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No channels found', 'No channels found',
@@ -194,7 +224,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
itemCount: displayedChannels.length, itemCount: displayedChannels.length,
separatorBuilder: (_, __) => const SizedBox(height: 8), separatorBuilder: (_, __) =>
const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final channel = displayedChannels[index]; final channel = displayedChannels[index];
return _MobileChannelTile( return _MobileChannelTile(
@@ -240,14 +271,17 @@ class _MobileChannelTile extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http') final iconUrl =
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}' ? '/api/xtream/${channel.streamIcon}'
: null; : null;
// Fetch EPG // Fetch EPG
final epgAsync = ref.watch(epgByPlaylistProvider( final epgAsync = ref.watch(
EpgRequestKey(playlist: playlist, streamId: channel.streamId) epgByPlaylistProvider(
)); EpgRequestKey(playlist: playlist, streamId: channel.streamId),
),
);
return GlassContainer( return GlassContainer(
borderRadius: 12, borderRadius: 12,
@@ -272,7 +306,8 @@ class _MobileChannelTile extends ConsumerWidget {
? Image.network( ? Image.network(
iconUrl, iconUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24), errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white24),
) )
: const Icon(Icons.tv, color: Colors.white24), : const Icon(Icons.tv, color: Colors.white24),
), ),
@@ -308,7 +343,8 @@ class _MobileChannelTile extends ConsumerWidget {
return Text( return Text(
current.title, current.title,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: const Color(0xFFFFD700), // Gold/Amber for visibility check color: const Color(
0xFFFFD700), // Gold/Amber for visibility check
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -316,8 +352,11 @@ class _MobileChannelTile extends ConsumerWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
); );
}, },
loading: () => Text('...', style: TextStyle(color: Colors.white38, fontSize: 12)), loading: () => const Text('...',
error: (err, stack) => Text('Err', style: TextStyle(color: Colors.red, fontSize: 10)), style:
TextStyle(color: Colors.white38, fontSize: 12)),
error: (err, stack) => const Text('Err',
style: TextStyle(color: Colors.red, fontSize: 10)),
), ),
], ],
), ),
@@ -328,7 +367,8 @@ class _MobileChannelTile extends ConsumerWidget {
color: Colors.white.withOpacity(0.1), color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 16), child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 16),
), ),
], ],
), ),
@@ -49,7 +49,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
} }
void _onScroll() { void _onScroll() {
if (!_isLoading && _hasMore && if (!_isLoading &&
_hasMore &&
_scrollController.position.pixels >= _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) { _scrollController.position.maxScrollExtent * 0.8) {
_loadMoreMovies(); _loadMoreMovies();
@@ -164,16 +165,23 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
} else { } else {
displayMovies = settings.moviesKeywords.isEmpty displayMovies = settings.moviesKeywords.isEmpty
? _movies ? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); : _movies
.where((m) => settings.matchesMoviesFilter(m.categoryName))
.toList();
} }
final heroItems = displayMovies.take(3).map((m) => HeroItem( final heroItems = displayMovies
.take(3)
.map(
(m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon), imageUrl: _getProxiedImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () => _playMovie(m), onMoreInfo: () => _playMovie(m),
)).toList(); ),
)
.toList();
return SafeArea( return SafeArea(
bottom: false, bottom: false,
@@ -187,7 +195,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
child: GlassContainer( child: GlassContainer(
borderRadius: 100, borderRadius: 100,
opacity: 0.1, // Glass opacity: 0.1, // Glass
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: Colors.white54), const Icon(Icons.search, size: 20, color: Colors.white54),
@@ -195,7 +204,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter(fontSize: 14, color: Colors.white), style: GoogleFonts.inter(
fontSize: 14, color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search Movies...', hintText: 'Search Movies...',
hintStyle: GoogleFonts.inter(color: Colors.white54), hintStyle: GoogleFonts.inter(color: Colors.white54),
@@ -207,14 +217,19 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
), ),
), ),
if (_isSearching) if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white)),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
child: const Icon(Icons.close, size: 16, color: Colors.white), child: const Icon(Icons.close,
size: 16, color: Colors.white),
), ),
], ],
), ),
@@ -232,7 +247,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
final movie = _movies.firstWhere((element) => element.streamId == item.id); final movie = _movies
.firstWhere((element) => element.streamId == item.id);
_playMovie(movie); _playMovie(movie);
}, },
), ),
@@ -259,7 +275,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return GestureDetector( return GestureDetector(
onTap: () => _playMovie(movie), onTap: () => _playMovie(movie),
onLongPress: () { onLongPress: () {
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); ref
.read(watchHistoryProvider.notifier)
.toggleMovieWatched(movie.streamId);
}, },
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@@ -267,46 +285,66 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
// Poster // Poster
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: movie.streamIcon != null && movie.streamIcon!.isNotEmpty child: movie.streamIcon != null &&
movie.streamIcon!.isNotEmpty
? Image.network( ? Image.network(
_getProxiedImageUrl(movie.streamIcon), _getProxiedImageUrl(movie.streamIcon),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: Colors.white10), errorBuilder: (_, __, ___) =>
Container(color: Colors.white10),
) )
: Container(color: Colors.white10, child: const Icon(Icons.movie, color: Colors.white24)), : Container(
color: Colors.white10,
child: const Icon(Icons.movie,
color: Colors.white24)),
), ),
// Gradient Overlay // Gradient Overlay
Positioned( Positioned(
bottom: 0, left: 0, right: 0, bottom: 0,
left: 0,
right: 0,
height: 80, height: 80,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withOpacity(0.9)], colors: [
Colors.transparent,
Colors.black.withOpacity(0.9)
],
), ),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)), borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(16)),
), ),
), ),
), ),
// Title // Title
Positioned( Positioned(
bottom: 12, left: 12, right: 12, bottom: 12,
left: 12,
right: 12,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
movie.name, movie.name,
style: GoogleFonts.inter(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), style: GoogleFonts.inter(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (isWatched) if (isWatched)
Text('WATCHED', style: GoogleFonts.inter(color: AppColors.primary, fontSize: 10, fontWeight: FontWeight.bold)), Text('WATCHED',
style: GoogleFonts.inter(
color: AppColors.primary,
fontSize: 10,
fontWeight: FontWeight.bold)),
], ],
), ),
), ),
@@ -314,19 +352,25 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
// Rating // Rating
if (movie.rating != null && movie.rating!.isNotEmpty) if (movie.rating != null && movie.rating!.isNotEmpty)
Positioned( Positioned(
top: 8, right: 8, top: 8,
right: 8,
child: GlassContainer( child: GlassContainer(
borderRadius: 4, borderRadius: 4,
opacity: 0.6, opacity: 0.6,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, size: 10, color: Colors.amber), const Icon(Icons.star,
size: 10, color: Colors.amber),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(movie.rating)!, _formatRating(movie.rating)!,
style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white), style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white),
), ),
], ],
), ),
@@ -345,7 +389,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(24), padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator(color: Colors.white)), child: Center(
child: CircularProgressIndicator(color: Colors.white)),
), ),
), ),
], ],