mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 20:59:59 +02:00
Auto NBA : guide officiel beIN, choix manuel, un seul enregistrement par créneau
- Programme officiel beIN Sports France (api/opta, ~8 jours) comme source : chaîne exacte et direct/rediffusion explicites, recoupés avec le coup d'envoi ESPN (un faux « direct » du guide est écarté). Repli sur l'EPG IPTV si le site est injoignable. Seuls les matchs annoncés en direct sur beIN sont affichés. - Plus aucun enregistrement automatique : seul un « Enregistrer » explicite programme un match ; les programmations sans choix sont annulées. - Un seul flux IPTV : un match qui chevauche un match choisi ou un autre enregistrement est refusé (409) et affiché « Créneau pris » ; la liste est relue après chaque choix et les boutons sont figés pendant la requête. - Interrupteurs lisibles (curseur et piste distincts), boutons Enregistrer / Choisi à la place des interrupteurs par match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a15311f98
commit
5f0f6abcc6
6 files changed
+621
-175
No files matched your search
@@ -1875,24 +1875,33 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
|
||||
Future<void> _decide(Map<String, dynamic> game, bool yes) async {
|
||||
final id = _rule?['id'];
|
||||
if (id == null) return;
|
||||
final r = await AuthedHttp.post(
|
||||
Uri.parse('/api/auto-rules/$id/games/${game['game_id']}/decision'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'decision': yes ? 'accepted' : 'refused'}),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (r.statusCode == 200) {
|
||||
final updated = json.decode(r.body);
|
||||
setState(() {
|
||||
_games = [
|
||||
for (final g in _games)
|
||||
g['game_id'] == updated['game_id'] ? updated : g,
|
||||
];
|
||||
});
|
||||
notifyRecordingsChanged();
|
||||
} else {
|
||||
_snack('❌ ${r.body}');
|
||||
if (id == null || _busy) return;
|
||||
// Tous les boutons sont figés pendant la requête : pas de double clic ni
|
||||
// de second choix sur le même créneau avant la réponse.
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final r = await AuthedHttp.post(
|
||||
Uri.parse('/api/auto-rules/$id/games/${game['game_id']}/decision'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
// Retirer son choix remet le match en attente : rien n'est
|
||||
// enregistré sans un oui explicite.
|
||||
body: json.encode({'decision': yes ? 'accepted' : 'auto'}),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (r.statusCode == 200) {
|
||||
notifyRecordingsChanged();
|
||||
} else {
|
||||
String message = r.body;
|
||||
try {
|
||||
message = json.decode(r.body)['error'] ?? r.body;
|
||||
} catch (_) {}
|
||||
_snack('❌ $message');
|
||||
}
|
||||
// Relire toute la liste : le serveur recalcule « Créneau pris » pour
|
||||
// les autres matchs du créneau, qui deviennent inactifs.
|
||||
await _load();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1913,14 +1922,10 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
final selected = <String>{
|
||||
for (final c in (rule['channel_ids'] as List)) c.toString(),
|
||||
};
|
||||
var askFirst = rule['default_decision'] == 'ask';
|
||||
final beforeCtrl =
|
||||
TextEditingController(text: '${rule['pad_before_min'] ?? 5}');
|
||||
final afterCtrl =
|
||||
TextEditingController(text: '${rule['pad_after_min'] ?? 45}');
|
||||
final teamsCtrl = TextEditingController(
|
||||
text: (rule['team_filter'] as List).join(', '),
|
||||
);
|
||||
final keepCtrl = TextEditingController(
|
||||
text: rule['keep_days'] == null ? '' : '${rule['keep_days']}',
|
||||
);
|
||||
@@ -1951,7 +1956,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
),
|
||||
),
|
||||
value: selected.contains(c['stream_id'].toString()),
|
||||
activeThumbColor: AppColors.primaryContainer,
|
||||
activeThumbColor: AppColors.primary,
|
||||
trackColor: _switchTrack,
|
||||
onChanged: (v) => setDialog(() {
|
||||
final id = c['stream_id'].toString();
|
||||
v ? selected.add(id) : selected.remove(id);
|
||||
@@ -1976,29 +1982,6 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'Demander avant chaque match',
|
||||
style: GoogleFonts.fraunces(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
askFirst
|
||||
? "Rien n'est enregistré sans votre oui"
|
||||
: 'Tout est enregistré, vous pouvez refuser',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface54,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
value: askFirst,
|
||||
activeThumbColor: AppColors.primaryContainer,
|
||||
onChanged: (v) => setDialog(() => askFirst = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -2019,11 +2002,6 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_underlineField(
|
||||
teamsCtrl,
|
||||
'Équipes suivies (vide = toutes, ex: Lakers, Celtics)',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_underlineField(
|
||||
keepCtrl,
|
||||
'Garder les matchs (jours, vide = toujours)',
|
||||
@@ -2079,16 +2057,10 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
Navigator.pop(ctx);
|
||||
final keep = int.tryParse(keepCtrl.text.trim());
|
||||
_updateRule({
|
||||
'default_decision': askFirst ? 'ask' : 'auto',
|
||||
'channel_ids': selected.toList(),
|
||||
'pad_before_min':
|
||||
int.tryParse(beforeCtrl.text.trim()) ?? 5,
|
||||
'pad_after_min': int.tryParse(afterCtrl.text.trim()) ?? 45,
|
||||
'team_filter': teamsCtrl.text
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList(),
|
||||
'keep_days': (keep != null && keep > 0) ? keep : null,
|
||||
});
|
||||
},
|
||||
@@ -2127,10 +2099,6 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
|
||||
(String, Color) _status(Map<String, dynamic> g) {
|
||||
if (g['conflict'] == true) return ('Conflit', AppColors.warning);
|
||||
if (g['decision'] == 'refused' &&
|
||||
!['recorded', 'missed'].contains(g['status'])) {
|
||||
return ('Refusé', AppColors.onSurface38);
|
||||
}
|
||||
return switch (g['status']) {
|
||||
'scheduled' => ('Programmé', AppColors.primaryContainer),
|
||||
'confirmed' => ('Confirmé', AppColors.tertiary),
|
||||
@@ -2150,6 +2118,53 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
String _hhmm(DateTime d) =>
|
||||
'${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
/// Piste lisible quel que soit l'état (même rendu que l'interrupteur de
|
||||
/// thème des Réglages) : sans elle, curseur et piste orange se confondent.
|
||||
static final _switchTrack = WidgetStateProperty.resolveWith<Color>(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? AppColors.primary.withOpacity(0.5)
|
||||
: AppColors.surfaceContainerHigh,
|
||||
);
|
||||
|
||||
/// Choix explicite par match : rien n'est enregistré sans ce bouton.
|
||||
Widget _choiceButton(Map<String, dynamic> g, bool locked) {
|
||||
const compact = VisualDensity.compact;
|
||||
if (g['wanted'] == true) {
|
||||
return Tooltip(
|
||||
message: locked ? 'Choisi' : 'Cliquer pour ne plus enregistrer',
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.secondaryContainer,
|
||||
foregroundColor: AppColors.onSecondaryContainer,
|
||||
visualDensity: compact,
|
||||
),
|
||||
icon: const Icon(Icons.check, size: 16),
|
||||
label: const Text('Choisi'),
|
||||
onPressed: (locked || _busy) ? null : () => _decide(g, false),
|
||||
),
|
||||
);
|
||||
}
|
||||
final taken = g['slot_taken_by'] as String?;
|
||||
if (taken != null) {
|
||||
return Tooltip(
|
||||
message: 'Créneau déjà pris par « $taken » : un seul enregistrement '
|
||||
'à la fois',
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(visualDensity: compact),
|
||||
icon: const Icon(Icons.block, size: 16),
|
||||
label: const Text('Créneau pris'),
|
||||
onPressed: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(visualDensity: compact),
|
||||
icon: const Icon(Icons.fiber_manual_record, size: 16),
|
||||
label: const Text('Enregistrer'),
|
||||
onPressed: (locked || _busy) ? null : () => _decide(g, true),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pill(String label, Color color) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -2170,10 +2185,12 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
Widget _gameCard(Map<String, dynamic> g) {
|
||||
final tipoff = DateTime.parse(g['tipoff_utc']).toLocal();
|
||||
final (label, color) = _status(g);
|
||||
final channel = g['channel_name'] as String?;
|
||||
// Pas annoncé en direct sur beIN : rien à enregistrer.
|
||||
final locked =
|
||||
['recorded', 'missed', 'not_on_bein'].contains(g['status']) ||
|
||||
tipoff.isBefore(DateTime.now());
|
||||
final channel = g['channel_name'] as String?;
|
||||
tipoff.isBefore(DateTime.now()) ||
|
||||
(channel == null && g['wanted'] != true);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -2212,7 +2229,7 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${_hhmm(tipoff)} · ${channel ?? 'chaîne confirmée ~48 h avant'}',
|
||||
'${_hhmm(tipoff)} · ${channel ?? 'pas annoncé sur beIN'}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface54,
|
||||
fontSize: 12,
|
||||
@@ -2233,16 +2250,7 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
children: [
|
||||
_pill(label, color),
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: g['wanted'] == true
|
||||
? 'Sera enregistré — désactiver pour refuser'
|
||||
: 'Ne sera pas enregistré — activer pour accepter',
|
||||
child: Switch(
|
||||
value: g['wanted'] == true,
|
||||
activeThumbColor: AppColors.primaryContainer,
|
||||
onChanged: locked ? null : (v) => _decide(g, v),
|
||||
),
|
||||
),
|
||||
_choiceButton(g, locked),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -2308,7 +2316,7 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
return _emptyState(
|
||||
Icons.sports_basketball,
|
||||
'Auto NBA désactivé',
|
||||
'Enregistre chaque match NBA diffusé en direct sur beIN Sports FR, '
|
||||
'Choisissez les matchs NBA à enregistrer en direct sur beIN Sports FR, '
|
||||
'sans les rediffusions.',
|
||||
action: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -2322,13 +2330,20 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
);
|
||||
}
|
||||
final visible = _games
|
||||
.where((g) => _showOffBein || g['status'] != 'not_on_bein')
|
||||
// Par défaut, seuls les matchs annoncés en direct sur une beIN FR
|
||||
// (ou déjà choisis) : le reste du calendrier NBA n'est pas diffusé.
|
||||
.where(
|
||||
(g) =>
|
||||
_showOffBein ||
|
||||
g['channel_name'] != null ||
|
||||
g['wanted'] == true,
|
||||
)
|
||||
.toList();
|
||||
if (visible.isEmpty) {
|
||||
return _emptyState(
|
||||
Icons.event_busy,
|
||||
'Aucun match à venir',
|
||||
'Le calendrier NBA est rechargé toutes les 12 h',
|
||||
'Aucun match NBA annoncé sur beIN Sports',
|
||||
'Le programme beIN couvre environ 8 jours et est relu toutes les 30 min',
|
||||
);
|
||||
}
|
||||
// Liste aplatie : intertitre de nuit puis ses matchs.
|
||||
@@ -2388,7 +2403,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
: 'Auto NBA en pause',
|
||||
child: Switch(
|
||||
value: rule['enabled'] == true,
|
||||
activeThumbColor: AppColors.primaryContainer,
|
||||
activeThumbColor: AppColors.primary,
|
||||
trackColor: _switchTrack,
|
||||
onChanged: (v) => _updateRule({'enabled': v}),
|
||||
),
|
||||
),
|
||||
@@ -2439,10 +2455,12 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Seule la diffusion qui démarre avec le match est '
|
||||
'enregistrée : les rediffusions sont ignorées. La chaîne '
|
||||
'est confirmée par le guide TV environ 48 h avant ; vous '
|
||||
'pouvez refuser un match à tout moment avant son début.',
|
||||
'Matchs NBA diffusés en direct sur beIN Sports FR, '
|
||||
"d'après le programme officiel beIN (~8 jours). Rien n'est "
|
||||
'enregistré sans votre choix : cliquez sur « Enregistrer ». '
|
||||
'Les rediffusions sont ignorées. Un seul '
|
||||
"enregistrement à la fois : le flux IPTV ne permet qu'une "
|
||||
'lecture.',
|
||||
style: GoogleFonts.fraunces(
|
||||
color: AppColors.secondaryFixed,
|
||||
fontSize: 12,
|
||||
|
||||
Reference in new issue
Block a user