feat: security hardening, streaming overhaul, design polish, tests

Security:
- Replace unsalted SHA-256 password hashing with bcrypt (lazy rehash on login)
- Add authenticated /api/xtream-api gateway: Xtream credentials are injected
  server-side and never sent to the frontend; /api/playlists no longer
  returns passwords
- Redact credentials from all logs (login body, proxy/FFmpeg/scheduler URLs)
- Add auth to recordings, EPG, season-passes and streaming routes
  (HttpOnly session cookie for hls.js; loopback bypass for local FFmpeg)
- Lock player postMessage to same-origin in both directions
- Vendor and pin hls.js 1.6.7 / mpegts.js 1.7.3 (drop CDN @latest)
- Fix rate limiter (client IP was never resolved), add login rate limit,
  restrict CORS, add CSP Report-Only, block private-IP SSRF targets,
  fix path traversal in recording log retrieval, chmod 777 -> 770
- Remove dead HiveService (seeded admin/admin into IndexedDB with SHA-256)
- Fix authMiddleware not populating 'user' context (getPlaylist ignored the
  logged-in user; admin purge always returned 403)

Streaming:
- New FfmpegSessionManager: process registry, idle reaper (4 min live /
  15 min VOD), orphan cleanup at startup, clean SIGTERM shutdown,
  fast-fail with stderr instead of 30 s timeout
- Quality selection (source/high/medium/low) for live and VOD; source mode
  streams with -c:v copy (zero transcoding); selector wired into the player
- Concurrent recordings (MAX_CONCURRENT_RECORDINGS, default 2); conflicts
  retry on the next tick instead of silently failing
- Lower live latency (HLS window 20 -> 10 segments, liveSync 10 -> 3)
- Fix recording log lookup (.mp4 vs .mkv mismatch)

Design:
- Replace hardcoded colors with AppColors tokens (12 files)
- web/theme.css syncs HTML players with the Flutter palette
- DPAD/keyboard navigation (arrow-key focus, player shortcuts)
- Tooltips on player icon buttons, Semantics on content cards
- Remove 7 dead widgets broken since the Stitch merge

Quality:
- bin/test/: 21 unit tests (bcrypt, redaction, traversal, SSRF, recording
  conflicts) plus a quality-selector widget test
- GitHub Actions CI (analyze + test + build web)
- Archive stale status docs into docs/archive/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-06-10 10:07:18 +02:00
1 parent cb5eca7547
commit 60d3f42901
107 files changed
+4525 -3815

No files matched your search

