Merge pull request #53 from R0m1k3/antigravity

feat: Implement server with API routing, static file serving, Xtream …
This commit is contained in:
LogiFlow authored and GitHub committed 2025-12-06 19:13:31 +01:00
commit 5293db2885
9 files changed
+1203 -920

No files matched your search

+4
View File
@@ -431,6 +431,10 @@ Handler _createStreamHandler() {
'-reconnect_on_http_error', '4xx,5xx', '-reconnect_on_http_error', '4xx,5xx',
'-rw_timeout', '15000000', // 15s timeout '-rw_timeout', '15000000', // 15s timeout
// Probing settings to ensure stream detection
'-analyzeduration', '20000000', // 20s
'-probesize', '10000000', // 10MB
'-i', iptvUrl, '-i', iptvUrl,
// Output format: MPEG-TS for streaming (compatible with mpegts.js) // Output format: MPEG-TS for streaming (compatible with mpegts.js)
+2 -2
View File
@@ -14,8 +14,8 @@ class AppColors {
/// Pure black for that infinite depth look (OLED friendly) /// Pure black for that infinite depth look (OLED friendly)
static const Color background = Color(0xFF000000); static const Color background = Color(0xFF000000);
/// Very deep grey for surfaces that need to be distinct but subtle /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
static const Color surface = Color(0xFF121212); static const Color surface = Color(0xFF1C1C1E);
/// Focused element background (often white in tvOS for text, or bright accent) /// Focused element background (often white in tvOS for text, or bright accent)
static const Color focusColor = Color(0xFFFFFFFF); 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 spacing32 = 32.0;
static const double spacing48 = 48.0; // Larger spacing for TV feel static const double spacing48 = 48.0; // Larger spacing for TV feel
// ============ RADIUS ============
// ============ RADIUS ============ // ============ RADIUS ============
static const double radiusSm = 8.0; static const double radiusSm = 8.0;
static const double radiusMd = 12.0; static const double radiusMd = 12.0;
static const double radiusLg = 16.0; static const double radiusLg = 20.0;
static const double radiusXl = 24.0; static const double radiusXl = 32.0;
// ============ ANIMATION ============ // ============ ANIMATION ============
static const Duration durationFast = Duration(milliseconds: 200); static const Duration durationFast = Duration(milliseconds: 200);
static const Duration durationNormal = Duration(milliseconds: 300); 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) ============ // ============ DARK THEME (TV Main) ============
static ThemeData get darkTheme { static ThemeData get darkTheme {
@@ -36,114 +37,95 @@ class AppTheme {
colorScheme: AppColors.darkColorScheme, colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background, 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( appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
iconTheme: const IconThemeData(color: AppColors.textSecondary), scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter( titleTextStyle: GoogleFonts.inter(
fontSize: 16, fontSize: 17,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textPrimary,
letterSpacing: 0.5,
), ),
), ),
// NavigationBar: Minimal, often just icons or text // Inputs: Dark, minimal, no borders unless focused
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
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: AppColors.surface, fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16), contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull), // Pill shape borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull), borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull), borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.focusColor, width: 2), borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
), ),
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary), hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
), ),
// Buttons: White pill buttons usually // Buttons: White pill or minimal text
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.focusColor, // White buttons backgroundColor: AppColors.focusColor, // White buttons
foregroundColor: Colors.black, // Black text foregroundColor: Colors.black, // Black text
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
borderRadius: BorderRadius.circular(radiusFull), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)),
), textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
textStyle: GoogleFonts.inter(
fontWeight: FontWeight.w700,
fontSize: 16,
),
).copyWith( ).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( // Dialogs: Glass/Dark
displayLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold), dialogTheme: DialogTheme(
titleLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600), backgroundColor: const Color(0xFF1C1C1E), // Apple Dark Grey
bodyLarge: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 16), elevation: 24,
bodyMedium: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 14), 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; 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( static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith(
colorScheme: AppColors.lightColorScheme, colorScheme: AppColors.lightColorScheme,
inputDecorationTheme: InputDecorationTheme( textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
filled: true,
fillColor: const Color(0xFFF2F2F7),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusFull),
borderSide: BorderSide.none)),
); );
} }
@@ -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/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:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/services/playlist_api_service.dart'; import '../../../core/services/playlist_api_service.dart';
import '../../../core/services/user_api_service.dart'; import '../../../core/services/user_api_service.dart';
@@ -42,40 +43,100 @@ class _AdminPanelState extends ConsumerState<AdminPanel>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: AppColors.background,
title: Row( body: SafeArea(
child: Column(
children: [ children: [
// Custom Glass Header
Container( Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( child: Row(
color: AppColors.primary.withOpacity(0.2), children: [
borderRadius: BorderRadius.circular(8), 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(), Widget _buildTabButton(int index, String label, IconData icon) {
_UsersTab(), 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( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: GradientButton( child: Row(
label: 'Ajouter une playlist', mainAxisAlignment: MainAxisAlignment.spaceBetween,
icon: Icons.add, children: [
onPressed: () => _showPlaylistDialog(context, ref), 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( Expanded(
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', style: const TextStyle(color: AppColors.error))),
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'),
),
],
),
),
data: (playlists) { data: (playlists) {
if (playlists.isEmpty) { if (playlists.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary), Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium),
'Aucune playlist', const SizedBox(height: 8),
style: TextStyle(fontSize: 18, color: AppColors.textSecondary), TextButton(
onPressed: () => _showPlaylistDialog(context, ref),
child: const Text('Add your first playlist'),
), ),
], ],
), ),
); );
} }
return ListView.builder( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: playlists.length, itemCount: playlists.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final playlist = playlists[index]; final playlist = playlists[index];
return Card( return GlassCard(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(20),
child: ListTile( child: Row(
leading: Container( children: [
width: 40, Container(
height: 40, width: 48,
decoration: BoxDecoration( height: 48,
gradient: AppColors.primaryGradient, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), 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), const SizedBox(width: 16),
), Expanded(
title: Text(playlist.name), child: Column(
subtitle: Text(playlist.dns, style: const TextStyle(fontSize: 11)), crossAxisAlignment: CrossAxisAlignment.start,
trailing: Row( children: [
mainAxisSize: MainAxisSize.min, Text(playlist.name, style: Theme.of(context).textTheme.titleMedium),
children: [ const SizedBox(height: 4),
IconButton( Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppColors.textTertiary)),
icon: const Icon(Icons.edit_outlined), ],
onPressed: () => _showPlaylistDialog(context, ref, playlist),
), ),
IconButton( ),
icon: Icon(Icons.delete_outline, color: AppColors.error), Row(
onPressed: () => _deletePlaylist(context, ref, playlist), 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( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: GradientButton( child: Row(
label: 'Créer un utilisateur', mainAxisAlignment: MainAxisAlignment.spaceBetween,
icon: Icons.person_add, children: [
onPressed: () => _showCreateUserDialog(context, ref), 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( Expanded(
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', style: const TextStyle(color: AppColors.error))),
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'),
),
],
),
),
data: (users) { data: (users) {
if (users.isEmpty) { if (users.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary), Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16), 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( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: users.length, itemCount: users.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = users[index]; final user = users[index];
return Card( return GlassCard(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(20),
child: ListTile( child: Row(
leading: CircleAvatar( children: [
backgroundColor: user.isAdmin ? AppColors.primary.withOpacity(0.2) : AppColors.surface.withOpacity(0.5), Container(
child: Icon(user.isAdmin ? Icons.admin_panel_settings : Icons.person, color: user.isAdmin ? AppColors.primary : AppColors.textSecondary), width: 48,
), height: 48,
title: Row( decoration: BoxDecoration(
children: [ color: user.isAdmin ? AppColors.focusColor : AppColors.surface,
Text(user.username), shape: BoxShape.circle,
if (user.isAdmin) ...[ ),
const SizedBox(width: 8), child: Icon(
Container( user.isAdmin ? Icons.admin_panel_settings : Icons.person,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), color: user.isAdmin ? Colors.black : AppColors.textPrimary,
decoration: BoxDecoration(color: AppColors.primary.withOpacity(0.2), borderRadius: BorderRadius.circular(12)), size: 24
child: Text('Admin', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.w600)), ),
), ),
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_colors.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart'; import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/ui_components.dart';
import 'channel_card.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 { class LiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -27,14 +28,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// State // State
String? _selectedCategory; String? _selectedCategory;
int _currentPage = 0;
static const int _itemsPerPage = 100; // Increased for grid view
// UI State // UI State
bool _isGridView = true; bool _isGridView = true;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _showFavoritesOnly = false; bool _showFavoritesOnly = false;
String _searchQuery = ''; String _searchQuery = '';
// Scroll Controllers
final ScrollController _mainScrollController = ScrollController();
@override @override
void initState() { void initState() {
@@ -42,7 +44,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
_searchController.addListener(() { _searchController.addListener(() {
setState(() { setState(() {
_searchQuery = _searchController.text.toLowerCase(); _searchQuery = _searchController.text.toLowerCase();
_currentPage = 0; // Reset page on search
}); });
}); });
} }
@@ -50,6 +51,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_mainScrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -62,316 +64,170 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider); 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); 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( return Scaffold(
context, backgroundColor: Colors.transparent,
mobile: 2, body: channelsAsync.when(
tablet: 3, loading: () => const Center(child: CircularProgressIndicator()),
desktop: 5, 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( return Column(
padding: const EdgeInsets.all(16), children: [
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( // Minimalist Header
crossAxisCount: crossAxisCount, Container(
mainAxisSpacing: 16, padding: const EdgeInsets.only(left: 24, right: 24, top: 16, bottom: 8),
crossAxisSpacing: 16, child: Row(
childAspectRatio: 1.5, children: [
), Text(
itemCount: categories.length, 'Live TV',
itemBuilder: (context, index) { style: Theme.of(context).textTheme.headlineMedium,
final category = categories[index]; ),
final count = groupedChannels[category]!.length; const Spacer(),
return InkWell( // Search Pill
onTap: () => setState(() { Container(
_selectedCategory = category; width: 250,
_currentPage = 0; height: 36,
}), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd), color: AppColors.surface,
child: Container( borderRadius: BorderRadius.circular(AppTheme.radiusFull),
decoration: BoxDecoration( border: Border.all(color: AppColors.border),
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,
), ),
const SizedBox(height: 8), padding: const EdgeInsets.symmetric(horizontal: 12),
Container( child: Row(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), children: [
decoration: BoxDecoration( const Icon(Icons.search, size: 18, color: AppColors.textSecondary),
color: AppColors.primary.withOpacity(0.2), const SizedBox(width: 8),
borderRadius: BorderRadius.circular(4), Expanded(
), child: TextField(
child: Text( controller: _searchController,
'$count channels', style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
style: GoogleFonts.inter( decoration: const InputDecoration(
fontSize: 12, hintText: 'Search channels',
color: AppColors.primary, border: InputBorder.none,
fontWeight: FontWeight.bold, 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) { const SizedBox(height: 8),
// 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);
return Column( // Channels Content
children: [ Expanded(
// Breadcrumb / Back Navigation child: displayedChannels.isEmpty
if (!isGlobalMode && _selectedCategory != null) ? Center(
Container( child: Text(
width: double.infinity, _showFavoritesOnly ? 'No favorites' : 'No channels found',
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
color: AppColors.surface.withOpacity(0.5), ),
child: Row( )
children: [ : _isGridView
IconButton( ? _buildGridView(displayedChannels)
icon: const Icon(Icons.arrow_back_ios, size: 16), : _buildListView(displayedChannels),
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,
),
],
),
),
],
); );
} }
@@ -384,21 +240,22 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
); );
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.all(16), controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns, crossAxisCount: columns,
mainAxisSpacing: 16, mainAxisSpacing: 16,
crossAxisSpacing: 16, crossAxisSpacing: 16,
childAspectRatio: 1.4, // Card ratio childAspectRatio: 1.3, // Card ratio 4:3ish
), ),
itemCount: channels.length, itemCount: channels.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final channel = channels[index]; final channel = channels[index];
return ChannelCard( return ChannelCard(
streamId: channel.streamId, // Now required streamId: channel.streamId,
name: channel.name, name: channel.name,
iconUrl: _getProxiedIconUrl(channel.streamIcon), iconUrl: _getProxiedIconUrl(channel.streamIcon),
currentProgram: null, // ShortEPG requires async fetch currentProgram: null,
onTap: () => _playChannel(channel), onTap: () => _playChannel(channel),
); );
}, },
@@ -407,19 +264,20 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Widget _buildListView(List<Channel> channels) { Widget _buildListView(List<Channel> channels) {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(16), controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
itemCount: channels.length, itemCount: channels.length,
separatorBuilder: (_, __) => const SizedBox(height: 8), separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final channel = channels[index]; final channel = channels[index];
return SizedBox( return SizedBox(
height: 80, // Fixed height for list item height: 70,
child: ChannelCard( child: ChannelCard(
streamId: channel.streamId, streamId: channel.streamId,
name: channel.name, name: channel.name,
iconUrl: _getProxiedIconUrl(channel.streamIcon), iconUrl: _getProxiedIconUrl(channel.streamIcon),
height: 80, height: 70,
width: double.infinity, // Full width width: double.infinity,
onTap: () => _playChannel(channel), onTap: () => _playChannel(channel),
), ),
); );
+148 -219
View File
@@ -1,13 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.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 '../models/xtream_models.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/watch_history_provider.dart';
import '../screens/player_screen.dart'; import '../screens/player_screen.dart';
class MoviesTab extends ConsumerStatefulWidget { class MoviesTab extends ConsumerStatefulWidget {
@@ -23,7 +27,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final List<Movie> _movies = []; final List<Movie> _movies = [];
List<Movie>? _searchResults; // Results from full catalog search List<Movie>? _searchResults;
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = false; bool _isLoading = false;
bool _isSearching = false; bool _isSearching = false;
@@ -103,7 +107,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(xtreamServiceProvider(widget.playlist));
final newMovies = await service.getMovies( final newMovies = await service.getMoviesPaginated(
offset: _currentOffset, offset: _currentOffset,
limit: _pageSize, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); 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; List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
displayMovies = _searchResults!; displayMovies = _searchResults!;
} else { } else {
displayMovies = settings.moviesKeywords.isEmpty displayMovies = settings.moviesKeywords.isEmpty
? _movies ? _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) { if (_movies.isEmpty && !_isLoading) {
return const Center(child: Text('No movies available')); return const Center(child: Text('No movies available'));
} }
return Column( final double gridItemRatio = 0.65;
children: [ final int crossAxisCount = ResponsiveLayout.value(
// Search bar context,
Padding( mobile: 3,
padding: const EdgeInsets.all(8.0), tablet: 5,
child: TextField( desktop: 7,
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,
);
},
),
),
],
); );
}
}
class _MovieCard extends ConsumerWidget { return CustomScrollView(
final Movie movie; controller: _scrollController,
final PlaylistConfig playlist; slivers: [
// Header
const _MovieCard({ SliverToBoxAdapter(
required this.movie, child: Container(
required this.playlist, padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
}); child: Row(
@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,
children: [ children: [
Expanded( Text(
child: movie.streamIcon != null 'Movies',
? CachedNetworkImage( style: Theme.of(context).textTheme.headlineMedium,
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),
),
), ),
const Spacer(),
Container( Container(
padding: const EdgeInsets.all(8), width: 300,
child: Column( height: 40,
crossAxisAlignment: CrossAxisAlignment.start, 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: [ children: [
Text( const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
movie.name, const SizedBox(width: 8),
style: GoogleFonts.roboto( Expanded(
fontSize: 11, child: TextField(
fontWeight: FontWeight.w500, 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) ...[ if (_isSearching)
const SizedBox(height: 2), const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
Row( if (_searchQuery.isNotEmpty)
children: [ GestureDetector(
Icon( onTap: () {
Icons.star, _searchController.clear();
size: 12, _onSearchChanged('');
color: Colors.amber.shade700, },
), child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
const SizedBox(width: 2),
Text(
movie.rating!,
style: GoogleFonts.roboto(
fontSize: 10,
color: Colors.grey.shade600,
),
),
],
), ),
],
], ],
), ),
), ),
], ],
), ),
// 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 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.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 '../models/xtream_models.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
// Use search results if searching, otherwise use loaded series with category filter
List<Series> displaySeries; List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!; displaySeries = _searchResults!;
@@ -143,194 +158,135 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
return const Center(child: Text('No series available')); return const Center(child: Text('No series available'));
} }
return Column( // Hero Items
children: [ final heroItems = _series.take(5).map((s) => HeroItem(
// Search bar id: s.seriesId.toString(), // ID logic might differ for Series
Padding( title: s.name,
padding: const EdgeInsets.all(8.0), imageUrl: s.cover ?? '',
child: TextField( subtitle: s.rating != null ? '${s.rating} ★' : null,
controller: _searchController, onMoreInfo: () => _openSeries(s),
decoration: InputDecoration( )).toList();
hintText: 'Search all series...',
hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500), final double gridItemRatio = 0.65;
prefixIcon: _isSearching final int crossAxisCount = ResponsiveLayout.value(
? const Padding( context,
padding: EdgeInsets.all(12), mobile: 3,
child: SizedBox( tablet: 5,
width: 20, height: 20, desktop: 7,
child: CircularProgressIndicator(strokeWidth: 2), );
),
) return CustomScrollView(
: const Icon(Icons.search, color: Colors.grey), controller: _scrollController,
suffixIcon: _searchQuery.isNotEmpty slivers: [
? IconButton( // Header
icon: const Icon(Icons.clear, color: Colors.grey), SliverToBoxAdapter(
onPressed: () { child: Container(
_searchController.clear(); padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
_onSearchChanged(''); child: Row(
}, children: [
) Text(
: null, 'TV Series',
filled: true, style: Theme.of(context).textTheme.headlineMedium,
fillColor: Colors.grey.shade800, ),
border: OutlineInputBorder( const Spacer(),
borderRadius: BorderRadius.circular(8), Container(
borderSide: BorderSide.none, width: 300,
), height: 40,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration(
), color: AppColors.surface,
style: GoogleFonts.roboto(color: Colors.white), borderRadius: BorderRadius.circular(AppTheme.radiusFull),
onChanged: _onSearchChanged, border: Border.all(color: AppColors.border),
), ),
), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
// 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,
children: [ children: [
Icon( const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
_searchQuery.isNotEmpty ? Icons.search_off : Icons.filter_list_off, const SizedBox(width: 8),
size: 48, Expanded(
color: Colors.grey, child: TextField(
), controller: _searchController,
const SizedBox(height: 16), style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
Text( decoration: const InputDecoration(
_searchQuery.isNotEmpty hintText: 'Search',
? 'No series match "$_searchQuery"' border: InputBorder.none,
: 'No series match the filter', enabledBorder: InputBorder.none,
style: GoogleFonts.roboto(color: Colors.grey), 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,
),
),
],
),
],
],
),
),
],
),
),
);
}
}