mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Fuseaux horaires : - POST /api/recordings rejette en 400 les dates sans fuseau et normalise tout en UTC à l'écriture (l'interprétation des dates naïves dans le TZ du conteneur décalait les enregistrements de 1-2 h) - Helper frontend unique postRecording() : les 2 points de création (modal, guide EPG) envoient la même convention UTC Contrôle d'accès : - stop/delete/logs d'un enregistrement et delete d'un season pass vérifient la propriété (userId ou admin), comme playlists_handler - Suppression des replis 'dev_user_id' et 'admin' (401 sans session) SQLite : - PRAGMA foreign_keys/WAL/busy_timeout (les ON DELETE CASCADE déclarés ne s'appliquaient pas : sessions et playlists orphelines) - Migrations de schéma versionnées (schema_version) + index user_id, start_time, season_passes(user_id) Gestion disque : - Refus explicite de capture sous MIN_FREE_DISK_MB (défaut 500 Mo) - Rotation par quota d'octets (RECORDINGS_QUOTA_GB, opt-in) qui ne touche jamais un enregistrement actif et supprime fichiers + ligne ensemble ; l'ancienne rotation « 50 fichiers » pouvait effacer une capture en cours - DELETE /api/recordings/<id> supprime aussi .mkv/.log/parties (SafePath) Scheduler : - Arrêt gracieux orchestré par server.dart : clôture des enregistrements (fusion des parties, statut) avant killAll des sessions de streaming ; l'ancien handler SIGTERM de FfmpegSessionManager faisait exit(0) direct - Noms de fichiers uniques par fragment d'id (deux enregistrements du même programme s'écrasaient mutuellement avec -y) - Requête filtrée scheduled/recording au lieu de toute la table / 10 s - Statut cancelled pour un scheduled arrêté (completed sans fichier cassait la lecture) Season passes : - Playlist du propriétaire du pass résolue à chaque scan (l'injection figée du 1er utilisateur rendait les passes muets après ajout de playlist, et mélangeait les credentials en multi-utilisateurs) - Correspondance exacte par défaut (match_mode, migration en 'contains' pour l'existant), plafond de créations par scan, réalignement des horaires déplacés dans l'EPG, déduplication tolérante ±2 min - Redaction des erreurs de scan (ClientException contient l'URL amont) API de suivi (polling conservé) : - GET /api/recordings enrichi : progress_pct, file_size_bytes, retry_count, is_active ; barre de progression + taille dans la liste Validé : dart analyze (0 issue) et dart test (48/48) sur bin/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
367 lines
15 KiB
Dart
367 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import '../../../core/api/recording_requests.dart';
|
|
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';
|
|
import '../providers/recordings_refresh.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 {
|
|
// _startTime est une vraie heure locale (pickers) : postRecording la
|
|
// convertit en UTC — seule convention acceptée par le backend.
|
|
final response = await postRecording(
|
|
channelId: widget.channel.streamId,
|
|
title: widget.channel.name,
|
|
start: _startTime,
|
|
end: endTime,
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
notifyRecordingsChanged();
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|