mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 21:48:06 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2151f2820c | ||
|
|
fa8eed38f2 | ||
|
|
b95d72fe80 | ||
|
|
d6c7184475 | ||
|
|
175cff560c |
No files matched your search
@@ -3,10 +3,19 @@
|
||||
## 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)
|
||||
- **Latence live maîtrisée** : rattrapage du direct activé dans mpegts.js (profil rapide) — les micro-coupures ne font plus dériver la lecture derrière le direct
|
||||
- **Fix « Échec du chargement : vendor/mpegts.min.js »** : un échec de chargement d'une lib de lecture n'affiche plus un écran d'erreur définitif. Le chargement est retenté une fois en contournant le cache HTTP (une entrée tronquée condamnait le lecteur jusqu'au vidage manuel du cache), le résultat n'est mémorisé qu'en cas de succès (une promesse rejetée en cache rendait tout réessai impossible) et, si la lib reste introuvable, le live bascule automatiquement sur la route HLS équivalente. Le message affiché précise désormais la cause (HTTP 404, 429, réseau injoignable)
|
||||
- **Préchargement de la bonne lib** : les trois players préchargeaient hls.js en dur, soit 618 Ko téléchargés pour rien à chaque zap TV — où c'est mpegts.js qui sert — au détriment du flux et du chargement de mpegts.js. Le préchargement suit maintenant le flux réellement demandé (et ne charge rien sur Safari/iOS, qui lit le HLS nativement)
|
||||
- **Alerte au démarrage** : le serveur signale explicitement l'absence de `web/vendor/*.min.js` au lancement, au lieu de laisser le navigateur échouer sans explication
|
||||
|
||||
### 🧰 Qualité / Infra
|
||||
- **Builds reproductibles** : `pubspec.lock` (frontend et backend) désormais versionnés et utilisés par le Dockerfile — chaque build résolvait jusqu'ici des versions fraîches
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -259,6 +259,20 @@ void main(List<String> args) async {
|
||||
}),
|
||||
);
|
||||
|
||||
// Les libs de lecture sont servies depuis l'image, plus depuis un CDN : si
|
||||
// elles manquent (build web incomplet, copie Docker partielle), le lecteur
|
||||
// échoue côté navigateur avec un simple « Échec du chargement » sans que
|
||||
// rien ne l'ait signalé au démarrage. On le dit ici, une fois, clairement.
|
||||
for (final lib in const ['hls.min.js', 'mpegts.min.js']) {
|
||||
final file = File('$webPath/vendor/$lib');
|
||||
if (!file.existsSync()) {
|
||||
print(
|
||||
'[Server] ATTENTION: web/vendor/$lib absent de $webPath — '
|
||||
'la lecture échouera dans le navigateur.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create static handler
|
||||
final baseStaticHandler = createStaticHandler(
|
||||
webPath,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+77
-24
@@ -11,7 +11,35 @@
|
||||
et environ 200 Ko de JS jamais téléchargés dans le cas courant.
|
||||
-->
|
||||
<link rel="stylesheet" href="xf-player.css">
|
||||
<link rel="preload" href="vendor/hls.min.js" as="script">
|
||||
<script>
|
||||
// Précharge la SEULE lib utile à CE flux, dès le parse du <head>.
|
||||
// Un préchargement fixe de hls.js faisait télécharger 618 Ko inutiles
|
||||
// à chaque zap TV — la lib réellement nécessaire au direct est
|
||||
// mpegts.js — et cette bande passante était prise sur le flux
|
||||
// lui-même et sur mpegts.js, jusqu'à faire échouer son chargement sur
|
||||
// les liens contraints. Même décision que XFPlayer.start().
|
||||
(function () {
|
||||
var p = new URLSearchParams(location.search);
|
||||
var url = (p.get('url') || '').toLowerCase();
|
||||
var isHls = url.indexOf('.m3u8') !== -1;
|
||||
var nativeHls =
|
||||
document.createElement('video')
|
||||
.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
var lib = null;
|
||||
if (isHls) {
|
||||
// Safari/iOS lit le HLS nativement : aucune lib à charger.
|
||||
if (!nativeHls) lib = 'hls.min.js';
|
||||
} else if (p.get('type') === 'live' || url.indexOf('.ts') !== -1) {
|
||||
lib = 'mpegts.min.js';
|
||||
}
|
||||
if (!lib) return;
|
||||
var l = document.createElement('link');
|
||||
l.rel = 'preload';
|
||||
l.as = 'script';
|
||||
l.href = 'vendor/' + lib;
|
||||
document.head.appendChild(l);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -68,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>
|
||||
|
||||
+29
-1
@@ -5,7 +5,35 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>XtremFlow Player Lite</title>
|
||||
<link rel="stylesheet" href="xf-player.css">
|
||||
<link rel="preload" href="vendor/hls.min.js" as="script">
|
||||
<script>
|
||||
// Précharge la SEULE lib utile à CE flux, dès le parse du <head>.
|
||||
// Un préchargement fixe de hls.js faisait télécharger 618 Ko inutiles
|
||||
// à chaque zap TV — la lib réellement nécessaire au direct est
|
||||
// mpegts.js — et cette bande passante était prise sur le flux
|
||||
// lui-même et sur mpegts.js, jusqu'à faire échouer son chargement sur
|
||||
// les liens contraints. Même décision que XFPlayer.start().
|
||||
(function () {
|
||||
var p = new URLSearchParams(location.search);
|
||||
var url = (p.get('url') || '').toLowerCase();
|
||||
var isHls = url.indexOf('.m3u8') !== -1;
|
||||
var nativeHls =
|
||||
document.createElement('video')
|
||||
.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
var lib = null;
|
||||
if (isHls) {
|
||||
// Safari/iOS lit le HLS nativement : aucune lib à charger.
|
||||
if (!nativeHls) lib = 'hls.min.js';
|
||||
} else if (p.get('type') === 'live' || url.indexOf('.ts') !== -1) {
|
||||
lib = 'mpegts.min.js';
|
||||
}
|
||||
if (!lib) return;
|
||||
var l = document.createElement('link');
|
||||
l.rel = 'preload';
|
||||
l.as = 'script';
|
||||
l.href = 'vendor/' + lib;
|
||||
document.head.appendChild(l);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
+29
-1
@@ -8,7 +8,35 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<title>XtremFlow Player Mobile</title>
|
||||
<link rel="stylesheet" href="xf-player.css">
|
||||
<link rel="preload" href="vendor/hls.min.js" as="script">
|
||||
<script>
|
||||
// Précharge la SEULE lib utile à CE flux, dès le parse du <head>.
|
||||
// Un préchargement fixe de hls.js faisait télécharger 618 Ko inutiles
|
||||
// à chaque zap TV — la lib réellement nécessaire au direct est
|
||||
// mpegts.js — et cette bande passante était prise sur le flux
|
||||
// lui-même et sur mpegts.js, jusqu'à faire échouer son chargement sur
|
||||
// les liens contraints. Même décision que XFPlayer.start().
|
||||
(function () {
|
||||
var p = new URLSearchParams(location.search);
|
||||
var url = (p.get('url') || '').toLowerCase();
|
||||
var isHls = url.indexOf('.m3u8') !== -1;
|
||||
var nativeHls =
|
||||
document.createElement('video')
|
||||
.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
var lib = null;
|
||||
if (isHls) {
|
||||
// Safari/iOS lit le HLS nativement : aucune lib à charger.
|
||||
if (!nativeHls) lib = 'hls.min.js';
|
||||
} else if (p.get('type') === 'live' || url.indexOf('.ts') !== -1) {
|
||||
lib = 'mpegts.min.js';
|
||||
}
|
||||
if (!lib) return;
|
||||
var l = document.createElement('link');
|
||||
l.rel = 'preload';
|
||||
l.as = 'script';
|
||||
l.href = 'vendor/' + lib;
|
||||
document.head.appendChild(l);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
+245
-32
@@ -60,17 +60,67 @@
|
||||
// ------------------------------------------------------------------
|
||||
var _scriptCache = {};
|
||||
|
||||
function loadScript(src) {
|
||||
if (_scriptCache[src]) return _scriptCache[src];
|
||||
_scriptCache[src] = new Promise(function (resolve, reject) {
|
||||
function _delay(ms) {
|
||||
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function _injectScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = resolve;
|
||||
s.onerror = function () { reject(new Error('Échec du chargement : ' + src)); };
|
||||
s.onload = function () { resolve(); };
|
||||
s.onerror = function () {
|
||||
// Laisser une balise morte dans le <head> ferait échouer une
|
||||
// éventuelle nouvelle tentative sur le même src.
|
||||
if (s.parentNode) s.parentNode.removeChild(s);
|
||||
reject(new Error('script error'));
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _scriptCache[src];
|
||||
}
|
||||
|
||||
/**
|
||||
* Précise la cause d'un échec pour le message affiché et les logs :
|
||||
* 404 = lib absente de l'image déployée, 429 = quota du rate limiter,
|
||||
* échec réseau = lien coupé. Sans cela, toutes les causes se
|
||||
* ressemblaient à l'écran.
|
||||
*/
|
||||
function _describeFailure(src) {
|
||||
if (typeof fetch !== 'function') return Promise.resolve('');
|
||||
return fetch(src, { credentials: 'same-origin', cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? '' : ' (HTTP ' + r.status + ')'; })
|
||||
.catch(function () { return ' (réseau injoignable)'; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge une lib vendorisée, avec une seconde tentative qui contourne le
|
||||
* cache HTTP : les libs sont servies en `max-age=86400`, donc une entrée
|
||||
* tronquée par une coupure réseau condamnait le lecteur jusqu'au vidage
|
||||
* manuel du cache navigateur. Seuls les succès sont mémorisés — une
|
||||
* promesse rejetée gardée en cache rendait tout réessai impossible pour
|
||||
* le reste de la session.
|
||||
*/
|
||||
function loadScript(src) {
|
||||
if (_scriptCache[src]) return _scriptCache[src];
|
||||
|
||||
var attempt = _injectScript(src)
|
||||
.catch(function () {
|
||||
return _delay(400).then(function () {
|
||||
return _injectScript(
|
||||
src + (src.indexOf('?') === -1 ? '?' : '&') + 'reload=' + Date.now()
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
return _describeFailure(src).then(function (why) {
|
||||
throw new Error('Échec du chargement : ' + src + why);
|
||||
});
|
||||
});
|
||||
|
||||
_scriptCache[src] = attempt;
|
||||
attempt.catch(function () { delete _scriptCache[src]; });
|
||||
return attempt;
|
||||
}
|
||||
|
||||
/** Ouvre la connexion vers l'hôte du flux avant même de connaître l'URL finale. */
|
||||
@@ -111,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;
|
||||
@@ -124,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);
|
||||
};
|
||||
@@ -174,6 +242,8 @@
|
||||
|
||||
XFPlayer.prototype._hlsConfig = function () {
|
||||
var prof = this.profile.hls;
|
||||
var isLive = this.type === 'live';
|
||||
|
||||
return {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
@@ -191,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,
|
||||
@@ -253,6 +347,11 @@
|
||||
}
|
||||
});
|
||||
}).catch(function (err) {
|
||||
// hls.js indisponible : plutôt que d'échouer, tenter les chemins qui
|
||||
// ne demandent aucune lib (HLS natif Safari/iOS, sinon lecture directe
|
||||
// si le navigateur sait décoder le conteneur).
|
||||
self.log(err.message);
|
||||
if (self._canPlayNativeHls()) { self._playDirect(self.url); return; }
|
||||
self.onError(err.message);
|
||||
});
|
||||
};
|
||||
@@ -267,6 +366,24 @@
|
||||
}
|
||||
self._createMpegts(isLive);
|
||||
}).catch(function (err) {
|
||||
// mpegts.js indisponible (build incomplet, coupure réseau, quota du
|
||||
// rate limiter…) : le même flux reste lisible par la route HLS, où
|
||||
// FFmpeg réencode déjà l'audio en AAC. Une seconde de latence en plus
|
||||
// vaut mieux qu'un écran d'erreur sur une chaîne parfaitement lisible.
|
||||
var hlsUrl = self._hlsEquivalent(self.url);
|
||||
self.log(err.message + (hlsUrl ? ' — bascule sur le chemin HLS' : ''));
|
||||
if (hlsUrl) {
|
||||
self.url = hlsUrl;
|
||||
self.onLoading('Ouverture du flux');
|
||||
if (self._canPlayNativeHls()) { self._playDirect(hlsUrl); return; }
|
||||
return self._startHls();
|
||||
}
|
||||
// Aucun équivalent HLS (flux direct fourni par la playlist) : dernier
|
||||
// recours, laisser le navigateur décoder lui-même le conteneur.
|
||||
if (self.video.canPlayType('video/mp2t') !== '') {
|
||||
self._playDirect(self.url);
|
||||
return;
|
||||
}
|
||||
self.onError(err.message);
|
||||
});
|
||||
};
|
||||
@@ -440,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 () {
|
||||
@@ -481,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;
|
||||
@@ -516,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));
|
||||
@@ -533,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