mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +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
+968
-612
No files matched your search
@@ -12,45 +12,58 @@ class AppColors {
|
||||
|
||||
// ============ BACKGROUNDS ============
|
||||
/// 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)
|
||||
static const Color surface = Color(0xFF1C1C1E);
|
||||
static const Color surface = Color(0xFF181920);
|
||||
|
||||
/// 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)
|
||||
static const Color focusColor = Color(0xFFFFFFFF);
|
||||
static const Color border = Color(0xFF1F1F1F);
|
||||
static const Color focusBorder = Color(0xFFFFFFFF);
|
||||
|
||||
// ============ ACCENTS ============
|
||||
/// Minimal white accent. In Apple TV, color is used sparingly.
|
||||
/// We keep a subtle blue only for specific indicators if needed.
|
||||
static const Color primary = Color(0xFFFFFFFF);
|
||||
static const Color accent = Color(0xFFE5E5E5);
|
||||
static const Color primary = Color(0xFF6C63FF);
|
||||
static const Color secondary = Color(0xFF00B4D8);
|
||||
static const Color accent = Color(0xFF00B4D8);
|
||||
|
||||
/// Primary Gradient (Subtle silver/white glow)
|
||||
/// Primary Gradient (Electric Violet to Cyan)
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)],
|
||||
colors: [Color(0xFF6C63FF), Color(0xFF00B4D8)],
|
||||
begin: Alignment.topLeft,
|
||||
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 textSecondary = Color(0xFF9E9E9E); // Medium Grey
|
||||
static const Color textTertiary = Color(0xFF616161); // Darker Grey
|
||||
static const Color textSecondary = Color(0xFFAAAAAA);
|
||||
static const Color textTertiary = Color(0xFF666666);
|
||||
|
||||
// ============ FUNCTIONAL COLORS ============
|
||||
static const Color success = Color(0xFF4DB6AC); // Teal-ish
|
||||
static const Color warning = Color(0xFFFFB74D);
|
||||
static const Color error = Color(0xFFEF5350);
|
||||
static const Color live = Color(0xFFFF3B30); // Apple Red
|
||||
static const Color ratingGold = Color(0xFFFFD700);
|
||||
static const Color premiumGold = Color(0xFFFFA000);
|
||||
|
||||
// ============ BORDERS ============
|
||||
// Borders are usually hidden until focused
|
||||
static const Color border = Color(0xFF1F1F1F);
|
||||
static const Color focusBorder = Color(0xFFFFFFFF);
|
||||
// Category Colors
|
||||
static const Color live = Color(0xFFFF5252); // Red
|
||||
static const Color movies = Color(0xFF448AFF); // Blue
|
||||
static const Color series = Color(0xFF69F0AE); // Green
|
||||
|
||||
// ============ GLASSMORPHISM ============
|
||||
// Apple TV uses this heavily for headers/overlays
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// XtremFlow Apple TV Theme
|
||||
///
|
||||
///
|
||||
/// Focus-driven, immersive, minimal.
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
@@ -27,27 +27,57 @@ class AppTheme {
|
||||
// ============ ANIMATION ============
|
||||
static const Duration durationFast = Duration(milliseconds: 200);
|
||||
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) ============
|
||||
static ThemeData get darkTheme {
|
||||
final baseTheme = ThemeData.dark(useMaterial3: true);
|
||||
|
||||
|
||||
return baseTheme.copyWith(
|
||||
colorScheme: AppColors.darkColorScheme,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
|
||||
// Typography: San Francisco style (using Inter as proxy)
|
||||
|
||||
// Typography: "Cinematic Modern" (Outfit for Headings, Inter for UI)
|
||||
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),
|
||||
displayLarge: GoogleFonts.outfit(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1.0,
|
||||
color: AppColors.textPrimary),
|
||||
displayMedium: GoogleFonts.outfit(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w700,
|
||||
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
|
||||
@@ -57,18 +87,19 @@ class AppTheme {
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
titleTextStyle: GoogleFonts.inter(
|
||||
fontSize: 17,
|
||||
titleTextStyle: GoogleFonts.outfit(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Inputs: Dark, minimal, no borders unless focused
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: spacing24, vertical: spacing16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(radiusMd),
|
||||
borderSide: BorderSide.none,
|
||||
@@ -83,7 +114,7 @@ class AppTheme {
|
||||
),
|
||||
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
|
||||
),
|
||||
|
||||
|
||||
// Buttons: White pill or minimal text
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -91,17 +122,21 @@ class AppTheme {
|
||||
foregroundColor: Colors.black, // Black text
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)),
|
||||
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radiusMd)),
|
||||
textStyle:
|
||||
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
).copyWith(
|
||||
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);
|
||||
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,
|
||||
@@ -124,8 +159,9 @@ class AppTheme {
|
||||
static const double radiusFull = 999.0;
|
||||
|
||||
// Light theme stub
|
||||
static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith(
|
||||
colorScheme: AppColors.lightColorScheme,
|
||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
|
||||
);
|
||||
static ThemeData get lightTheme =>
|
||||
ThemeData.light(useMaterial3: true).copyWith(
|
||||
colorScheme: AppColors.lightColorScheme,
|
||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
|
||||
);
|
||||
}
|
||||
@@ -35,11 +35,10 @@ class GlassContainer extends StatelessWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withOpacity(opacity),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: border
|
||||
? Border.all(
|
||||
@@ -47,6 +46,21 @@ class GlassContainer extends StatelessWidget {
|
||||
width: 1,
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
onFocusChange: _handleFocusChange,
|
||||
autofocus: widget.autofocus,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
focusColor: Colors.transparent, // We handle visual focus manually
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
border: _isFocused
|
||||
? Border.all(
|
||||
color: widget.focusColor ?? AppColors.focusBorder,
|
||||
width: 3,
|
||||
)
|
||||
: Border.all(color: Colors.transparent, width: 3),
|
||||
boxShadow: _isFocused
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: (widget.focusColor ?? AppColors.focusBorder).withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
final isActive = _isFocused || _isHovered;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _handleHover(true),
|
||||
onExit: (_) => _handleHover(false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
onFocusChange: _handleFocusChange,
|
||||
autofocus: widget.autofocus,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
focusColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
border: isActive
|
||||
? Border.all(
|
||||
color: widget.focusColor ?? AppColors.focusBorder,
|
||||
width: 2, // Slightly thinner for cleaner look
|
||||
)
|
||||
: Border.all(color: Colors.transparent, width: 2),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
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 '../../../core/services/settings_api_service.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
|
||||
/// Streaming quality presets for Live TV
|
||||
enum StreamQuality {
|
||||
low, // 1.5 Mbps, CRF 26
|
||||
low, // 1.5 Mbps, CRF 26
|
||||
medium, // 3 Mbps, CRF 23
|
||||
high, // 5 Mbps, CRF 20
|
||||
high, // 5 Mbps, CRF 20
|
||||
}
|
||||
|
||||
/// Buffer size presets for Live TV
|
||||
enum BufferSize {
|
||||
low, // 2s segments, 4MB buffer
|
||||
low, // 2s segments, 4MB buffer
|
||||
medium, // 4s segments, 8MB buffer
|
||||
high, // 6s segments, 12MB buffer
|
||||
high, // 6s segments, 12MB buffer
|
||||
}
|
||||
|
||||
/// Connection timeout presets
|
||||
enum ConnectionTimeout {
|
||||
short, // 15 seconds
|
||||
short, // 15 seconds
|
||||
medium, // 30 seconds
|
||||
long, // 60 seconds
|
||||
long, // 60 seconds
|
||||
}
|
||||
|
||||
/// EPG cache duration presets
|
||||
enum EpgCacheDuration {
|
||||
short, // 5 minutes
|
||||
short, // 5 minutes
|
||||
medium, // 15 minutes
|
||||
long, // 60 minutes
|
||||
long, // 60 minutes
|
||||
}
|
||||
|
||||
/// Transcoding mode for Live TV
|
||||
enum TranscodingMode {
|
||||
auto, // Auto-detect best mode
|
||||
forced, // Always transcode
|
||||
auto, // Auto-detect best mode
|
||||
forced, // Always transcode
|
||||
disabled, // Direct stream (no transcoding)
|
||||
}
|
||||
|
||||
/// Player Type Preference
|
||||
enum PlayerType {
|
||||
standard, // Heavily customized HTML5 player (default)
|
||||
lite, // Lightweight native/video_player based
|
||||
lite, // Lightweight native/video_player based
|
||||
}
|
||||
|
||||
/// Keys for API JSON storage
|
||||
@@ -49,7 +50,7 @@ class _SettingsKeys {
|
||||
static const String liveTvFilter = 'filter_live_tv';
|
||||
static const String moviesFilter = 'filter_movies';
|
||||
static const String seriesFilter = 'filter_series';
|
||||
|
||||
|
||||
// Streaming settings
|
||||
static const String streamQuality = 'stream_quality';
|
||||
static const String bufferSize = 'buffer_size';
|
||||
@@ -79,12 +80,11 @@ class IptvSettings {
|
||||
final EpgCacheDuration epgCacheDuration;
|
||||
final TranscodingMode transcodingMode;
|
||||
final bool preferDirectPlay;
|
||||
|
||||
|
||||
// Player Display Settings
|
||||
final bool showClock;
|
||||
final String preferredAspectRatio;
|
||||
final PlayerType playerType;
|
||||
|
||||
|
||||
const IptvSettings({
|
||||
// Filters
|
||||
@@ -142,63 +142,84 @@ class IptvSettings {
|
||||
/// Get bitrate in kbps based on quality setting
|
||||
int get bitrateKbps {
|
||||
switch (streamQuality) {
|
||||
case StreamQuality.low: return 1500;
|
||||
case StreamQuality.medium: return 3000;
|
||||
case StreamQuality.high: return 5000;
|
||||
case StreamQuality.low:
|
||||
return 1500;
|
||||
case StreamQuality.medium:
|
||||
return 3000;
|
||||
case StreamQuality.high:
|
||||
return 5000;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get CRF value based on quality setting
|
||||
int get crfValue {
|
||||
switch (streamQuality) {
|
||||
case StreamQuality.low: return 26;
|
||||
case StreamQuality.medium: return 23;
|
||||
case StreamQuality.high: return 20;
|
||||
case StreamQuality.low:
|
||||
return 26;
|
||||
case StreamQuality.medium:
|
||||
return 23;
|
||||
case StreamQuality.high:
|
||||
return 20;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get HLS segment duration in seconds
|
||||
int get hlsSegmentDuration {
|
||||
switch (bufferSize) {
|
||||
case BufferSize.low: return 2;
|
||||
case BufferSize.medium: return 4;
|
||||
case BufferSize.high: return 6;
|
||||
case BufferSize.low:
|
||||
return 2;
|
||||
case BufferSize.medium:
|
||||
return 4;
|
||||
case BufferSize.high:
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get buffer size in KB
|
||||
int get bufferSizeKb {
|
||||
switch (bufferSize) {
|
||||
case BufferSize.low: return 4000;
|
||||
case BufferSize.medium: return 8000;
|
||||
case BufferSize.high: return 12000;
|
||||
case BufferSize.low:
|
||||
return 4000;
|
||||
case BufferSize.medium:
|
||||
return 8000;
|
||||
case BufferSize.high:
|
||||
return 12000;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get connection timeout in seconds
|
||||
int get timeoutSeconds {
|
||||
switch (connectionTimeout) {
|
||||
case ConnectionTimeout.short: return 15;
|
||||
case ConnectionTimeout.medium: return 30;
|
||||
case ConnectionTimeout.long: return 60;
|
||||
case ConnectionTimeout.short:
|
||||
return 15;
|
||||
case ConnectionTimeout.medium:
|
||||
return 30;
|
||||
case ConnectionTimeout.long:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get EPG cache duration in minutes
|
||||
int get epgCacheMinutes {
|
||||
switch (epgCacheDuration) {
|
||||
case EpgCacheDuration.short: return 5;
|
||||
case EpgCacheDuration.medium: return 15;
|
||||
case EpgCacheDuration.long: return 60;
|
||||
case EpgCacheDuration.short:
|
||||
return 5;
|
||||
case EpgCacheDuration.medium:
|
||||
return 15;
|
||||
case EpgCacheDuration.long:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get transcoding mode string for FFmpeg URL parameter
|
||||
String get modeString {
|
||||
switch (transcodingMode) {
|
||||
case TranscodingMode.disabled: return 'direct'; // Passthrough, 0% CPU
|
||||
case TranscodingMode.forced: return 'transcode';
|
||||
case TranscodingMode.auto: return 'auto';
|
||||
case TranscodingMode.disabled:
|
||||
return 'direct'; // Passthrough, 0% CPU
|
||||
case TranscodingMode.forced:
|
||||
return 'transcode';
|
||||
case TranscodingMode.auto:
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,31 +303,53 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
||||
liveTvCategoryFilter: getValue<String>(_SettingsKeys.liveTvFilter),
|
||||
moviesCategoryFilter: getValue<String>(_SettingsKeys.moviesFilter),
|
||||
seriesCategoryFilter: getValue<String>(_SettingsKeys.seriesFilter),
|
||||
|
||||
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
|
||||
? StreamQuality.values[remoteSettings[_SettingsKeys.streamQuality] as int] : null,
|
||||
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
|
||||
? StreamQuality
|
||||
.values[remoteSettings[_SettingsKeys.streamQuality] as int]
|
||||
: null,
|
||||
bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null
|
||||
? BufferSize.values[remoteSettings[_SettingsKeys.bufferSize] as int] : null,
|
||||
connectionTimeout: remoteSettings[_SettingsKeys.connectionTimeout] != null
|
||||
? ConnectionTimeout.values[remoteSettings[_SettingsKeys.connectionTimeout] as int] : null,
|
||||
? BufferSize
|
||||
.values[remoteSettings[_SettingsKeys.bufferSize] as int]
|
||||
: null,
|
||||
connectionTimeout:
|
||||
remoteSettings[_SettingsKeys.connectionTimeout] != null
|
||||
? ConnectionTimeout.values[
|
||||
remoteSettings[_SettingsKeys.connectionTimeout] as int]
|
||||
: null,
|
||||
autoReconnect: getValue<bool>(_SettingsKeys.autoReconnect),
|
||||
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != null
|
||||
? EpgCacheDuration.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] : null,
|
||||
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] !=
|
||||
null
|
||||
? EpgCacheDuration
|
||||
.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int]
|
||||
: 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),
|
||||
showClock: getValue<bool>(_SettingsKeys.showClock),
|
||||
preferredAspectRatio: getValue<String>(_SettingsKeys.preferredAspectRatio),
|
||||
preferredAspectRatio:
|
||||
getValue<String>(_SettingsKeys.preferredAspectRatio),
|
||||
playerType: remoteSettings[_SettingsKeys.playerType] != null
|
||||
? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null,
|
||||
? PlayerType
|
||||
.values[remoteSettings[_SettingsKeys.playerType] as int]
|
||||
: null,
|
||||
);
|
||||
}
|
||||
_initialized = true;
|
||||
} 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 {
|
||||
final authState = _ref.read(authProvider);
|
||||
if (!authState.isAuthenticated) {
|
||||
@@ -314,27 +357,32 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
_SettingsKeys.playerType: state.playerType.index,
|
||||
};
|
||||
// Cancel pending save
|
||||
_saveDebounceTimer?.cancel();
|
||||
|
||||
print('[SettingsProvider] _saveToApi: Saving settings...');
|
||||
final success = await _apiService.saveSettings(settingsMap);
|
||||
print('[SettingsProvider] _saveToApi: Save result: $success');
|
||||
// Debounce for 1 second to avoid rapid API calls
|
||||
_saveDebounceTimer = Timer(const Duration(seconds: 1), () async {
|
||||
// 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 =====
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
import '../../../core/widgets/digital_clock.dart';
|
||||
import '../widgets/live_tv_tab.dart';
|
||||
import '../widgets/movies_tab.dart';
|
||||
import '../widgets/series_tab.dart';
|
||||
@@ -41,113 +39,147 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 1. Global Atmospheric Background
|
||||
// 1. Cinematic Deep Space Background
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(0, -0.4),
|
||||
radius: 1.3,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF252525), // Center light
|
||||
Color(0xFF000000), // Vignet
|
||||
Color(0xFF0F1014), // Deep Space Blue-Grey
|
||||
Color(0xFF181920), // Darker shade
|
||||
],
|
||||
stops: [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Main Content Area
|
||||
Positioned.fill(
|
||||
top: 0, // Content goes behind header
|
||||
left: 100, // Leave space for sidebar
|
||||
child: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: [
|
||||
// Add padding top internally in tabs or here
|
||||
Padding(padding: const EdgeInsets.only(top: 100), child: LiveTVTab(playlist: widget.playlist)),
|
||||
Padding(padding: const EdgeInsets.only(top: 100), child: MoviesTab(playlist: widget.playlist)),
|
||||
Padding(padding: const EdgeInsets.only(top: 100), child: SeriesTab(playlist: widget.playlist)),
|
||||
const Padding(padding: EdgeInsets.only(top: 100), child: SettingsTab()),
|
||||
LiveTVTab(playlist: widget.playlist),
|
||||
MoviesTab(playlist: widget.playlist),
|
||||
SeriesTab(playlist: widget.playlist),
|
||||
const SettingsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 3. Floating Glass Header
|
||||
// 3. Vertical Glass Sidebar
|
||||
Positioned(
|
||||
top: 24, // Floating margin
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
bottom: 24,
|
||||
width: 80,
|
||||
child: GlassContainer(
|
||||
height: 72,
|
||||
borderRadius: 100, // Pill shape
|
||||
opacity: 0.85,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
borderRadius: 24,
|
||||
opacity: 0.1,
|
||||
// blur is hardcoded in GlassContainer (15), so we don't pass it
|
||||
// border is bool, borderColor allows customization
|
||||
border: true,
|
||||
borderColor: Colors.white.withOpacity(0.1),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo
|
||||
_buildLogo(),
|
||||
|
||||
// Logo Icon
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.4),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded,
|
||||
color: Colors.white, size: 28),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Navigation Pills
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(_tabs.length, (index) {
|
||||
final isSelected = _selectedIndex == index;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: TvFocusableCard(
|
||||
onTap: () => setState(() => _selectedIndex = index),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Navigation Icons
|
||||
...List.generate(_tabs.length, (index) {
|
||||
final isSelected = _selectedIndex == index;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: TvFocusableCard(
|
||||
onTap: () => setState(() => _selectedIndex = index),
|
||||
scaleFactor: 1.2,
|
||||
borderRadius: 16,
|
||||
focusColor: AppColors.primary,
|
||||
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(),
|
||||
|
||||
// Tools
|
||||
Row(
|
||||
children: [
|
||||
_buildIconButton(Icons.search_rounded, () {}),
|
||||
const SizedBox(width: 16),
|
||||
const DigitalClock(), // Kept existing widget
|
||||
const SizedBox(width: 16),
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: Colors.white.withOpacity(0.1),
|
||||
child: const Icon(Icons.person_rounded, size: 20, color: Colors.white),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
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
|
||||
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;
|
||||
|
||||
// LITE PLAYER MODE for Live TV: Simple iframe with native HTML5 controls, minimal Flutter overlay
|
||||
// LITE PLAYER MODE for Live TV
|
||||
if (isLiveTV) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Colors.black, // Video background
|
||||
body: MouseRegion(
|
||||
onHover: (_) => _onHover(),
|
||||
child: GestureDetector(
|
||||
@@ -311,130 +308,110 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Video Player (iframe with native controls)
|
||||
if (_isInitialized) HtmlElementView(viewType: _viewId),
|
||||
|
||||
// Top Bar: Back Button + Title (with auto-hide)
|
||||
// Top Bar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
top: _showControls ? 24 : -80,
|
||||
top: _showControls ? 24 : -100,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: PointerInterceptor(
|
||||
child: Row(
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.black54,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
child: GlassContainer(
|
||||
height: 72,
|
||||
borderRadius: 24,
|
||||
opacity: 0.15,
|
||||
border: true,
|
||||
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),
|
||||
customBorder: const CircleBorder(),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.channels != null
|
||||
? widget.channels![_currentIndex].name
|
||||
: widget.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(blurRadius: 4, color: Colors.black),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.channels != null
|
||||
? widget.channels![_currentIndex].name
|
||||
: widget.title,
|
||||
style: GoogleFonts.outfit(
|
||||
color: Colors.white,
|
||||
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,
|
||||
),
|
||||
),
|
||||
// Mute button for Live TV
|
||||
Material(
|
||||
color: Colors.black54,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isMuted
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
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)
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
bottom: _showControls ? 60 : -120,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _showControls ? 40 : -140,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: PointerInterceptor(
|
||||
child: EpgOverlay(
|
||||
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(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 1. Video Player Layer
|
||||
if (_isInitialized) HtmlElementView(viewType: _viewId),
|
||||
|
||||
// 2. Interaction Layer
|
||||
// User interaction area
|
||||
Positioned.fill(
|
||||
child: PointerInterceptor(
|
||||
child: MouseRegion(
|
||||
@@ -470,7 +446,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
if (_showControls) {
|
||||
setState(() => _showControls = false);
|
||||
} else {
|
||||
_onHover(); // Shows controls and starts timer
|
||||
_onHover();
|
||||
}
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
@@ -480,257 +456,197 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 3. UI Overlay (Glassmorphism)
|
||||
// Controls Overlay
|
||||
if (_showControls || _isLoading)
|
||||
Positioned.fill(
|
||||
child: PointerInterceptor(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.3), // Dim background
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Top Bar (Back + Title)
|
||||
// Top Bar
|
||||
Positioned(
|
||||
top: 24,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
TvFocusableCard(
|
||||
onTap: () => Navigator.pop(context),
|
||||
borderRadius: 50,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
child: GlassContainer(
|
||||
height: 72,
|
||||
borderRadius: 24,
|
||||
opacity: 0.1,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.arrow_back_rounded,
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.channels != null
|
||||
? widget.channels![_currentIndex].name
|
||||
: widget.title,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
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(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
)
|
||||
color: AppColors.primary)
|
||||
: TvFocusableCard(
|
||||
onTap: _togglePlayPause,
|
||||
borderRadius: 100,
|
||||
scaleFactor: 1.2,
|
||||
focusColor: AppColors.primary,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
gradient: AppColors.primaryGradient,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
AppColors.primary.withOpacity(0.5),
|
||||
blurRadius: 30,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
_isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// EPG Overlay
|
||||
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
|
||||
// Bottom Controls
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
left: 40,
|
||||
right: 40,
|
||||
child: GlassContainer(
|
||||
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),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress Bar (if not live)
|
||||
if (widget.streamType != StreamType.live)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Progress
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(Duration(
|
||||
seconds: _currentPosition.toInt())),
|
||||
style: GoogleFonts.inter(
|
||||
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(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Channel Zapping for Live TV
|
||||
if (widget.streamType == StreamType.live &&
|
||||
widget.channels != null) ...[
|
||||
_buildTvControl(
|
||||
Icons.navigate_before_rounded,
|
||||
_previousChannel,
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_buildTvControl(
|
||||
Icons.navigate_next_rounded,
|
||||
_nextChannel,
|
||||
),
|
||||
],
|
||||
|
||||
// Controls for VOD (Keep Skip buttons, they act as Prev/Next track if playlist or maybe seek)
|
||||
// The user said: "only for live tv, other buttons skip/prev must allow changing channel"
|
||||
// This implies for VOD they might want seek? But existing buttons were skip_previous/skip_next.
|
||||
// We keep them for VOD if channels list is present, or just hide them if not needed.
|
||||
// For now, I will restore them for VOD as well if channels > 0 (playlist mode)
|
||||
if (widget.streamType != StreamType.live &&
|
||||
widget.channels != null &&
|
||||
widget.channels!.isNotEmpty) ...[
|
||||
_buildTvControl(
|
||||
Icons.skip_previous_rounded,
|
||||
_previousChannel,
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_buildTvControl(
|
||||
Icons.skip_next_rounded,
|
||||
_nextChannel,
|
||||
),
|
||||
],
|
||||
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.replay_10_rounded,
|
||||
onTap: () => _sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition - 10)
|
||||
.clamp(0, _totalDuration)
|
||||
}),
|
||||
transparent: true,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: _isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
onTap: _togglePlayPause,
|
||||
size: 56,
|
||||
iconSize: 32,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.forward_10_rounded,
|
||||
onTap: () => _sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition + 10)
|
||||
.clamp(0, _totalDuration)
|
||||
}),
|
||||
transparent: true,
|
||||
),
|
||||
const Spacer(),
|
||||
// Volume/Mute button
|
||||
_buildTvControl(
|
||||
_isMuted
|
||||
_buildGlassIconButton(
|
||||
icon: _isMuted
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
_toggleMute,
|
||||
onTap: _toggleMute,
|
||||
transparent: true,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildTvControl(
|
||||
Icons.subtitles_rounded,
|
||||
() {},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildTvControl(
|
||||
Icons.aspect_ratio_rounded,
|
||||
_toggleFullscreen,
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.aspect_ratio_rounded,
|
||||
onTap: _toggleFullscreen,
|
||||
transparent: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -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(
|
||||
onTap: onTap,
|
||||
borderRadius: 12,
|
||||
borderRadius: size / 2,
|
||||
scaleFactor: 1.1,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color:
|
||||
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/theme/app_colors.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/tv_focusable_card.dart';
|
||||
import '../providers/watch_history_provider.dart';
|
||||
@@ -305,22 +305,18 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
items: displayMovies
|
||||
.take(5)
|
||||
.map(
|
||||
(m) => HeroItem(
|
||||
(m) => HeroCarouselItem(
|
||||
id: m.streamId,
|
||||
title: m.name,
|
||||
imageUrl: _getProxiedImageUrl(m.streamIcon),
|
||||
subtitle: m.rating != null
|
||||
? '${_formatRating(m.rating)} ★'
|
||||
: null,
|
||||
onMoreInfo: () => _playMovie(m),
|
||||
onPlay: () => _playMovie(m),
|
||||
onTap: () => _playMovie(m),
|
||||
),
|
||||
)
|
||||
.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_theme.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/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
@@ -283,14 +283,23 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
items: displaySeries
|
||||
.take(5)
|
||||
.map(
|
||||
(s) => HeroItem(
|
||||
(s) => HeroCarouselItem(
|
||||
id: s.seriesId.toString(),
|
||||
title: s.name,
|
||||
imageUrl: _getProxiedImageUrl(s.cover),
|
||||
subtitle: s.rating != null
|
||||
? '${_formatRating(s.rating)} ★'
|
||||
: null,
|
||||
onMoreInfo: () {
|
||||
onPlay: () {
|
||||
final series = _series.firstWhere(
|
||||
(element) =>
|
||||
element.seriesId.toString() ==
|
||||
s.seriesId.toString(),
|
||||
orElse: () => s,
|
||||
);
|
||||
_openSeries(series);
|
||||
},
|
||||
onTap: () {
|
||||
final series = _series.firstWhere(
|
||||
(element) =>
|
||||
element.seriesId.toString() ==
|
||||
@@ -302,13 +311,6 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
),
|
||||
)
|
||||
.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