mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 15:08:07 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2151f2820c | ||
|
|
fa8eed38f2 | ||
|
|
b95d72fe80 |
No files matched your search
@@ -3,6 +3,12 @@
|
||||
## Non publié
|
||||
|
||||
### ▶️ Lecture vidéo
|
||||
- **Fix du démarrage des enregistrements** : la lecture partait au milieu du programme et se coupait aussitôt, obligeant à relancer une deuxième fois. Une playlist encore en cours de transcodage n'a pas d'`EXT-X-ENDLIST` : hls.js la traite comme du direct et démarrait donc au « bord du direct », collé au front d'encodage, sans aucune avance de segments. `startPosition: 0` hors direct, plus la neutralisation du rattrapage de latence (qui accélérait la lecture puis forçait un saut en avant pour rejoindre un direct inexistant)
|
||||
- **Plus de ré-encodage inutile à la lecture d'un enregistrement** : la capture étant faite en copie, le fichier contient déjà du H.264 dans la quasi-totalité des cas. Il est désormais servi tel quel (`-c:v copy`, seul l'audio est converti en AAC) au lieu d'être ré-encodé à peine plus vite que le temps réel — la segmentation va maintenant à la vitesse du disque, l'enregistrement devient navigable en quelques secondes. Les codecs illisibles par le navigateur (HEVC, MPEG-2…) restent ré-encodés
|
||||
- **Démarrage plus robuste** : le serveur attend trois segments d'avance avant de servir la playlist (au lieu d'un seul, qui laissait le lecteur courir après l'encodeur), et sert ce qu'il a plutôt qu'une erreur si le délai expire. Côté navigateur, le délai d'attente de playlist passe de 10 s à 45 s hors direct — une session FFmpeg qui démarre n'est plus prise pour un manifeste mort — et le lecteur de bureau réessaie seul jusqu'à 3 fois, comme le faisait déjà le lecteur mobile
|
||||
- **Lecteur avancé pour les enregistrements** : barre de progression réellement utilisable (avance, retour, saut n'importe où), pause/reprise, et reprise automatique là où on s'était arrêté. La playlist d'un enregistrement était servie en `EXT-X-PLAYLIST-TYPE:VOD` : hls.js la considérait comme définitive et ne voyait donc que les quelques secondes déjà transcodées à l'ouverture. Elle passe en `EVENT` (la zone navigable grandit avec l'encodage) et un saut hors de cette zone relance FFmpeg à la position visée (`?start=`), au lieu d'attendre que l'encodeur y arrive
|
||||
- **Reprise des enregistrements** : la liste affiche « Reprendre à … » avec la progression, et propose au clic de reprendre ou de repartir du début ; la durée affichée est mesurée par ffprobe (`duration_seconds`) et non plus déduite des horaires programmés — un enregistrement arrêté en avance donnait une barre fausse
|
||||
- **Contrôles enrichis (VOD, séries, enregistrements)** : sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà transcodée visible sur la barre, et raccourcis clavier `K`/espace, `J`/`L`, Maj+←/→, `0`-`9`, `F`, `M`, `,`/`.`
|
||||
- **Fix du son manquant sur certaines chaînes** : nouvelle route `/api/live/<id>/turbo.ts` pour le zapping — vidéo copiée telle quelle, audio systématiquement réencodé en AAC côté serveur. Les chaînes en AC-3/E-AC-3/MP2 (que mpegts.js ne décode pas) ont maintenant du son, sans coût de transcodage vidéo
|
||||
- **Players mis à jour** : hls.js 1.6.7 → 1.7.1, mpegts.js 1.7.3 → 1.8.2 (vendorisés)
|
||||
- **Démarrage plus rapide** : sonde FFmpeg bornée (`-fflags nobuffer`, probesize réduit) sur le live et le turbo ; probesize VOD 10 Mo → 5 Mo ; preset live `high` et lecture d'enregistrement en `veryfast` (medium ne tenait pas le temps réel) ; détection de playlist toutes les 100 ms au lieu de 500 ms ; suppression du cache-buster qui re-téléchargeait player.html à chaque zap (les .html passent en no-cache serveur)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/// Adressage des sessions de lecture d'un enregistrement.
|
||||
///
|
||||
/// Un enregistrement est transcodé à la volée par FFmpeg, séquentiellement.
|
||||
/// Reprendre à 45 min sans le lui dire imposerait d'attendre que l'encodeur
|
||||
/// y arrive : le lecteur demande donc une playlist qui *commence* à cette
|
||||
/// position (`?start=2700`), servie par une session FFmpeg dédiée.
|
||||
///
|
||||
/// Ces fonctions sont isolées du handler HTTP pour rester testables.
|
||||
library;
|
||||
|
||||
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
|
||||
/// Toute valeur absente, négative ou non numérique vaut « depuis le début ».
|
||||
int parseRecordingStart(String? raw) {
|
||||
final value = double.tryParse(raw ?? '');
|
||||
if (value == null || !value.isFinite || value <= 0) return 0;
|
||||
return value.floor();
|
||||
}
|
||||
|
||||
/// Segment de chemin qui rattache un segment `.ts` à SA session.
|
||||
///
|
||||
/// Les segments sont référencés en relatif dans la playlist et la
|
||||
/// query string n'y survit pas : sans ce répertoire virtuel, la playlist
|
||||
/// démarrée à 0 s et celle démarrée à 45 min demanderaient toutes deux
|
||||
/// `segment_000.ts` à la même URL.
|
||||
String recordingOffsetKey(int start) => 't$start';
|
||||
|
||||
/// Inverse de [recordingOffsetKey] ; `null` si la clé est mal formée.
|
||||
int? parseRecordingOffsetKey(String key) {
|
||||
if (!key.startsWith('t') || key.length < 2) return null;
|
||||
final value = int.tryParse(key.substring(1));
|
||||
return (value == null || value < 0) ? null : value;
|
||||
}
|
||||
|
||||
/// Préfixe les segments de [playlist] par [offsetKey].
|
||||
///
|
||||
/// Seules les lignes de segment sont touchées : les balises `#EXT-X-…` et
|
||||
/// les lignes vides passent telles quelles.
|
||||
String rewriteRecordingPlaylist(String playlist, String offsetKey) {
|
||||
return playlist.replaceAllMapped(
|
||||
RegExp(r'^(segment_\d+\.ts)\s*$', multiLine: true),
|
||||
(m) => '$offsetKey/${m[1]}',
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import '../database/database.dart';
|
||||
import '../models/recording.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/recording_scheduler.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import '../utils/safe_path.dart';
|
||||
|
||||
class RecordingsApi {
|
||||
@@ -74,18 +75,47 @@ class RecordingsApi {
|
||||
/// GET /api/recordings — Liste les enregistrements de l'utilisateur
|
||||
/// (tous les enregistrements pour un admin), enrichis des informations de
|
||||
/// suivi : taille du fichier, progression, relances FFmpeg.
|
||||
Response handleGetAll(Request request) {
|
||||
Future<Response> handleGetAll(Request request) async {
|
||||
final user = request.context['user'] as User?;
|
||||
final recordings = (user != null && !user.isAdmin)
|
||||
? _db.getUserRecordings(user.id)
|
||||
: _db.getAllRecordings();
|
||||
final now = DateTime.now().toUtc();
|
||||
final enriched = <Map<String, dynamic>>[];
|
||||
for (final r in recordings) {
|
||||
final map = _enrich(r, now);
|
||||
// Durée réelle du média : `end_time - start_time` n'est que la durée
|
||||
// *programmée*. Un enregistrement arrêté en avance ou tronqué par une
|
||||
// coupure amont donnait une barre de progression fausse et un « reprendre »
|
||||
// hors des clous.
|
||||
final duration = await _probeDuration(r);
|
||||
if (duration != null) map['duration_seconds'] = duration;
|
||||
enriched.add(map);
|
||||
}
|
||||
return Response.ok(
|
||||
json.encode(recordings.map((r) => _enrich(r, now)).toList()),
|
||||
json.encode(enriched),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable.
|
||||
///
|
||||
/// Un enregistrement en cours grossit en permanence : le cache de
|
||||
/// [MediaProbe] serait invalidé à chaque appel et ffprobe tournerait en
|
||||
/// boucle. La durée programmée suffit tant que la capture n'est pas
|
||||
/// terminée.
|
||||
Future<double?> _probeDuration(Recording r) async {
|
||||
final path = r.filePath;
|
||||
if (path == null || r.status != 'completed') return null;
|
||||
|
||||
// Anti path-traversal : la mesure doit rester dans le dossier des
|
||||
// enregistrements, comme la lecture des logs.
|
||||
final safePath = SafePath.resolveWithin(recordingsDirPath, path);
|
||||
if (safePath == null) return null;
|
||||
|
||||
return MediaProbe.duration(safePath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _enrich(Recording r, DateTime now) {
|
||||
final map = r.toMap();
|
||||
|
||||
|
||||
+121
-22
@@ -7,6 +7,8 @@ import '../database/database.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/ffmpeg_session_manager.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
import '../utils/media_probe.dart';
|
||||
import 'recording_playlist.dart';
|
||||
|
||||
/// Directory for temporary HLS segments
|
||||
final Directory _hlsTempDir =
|
||||
@@ -632,6 +634,10 @@ Handler createRecordingStreamHandler(
|
||||
|
||||
router.get('/<streamId>/playlist.m3u8',
|
||||
(Request request, String streamId) async {
|
||||
if (!_isValidStreamId(streamId)) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
|
||||
final recording = db.getRecordingById(streamId);
|
||||
if (recording == null) {
|
||||
return Response.notFound('Recording not found');
|
||||
@@ -660,18 +666,36 @@ Handler createRecordingStreamHandler(
|
||||
body: 'Physical recording file not found');
|
||||
}
|
||||
|
||||
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
|
||||
final sessionId = 'rec_$streamId';
|
||||
final start = parseRecordingStart(request.url.queryParameters['start']);
|
||||
final offsetKey = recordingOffsetKey(start);
|
||||
final sessionPrefix = 'rec_${streamId}_t';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
|
||||
final session = await sessionManager.getOrStart(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
ffmpegPath: _getFFmpegPath(),
|
||||
argsBuilder: (dir) => [
|
||||
// La capture est faite en `-c copy` : le fichier contient le codec
|
||||
// d'origine de la chaîne, presque toujours du H.264 — directement
|
||||
// lisible en HLS. Le ré-encoder tenait à peine le temps réel en 1080p,
|
||||
// si bien que la lecture démarrait collée au front d'encodage et se
|
||||
// coupait à la moindre hésitation. En copie, la segmentation va à la
|
||||
// vitesse du disque : l'enregistrement devient navigable en quelques
|
||||
// secondes. Seuls les codecs que le navigateur ne sait pas lire
|
||||
// (HEVC, MPEG-2…) justifient encore un ré-encodage.
|
||||
final videoCodec = await MediaProbe.videoCodec(targetUrl);
|
||||
final canCopyVideo = videoCodec == 'h264';
|
||||
|
||||
List<String> buildArgs({required bool copyVideo}) {
|
||||
final useNvidiaGpu =
|
||||
!copyVideo && (isGpuEnabled?.call() ?? _isNvidiaGpuEnabled());
|
||||
return [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
|
||||
// -ss AVANT -i : recherche rapide par index, sans décoder ce qui
|
||||
// précède. La sortie repart de 0, le lecteur ré-ajoute l'offset.
|
||||
// En copie, la position atteinte est l'image-clé qui précède.
|
||||
if (start > 0) ...['-ss', '$start'],
|
||||
'-i', targetUrl,
|
||||
if (useNvidiaGpu) ...[
|
||||
if (copyVideo) ...[
|
||||
'-c:v', 'copy',
|
||||
] else if (useNvidiaGpu) ...[
|
||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
|
||||
'-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k',
|
||||
'-bufsize', '6000k',
|
||||
@@ -686,38 +710,113 @@ Handler createRecordingStreamHandler(
|
||||
'-g', '48', '-threads', '0',
|
||||
],
|
||||
..._audioArgs(withFilters: true),
|
||||
// `event` et non `vod` : en VOD, hls.js considère la playlist comme
|
||||
// définitive et ne la relit jamais. Il ne voyait donc que les
|
||||
// quelques secondes déjà transcodées à l'ouverture — d'où une durée
|
||||
// absurde et une barre de progression inutilisable. En `event`, la
|
||||
// playlist est rechargée et la zone navigable grandit avec l'encodage.
|
||||
'-f', 'hls', '-hls_time', '4', '-hls_list_size', '0',
|
||||
'-hls_playlist_type', 'vod', '-hls_allow_cache', '1',
|
||||
'-hls_playlist_type', 'event', '-hls_allow_cache', '1',
|
||||
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
|
||||
'playlist.m3u8',
|
||||
],
|
||||
);
|
||||
|
||||
final result = await sessionManager.waitForPlaylist(session);
|
||||
if (!result.ready) {
|
||||
sessionManager.killSession(sessionId);
|
||||
return Response(502,
|
||||
body: 'Recording transcoder failed: ${result.error}');
|
||||
];
|
||||
}
|
||||
|
||||
/// Démarre (ou récupère) la session et attend qu'elle ait de l'avance.
|
||||
///
|
||||
/// Trois segments avant de rendre la main : c'est la marge qui manquait
|
||||
/// au démarrage, la playlist étant servie dès le premier segment. En
|
||||
/// copie vidéo elle est produite en une fraction de seconde, elle ne
|
||||
/// coûte donc que sur un ré-encodage.
|
||||
Future<({FfmpegSession session, bool ready, String? error})> run(
|
||||
bool copyVideo,
|
||||
) async {
|
||||
if (!sessionManager.contains(sessionId)) {
|
||||
print(
|
||||
'[Recording] $sessionId : '
|
||||
'${copyVideo ? 'copie vidéo' : 'ré-encodage'} '
|
||||
'(codec source : ${videoCodec ?? 'inconnu'})',
|
||||
);
|
||||
}
|
||||
final session = await sessionManager.getOrStart(
|
||||
id: sessionId,
|
||||
isLive: false,
|
||||
ffmpegPath: _getFFmpegPath(),
|
||||
argsBuilder: (dir) => buildArgs(copyVideo: copyVideo),
|
||||
);
|
||||
final outcome =
|
||||
await sessionManager.waitForPlaylist(session, minSegments: 3);
|
||||
return (
|
||||
session: session,
|
||||
ready: outcome.ready,
|
||||
error: outcome.error,
|
||||
);
|
||||
}
|
||||
|
||||
var attempt = await run(canCopyVideo);
|
||||
|
||||
// La copie a échoué : certains conteneurs ou horodatages ne s'y prêtent
|
||||
// pas. Plutôt qu'un écran d'erreur, on retombe sur le ré-encodage, qui
|
||||
// était le seul chemin jusqu'ici.
|
||||
if (!attempt.ready && canCopyVideo) {
|
||||
print(
|
||||
'[Recording] $sessionId : copie vidéo refusée (${attempt.error}) '
|
||||
'— repli sur le ré-encodage',
|
||||
);
|
||||
sessionManager.killSession(sessionId);
|
||||
attempt = await run(false);
|
||||
}
|
||||
|
||||
if (!attempt.ready) {
|
||||
sessionManager.killSession(sessionId);
|
||||
return Response(502,
|
||||
body: 'Recording transcoder failed: ${attempt.error}');
|
||||
}
|
||||
|
||||
final session = attempt.session;
|
||||
|
||||
session.touch();
|
||||
// Ménage des offsets abandonnés (aller-retours dans la barre de
|
||||
// progression), une fois la nouvelle session confirmée démarrée.
|
||||
sessionManager.killIdleSiblings(sessionPrefix, keep: sessionId);
|
||||
|
||||
// Les segments sont préfixés par l'offset pour rester rattachés à LEUR
|
||||
// session : `segment_000.ts` d'une playlist à 0 s et d'une playlist à
|
||||
// 45 min sont deux fichiers différents.
|
||||
final playlist = await File('${session.dir.path}/playlist.m3u8')
|
||||
.readAsString();
|
||||
|
||||
return Response.ok(
|
||||
File('${session.dir.path}/playlist.m3u8').openRead(),
|
||||
rewriteRecordingPlaylist(playlist, offsetKey),
|
||||
headers: _hlsHeaders(),
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/<streamId>/<segment>',
|
||||
(Request request, String streamId, String segment) async {
|
||||
if (segment.contains('..')) {
|
||||
Response serveSegment(String streamId, String offsetKey, String segment) {
|
||||
if (!_isValidStreamId(streamId) ||
|
||||
parseRecordingOffsetKey(offsetKey) == null ||
|
||||
segment.contains('..') ||
|
||||
segment.contains('/')) {
|
||||
return Response.badRequest(body: 'Invalid request');
|
||||
}
|
||||
final sessionId = 'rec_$streamId';
|
||||
final sessionId = 'rec_${streamId}_$offsetKey';
|
||||
sessionManager.touch(sessionId);
|
||||
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
|
||||
if (!file.existsSync()) return Response.notFound('Segment not found');
|
||||
return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
|
||||
}
|
||||
|
||||
router.get('/<streamId>/<offsetKey>/<segment>',
|
||||
(Request request, String streamId, String offsetKey, String segment) {
|
||||
return serveSegment(streamId, offsetKey, segment);
|
||||
});
|
||||
|
||||
// Compat : playlist servie avant la mise en place des offsets, encore en
|
||||
// cache dans un onglet ouvert.
|
||||
router.get('/<streamId>/<segment>',
|
||||
(Request request, String streamId, String segment) {
|
||||
return serveSegment(streamId, recordingOffsetKey(0), segment);
|
||||
});
|
||||
|
||||
return router.call;
|
||||
|
||||
@@ -143,22 +143,40 @@ class FfmpegSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the session's playlist references at least one segment.
|
||||
/// Fails fast when the process dies before producing output, returning
|
||||
/// the recent stderr for diagnostics.
|
||||
/// Waits until the session's playlist references at least [minSegments]
|
||||
/// segments. Fails fast when the process dies before producing output,
|
||||
/// returning the recent stderr for diagnostics.
|
||||
///
|
||||
/// [minSegments] > 1 donne au lecteur une avance de démarrage : servir la
|
||||
/// playlist dès le premier segment le fait partir avec 4 s de marge sur un
|
||||
/// encodeur qui n'a pas fini — la moindre hésitation coupe la lecture. La
|
||||
/// playlist déjà close (`#EXT-X-ENDLIST`) est renvoyée telle quelle, quel
|
||||
/// que soit son nombre de segments : rien de plus n'arrivera.
|
||||
Future<({bool ready, String? error})> waitForPlaylist(
|
||||
FfmpegSession session, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
int minSegments = 1,
|
||||
}) async {
|
||||
final playlistFile = File('${session.dir.path}/playlist.m3u8');
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
if (playlistFile.existsSync()) {
|
||||
final content = playlistFile.readAsStringSync();
|
||||
final segments = '.ts'.allMatches(content).length;
|
||||
if (segments >= minSegments ||
|
||||
(segments > 0 && content.contains('#EXT-X-ENDLIST'))) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
}
|
||||
if (session.exited) {
|
||||
// Un encodeur qui s'est terminé proprement en ayant produit moins de
|
||||
// segments que demandé a simplement fini : c'est un succès.
|
||||
if (session.exitCode == 0 &&
|
||||
playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (
|
||||
ready: false,
|
||||
error: 'FFmpeg exited (${session.exitCode}): '
|
||||
@@ -169,6 +187,11 @@ class FfmpegSessionManager {
|
||||
// latence pure entre la disponibilité de la playlist et sa réponse.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
// Le délai est écoulé : mieux vaut servir ce qui existe que rien.
|
||||
if (playlistFile.existsSync() &&
|
||||
playlistFile.readAsStringSync().contains('.ts')) {
|
||||
return (ready: true, error: null);
|
||||
}
|
||||
return (ready: false, error: 'Timeout waiting for transcoder');
|
||||
}
|
||||
|
||||
@@ -185,6 +208,28 @@ class FfmpegSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tue les sessions frères d'un même contenu (`rec_<id>_t0`, `rec_<id>_t600`…)
|
||||
/// que plus personne ne consomme depuis [idleFor].
|
||||
///
|
||||
/// Chaque saut hors de la zone déjà transcodée démarre un FFmpeg à un
|
||||
/// nouvel offset ; sans ce ménage, une poignée d'allers-retours dans la
|
||||
/// barre de progression laisserait autant de processus vivants jusqu'au
|
||||
/// TTL de 15 minutes. Le délai d'inactivité protège un second spectateur
|
||||
/// du même enregistrement : sa session est « touchée » à chaque segment.
|
||||
void killIdleSiblings(
|
||||
String prefix, {
|
||||
required String keep,
|
||||
Duration idleFor = const Duration(seconds: 30),
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
for (final session in _sessions.values.toList()) {
|
||||
if (session.id == keep || !session.id.startsWith(prefix)) continue;
|
||||
if (now.difference(session.lastAccess) < idleFor) continue;
|
||||
print('[FFmpegManager] Dropping stale sibling ${session.id}');
|
||||
killSession(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
void killAll() {
|
||||
print('[FFmpegManager] Killing ${_sessions.length} session(s)');
|
||||
for (final id in _sessions.keys.toList()) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:test/test.dart';
|
||||
import '../api/recording_playlist.dart';
|
||||
|
||||
void main() {
|
||||
group('parseRecordingStart', () {
|
||||
test('reads a positive position in seconds', () {
|
||||
expect(parseRecordingStart('2700'), 2700);
|
||||
});
|
||||
|
||||
test('truncates fractions of a second', () {
|
||||
expect(parseRecordingStart('2700.9'), 2700);
|
||||
});
|
||||
|
||||
test('falls back to the beginning when absent or unusable', () {
|
||||
expect(parseRecordingStart(null), 0);
|
||||
expect(parseRecordingStart(''), 0);
|
||||
expect(parseRecordingStart('abc'), 0);
|
||||
expect(parseRecordingStart('-30'), 0);
|
||||
expect(parseRecordingStart('NaN'), 0);
|
||||
expect(parseRecordingStart('Infinity'), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('recordingOffsetKey', () {
|
||||
test('round-trips through its parser', () {
|
||||
for (final start in [0, 1, 2700, 86400]) {
|
||||
expect(parseRecordingOffsetKey(recordingOffsetKey(start)), start);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects keys that are not an offset', () {
|
||||
expect(parseRecordingOffsetKey('t'), isNull);
|
||||
expect(parseRecordingOffsetKey('segment_000.ts'), isNull);
|
||||
expect(parseRecordingOffsetKey('..'), isNull);
|
||||
expect(parseRecordingOffsetKey('t-30'), isNull);
|
||||
expect(parseRecordingOffsetKey('tabc'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('rewriteRecordingPlaylist', () {
|
||||
const playlist = '#EXTM3U\n'
|
||||
'#EXT-X-VERSION:3\n'
|
||||
'#EXT-X-TARGETDURATION:4\n'
|
||||
'#EXT-X-PLAYLIST-TYPE:EVENT\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_000.ts\n'
|
||||
'#EXTINF:4.000000,\n'
|
||||
'segment_001.ts\n';
|
||||
|
||||
test('binds every segment to its own session directory', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't2700');
|
||||
expect(rewritten, contains('t2700/segment_000.ts'));
|
||||
expect(rewritten, contains('t2700/segment_001.ts'));
|
||||
expect(rewritten, isNot(contains('\nsegment_')));
|
||||
});
|
||||
|
||||
test('leaves tags untouched', () {
|
||||
final rewritten = rewriteRecordingPlaylist(playlist, 't0');
|
||||
expect(rewritten, startsWith('#EXTM3U\n#EXT-X-VERSION:3\n'));
|
||||
expect(rewritten, contains('#EXT-X-PLAYLIST-TYPE:EVENT'));
|
||||
expect('#EXTINF:4.000000,'.allMatches(rewritten).length, 2);
|
||||
});
|
||||
|
||||
test('is idempotent on an already rewritten playlist', () {
|
||||
final once = rewriteRecordingPlaylist(playlist, 't60');
|
||||
expect(rewriteRecordingPlaylist(once, 't60'), once);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Inspection des fichiers média locaux (enregistrements) via ffprobe.
|
||||
///
|
||||
/// Les résultats sont mémorisés tant que le fichier ne change pas : une
|
||||
/// liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture,
|
||||
/// et la playlist de lecture est rechargée en continu — sans cache, ffprobe
|
||||
/// tournerait en boucle.
|
||||
class MediaProbe {
|
||||
MediaProbe._();
|
||||
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
|
||||
/// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin`
|
||||
/// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger.
|
||||
static String ffprobePath() {
|
||||
final fromEnv = Platform.environment['FFPROBE_PATH'];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) {
|
||||
return '/usr/local/bin/ffprobe';
|
||||
}
|
||||
return 'ffprobe';
|
||||
}
|
||||
|
||||
/// Durée du média en secondes, ou `null` si elle n'est pas mesurable
|
||||
/// (ffprobe absent, fichier introuvable, conteneur sans durée).
|
||||
static Future<double?> duration(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'duration',
|
||||
args: const [
|
||||
'-show_entries', 'format=duration',
|
||||
],
|
||||
);
|
||||
final seconds = double.tryParse(value ?? '');
|
||||
if (seconds == null || !seconds.isFinite || seconds <= 0) return null;
|
||||
return seconds;
|
||||
}
|
||||
|
||||
/// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…),
|
||||
/// ou `null` si indéterminable.
|
||||
static Future<String?> videoCodec(String path) async {
|
||||
final value = await _probe(
|
||||
path,
|
||||
field: 'videoCodec',
|
||||
args: const [
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
],
|
||||
);
|
||||
return (value == null || value.isEmpty) ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
/// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat
|
||||
/// tant que taille et date de modification sont inchangées.
|
||||
static Future<String?> _probe(
|
||||
String path, {
|
||||
required String field,
|
||||
required List<String> args,
|
||||
}) async {
|
||||
FileStat stat;
|
||||
try {
|
||||
stat = File(path).statSync();
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = '$field@$path';
|
||||
final cached = _cache[key];
|
||||
if (cached != null && cached.matches(stat)) return cached.value;
|
||||
|
||||
try {
|
||||
final result = await Process.run(ffprobePath(), [
|
||||
'-v', 'error',
|
||||
...args,
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
path,
|
||||
]);
|
||||
// ffprobe imprime une ligne par flux : seule la première nous intéresse.
|
||||
final output = (result.stdout as String).trim().split('\n').first.trim();
|
||||
if (output.isEmpty || output == 'N/A') return null;
|
||||
_cache[key] = _CacheEntry(stat, output);
|
||||
return output;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final int size;
|
||||
final int mtimeMs;
|
||||
final String value;
|
||||
|
||||
_CacheEntry(FileStat stat, this.value)
|
||||
: size = stat.size,
|
||||
mtimeMs = stat.modified.millisecondsSinceEpoch;
|
||||
|
||||
bool matches(FileStat stat) =>
|
||||
stat.size == size &&
|
||||
stat.modified.millisecondsSinceEpoch == mtimeMs;
|
||||
}
|
||||
@@ -61,6 +61,29 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
bool _isMuted = false;
|
||||
bool _ignoreStatusUpdates = false;
|
||||
|
||||
/// Fin de la plage réellement navigable, en temps absolu.
|
||||
///
|
||||
/// Un enregistrement est transcodé au fil de l'eau par FFmpeg : ce qui est
|
||||
/// au-delà de ce point n'existe pas encore côté serveur. Sauter dedans
|
||||
/// suppose de relancer l'encodeur à la position visée.
|
||||
double _seekableEnd = 0;
|
||||
|
||||
/// Décalage du média courant par rapport au début du contenu : un
|
||||
/// enregistrement rouvert à 45 min est transcodé *depuis* 45 min.
|
||||
double _mediaOffset = 0;
|
||||
|
||||
/// Vitesse de lecture (hors direct).
|
||||
double _rate = 1.0;
|
||||
|
||||
/// Un rechargement du flux à une nouvelle position est en cours : les
|
||||
/// positions remontées par l'ancienne iframe ne doivent plus rien écraser.
|
||||
bool _seekReloading = false;
|
||||
|
||||
/// Incrémenté à chaque (re)création de l'iframe : deux vues de plateforme
|
||||
/// ne peuvent pas partager le même identifiant, et il faut un identifiant
|
||||
/// neuf pour que Flutter reconstruise réellement l'iframe.
|
||||
int _viewGeneration = 0;
|
||||
|
||||
/// Reflète l'état plein écran du document. Le navigateur peut en sortir
|
||||
/// sans passer par notre bouton (touche Échap), d'où l'écoute de
|
||||
/// `fullscreenchange` plutôt qu'un simple booléen basculé au clic.
|
||||
@@ -113,6 +136,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
/// tous les navigateurs visés.
|
||||
bool get _documentIsFullscreen => html.document.fullscreenElement != null;
|
||||
|
||||
/// Clé de reprise. Les enregistrements sont préfixés : leur identifiant est
|
||||
/// un UUID stocké dans le même espace de noms que les identifiants VOD.
|
||||
String get _positionKey => widget.streamType == StreamType.recording
|
||||
? 'rec_${widget.streamId}'
|
||||
: widget.streamId;
|
||||
|
||||
/// La position peut-elle avoir un sens (barre de progression, reprise) ?
|
||||
bool get _isSeekable => widget.streamType != StreamType.live;
|
||||
|
||||
void _syncFullscreenState() {
|
||||
final value = _documentIsFullscreen;
|
||||
if (mounted && value != _isFullscreen) {
|
||||
@@ -139,9 +171,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
}
|
||||
|
||||
// Stable view ID for the instance to prevent iframe reload on rebuild
|
||||
// (orientation change). Includes quality so a quality switch forces a
|
||||
// fresh iframe.
|
||||
_viewId = 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix';
|
||||
// (orientation change). La génération change à chaque appel : sans
|
||||
// elle, relancer le flux à une autre position réutiliserait la même
|
||||
// vue de plateforme et l'iframe ne serait jamais reconstruite.
|
||||
_viewId =
|
||||
'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix-'
|
||||
'${_viewGeneration++}';
|
||||
|
||||
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||
String streamUrl = '';
|
||||
@@ -166,7 +201,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
quality: _quality.value,
|
||||
);
|
||||
} else if (widget.streamType == StreamType.recording) {
|
||||
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8';
|
||||
// FFmpeg transcode l'enregistrement séquentiellement : reprendre à
|
||||
// 45 min sans le lui dire imposerait d'attendre qu'il y arrive.
|
||||
// `?start=` lui fait démarrer l'encodage à la position demandée.
|
||||
_mediaOffset = (startTimeOverride ?? 0) > 0
|
||||
? startTimeOverride!.floorToDouble()
|
||||
: 0;
|
||||
final base = '${service.backendBaseUrl}/api/recordings/stream/'
|
||||
'$currentStreamId/playlist.m3u8';
|
||||
streamUrl =
|
||||
_mediaOffset > 0 ? '$base?start=${_mediaOffset.toInt()}' : base;
|
||||
}
|
||||
|
||||
// Store URL for Lite Player
|
||||
@@ -204,9 +248,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
|
||||
if (widget.streamType == StreamType.recording) {
|
||||
playerSrc += '&is_recording=true';
|
||||
}
|
||||
|
||||
if (startTimeOverride != null) {
|
||||
// Le média commence à l'offset : le lecteur ré-ajoute ce décalage
|
||||
// pour exprimer position et recherches en temps absolu.
|
||||
if (_mediaOffset > 0) playerSrc += '&offset=${_mediaOffset.toInt()}';
|
||||
} else if (startTimeOverride != null && startTimeOverride > 0) {
|
||||
playerSrc += '&t=$startTimeOverride';
|
||||
}
|
||||
|
||||
@@ -230,12 +275,20 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
_isLoading = false;
|
||||
_isPlaying = true;
|
||||
_seekReloading = false;
|
||||
// La nouvelle iframe repart d'une plage navigable vide, qui
|
||||
// grandira au rythme du transcodage.
|
||||
_seekableEnd = _mediaOffset;
|
||||
if (_mediaOffset > 0) _currentPosition = _mediaOffset;
|
||||
});
|
||||
|
||||
// Force unmute on load (after a short delay to ensure iframe is ready)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
_sendMessage({'type': 'set_volume', 'value': 1.0});
|
||||
if (!mounted) return;
|
||||
_sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0});
|
||||
if (_rate != 1.0) {
|
||||
_sendMessage({'type': 'set_rate', 'value': _rate});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -258,28 +311,47 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
|
||||
final type = data['type'];
|
||||
if (type == 'playback_position') {
|
||||
// Un rechargement à une autre position est en cours : l'ancienne
|
||||
// iframe continue d'émettre quelques instants, ne pas la laisser
|
||||
// ramener la barre en arrière.
|
||||
if (_seekReloading) return;
|
||||
|
||||
final currentTime = (data['currentTime'] as num).toDouble();
|
||||
final rawDuration = (data['duration'] as num).toDouble();
|
||||
final duration = rawDuration.isFinite ? rawDuration : 0.0;
|
||||
final rawSeekable = data['seekableEnd'];
|
||||
final seekableEnd =
|
||||
rawSeekable is num ? rawSeekable.toDouble() : currentTime;
|
||||
|
||||
// Only update position if user is NOT dragging the slider
|
||||
if (!_isSeeking) {
|
||||
setState(() {
|
||||
setState(() {
|
||||
if (!_isSeeking) {
|
||||
_currentPosition = currentTime;
|
||||
_totalDuration = duration > 0 ? duration : 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (seekableEnd.isFinite && seekableEnd > 0) {
|
||||
_seekableEnd = seekableEnd;
|
||||
}
|
||||
});
|
||||
|
||||
// Update watch history if relevant. Jamais en live : la « position »
|
||||
// d'un flux continu n'a pas de sens et polluait le stockage avec une
|
||||
// entrée bidon par chaîne zappée.
|
||||
if (currentTime > 0 && widget.streamType != StreamType.live) {
|
||||
ref.read(playbackPositionsProvider.notifier).savePosition(
|
||||
widget.streamId,
|
||||
_positionKey,
|
||||
currentTime,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
} else if (type == 'seek_out_of_range') {
|
||||
// Cible hors de la zone déjà transcodée : seul un redémarrage du
|
||||
// flux à cet instant peut la servir.
|
||||
final target = (data['value'] as num?)?.toDouble();
|
||||
if (target != null) _restartAt(target);
|
||||
} else if (type == 'playback_ended') {
|
||||
setState(() => _isPlaying = false);
|
||||
ref.read(playbackPositionsProvider.notifier).clearPosition(_positionKey);
|
||||
} else if (type == 'playback_status') {
|
||||
if (!_ignoreStatusUpdates) {
|
||||
setState(() => _isPlaying = data['status'] == 'playing');
|
||||
@@ -345,6 +417,48 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche à un instant absolu du contenu.
|
||||
///
|
||||
/// Le lecteur tranche lui-même : saut local s'il a la donnée, sinon il
|
||||
/// répond `seek_out_of_range` et le flux est relancé à cet instant.
|
||||
void _seekTo(double target) {
|
||||
final clamped = target.clamp(0.0, _totalDuration);
|
||||
setState(() => _currentPosition = clamped);
|
||||
_sendMessage({'type': 'seek', 'value': clamped});
|
||||
_onHover();
|
||||
}
|
||||
|
||||
void _seekBy(double delta) => _seekTo(_currentPosition + delta);
|
||||
|
||||
/// Relance le flux à [target] : nouvelle session de transcodage côté
|
||||
/// serveur, donc quelques secondes d'attente — réservé aux sauts que la
|
||||
/// zone déjà transcodée ne couvre pas.
|
||||
void _restartAt(double target) {
|
||||
if (!_isSeekable || _seekReloading) return;
|
||||
// Jamais tout à la fin : FFmpeg lancé au-delà de la dernière image
|
||||
// n'encoderait rien et le lecteur n'aurait qu'une erreur à afficher.
|
||||
final ceiling =
|
||||
_totalDuration > 10 ? _totalDuration - 5 : _totalDuration;
|
||||
final clamped = target.clamp(0.0, ceiling);
|
||||
if (widget.streamType != StreamType.recording) {
|
||||
// VOD/séries : le serveur ne sait pas démarrer ailleurs qu'au début,
|
||||
// on se contente du bord de la zone disponible.
|
||||
_sendMessage({'type': 'seek', 'value': _seekableEnd});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_seekReloading = true;
|
||||
_currentPosition = clamped;
|
||||
});
|
||||
_initializePlayer(startTimeOverride: clamped, isChannelSwitch: true);
|
||||
}
|
||||
|
||||
void _setRate(double rate) {
|
||||
setState(() => _rate = rate);
|
||||
_sendMessage({'type': 'set_rate', 'value': rate});
|
||||
_onHover();
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
try {
|
||||
if (_documentIsFullscreen) {
|
||||
@@ -379,6 +493,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
// current position, live restarts at the live edge.
|
||||
final resumeAt =
|
||||
widget.streamType == StreamType.live ? null : _currentPosition;
|
||||
// Un enregistrement redémarre à cette position côté serveur : sa zone
|
||||
// navigable repart de zéro.
|
||||
_initializePlayer(startTimeOverride: resumeAt, isChannelSwitch: true);
|
||||
}
|
||||
|
||||
@@ -408,17 +524,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauts proposés par les raccourcis et les boutons.
|
||||
static const _smallStep = 10.0;
|
||||
static const _bigStep = 60.0;
|
||||
|
||||
/// Vitesses de lecture proposées hors direct.
|
||||
static const _rates = <double>[0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
/// Keyboard / TV remote controls:
|
||||
/// space-enter = play/pause, ←/→ = seek ±10s (VOD) or zap (live),
|
||||
/// ↑/↓ = zap (live), M = mute, Esc = exit player.
|
||||
/// space-enter-K = play/pause, ←/→ = seek ±10 s (VOD) or zap (live),
|
||||
/// Maj+←/→ ou J/L = ±60 s, ↑/↓ = zap (live), 0-9 = saut en % du contenu,
|
||||
/// M = mute, F = plein écran, +/- = vitesse, Esc = exit player.
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final isLive = widget.streamType == StreamType.live;
|
||||
final key = event.logicalKey;
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
|
||||
if (key == LogicalKeyboardKey.space ||
|
||||
key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.keyK ||
|
||||
key == LogicalKeyboardKey.mediaPlayPause) {
|
||||
_togglePlayPause();
|
||||
_onHover();
|
||||
@@ -433,28 +559,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyF) {
|
||||
_toggleFullscreen();
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
if (isLive) {
|
||||
_previousChannel();
|
||||
_onHover();
|
||||
} else {
|
||||
_sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition - 10).clamp(0, _totalDuration),
|
||||
});
|
||||
_seekBy(shift ? -_bigStep : -_smallStep);
|
||||
}
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (isLive) {
|
||||
_nextChannel();
|
||||
_onHover();
|
||||
} else {
|
||||
_sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition + 10).clamp(0, _totalDuration),
|
||||
});
|
||||
_seekBy(shift ? _bigStep : _smallStep);
|
||||
}
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (isLive && key == LogicalKeyboardKey.arrowUp) {
|
||||
@@ -467,9 +592,68 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
_onHover();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (!isLive) {
|
||||
if (key == LogicalKeyboardKey.keyJ) {
|
||||
_seekBy(-_bigStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyL) {
|
||||
_seekBy(_bigStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// 0-9 : saut direct au dixième correspondant du contenu.
|
||||
final decile = _decileFor(key);
|
||||
if (decile != null) {
|
||||
_seekTo(_totalDuration * decile / 10);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.period) {
|
||||
_stepRate(1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.comma) {
|
||||
_stepRate(-1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Touche 0-9 (rangée du haut ou pavé numérique) → dixième visé.
|
||||
/// Table non `const` : `LogicalKeyboardKey` redéfinit `==`, ce qui interdit
|
||||
/// une table constante.
|
||||
static final Map<LogicalKeyboardKey, int> _digitKeys = {
|
||||
LogicalKeyboardKey.digit0: 0,
|
||||
LogicalKeyboardKey.digit1: 1,
|
||||
LogicalKeyboardKey.digit2: 2,
|
||||
LogicalKeyboardKey.digit3: 3,
|
||||
LogicalKeyboardKey.digit4: 4,
|
||||
LogicalKeyboardKey.digit5: 5,
|
||||
LogicalKeyboardKey.digit6: 6,
|
||||
LogicalKeyboardKey.digit7: 7,
|
||||
LogicalKeyboardKey.digit8: 8,
|
||||
LogicalKeyboardKey.digit9: 9,
|
||||
LogicalKeyboardKey.numpad0: 0,
|
||||
LogicalKeyboardKey.numpad1: 1,
|
||||
LogicalKeyboardKey.numpad2: 2,
|
||||
LogicalKeyboardKey.numpad3: 3,
|
||||
LogicalKeyboardKey.numpad4: 4,
|
||||
LogicalKeyboardKey.numpad5: 5,
|
||||
LogicalKeyboardKey.numpad6: 6,
|
||||
LogicalKeyboardKey.numpad7: 7,
|
||||
LogicalKeyboardKey.numpad8: 8,
|
||||
LogicalKeyboardKey.numpad9: 9,
|
||||
};
|
||||
|
||||
int? _decileFor(LogicalKeyboardKey key) => _digitKeys[key];
|
||||
|
||||
void _stepRate(int direction) {
|
||||
final index = _rates.indexOf(_rate);
|
||||
final next = (index < 0 ? _rates.indexOf(1.0) : index) + direction;
|
||||
if (next < 0 || next >= _rates.length) return;
|
||||
_setRate(_rates[next]);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final hours = twoDigits(d.inHours);
|
||||
@@ -748,9 +932,28 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
|
||||
// Center Loading Indicator (only show when loading)
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primary,
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
// Un saut hors de la zone transcodée relance
|
||||
// l'encodeur : quelques secondes d'attente,
|
||||
// autant dire pourquoi.
|
||||
if (_seekReloading) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Reprise à '
|
||||
'${_formatDuration(Duration(seconds: _currentPosition.toInt()))}…',
|
||||
style: GoogleFonts.karla(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -787,6 +990,11 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
activeTrackColor: AppColors.primary,
|
||||
inactiveTrackColor:
|
||||
AppColors.onSurface.withOpacity(0.2),
|
||||
// Portion déjà transcodée : navigable
|
||||
// instantanément, contrairement au
|
||||
// reste qui redémarre l'encodeur.
|
||||
secondaryActiveTrackColor:
|
||||
AppColors.onSurface.withOpacity(0.4),
|
||||
thumbColor: AppColors.onSurface,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 8,
|
||||
@@ -795,7 +1003,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
AppColors.primary.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: _currentPosition,
|
||||
value: _currentPosition
|
||||
.clamp(0.0, _totalDuration),
|
||||
secondaryTrackValue: _seekableEnd > 0
|
||||
? _seekableEnd
|
||||
.clamp(0.0, _totalDuration)
|
||||
: null,
|
||||
min: 0,
|
||||
max: _totalDuration,
|
||||
onChanged: (val) => setState(
|
||||
@@ -804,9 +1017,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
onChangeStart: (_) =>
|
||||
setState(() => _isSeeking = true),
|
||||
onChangeEnd: (val) {
|
||||
_sendMessage(
|
||||
{'type': 'seek', 'value': val},
|
||||
);
|
||||
_seekTo(val);
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
() {
|
||||
@@ -836,68 +1047,91 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.replay_10_rounded,
|
||||
onTap: () => _sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition - 10)
|
||||
.clamp(0, _totalDuration),
|
||||
}),
|
||||
transparent: true,
|
||||
tooltip: 'Reculer de 10 s',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: _isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
onTap: _togglePlayPause,
|
||||
size: 56,
|
||||
iconSize: 32,
|
||||
tooltip: _isPlaying ? 'Pause' : 'Lecture',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.forward_10_rounded,
|
||||
onTap: () => _sendMessage({
|
||||
'type': 'seek',
|
||||
'value': (_currentPosition + 10)
|
||||
.clamp(0, _totalDuration),
|
||||
}),
|
||||
transparent: true,
|
||||
tooltip: 'Avancer de 10 s',
|
||||
),
|
||||
const Spacer(),
|
||||
QualitySelectorButton(
|
||||
current: _quality,
|
||||
onSelected: _changeQuality,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isMuted
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
onTap: _toggleMute,
|
||||
transparent: true,
|
||||
tooltip: _isMuted
|
||||
? 'Activer le son'
|
||||
: 'Couper le son',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit_rounded
|
||||
: Icons.fullscreen_rounded,
|
||||
onTap: _toggleFullscreen,
|
||||
transparent: true,
|
||||
tooltip: _isFullscreen
|
||||
? 'Quitter le plein écran'
|
||||
: 'Plein écran',
|
||||
),
|
||||
],
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Sous 720 px, les sauts de 60 s et le
|
||||
// sélecteur de vitesse sortent de la barre
|
||||
// plutôt que de la faire déborder.
|
||||
final compact = constraints.maxWidth < 720;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!compact) ...[
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.fast_rewind_rounded,
|
||||
onTap: () => _seekBy(-_bigStep),
|
||||
transparent: true,
|
||||
tooltip: 'Reculer de 1 min',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.replay_10_rounded,
|
||||
onTap: () => _seekBy(-_smallStep),
|
||||
transparent: true,
|
||||
tooltip: 'Reculer de 10 s',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: _isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
onTap: _togglePlayPause,
|
||||
size: 56,
|
||||
iconSize: 32,
|
||||
tooltip:
|
||||
_isPlaying ? 'Pause' : 'Lecture',
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.forward_10_rounded,
|
||||
onTap: () => _seekBy(_smallStep),
|
||||
transparent: true,
|
||||
tooltip: 'Avancer de 10 s',
|
||||
),
|
||||
if (!compact) ...[
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: Icons.fast_forward_rounded,
|
||||
onTap: () => _seekBy(_bigStep),
|
||||
transparent: true,
|
||||
tooltip: 'Avancer de 1 min',
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (!compact) ...[
|
||||
_buildRateSelector(),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
QualitySelectorButton(
|
||||
current: _quality,
|
||||
onSelected: _changeQuality,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isMuted
|
||||
? Icons.volume_off_rounded
|
||||
: Icons.volume_up_rounded,
|
||||
onTap: _toggleMute,
|
||||
transparent: true,
|
||||
tooltip: _isMuted
|
||||
? 'Activer le son'
|
||||
: 'Couper le son',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildGlassIconButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit_rounded
|
||||
: Icons.fullscreen_rounded,
|
||||
onTap: _toggleFullscreen,
|
||||
transparent: true,
|
||||
tooltip: _isFullscreen
|
||||
? 'Quitter le plein écran'
|
||||
: 'Plein écran',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -914,6 +1148,73 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Sélecteur de vitesse de lecture — utile pour parcourir un
|
||||
/// enregistrement long sans sauter à l'aveugle.
|
||||
Widget _buildRateSelector() {
|
||||
return Tooltip(
|
||||
message: 'Vitesse de lecture',
|
||||
child: PopupMenuButton<double>(
|
||||
initialValue: _rate,
|
||||
onSelected: _setRate,
|
||||
color: AppColors.surfaceContainer,
|
||||
tooltip: '',
|
||||
itemBuilder: (context) => [
|
||||
for (final rate in _rates)
|
||||
PopupMenuItem<double>(
|
||||
value: rate,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
rate == _rate
|
||||
? Icons.check_rounded
|
||||
: Icons.speed_rounded,
|
||||
size: 16,
|
||||
color: rate == _rate
|
||||
? AppColors.primary
|
||||
: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatRate(rate),
|
||||
style: TextStyle(
|
||||
color: rate == _rate
|
||||
? AppColors.primary
|
||||
: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.onSurface.withOpacity(0.15)),
|
||||
),
|
||||
child: Text(
|
||||
_formatRate(_rate),
|
||||
style: GoogleFonts.karla(
|
||||
color: _rate == 1.0 ? AppColors.onSurface : AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// « 1× », « 1.5× » — sans décimale inutile.
|
||||
static String _formatRate(double rate) {
|
||||
final text = rate == rate.roundToDouble()
|
||||
? rate.toStringAsFixed(0)
|
||||
: rate.toString();
|
||||
return '$text×';
|
||||
}
|
||||
|
||||
Widget _buildGlassIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onTap,
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../core/api/recording_requests.dart';
|
||||
import '../../../core/models/iptv_models.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../providers/playback_positions_provider.dart';
|
||||
import '../providers/recordings_refresh.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
@@ -780,14 +781,15 @@ class _ProgrammeCard extends StatelessWidget {
|
||||
// ONGLET 2 — ENREGISTREMENTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _RecordingsListView extends StatefulWidget {
|
||||
class _RecordingsListView extends ConsumerStatefulWidget {
|
||||
final PlaylistConfig playlist;
|
||||
const _RecordingsListView({required this.playlist});
|
||||
@override
|
||||
State<_RecordingsListView> createState() => _RecordingsListViewState();
|
||||
ConsumerState<_RecordingsListView> createState() =>
|
||||
_RecordingsListViewState();
|
||||
}
|
||||
|
||||
class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
class _RecordingsListViewState extends ConsumerState<_RecordingsListView> {
|
||||
List<dynamic> _recordings = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
@@ -917,6 +919,35 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
_fetchRecordings();
|
||||
}
|
||||
|
||||
/// Clé de reprise d'un enregistrement (même convention que le lecteur).
|
||||
static String _resumeKey(String recordingId) => 'rec_$recordingId';
|
||||
|
||||
/// Position sauvegardée, ou 0 s'il n'y en a pas d'exploitable.
|
||||
double _savedPosition(Map<String, dynamic> rec) {
|
||||
final id = rec['id'] as String?;
|
||||
if (id == null || id.isEmpty) return 0;
|
||||
return ref.read(playbackPositionsProvider).getPosition(_resumeKey(id));
|
||||
}
|
||||
|
||||
/// Durée du média : celle mesurée par le serveur si disponible, sinon la
|
||||
/// durée programmée. Un enregistrement arrêté en avance est plus court que
|
||||
/// `end_time - start_time`.
|
||||
Duration? _recordingDuration(Map<String, dynamic> rec) {
|
||||
final probed = rec['duration_seconds'];
|
||||
if (probed is num && probed > 0) {
|
||||
return Duration(seconds: probed.round());
|
||||
}
|
||||
try {
|
||||
if (rec['start_time'] != null && rec['end_time'] != null) {
|
||||
final start = DateTime.parse(rec['start_time'].toString());
|
||||
final end = DateTime.parse(rec['end_time'].toString());
|
||||
final scheduled = end.difference(start);
|
||||
if (scheduled > Duration.zero) return scheduled;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _playRecording(BuildContext context, Map<String, dynamic> rec) async {
|
||||
final recordingId = rec['id'] as String?;
|
||||
final title = rec['title'] as String? ?? 'Enregistrement';
|
||||
@@ -928,17 +959,52 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
return;
|
||||
}
|
||||
|
||||
Duration? recordingDuration;
|
||||
try {
|
||||
if (rec['start_time'] != null && rec['end_time'] != null) {
|
||||
final start = DateTime.parse(rec['start_time'].toString());
|
||||
final end = DateTime.parse(rec['end_time'].toString());
|
||||
recordingDuration = end.difference(start);
|
||||
final recordingDuration = _recordingDuration(rec);
|
||||
final saved = _savedPosition(rec);
|
||||
|
||||
// Reprise proposée, jamais imposée : un enregistrement déjà vu se
|
||||
// relance parfois depuis le début.
|
||||
double? startAt;
|
||||
if (saved > 30) {
|
||||
if (!mounted) return;
|
||||
final resume = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceContainer,
|
||||
title: Text(
|
||||
'Reprendre la lecture ?',
|
||||
style: GoogleFonts.fraunces(color: AppColors.onSurface),
|
||||
),
|
||||
content: Text(
|
||||
'« $title » a été interrompu à '
|
||||
'${PlaybackPositionsNotifier.formatTime(saved)}.',
|
||||
style: const TextStyle(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Depuis le début'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: const Text('Reprendre'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (resume == null) return; // dialogue fermé : on n'ouvre rien
|
||||
if (resume) {
|
||||
startAt = saved;
|
||||
} else {
|
||||
ref
|
||||
.read(playbackPositionsProvider.notifier)
|
||||
.clearPosition(_resumeKey(recordingId));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).push(
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => PlayerScreen(
|
||||
streamId: recordingId,
|
||||
@@ -947,9 +1013,51 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
streamType: StreamType.recording,
|
||||
containerExtension: 'ts',
|
||||
duration: recordingDuration,
|
||||
startTime: startAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Au retour du lecteur, la progression affichée dans la liste doit
|
||||
// refléter la position atteinte.
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Barre « déjà vu jusqu'ici » sous un enregistrement entamé.
|
||||
List<Widget> _buildResumeIndicator(Map<String, dynamic> rec) {
|
||||
final saved = _savedPosition(rec);
|
||||
if (saved <= 30) return const [];
|
||||
|
||||
final total = _recordingDuration(rec)?.inSeconds ?? 0;
|
||||
final ratio = total > 0 ? (saved / total).clamp(0.0, 1.0) : null;
|
||||
|
||||
return [
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
if (ratio != null)
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio,
|
||||
minHeight: 4,
|
||||
backgroundColor: AppColors.onSurface.withOpacity(0.1),
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ratio != null) const SizedBox(width: 8),
|
||||
Text(
|
||||
'Reprendre à ${PlaybackPositionsNotifier.formatTime(saved)}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _showLogs(String id, String title) async {
|
||||
@@ -1037,6 +1145,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Les positions de reprise arrivent de façon asynchrone (SharedPreferences)
|
||||
// et changent au retour du lecteur : sans cette écoute, la mention
|
||||
// « Reprendre à … » n'apparaîtrait qu'au prochain rafraîchissement.
|
||||
ref.watch(playbackPositionsProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -1178,6 +1290,8 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (status == 'completed')
|
||||
..._buildResumeIndicator(rec),
|
||||
if (rec['error_reason'] != null)
|
||||
Text(
|
||||
'⚠ ${rec['error_reason']}',
|
||||
@@ -1228,13 +1342,18 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
),
|
||||
if (status == 'completed')
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.play_circle_outline,
|
||||
icon: Icon(
|
||||
_savedPosition(rec) > 30
|
||||
? Icons.play_circle_fill
|
||||
: Icons.play_circle_outline,
|
||||
color: AppColors.success,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Lecture',
|
||||
onPressed: () => _playRecording(context, rec),
|
||||
tooltip: _savedPosition(rec) > 30
|
||||
? 'Reprendre'
|
||||
: 'Lecture',
|
||||
onPressed: () =>
|
||||
_playRecording(context, rec),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
|
||||
+48
-23
@@ -96,35 +96,60 @@
|
||||
playOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
var player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[Player]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: showError,
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
// Retour visuel discret : l'utilisateur comprend pourquoi
|
||||
// la latence augmente légèrement après des coupures.
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
// Un enregistrement ou un film démarre une session FFmpeg côté
|
||||
// serveur : le premier essai peut tomber pendant que l'encodeur
|
||||
// se met en route. Le player mobile réessayait déjà seul ; ici
|
||||
// l'utilisateur devait relancer la lecture à la main.
|
||||
var retryCount = 0;
|
||||
var maxRetries = 3;
|
||||
var player = null;
|
||||
|
||||
function buildPlayer() {
|
||||
if (player) { try { player.destroy(); } catch (e) {} }
|
||||
|
||||
player = new XFPlayer({
|
||||
video: video,
|
||||
logPrefix: '[Player]',
|
||||
onLoading: showLoading,
|
||||
onReady: function () {
|
||||
hideLoading();
|
||||
retryCount = 0;
|
||||
playOverlay.style.display = 'none';
|
||||
},
|
||||
onError: function (msg) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
showLoading(
|
||||
'Préparation du flux ' + retryCount + '/' + maxRetries
|
||||
);
|
||||
setTimeout(buildPlayer, 1500);
|
||||
} else {
|
||||
showError(msg);
|
||||
}
|
||||
},
|
||||
onBlocked: function () {
|
||||
hideLoading();
|
||||
playOverlay.style.display = 'flex';
|
||||
},
|
||||
onProfileChange: function (name) {
|
||||
// Retour visuel discret : l'utilisateur comprend pourquoi
|
||||
// la latence augmente légèrement après des coupures.
|
||||
loadingHint.textContent =
|
||||
name === 'safe' ? 'Réseau instable — buffer maximal'
|
||||
: 'Réseau instable — buffer élargi';
|
||||
}
|
||||
});
|
||||
|
||||
player.start();
|
||||
window.xfPlayer = player;
|
||||
}
|
||||
|
||||
playOverlay.addEventListener('click', function () {
|
||||
video.play();
|
||||
playOverlay.style.display = 'none';
|
||||
});
|
||||
|
||||
player.start();
|
||||
window.xfPlayer = player;
|
||||
buildPlayer();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+166
-26
@@ -161,6 +161,13 @@
|
||||
this.injectedDuration = parseFloat(p.get('duration') || '0');
|
||||
this.isRecording = p.get('is_recording') === 'true';
|
||||
|
||||
// Décalage du média par rapport au début du contenu. Un enregistrement
|
||||
// ouvert à 45 min est transcodé par FFmpeg *depuis* 45 min : le média
|
||||
// commence à 0 alors que la lecture, elle, est à 2700 s. Tout ce qui
|
||||
// sort du lecteur (position, recherche) est exprimé en temps absolu.
|
||||
this.mediaOffset = parseFloat(p.get('offset') || '0');
|
||||
if (!isFinite(this.mediaOffset) || this.mediaOffset < 0) this.mediaOffset = 0;
|
||||
|
||||
// Profil de départ : forçable via ?buffer=balanced|safe
|
||||
var forced = p.get('buffer');
|
||||
this.profile = PROFILES[forced] ? PROFILES[forced] : PROFILES.fast;
|
||||
@@ -174,8 +181,19 @@
|
||||
this._destroyed = false;
|
||||
this._reportedTime = 0;
|
||||
this._hlsDuration = 0;
|
||||
|
||||
// Écouteurs posés par cette instance, retirés à la destruction : sans
|
||||
// ça, une relance après échec laisserait l'ancienne instance réagir aux
|
||||
// commandes du parent et republier des positions périmées.
|
||||
this._listeners = [];
|
||||
}
|
||||
|
||||
/** addEventListener + mémorisation pour un retrait propre. */
|
||||
XFPlayer.prototype._on = function (target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
this._listeners.push({ target: target, type: type, handler: handler });
|
||||
};
|
||||
|
||||
XFPlayer.prototype.log = function (m) {
|
||||
if (global.console && console.log) console.log(this.logPrefix + ' ' + m);
|
||||
};
|
||||
@@ -224,6 +242,8 @@
|
||||
|
||||
XFPlayer.prototype._hlsConfig = function () {
|
||||
var prof = this.profile.hls;
|
||||
var isLive = this.type === 'live';
|
||||
|
||||
return {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
@@ -241,15 +261,39 @@
|
||||
startFragPrefetch: true, // précharge le 1er fragment pendant le parse
|
||||
testBandwidth: false, // pas de mesure préalable : on joue tout de suite
|
||||
|
||||
// ---- Début de lecture ----
|
||||
// Un enregistrement (ou un film) est transcodé au fil de l'eau : sa
|
||||
// playlist n'a pas encore d'`EXT-X-ENDLIST`, donc hls.js la considère
|
||||
// comme du direct. Sans consigne explicite il démarre alors au « bord
|
||||
// du direct », c'est-à-dire collé au front d'encodage : la lecture
|
||||
// partait au milieu du programme et se coupait aussitôt, faute de
|
||||
// segments d'avance. `0` = au début, comme attendu d'un contenu
|
||||
// à la demande. En direct, -1 conserve le démarrage au bord du direct.
|
||||
startPosition: isLive ? -1 : (this.startTime > 0 ? this.startTime : 0),
|
||||
|
||||
// ---- Live ----
|
||||
liveSyncDurationCount: prof.liveSyncDurationCount,
|
||||
liveMaxLatencyDurationCount: prof.liveMaxLatencyDurationCount,
|
||||
maxLiveSyncPlaybackRate: 1.15,
|
||||
// Hors direct, ces deux réglages doivent être neutralisés : ils
|
||||
// accélèrent la lecture et finissent par forcer un saut en avant pour
|
||||
// « rattraper » un direct qui n'existe pas.
|
||||
// 86400 segments = hors d'atteinte, sans recourir à Infinity dont
|
||||
// hls.js fait ensuite des multiplications.
|
||||
liveMaxLatencyDurationCount: isLive
|
||||
? prof.liveMaxLatencyDurationCount
|
||||
: 86400,
|
||||
maxLiveSyncPlaybackRate: isLive ? 1.15 : 1,
|
||||
|
||||
// ---- Robustesse ----
|
||||
nudgeOffset: 0.6,
|
||||
nudgeMaxRetries: 20,
|
||||
abrEwmaDefaultEstimate: 2000000,
|
||||
// Hors direct, la première requête de playlist peut légitimement
|
||||
// bloquer le temps que FFmpeg démarre et produise ses premiers
|
||||
// segments. Le défaut de 10 s de hls.js transformait ce démarrage en
|
||||
// « erreur de chargement » — il fallait relancer la lecture pour
|
||||
// tomber sur une session déjà chaude.
|
||||
manifestLoadingTimeOut: isLive ? 10000 : 45000,
|
||||
levelLoadingTimeOut: isLive ? 10000 : 45000,
|
||||
manifestLoadingRetryDelay: 500,
|
||||
levelLoadingRetryDelay: 500,
|
||||
fragLoadingRetryDelay: 500,
|
||||
@@ -513,40 +557,94 @@
|
||||
var self = this;
|
||||
var v = this.video;
|
||||
|
||||
v.addEventListener('loadedmetadata', function () {
|
||||
this._on(v, 'loadedmetadata', function () {
|
||||
if (self.startTime > 0 && self.startTime < v.duration) {
|
||||
v.currentTime = self.startTime;
|
||||
}
|
||||
});
|
||||
|
||||
v.addEventListener('playing', function () {
|
||||
this._on(v, 'playing', function () {
|
||||
self._started = true;
|
||||
self.onReady();
|
||||
self.send({ type: 'playback_status', status: 'playing' });
|
||||
});
|
||||
|
||||
v.addEventListener('pause', function () {
|
||||
this._on(v, 'pause', function () {
|
||||
self.send({ type: 'playback_status', status: 'paused' });
|
||||
});
|
||||
|
||||
v.addEventListener('waiting', function () { self._noteStall(); });
|
||||
v.addEventListener('stalled', function () { self._noteStall(); });
|
||||
this._on(v, 'waiting', function () { self._noteStall(); });
|
||||
this._on(v, 'stalled', function () { self._noteStall(); });
|
||||
|
||||
v.addEventListener('ended', function () {
|
||||
this._on(v, 'ended', function () {
|
||||
self.send({
|
||||
type: 'playback_ended',
|
||||
duration: isFinite(v.duration) ? v.duration : self.injectedDuration
|
||||
duration: self._totalDuration()
|
||||
});
|
||||
});
|
||||
|
||||
// Le front de transcodage avance : le parent doit pouvoir griser la
|
||||
// portion pas encore navigable de la barre de progression. `progress`
|
||||
// se déclenche à chaque bloc reçu — bridé à 2 s, sinon chaque segment
|
||||
// provoquerait une reconstruction de l'interface Flutter.
|
||||
this._on(v, 'progress', function () {
|
||||
if (!self._started) return;
|
||||
var now = Date.now();
|
||||
if (now - (self._lastProgressSent || 0) < 2000) return;
|
||||
self._lastProgressSent = now;
|
||||
self._sendPosition();
|
||||
});
|
||||
|
||||
this._on(v, 'seeked', function () { self._sendPosition(); });
|
||||
|
||||
// Remontée de position (5 s) — même contrat qu'avant.
|
||||
this._posTimer = setInterval(function () { self._reportPosition(); }, 5000);
|
||||
|
||||
// Activité utilisateur → masquage auto des contrôles côté Flutter.
|
||||
function activity() { self.send({ type: 'user_activity' }); }
|
||||
document.addEventListener('mousemove', activity, { passive: true });
|
||||
document.addEventListener('touchstart', activity, { passive: true });
|
||||
document.addEventListener('click', activity, { passive: true });
|
||||
this._on(document, 'mousemove', activity, { passive: true });
|
||||
this._on(document, 'touchstart', activity, { passive: true });
|
||||
this._on(document, 'click', activity, { passive: true });
|
||||
};
|
||||
|
||||
/** Fin de la plage réellement navigable, en temps média. */
|
||||
XFPlayer.prototype._seekableEnd = function () {
|
||||
var v = this.video;
|
||||
try {
|
||||
if (v.seekable && v.seekable.length > 0) {
|
||||
return v.seekable.end(v.seekable.length - 1);
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (v.buffered && v.buffered.length > 0) {
|
||||
return v.buffered.end(v.buffered.length - 1);
|
||||
}
|
||||
} catch (e) {}
|
||||
return isFinite(v.duration) ? v.duration : 0;
|
||||
};
|
||||
|
||||
/** Durée totale du contenu (et non du seul média chargé), en secondes. */
|
||||
XFPlayer.prototype._totalDuration = function () {
|
||||
var v = this.video;
|
||||
var duration = v.duration;
|
||||
var hlsDur = this._hlsDuration || 0;
|
||||
if (!isFinite(duration) || isNaN(duration) || duration < 1) {
|
||||
duration = hlsDur;
|
||||
} else if (hlsDur > duration) {
|
||||
duration = hlsDur;
|
||||
}
|
||||
|
||||
// Temps absolu : le média peut commencer après le début du contenu.
|
||||
var total = duration > 0 ? this.mediaOffset + duration : 0;
|
||||
|
||||
// La durée annoncée par l'app fait foi tant que le média n'en connaît
|
||||
// qu'une fraction — cas d'une playlist `event` encore en cours de
|
||||
// transcodage, où la durée grandirait sous la barre de progression.
|
||||
// Au-delà, le média a raison : les métadonnées Xtream sont approximatives.
|
||||
if (this.injectedDuration > 0 && total < this.injectedDuration * 0.98) {
|
||||
return this.injectedDuration;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
XFPlayer.prototype._reportPosition = function () {
|
||||
@@ -554,27 +652,60 @@
|
||||
if (!(v.currentTime > 0) || v.paused || v.readyState <= 2) return;
|
||||
if (Math.abs(v.currentTime - this._reportedTime) < 1) return;
|
||||
this._reportedTime = v.currentTime;
|
||||
this._sendPosition();
|
||||
};
|
||||
|
||||
var duration = v.duration;
|
||||
var hlsDur = this._hlsDuration || 0;
|
||||
if (!isFinite(duration) || isNaN(duration) || duration < 1) {
|
||||
duration = hlsDur > 0 ? hlsDur : (this.injectedDuration > 0 ? this.injectedDuration : 0);
|
||||
} else if (hlsDur > duration) {
|
||||
duration = hlsDur;
|
||||
} else if (this.injectedDuration > 0 && duration < this.injectedDuration * 0.9) {
|
||||
duration = this.injectedDuration;
|
||||
}
|
||||
|
||||
/** Envoi immédiat de l'état de lecture (position, durée, zone navigable). */
|
||||
XFPlayer.prototype._sendPosition = function () {
|
||||
this.send({
|
||||
type: 'playback_position',
|
||||
currentTime: v.currentTime,
|
||||
duration: duration
|
||||
currentTime: this.mediaOffset + this.video.currentTime,
|
||||
duration: this._totalDuration(),
|
||||
seekableEnd: this.mediaOffset + this._seekableEnd()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Recherche à un instant ABSOLU du contenu.
|
||||
*
|
||||
* Tant que la cible est dans la zone déjà transcodée, c'est une simple
|
||||
* affectation de `currentTime`. Au-delà, aucune quantité d'attente ne la
|
||||
* rendra disponible rapidement : le parent est prévenu pour relancer le
|
||||
* flux à cet instant (nouvelle session FFmpeg `?start=`).
|
||||
*/
|
||||
XFPlayer.prototype.seekTo = function (absolute) {
|
||||
if (!isFinite(absolute)) return;
|
||||
var v = this.video;
|
||||
var target = absolute - this.mediaOffset;
|
||||
var end = this._seekableEnd();
|
||||
|
||||
if (target < 0) {
|
||||
// Avant le début du média chargé : cette portion n'est pas dans cette
|
||||
// session de transcodage, il faut en relancer une plus tôt.
|
||||
this.send({ type: 'seek_out_of_range', value: Math.max(0, absolute) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Marge de 10 s : juste devant le front de transcodage, attendre coûte
|
||||
// moins cher que relancer un encodeur.
|
||||
if (end > 0 && target > end + 10) {
|
||||
this.send({ type: 'seek_out_of_range', value: absolute });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
v.currentTime = end > 0 ? Math.min(target, Math.max(0, end - 0.5)) : target;
|
||||
} catch (e) {
|
||||
this.send({ type: 'seek_out_of_range', value: absolute });
|
||||
return;
|
||||
}
|
||||
this._reportedTime = v.currentTime;
|
||||
this._sendPosition();
|
||||
};
|
||||
|
||||
XFPlayer.prototype._wireParentMessages = function () {
|
||||
var self = this;
|
||||
global.addEventListener('message', function (event) {
|
||||
this._on(global, 'message', function (event) {
|
||||
// N'accepter que les commandes émises par notre propre origine
|
||||
// (le côté Flutter filtre déjà les messages entrants de la même façon).
|
||||
if (event.origin !== global.location.origin) return;
|
||||
@@ -589,7 +720,12 @@
|
||||
v.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
if (isFinite(d.value)) v.currentTime = d.value;
|
||||
self.seekTo(d.value);
|
||||
break;
|
||||
case 'set_rate':
|
||||
if (isFinite(d.value) && d.value > 0) {
|
||||
try { v.playbackRate = Math.max(0.25, Math.min(4, d.value)); } catch (e) {}
|
||||
}
|
||||
break;
|
||||
case 'set_volume':
|
||||
v.volume = Math.max(0, Math.min(1, d.value));
|
||||
@@ -606,6 +742,10 @@
|
||||
XFPlayer.prototype.destroy = function () {
|
||||
this._destroyed = true;
|
||||
clearInterval(this._posTimer);
|
||||
this._listeners.forEach(function (l) {
|
||||
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
|
||||
});
|
||||
this._listeners = [];
|
||||
try { if (this.hls) this.hls.destroy(); } catch (e) {}
|
||||
try {
|
||||
if (this.mpegts) {
|
||||
|
||||
Reference in new issue
Block a user