feat: Implement server with API routing, static file serving, Xtream proxy, and streaming capabilities.

This commit is contained in:
Michael committed 2025-12-06 19:13:10 +01:00
1 parent b6c0c3ddc7
commit 4786ded67d
9 files changed
+1203 -920

No files matched your search

+2 -2
View File
@@ -14,8 +14,8 @@ class AppColors {
/// Pure black for that infinite depth look (OLED friendly)
static const Color background = Color(0xFF000000);
/// Very deep grey for surfaces that need to be distinct but subtle
static const Color surface = Color(0xFF121212);
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
static const Color surface = Color(0xFF1C1C1E);
/// Focused element background (often white in tvOS for text, or bright accent)
static const Color focusColor = Color(0xFFFFFFFF);
+53 -71
View File
@@ -17,16 +17,17 @@ class AppTheme {
static const double spacing32 = 32.0;
static const double spacing48 = 48.0; // Larger spacing for TV feel
// ============ RADIUS ============
// ============ RADIUS ============
static const double radiusSm = 8.0;
static const double radiusMd = 12.0;
static const double radiusLg = 16.0;
static const double radiusXl = 24.0;
static const double radiusLg = 20.0;
static const double radiusXl = 32.0;
// ============ ANIMATION ============
static const Duration durationFast = Duration(milliseconds: 200);
static const Duration durationNormal = Duration(milliseconds: 300);
static const Curve curveDefault = Curves.easeOutCubic;
static const Curve curveDefault = Curves.fastOutSlowIn; // Apple-like snappy curve
// ============ DARK THEME (TV Main) ============
static ThemeData get darkTheme {
@@ -36,114 +37,95 @@ class AppTheme {
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background,
// AppBar: Totally transparent, content floats below
// Typography: San Francisco style (using Inter as proxy)
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(fontSize: 56, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: AppColors.textPrimary),
displayMedium: GoogleFonts.inter(fontSize: 48, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: AppColors.textPrimary),
displaySmall: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
headlineMedium: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
titleLarge: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
bodyLarge: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
bodyMedium: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
labelLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
// AppBar: Transparent / Glass
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: true,
iconTheme: const IconThemeData(color: AppColors.textSecondary),
scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter(
fontSize: 16,
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
letterSpacing: 0.5,
color: AppColors.textPrimary,
),
),
// NavigationBar: Minimal, often just icons or text
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.transparent,
indicatorColor: Colors.transparent,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
height: 80,
iconTheme: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(WidgetState.selected);
// Selected: White and slightly larger/glowing
// Unselected: Grey and smaller
return IconThemeData(
color: isSelected ? AppColors.focusColor : AppColors.textSecondary,
size: isSelected ? 28 : 24,
);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(WidgetState.selected);
return GoogleFonts.inter(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? AppColors.focusColor : AppColors.textSecondary,
);
}),
),
// Cards: No background by default, they pop on focus
cardTheme: CardThemeData(
color: Colors.transparent, // Background comes from container in widget
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
),
// Inputs: Minimal, dark pills
// Inputs: Dark, minimal, no borders unless focused
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull), // Pill shape
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull),
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull),
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
),
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
),
// Buttons: White pill buttons usually
// Buttons: White pill or minimal text
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.focusColor, // White buttons
foregroundColor: Colors.black, // Black text
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusFull),
),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
textStyle: GoogleFonts.inter(
fontWeight: FontWeight.w700,
fontSize: 16,
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)),
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
).copyWith(
overlayColor: WidgetStateProperty.all(Colors.black.withOpacity(0.1)),
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.hovered)) return Colors.black.withOpacity(0.1);
if (states.contains(WidgetState.pressed)) return Colors.black.withOpacity(0.2);
return null;
}),
),
),
// Cards: Transparent by default (content defines look)
cardTheme: const CardTheme(
color: AppColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(radiusMd))),
),
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold),
titleLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600),
bodyLarge: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 16),
bodyMedium: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 14),
// Dialogs: Glass/Dark
dialogTheme: DialogTheme(
backgroundColor: const Color(0xFF1C1C1E), // Apple Dark Grey
elevation: 24,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)),
titleTextStyle: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
contentTextStyle: GoogleFonts.inter(fontSize: 16, color: AppColors.textSecondary),
),
);
}
static const double radiusFull = 999.0;
// Light theme stub (TV interfaces rarely use light mode, but good for completeness)
// Light theme stub
static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith(
colorScheme: AppColors.lightColorScheme,
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF2F2F7),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull),
borderSide: BorderSide.none)),
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
);
}
@@ -0,0 +1,273 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_colors.dart';
import '../../theme/app_theme.dart';
class HeroCarousel extends StatefulWidget {
final List<HeroItem> items;
final Function(HeroItem) onTap;
const HeroCarousel({
super.key,
required this.items,
required this.onTap,
});
@override
State<HeroCarousel> createState() => _HeroCarouselState();
}
class _HeroCarouselState extends State<HeroCarousel> {
late PageController _pageController;
int _currentPage = 0;
Timer? _timer;
@override
void initState() {
super.initState();
_pageController = PageController(viewportFraction: 0.9);
_startAutoPlay();
}
@override
void dispose() {
_pageController.dispose();
_stopAutoPlay();
super.dispose();
}
void _startAutoPlay() {
_timer = Timer.periodic(const Duration(seconds: 8), (timer) {
if (_currentPage < widget.items.length - 1) {
_currentPage++;
} else {
_currentPage = 0;
}
if (_pageController.hasClients) {
_pageController.animateToPage(
_currentPage,
duration: const Duration(milliseconds: 800),
curve: Curves.fastOutSlowIn,
);
}
});
}
void _stopAutoPlay() {
_timer?.cancel();
}
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 400,
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() => _currentPage = index);
},
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
final isActive = index == _currentPage;
return AnimatedScale(
duration: const Duration(milliseconds: 500),
scale: isActive ? 1.0 : 0.9,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.5,
child: GestureDetector(
onTap: () => widget.onTap(item),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: [
if (isActive)
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
child: Stack(
fit: StackFit.expand,
children: [
// Background Image
CachedNetworkImage(
imageUrl: item.imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: AppColors.surfaceVariant),
errorWidget: (context, url, error) => Container(
color: AppColors.surfaceVariant,
child: const Icon(Icons.movie, size: 64, color: AppColors.textTertiary),
),
),
// Gradient Overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.2),
Colors.black.withOpacity(0.9),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
// Content
Positioned(
bottom: 32,
left: 32,
right: 32,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (item.subtitle != null) ...[
const SizedBox(height: 8),
Text(
item.subtitle!,
style: GoogleFonts.inter(
fontSize: 16,
color: Colors.white.withOpacity(0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 16),
Row(
children: [
_HeroButton(
icon: Icons.play_arrow,
label: 'Play',
onPressed: () => widget.onTap(item),
isPrimary: true,
),
const SizedBox(width: 12),
if (item.onMoreInfo != null)
_HeroButton(
icon: Icons.info_outline,
label: 'More Info',
onPressed: item.onMoreInfo!,
isPrimary: false,
),
],
),
],
),
),
],
),
),
),
),
),
);
},
),
);
}
}
class _HeroButton extends StatefulWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
final bool isPrimary;
const _HeroButton({
required this.icon,
required this.label,
required this.onPressed,
required this.isPrimary,
});
@override
State<_HeroButton> createState() => _HeroButtonState();
}
class _HeroButtonState extends State<_HeroButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: widget.isPrimary
? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9))
: (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.icon,
color: widget.isPrimary ? Colors.black : Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
widget.label,
style: GoogleFonts.inter(
color: widget.isPrimary ? Colors.black : Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
class HeroItem {
final String id;
final String title;
final String imageUrl;
final String? subtitle;
final VoidCallback? onMoreInfo;
HeroItem({
required this.id,
required this.title,
required this.imageUrl,
this.subtitle,
this.onMoreInfo,
});
}
@@ -306,3 +306,194 @@ class CategoryChip extends StatelessWidget {
);
}
}
/// Apple TV style Media Card (Vertical Poster)
class MediaCard extends StatefulWidget {
final String title;
final String? imageUrl;
final String? subtitle;
final String? rating;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool isWatched;
final IconData placeholderIcon;
const MediaCard({
super.key,
required this.title,
this.imageUrl,
this.subtitle,
this.rating,
this.onTap,
this.onLongPress,
this.isWatched = false,
this.placeholderIcon = Icons.movie,
});
@override
State<MediaCard> createState() => _MediaCardState();
}
class _MediaCardState extends State<MediaCard>
with SingleTickerProviderStateMixin {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Poster Image
Expanded(
child: AnimatedScale(
scale: _isHovered ? 1.05 : 1.0,
duration: AppTheme.durationFast,
curve: AppTheme.curveDefault,
child: AnimatedContainer(
duration: AppTheme.durationFast,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
color: AppColors.surface,
border: Border.all(
color: _isHovered ? AppColors.focusColor : Colors.transparent,
width: _isHovered ? 3 : 0,
),
boxShadow: _isHovered
? [
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 20,
offset: const Offset(0, 10),
)
]
: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd - (_isHovered ? 2 : 0)),
child: Stack(
fit: StackFit.expand,
children: [
if (widget.imageUrl != null)
CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: AppColors.surface,
child: Center(
child: Icon(widget.placeholderIcon, color: AppColors.textTertiary),
),
),
errorWidget: (context, url, error) => Container(
color: AppColors.surface,
child: Center(
child: Icon(widget.placeholderIcon, color: AppColors.textTertiary),
),
),
)
else
Container(
color: AppColors.surface,
child: Center(
child: Icon(widget.placeholderIcon, color: AppColors.textTertiary),
),
),
// Rating Badge (Top Left)
if (widget.rating != null && widget.rating!.isNotEmpty)
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)),
const SizedBox(width: 4),
Text(
widget.rating!,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
],
),
),
),
// Watched Indicator (Top Right)
if (widget.isWatched)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
),
child: const Icon(Icons.check, size: 12, color: Colors.white),
),
),
],
),
),
),
),
),
// Title & Subtitle (Below)
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: Column(
children: [
Text(
widget.title,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _isHovered ? AppColors.focusColor : AppColors.textSecondary,
fontWeight: _isHovered ? FontWeight.w600 : FontWeight.w500,
fontSize: 13,
),
),
if (widget.subtitle != null && _isHovered) ...[
const SizedBox(height: 2),
Text(
widget.subtitle!,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: 11,
),
),
],
],
),
),
],
),
),
);
}
}
+223 -133
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import '../../../core/services/playlist_api_service.dart';
import '../../../core/services/user_api_service.dart';
@@ -42,40 +43,100 @@ class _AdminPanelState extends ConsumerState<AdminPanel>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
backgroundColor: AppColors.background,
body: SafeArea(
child: Column(
children: [
// Custom Glass Header
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
padding: const EdgeInsets.all(24),
child: Row(
children: [
GlassCard(
borderRadius: 12,
padding: const EdgeInsets.all(8),
showBorder: false,
onTap: () => context.go('/playlists'),
child: const Icon(Icons.arrow_back, color: AppColors.textSecondary),
),
const SizedBox(width: 16),
Text(
'Administration',
style: Theme.of(context).textTheme.headlineMedium,
),
const Spacer(),
// Custom Tab Bar Indicator
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(50),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildTabButton(0, 'Playlists', Icons.playlist_play),
const SizedBox(width: 4),
_buildTabButton(1, 'Users', Icons.people),
],
),
),
],
),
),
// Content
Expanded(
child: TabBarView(
controller: _tabController,
physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref
children: const [
_PlaylistsTab(),
_UsersTab(),
],
),
child: const Icon(Icons.admin_panel_settings, size: 20),
),
const SizedBox(width: 12),
const Text('Administration'),
],
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/playlists'),
),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.playlist_play), text: 'Playlists'),
Tab(icon: Icon(Icons.people), text: 'Utilisateurs'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_PlaylistsTab(),
_UsersTab(),
],
),
);
}
Widget _buildTabButton(int index, String label, IconData icon) {
return AnimatedBuilder(
animation: _tabController.animation!,
builder: (context, child) {
final isSelected = _tabController.index == index;
return GestureDetector(
onTap: () => _tabController.animateTo(index),
child: AnimatedContainer(
duration: AppTheme.durationFast,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? AppColors.focusColor : Colors.transparent,
borderRadius: BorderRadius.circular(50),
),
child: Row(
children: [
Icon(
icon,
size: 18,
color: isSelected ? Colors.black : AppColors.textSecondary,
),
const SizedBox(width: 8),
Text(
label,
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 14,
color: isSelected ? Colors.black : AppColors.textSecondary,
),
),
],
),
),
);
},
);
}
}
@@ -91,80 +152,89 @@ class _PlaylistsTab extends ConsumerWidget {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: GradientButton(
label: 'Ajouter une playlist',
icon: Icons.add,
onPressed: () => _showPlaylistDialog(context, ref),
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge),
GradientButton(
label: 'Add Playlist',
icon: Icons.add,
onPressed: () => _showPlaylistDialog(context, ref),
),
],
),
),
Expanded(
child: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Erreur: $error'),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => ref.refresh(playlistsProvider),
child: const Text('Réessayer'),
),
],
),
),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))),
data: (playlists) {
if (playlists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary),
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16),
Text(
'Aucune playlist',
style: TextStyle(fontSize: 18, color: AppColors.textSecondary),
Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextButton(
onPressed: () => _showPlaylistDialog(context, ref),
child: const Text('Add your first playlist'),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: playlists.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final playlist = playlists[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(10),
return GlassCard(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.playlist_play, color: AppColors.textPrimary),
),
child: const Icon(Icons.playlist_play, color: Colors.black, size: 20),
),
title: Text(playlist.name),
subtitle: Text(playlist.dns, style: const TextStyle(fontSize: 11)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _showPlaylistDialog(context, ref, playlist),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(playlist.name, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppColors.textTertiary)),
],
),
IconButton(
icon: Icon(Icons.delete_outline, color: AppColors.error),
onPressed: () => _deletePlaylist(context, ref, playlist),
),
],
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: AppColors.textSecondary,
onPressed: () => _showPlaylistDialog(context, ref, playlist),
tooltip: 'Edit',
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: AppColors.error,
onPressed: () => _deletePlaylist(context, ref, playlist),
tooltip: 'Delete',
),
],
),
],
),
);
},
@@ -263,82 +333,102 @@ class _UsersTab extends ConsumerWidget {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: GradientButton(
label: 'Créer un utilisateur',
icon: Icons.person_add,
onPressed: () => _showCreateUserDialog(context, ref),
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge),
GradientButton(
label: 'Create User',
icon: Icons.person_add,
onPressed: () => _showCreateUserDialog(context, ref),
),
],
),
),
Expanded(
child: usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Erreur: $error'),
OutlinedButton(
onPressed: () => ref.refresh(usersProvider),
child: const Text('Réessayer'),
),
],
),
),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))),
data: (users) {
if (users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary),
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16),
Text('Aucun utilisateur', style: TextStyle(fontSize: 18, color: AppColors.textSecondary)),
Text('No users found', style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: users.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final user = users[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: user.isAdmin ? AppColors.primary.withOpacity(0.2) : AppColors.surface.withOpacity(0.5),
child: Icon(user.isAdmin ? Icons.admin_panel_settings : Icons.person, color: user.isAdmin ? AppColors.primary : AppColors.textSecondary),
),
title: Row(
children: [
Text(user.username),
if (user.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: AppColors.primary.withOpacity(0.2), borderRadius: BorderRadius.circular(12)),
child: Text('Admin', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.w600)),
),
return GlassCard(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: user.isAdmin ? AppColors.focusColor : AppColors.surface,
shape: BoxShape.circle,
),
child: Icon(
user.isAdmin ? Icons.admin_panel_settings : Icons.person,
color: user.isAdmin ? Colors.black : AppColors.textPrimary,
size: 24
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(user.username, style: Theme.of(context).textTheme.titleMedium),
if (user.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.focusColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(50),
),
child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)),
),
],
],
),
const SizedBox(height: 4),
Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppColors.textTertiary)),
],
),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: AppColors.textSecondary),
color: AppColors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (value) {
switch (value) {
case 'password': _showChangePasswordDialog(context, ref, user); break;
case 'delete': _deleteUser(context, ref, user); break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])),
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])),
],
],
),
subtitle: Text('ID: ${user.id}', style: const TextStyle(fontSize: 11)),
trailing: PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'password': _showChangePasswordDialog(context, ref, user); break;
case 'delete': _deleteUser(context, ref, user); break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'password', child: ListTile(leading: Icon(Icons.lock_reset), title: Text('Changer mot de passe'), dense: true)),
PopupMenuItem(value: 'delete', child: ListTile(leading: Icon(Icons.delete, color: AppColors.error), title: Text('Supprimer', style: TextStyle(color: AppColors.error)), dense: true)),
],
),
),
],
),
);
},
+170 -312
View File
@@ -10,9 +10,10 @@ import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/ui_components.dart';
import 'channel_card.dart';
/// Live TV tab - Enhanced with Search, Favorites and Grid/List View
/// Live TV tab - Apple TV Style with Horizontal Category Filter
class LiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -27,14 +28,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// State
String? _selectedCategory;
int _currentPage = 0;
static const int _itemsPerPage = 100; // Increased for grid view
// UI State
bool _isGridView = true;
final TextEditingController _searchController = TextEditingController();
bool _showFavoritesOnly = false;
String _searchQuery = '';
// Scroll Controllers
final ScrollController _mainScrollController = ScrollController();
@override
void initState() {
@@ -42,7 +44,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text.toLowerCase();
_currentPage = 0; // Reset page on search
});
});
}
@@ -50,6 +51,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
@override
void dispose() {
_searchController.dispose();
_mainScrollController.dispose();
super.dispose();
}
@@ -62,316 +64,170 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider);
return Scaffold( // Use scaffold for toolbar body structure
backgroundColor: Colors.transparent,
body: Column(
children: [
// Toolbar
_buildToolbar(),
// Content
Expanded(
child: channelsAsync.when(
data: (groupedChannels) {
// 1. Flatten for global search/filter if needed
final allChannels = groupedChannels.values.expand((element) => element).toList();
List<Channel> displayedChannels = [];
bool isGlobalMode = _searchQuery.isNotEmpty || _showFavoritesOnly;
if (isGlobalMode) {
// Global Filter Mode
displayedChannels = allChannels.where((channel) {
final matchesSearch = _searchQuery.isEmpty ||
channel.name.toLowerCase().contains(_searchQuery);
final matchesFavorite = !_showFavoritesOnly ||
favorites.contains(channel.streamId);
return matchesSearch && matchesFavorite;
}).toList();
} else if (_selectedCategory != null) {
// Category Mode
displayedChannels = groupedChannels[_selectedCategory] ?? [];
}
// If no global mode and no category selected, show Category Grid
if (!isGlobalMode && _selectedCategory == null) {
if (groupedChannels.isEmpty) {
return const Center(child: Text('No channels found'));
}
return _buildCategoryGrid(groupedChannels);
}
// Show Channels (Grid or List)
if (displayedChannels.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.tv_off, size: 64, color: AppColors.textTertiary),
const SizedBox(height: 16),
Text(
_showFavoritesOnly ? 'No favorites found' : 'No channels found',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
return _buildChannelsView(displayedChannels, isGlobalMode);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(child: Text('Error: $e')),
),
),
],
),
);
}
Widget _buildToolbar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
// Search Bar
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search channels...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
isDense: true,
filled: true,
fillColor: AppColors.surface.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 12),
// Favorites Toggle
IconButton(
tooltip: 'Favorites Only',
icon: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
color: _showFavoritesOnly ? AppColors.primary : AppColors.textSecondary,
),
onPressed: () {
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
_selectedCategory = null; // Reset category when toggling favorites
_currentPage = 0;
});
},
),
// View Toggle
IconButton(
tooltip: _isGridView ? 'List View' : 'Grid View',
icon: Icon(
_isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _isGridView = !_isGridView),
),
],
),
);
}
Widget _buildCategoryGrid(Map<String, List<Channel>> groupedChannels) {
final settings = ref.watch(iptvSettingsProvider);
var categories = groupedChannels.keys.toList();
// Apply Settings Filter (Hidden categories)
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
}
categories.sort();
final crossAxisCount = ResponsiveLayout.value(
context,
mobile: 2,
tablet: 3,
desktop: 5,
);
return Scaffold(
backgroundColor: Colors.transparent,
body: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(child: Text('Error: $e')),
data: (groupedChannels) {
// Groups
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
}
categories.sort();
// Filter Logic
List<Channel> displayedChannels = [];
if (_searchQuery.isNotEmpty) {
// Global Search
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
// Favorites
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (_selectedCategory != null) {
// Selected Category
displayedChannels = groupedChannels[_selectedCategory] ?? [];
} else {
// Default: Show first category or All if practical, but "All" is too big.
// Apple TV pattern: Auto-select first category if nothing selected.
if (categories.isNotEmpty) {
// We don't want to trigger setState during build, so we just use the first category functionality
// But visualized as "All" might be confusing with map keys.
// Let's default to the first category in the list.
_selectedCategory ??= categories.first;
displayedChannels = groupedChannels[_selectedCategory] ?? [];
}
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.5,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final count = groupedChannels[category]!.length;
return InkWell(
onTap: () => setState(() {
_selectedCategory = category;
_currentPage = 0;
}),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppColors.border),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surface.withOpacity(0.8),
AppColors.surface,
],
),
),
child: Stack(
children: [
Positioned(
right: -10,
bottom: -10,
child: Icon(
Icons.tv,
size: 80,
color: Colors.white.withOpacity(0.03),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
category,
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
return Column(
children: [
// Minimalist Header
Container(
padding: const EdgeInsets.only(left: 24, right: 24, top: 16, bottom: 8),
child: Row(
children: [
Text(
'Live TV',
style: Theme.of(context).textTheme.headlineMedium,
),
const Spacer(),
// Search Pill
Container(
width: 250,
height: 36,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$count channels',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.primary,
fontWeight: FontWeight.bold,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.search, size: 18, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Search channels',
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 12),
),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary)
),
],
),
],
),
const SizedBox(width: 16),
// Favorites Toggle
IconButton(
icon: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary,
),
onPressed: () {
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) _searchController.clear();
});
},
tooltip: 'Favorites Only',
),
IconButton(
icon: Icon(
_isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _isGridView = !_isGridView),
tooltip: 'Toggle View',
),
],
),
),
// Categories Horizontal List (Only if not searching/favorites)
if (_searchQuery.isEmpty && !_showFavoritesOnly)
Container(
height: 48,
padding: const EdgeInsets.symmetric(vertical: 4),
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24),
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = category == _selectedCategory;
return CategoryChip(
label: category,
isSelected: isSelected,
onTap: () {
setState(() {
_selectedCategory = category;
_mainScrollController.jumpTo(0); // Reset scroll
});
},
);
},
),
),
],
),
),
);
},
);
}
Widget _buildChannelsView(List<Channel> channels, bool isGlobalMode) {
// Pagination
final totalPages = (channels.length / _itemsPerPage).ceil();
final startIndex = _currentPage * _itemsPerPage;
final endIndex = (startIndex + _itemsPerPage > channels.length)
? channels.length
: startIndex + _itemsPerPage;
final pageChannels = channels.sublist(startIndex, endIndex);
const SizedBox(height: 8),
return Column(
children: [
// Breadcrumb / Back Navigation
if (!isGlobalMode && _selectedCategory != null)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: AppColors.surface.withOpacity(0.5),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 16),
onPressed: () => setState(() => _selectedCategory = null),
tooltip: 'Back to Categories',
),
Text(
_selectedCategory!,
style: GoogleFonts.inter(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const Spacer(),
Text(
'${channels.length} items',
style: GoogleFonts.inter(color: AppColors.textSecondary),
),
],
),
),
// Grid/List Content
Expanded(
child: _isGridView
? _buildGridView(pageChannels)
: _buildListView(pageChannels),
),
// Pagination Controls
if (totalPages > 1)
Container(
height: 50,
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _currentPage > 0 ? () => setState(() => _currentPage--) : null,
),
Text(
'Page ${_currentPage + 1} of $totalPages',
style: GoogleFonts.inter(fontWeight: FontWeight.w500),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _currentPage < totalPages - 1 ? () => setState(() => _currentPage++) : null,
),
],
),
),
],
// Channels Content
Expanded(
child: displayedChannels.isEmpty
? Center(
child: Text(
_showFavoritesOnly ? 'No favorites' : 'No channels found',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
),
)
: _isGridView
? _buildGridView(displayedChannels)
: _buildListView(displayedChannels),
),
],
);
},
),
);
}
@@ -384,21 +240,22 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
);
return GridView.builder(
padding: const EdgeInsets.all(16),
controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.4, // Card ratio
childAspectRatio: 1.3, // Card ratio 4:3ish
),
itemCount: channels.length,
itemBuilder: (context, index) {
final channel = channels[index];
return ChannelCard(
streamId: channel.streamId, // Now required
streamId: channel.streamId,
name: channel.name,
iconUrl: _getProxiedIconUrl(channel.streamIcon),
currentProgram: null, // ShortEPG requires async fetch
currentProgram: null,
onTap: () => _playChannel(channel),
);
},
@@ -407,19 +264,20 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Widget _buildListView(List<Channel> channels) {
return ListView.separated(
padding: const EdgeInsets.all(16),
controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
itemCount: channels.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final channel = channels[index];
return SizedBox(
height: 80, // Fixed height for list item
height: 70,
child: ChannelCard(
streamId: channel.streamId,
name: channel.name,
iconUrl: _getProxiedIconUrl(channel.streamIcon),
height: 80,
width: double.infinity, // Full width
height: 70,
width: double.infinity,
onTap: () => _playChannel(channel),
),
);
+148 -219
View File
@@ -1,13 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/hero_carousel.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../../favorites/providers/watch_history_provider.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../providers/watch_history_provider.dart';
import '../screens/player_screen.dart';
class MoviesTab extends ConsumerStatefulWidget {
@@ -23,7 +27,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final List<Movie> _movies = [];
List<Movie>? _searchResults; // Results from full catalog search
List<Movie>? _searchResults;
String _searchQuery = '';
bool _isLoading = false;
bool _isSearching = false;
@@ -103,7 +107,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newMovies = await service.getMovies(
final newMovies = await service.getMoviesPaginated(
offset: _currentOffset,
limit: _pageSize,
);
@@ -126,252 +130,177 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
}
}
void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: movie.streamId,
title: movie.name,
playlist: widget.playlist,
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
final watchHistory = ref.watch(watchHistoryProvider);
// Use search results if searching, otherwise use loaded movies with category filter
List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displayMovies = _searchResults!;
} else {
displayMovies = settings.moviesKeywords.isEmpty
? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
: _movies.where((m) => settings.matchesMovieFilter(m.categoryName)).toList();
}
// Hero Items (Take 5 random or first 5)
final heroItems = _movies.take(5).map((m) => HeroItem(
id: m.streamId,
title: m.name,
imageUrl: m.streamIcon ?? '',
subtitle: m.rating != null ? '${m.rating} ★' : null,
onMoreInfo: () {
_playMovie(m);
},
)).toList();
if (_movies.isEmpty && !_isLoading) {
return const Center(child: Text('No movies available'));
}
return Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search all movies...',
hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500),
prefixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
filled: true,
fillColor: Colors.grey.shade800,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: GoogleFonts.roboto(color: Colors.white),
onChanged: _onSearchChanged,
),
),
// Results count
if (_searchQuery.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'${displayMovies.length} result${displayMovies.length != 1 ? 's' : ''} found',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
),
),
),
// Movie grid
Expanded(
child: displayMovies.isEmpty && _movies.isNotEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_searchQuery.isNotEmpty ? Icons.search_off : Icons.filter_list_off,
size: 48,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty
? 'No movies match "$_searchQuery"'
: 'No movies match the filter',
style: GoogleFonts.roboto(color: Colors.grey),
),
],
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 0.65,
),
itemCount: displayMovies.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index >= displayMovies.length) {
return const Center(child: CircularProgressIndicator());
}
final movie = displayMovies[index];
return _MovieCard(
movie: movie,
playlist: widget.playlist,
);
},
),
),
],
final double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value(
context,
mobile: 3,
tablet: 5,
desktop: 7,
);
}
}
class _MovieCard extends ConsumerWidget {
final Movie movie;
final PlaylistConfig playlist;
const _MovieCard({
required this.movie,
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final watchHistory = ref.watch(watchHistoryProvider);
final isWatched = watchHistory.isMovieWatched(movie.streamId);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Mark as watched when opening
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: movie.streamId,
title: movie.name,
playlist: playlist,
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
),
),
);
},
onLongPress: () {
// Toggle watched status on long press
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
},
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
return CustomScrollView(
controller: _scrollController,
slivers: [
// Header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Row(
children: [
Expanded(
child: movie.streamIcon != null
? CachedNetworkImage(
imageUrl: movie.streamIcon!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey.shade800,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey.shade800,
child: const Icon(Icons.movie, size: 48),
),
)
: Container(
color: Colors.grey.shade800,
child: const Icon(Icons.movie, size: 48),
),
Text(
'Movies',
style: Theme.of(context).textTheme.headlineMedium,
),
const Spacer(),
Container(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
width: 300,
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
movie.name,
style: GoogleFonts.roboto(
fontSize: 11,
fontWeight: FontWeight.w500,
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Search',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.only(bottom: 12),
isDense: true,
),
onChanged: _onSearchChanged,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (movie.rating != null) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.star,
size: 12,
color: Colors.amber.shade700,
),
const SizedBox(width: 2),
Text(
movie.rating!,
style: GoogleFonts.roboto(
fontSize: 10,
color: Colors.grey.shade600,
),
),
],
if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
),
],
],
),
),
],
),
// Watched indicator
if (isWatched)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.check,
size: 14,
color: Colors.white,
),
),
),
],
),
),
),
// Hero Carousel
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: HeroCarousel(
items: heroItems,
onTap: (item) {
final movie = _movies.firstWhere((element) => element.streamId == item.id);
_playMovie(movie);
},
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 24,
mainAxisSpacing: 32,
childAspectRatio: gridItemRatio,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displayMovies.length) return null;
final movie = displayMovies[index];
final isWatched = watchHistory.isMovieWatched(movie.streamId);
return MediaCard(
title: movie.name,
imageUrl: movie.streamIcon,
subtitle: movie.rating != null ? '${movie.rating} ★' : null,
rating: movie.rating,
isWatched: isWatched,
onTap: () => _playMovie(movie),
onLongPress: () {
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
},
);
},
childCount: displayMovies.length,
),
),
),
// Loader
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
],
);
}
}
+139 -183
View File
@@ -1,9 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/hero_carousel.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
@@ -125,11 +129,22 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
}
}
void _openSeries(Series series) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SeriesDetailScreen(
series: series,
playlist: widget.playlist,
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
// Use search results if searching, otherwise use loaded series with category filter
List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!;
@@ -143,194 +158,135 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
return const Center(child: Text('No series available'));
}
return Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search all series...',
hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500),
prefixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
filled: true,
fillColor: Colors.grey.shade800,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: GoogleFonts.roboto(color: Colors.white),
onChanged: _onSearchChanged,
),
),
// Results count
if (_searchQuery.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'${displaySeries.length} result${displaySeries.length != 1 ? 's' : ''} found',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
),
),
),
// Series grid
Expanded(
child: displaySeries.isEmpty && _series.isNotEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
// Hero Items
final heroItems = _series.take(5).map((s) => HeroItem(
id: s.seriesId.toString(), // ID logic might differ for Series
title: s.name,
imageUrl: s.cover ?? '',
subtitle: s.rating != null ? '${s.rating} ★' : null,
onMoreInfo: () => _openSeries(s),
)).toList();
final double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value(
context,
mobile: 3,
tablet: 5,
desktop: 7,
);
return CustomScrollView(
controller: _scrollController,
slivers: [
// Header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Row(
children: [
Text(
'TV Series',
style: Theme.of(context).textTheme.headlineMedium,
),
const Spacer(),
Container(
width: 300,
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(
_searchQuery.isNotEmpty ? Icons.search_off : Icons.filter_list_off,
size: 48,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty
? 'No series match "$_searchQuery"'
: 'No series match the filter',
style: GoogleFonts.roboto(color: Colors.grey),
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Search',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.only(bottom: 12),
isDense: true,
),
onChanged: _onSearchChanged,
),
),
if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
),
],
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 0.65,
),
itemCount: displaySeries.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index >= displaySeries.length) {
return const Center(child: CircularProgressIndicator());
}
final serie = displaySeries[index];
return _SeriesCard(series: serie, playlist: widget.playlist);
},
),
],
),
),
),
// Hero Carousel
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: HeroCarousel(
items: heroItems,
onTap: (item) {
final series = _series.firstWhere((s) => s.name == item.title); // Fallback by title if ID mismatch
_openSeries(series);
},
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 24,
mainAxisSpacing: 32,
childAspectRatio: gridItemRatio,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displaySeries.length) return null;
final serie = displaySeries[index];
return MediaCard(
title: serie.name,
imageUrl: serie.cover,
subtitle: serie.rating != null ? '${serie.rating} ★' : null,
rating: serie.rating,
placeholderIcon: Icons.tv,
onTap: () => _openSeries(serie),
);
},
childCount: displaySeries.length,
),
),
),
// Loader
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
],
);
}
}
class _SeriesCard extends StatelessWidget {
final Series series;
final PlaylistConfig playlist;
const _SeriesCard({required this.series, required this.playlist});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Navigate to series detail screen for episode selection
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SeriesDetailScreen(
series: series,
playlist: playlist,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: series.cover != null
? CachedNetworkImage(
imageUrl: series.cover!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey.shade800,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey.shade800,
child: const Icon(Icons.tv, size: 48),
),
)
: Container(
color: Colors.grey.shade800,
child: const Icon(Icons.tv, size: 48),
),
),
Container(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
series.name,
style: GoogleFonts.roboto(
fontSize: 11,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (series.rating != null) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.star,
size: 12,
color: Colors.amber.shade700,
),
const SizedBox(width: 2),
Text(
series.rating!,
style: GoogleFonts.roboto(
fontSize: 10,
color: Colors.grey.shade600,
),
),
],
),
],
],
),
),
],
),
),
);
}
}