-227
View File
@@ -1,227 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
class ChannelCard extends StatefulWidget {
final String channelNumber;
final String channelName;
final String? channelLogo;
final String? currentProgram;
final String? programTime;
final bool isFavorite;
final VoidCallback? onTap;
final VoidCallback? onFavoriteTap;
final bool isActive;
const ChannelCard({
super.key,
required this.channelNumber,
required this.channelName,
this.channelLogo,
this.currentProgram,
this.programTime,
this.isFavorite = false,
this.onTap,
this.onFavoriteTap,
this.isActive = false,
});
@override
State<ChannelCard> createState() => _ChannelCardState();
}
class _ChannelCardState extends State<ChannelCard>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: AppTheme.durationMd,
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.05).animate(
CurvedAnimation(parent: _pulseController, curve: AppTheme.curveSmooth),
);
if (widget.isActive) {
_pulseController.forward();
}
}
@override
void didUpdateWidget(ChannelCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) {
_pulseController.forward();
} else if (!widget.isActive && oldWidget.isActive) {
_pulseController.reverse();
}
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: ScaleTransition(
scale: widget.isActive
? _pulseAnimation
: const AlwaysStoppedAnimation(1.0),
child: AnimatedContainer(
duration: AppTheme.durationMd,
curve: AppTheme.curveDefault,
decoration: BoxDecoration(
color: widget.isActive
? AppColors.primary.withOpacity(0.15)
: AppColors.surface,
border: Border.all(
color: widget.isActive
? AppColors.primary
: (_isHovered
? AppColors.border
: AppColors.border.withOpacity(0.5)),
width: widget.isActive ? 2.5 : 1.5,
),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
boxShadow: [
if (_isHovered || widget.isActive)
BoxShadow(
color: widget.isActive
? AppColors.primary.withOpacity(0.25)
: AppColors.primary.withOpacity(0.1),
blurRadius: 12,
offset: const Offset(0, 4),
spreadRadius: 1,
),
],
),
child: Stack(
fit: StackFit.expand,
children: [
Padding(
padding: EdgeInsets.all(AppTheme.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channelNumber,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
letterSpacing: 0.5,
),
),
SizedBox(height: AppTheme.spacingXs),
Text(
widget.channelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
GestureDetector(
onTap: widget.onFavoriteTap,
child: AnimatedScale(
scale: widget.isFavorite ? 1.1 : 1.0,
duration: AppTheme.durationXs,
child: Icon(
widget.isFavorite
? Icons.favorite
: Icons.favorite_border,
color: widget.isFavorite
? AppColors.secondary
: AppColors.textSecondary,
size: 20,
),
),
),
],
),
SizedBox(height: AppTheme.spacingSm),
if (widget.currentProgram != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.currentProgram!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
if (widget.programTime != null) ...[
SizedBox(height: AppTheme.spacingXs),
Text(
widget.programTime!,
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
),
],
],
),
],
),
),
if (widget.isActive)
Positioned(
top: AppTheme.spacingSm,
right: AppTheme.spacingSm,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.6),
blurRadius: 8,
spreadRadius: 2,
),
],
),
),
),
],
),
),
),
),
);
}
}
@@ -1,543 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/recommendations_provider.dart';
import '../../../core/models/playlist_config.dart';
import '../models/playlist.dart';
/// Widget showing "Continue Watching" section
class ContinueWatchingWidget extends ConsumerStatefulWidget {
final Playlist playlist;
final List<Playlist> content;
final VoidCallback? onItemTap;
const ContinueWatchingWidget({
super.key,
required this.playlist,
required this.content,
this.onItemTap,
});
@override
ConsumerState<ContinueWatchingWidget> createState() =>
_ContinueWatchingWidgetState();
}
class _ContinueWatchingWidgetState
extends ConsumerState<ContinueWatchingWidget> {
@override
Widget build(BuildContext context) {
final watchHistory = ref.watch(watchHistoryProvider);
final continueWatching =
RecommendationService.getContinueWatching(watchHistory, widget.content);
if (continueWatching.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Continue Watching',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () {
// Navigate to full continue watching list
},
child: const Text('View All'),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
...continueWatching.take(5).map((rec) {
final item = rec.item;
final percentage =
watchHistory[item.streamId.toString()] is Map
? (watchHistory[item.streamId.toString()]
as Map)['percentage'] as double? ??
0.0
: 0.0;
return Container(
margin: const EdgeInsets.only(right: 12),
child: _ContinueWatchingCard(
item: item,
progress: percentage,
onTap: widget.onItemTap,
),
);
}),
const SizedBox(width: 8),
],
),
),
],
),
);
}
}
class _ContinueWatchingCard extends StatelessWidget {
final Playlist item;
final double progress;
final VoidCallback? onTap;
const _ContinueWatchingCard({
required this.item,
required this.progress,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster with progress bar
Container(
width: 160,
height: 240,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[800],
),
child: Stack(
fit: StackFit.expand,
children: [
// Poster image
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.cover ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) => Icon(
Icons.image_not_supported,
color: Colors.grey[600],
),
),
),
// Dark overlay at bottom
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.8),
],
),
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress / 100.0,
minHeight: 3,
backgroundColor: Colors.white30,
valueColor: AlwaysStoppedAnimation<Color>(
_getProgressColor(progress),
),
),
),
const SizedBox(height: 4),
Text(
'${progress.toStringAsFixed(0)}% watched',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
),
),
],
),
),
),
// Play button overlay on hover
Positioned(
top: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.7),
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.all(8),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 24,
),
),
),
],
),
),
const SizedBox(height: 8),
// Title
SizedBox(
width: 160,
child: Text(
item.name ?? 'Unknown',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Color _getProgressColor(double progress) {
if (progress < 30) return Colors.red;
if (progress < 70) return Colors.orange;
return Colors.green;
}
}
/// Widget showing trending/popular content
class TrendingWidget extends ConsumerWidget {
final Playlist playlist;
final List<Playlist> content;
final VoidCallback? onItemTap;
const TrendingWidget({
super.key,
required this.playlist,
required this.content,
this.onItemTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trending = ref.watch(trendingProvider);
final trendingContent =
RecommendationService.getTrending(trending, content);
if (trendingContent.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(
Icons.local_fire_department,
color: Colors.red,
size: 24,
),
SizedBox(width: 8),
Text(
'Trending Now',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
TextButton(
onPressed: () {},
child: const Text('View All'),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
...trendingContent.take(5).map((rec) {
final item = rec.item;
final rank = trendingContent.indexOf(rec) + 1;
return Container(
margin: const EdgeInsets.only(right: 12),
child: _TrendingCard(
item: item,
rank: rank,
onTap: onItemTap,
),
);
}),
const SizedBox(width: 8),
],
),
),
],
),
);
}
}
class _TrendingCard extends StatelessWidget {
final Playlist item;
final int rank;
final VoidCallback? onTap;
const _TrendingCard({
required this.item,
required this.rank,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Stack(
children: [
// Card
Container(
width: 180,
height: 260,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[800],
),
child: Column(
children: [
// Image
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
child: CachedNetworkImage(
imageUrl: item.cover ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) => Icon(
Icons.image_not_supported,
color: Colors.grey[600],
),
),
),
),
// Info
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? 'Unknown',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
if (item.rating != null)
Row(
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 12,
),
const SizedBox(width: 4),
Text(
item.rating ?? '0',
style: const TextStyle(fontSize: 11),
),
],
),
],
),
),
],
),
),
// Rank badge
Positioned(
top: 8,
left: 8,
child: Container(
decoration: BoxDecoration(
color: _getRankColor(rank),
shape: BoxShape.circle,
),
width: 40,
height: 40,
child: Center(
child: Text(
'#$rank',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
),
],
),
);
}
Color _getRankColor(int rank) {
switch (rank) {
case 1:
return Colors.amber.shade700;
case 2:
return Colors.grey.shade600;
case 3:
return Colors.orange.shade700;
default:
return Colors.blue.shade700;
}
}
}
/// Widget showing recently added content
class RecentlyAddedWidget extends ConsumerWidget {
final Playlist playlist;
final List<Playlist> content;
final VoidCallback? onItemTap;
const RecentlyAddedWidget({
super.key,
required this.playlist,
required this.content,
this.onItemTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recent = RecommendationService.getRecentlyAdded(content);
if (recent.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Recently Added',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
...recent.take(5).map((rec) {
return Container(
margin: const EdgeInsets.only(right: 12),
child: _RecentCard(
item: rec.item,
onTap: onItemTap,
),
);
}),
const SizedBox(width: 8),
],
),
),
],
),
);
}
}
class _RecentCard extends StatelessWidget {
final Playlist item;
final VoidCallback? onTap;
const _RecentCard({
required this.item,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[800],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.cover ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) =>
Icon(Icons.image_not_supported, color: Colors.grey[600]),
),
),
),
);
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ class EpgOverlay extends ConsumerWidget {
margin: const EdgeInsets.fromLTRB(0, 0, 24, 0),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF1A1A2E)
color: AppColors.surfaceContainerLow
.withOpacity(0.85), // Match Native player controls
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withOpacity(0.1)),
+2 -1
View File
@@ -371,6 +371,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return TvFocusableCard(
onTap: () => _playChannel(channel, channels),
borderRadius: 12,
semanticLabel: 'Chaîne ${channel.name}',
child: Container(
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
@@ -481,7 +482,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return Text(
currentProgram.title,
style: GoogleFonts.inter(
color: const Color(0xFFFFD700),
color: AppColors.ratingGold,
fontSize: 10,
fontWeight: FontWeight.w600,
),
+2 -1
View File
@@ -341,6 +341,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
return TvFocusableCard(
onTap: () => _playMovie(movie),
borderRadius: 12,
semanticLabel: 'Film ${movie.name}',
child: Stack(
fit: StackFit.expand,
children: [
@@ -420,7 +421,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
const Icon(
Icons.star,
size: 10,
color: Color(0xFFFFD700),
color: AppColors.ratingGold,
),
const SizedBox(width: 4),
Text(
@@ -1,253 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/adaptive_bitrate_service.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/theme/app_colors.dart';
/// Widget for displaying and selecting video quality during playback
class QualitySelectorWidget extends ConsumerStatefulWidget {
final QualitySelector qualitySelector;
final VoidCallback onClose;
/// Stream quality presets handled server-side by FFmpeg
/// (see bin/api/streaming_handler.dart).
enum StreamQuality {
source('source', 'Source', 'Flux direct, zéro transcodage'),
high('high', 'Haute', '1080p · 6-8 Mbps'),
medium('medium', 'Moyenne', '3 Mbps'),
low('low', 'Basse', '720p · 1.5 Mbps');
const QualitySelectorWidget({
super.key,
required this.qualitySelector,
required this.onClose,
});
const StreamQuality(this.value, this.label, this.description);
@override
ConsumerState<QualitySelectorWidget> createState() =>
_QualitySelectorWidgetState();
final String value;
final String label;
final String description;
}
class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
late List<QualityLevel> _availableQualities;
late QualityLevel _selectedQuality;
/// Popup button to switch the transcoding quality of the current stream.
class QualitySelectorButton extends StatelessWidget {
final StreamQuality current;
final ValueChanged<StreamQuality> onSelected;
final double size;
@override
void initState() {
super.initState();
_availableQualities = QualityProfiles.getBalancedProfiles();
_selectedQuality = widget.qualitySelector.currentQuality;
}
const QualitySelectorButton({
super.key,
required this.current,
required this.onSelected,
this.size = 44,
});
@override
Widget build(BuildContext context) {
final currentQuality = widget.qualitySelector.currentQuality;
final bandwidthMbps = widget.qualitySelector.getEstimatedBandwidthMbps();
return Dialog(
backgroundColor: Colors.black87,
insetPadding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.white24),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Video Quality',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: widget.onClose,
),
],
),
),
// Network Info
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Current Bandwidth:',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
Text(
'${bandwidthMbps.toStringAsFixed(1)} Mbps',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
return Tooltip(
message: 'Qualité : ${current.label}',
child: PopupMenuButton<StreamQuality>(
initialValue: current,
color: AppColors.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
onSelected: onSelected,
itemBuilder: (context) => StreamQuality.values
.map(
(q) => PopupMenuItem<StreamQuality>(
value: q,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
q == current
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 18,
color: q == current
? AppColors.primary
: AppColors.textTertiary,
),
),
],
),
),
),
// Quality Options
ListView.builder(
shrinkWrap: true,
itemCount: _availableQualities.length,
itemBuilder: (context, index) {
final quality = _availableQualities[index];
final isSelected = quality == currentQuality;
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: isSelected
? const Icon(Icons.check_circle, color: Colors.blue)
: const Icon(
Icons.radio_button_unchecked,
color: Colors.white30,
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
q.label,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
Text(
q.description,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
color: AppColors.textTertiary,
fontSize: 11,
),
),
],
),
title: Text(
quality.label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
],
),
subtitle: Text(
'${quality.bitrateBps ~/ 1000000} Mbps',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
onTap: () {
widget.qualitySelector.setQuality(quality);
setState(() {
_selectedQuality = quality;
});
},
);
},
),
)
.toList(),
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.2)),
),
// Auto Quality Option
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Colors.white24),
),
),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
leading: const Icon(Icons.auto_awesome, color: Colors.amber),
title: const Text(
'Auto Quality',
style: TextStyle(
color: Colors.amber,
fontWeight: FontWeight.bold,
),
),
subtitle: const Text(
'Adjusts based on network speed',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
onTap: () {
// Auto mode would be handled by the player
widget.onClose();
},
),
),
child: const Icon(
Icons.high_quality_rounded,
color: Colors.white,
size: 22,
),
const SizedBox(height: 8),
],
),
);
}
}
/// Floating quality indicator (minimal UI during playback)
class QualityIndicator extends ConsumerWidget {
final QualitySelector qualitySelector;
final VoidCallback onTap;
const QualityIndicator({
super.key,
required this.qualitySelector,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final quality = qualitySelector.currentQuality;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.high_quality, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
quality.resolution.split('x')[1], // Show just the height
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}
/// Bandwidth monitor widget (for debugging/info)
class BandwidthMonitor extends ConsumerWidget {
final QualitySelector qualitySelector;
const BandwidthMonitor({
super.key,
required this.qualitySelector,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.speed, color: Colors.green, size: 14),
const SizedBox(width: 4),
Text(
'${qualitySelector.getEstimatedBandwidthMbps().toStringAsFixed(1)} Mbps',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
),
),
],
),
);
}
}
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http;
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/player_screen.dart';
@@ -363,7 +364,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
const Icon(
Icons.tv_off,
size: 64,
color: Color(0x1FFFFFFF),
color: AppColors.glassLevel1Border,
),
const SizedBox(height: 16),
Text(
@@ -512,7 +513,7 @@ class _ProgrammeCard extends StatelessWidget {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E),
backgroundColor: AppColors.surfaceContainer,
title: Row(
children: [
const Icon(
@@ -823,7 +824,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E),
backgroundColor: AppColors.surfaceContainer,
title: Text(
title,
style: GoogleFonts.outfit(color: Colors.white, fontSize: 14),
@@ -918,7 +919,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
const Icon(
Icons.videocam_off,
size: 64,
color: Color(0x1FFFFFFF),
color: AppColors.glassLevel1Border,
),
const SizedBox(height: 16),
Text(
@@ -1124,7 +1125,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E),
backgroundColor: AppColors.surfaceContainer,
title: Row(
children: [
const Icon(Icons.repeat, color: Colors.purpleAccent),
@@ -1282,7 +1283,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
const Icon(
Icons.repeat,
size: 64,
color: Color(0x1FFFFFFF),
color: AppColors.glassLevel1Border,
),
const SizedBox(height: 16),
Text(
+2 -1
View File
@@ -333,6 +333,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
return TvFocusableCard(
onTap: () => _openSeries(serie),
borderRadius: 12,
semanticLabel: 'Série ${serie.name}',
child: Stack(
fit: StackFit.expand,
children: [
@@ -434,7 +435,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
const Icon(
Icons.star,
size: 10,
color: Color(0xFFFFD700),
color: AppColors.ratingGold,
),
const SizedBox(width: 4),
Text(
+1 -1
View File
@@ -482,7 +482,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
backgroundColor: AppColors.surfaceContainer,
title: const Text(
'Vider le cache ?',
style: TextStyle(color: AppColors.onSurface),
@@ -287,7 +287,7 @@ class StreamingSettingsTab extends ConsumerWidget {
trailing: DropdownButton<T>(
value: value,
underline: const SizedBox(),
dropdownColor: const Color(0xFF1C1C1E), // Dark background for dropdown
dropdownColor: AppColors.surfaceContainer, // Dark background for dropdown
items: items.map((item) {
return DropdownMenuItem<T>(
value: item,