mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 23:18:06 +02:00
feat: introduce streaming handlers for live TV proxy and VOD HLS transcoding using FFmpeg.
This commit is contained in:
1 parent
d22350fb89
commit
a1a8810ddc
10 files changed
+956
-600
No files matched your search
@@ -12,45 +12,58 @@ class AppColors {
|
|||||||
|
|
||||||
// ============ BACKGROUNDS ============
|
// ============ BACKGROUNDS ============
|
||||||
/// 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(0xFF0F1014); // Deepest Blue-Black
|
||||||
|
|
||||||
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
|
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
|
||||||
static const Color surface = Color(0xFF1C1C1E);
|
static const Color surface = Color(0xFF181920);
|
||||||
|
|
||||||
/// Slightly lighter grey for secondary surfaces or hover states
|
/// Slightly lighter grey for secondary surfaces or hover states
|
||||||
static const Color surfaceVariant = Color(0xFF3A3A3C);
|
static const Color surfaceVariant = Color(0xFF1E2028);
|
||||||
|
|
||||||
|
static const Color surfaceGlass =
|
||||||
|
Color(0x1AFFFFFF); // 10% White for glass effect
|
||||||
|
|
||||||
/// Focused element background (often white in tvOS for text, or bright accent)
|
/// Focused element background (often white in tvOS for text, or bright accent)
|
||||||
static const Color focusColor = Color(0xFFFFFFFF);
|
static const Color focusColor = Color(0xFFFFFFFF);
|
||||||
|
static const Color border = Color(0xFF1F1F1F);
|
||||||
|
static const Color focusBorder = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
// ============ ACCENTS ============
|
// ============ ACCENTS ============
|
||||||
/// Minimal white accent. In Apple TV, color is used sparingly.
|
static const Color primary = Color(0xFF6C63FF);
|
||||||
/// We keep a subtle blue only for specific indicators if needed.
|
static const Color secondary = Color(0xFF00B4D8);
|
||||||
static const Color primary = Color(0xFFFFFFFF);
|
static const Color accent = Color(0xFF00B4D8);
|
||||||
static const Color accent = Color(0xFFE5E5E5);
|
|
||||||
|
|
||||||
/// Primary Gradient (Subtle silver/white glow)
|
/// Primary Gradient (Electric Violet to Cyan)
|
||||||
static const LinearGradient primaryGradient = LinearGradient(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)],
|
colors: [Color(0xFF6C63FF), Color(0xFF00B4D8)],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ TEXT ============
|
static const LinearGradient backgroundGradient = LinearGradient(
|
||||||
|
colors: [Color(0xFF0F1014), Color(0xFF131418)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status Colors
|
||||||
|
static const Color success = Color(0xFF00E676);
|
||||||
|
static const Color warning = Color(0xFFFFAB00);
|
||||||
|
static const Color error = Color(0xFFFF5252);
|
||||||
|
static const Color info = Color(0xFF2979FF);
|
||||||
|
|
||||||
|
// Content Colors
|
||||||
static const Color textPrimary = Color(0xFFFFFFFF);
|
static const Color textPrimary = Color(0xFFFFFFFF);
|
||||||
static const Color textSecondary = Color(0xFF9E9E9E); // Medium Grey
|
static const Color textSecondary = Color(0xFFAAAAAA);
|
||||||
static const Color textTertiary = Color(0xFF616161); // Darker Grey
|
static const Color textTertiary = Color(0xFF666666);
|
||||||
|
|
||||||
// ============ FUNCTIONAL COLORS ============
|
static const Color ratingGold = Color(0xFFFFD700);
|
||||||
static const Color success = Color(0xFF4DB6AC); // Teal-ish
|
static const Color premiumGold = Color(0xFFFFA000);
|
||||||
static const Color warning = Color(0xFFFFB74D);
|
|
||||||
static const Color error = Color(0xFFEF5350);
|
|
||||||
static const Color live = Color(0xFFFF3B30); // Apple Red
|
|
||||||
|
|
||||||
// ============ BORDERS ============
|
// Category Colors
|
||||||
// Borders are usually hidden until focused
|
static const Color live = Color(0xFFFF5252); // Red
|
||||||
static const Color border = Color(0xFF1F1F1F);
|
static const Color movies = Color(0xFF448AFF); // Blue
|
||||||
static const Color focusBorder = Color(0xFFFFFFFF);
|
static const Color series = Color(0xFF69F0AE); // Green
|
||||||
|
|
||||||
// ============ GLASSMORPHISM ============
|
// ============ GLASSMORPHISM ============
|
||||||
// Apple TV uses this heavily for headers/overlays
|
// Apple TV uses this heavily for headers/overlays
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class AppTheme {
|
|||||||
// ============ 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.fastOutSlowIn; // Apple-like snappy curve
|
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 {
|
||||||
@@ -37,17 +38,46 @@ class AppTheme {
|
|||||||
colorScheme: AppColors.darkColorScheme,
|
colorScheme: AppColors.darkColorScheme,
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
|
||||||
// Typography: San Francisco style (using Inter as proxy)
|
// Typography: "Cinematic Modern" (Outfit for Headings, Inter for UI)
|
||||||
textTheme: TextTheme(
|
textTheme: TextTheme(
|
||||||
displayLarge: GoogleFonts.inter(fontSize: 56, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: AppColors.textPrimary),
|
displayLarge: GoogleFonts.outfit(
|
||||||
displayMedium: GoogleFonts.inter(fontSize: 48, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: AppColors.textPrimary),
|
fontSize: 56,
|
||||||
displaySmall: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
fontWeight: FontWeight.w700,
|
||||||
headlineMedium: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
letterSpacing: -1.0,
|
||||||
titleLarge: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
color: AppColors.textPrimary),
|
||||||
titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
displayMedium: GoogleFonts.outfit(
|
||||||
bodyLarge: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
|
fontSize: 48,
|
||||||
bodyMedium: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
|
fontWeight: FontWeight.w700,
|
||||||
labelLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
letterSpacing: -0.5,
|
||||||
|
color: AppColors.textPrimary),
|
||||||
|
displaySmall: GoogleFonts.outfit(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary),
|
||||||
|
headlineMedium: GoogleFonts.outfit(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary),
|
||||||
|
titleLarge: GoogleFonts.outfit(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary),
|
||||||
|
titleMedium: GoogleFonts.inter(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary),
|
||||||
|
bodyLarge: GoogleFonts.inter(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.textSecondary),
|
||||||
|
bodyMedium: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.textSecondary),
|
||||||
|
labelLarge: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary),
|
||||||
),
|
),
|
||||||
|
|
||||||
// AppBar: Transparent / Glass
|
// AppBar: Transparent / Glass
|
||||||
@@ -57,8 +87,8 @@ class AppTheme {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: GoogleFonts.outfit(
|
||||||
fontSize: 17,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
@@ -68,7 +98,8 @@ class AppTheme {
|
|||||||
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(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
@@ -91,12 +122,16 @@ class AppTheme {
|
|||||||
foregroundColor: Colors.black, // Black text
|
foregroundColor: Colors.black, // Black text
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)),
|
shape: RoundedRectangleBorder(
|
||||||
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
borderRadius: BorderRadius.circular(radiusMd)),
|
||||||
|
textStyle:
|
||||||
|
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
||||||
).copyWith(
|
).copyWith(
|
||||||
overlayColor: WidgetStateProperty.resolveWith((states) {
|
overlayColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.hovered)) return Colors.black.withOpacity(0.1);
|
if (states.contains(WidgetState.hovered))
|
||||||
if (states.contains(WidgetState.pressed)) return Colors.black.withOpacity(0.2);
|
return Colors.black.withOpacity(0.1);
|
||||||
|
if (states.contains(WidgetState.pressed))
|
||||||
|
return Colors.black.withOpacity(0.2);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -124,8 +159,9 @@ class AppTheme {
|
|||||||
static const double radiusFull = 999.0;
|
static const double radiusFull = 999.0;
|
||||||
|
|
||||||
// Light theme stub
|
// Light theme stub
|
||||||
static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith(
|
static ThemeData get lightTheme =>
|
||||||
colorScheme: AppColors.lightColorScheme,
|
ThemeData.light(useMaterial3: true).copyWith(
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
|
colorScheme: AppColors.lightColorScheme,
|
||||||
);
|
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -35,11 +35,10 @@ class GlassContainer extends StatelessWidget {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surface.withOpacity(opacity),
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
border: border
|
border: border
|
||||||
? Border.all(
|
? Border.all(
|
||||||
@@ -47,6 +46,21 @@ class GlassContainer extends StatelessWidget {
|
|||||||
width: 1,
|
width: 1,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppColors.surface.withOpacity(opacity),
|
||||||
|
AppColors.surface.withOpacity(opacity * 0.5),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.2),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: -5,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import 'glass_container.dart';
|
||||||
|
import 'tv_focusable_card.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
class HeroCarouselItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? imageUrl;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onPlay;
|
||||||
|
|
||||||
|
HeroCarouselItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.imageUrl,
|
||||||
|
this.onTap,
|
||||||
|
this.onPlay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeroCarousel extends StatefulWidget {
|
||||||
|
final List<HeroCarouselItem> items;
|
||||||
|
final bool autoPlay;
|
||||||
|
|
||||||
|
const HeroCarousel({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
this.autoPlay = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HeroCarousel> createState() => _HeroCarouselState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeroCarouselState extends State<HeroCarousel> {
|
||||||
|
late PageController _pageController;
|
||||||
|
int _currentIndex = 0;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pageController = PageController();
|
||||||
|
if (widget.autoPlay) {
|
||||||
|
_startAutoPlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAutoPlay() {
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 8), (timer) {
|
||||||
|
if (_currentIndex < widget.items.length - 1) {
|
||||||
|
_currentIndex++;
|
||||||
|
} else {
|
||||||
|
_currentIndex = 0;
|
||||||
|
}
|
||||||
|
if (_pageController.hasClients) {
|
||||||
|
_pageController.animateToPage(
|
||||||
|
_currentIndex,
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _getProxiedUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) return null;
|
||||||
|
if (url.startsWith('http')) {
|
||||||
|
return '/api/xtream/$url';
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.items.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 500, // Cinematic height
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Carousel
|
||||||
|
PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() => _currentIndex = index);
|
||||||
|
},
|
||||||
|
itemCount: widget.items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = widget.items[index];
|
||||||
|
return _buildHeroSlide(item);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Indicators
|
||||||
|
Positioned(
|
||||||
|
bottom: 24,
|
||||||
|
right: 48,
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(
|
||||||
|
widget.items.length,
|
||||||
|
(index) => AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
width: _currentIndex == index ? 24 : 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _currentIndex == index
|
||||||
|
? AppColors.primary
|
||||||
|
: Colors.white.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeroSlide(HeroCarouselItem item) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Background Image with Ken Burns Effect (Simulated via Scale in future, static for now)
|
||||||
|
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
|
||||||
|
Image.network(
|
||||||
|
_getProxiedUrl(item.imageUrl)!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(color: AppColors.surface),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Gradient Overlays
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
AppColors.background.withOpacity(0.2),
|
||||||
|
AppColors.background.withOpacity(0.9),
|
||||||
|
AppColors.background,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.5, 0.8, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerRight,
|
||||||
|
end: Alignment.centerLeft,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
AppColors.background.withOpacity(0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content Info
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(48.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Category Tag
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.primary.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'FEATURED',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Title
|
||||||
|
SizedBox(
|
||||||
|
width: 600,
|
||||||
|
child: Text(
|
||||||
|
item.title,
|
||||||
|
style: GoogleFonts.outfit(
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.subtitle != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.subtitle!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TvFocusableCard(
|
||||||
|
onTap: item.onPlay,
|
||||||
|
borderRadius: 50,
|
||||||
|
scaleFactor: 1.1,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 32,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.primary.withOpacity(0.4),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.play_arrow_rounded,
|
||||||
|
color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'WATCH NOW',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
TvFocusableCard(
|
||||||
|
onTap: item.onTap, // Add to favorites or details
|
||||||
|
borderRadius: 50,
|
||||||
|
child: GlassContainer(
|
||||||
|
borderRadius: 50,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'DETAILS',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48), // Bottom padding
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,50 +64,72 @@ class _TvFocusableCardState extends State<TvFocusableCard>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
void _handleHover(bool isHovered) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = isHovered;
|
||||||
|
});
|
||||||
|
if (isHovered || _isFocused) {
|
||||||
|
_controller.forward();
|
||||||
|
} else {
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
final isActive = _isFocused || _isHovered;
|
||||||
animation: _scaleAnimation,
|
|
||||||
builder: (context, child) {
|
return MouseRegion(
|
||||||
return Transform.scale(
|
onEnter: (_) => _handleHover(true),
|
||||||
scale: _scaleAnimation.value,
|
onExit: (_) => _handleHover(false),
|
||||||
child: child,
|
cursor: SystemMouseCursors.click,
|
||||||
);
|
child: AnimatedBuilder(
|
||||||
},
|
animation: _scaleAnimation,
|
||||||
child: InkWell(
|
builder: (context, child) {
|
||||||
onTap: widget.onTap,
|
return Transform.scale(
|
||||||
onFocusChange: _handleFocusChange,
|
scale: _scaleAnimation.value,
|
||||||
autofocus: widget.autofocus,
|
child: child,
|
||||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
);
|
||||||
focusColor: Colors.transparent, // We handle visual focus manually
|
},
|
||||||
hoverColor: Colors.transparent,
|
child: InkWell(
|
||||||
highlightColor: Colors.transparent,
|
onTap: widget.onTap,
|
||||||
splashColor: Colors.transparent,
|
onFocusChange: _handleFocusChange,
|
||||||
child: Container(
|
autofocus: widget.autofocus,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
focusColor: Colors.transparent,
|
||||||
border: _isFocused
|
hoverColor: Colors.transparent,
|
||||||
? Border.all(
|
highlightColor: Colors.transparent,
|
||||||
color: widget.focusColor ?? AppColors.focusBorder,
|
splashColor: Colors.transparent,
|
||||||
width: 3,
|
child: Container(
|
||||||
)
|
decoration: BoxDecoration(
|
||||||
: Border.all(color: Colors.transparent, width: 3),
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||||
boxShadow: _isFocused
|
border: isActive
|
||||||
? [
|
? Border.all(
|
||||||
BoxShadow(
|
color: widget.focusColor ?? AppColors.focusBorder,
|
||||||
color: Colors.black.withOpacity(0.3),
|
width: 2, // Slightly thinner for cleaner look
|
||||||
blurRadius: 20,
|
)
|
||||||
offset: const Offset(0, 10),
|
: Border.all(color: Colors.transparent, width: 2),
|
||||||
),
|
boxShadow: isActive
|
||||||
BoxShadow(
|
? [
|
||||||
color: (widget.focusColor ?? AppColors.focusBorder).withOpacity(0.2),
|
BoxShadow(
|
||||||
blurRadius: 10,
|
color: Colors.black.withOpacity(0.5),
|
||||||
spreadRadius: 1,
|
blurRadius: 25,
|
||||||
),
|
offset: const Offset(0, 10),
|
||||||
]
|
spreadRadius: 2,
|
||||||
: [],
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: (widget.focusColor ?? AppColors.focusBorder)
|
||||||
|
.withOpacity(0.3),
|
||||||
|
blurRadius: 15,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
child: widget.child,
|
||||||
),
|
),
|
||||||
child: widget.child,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,46 +1,47 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/services/settings_api_service.dart';
|
import '../../../core/services/settings_api_service.dart';
|
||||||
import '../../auth/providers/auth_provider.dart';
|
import '../../auth/providers/auth_provider.dart';
|
||||||
|
|
||||||
/// Streaming quality presets for Live TV
|
/// Streaming quality presets for Live TV
|
||||||
enum StreamQuality {
|
enum StreamQuality {
|
||||||
low, // 1.5 Mbps, CRF 26
|
low, // 1.5 Mbps, CRF 26
|
||||||
medium, // 3 Mbps, CRF 23
|
medium, // 3 Mbps, CRF 23
|
||||||
high, // 5 Mbps, CRF 20
|
high, // 5 Mbps, CRF 20
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buffer size presets for Live TV
|
/// Buffer size presets for Live TV
|
||||||
enum BufferSize {
|
enum BufferSize {
|
||||||
low, // 2s segments, 4MB buffer
|
low, // 2s segments, 4MB buffer
|
||||||
medium, // 4s segments, 8MB buffer
|
medium, // 4s segments, 8MB buffer
|
||||||
high, // 6s segments, 12MB buffer
|
high, // 6s segments, 12MB buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connection timeout presets
|
/// Connection timeout presets
|
||||||
enum ConnectionTimeout {
|
enum ConnectionTimeout {
|
||||||
short, // 15 seconds
|
short, // 15 seconds
|
||||||
medium, // 30 seconds
|
medium, // 30 seconds
|
||||||
long, // 60 seconds
|
long, // 60 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/// EPG cache duration presets
|
/// EPG cache duration presets
|
||||||
enum EpgCacheDuration {
|
enum EpgCacheDuration {
|
||||||
short, // 5 minutes
|
short, // 5 minutes
|
||||||
medium, // 15 minutes
|
medium, // 15 minutes
|
||||||
long, // 60 minutes
|
long, // 60 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transcoding mode for Live TV
|
/// Transcoding mode for Live TV
|
||||||
enum TranscodingMode {
|
enum TranscodingMode {
|
||||||
auto, // Auto-detect best mode
|
auto, // Auto-detect best mode
|
||||||
forced, // Always transcode
|
forced, // Always transcode
|
||||||
disabled, // Direct stream (no transcoding)
|
disabled, // Direct stream (no transcoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Player Type Preference
|
/// Player Type Preference
|
||||||
enum PlayerType {
|
enum PlayerType {
|
||||||
standard, // Heavily customized HTML5 player (default)
|
standard, // Heavily customized HTML5 player (default)
|
||||||
lite, // Lightweight native/video_player based
|
lite, // Lightweight native/video_player based
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keys for API JSON storage
|
/// Keys for API JSON storage
|
||||||
@@ -85,7 +86,6 @@ class IptvSettings {
|
|||||||
final String preferredAspectRatio;
|
final String preferredAspectRatio;
|
||||||
final PlayerType playerType;
|
final PlayerType playerType;
|
||||||
|
|
||||||
|
|
||||||
const IptvSettings({
|
const IptvSettings({
|
||||||
// Filters
|
// Filters
|
||||||
this.liveTvCategoryFilter = '',
|
this.liveTvCategoryFilter = '',
|
||||||
@@ -142,63 +142,84 @@ class IptvSettings {
|
|||||||
/// Get bitrate in kbps based on quality setting
|
/// Get bitrate in kbps based on quality setting
|
||||||
int get bitrateKbps {
|
int get bitrateKbps {
|
||||||
switch (streamQuality) {
|
switch (streamQuality) {
|
||||||
case StreamQuality.low: return 1500;
|
case StreamQuality.low:
|
||||||
case StreamQuality.medium: return 3000;
|
return 1500;
|
||||||
case StreamQuality.high: return 5000;
|
case StreamQuality.medium:
|
||||||
|
return 3000;
|
||||||
|
case StreamQuality.high:
|
||||||
|
return 5000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get CRF value based on quality setting
|
/// Get CRF value based on quality setting
|
||||||
int get crfValue {
|
int get crfValue {
|
||||||
switch (streamQuality) {
|
switch (streamQuality) {
|
||||||
case StreamQuality.low: return 26;
|
case StreamQuality.low:
|
||||||
case StreamQuality.medium: return 23;
|
return 26;
|
||||||
case StreamQuality.high: return 20;
|
case StreamQuality.medium:
|
||||||
|
return 23;
|
||||||
|
case StreamQuality.high:
|
||||||
|
return 20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get HLS segment duration in seconds
|
/// Get HLS segment duration in seconds
|
||||||
int get hlsSegmentDuration {
|
int get hlsSegmentDuration {
|
||||||
switch (bufferSize) {
|
switch (bufferSize) {
|
||||||
case BufferSize.low: return 2;
|
case BufferSize.low:
|
||||||
case BufferSize.medium: return 4;
|
return 2;
|
||||||
case BufferSize.high: return 6;
|
case BufferSize.medium:
|
||||||
|
return 4;
|
||||||
|
case BufferSize.high:
|
||||||
|
return 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get buffer size in KB
|
/// Get buffer size in KB
|
||||||
int get bufferSizeKb {
|
int get bufferSizeKb {
|
||||||
switch (bufferSize) {
|
switch (bufferSize) {
|
||||||
case BufferSize.low: return 4000;
|
case BufferSize.low:
|
||||||
case BufferSize.medium: return 8000;
|
return 4000;
|
||||||
case BufferSize.high: return 12000;
|
case BufferSize.medium:
|
||||||
|
return 8000;
|
||||||
|
case BufferSize.high:
|
||||||
|
return 12000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get connection timeout in seconds
|
/// Get connection timeout in seconds
|
||||||
int get timeoutSeconds {
|
int get timeoutSeconds {
|
||||||
switch (connectionTimeout) {
|
switch (connectionTimeout) {
|
||||||
case ConnectionTimeout.short: return 15;
|
case ConnectionTimeout.short:
|
||||||
case ConnectionTimeout.medium: return 30;
|
return 15;
|
||||||
case ConnectionTimeout.long: return 60;
|
case ConnectionTimeout.medium:
|
||||||
|
return 30;
|
||||||
|
case ConnectionTimeout.long:
|
||||||
|
return 60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get EPG cache duration in minutes
|
/// Get EPG cache duration in minutes
|
||||||
int get epgCacheMinutes {
|
int get epgCacheMinutes {
|
||||||
switch (epgCacheDuration) {
|
switch (epgCacheDuration) {
|
||||||
case EpgCacheDuration.short: return 5;
|
case EpgCacheDuration.short:
|
||||||
case EpgCacheDuration.medium: return 15;
|
return 5;
|
||||||
case EpgCacheDuration.long: return 60;
|
case EpgCacheDuration.medium:
|
||||||
|
return 15;
|
||||||
|
case EpgCacheDuration.long:
|
||||||
|
return 60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transcoding mode string for FFmpeg URL parameter
|
/// Get transcoding mode string for FFmpeg URL parameter
|
||||||
String get modeString {
|
String get modeString {
|
||||||
switch (transcodingMode) {
|
switch (transcodingMode) {
|
||||||
case TranscodingMode.disabled: return 'direct'; // Passthrough, 0% CPU
|
case TranscodingMode.disabled:
|
||||||
case TranscodingMode.forced: return 'transcode';
|
return 'direct'; // Passthrough, 0% CPU
|
||||||
case TranscodingMode.auto: return 'auto';
|
case TranscodingMode.forced:
|
||||||
|
return 'transcode';
|
||||||
|
case TranscodingMode.auto:
|
||||||
|
return 'auto';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,31 +303,53 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
|||||||
liveTvCategoryFilter: getValue<String>(_SettingsKeys.liveTvFilter),
|
liveTvCategoryFilter: getValue<String>(_SettingsKeys.liveTvFilter),
|
||||||
moviesCategoryFilter: getValue<String>(_SettingsKeys.moviesFilter),
|
moviesCategoryFilter: getValue<String>(_SettingsKeys.moviesFilter),
|
||||||
seriesCategoryFilter: getValue<String>(_SettingsKeys.seriesFilter),
|
seriesCategoryFilter: getValue<String>(_SettingsKeys.seriesFilter),
|
||||||
|
|
||||||
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
|
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
|
||||||
? StreamQuality.values[remoteSettings[_SettingsKeys.streamQuality] as int] : null,
|
? StreamQuality
|
||||||
|
.values[remoteSettings[_SettingsKeys.streamQuality] as int]
|
||||||
|
: null,
|
||||||
bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null
|
bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null
|
||||||
? BufferSize.values[remoteSettings[_SettingsKeys.bufferSize] as int] : null,
|
? BufferSize
|
||||||
connectionTimeout: remoteSettings[_SettingsKeys.connectionTimeout] != null
|
.values[remoteSettings[_SettingsKeys.bufferSize] as int]
|
||||||
? ConnectionTimeout.values[remoteSettings[_SettingsKeys.connectionTimeout] as int] : null,
|
: null,
|
||||||
|
connectionTimeout:
|
||||||
|
remoteSettings[_SettingsKeys.connectionTimeout] != null
|
||||||
|
? ConnectionTimeout.values[
|
||||||
|
remoteSettings[_SettingsKeys.connectionTimeout] as int]
|
||||||
|
: null,
|
||||||
autoReconnect: getValue<bool>(_SettingsKeys.autoReconnect),
|
autoReconnect: getValue<bool>(_SettingsKeys.autoReconnect),
|
||||||
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != null
|
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] !=
|
||||||
? EpgCacheDuration.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] : null,
|
null
|
||||||
|
? EpgCacheDuration
|
||||||
|
.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int]
|
||||||
|
: null,
|
||||||
transcodingMode: remoteSettings[_SettingsKeys.transcodingMode] != null
|
transcodingMode: remoteSettings[_SettingsKeys.transcodingMode] != null
|
||||||
? TranscodingMode.values[remoteSettings[_SettingsKeys.transcodingMode] as int] : null,
|
? TranscodingMode
|
||||||
|
.values[remoteSettings[_SettingsKeys.transcodingMode] as int]
|
||||||
|
: null,
|
||||||
preferDirectPlay: getValue<bool>(_SettingsKeys.preferDirectPlay),
|
preferDirectPlay: getValue<bool>(_SettingsKeys.preferDirectPlay),
|
||||||
showClock: getValue<bool>(_SettingsKeys.showClock),
|
showClock: getValue<bool>(_SettingsKeys.showClock),
|
||||||
preferredAspectRatio: getValue<String>(_SettingsKeys.preferredAspectRatio),
|
preferredAspectRatio:
|
||||||
|
getValue<String>(_SettingsKeys.preferredAspectRatio),
|
||||||
playerType: remoteSettings[_SettingsKeys.playerType] != null
|
playerType: remoteSettings[_SettingsKeys.playerType] != null
|
||||||
? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null,
|
? PlayerType
|
||||||
|
.values[remoteSettings[_SettingsKeys.playerType] as int]
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error syncing settings from API: $e');
|
print('Error syncing settings from API: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer? _saveDebounceTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveDebounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveToApi() async {
|
Future<void> _saveToApi() async {
|
||||||
final authState = _ref.read(authProvider);
|
final authState = _ref.read(authProvider);
|
||||||
if (!authState.isAuthenticated) {
|
if (!authState.isAuthenticated) {
|
||||||
@@ -314,27 +357,32 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create map from state
|
// Cancel pending save
|
||||||
final settingsMap = {
|
_saveDebounceTimer?.cancel();
|
||||||
_SettingsKeys.liveTvFilter: state.liveTvCategoryFilter,
|
|
||||||
_SettingsKeys.moviesFilter: state.moviesCategoryFilter,
|
|
||||||
_SettingsKeys.seriesFilter: state.seriesCategoryFilter,
|
|
||||||
_SettingsKeys.streamQuality: state.streamQuality.index,
|
|
||||||
_SettingsKeys.bufferSize: state.bufferSize.index,
|
|
||||||
_SettingsKeys.connectionTimeout: state.connectionTimeout.index,
|
|
||||||
_SettingsKeys.autoReconnect: state.autoReconnect,
|
|
||||||
_SettingsKeys.epgCacheDuration: state.epgCacheDuration.index,
|
|
||||||
_SettingsKeys.transcodingMode: state.transcodingMode.index,
|
|
||||||
_SettingsKeys.preferDirectPlay: state.preferDirectPlay,
|
|
||||||
_SettingsKeys.showClock: state.showClock,
|
|
||||||
_SettingsKeys.preferredAspectRatio: state.preferredAspectRatio,
|
|
||||||
_SettingsKeys.playerType: state.playerType.index,
|
|
||||||
_SettingsKeys.playerType: state.playerType.index,
|
|
||||||
};
|
|
||||||
|
|
||||||
print('[SettingsProvider] _saveToApi: Saving settings...');
|
// Debounce for 1 second to avoid rapid API calls
|
||||||
final success = await _apiService.saveSettings(settingsMap);
|
_saveDebounceTimer = Timer(const Duration(seconds: 1), () async {
|
||||||
print('[SettingsProvider] _saveToApi: Save result: $success');
|
// Create map from state
|
||||||
|
final settingsMap = {
|
||||||
|
_SettingsKeys.liveTvFilter: state.liveTvCategoryFilter,
|
||||||
|
_SettingsKeys.moviesFilter: state.moviesCategoryFilter,
|
||||||
|
_SettingsKeys.seriesFilter: state.seriesCategoryFilter,
|
||||||
|
_SettingsKeys.streamQuality: state.streamQuality.index,
|
||||||
|
_SettingsKeys.bufferSize: state.bufferSize.index,
|
||||||
|
_SettingsKeys.connectionTimeout: state.connectionTimeout.index,
|
||||||
|
_SettingsKeys.autoReconnect: state.autoReconnect,
|
||||||
|
_SettingsKeys.epgCacheDuration: state.epgCacheDuration.index,
|
||||||
|
_SettingsKeys.transcodingMode: state.transcodingMode.index,
|
||||||
|
_SettingsKeys.preferDirectPlay: state.preferDirectPlay,
|
||||||
|
_SettingsKeys.showClock: state.showClock,
|
||||||
|
_SettingsKeys.preferredAspectRatio: state.preferredAspectRatio,
|
||||||
|
_SettingsKeys.playerType: state.playerType.index,
|
||||||
|
};
|
||||||
|
|
||||||
|
print('[SettingsProvider] _saveToApi: Saving settings...');
|
||||||
|
final success = await _apiService.saveSettings(settingsMap);
|
||||||
|
print('[SettingsProvider] _saveToApi: Save result: $success');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Setters with Auto-Save =====
|
// ===== Setters with Auto-Save =====
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
|
import 'dart:ui';
|
||||||
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 '../../../core/models/playlist_config.dart';
|
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/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/tv_focusable_card.dart';
|
import '../../../core/widgets/tv_focusable_card.dart';
|
||||||
import '../../../core/widgets/digital_clock.dart';
|
|
||||||
import '../widgets/live_tv_tab.dart';
|
import '../widgets/live_tv_tab.dart';
|
||||||
import '../widgets/movies_tab.dart';
|
import '../widgets/movies_tab.dart';
|
||||||
import '../widgets/series_tab.dart';
|
import '../widgets/series_tab.dart';
|
||||||
@@ -41,18 +39,36 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// 1. Global Atmospheric Background
|
// 1. Cinematic Deep Space Background
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: RadialGradient(
|
gradient: LinearGradient(
|
||||||
center: Alignment(0, -0.4),
|
begin: Alignment.topLeft,
|
||||||
radius: 1.3,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [
|
||||||
Color(0xFF252525), // Center light
|
Color(0xFF0F1014), // Deep Space Blue-Grey
|
||||||
Color(0xFF000000), // Vignet
|
Color(0xFF181920), // Darker shade
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Ambient Glow (Subtle accent)
|
||||||
|
Positioned(
|
||||||
|
top: -100,
|
||||||
|
left: -100,
|
||||||
|
child: Container(
|
||||||
|
width: 500,
|
||||||
|
height: 500,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
AppColors.primary.withOpacity(0.2),
|
||||||
|
Colors.transparent,
|
||||||
],
|
],
|
||||||
stops: [0.0, 1.0],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -60,94 +76,110 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
// 2. Main Content Area
|
// 2. Main Content Area
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
top: 0, // Content goes behind header
|
left: 100, // Leave space for sidebar
|
||||||
child: IndexedStack(
|
child: IndexedStack(
|
||||||
index: _selectedIndex,
|
index: _selectedIndex,
|
||||||
children: [
|
children: [
|
||||||
// Add padding top internally in tabs or here
|
LiveTVTab(playlist: widget.playlist),
|
||||||
Padding(padding: const EdgeInsets.only(top: 100), child: LiveTVTab(playlist: widget.playlist)),
|
MoviesTab(playlist: widget.playlist),
|
||||||
Padding(padding: const EdgeInsets.only(top: 100), child: MoviesTab(playlist: widget.playlist)),
|
SeriesTab(playlist: widget.playlist),
|
||||||
Padding(padding: const EdgeInsets.only(top: 100), child: SeriesTab(playlist: widget.playlist)),
|
const SettingsTab(),
|
||||||
const Padding(padding: EdgeInsets.only(top: 100), child: SettingsTab()),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3. Floating Glass Header
|
// 3. Vertical Glass Sidebar
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 24, // Floating margin
|
|
||||||
left: 24,
|
left: 24,
|
||||||
right: 24,
|
top: 24,
|
||||||
|
bottom: 24,
|
||||||
|
width: 80,
|
||||||
child: GlassContainer(
|
child: GlassContainer(
|
||||||
height: 72,
|
borderRadius: 24,
|
||||||
borderRadius: 100, // Pill shape
|
opacity: 0.1,
|
||||||
opacity: 0.85,
|
// blur is hardcoded in GlassContainer (15), so we don't pass it
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
// border is bool, borderColor allows customization
|
||||||
child: Row(
|
border: true,
|
||||||
|
borderColor: Colors.white.withOpacity(0.1),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Logo
|
// Logo Icon
|
||||||
_buildLogo(),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
const Spacer(),
|
child: Container(
|
||||||
|
width: 40,
|
||||||
// Navigation Pills
|
height: 40,
|
||||||
Row(
|
decoration: BoxDecoration(
|
||||||
mainAxisSize: MainAxisSize.min,
|
gradient: AppColors.primaryGradient,
|
||||||
children: List.generate(_tabs.length, (index) {
|
borderRadius: BorderRadius.circular(12),
|
||||||
final isSelected = _selectedIndex == index;
|
boxShadow: [
|
||||||
return Padding(
|
BoxShadow(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
color: AppColors.primary.withOpacity(0.4),
|
||||||
child: TvFocusableCard(
|
blurRadius: 10,
|
||||||
onTap: () => setState(() => _selectedIndex = index),
|
offset: const Offset(0, 4),
|
||||||
scaleFactor: 1.1,
|
|
||||||
borderRadius: 100,
|
|
||||||
focusColor: Colors.white,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: AppTheme.durationFast,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected ? Colors.white : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(100),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_icons[index],
|
|
||||||
size: 18,
|
|
||||||
color: isSelected ? Colors.black : AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
_tabs[index],
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
|
||||||
color: isSelected ? Colors.black : AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
}),
|
child: const Icon(Icons.play_arrow_rounded,
|
||||||
|
color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
// Tools
|
// Navigation Icons
|
||||||
Row(
|
...List.generate(_tabs.length, (index) {
|
||||||
children: [
|
final isSelected = _selectedIndex == index;
|
||||||
_buildIconButton(Icons.search_rounded, () {}),
|
return Padding(
|
||||||
const SizedBox(width: 16),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
const DigitalClock(), // Kept existing widget
|
child: TvFocusableCard(
|
||||||
const SizedBox(width: 16),
|
onTap: () => setState(() => _selectedIndex = index),
|
||||||
CircleAvatar(
|
scaleFactor: 1.2,
|
||||||
radius: 18,
|
borderRadius: 16,
|
||||||
backgroundColor: Colors.white.withOpacity(0.1),
|
focusColor: AppColors.primary,
|
||||||
child: const Icon(Icons.person_rounded, size: 20, color: Colors.white),
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? AppColors.primary.withOpacity(0.2)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(
|
||||||
|
color: AppColors.primary.withOpacity(0.5))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_icons[index],
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// Settings / Profile
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: TvFocusableCard(
|
||||||
|
onTap: () {},
|
||||||
|
borderRadius: 50,
|
||||||
|
scaleFactor: 1.1,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.1),
|
||||||
|
child: const Icon(Icons.person,
|
||||||
|
color: Colors.white70, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -157,47 +189,4 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIconButton(IconData icon, VoidCallback onTap) {
|
|
||||||
return TvFocusableCard(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: 50,
|
|
||||||
scaleFactor: 1.15,
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(icon, color: Colors.white, size: 20),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLogo() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: const LinearGradient(colors: [Colors.white, Color(0xFFAAAAAA)]),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.black, size: 24),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
'XtremFlow',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 18,
|
|
||||||
color: Colors.white,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -289,15 +289,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Use stream type to determine player UI:
|
|
||||||
// - Live TV: Lite player UI (minimal overlay)
|
|
||||||
// - VOD/Series: Standard player UI (full controls)
|
|
||||||
final isLiveTV = widget.streamType == StreamType.live;
|
final isLiveTV = widget.streamType == StreamType.live;
|
||||||
|
|
||||||
// LITE PLAYER MODE for Live TV: Simple iframe with native HTML5 controls, minimal Flutter overlay
|
// LITE PLAYER MODE for Live TV
|
||||||
if (isLiveTV) {
|
if (isLiveTV) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black, // Video background
|
||||||
body: MouseRegion(
|
body: MouseRegion(
|
||||||
onHover: (_) => _onHover(),
|
onHover: (_) => _onHover(),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -311,130 +308,110 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Video Player (iframe with native controls)
|
|
||||||
if (_isInitialized) HtmlElementView(viewType: _viewId),
|
if (_isInitialized) HtmlElementView(viewType: _viewId),
|
||||||
|
|
||||||
// Top Bar: Back Button + Title (with auto-hide)
|
// Top Bar
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
top: _showControls ? 24 : -80,
|
top: _showControls ? 24 : -100,
|
||||||
left: 24,
|
left: 24,
|
||||||
right: 24,
|
right: 24,
|
||||||
child: PointerInterceptor(
|
child: PointerInterceptor(
|
||||||
child: Row(
|
child: GlassContainer(
|
||||||
children: [
|
height: 72,
|
||||||
Material(
|
borderRadius: 24,
|
||||||
color: Colors.black54,
|
opacity: 0.15,
|
||||||
shape: const CircleBorder(),
|
border: true,
|
||||||
child: InkWell(
|
borderColor: Colors.white.withOpacity(0.1),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildGlassIconButton(
|
||||||
|
icon: Icons.arrow_back_rounded,
|
||||||
onTap: () => Navigator.pop(context),
|
onTap: () => Navigator.pop(context),
|
||||||
customBorder: const CircleBorder(),
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.all(12),
|
|
||||||
child: Icon(
|
|
||||||
Icons.arrow_back_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
const SizedBox(width: 16),
|
Expanded(
|
||||||
Expanded(
|
child: Column(
|
||||||
child: Text(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
widget.channels != null
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? widget.channels![_currentIndex].name
|
children: [
|
||||||
: widget.title,
|
Text(
|
||||||
style: const TextStyle(
|
widget.channels != null
|
||||||
color: Colors.white,
|
? widget.channels![_currentIndex].name
|
||||||
fontSize: 18,
|
: widget.title,
|
||||||
fontWeight: FontWeight.bold,
|
style: GoogleFonts.outfit(
|
||||||
shadows: [
|
color: Colors.white,
|
||||||
Shadow(blurRadius: 4, color: Colors.black),
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.channels != null)
|
||||||
|
Text(
|
||||||
|
'Live TV',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
// Mute button for Live TV
|
_buildGlassIconButton(
|
||||||
Material(
|
icon: _isMuted
|
||||||
color: Colors.black54,
|
? Icons.volume_off_rounded
|
||||||
shape: const CircleBorder(),
|
: Icons.volume_up_rounded,
|
||||||
child: InkWell(
|
|
||||||
onTap: _toggleMute,
|
onTap: _toggleMute,
|
||||||
customBorder: const CircleBorder(),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Icon(
|
|
||||||
_isMuted
|
|
||||||
? Icons.volume_off_rounded
|
|
||||||
: Icons.volume_up_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Channel Zapping Controls (with auto-hide)
|
|
||||||
if (widget.channels != null && widget.channels!.length > 1)
|
|
||||||
AnimatedPositioned(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
bottom: 160,
|
|
||||||
right: _showControls ? 24 : -80,
|
|
||||||
child: PointerInterceptor(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Previous Channel
|
|
||||||
Material(
|
|
||||||
color: Colors.black54,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _previousChannel,
|
|
||||||
customBorder: const CircleBorder(),
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.all(14),
|
|
||||||
child: Icon(
|
|
||||||
Icons.keyboard_arrow_up,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Next Channel
|
|
||||||
Material(
|
|
||||||
color: Colors.black54,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _nextChannel,
|
|
||||||
customBorder: const CircleBorder(),
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.all(14),
|
|
||||||
child: Icon(
|
|
||||||
Icons.keyboard_arrow_down,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// EPG Overlay (for Live TV, with auto-hide, higher position)
|
// Channel Zapping Controls
|
||||||
|
if (widget.channels != null && widget.channels!.length > 1)
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
bottom: 160,
|
||||||
|
right: _showControls ? 24 : -100,
|
||||||
|
child: PointerInterceptor(
|
||||||
|
child: GlassContainer(
|
||||||
|
width: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
opacity: 0.15,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildGlassIconButton(
|
||||||
|
icon: Icons.keyboard_arrow_up_rounded,
|
||||||
|
onTap: _previousChannel,
|
||||||
|
transparent: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildGlassIconButton(
|
||||||
|
icon: Icons.keyboard_arrow_down_rounded,
|
||||||
|
onTap: _nextChannel,
|
||||||
|
transparent: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// EPG Overlay
|
||||||
if (widget.streamType == StreamType.live)
|
if (widget.streamType == StreamType.live)
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
bottom: _showControls ? 60 : -120,
|
bottom: _showControls ? 40 : -140,
|
||||||
left: 0,
|
left: 24,
|
||||||
right: 0,
|
right: 24,
|
||||||
child: PointerInterceptor(
|
child: PointerInterceptor(
|
||||||
child: EpgOverlay(
|
child: EpgOverlay(
|
||||||
playlist: widget.playlist,
|
playlist: widget.playlist,
|
||||||
@@ -451,16 +428,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// STANDARD PLAYER MODE (Custom Flutter Overlay)
|
// STANDARD PLAYER MODE (VOD/Series)
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// 1. Video Player Layer
|
|
||||||
if (_isInitialized) HtmlElementView(viewType: _viewId),
|
if (_isInitialized) HtmlElementView(viewType: _viewId),
|
||||||
|
|
||||||
// 2. Interaction Layer
|
// User interaction area
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: PointerInterceptor(
|
child: PointerInterceptor(
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
@@ -470,7 +446,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
if (_showControls) {
|
if (_showControls) {
|
||||||
setState(() => _showControls = false);
|
setState(() => _showControls = false);
|
||||||
} else {
|
} else {
|
||||||
_onHover(); // Shows controls and starts timer
|
_onHover();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
@@ -480,257 +456,197 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3. UI Overlay (Glassmorphism)
|
// Controls Overlay
|
||||||
if (_showControls || _isLoading)
|
if (_showControls || _isLoading)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: PointerInterceptor(
|
child: PointerInterceptor(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.3), // Dim background
|
color: Colors.black.withOpacity(0.4),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Top Bar (Back + Title)
|
// Top Bar
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 24,
|
top: 24,
|
||||||
left: 24,
|
left: 24,
|
||||||
right: 24,
|
right: 24,
|
||||||
child: Row(
|
child: GlassContainer(
|
||||||
children: [
|
height: 72,
|
||||||
TvFocusableCard(
|
borderRadius: 24,
|
||||||
onTap: () => Navigator.pop(context),
|
opacity: 0.1,
|
||||||
borderRadius: 50,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Container(
|
child: Row(
|
||||||
padding: const EdgeInsets.all(12),
|
children: [
|
||||||
decoration: BoxDecoration(
|
_buildGlassIconButton(
|
||||||
color: Colors.white.withOpacity(0.1),
|
icon: Icons.arrow_back_rounded,
|
||||||
shape: BoxShape.circle,
|
onTap: () => Navigator.pop(context),
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.arrow_back_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
const SizedBox(width: 24),
|
Expanded(
|
||||||
Column(
|
child: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
widget.title,
|
||||||
mainAxisSize: MainAxisSize.min,
|
style: GoogleFonts.outfit(
|
||||||
children: [
|
fontSize: 20,
|
||||||
Text(
|
fontWeight: FontWeight.w600,
|
||||||
widget.channels != null
|
|
||||||
? widget.channels![_currentIndex].name
|
|
||||||
: widget.title,
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.streamType == StreamType.live &&
|
),
|
||||||
widget.channels != null)
|
],
|
||||||
Text(
|
),
|
||||||
'Live TV',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.live,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Center Play/Pause (Animated)
|
// Center Play/Pause
|
||||||
Center(
|
Center(
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const CircularProgressIndicator(
|
? const CircularProgressIndicator(
|
||||||
color: Colors.white,
|
color: AppColors.primary)
|
||||||
)
|
|
||||||
: TvFocusableCard(
|
: TvFocusableCard(
|
||||||
onTap: _togglePlayPause,
|
onTap: _togglePlayPause,
|
||||||
borderRadius: 100,
|
borderRadius: 100,
|
||||||
scaleFactor: 1.2,
|
scaleFactor: 1.2,
|
||||||
|
focusColor: AppColors.primary,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.2),
|
gradient: AppColors.primaryGradient,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
boxShadow: [
|
||||||
color: Colors.white.withOpacity(0.3),
|
BoxShadow(
|
||||||
),
|
color:
|
||||||
|
AppColors.primary.withOpacity(0.5),
|
||||||
|
blurRadius: 30,
|
||||||
|
spreadRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_isPlaying
|
_isPlaying
|
||||||
? Icons.pause_rounded
|
? Icons.pause_rounded
|
||||||
: Icons.play_arrow_rounded,
|
: Icons.play_arrow_rounded,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 48,
|
size: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// EPG Overlay
|
// Bottom Controls
|
||||||
if (widget.streamType == StreamType.live)
|
|
||||||
Positioned(
|
|
||||||
bottom: 140,
|
|
||||||
left: 40,
|
|
||||||
child: EpgOverlay(
|
|
||||||
streamId: widget.channels != null
|
|
||||||
? widget.channels![_currentIndex].streamId
|
|
||||||
: widget.streamId,
|
|
||||||
playlist: widget.playlist,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Bottom Control Bar
|
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 40,
|
bottom: 40,
|
||||||
left: 40,
|
left: 40,
|
||||||
right: 40,
|
right: 40,
|
||||||
child: GlassContainer(
|
child: GlassContainer(
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
opacity: 0.8,
|
opacity: 0.2, // Slightly more opaque for controls
|
||||||
|
border: true,
|
||||||
|
borderColor: Colors.white.withOpacity(0.1),
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Progress Bar (if not live)
|
// Progress
|
||||||
if (widget.streamType != StreamType.live)
|
Row(
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
Text(
|
||||||
child: Row(
|
_formatDuration(Duration(
|
||||||
children: [
|
seconds: _currentPosition.toInt())),
|
||||||
Text(
|
style: GoogleFonts.inter(
|
||||||
_formatDuration(
|
color: Colors.white70),
|
||||||
Duration(
|
|
||||||
seconds: _currentPosition.toInt(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: SliderTheme(
|
|
||||||
data: const SliderThemeData(
|
|
||||||
trackHeight: 4,
|
|
||||||
thumbShape: RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: 8,
|
|
||||||
),
|
|
||||||
overlayShape:
|
|
||||||
RoundSliderOverlayShape(
|
|
||||||
overlayRadius: 16,
|
|
||||||
),
|
|
||||||
activeTrackColor: AppColors.primary,
|
|
||||||
inactiveTrackColor: Colors.white24,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
),
|
|
||||||
child: Slider(
|
|
||||||
value: _currentPosition,
|
|
||||||
min: 0,
|
|
||||||
max: _totalDuration,
|
|
||||||
onChangeStart: (value) {
|
|
||||||
setState(() => _isSeeking = true);
|
|
||||||
},
|
|
||||||
onChanged: (value) {
|
|
||||||
// Update UI immediately (optimistic update)
|
|
||||||
setState(
|
|
||||||
() => _currentPosition = value,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onChangeEnd: (value) {
|
|
||||||
_sendMessage({
|
|
||||||
'type': 'seek',
|
|
||||||
'value': value,
|
|
||||||
});
|
|
||||||
// Small delay to prevent jitter from incoming messages
|
|
||||||
Future.delayed(
|
|
||||||
const Duration(
|
|
||||||
milliseconds: 500,
|
|
||||||
), () {
|
|
||||||
if (mounted) {
|
|
||||||
setState(
|
|
||||||
() => _isSeeking = false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Text(
|
|
||||||
_formatDuration(
|
|
||||||
Duration(
|
|
||||||
seconds: _totalDuration.toInt(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderThemeData(
|
||||||
|
trackHeight: 4,
|
||||||
|
activeTrackColor: AppColors.primary,
|
||||||
|
inactiveTrackColor:
|
||||||
|
Colors.white.withOpacity(0.2),
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
thumbShape: const RoundSliderThumbShape(
|
||||||
|
enabledThumbRadius: 8),
|
||||||
|
overlayColor:
|
||||||
|
AppColors.primary.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: _currentPosition,
|
||||||
|
min: 0,
|
||||||
|
max: _totalDuration,
|
||||||
|
onChanged: (val) => setState(
|
||||||
|
() => _currentPosition = val),
|
||||||
|
onChangeStart: (_) =>
|
||||||
|
setState(() => _isSeeking = true),
|
||||||
|
onChangeEnd: (val) {
|
||||||
|
_sendMessage(
|
||||||
|
{'type': 'seek', 'value': val});
|
||||||
|
Future.delayed(
|
||||||
|
const Duration(milliseconds: 500),
|
||||||
|
() {
|
||||||
|
if (mounted)
|
||||||
|
setState(
|
||||||
|
() => _isSeeking = false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Text(
|
||||||
|
_formatDuration(Duration(
|
||||||
|
seconds: _totalDuration.toInt())),
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Controls Row
|
// Buttons
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Channel Zapping for Live TV
|
_buildGlassIconButton(
|
||||||
if (widget.streamType == StreamType.live &&
|
icon: Icons.replay_10_rounded,
|
||||||
widget.channels != null) ...[
|
onTap: () => _sendMessage({
|
||||||
_buildTvControl(
|
'type': 'seek',
|
||||||
Icons.navigate_before_rounded,
|
'value': (_currentPosition - 10)
|
||||||
_previousChannel,
|
.clamp(0, _totalDuration)
|
||||||
),
|
}),
|
||||||
const SizedBox(width: 32),
|
transparent: true,
|
||||||
_buildTvControl(
|
),
|
||||||
Icons.navigate_next_rounded,
|
const SizedBox(width: 24),
|
||||||
_nextChannel,
|
_buildGlassIconButton(
|
||||||
),
|
icon: _isPlaying
|
||||||
],
|
? Icons.pause_rounded
|
||||||
|
: Icons.play_arrow_rounded,
|
||||||
// Controls for VOD (Keep Skip buttons, they act as Prev/Next track if playlist or maybe seek)
|
onTap: _togglePlayPause,
|
||||||
// The user said: "only for live tv, other buttons skip/prev must allow changing channel"
|
size: 56,
|
||||||
// This implies for VOD they might want seek? But existing buttons were skip_previous/skip_next.
|
iconSize: 32,
|
||||||
// We keep them for VOD if channels list is present, or just hide them if not needed.
|
),
|
||||||
// For now, I will restore them for VOD as well if channels > 0 (playlist mode)
|
const SizedBox(width: 24),
|
||||||
if (widget.streamType != StreamType.live &&
|
_buildGlassIconButton(
|
||||||
widget.channels != null &&
|
icon: Icons.forward_10_rounded,
|
||||||
widget.channels!.isNotEmpty) ...[
|
onTap: () => _sendMessage({
|
||||||
_buildTvControl(
|
'type': 'seek',
|
||||||
Icons.skip_previous_rounded,
|
'value': (_currentPosition + 10)
|
||||||
_previousChannel,
|
.clamp(0, _totalDuration)
|
||||||
),
|
}),
|
||||||
const SizedBox(width: 32),
|
transparent: true,
|
||||||
_buildTvControl(
|
),
|
||||||
Icons.skip_next_rounded,
|
|
||||||
_nextChannel,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
// Volume/Mute button
|
_buildGlassIconButton(
|
||||||
_buildTvControl(
|
icon: _isMuted
|
||||||
_isMuted
|
|
||||||
? Icons.volume_off_rounded
|
? Icons.volume_off_rounded
|
||||||
: Icons.volume_up_rounded,
|
: Icons.volume_up_rounded,
|
||||||
_toggleMute,
|
onTap: _toggleMute,
|
||||||
|
transparent: true,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_buildTvControl(
|
_buildGlassIconButton(
|
||||||
Icons.subtitles_rounded,
|
icon: Icons.aspect_ratio_rounded,
|
||||||
() {},
|
onTap: _toggleFullscreen,
|
||||||
),
|
transparent: true,
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildTvControl(
|
|
||||||
Icons.aspect_ratio_rounded,
|
|
||||||
_toggleFullscreen,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -748,17 +664,29 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTvControl(IconData icon, VoidCallback onTap) {
|
Widget _buildGlassIconButton({
|
||||||
|
required IconData icon,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
bool transparent = false,
|
||||||
|
double size = 48,
|
||||||
|
double iconSize = 24,
|
||||||
|
}) {
|
||||||
return TvFocusableCard(
|
return TvFocusableCard(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: 12,
|
borderRadius: size / 2,
|
||||||
|
scaleFactor: 1.1,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
width: size,
|
||||||
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.1),
|
color:
|
||||||
borderRadius: BorderRadius.circular(12),
|
transparent ? Colors.transparent : Colors.white.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: transparent
|
||||||
|
? null
|
||||||
|
: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: Colors.white, size: 24),
|
child: Icon(icon, color: Colors.white, size: iconSize),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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_colors.dart';
|
||||||
import '../../../core/utils/responsive_layout.dart';
|
import '../../../core/utils/responsive_layout.dart';
|
||||||
import '../../../core/widgets/components/hero_carousel.dart';
|
import '../../../core/widgets/hero_carousel.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/tv_focusable_card.dart';
|
import '../../../core/widgets/tv_focusable_card.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
import '../providers/watch_history_provider.dart';
|
||||||
@@ -305,22 +305,18 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
items: displayMovies
|
items: displayMovies
|
||||||
.take(5)
|
.take(5)
|
||||||
.map(
|
.map(
|
||||||
(m) => HeroItem(
|
(m) => HeroCarouselItem(
|
||||||
id: m.streamId,
|
id: m.streamId,
|
||||||
title: m.name,
|
title: m.name,
|
||||||
imageUrl: _getProxiedImageUrl(m.streamIcon),
|
imageUrl: _getProxiedImageUrl(m.streamIcon),
|
||||||
subtitle: m.rating != null
|
subtitle: m.rating != null
|
||||||
? '${_formatRating(m.rating)} ★'
|
? '${_formatRating(m.rating)} ★'
|
||||||
: null,
|
: null,
|
||||||
onMoreInfo: () => _playMovie(m),
|
onPlay: () => _playMovie(m),
|
||||||
|
onTap: () => _playMovie(m),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onTap: (item) {
|
|
||||||
final movie = _movies
|
|
||||||
.firstWhere((element) => element.streamId == item.id);
|
|
||||||
_playMovie(movie);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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/hero_carousel.dart';
|
import '../../../core/widgets/hero_carousel.dart';
|
||||||
import '../../../core/widgets/themed_loading_screen.dart';
|
import '../../../core/widgets/themed_loading_screen.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/tv_focusable_card.dart';
|
import '../../../core/widgets/tv_focusable_card.dart';
|
||||||
@@ -283,14 +283,23 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
|||||||
items: displaySeries
|
items: displaySeries
|
||||||
.take(5)
|
.take(5)
|
||||||
.map(
|
.map(
|
||||||
(s) => HeroItem(
|
(s) => HeroCarouselItem(
|
||||||
id: s.seriesId.toString(),
|
id: s.seriesId.toString(),
|
||||||
title: s.name,
|
title: s.name,
|
||||||
imageUrl: _getProxiedImageUrl(s.cover),
|
imageUrl: _getProxiedImageUrl(s.cover),
|
||||||
subtitle: s.rating != null
|
subtitle: s.rating != null
|
||||||
? '${_formatRating(s.rating)} ★'
|
? '${_formatRating(s.rating)} ★'
|
||||||
: null,
|
: null,
|
||||||
onMoreInfo: () {
|
onPlay: () {
|
||||||
|
final series = _series.firstWhere(
|
||||||
|
(element) =>
|
||||||
|
element.seriesId.toString() ==
|
||||||
|
s.seriesId.toString(),
|
||||||
|
orElse: () => s,
|
||||||
|
);
|
||||||
|
_openSeries(series);
|
||||||
|
},
|
||||||
|
onTap: () {
|
||||||
final series = _series.firstWhere(
|
final series = _series.firstWhere(
|
||||||
(element) =>
|
(element) =>
|
||||||
element.seriesId.toString() ==
|
element.seriesId.toString() ==
|
||||||
@@ -302,13 +311,6 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onTap: (item) {
|
|
||||||
final series = _series.firstWhere(
|
|
||||||
(element) => element.seriesId.toString() == item.id,
|
|
||||||
orElse: () => _series[0],
|
|
||||||
);
|
|
||||||
_openSeries(series);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user