feat: add initial Windows desktop platform support files and configuration.

This commit is contained in:
Michael committed 2025-12-16 07:41:08 +01:00
1 parent 8128174ac3
commit 83886e39ae
38 files changed
+2084 -481

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: windows
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+6 -3
View File
@@ -80,11 +80,14 @@ class AppDatabase {
// Indexes // Indexes
_db.execute( _db.execute(
'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); '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_expires ON sessions(expires_at)',
);
_db.execute( _db.execute(
'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); '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
+29
View File
@@ -0,0 +1,29 @@
$ErrorActionPreference = "Stop"
Write-Host "🚧 Starting XtremFlow Windows Build Process..." -ForegroundColor Cyan
# 1. Clean previous builds
Write-Host "Cleaning previous builds..."
if (Test-Path "dist\windows") { Remove-Item -Recurse -Force "dist\windows" }
if (Test-Path "build\windows") { Remove-Item -Recurse -Force "build\windows" }
New-Item -ItemType Directory -Force "dist\windows"
# 2. Build Flutter Windows
Write-Host "📦 Building Flutter Windows Application..." -ForegroundColor Yellow
flutter build windows --release
if ($LASTEXITCODE -ne 0) { Write-Error "Flutter build failed"; exit 1 }
# 3. Copy Build Artifacts to Dist
Write-Host "📂 Copying Build Artifacts..."
$buildDir = "build\windows\x64\runner\Release"
Copy-Item -Recurse "$buildDir\*" "dist\windows"
# 4. Setup FFmpeg Placeholder
Write-Host "🎥 Setting up FFmpeg folder..."
New-Item -ItemType Directory -Force "dist\windows\ffmpeg\bin"
Set-Content "dist\windows\ffmpeg\README.txt" "Please download ffmpeg.exe and place it in the 'bin' folder here."
# 5. Final Instructions
Write-Host "✅ Build Complete!" -ForegroundColor Green
Write-Host "Output directory: dist\windows"
Write-Host "IMPORTANT: You must put 'ffmpeg.exe' in dist/windows/ffmpeg/bin/"
+238 -146
View File
@@ -183,11 +183,13 @@ class _PlaylistsTab extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', Text(
style: Theme.of(context) '${playlistsAsync.asData?.value.length ?? 0} Playlists',
.textTheme style: Theme.of(context)
.bodyLarge .textTheme
?.copyWith(color: Colors.white70)), .bodyLarge
?.copyWith(color: Colors.white70),
),
GradientButton( GradientButton(
label: 'Add Playlist', label: 'Add Playlist',
icon: Icons.add, icon: Icons.add,
@@ -200,20 +202,27 @@ class _PlaylistsTab extends ConsumerWidget {
child: playlistsAsync.when( child: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center( error: (error, stack) => Center(
child: Text('Error: $error', child: Text(
style: const TextStyle(color: AppColors.error))), '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, Icon(
size: 64, Icons.playlist_remove,
color: AppColors.textSecondary.withOpacity(0.5)), size: 64,
color: AppColors.textSecondary.withOpacity(0.5),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No playlists configured', Text(
style: Theme.of(context).textTheme.titleMedium), '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),
@@ -242,25 +251,31 @@ class _PlaylistsTab extends ConsumerWidget {
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Icon(Icons.playlist_play, child: const Icon(
color: AppColors.textPrimary), 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, Text(
style: Theme.of(context) playlist.name,
.textTheme style: Theme.of(context)
.titleMedium .textTheme
?.copyWith(color: Colors.white)), .titleMedium
?.copyWith(color: Colors.white),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(playlist.dns, Text(
style: Theme.of(context) playlist.dns,
.textTheme style: Theme.of(context)
.bodyMedium .textTheme
?.copyWith(color: Colors.white60)), .bodyMedium
?.copyWith(color: Colors.white60),
),
], ],
), ),
), ),
@@ -294,8 +309,11 @@ class _PlaylistsTab extends ConsumerWidget {
); );
} }
void _showPlaylistDialog(BuildContext context, WidgetRef ref, void _showPlaylistDialog(
[PlaylistConfig? playlist]) { 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);
@@ -307,41 +325,44 @@ 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 title: Text(
? 'Ajouter une playlist' playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist',
: 'Modifier la playlist'), ),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( TextField(
controller: nameController, controller: nameController,
decoration: const InputDecoration(labelText: 'Nom')), decoration: const InputDecoration(labelText: 'Nom'),
),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: dnsController, controller: dnsController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'URL du serveur', labelText: 'URL du serveur',
hintText: 'http://server.com:8080')), hintText: 'http://server.com:8080',
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: usernameController, controller: usernameController,
decoration: decoration: const InputDecoration(labelText: 'Utilisateur'),
const InputDecoration(labelText: 'Utilisateur')), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: passwordController, controller: passwordController,
decoration: decoration: const InputDecoration(labelText: 'Mot de passe'),
const InputDecoration(labelText: 'Mot de passe'), obscureText: true,
obscureText: true), ),
], ],
), ),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler'),
child: const Text('Annuler')), ),
FilledButton( FilledButton(
onPressed: isLoading onPressed: isLoading
? null ? null
@@ -349,8 +370,10 @@ class _PlaylistsTab extends ConsumerWidget {
if (nameController.text.isEmpty || if (nameController.text.isEmpty ||
dnsController.text.isEmpty) { dnsController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Tous les champs sont requis'))); content: Text('Tous les champs sont requis'),
),
);
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
@@ -359,17 +382,19 @@ class _PlaylistsTab extends ConsumerWidget {
.replaceAll(RegExp(r'/$'), ''); .replaceAll(RegExp(r'/$'), '');
if (playlist == null) { if (playlist == null) {
await service.createPlaylist( await service.createPlaylist(
name: nameController.text.trim(), name: nameController.text.trim(),
dns: dns, dns: dns,
username: usernameController.text.trim(), username: usernameController.text.trim(),
password: passwordController.text); password: passwordController.text,
);
} else { } else {
await service.updatePlaylist( await service.updatePlaylist(
id: playlist.id, id: playlist.id,
name: nameController.text.trim(), name: nameController.text.trim(),
dns: dns, dns: dns,
username: usernameController.text.trim(), username: usernameController.text.trim(),
password: passwordController.text); password: passwordController.text,
);
} }
if (dialogContext.mounted) Navigator.pop(dialogContext); if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(playlistsProvider); ref.invalidate(playlistsProvider);
@@ -378,7 +403,8 @@ class _PlaylistsTab extends ConsumerWidget {
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2)) child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sauvegarder'), : const Text('Sauvegarder'),
), ),
], ],
@@ -388,7 +414,10 @@ class _PlaylistsTab extends ConsumerWidget {
} }
void _deletePlaylist( void _deletePlaylist(
BuildContext context, WidgetRef ref, PlaylistConfig playlist) { BuildContext context,
WidgetRef ref,
PlaylistConfig playlist,
) {
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -396,8 +425,9 @@ class _PlaylistsTab extends ConsumerWidget {
content: Text('Supprimer "${playlist.name}" ?'), content: Text('Supprimer "${playlist.name}" ?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')), child: const Text('Annuler'),
),
FilledButton( FilledButton(
onPressed: () async { onPressed: () async {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
@@ -428,11 +458,13 @@ class _UsersTab extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('${usersAsync.asData?.value.length ?? 0} Users', Text(
style: Theme.of(context) '${usersAsync.asData?.value.length ?? 0} Users',
.textTheme style: Theme.of(context)
.bodyLarge .textTheme
?.copyWith(color: Colors.white70)), .bodyLarge
?.copyWith(color: Colors.white70),
),
GradientButton( GradientButton(
label: 'Create User', label: 'Create User',
icon: Icons.person_add, icon: Icons.person_add,
@@ -445,20 +477,27 @@ class _UsersTab extends ConsumerWidget {
child: usersAsync.when( child: usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center( error: (error, stack) => Center(
child: Text('Error: $error', child: Text(
style: const TextStyle(color: AppColors.error))), '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, Icon(
size: 64, Icons.people_outline,
color: AppColors.textSecondary.withOpacity(0.5)), size: 64,
color: AppColors.textSecondary.withOpacity(0.5),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No users found', Text(
style: Theme.of(context).textTheme.titleMedium), 'No users found',
style: Theme.of(context).textTheme.titleMedium,
),
], ],
), ),
); );
@@ -501,45 +540,57 @@ class _UsersTab extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text(user.username, Text(
style: Theme.of(context) user.username,
.textTheme style: Theme.of(context)
.titleMedium .textTheme
?.copyWith(color: Colors.white)), .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( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.focusColor color: AppColors.focusColor
.withOpacity(0.2), .withOpacity(0.2),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
), ),
child: const Text('ADMIN', child: const Text(
style: TextStyle( 'ADMIN',
fontSize: 10, style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 10,
color: AppColors.focusColor)), fontWeight: FontWeight.bold,
color: AppColors.focusColor,
),
),
), ),
], ],
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('ID: ${user.id}', Text(
style: Theme.of(context) 'ID: ${user.id}',
.textTheme style: Theme.of(context)
.bodyMedium .textTheme
?.copyWith(color: Colors.white60)), .bodyMedium
?.copyWith(color: Colors.white60),
),
], ],
), ),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, icon: const Icon(
color: Colors.white70), Icons.more_vert,
color: Colors.white70,
),
color: AppColors.surface, color: AppColors.surface,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12),
),
onSelected: (value) { onSelected: (value) {
switch (value) { switch (value) {
case 'password': case 'password':
@@ -552,21 +603,32 @@ class _UsersTab extends ConsumerWidget {
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem( const PopupMenuItem(
value: 'password', value: 'password',
child: Row(children: [ child: Row(
children: [
Icon(Icons.lock_reset, size: 18), Icon(Icons.lock_reset, size: 18),
SizedBox(width: 8), SizedBox(width: 8),
Text('Change Password') Text('Change Password'),
])), ],
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'delete', value: 'delete',
child: Row(children: [ child: Row(
Icon(Icons.delete, children: [
color: AppColors.error, size: 18), Icon(
Icons.delete,
color: AppColors.error,
size: 18,
),
SizedBox(width: 8), SizedBox(width: 8),
Text('Delete', Text(
style: TextStyle(color: AppColors.error)) 'Delete',
])), style: TextStyle(color: AppColors.error),
),
],
),
),
], ],
), ),
], ],
@@ -596,17 +658,21 @@ class _UsersTab extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( TextField(
controller: usernameController, controller: usernameController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Nom d\'utilisateur', labelText: 'Nom d\'utilisateur',
prefixIcon: Icon(Icons.person_outline))), prefixIcon: Icon(Icons.person_outline),
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: passwordController, controller: passwordController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Mot de passe', labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline)), prefixIcon: Icon(Icons.lock_outline),
obscureText: true), ),
obscureText: true,
),
const SizedBox(height: 16), const SizedBox(height: 16),
SwitchListTile( SwitchListTile(
title: const Text('Administrateur'), title: const Text('Administrateur'),
@@ -618,9 +684,9 @@ class _UsersTab extends ConsumerWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler'),
child: const Text('Annuler')), ),
FilledButton( FilledButton(
onPressed: isLoading onPressed: isLoading
? null ? null
@@ -628,28 +694,35 @@ class _UsersTab extends ConsumerWidget {
if (usernameController.text.isEmpty || if (usernameController.text.isEmpty ||
passwordController.text.isEmpty) { passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Tous les champs sont requis'))); content: Text('Tous les champs sont requis'),
),
);
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final result = await UserApiService().createUser( final result = await UserApiService().createUser(
username: usernameController.text.trim(), username: usernameController.text.trim(),
password: passwordController.text, password: passwordController.text,
isAdmin: isAdmin); 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.error ?? 'Erreur'), content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error)); backgroundColor: AppColors.error,
),
);
} }
}, },
child: isLoading child: isLoading
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2)) child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Créer'), : const Text('Créer'),
), ),
], ],
@@ -659,7 +732,10 @@ class _UsersTab extends ConsumerWidget {
} }
void _showChangePasswordDialog( void _showChangePasswordDialog(
BuildContext context, WidgetRef ref, AppUser user) { BuildContext context,
WidgetRef ref,
AppUser user,
) {
final passwordController = TextEditingController(); final passwordController = TextEditingController();
bool isLoading = false; bool isLoading = false;
@@ -669,47 +745,58 @@ class _UsersTab extends ConsumerWidget {
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( content: TextField(
controller: passwordController, controller: passwordController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Nouveau mot de passe', labelText: 'Nouveau mot de passe',
prefixIcon: Icon(Icons.lock_outline)), prefixIcon: Icon(Icons.lock_outline),
obscureText: true), ),
obscureText: true,
),
actions: [ actions: [
TextButton( TextButton(
onPressed: onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler'),
child: const Text('Annuler')), ),
FilledButton( FilledButton(
onPressed: isLoading onPressed: isLoading
? null ? null
: () async { : () async {
if (passwordController.text.isEmpty) { if (passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Mot de passe requis'))); content: Text('Mot de passe requis'),
),
);
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final result = await UserApiService().updatePassword( final result = await UserApiService().updatePassword(
userId: user.id, userId: user.id,
newPassword: passwordController.text); 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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Mot de passe modifié'), content: Text('Mot de passe modifié'),
backgroundColor: AppColors.success)); backgroundColor: AppColors.success,
),
);
} else if (context.mounted) { } else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.error ?? 'Erreur'), content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error)); backgroundColor: AppColors.error,
),
);
} }
}, },
child: isLoading child: isLoading
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2)) child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Changer'), : const Text('Changer'),
), ),
], ],
@@ -724,20 +811,25 @@ class _UsersTab extends ConsumerWidget {
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer l\'utilisateur'), title: const Text('Supprimer l\'utilisateur'),
content: Text( content: Text(
'Supprimer "${user.username}" ? Cette action est irréversible.'), 'Supprimer "${user.username}" ? Cette action est irréversible.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')), 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.error ?? 'Erreur'), content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error)); backgroundColor: AppColors.error,
),
);
} }
}, },
style: FilledButton.styleFrom(backgroundColor: AppColors.error), style: FilledButton.styleFrom(backgroundColor: AppColors.error),
+3 -2
View File
@@ -261,8 +261,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: Colors.black), color: Colors.black,
),
) )
: Text( : Text(
'Sign In', 'Sign In',
+87 -44
View File
@@ -88,8 +88,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
super.dispose(); super.dispose();
} }
Future<void> _initializePlayer( Future<void> _initializePlayer({
{double? startTimeOverride, bool isChannelSwitch = false}) async { double? startTimeOverride,
bool isChannelSwitch = false,
}) async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_statusMessage = 'Loading...'; _statusMessage = 'Loading...';
@@ -116,7 +118,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
service.getVodStreamUrl(currentStreamId, widget.containerExtension); service.getVodStreamUrl(currentStreamId, widget.containerExtension);
} else if (widget.streamType == StreamType.series) { } else if (widget.streamType == StreamType.series) {
streamUrl = service.getSeriesStreamUrl( streamUrl = service.getSeriesStreamUrl(
currentStreamId, widget.containerExtension); currentStreamId,
widget.containerExtension,
);
} }
// Store URL for Lite Player // Store URL for Lite Player
@@ -330,8 +334,10 @@ 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, child: Icon(
color: Colors.white), Icons.arrow_back_rounded,
color: Colors.white,
),
), ),
), ),
), ),
@@ -346,7 +352,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: [ shadows: [
Shadow(blurRadius: 4, color: Colors.black) Shadow(blurRadius: 4, color: Colors.black),
], ],
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -394,8 +400,11 @@ 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, child: Icon(
color: Colors.white, size: 28), Icons.keyboard_arrow_up,
color: Colors.white,
size: 28,
),
), ),
), ),
), ),
@@ -409,8 +418,11 @@ 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, child: Icon(
color: Colors.white, size: 28), Icons.keyboard_arrow_down,
color: Colors.white,
size: 28,
),
), ),
), ),
), ),
@@ -495,8 +507,10 @@ 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, child: const Icon(
color: Colors.white), Icons.arrow_back_rounded,
color: Colors.white,
),
), ),
), ),
const SizedBox(width: 24), const SizedBox(width: 24),
@@ -519,9 +533,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Text( Text(
'Live TV', 'Live TV',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 14, fontSize: 14,
color: AppColors.live, color: AppColors.live,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
@@ -533,7 +548,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Center( Center(
child: _isLoading child: _isLoading
? const CircularProgressIndicator( ? const CircularProgressIndicator(
color: Colors.white) color: Colors.white,
)
: TvFocusableCard( : TvFocusableCard(
onTap: _togglePlayPause, onTap: _togglePlayPause,
borderRadius: 100, borderRadius: 100,
@@ -545,7 +561,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
color: Colors.white.withOpacity(0.2), color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: Colors.white.withOpacity(0.3)), color: Colors.white.withOpacity(0.3),
),
), ),
child: Icon( child: Icon(
_isPlaying _isPlaying
@@ -590,21 +607,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: Row( child: Row(
children: [ children: [
Text( Text(
_formatDuration(Duration( _formatDuration(
seconds: Duration(
_currentPosition.toInt())), seconds: _currentPosition.toInt(),
style: const TextStyle( ),
color: Colors.white70)), ),
style: const TextStyle(
color: Colors.white70,
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: SliderTheme( child: SliderTheme(
data: const SliderThemeData( data: const SliderThemeData(
trackHeight: 4, trackHeight: 4,
thumbShape: RoundSliderThumbShape( thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 8), enabledThumbRadius: 8,
),
overlayShape: overlayShape:
RoundSliderOverlayShape( RoundSliderOverlayShape(
overlayRadius: 16), overlayRadius: 16,
),
activeTrackColor: AppColors.primary, activeTrackColor: AppColors.primary,
inactiveTrackColor: Colors.white24, inactiveTrackColor: Colors.white24,
thumbColor: Colors.white, thumbColor: Colors.white,
@@ -618,21 +641,25 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}, },
onChanged: (value) { onChanged: (value) {
// Update UI immediately (optimistic update) // Update UI immediately (optimistic update)
setState(() => setState(
_currentPosition = value); () => _currentPosition = value,
);
}, },
onChangeEnd: (value) { onChangeEnd: (value) {
_sendMessage({ _sendMessage({
'type': 'seek', 'type': 'seek',
'value': value 'value': value,
}); });
// Small delay to prevent jitter from incoming messages // Small delay to prevent jitter from incoming messages
Future.delayed( Future.delayed(
const Duration( const Duration(
milliseconds: 500), () { milliseconds: 500,
if (mounted) ), () {
if (mounted) {
setState( setState(
() => _isSeeking = false); () => _isSeeking = false,
);
}
}); });
}, },
), ),
@@ -640,10 +667,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Text( Text(
_formatDuration(Duration( _formatDuration(
seconds: _totalDuration.toInt())), Duration(
style: const TextStyle( seconds: _totalDuration.toInt(),
color: Colors.white70)), ),
),
style: const TextStyle(
color: Colors.white70,
),
),
], ],
), ),
), ),
@@ -656,11 +688,14 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
if (widget.streamType == StreamType.live && if (widget.streamType == StreamType.live &&
widget.channels != null) ...[ widget.channels != null) ...[
_buildTvControl( _buildTvControl(
Icons.navigate_before_rounded, Icons.navigate_before_rounded,
_previousChannel), _previousChannel,
),
const SizedBox(width: 32), const SizedBox(width: 32),
_buildTvControl(Icons.navigate_next_rounded, _buildTvControl(
_nextChannel), 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)
@@ -671,11 +706,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
if (widget.streamType != StreamType.live && if (widget.streamType != StreamType.live &&
widget.channels != null && widget.channels != null &&
widget.channels!.isNotEmpty) ...[ widget.channels!.isNotEmpty) ...[
_buildTvControl(Icons.skip_previous_rounded, _buildTvControl(
_previousChannel), Icons.skip_previous_rounded,
_previousChannel,
),
const SizedBox(width: 32), const SizedBox(width: 32),
_buildTvControl( _buildTvControl(
Icons.skip_next_rounded, _nextChannel), Icons.skip_next_rounded,
_nextChannel,
),
], ],
const Spacer(), const Spacer(),
@@ -688,10 +727,14 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildTvControl( _buildTvControl(
Icons.subtitles_rounded, () {}), Icons.subtitles_rounded,
() {},
),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildTvControl(Icons.aspect_ratio_rounded, _buildTvControl(
_toggleFullscreen), Icons.aspect_ratio_rounded,
_toggleFullscreen,
),
], ],
), ),
], ],
@@ -27,7 +27,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
title: Text( title: Text(
'Select Playlist', 'Select Playlist',
style: GoogleFonts.roboto( style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white), fontWeight: FontWeight.w600,
color: Colors.white,
),
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
@@ -71,9 +73,13 @@ 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', Text(
style: GoogleFonts.roboto( 'Error loading playlists',
fontSize: 18, color: Colors.white70)), 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),
@@ -118,7 +124,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
24, 100, 24, 24), // Top padding for transparent AppBar 24,
100,
24,
24,
), // Top padding for transparent AppBar
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 3,
crossAxisSpacing: 24, crossAxisSpacing: 24,
+75 -38
View File
@@ -66,8 +66,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
body: channelsAsync.when( body: channelsAsync.when(
loading: () => const ThemedLoading(), loading: () => const ThemedLoading(),
error: (e, s) => Center( error: (e, s) => Center(
child: child: Text('Error: $e', style: const TextStyle(color: Colors.white)),
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) {
@@ -125,8 +125,10 @@ 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, child: const Icon(
color: Colors.white), Icons.arrow_back,
color: Colors.white,
),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -167,12 +169,17 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
const Icon(Icons.search, color: Colors.white54), const Icon(Icons.search, color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10), horizontal: 16,
vertical: 10,
),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear, icon: const Icon(
color: Colors.white), Icons.clear,
onPressed: _searchController.clear) color: Colors.white,
),
onPressed: _searchController.clear,
)
: null, : null,
), ),
), ),
@@ -224,14 +231,18 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.tv_off, Icon(
size: 64, Icons.tv_off,
color: Colors.white.withOpacity(0.3)), 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( style: GoogleFonts.inter(
color: Colors.white54, fontSize: 18), color: Colors.white54,
fontSize: 18,
),
), ),
], ],
), ),
@@ -247,7 +258,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
} }
Widget _buildCategoryGrid( Widget _buildCategoryGrid(
List<String> categories, Map<String, List<Channel>> groupedChannels) { List<String> categories,
Map<String, List<Channel>> groupedChannels,
) {
final columns = ResponsiveLayout.value( final columns = ResponsiveLayout.value(
context, context,
mobile: 2, mobile: 2,
@@ -300,8 +313,11 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.folder_copy_outlined, Icon(
color: Colors.white.withOpacity(0.9), size: 32), 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),
@@ -321,7 +337,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Text( Text(
'$count Channels', '$count Channels',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 13, color: Colors.white54), fontSize: 13,
color: Colors.white54,
),
), ),
], ],
), ),
@@ -378,15 +396,19 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: channel.streamIcon.isNotEmpty child: channel.streamIcon.isNotEmpty
? Image.network( ? Image.network(
_getProxiedIconUrl(channel.streamIcon)!, _getProxiedIconUrl(channel.streamIcon)!,
errorBuilder: (_, __, ___) => const Icon(Icons.tv, errorBuilder: (_, __, ___) => const Icon(
color: Colors.grey, size: 40), Icons.tv,
color: Colors.grey,
size: 40,
),
) )
: Text( : Text(
channel.name.characters.first.toUpperCase(), channel.name.characters.first.toUpperCase(),
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 40, fontSize: 40,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.black12), color: Colors.black12,
),
), ),
), ),
), ),
@@ -402,9 +424,10 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Text( Text(
channel.name, channel.name,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500), fontWeight: FontWeight.w500,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -414,17 +437,22 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
final epgAsync = ref.watch( final epgAsync = ref.watch(
epgByPlaylistProvider( epgByPlaylistProvider(
EpgRequestKey( EpgRequestKey(
playlist: widget.playlist, playlist: widget.playlist,
streamId: channel.streamId), streamId: channel.streamId,
),
), ),
); );
return epgAsync.when( return epgAsync.when(
data: (epgList) { data: (epgList) {
if (epgList.isEmpty) { if (epgList.isEmpty) {
return Text('No Info', return Text(
style: GoogleFonts.inter( 'No Info',
color: Colors.white54, fontSize: 10)); style: GoogleFonts.inter(
color: Colors.white54,
fontSize: 10,
),
);
} }
final now = DateTime.now(); final now = DateTime.now();
final currentProgram = epgList.firstWhere( final currentProgram = epgList.firstWhere(
@@ -444,19 +472,28 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return Text( return Text(
currentProgram.title, currentProgram.title,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: const Color(0xFFFFD700), color: const Color(0xFFFFD700),
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w600), fontWeight: FontWeight.w600,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
); );
}, },
loading: () => Text('...', loading: () => Text(
style: GoogleFonts.inter( '...',
color: Colors.white24, fontSize: 10)), style: GoogleFonts.inter(
error: (_, __) => Text('Err', color: Colors.white24,
style: GoogleFonts.inter( fontSize: 10,
color: Colors.red, fontSize: 10)), ),
),
error: (_, __) => Text(
'Err',
style: GoogleFonts.inter(
color: Colors.red,
fontSize: 10,
),
),
); );
}, },
), ),
+56 -26
View File
@@ -243,14 +243,19 @@ 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, const Icon(
size: 20, color: Colors.white54), 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( style: GoogleFonts.inter(
fontSize: 14, color: Colors.white), fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search movies...', hintText: 'Search movies...',
hintStyle: hintStyle:
@@ -265,18 +270,24 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
), ),
if (_isSearching) if (_isSearching)
const SizedBox( const SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white)), 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, child: const Icon(
size: 16, color: Colors.white), Icons.close,
size: 16,
color: Colors.white,
),
), ),
], ],
), ),
@@ -349,15 +360,23 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
errorBuilder: (ctx, err, stack) => Container( errorBuilder: (ctx, err, stack) => Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center( child: const Center(
child: Icon(Icons.movie, child: Icon(
size: 48, color: Colors.white24)), Icons.movie,
size: 48,
color: Colors.white24,
),
),
), ),
) )
: Container( : Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center( child: const Center(
child: Icon(Icons.movie, child: Icon(
size: 48, color: Colors.white24)), Icons.movie,
size: 48,
color: Colors.white24,
),
),
), ),
), ),
@@ -374,11 +393,12 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Colors.transparent, Colors.transparent,
Colors.black.withOpacity(0.9) Colors.black.withOpacity(0.9),
], ],
), ),
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12)), bottom: Radius.circular(12),
),
), ),
), ),
), ),
@@ -390,7 +410,9 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
right: 8, right: 8,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2), 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),
@@ -399,15 +421,19 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, const Icon(
size: 10, color: Colors.amber), 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( style: GoogleFonts.inter(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white), color: Colors.white,
),
), ),
], ],
), ),
@@ -431,7 +457,9 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
shadows: [ shadows: [
const Shadow( const Shadow(
color: Colors.black, blurRadius: 4) color: Colors.black,
blurRadius: 4,
),
], ],
), ),
maxLines: 2, maxLines: 2,
@@ -443,9 +471,10 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: Text( child: Text(
'WATCHED', 'WATCHED',
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: AppColors.primary, color: AppColors.primary,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
),
), ),
), ),
], ],
@@ -466,7 +495,8 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
child: Center( child: Center(
child: CircularProgressIndicator(color: Colors.white)), child: CircularProgressIndicator(color: Colors.white),
),
), ),
), ),
+59 -29
View File
@@ -219,14 +219,19 @@ 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, const Icon(
size: 20, color: Colors.white54), 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( style: GoogleFonts.inter(
fontSize: 14, color: Colors.white), fontSize: 14,
color: Colors.white,
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search series...', hintText: 'Search series...',
hintStyle: hintStyle:
@@ -241,18 +246,24 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
), ),
if (_isSearching) if (_isSearching)
const SizedBox( const SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white)), 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, child: const Icon(
size: 16, color: Colors.white), Icons.close,
size: 16,
color: Colors.white,
),
), ),
], ],
), ),
@@ -281,10 +292,11 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
: null, : null,
onMoreInfo: () { onMoreInfo: () {
final series = _series.firstWhere( final series = _series.firstWhere(
(element) => (element) =>
element.seriesId.toString() == element.seriesId.toString() ==
s.seriesId.toString(), s.seriesId.toString(),
orElse: () => s); orElse: () => s,
);
_openSeries(series); _openSeries(series);
}, },
), ),
@@ -292,8 +304,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
.toList(), .toList(),
onTap: (item) { onTap: (item) {
final series = _series.firstWhere( final series = _series.firstWhere(
(element) => element.seriesId.toString() == item.id, (element) => element.seriesId.toString() == item.id,
orElse: () => _series[0]); orElse: () => _series[0],
);
_openSeries(series); _openSeries(series);
}, },
), ),
@@ -330,15 +343,23 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
errorBuilder: (ctx, err, stack) => Container( errorBuilder: (ctx, err, stack) => Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center( child: const Center(
child: Icon(Icons.tv, child: Icon(
size: 48, color: Colors.white24)), Icons.tv,
size: 48,
color: Colors.white24,
),
),
), ),
) )
: Container( : Container(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
child: const Center( child: const Center(
child: Icon(Icons.tv, child: Icon(
size: 48, color: Colors.white24)), Icons.tv,
size: 48,
color: Colors.white24,
),
),
), ),
), ),
@@ -355,11 +376,12 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Colors.transparent, Colors.transparent,
Colors.black.withOpacity(0.9) Colors.black.withOpacity(0.9),
], ],
), ),
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12)), bottom: Radius.circular(12),
),
), ),
), ),
), ),
@@ -376,7 +398,8 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
shadows: [ shadows: [
const Shadow(color: Colors.black, blurRadius: 4) const Shadow(
color: Colors.black, blurRadius: 4),
], ],
), ),
maxLines: 2, maxLines: 2,
@@ -391,7 +414,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
right: 8, right: 8,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2), 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),
@@ -400,15 +425,19 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, const Icon(
size: 10, color: Colors.amber), 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( style: GoogleFonts.inter(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white), color: Colors.white,
),
), ),
], ],
), ),
@@ -429,7 +458,8 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
child: Center( child: Center(
child: CircularProgressIndicator(color: Colors.white)), child: CircularProgressIndicator(color: Colors.white),
),
), ),
), ),
+43 -19
View File
@@ -96,8 +96,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
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( const Tab(
icon: Icon(Icons.admin_panel_settings), icon: Icon(Icons.admin_panel_settings),
text: 'Administration'), text: 'Administration',
),
], ],
), ),
), ),
@@ -212,7 +213,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
title: Text( title: Text(
'Mode actuel', 'Mode actuel',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontWeight: FontWeight.w500, color: Colors.white), 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',
@@ -305,7 +308,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
} }
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: [
@@ -326,13 +332,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
subtitle: Text( subtitle: Text(
currentUser?.username ?? 'Unknown', currentUser?.username ?? 'Unknown',
style: GoogleFonts.roboto( style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white), fontWeight: FontWeight.w600,
color: Colors.white,
),
), ),
trailing: currentUser?.isAdmin ?? false trailing: currentUser?.isAdmin ?? false
? Chip( ? Chip(
label: Text('Admin', label: Text(
style: GoogleFonts.roboto( 'Admin',
fontSize: 11, color: Colors.black)), style: GoogleFonts.roboto(
fontSize: 11,
color: Colors.black,
),
),
backgroundColor: Colors.white, backgroundColor: Colors.white,
) )
: null, : null,
@@ -416,8 +428,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
), ),
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', title: Text(
style: GoogleFonts.roboto(color: Colors.white)), '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'),
), ),
@@ -433,8 +447,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
), ),
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', title: Text(
style: GoogleFonts.roboto(color: Colors.white)), 'À 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),
@@ -452,8 +468,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
), ),
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', title: Text(
style: GoogleFonts.roboto(color: Colors.white)), '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),
@@ -465,8 +483,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
title: const Text('Vider le cache ?', title: const Text(
style: TextStyle(color: Colors.white)), '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.',
@@ -479,7 +499,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
), ),
FilledButton( FilledButton(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.orange), backgroundColor: Colors.orange,
),
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
@@ -580,8 +601,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
), ),
suffixIcon: controller.text.isNotEmpty suffixIcon: controller.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear, icon: const Icon(
size: 16, color: Colors.white54), Icons.clear,
size: 16,
color: Colors.white54,
),
onPressed: onClear, onPressed: onClear,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
@@ -252,10 +252,14 @@ class StreamingSettingsTab extends ConsumerWidget {
required String Function(T) labelBuilder, required String Function(T) labelBuilder,
}) { }) {
return ListTile( return ListTile(
title: Text(title, title: Text(
style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), title,
subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 14, color: Colors.white),
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), ),
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(),
@@ -102,13 +102,20 @@ class _MobileSeriesDetailScreenState
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, const Icon(
size: 48, color: AppColors.error), Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Error: $_error', Text(
style: GoogleFonts.inter(color: Colors.white70)), 'Error: $_error',
style: GoogleFonts.inter(color: Colors.white70),
),
TextButton( TextButton(
onPressed: _loadSeriesInfo, child: const Text('Retry')), onPressed: _loadSeriesInfo,
child: const Text('Retry'),
),
], ],
), ),
) )
@@ -189,20 +196,27 @@ class _MobileSeriesDetailScreenState
Row( Row(
children: [ children: [
if (_seriesInfo!.rating != null) ...[ if (_seriesInfo!.rating != null) ...[
const Icon(Icons.star, const Icon(
size: 16, color: Colors.amber), 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( style: GoogleFonts.inter(
color: Colors.white70, fontSize: 13), 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( style: GoogleFonts.inter(
color: Colors.white70, fontSize: 13), color: Colors.white70,
fontSize: 13,
),
), ),
], ],
), ),
@@ -253,7 +267,8 @@ class _MobileSeriesDetailScreenState
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20)), borderRadius: BorderRadius.circular(20),
),
showCheckmark: false, showCheckmark: false,
side: BorderSide.none, side: BorderSide.none,
); );
@@ -356,7 +371,9 @@ class _MobileSeriesDetailScreenState
Text( Text(
_formatDuration(episode.durationSecs!), _formatDuration(episode.durationSecs!),
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: Colors.white54, fontSize: 12), color: Colors.white54,
fontSize: 12,
),
), ),
], ],
), ),
@@ -44,8 +44,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
body: channelsAsync.when( body: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center( error: (e, s) => Center(
child: child: Text('Error: $e', style: const TextStyle(color: Colors.white)),
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) {
@@ -98,8 +98,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
hintStyle: hintStyle:
GoogleFonts.inter(color: Colors.white54), GoogleFonts.inter(color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
prefixIcon: const Icon(Icons.search, prefixIcon: const Icon(
color: Colors.white54), Icons.search,
color: Colors.white54,
),
isDense: true, isDense: true,
), ),
), ),
@@ -133,7 +135,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10), horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Colors.white ? Colors.white
@@ -177,7 +181,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
const Spacer(), const Spacer(),
GestureDetector( GestureDetector(
onTap: () => setState( onTap: () => setState(
() => _showFavoritesOnly = !_showFavoritesOnly), () => _showFavoritesOnly = !_showFavoritesOnly,
),
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -211,8 +216,11 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.tv_off, const Icon(
color: Colors.white24, size: 48), Icons.tv_off,
color: Colors.white24,
size: 48,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No channels found', 'No channels found',
@@ -344,7 +352,8 @@ class _MobileChannelTile extends ConsumerWidget {
current.title, current.title,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: const Color( color: const Color(
0xFFFFD700), // Gold/Amber for visibility check 0xFFFFD700,
), // Gold/Amber for visibility check
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -352,11 +361,14 @@ class _MobileChannelTile extends ConsumerWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
); );
}, },
loading: () => const Text('...', loading: () => const Text(
style: '...',
TextStyle(color: Colors.white38, fontSize: 12)), style: TextStyle(color: Colors.white38, fontSize: 12),
error: (err, stack) => const Text('Err', ),
style: TextStyle(color: Colors.red, fontSize: 10)), error: (err, stack) => const Text(
'Err',
style: TextStyle(color: Colors.red, fontSize: 10),
),
), ),
], ],
), ),
@@ -367,8 +379,11 @@ 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, child: const Icon(
color: Colors.white, size: 16), Icons.play_arrow_rounded,
color: Colors.white,
size: 16,
),
), ),
], ],
), ),
@@ -205,7 +205,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 14, color: Colors.white), 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),
@@ -218,18 +220,24 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
), ),
if (_isSearching) if (_isSearching)
const SizedBox( const SizedBox(
width: 12, width: 12,
height: 12, height: 12,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white)), 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, child: const Icon(
size: 16, color: Colors.white), Icons.close,
size: 16,
color: Colors.white,
),
), ),
], ],
), ),
@@ -295,8 +303,11 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
) )
: Container( : Container(
color: Colors.white10, color: Colors.white10,
child: const Icon(Icons.movie, child: const Icon(
color: Colors.white24)), Icons.movie,
color: Colors.white24,
),
),
), ),
// Gradient Overlay // Gradient Overlay
@@ -312,11 +323,12 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Colors.transparent, Colors.transparent,
Colors.black.withOpacity(0.9) Colors.black.withOpacity(0.9),
], ],
), ),
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(16)), bottom: Radius.circular(16),
),
), ),
), ),
), ),
@@ -333,18 +345,22 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
Text( Text(
movie.name, movie.name,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600), fontWeight: FontWeight.w600,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (isWatched) if (isWatched)
Text('WATCHED', Text(
style: GoogleFonts.inter( 'WATCHED',
color: AppColors.primary, style: GoogleFonts.inter(
fontSize: 10, color: AppColors.primary,
fontWeight: FontWeight.bold)), fontSize: 10,
fontWeight: FontWeight.bold,
),
),
], ],
), ),
), ),
@@ -358,19 +374,25 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
borderRadius: 4, borderRadius: 4,
opacity: 0.6, opacity: 0.6,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2), horizontal: 6,
vertical: 2,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, const Icon(
size: 10, color: Colors.amber), 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( style: GoogleFonts.inter(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white), color: Colors.white,
),
), ),
], ],
), ),
@@ -390,7 +412,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
child: Padding( child: Padding(
padding: EdgeInsets.all(24), padding: EdgeInsets.all(24),
child: Center( child: Center(
child: CircularProgressIndicator(color: Colors.white)), child: CircularProgressIndicator(color: Colors.white),
),
), ),
), ),
], ],
@@ -29,7 +29,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
bool _isSearching = false; bool _isSearching = false;
bool _hasMore = true; bool _hasMore = true;
int _currentOffset = 0; int _currentOffset = 0;
static const int _pageSize = 50; static const int _pageSize = 50;
Timer? _searchDebounce; Timer? _searchDebounce;
@override @override
@@ -48,9 +48,10 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
} }
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) {
_loadMoreSeries(); _loadMoreSeries();
} }
} }
@@ -64,7 +65,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
_isSearching = false; _isSearching = false;
} }
}); });
if (query.length >= 2) { if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () { _searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query); _performSearch(query);
@@ -75,11 +76,11 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
Future<void> _performSearch(String query) async { Future<void> _performSearch(String query) async {
if (query.isEmpty) return; if (query.isEmpty) return;
setState(() => _isSearching = true); setState(() => _isSearching = true);
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchSeries(query); final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) { if (mounted && _searchQuery == query) {
setState(() { setState(() {
_searchResults = results; _searchResults = results;
@@ -150,23 +151,30 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
List<Series> displaySeries; List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!; displaySeries = _searchResults!;
} 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 heroItems = displaySeries.take(3).map((s) => HeroItem( final heroItems = displaySeries
id: s.seriesId.toString(), .take(3)
title: s.name, .map(
imageUrl: _getProxiedImageUrl(s.cover), (s) => HeroItem(
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, id: s.seriesId.toString(),
onMoreInfo: () => _openSeries(s), title: s.name,
)).toList(); imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s),
),
)
.toList();
return SafeArea( return SafeArea(
bottom: false, bottom: false,
@@ -180,7 +188,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
child: GlassContainer( child: GlassContainer(
borderRadius: 100, borderRadius: 100,
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: [
const Icon(Icons.search, size: 20, color: Colors.white54), const Icon(Icons.search, size: 20, color: Colors.white54),
@@ -188,7 +197,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
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),
@@ -200,14 +210,19 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
), ),
), ),
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),
), ),
], ],
), ),
@@ -221,22 +236,23 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
child: SizedBox( child: SizedBox(
height: 250, height: 250,
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
try { try {
final series = _series.firstWhere((s) => s.seriesId.toString() == item.id); final series = _series.firstWhere(
_openSeries(series); (s) => s.seriesId.toString() == item.id);
} catch (e) { _openSeries(series);
// Fallback } catch (e) {
} // Fallback
}, }
), },
),
), ),
), ),
), ),
// Grid // Grid
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
@@ -251,7 +267,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
(context, index) { (context, index) {
if (index >= displaySeries.length) return null; if (index >= displaySeries.length) return null;
final series = displaySeries[index]; final series = displaySeries[index];
return GestureDetector( return GestureDetector(
onTap: () => _openSeries(series), onTap: () => _openSeries(series),
child: Stack( child: Stack(
@@ -260,37 +276,53 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
// Poster // Poster
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: series.cover != null && series.cover!.isNotEmpty child:
? Image.network( series.cover != null && series.cover!.isNotEmpty
_getProxiedImageUrl(series.cover), ? Image.network(
fit: BoxFit.cover, _getProxiedImageUrl(series.cover),
errorBuilder: (_, __, ___) => Container(color: Colors.white10), fit: BoxFit.cover,
) errorBuilder: (_, __, ___) =>
: Container(color: Colors.white10, child: const Icon(Icons.tv, color: Colors.white24)), Container(color: Colors.white10),
)
: Container(
color: Colors.white10,
child: const Icon(Icons.tv,
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: Text( child: Text(
series.name, series.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,
), ),
@@ -299,19 +331,25 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
// Rating // Rating
if (series.rating != null && series.rating!.isNotEmpty) if (series.rating != null && series.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(series.rating)!, _formatRating(series.rating)!,
style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white), style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white),
), ),
], ],
), ),
@@ -325,12 +363,13 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
), ),
), ),
), ),
if (_isLoading) if (_isLoading)
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)),
), ),
), ),
], ],
@@ -37,13 +37,16 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
} }
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;
@@ -56,7 +59,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
final themeState = ref.watch(themeProvider); final themeState = ref.watch(themeProvider);
final themeNotifier = ref.read(themeProvider.notifier); final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings); if (!_initialized) _syncControllers(settings);
}); });
@@ -66,7 +69,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
// Header // Header
Padding( Padding(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
child: Text( child: Text(
'Settings', 'Settings',
@@ -87,7 +90,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
backgroundColor: AppColors.primary.withOpacity(0.2), backgroundColor: AppColors.primary.withOpacity(0.2),
child: Text( child: Text(
currentUser?.username.substring(0, 1).toUpperCase() ?? 'U', currentUser?.username.substring(0, 1).toUpperCase() ?? 'U',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary), style: const TextStyle(
fontWeight: FontWeight.bold, color: AppColors.primary),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -106,12 +110,17 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false)
Container( Container(
margin: const EdgeInsets.only(top: 4), margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.2), color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: const Text('ADMIN', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.bold)), child: const Text('ADMIN',
style: TextStyle(
fontSize: 10,
color: AppColors.primary,
fontWeight: FontWeight.bold)),
), ),
], ],
), ),
@@ -131,19 +140,24 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
child: Column( child: Column(
children: [ children: [
SwitchListTile( SwitchListTile(
title: const Text('Dark Mode', style: TextStyle(color: AppColors.textPrimary)), title: const Text('Dark Mode',
secondary: Icon(isDark ? Icons.dark_mode : Icons.light_mode, color: AppColors.primary), style: TextStyle(color: AppColors.textPrimary)),
secondary: Icon(isDark ? Icons.dark_mode : Icons.light_mode,
color: AppColors.primary),
value: isDark, value: isDark,
onChanged: (_) => themeNotifier.toggleTheme(), onChanged: (_) => themeNotifier.toggleTheme(),
activeColor: AppColors.primary, activeThumbColor: AppColors.primary,
), ),
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
title: const Text('Show Clock', style: TextStyle(color: AppColors.textPrimary)), title: const Text('Show Clock',
secondary: const Icon(Icons.access_time, color: AppColors.primary), style: TextStyle(color: AppColors.textPrimary)),
secondary:
const Icon(Icons.access_time, color: AppColors.primary),
value: settings.showClock, value: settings.showClock,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setShowClock(val), onChanged: (val) =>
activeColor: AppColors.primary, ref.read(iptvSettingsProvider.notifier).setShowClock(val),
activeThumbColor: AppColors.primary,
), ),
], ],
), ),
@@ -167,7 +181,10 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
items: StreamQuality.values, items: StreamQuality.values,
labelBuilder: (v) => v.name.toUpperCase(), labelBuilder: (v) => v.name.toUpperCase(),
onChanged: (v) { onChanged: (v) {
if (v != null) ref.read(iptvSettingsProvider.notifier).setStreamQuality(v); if (v != null)
ref
.read(iptvSettingsProvider.notifier)
.setStreamQuality(v);
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
@@ -178,19 +195,24 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
items: BufferSize.values, items: BufferSize.values,
labelBuilder: (v) => v.name.toUpperCase(), labelBuilder: (v) => v.name.toUpperCase(),
onChanged: (v) { onChanged: (v) {
if (v != null) ref.read(iptvSettingsProvider.notifier).setBufferSize(v); if (v != null)
ref.read(iptvSettingsProvider.notifier).setBufferSize(v);
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
_DropdownSetting<ConnectionTimeout>( _DropdownSetting<ConnectionTimeout>(
label: 'Timeout', label: 'Timeout',
icon: Icons.timer, icon: Icons.timer,
value: settings.connectionTimeout, value: settings.connectionTimeout,
items: ConnectionTimeout.values, items: ConnectionTimeout.values,
labelBuilder: (v) => '${v.name.toUpperCase()} (${ref.read(iptvSettingsProvider).timeoutSeconds}s)', labelBuilder: (v) =>
onChanged: (v) { '${v.name.toUpperCase()} (${ref.read(iptvSettingsProvider).timeoutSeconds}s)',
if (v != null) ref.read(iptvSettingsProvider.notifier).setConnectionTimeout(v); onChanged: (v) {
}, if (v != null)
ref
.read(iptvSettingsProvider.notifier)
.setConnectionTimeout(v);
},
), ),
const Divider(height: 1), const Divider(height: 1),
_DropdownSetting<TranscodingMode>( _DropdownSetting<TranscodingMode>(
@@ -200,22 +222,32 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
items: TranscodingMode.values, items: TranscodingMode.values,
labelBuilder: (v) { labelBuilder: (v) {
switch (v) { switch (v) {
case TranscodingMode.auto: return 'AUTO'; case TranscodingMode.auto:
case TranscodingMode.forced: return 'TRANSCODE'; return 'AUTO';
case TranscodingMode.disabled: return 'DIRECT'; case TranscodingMode.forced:
return 'TRANSCODE';
case TranscodingMode.disabled:
return 'DIRECT';
} }
}, },
onChanged: (v) { onChanged: (v) {
if (v != null) ref.read(iptvSettingsProvider.notifier).setTranscodingMode(v); if (v != null)
ref
.read(iptvSettingsProvider.notifier)
.setTranscodingMode(v);
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
title: const Text('Auto Reconnect', style: TextStyle(color: AppColors.textPrimary)), title: const Text('Auto Reconnect',
secondary: const Icon(Icons.refresh, color: AppColors.primary), style: TextStyle(color: AppColors.textPrimary)),
secondary:
const Icon(Icons.refresh, color: AppColors.primary),
value: settings.autoReconnect, value: settings.autoReconnect,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setAutoReconnect(val), onChanged: (val) => ref
activeColor: AppColors.primary, .read(iptvSettingsProvider.notifier)
.setAutoReconnect(val),
activeThumbColor: AppColors.primary,
), ),
], ],
), ),
@@ -236,21 +268,27 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
label: 'Live TV Keywords', label: 'Live TV Keywords',
icon: Icons.live_tv, icon: Icons.live_tv,
controller: _liveTvController, controller: _liveTvController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setLiveTvFilter(val), onChanged: (val) => ref
.read(iptvSettingsProvider.notifier)
.setLiveTvFilter(val),
), ),
const Divider(height: 24), const Divider(height: 24),
_FilterInput( _FilterInput(
label: 'Movies Keywords', label: 'Movies Keywords',
icon: Icons.movie, icon: Icons.movie,
controller: _moviesController, controller: _moviesController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setMoviesFilter(val), onChanged: (val) => ref
.read(iptvSettingsProvider.notifier)
.setMoviesFilter(val),
), ),
const Divider(height: 24), const Divider(height: 24),
_FilterInput( _FilterInput(
label: 'Series Keywords', label: 'Series Keywords',
icon: Icons.tv, icon: Icons.tv,
controller: _seriesController, controller: _seriesController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setSeriesFilter(val), onChanged: (val) => ref
.read(iptvSettingsProvider.notifier)
.setSeriesFilter(val),
), ),
], ],
), ),
@@ -271,19 +309,19 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
onTap: () => context.go('/playlists'), onTap: () => context.go('/playlists'),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
ref.read(authProvider.notifier).logout(); ref.read(authProvider.notifier).logout();
context.go('/login'); context.go('/login');
}, },
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.error.withOpacity(0.1), backgroundColor: AppColors.error.withOpacity(0.1),
foregroundColor: AppColors.error, foregroundColor: AppColors.error,
minimumSize: const Size(double.infinity, 50), minimumSize: const Size(double.infinity, 50),
), ),
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
label: const Text('Logout'), label: const Text('Logout'),
), ),
const SizedBox(height: 100), const SizedBox(height: 100),
], ],
@@ -335,10 +373,13 @@ class _FilterInput extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), Text(label,
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary)),
TextField( TextField(
controller: controller, controller: controller,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), style:
const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration( decoration: const InputDecoration(
isDense: true, isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 4), contentPadding: EdgeInsets.symmetric(vertical: 4),
@@ -435,7 +476,8 @@ class _DropdownSetting<T> extends StatelessWidget {
value: value, value: value,
underline: const SizedBox(), underline: const SizedBox(),
dropdownColor: AppColors.surfaceVariant, dropdownColor: AppColors.surfaceVariant,
icon: const Icon(Icons.arrow_drop_down, color: AppColors.textSecondary), icon: const Icon(Icons.arrow_drop_down,
color: AppColors.textSecondary),
style: const TextStyle(color: AppColors.textPrimary), style: const TextStyle(color: AppColors.textPrimary),
items: items.map((item) { items: items.map((item) {
return DropdownMenuItem<T>( return DropdownMenuItem<T>(
+9 -6
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'dart:ui'; import 'dart:ui';
import '../../core/theme/app_colors.dart';
class MobileScaffold extends ConsumerWidget { class MobileScaffold extends ConsumerWidget {
final Widget child; final Widget child;
@@ -25,7 +24,7 @@ class MobileScaffold extends ConsumerWidget {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Global Background // Global Background
Container( Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: RadialGradient( gradient: RadialGradient(
center: Alignment.center, center: Alignment.center,
@@ -37,7 +36,7 @@ class MobileScaffold extends ConsumerWidget {
), ),
), ),
), ),
// Content // Content
child, child,
], ],
@@ -48,7 +47,9 @@ class MobileScaffold extends ConsumerWidget {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1C1C1E).withOpacity(0.85), color: const Color(0xFF1C1C1E).withOpacity(0.85),
border: Border(top: BorderSide(color: Colors.white.withOpacity(0.1), width: 0.5)), border: Border(
top: BorderSide(
color: Colors.white.withOpacity(0.1), width: 0.5)),
), ),
child: BottomNavigationBar( child: BottomNavigationBar(
currentIndex: currentIndex, currentIndex: currentIndex,
@@ -58,8 +59,10 @@ class MobileScaffold extends ConsumerWidget {
elevation: 0, elevation: 0,
selectedItemColor: Colors.white, selectedItemColor: Colors.white,
unselectedItemColor: Colors.white38, unselectedItemColor: Colors.white38,
selectedLabelStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11), selectedLabelStyle:
unselectedLabelStyle: GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11), GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11),
unselectedLabelStyle:
GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11),
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.live_tv_rounded), icon: Icon(Icons.live_tv_rounded),
+2
View File
@@ -31,6 +31,7 @@ dependencies:
video_player: ^2.9.1 video_player: ^2.9.1
video_player_web: ^2.3.0 video_player_web: ^2.3.0
video_player_web_hls: ^1.3.0 video_player_web_hls: ^1.3.0
video_player_win: ^3.2.2
chewie: ^1.8.3 chewie: ^1.8.3
# UI Components # UI Components
@@ -46,6 +47,7 @@ dependencies:
intl: ^0.19.0 intl: ^0.19.0
equatable: ^2.0.5 equatable: ^2.0.5
shared_preferences: ^2.3.2 shared_preferences: ^2.3.2
video_player_win: ^3.2.2
+30
View File
@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremflow/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
+17
View File
@@ -0,0 +1,17 @@
flutter/ephemeral/
# Visual Studio user-specific files.
*.suo
*.user
*.userosscache
*.sln.docstates
# Visual Studio build-related files.
x64/
x86/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
+108
View File
@@ -0,0 +1,108 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(xtremflow LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "xtremflow")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(VERSION 3.14...3.25)
# Define build configuration option.
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
if(IS_MULTICONFIG)
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
CACHE STRING "" FORCE)
else()
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
endif()
# Define settings for the Profile build mode.
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
# Use Unicode for all projects.
add_definitions(-DUNICODE -D_UNICODE)
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_17)
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
target_compile_options(${TARGET} PRIVATE /EHsc)
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# Support files are copied into place next to the executable, so that it can
# run in place. This is done instead of making a separate bundle (as on Linux)
# so that building and running from within Visual Studio will work.
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
# Make the "install" step default, as it's required to run.
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(PLUGIN_BUNDLED_LIBRARIES)
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
CONFIGURATIONS Profile;Release
COMPONENT Runtime)
+109
View File
@@ -0,0 +1,109 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.14)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
# Set fallback configurations for older versions of the flutter tool.
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
set(FLUTTER_TARGET_PLATFORM "windows-x64")
endif()
# === Flutter Library ===
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"flutter_export.h"
"flutter_windows.h"
"flutter_messenger.h"
"flutter_plugin_registrar.h"
"flutter_texture_registrar.h"
)
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
add_dependencies(flutter flutter_assemble)
# === Wrapper ===
list(APPEND CPP_WRAPPER_SOURCES_CORE
"core_implementations.cc"
"standard_codec.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
"plugin_registrar.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_APP
"flutter_engine.cc"
"flutter_view_controller.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
# Wrapper sources needed for a plugin.
add_library(flutter_wrapper_plugin STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
)
apply_standard_settings(flutter_wrapper_plugin)
set_target_properties(flutter_wrapper_plugin PROPERTIES
POSITION_INDEPENDENT_CODE ON)
set_target_properties(flutter_wrapper_plugin PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
target_include_directories(flutter_wrapper_plugin PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_plugin flutter_assemble)
# Wrapper sources needed for the runner.
add_library(flutter_wrapper_app STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_APP}
)
apply_standard_settings(flutter_wrapper_app)
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
target_include_directories(flutter_wrapper_app PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_app flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
${PHONY_OUTPUT}
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
)
@@ -0,0 +1,14 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <video_player_win/video_player_win_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
VideoPlayerWinPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VideoPlayerWinPluginCApi"));
}
@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
+24
View File
@@ -0,0 +1,24 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
video_player_win
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
+40
View File
@@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.14)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME} WIN32
"flutter_window.cpp"
"main.cpp"
"utils.cpp"
"win32_window.cpp"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
"Runner.rc"
"runner.exe.manifest"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the build version.
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
# Disable Windows macros that collide with C++ standard library functions.
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
# Add dependency libraries and include directories. Add any application-specific
# dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
+121
View File
@@ -0,0 +1,121 @@
// Microsoft Visual C++ generated resource script.
//
#pragma code_page(65001)
#include "resource.h"
#define APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "winres.h"
/////////////////////////////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
// English (United States) resources
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
#ifdef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// TEXTINCLUDE
//
1 TEXTINCLUDE
BEGIN
"resource.h\0"
END
2 TEXTINCLUDE
BEGIN
"#include ""winres.h""\r\n"
"\0"
END
3 TEXTINCLUDE
BEGIN
"\r\n"
"\0"
END
#endif // APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Icon
//
// Icon with lowest ID value placed first to ensure application icon
// remains consistent on all systems.
IDI_APP_ICON ICON "resources\\app_icon.ico"
/////////////////////////////////////////////////////////////////////////////
//
// Version
//
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
#else
#define VERSION_AS_NUMBER 1,0,0,0
#endif
#if defined(FLUTTER_VERSION)
#define VERSION_AS_STRING FLUTTER_VERSION
#else
#define VERSION_AS_STRING "1.0.0"
#endif
VS_VERSION_INFO VERSIONINFO
FILEVERSION VERSION_AS_NUMBER
PRODUCTVERSION VERSION_AS_NUMBER
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS__WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "xtremflow" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "xtremflow" "\0"
VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "xtremflow.exe" "\0"
VALUE "ProductName", "xtremflow" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1252
END
END
#endif // English (United States) resources
/////////////////////////////////////////////////////////////////////////////
#ifndef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 3 resource.
//
/////////////////////////////////////////////////////////////////////////////
#endif // not APSTUDIO_INVOKED
+71
View File
@@ -0,0 +1,71 @@
#include "flutter_window.h"
#include <optional>
#include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {}
FlutterWindow::~FlutterWindow() {}
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
}
RECT frame = GetClientArea();
// The size here must match the window dimensions to avoid unnecessary surface
// creation / destruction in the startup path.
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
frame.right - frame.left, frame.bottom - frame.top, project_);
// Ensure that basic setup of the controller was successful.
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
return false;
}
RegisterPlugins(flutter_controller_->engine());
SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() {
this->Show();
});
// Flutter can complete the first frame before the "show window" callback is
// registered. The following call ensures a frame is pending to ensure the
// window is shown. It is a no-op if the first frame hasn't completed yet.
flutter_controller_->ForceRedraw();
return true;
}
void FlutterWindow::OnDestroy() {
if (flutter_controller_) {
flutter_controller_ = nullptr;
}
Win32Window::OnDestroy();
}
LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
// Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) {
std::optional<LRESULT> result =
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
lparam);
if (result) {
return *result;
}
}
switch (message) {
case WM_FONTCHANGE:
flutter_controller_->engine()->ReloadSystemFonts();
break;
}
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
}
+33
View File
@@ -0,0 +1,33 @@
#ifndef RUNNER_FLUTTER_WINDOW_H_
#define RUNNER_FLUTTER_WINDOW_H_
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <memory>
#include "win32_window.h"
// A window that does nothing but host a Flutter view.
class FlutterWindow : public Win32Window {
public:
// Creates a new FlutterWindow hosting a Flutter view running |project|.
explicit FlutterWindow(const flutter::DartProject& project);
virtual ~FlutterWindow();
protected:
// Win32Window:
bool OnCreate() override;
void OnDestroy() override;
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
LPARAM const lparam) noexcept override;
private:
// The project to run.
flutter::DartProject project_;
// The Flutter instance hosted by this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
};
#endif // RUNNER_FLUTTER_WINDOW_H_
+43
View File
@@ -0,0 +1,43 @@
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <windows.h>
#include "flutter_window.h"
#include "utils.h"
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
_In_ wchar_t *command_line, _In_ int show_command) {
// Attach to console when present (e.g., 'flutter run') or create a
// new console when running with a debugger.
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
CreateAndAttachConsole();
}
// Initialize COM, so that it is available for use in the library and/or
// plugins.
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
flutter::DartProject project(L"data");
std::vector<std::string> command_line_arguments =
GetCommandLineArguments();
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"xtremflow", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
::MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0)) {
::TranslateMessage(&msg);
::DispatchMessage(&msg);
}
::CoUninitialize();
return EXIT_SUCCESS;
}
+16
View File
@@ -0,0 +1,16 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ generated include file.
// Used by Runner.rc
//
#define IDI_APP_ICON 101
// Next default values for new objects
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 102
#define _APS_NEXT_COMMAND_VALUE 40001
#define _APS_NEXT_CONTROL_VALUE 1001
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
+65
View File
@@ -0,0 +1,65 @@
#include "utils.h"
#include <flutter_windows.h>
#include <io.h>
#include <stdio.h>
#include <windows.h>
#include <iostream>
void CreateAndAttachConsole() {
if (::AllocConsole()) {
FILE *unused;
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
_dup2(_fileno(stdout), 1);
}
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
_dup2(_fileno(stdout), 2);
}
std::ios::sync_with_stdio();
FlutterDesktopResyncOutputStreams();
}
}
std::vector<std::string> GetCommandLineArguments() {
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
int argc;
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
if (argv == nullptr) {
return std::vector<std::string>();
}
std::vector<std::string> command_line_arguments;
// Skip the first argument as it's the binary name.
for (int i = 1; i < argc; i++) {
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
}
::LocalFree(argv);
return command_line_arguments;
}
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
if (utf16_string == nullptr) {
return std::string();
}
unsigned int target_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
-1, nullptr, 0, nullptr, nullptr)
-1; // remove the trailing null character
int input_length = (int)wcslen(utf16_string);
std::string utf8_string;
if (target_length == 0 || target_length > utf8_string.max_size()) {
return utf8_string;
}
utf8_string.resize(target_length);
int converted_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
input_length, utf8_string.data(), target_length, nullptr, nullptr);
if (converted_length == 0) {
return std::string();
}
return utf8_string;
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef RUNNER_UTILS_H_
#define RUNNER_UTILS_H_
#include <string>
#include <vector>
// Creates a console for the process, and redirects stdout and stderr to
// it for both the runner and the Flutter library.
void CreateAndAttachConsole();
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
// encoded in UTF-8. Returns an empty std::string on failure.
std::string Utf8FromUtf16(const wchar_t* utf16_string);
// Gets the command line arguments passed in as a std::vector<std::string>,
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
std::vector<std::string> GetCommandLineArguments();
#endif // RUNNER_UTILS_H_
+288
View File
@@ -0,0 +1,288 @@
#include "win32_window.h"
#include <dwmapi.h>
#include <flutter_windows.h>
#include "resource.h"
namespace {
/// Window attribute that enables dark mode window decorations.
///
/// Redefined in case the developer's machine has a Windows SDK older than
/// version 10.0.22000.0.
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
/// Registry key for app theme preference.
///
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
/// value indicates apps should use light mode.
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
// The number of Win32Window objects that currently exist.
static int g_active_window_count = 0;
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
// Scale helper to convert logical scaler values to physical using passed in
// scale factor
int Scale(int source, double scale_factor) {
return static_cast<int>(source * scale_factor);
}
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
// This API is only needed for PerMonitor V1 awareness mode.
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
HMODULE user32_module = LoadLibraryA("User32.dll");
if (!user32_module) {
return;
}
auto enable_non_client_dpi_scaling =
reinterpret_cast<EnableNonClientDpiScaling*>(
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
if (enable_non_client_dpi_scaling != nullptr) {
enable_non_client_dpi_scaling(hwnd);
}
FreeLibrary(user32_module);
}
} // namespace
// Manages the Win32Window's window class registration.
class WindowClassRegistrar {
public:
~WindowClassRegistrar() = default;
// Returns the singleton registrar instance.
static WindowClassRegistrar* GetInstance() {
if (!instance_) {
instance_ = new WindowClassRegistrar();
}
return instance_;
}
// Returns the name of the window class, registering the class if it hasn't
// previously been registered.
const wchar_t* GetWindowClass();
// Unregisters the window class. Should only be called if there are no
// instances of the window.
void UnregisterWindowClass();
private:
WindowClassRegistrar() = default;
static WindowClassRegistrar* instance_;
bool class_registered_ = false;
};
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
const wchar_t* WindowClassRegistrar::GetWindowClass() {
if (!class_registered_) {
WNDCLASS window_class{};
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.lpszClassName = kWindowClassName;
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.cbClsExtra = 0;
window_class.cbWndExtra = 0;
window_class.hInstance = GetModuleHandle(nullptr);
window_class.hIcon =
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
window_class.hbrBackground = 0;
window_class.lpszMenuName = nullptr;
window_class.lpfnWndProc = Win32Window::WndProc;
RegisterClass(&window_class);
class_registered_ = true;
}
return kWindowClassName;
}
void WindowClassRegistrar::UnregisterWindowClass() {
UnregisterClass(kWindowClassName, nullptr);
class_registered_ = false;
}
Win32Window::Win32Window() {
++g_active_window_count;
}
Win32Window::~Win32Window() {
--g_active_window_count;
Destroy();
}
bool Win32Window::Create(const std::wstring& title,
const Point& origin,
const Size& size) {
Destroy();
const wchar_t* window_class =
WindowClassRegistrar::GetInstance()->GetWindowClass();
const POINT target_point = {static_cast<LONG>(origin.x),
static_cast<LONG>(origin.y)};
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
double scale_factor = dpi / 96.0;
HWND window = CreateWindow(
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
nullptr, nullptr, GetModuleHandle(nullptr), this);
if (!window) {
return false;
}
UpdateTheme(window);
return OnCreate();
}
bool Win32Window::Show() {
return ShowWindow(window_handle_, SW_SHOWNORMAL);
}
// static
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
if (message == WM_NCCREATE) {
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
SetWindowLongPtr(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
EnableFullDpiSupportIfAvailable(window);
that->window_handle_ = window;
} else if (Win32Window* that = GetThisFromHandle(window)) {
return that->MessageHandler(window, message, wparam, lparam);
}
return DefWindowProc(window, message, wparam, lparam);
}
LRESULT
Win32Window::MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
switch (message) {
case WM_DESTROY:
window_handle_ = nullptr;
Destroy();
if (quit_on_close_) {
PostQuitMessage(0);
}
return 0;
case WM_DPICHANGED: {
auto newRectSize = reinterpret_cast<RECT*>(lparam);
LONG newWidth = newRectSize->right - newRectSize->left;
LONG newHeight = newRectSize->bottom - newRectSize->top;
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
return 0;
}
case WM_SIZE: {
RECT rect = GetClientArea();
if (child_content_ != nullptr) {
// Size and position the child window.
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
rect.bottom - rect.top, TRUE);
}
return 0;
}
case WM_ACTIVATE:
if (child_content_ != nullptr) {
SetFocus(child_content_);
}
return 0;
case WM_DWMCOLORIZATIONCOLORCHANGED:
UpdateTheme(hwnd);
return 0;
}
return DefWindowProc(window_handle_, message, wparam, lparam);
}
void Win32Window::Destroy() {
OnDestroy();
if (window_handle_) {
DestroyWindow(window_handle_);
window_handle_ = nullptr;
}
if (g_active_window_count == 0) {
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
}
}
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
return reinterpret_cast<Win32Window*>(
GetWindowLongPtr(window, GWLP_USERDATA));
}
void Win32Window::SetChildContent(HWND content) {
child_content_ = content;
SetParent(content, window_handle_);
RECT frame = GetClientArea();
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
frame.bottom - frame.top, true);
SetFocus(child_content_);
}
RECT Win32Window::GetClientArea() {
RECT frame;
GetClientRect(window_handle_, &frame);
return frame;
}
HWND Win32Window::GetHandle() {
return window_handle_;
}
void Win32Window::SetQuitOnClose(bool quit_on_close) {
quit_on_close_ = quit_on_close;
}
bool Win32Window::OnCreate() {
// No-op; provided for subclasses.
return true;
}
void Win32Window::OnDestroy() {
// No-op; provided for subclasses.
}
void Win32Window::UpdateTheme(HWND const window) {
DWORD light_mode;
DWORD light_mode_size = sizeof(light_mode);
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
kGetPreferredBrightnessRegValue,
RRF_RT_REG_DWORD, nullptr, &light_mode,
&light_mode_size);
if (result == ERROR_SUCCESS) {
BOOL enable_dark_mode = light_mode == 0;
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
&enable_dark_mode, sizeof(enable_dark_mode));
}
}
+102
View File
@@ -0,0 +1,102 @@
#ifndef RUNNER_WIN32_WINDOW_H_
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <functional>
#include <memory>
#include <string>
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
// inherited from by classes that wish to specialize with custom
// rendering and input handling
class Win32Window {
public:
struct Point {
unsigned int x;
unsigned int y;
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
};
struct Size {
unsigned int width;
unsigned int height;
Size(unsigned int width, unsigned int height)
: width(width), height(height) {}
};
Win32Window();
virtual ~Win32Window();
// Creates a win32 window with |title| that is positioned and sized using
// |origin| and |size|. New windows are created on the default monitor. Window
// sizes are specified to the OS in physical pixels, hence to ensure a
// consistent size this function will scale the inputted width and height as
// as appropriate for the default monitor. The window is invisible until
// |Show| is called. Returns true if the window was created successfully.
bool Create(const std::wstring& title, const Point& origin, const Size& size);
// Show the current window. Returns true if the window was successfully shown.
bool Show();
// Release OS resources associated with window.
void Destroy();
// Inserts |content| into the window tree.
void SetChildContent(HWND content);
// Returns the backing Window handle to enable clients to set icon and other
// window properties. Returns nullptr if the window has been destroyed.
HWND GetHandle();
// If true, closing this window will quit the application.
void SetQuitOnClose(bool quit_on_close);
// Return a RECT representing the bounds of the current client area.
RECT GetClientArea();
protected:
// Processes and route salient window messages for mouse handling,
// size change and DPI. Delegates handling of these to member overloads that
// inheriting classes can handle.
virtual LRESULT MessageHandler(HWND window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Called when CreateAndShow is called, allowing subclass window-related
// setup. Subclasses should return false if setup fails.
virtual bool OnCreate();
// Called when Destroy is called.
virtual void OnDestroy();
private:
friend class WindowClassRegistrar;
// OS callback called by message pump. Handles the WM_NCCREATE message which
// is passed when the non-client area is being created and enables automatic
// non-client DPI scaling so that the non-client area automatically
// responds to changes in DPI. All other messages are handled by
// MessageHandler.
static LRESULT CALLBACK WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Retrieves a class instance pointer for |window|
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
// Update the window frame's theme to match the system theme.
static void UpdateTheme(HWND const window);
bool quit_on_close_ = false;
// window handle for top level window.
HWND window_handle_ = nullptr;
// window handle for hosted content.
HWND child_content_ = nullptr;
};
#endif // RUNNER_WIN32_WINDOW_H_