mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-21 05:58:05 +02:00
Full visual overhaul from Cyber-Cinematic Glass to "Warm Cinema": warm charcoal (#0B0908), cream (#F3E9DF), ember (#D9541F). Fraunces (editorial serif) for display, Karla for body/UI. Design - app_colors: new palette, all existing token names preserved, plus posterScrim/heroScrim gradients and lift()/emberFocus() shadows - app_theme: new type scale, tighter radii, 20+ component themes - mobile_theme aligned on the same tokens - glass_container: opaque layered surfaces replace glassmorphism; adds GlassCard (hover + remote-focus states) and CinemaBackdrop - players and boot splash restyled (film-leader motif, no web fonts) Performance - remove every BackdropFilter (33 call sites): backdrop blur forced a full framebuffer read per frame per card, the main scroll jank source - bundle Fraunces/Karla as assets: google_fonts no longer fetches from fonts.gstatic.com at startup - drop the blocking hls.js CDN script from index.html; no Dart code ever used it, only the player iframes need it - self-host hls.js/mpegts.js and load them on demand, so ~213 KB of mpegts is never downloaded for HLS streams - bound image decode size on 10 sites: a 40-poster grid drops from ~240 MB to a few tens of MB of GPU memory Players (new shared engine: web/xf-player-core.js) - event-driven start instead of a 200 ms buffer poll plus a 1.5 s wait - initialLiveManifestSize 3 -> 1, liveSyncDurationCount 10 -> 2, startFragPrefetch on, testBandwidth off - MPEG-TS initial stash 512 KB -> 64 KB - adaptive escalation fast -> balanced -> safe on repeated stalls (60 s rolling window), applied live for HLS so there is no visible cut Pre-existing build breakage fixed along the way - undefined AppColors.shimmer, AppTheme.durationXs and spacing8/12/16/32 - broken relative imports in channel_card and quality_selector_widget - missing GlassCard widget and hasBorder/showShadow parameters Also resolves a leftover unmerged .claude/settings.local.json by keeping the remote version. Still broken (pre-existing, dead code, not reachable from main.dart): recommendations_provider.dart and continue_watching_widget.dart import a models/playlist.dart that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
371 lines
15 KiB
Dart
371 lines
15 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import '../../../core/models/iptv_models.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/widgets/glass_container.dart';
|
|
import '../../../core/widgets/tv_focusable_card.dart';
|
|
|
|
class RecordingModal extends StatefulWidget {
|
|
final Channel channel;
|
|
|
|
const RecordingModal({
|
|
super.key,
|
|
required this.channel,
|
|
});
|
|
|
|
static Future<void> show(BuildContext context, Channel channel) {
|
|
return showDialog(
|
|
context: context,
|
|
builder: (context) => RecordingModal(channel: channel),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<RecordingModal> createState() => _RecordingModalState();
|
|
}
|
|
|
|
class _RecordingModalState extends State<RecordingModal> {
|
|
DateTime _startTime = DateTime.now();
|
|
int _durationMinutes = 60;
|
|
bool _isLoading = false;
|
|
|
|
Future<void> _recordNow() async {
|
|
setState(() {
|
|
_startTime = DateTime.now();
|
|
});
|
|
await _scheduleRecording();
|
|
}
|
|
|
|
Future<void> _scheduleRecording() async {
|
|
setState(() => _isLoading = true);
|
|
|
|
final endTime = _startTime.add(Duration(minutes: _durationMinutes));
|
|
|
|
try {
|
|
// Utilisation d'une URL relative en Web (ou d'une configuration pour autres plateformes)
|
|
final response = await http.post(
|
|
Uri.parse('/api/recordings'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({
|
|
'channel_id': widget.channel.streamId,
|
|
'stream_url': '/api/live/${widget.channel.streamId}.ts',
|
|
'title': widget.channel.name,
|
|
// Forcer UTC pour éviter le décalage +01:00 (France) vs UTC (serveur Docker)
|
|
'start_time': _startTime.toUtc().toIso8601String(),
|
|
'end_time': endTime.toUtc().toIso8601String(),
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Enregistrement programmé !')),
|
|
);
|
|
Navigator.of(context).pop();
|
|
}
|
|
} else {
|
|
throw Exception('Erreur API');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Erreur: $e')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
width: 600,
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.background.withOpacity(0.9),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(color: AppColors.primary.withOpacity(0.3)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.fiber_manual_record, color: AppColors.error),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'Programmer un enregistrement',
|
|
style: GoogleFonts.karla(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Informations Chaine
|
|
Text(
|
|
'Chaîne ciblée :',
|
|
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
widget.channel.name,
|
|
style: GoogleFonts.karla(color: AppColors.onSurface, fontSize: 18),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Date, Heure et Durée
|
|
Row(
|
|
children: [
|
|
// Date
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Date :',
|
|
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TvFocusableCard(
|
|
onTap: () async {
|
|
final date = await showDatePicker(
|
|
context: context,
|
|
initialDate: _startTime,
|
|
firstDate: DateTime.now(),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (date != null) {
|
|
setState(() {
|
|
_startTime = DateTime(
|
|
date.year, date.month, date.day,
|
|
_startTime.hour, _startTime.minute,
|
|
);
|
|
});
|
|
}
|
|
},
|
|
borderRadius: 8,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.onSurface.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.calendar_today, color: AppColors.onSurface, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'${_startTime.day.toString().padLeft(2, '0')}/${_startTime.month.toString().padLeft(2, '0')}/${_startTime.year}',
|
|
style: GoogleFonts.karla(color: AppColors.onSurface),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
// Heure
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Heure :',
|
|
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TvFocusableCard(
|
|
onTap: () async {
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.fromDateTime(_startTime),
|
|
builder: (context, child) {
|
|
return MediaQuery(
|
|
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
|
|
child: child!,
|
|
);
|
|
},
|
|
);
|
|
if (time != null) {
|
|
setState(() {
|
|
final now = DateTime.now();
|
|
_startTime = DateTime(
|
|
_startTime.year, _startTime.month, _startTime.day,
|
|
time.hour, time.minute,
|
|
);
|
|
// Empêcher l'heure de passé si c'est aujourd'hui
|
|
if (_startTime.isBefore(now)) {
|
|
_startTime = _startTime.add(const Duration(days: 1));
|
|
}
|
|
});
|
|
}
|
|
},
|
|
borderRadius: 8,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.onSurface.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.access_time, color: AppColors.onSurface, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'${_startTime.hour.toString().padLeft(2, '0')}:${_startTime.minute.toString().padLeft(2, '0')}',
|
|
style: GoogleFonts.karla(color: AppColors.onSurface),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
// Durée
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Durée (min) :',
|
|
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 8),
|
|
GlassContainer.glass(
|
|
borderRadius: 8,
|
|
padding: EdgeInsets.zero,
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.remove, color: AppColors.onSurface, size: 18),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 46),
|
|
onPressed: () {
|
|
if (_durationMinutes > 15) {
|
|
setState(() => _durationMinutes -= 15);
|
|
}
|
|
},
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
'$_durationMinutes',
|
|
textAlign: TextAlign.center,
|
|
style: GoogleFonts.karla(color: AppColors.onSurface),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add, color: AppColors.onSurface, size: 18),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 46),
|
|
onPressed: () {
|
|
if (_durationMinutes < 300) {
|
|
setState(() => _durationMinutes += 15);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// Actions
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
|
child: Text(
|
|
'Annuler',
|
|
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
TvFocusableCard(
|
|
onTap: _isLoading ? () {} : _recordNow,
|
|
borderRadius: 8,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error.withOpacity(0.8),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
width: 20, height: 20,
|
|
child: CircularProgressIndicator(color: AppColors.onSurface, strokeWidth: 2),
|
|
)
|
|
: Text(
|
|
'Enregistrer de suite',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TvFocusableCard(
|
|
onTap: _isLoading ? () {} : _scheduleRecording,
|
|
borderRadius: 8,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
width: 20, height: 20,
|
|
child: CircularProgressIndicator(color: AppColors.onSurface, strokeWidth: 2),
|
|
)
|
|
: Text(
|
|
'Programmer',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|