Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5 d6c7184475 Lecture : un échec de chargement de mpegts.js n'est plus fatal
L'écran « Échec du chargement : vendor/mpegts.min.js » était un cul-de-sac :
la moindre requête ratée sur la lib condamnait la chaîne, alors que le flux
restait parfaitement lisible autrement.

- xf-player-core : le chargement des libs est retenté une fois en
  contournant le cache HTTP (les libs sont servies en max-age=86400, une
  entrée tronquée bloquait le lecteur jusqu'au vidage manuel du cache), et
  seul un succès est mémorisé — la promesse rejetée restait en cache et
  rendait tout réessai impossible pour le reste de la session.
- Repli automatique : si mpegts.js reste introuvable, le live bascule sur la
  route HLS équivalente (où FFmpeg réencode déjà l'audio en AAC) ; si hls.js
  reste introuvable, on tente le HLS natif. Plus d'écran d'erreur quand un
  chemin de lecture est encore disponible.
- Message d'erreur exploitable : la cause réelle est affichée (HTTP 404,
  429, réseau injoignable) au lieu d'un échec indifférencié.
- Préchargement corrigé : les trois players préchargeaient hls.js en dur,
  soit 618 Ko téléchargés pour rien à chaque zap TV — c'est mpegts.js qui
  sert au direct — au détriment du flux et du chargement de mpegts.js
  lui-même. Le préchargement suit désormais le flux demandé, avec la même
  décision que XFPlayer.start() (et ne charge rien sur Safari/iOS).
- Serveur : avertissement explicite au démarrage si web/vendor/*.min.js
  manque, au lieu de laisser le navigateur échouer sans explication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FSovdudi3KBwZK3aC42fw
2026-09-09 16:40:31 +00:00
LogiFlow 175cff560c Merge pull request #11 from R0m1k3/claude/lecture-video
Lecture vidéo : son garanti sur toutes les chaînes, zapping et démarrages accélérés
2026-08-28 09:01:05 +02:00
Claude e5079127ab Merge remote-tracking branch 'origin/main' into claude/lecture-video
# Conflicts:
#	CHANGELOG.md
2026-08-28 06:57:42 +00:00
LogiFlow 4e453850ae Merge pull request #10 from R0m1k3/claude/nettoyage-qualite
Qualité : builds reproductibles (lockfiles), CI durcie, publication chaînée, docs remises à jour
2026-08-28 08:57:13 +02:00
Claude 1295bd12ef Merge remote-tracking branch 'origin/main' into claude/nettoyage-qualite
# Conflicts:
#	CHANGELOG.md
2026-08-28 06:54:22 +00:00
LogiFlow dfc8a33916 Merge pull request #9 from R0m1k3/claude/fonctionnalites-frontend
Fonctionnalités : guide TV + Season Passes rebranchés, favoris, reprise de lecture, enregistrements mobile
2026-08-28 08:53:40 +02:00
Claude 0541e1b0e3 Merge remote-tracking branch 'origin/main' into claude/fonctionnalites-frontend 2026-08-28 06:50:43 +00:00
LogiFlow 8ba57a7367 Merge pull request #8 from R0m1k3/claude/securite-backend
Sécurité backend : proxy authentifié, SSRF sur redirections, XFF de confiance, logs redactés
2026-08-28 08:50:35 +02:00
Claude 8bed6cf7b8 Merge remote-tracking branch 'origin/main' into claude/fonctionnalites-frontend
# Conflicts:
#	CHANGELOG.md
2026-08-28 06:48:20 +00:00
Claude 85a8d16542 Lecture vidéo : son garanti sur toutes les chaînes, zapping et démarrages accélérés
Son manquant sur certaines chaînes :
- Nouvelle route GET /api/live/<id>/turbo.ts pour le player web : vidéo
  copiée (-c:v copy), audio TOUJOURS réencodé en AAC. mpegts.js ne démuxe
  que l'AAC/MP3 : les chaînes en AC-3/E-AC-3/MP2 passaient par le proxy
  brut → image sans son, et le fallback HLS côté client ne couvrait pas
  les codecs démuxés mais non décodables par le navigateur (MP2)
- getLiveStreamUrlTs pointe sur la route turbo ; le proxy brut
  /api/live/<id>.ts reste inchangé pour le scheduler d'enregistrement
- FFmpeg turbo tué dès que le client zappe (onCancel) ; stderr redacté
- Le fallback client (_hlsEquivalent) reconnaît le nouveau format d'URL
  et reste en filet de sécurité

Temps de chargement :
- Players vendorisés mis à jour : hls.js 1.6.7 → 1.7.1,
  mpegts.js 1.7.3 → 1.8.2
- Live HLS et turbo : -fflags nobuffer + probesize/analyzeduration 1 Mo
  (sans borne, FFmpeg pouvait sonder plusieurs secondes avant le premier
  segment)
- VOD : probesize 10 Mo → 5 Mo, analyzeduration 5 s → 2 s
- Live 'high' : preset medium → veryfast (zerolatency déjà actif) ;
  lecture d'enregistrement : medium/crf18 → veryfast/crf20 (medium ne
  tenait pas le temps réel en 1080p sur CPU modeste)
- waitForPlaylist : polling 500 ms → 100 ms
- Suppression du cache-buster &v=timestamp qui re-téléchargeait
  player.html à chaque zap ; les .html sont servis en no-cache côté
  serveur pour garder la fraîcheur après déploiement
- mpegts.js : liveBufferLatencyChasing activé en profil « fast » — les
  micro-coupures ne font plus dériver la lecture derrière le direct

Validé : dart analyze (0 issue) + dart test (48/48) sur bin/, syntaxe JS
vérifiée.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 21:17:27 +00:00
Claude 7fa9897d5c Builds reproductibles, CI durcie, docs remises à jour
Lockfiles :
- pubspec.lock et bin/pubspec.lock versionnés (le .gitignore les excluait
  via *.lock ; le Dockerfile résolvait des versions fraîches à chaque
  build, comme son commentaire l'assumait) ; le Dockerfile les COPY
  désormais, .dockerignore ajusté

CI (ci.yml) :
- flutter-version 3.38.4 et Dart 3.13.2 épinglés (channel: stable seul
  fait dériver le SDK et peut casser la CI sans changement du dépôt)
- cache pub activé, concurrency: cancel-in-progress

Publication (docker-publish.yml) :
- Chaîné sur la CI via workflow_run : l'image :latest n'est publiée
  qu'après analyze/test/build verts sur main (l'ancien push: [main]
  tournait en parallèle et pouvait publier une image cassée) ; build du
  head_sha validé par la CI

docker-compose :
- Défaut RECORDINGS_PATH: ./data/recordings au lieu du chemin unRAID
  spécifique à une machine (/mnt/user/Data/Sport)
- Variable TZ (défaut Europe/Paris)

Docs :
- README réécrit : il décrivait une architecture disparue (Hive/
  IndexedDB, hachage SHA-256, dhttpd port 8080, --web-renderer html)
  au lieu de l'état réel (SQLite serveur, bcrypt, binaire natif port
  8089, enregistrements, transcodage, CI)
- DEPLOYMENT_CHECKLIST.md archivé (6 fichiers cités inexistants,
  versions de dépendances fausses)
- docs/archive/README.md : avertissement que ces documents sont
  historiques (plusieurs se déclarent « COMPLETE » à tort)

Non traité ici : l'épinglage de la release FFmpeg du Dockerfile (l'accès
aux releases BtbN est bloqué depuis cet environnement, impossible de
vérifier un tag valide) et la suppression du code mort (reportée après le
merge des PRs #7/#8/#9 pour éviter les conflits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 12:33:36 +00:00
Claude aacfa54604 Rebranche les fonctionnalités codées mais inaccessibles
Onglet Enregistrements :
- TabBar à 3 vues : Guide TV (programmer depuis l'EPG), liste des
  enregistrements, Season Passes. _EpgGuideView et _SeasonPassesView
  (~800 lignes fonctionnelles) n'étaient plus instanciés depuis une
  refonte — programmer depuis le guide et les enregistrements
  récurrents étaient devenus inaccessibles
- Confirmation avant suppression d'un enregistrement (la corbeille
  supprimait immédiatement, sans retour ni annulation)
- Erreur de chargement avec bouton Réessayer

Favoris :
- Bouton cœur sur les tuiles chaînes desktop (overlay) et mobile :
  toggleFavorite() n'était appelé nulle part, le filtre « Favoris »
  affichait toujours une liste vide

Reprise de lecture :
- Films et épisodes lisent playbackPositionsProvider et passent
  startTime au player : les positions étaient écrites mais jamais
  relues, tout repartait de zéro
- Plus de sauvegarde de position en live (une entrée bidon par chaîne
  zappée)

Mobile :
- 5e onglet REC réutilisant RecordingsTab
- IndexedStack au lieu du switch : les onglets conservent scroll et
  catalogues chargés (aligné sur le dashboard desktop)

Dashboard :
- Menu profil sur l'avatar de la sidebar (nom d'utilisateur +
  déconnexion) : le bouton était mort, aucune déconnexion possible
  depuis le dashboard desktop
- Erreur de chargement des chaînes avec bouton Réessayer

Validé : flutter analyze (202 issues, identique à la baseline main,
0 erreur/warning), flutter test (OK), flutter build web --release (OK).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa
2026-08-27 12:29:23 +00:00
31 changed files with 2489 additions and 346 deletions

No files matched your search

-1
View File
@@ -8,7 +8,6 @@
build/
bin/.dart_tool
bin/.packages
bin/pubspec.lock
ios/
android/
windows/
+13 -1
View File
@@ -6,6 +6,12 @@ on:
pull_request:
branches: [main]
# Un push qui en suit un autre annule le run précédent : inutile de
# consommer un runner pour un commit déjà obsolète.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
runs-on: ubuntu-latest
@@ -13,7 +19,13 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
# Version épinglée : `channel: stable` seul fait dériver le SDK au
# fil du temps (une release Flutter peut casser la CI sans aucun
# changement dans le dépôt). Le cache évite de retélécharger SDK et
# dépendances pub à chaque run.
flutter-version: 3.38.4
channel: stable
cache: true
- run: flutter pub get
# Infos (pre-existing withOpacity/dart:html deprecations) are not
# fatal; errors and warnings still fail the build.
@@ -30,7 +42,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
sdk: 3.13.2
- run: dart pub get
- run: dart analyze
- run: dart test
+14 -2
View File
@@ -1,7 +1,12 @@
name: Docker Publish
# Chaîné sur la CI : l'image :latest n'est publiée que si analyze/test/build
# sont verts sur main. L'ancien déclencheur `push: [main]` tournait en
# parallèle de la CI et pouvait publier une image cassée.
on:
push:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
@@ -11,11 +16,18 @@ env:
jobs:
build-and-push:
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
# Sur workflow_run, github.sha pointe sur le commit du workflow
# par défaut : construire exactement le commit validé par la CI.
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: docker/setup-buildx-action@v3
@@ -34,6 +46,6 @@ jobs:
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.IMAGE_NAME }}:${{ github.event.workflow_run.head_sha || github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3
View File
@@ -49,4 +49,7 @@ app.*.map.json
# Hive data (local development)
*.hive
*.lock
# Les lockfiles pub sont versionnés : sans eux, chaque build Docker/CI
# résout des versions différentes (builds non reproductibles).
!pubspec.lock
.claude/settings.local.json
+24
View File
@@ -2,6 +2,30 @@
## Non publié
### ▶️ Lecture vidéo
- **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
- **CI durcie** : versions Flutter/Dart épinglées, cache pub, annulation des runs obsolètes (`concurrency`) ; `docker-publish` ne publie plus `:latest` qu'après une CI verte sur main (il tournait en parallèle et pouvait publier une image cassée)
- **docker-compose** : défaut `RECORDINGS_PATH` porté à `./data/recordings` (l'ancien défaut était un chemin unRAID spécifique à une machine), variable `TZ` ajoutée
- **README réécrit** : il décrivait une architecture disparue (Hive/IndexedDB, SHA-256, dhttpd port 8080) — remplacé par l'état réel (SQLite serveur, bcrypt, binaire natif port 8089, enregistrements, CI)
- `DEPLOYMENT_CHECKLIST.md` archivé et avertissement ajouté sur `docs/archive/` (plusieurs documents s'y déclarent « COMPLETE » à tort)
### ✨ Fonctionnalités
- **Guide TV et Season Passes de retour** : l'onglet Enregistrements retrouve ses 3 vues (Guide TV pour programmer depuis l'EPG, liste des enregistrements, Season Passes) — le code existait mais n'était plus branché depuis une refonte
- **Favoris enfin utilisables** : bouton cœur sur les tuiles chaînes (desktop et mobile) ; le filtre « Favoris » affichait toujours vide faute de moyen d'en ajouter
- **Reprise de lecture** : films et épisodes reprennent où on s'était arrêté (les positions étaient sauvegardées mais jamais relues) ; le live ne pollue plus le stockage de positions
- **Enregistrements sur mobile** : nouvel onglet REC dans la barre de navigation ; les onglets mobiles conservent leur état (IndexedStack) au lieu d'être reconstruits à chaque bascule
- **Menu profil** sur l'avatar de la sidebar : nom d'utilisateur + déconnexion (le bouton était mort, aucune déconnexion possible depuis le dashboard)
- **Confirmation avant suppression** d'un enregistrement, et messages d'erreur avec bouton « Réessayer » (chaînes, enregistrements) au lieu d'exceptions brutes
### 🔧 Fiabilité des enregistrements
- **Fuseaux horaires unifiés** : le backend exige des dates ISO-8601 avec fuseau (400 sinon) et stocke tout en UTC ; le frontend passe par un helper unique `postRecording()` — fini les enregistrements décalés de 1-2 h selon l'écran utilisé
- **Contrôle de propriété** : stop/suppression/logs d'un enregistrement et suppression d'un season pass ne sont plus possibles que par leur propriétaire (ou un admin)
+4 -3
View File
@@ -12,9 +12,10 @@ ENV FLUTTER_NO_ANALYTICS=1
RUN flutter config --enable-web && flutter precache --web
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
COPY pubspec.yaml ./
COPY bin/pubspec.yaml ./bin/
# Note: No .lock files found locally, so we fetch fresh ones here
# Les lockfiles sont versionnés : le build résout exactement les versions
# committées au lieu d'en chercher de nouvelles à chaque build.
COPY pubspec.yaml pubspec.lock ./
COPY bin/pubspec.yaml bin/pubspec.lock ./bin/
RUN flutter pub get && cd bin && dart pub get
# 3. Source Code Copy (CHANGES OFTEN)
+76 -255
View File
@@ -1,282 +1,103 @@
# XtremFlow - IPTV Web Application
# XtremFlow — Application Web IPTV
High-performance, containerized IPTV Web Application using Flutter Web and Xtream Codes API.
Application IPTV auto-hébergée : frontend Flutter Web + serveur Dart natif, empaquetés dans une seule image Docker. Se connecte à un abonnement Xtream Codes et ajoute le magnétoscope (enregistrements planifiés, season passes), l'EPG avec repli XMLTV, et le transcodage FFmpeg à la demande.
## Features
## Fonctionnalités
✅ **Local Authentication System**
- Default admin user (`admin`/`admin`)
- Secure salt-based password hashing (SHA-256)
- No public signup - private app only
- **Live TV, Films, Séries** : catalogues Xtream avec catégories, recherche, favoris, reprise de lecture
- **Guide TV (EPG)** : panneau de l'abonné en priorité, repli automatique sur un dump XMLTV quand le panneau est figé
- **Enregistrements TV** : planification depuis le guide, capture FFmpeg (`-c copy`), reprise après coupure amont ou redémarrage du serveur, fusion automatique des parties
- **Season Passes** : enregistrement automatique de toutes les diffusions d'une émission (scan EPG toutes les 4 h)
- **Transcodage à la demande** : `source | high | medium | low` (live et VOD), `source` = zéro transcodage ; NVENC optionnel
- **Multi-utilisateurs** : comptes locaux (bcrypt), playlists par utilisateur, panneau d'administration
✅ **Multi-Playlist Management**
- Centralized Xtream credentials management
- Playlist assignment to users
- Easy switching between playlists
## Stack
✅ **High-Performance Dashboard (60fps)**
- Category-based pagination (100 items/page for Live TV, 50 for Movies)
- Lazy loading with `ListView.builder` / `GridView.builder`
- Image caching with `cached_network_image`
| Couche | Techno |
|---|---|
| Frontend | Flutter Web (Riverpod, GoRouter), players HTML (hls.js / mpegts.js vendorisés) |
| Backend | Dart compilé en natif (`dart compile exe`), shelf |
| Base | SQLite côté serveur (`/app/data/xtremflow.db`) |
| Capture/Transcodage | FFmpeg (build BtbN, NVENC inclus) |
| Conteneur | Debian slim multi-stage, port **8089** |
✅ **Live TV with EPG**
- Electronic Program Guide (EPG) overlay
- "Now & Next" program display
- Real-time progress bar
✅ **VOD & Series**
- Movies and Series organized by categories
- Grid layout with posters
- Optimized ratings display (1 decimal place)
✅ **Docker Deployment**
- Multi-stage build with Flutter and Dart
- Custom Dart Server (`bin/server.dart`)
- **FFmpeg Transcoding** for mobile compatibility
- **Cache Management** system for temporary files
- External network support (`nginx_default`)
## Tech Stack
- **Framework**: Flutter Web
- **State Management**: Riverpod
- **Local Database**: Hive (Web IndexedDB) with AES encryption
- **Networking**: Dio with cache interceptors
- **Routing**: GoRouter with auth guards
- **Video Player**: `video_player` + `chewie`
- **UI**: Google Fonts, Material Design 3
## Prerequisites
- Docker & Docker Compose
- Existing `nginx_default` network (for reverse proxy routing)
- Flutter SDK (for local development only)
## Quick Start (Docker)
### 1. Build the Docker image
## Démarrage rapide (Docker)
```bash
docker-compose build
docker-compose up -d --build
# Application sur http://localhost:8089
```
### 2. Start the container
```bash
docker-compose up -d
```
### 3. Access via reverse proxy
Configure your reverse proxy (Nginx/Traefik) to route traffic to:
- **Container**: `xtremflow`
- **Internal Port**: `8080`
- **Network**: `nginx_default`
Example Nginx configuration:
```nginx
location /iptv {
proxy_pass http://xtremflow:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
### 4. Login
- **URL**: `http://your-domain/iptv`
- **Default Credentials**:
- Username: `admin`
- Password: `admin`
⚠️ **Change the admin password immediately after first login!**
## Local Development
### Install dependencies
Au premier démarrage, un compte `admin` est créé avec un **mot de passe aléatoire affiché une seule fois dans les logs** (`docker logs xtremflow`), sauf si `ADMIN_INITIAL_PASSWORD` est défini. Changez-le après la première connexion.
### Variables d'environnement (docker-compose.yml)
| Variable | Défaut | Rôle |
|---|---|---|
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
| `EPG_XMLTV_URLS` | dump FR | Sources XMLTV de repli (vide = aucun appel sortant) |
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
| `ADMIN_INITIAL_PASSWORD` | *(généré)* | Mot de passe initial du compte admin |
| `MIN_FREE_DISK_MB` | `500` | Espace libre minimal pour démarrer une capture |
| `RECORDINGS_QUOTA_GB` | `0` (off) | Quota du dossier d'enregistrements (rotation des plus anciens terminés) |
| `TRUSTED_PROXIES` | loopback + RFC1918 | IPs de reverse proxy dont `X-Forwarded-For` est honoré |
## Développement local
```bash
# Frontend
flutter pub get
flutter analyze && flutter test
flutter run -d chrome # nécessite le backend lancé pour les routes /api
# Backend
cd bin
dart pub get
dart analyze && dart test
dart run server.dart --port 8089 --path ../build/web
```
### Generate Hive adapters (if modified)
Le build Windows natif est documenté dans `BUILD.md`.
```bash
flutter pub run build_runner build --delete-conflicting-outputs
```
### Run web app
```bash
flutter run -d chrome
```
## Project Structure
## Structure du projet
```
lib/
├── core/
│ ├── database/
│ │ └── hive_service.dart # Hive initialization & encryption
│ ├── models/
│ │ ├── app_user.dart # User model (Hive)
│ │ ├── playlist_config.dart # Playlist credentials (Hive)
│ │ └── iptv_models.dart # Channel, VOD, Series, EPG models
│ ├── router/
│ │ └── app_router.dart # GoRouter configuration
│ └── utils/
│ └── crypto_utils.dart # Password hashing utilities
├── features/
│ ├── auth/
│ │ ├── providers/
│ │ │ └── auth_provider.dart # Authentication state
│ │ └── screens/
│ │ └── login_screen.dart
│ ├── admin/
│ │ └── screens/
│ │ └── admin_panel.dart # User & Playlist CRUD
│ └── iptv/
│ ├── services/
│ │ └── xtream_service.dart # Xtream API client
│ ├── providers/
│ │ └── xtream_provider.dart # Riverpod providers
│ ├── screens/
│ │ └── player_screen.dart # Video player
│ └── widgets/
│ ├── live_tv_tab.dart # Live TV with pagination
│ ├── movies_tab.dart # Movies grid
│ ├── series_tab.dart # Series grid
│ └── epg_overlay.dart # EPG display
└── main.dart
bin/ Serveur Dart
├── server.dart Point d'entrée, routage, arrêt gracieux
├── api/ Handlers HTTP (auth, playlists, recordings, EPG, proxy…)
├── services/ Scheduler d'enregistrement, sessions FFmpeg, XMLTV
├── database/ SQLite (schéma, migrations)
├── middleware/ Auth (session), sécurité (rate limit, honeypot, logs redactés)
└── test/ Tests backend (dart test)
lib/ Frontend Flutter
├── core/ Modèles, thème, router, clients API
├── features/ auth / admin / iptv (desktop)
└── mobile/ Variantes d'écrans mobiles
web/ Players HTML + libs vendorisées (hls.js, mpegts.js)
```
## Security Features
## Sécurité
### Password Storage
- **Algorithm**: SHA-256 with random UUID-based salt
- **Format**: `salt:hash` (stored in Hive)
- **Legacy Support**: Fallback to unsalted comparison for migration
- Mots de passe **bcrypt** (migration lazy depuis les anciens hashes au login)
- Les credentials Xtream ne quittent jamais le serveur : passerelle `/api/xtream-api` et proxy `/api/xtream` (authentifié par session) avec injection côté serveur
- Redaction des credentials dans tous les logs ; anti-SSRF avec revalidation des redirections
- Cookie de session HttpOnly ; rate limiting global + limite de tentatives de login par IP
### Database Encryption
- **Hive AES Cipher** (256-bit key)
- Key stored in `FlutterSecureStorage`
- Automatic key generation on first run
## CI / Publication
### Authentication Flow
1. User enters credentials
2. System retrieves stored hash
3. Input password is hashed with same salt
4. Constant-time comparison prevents timing attacks
`ci.yml` (analyze + tests + build web/backend) tourne sur chaque PR et push main ; `docker-publish.yml` publie `ghcr.io/r0m1k3/xtremflow:latest` **uniquement après une CI verte** sur main.
## Performance Optimizations
## Dépannage
### Memory Management (20k+ channels)
- **Grouping**: Channels organized by category
- **Pagination**: 100 items per page (Live TV), 50 per page (Movies)
- **Lazy Loading**: Only render visible items
- **Image Caching**: Disk/memory cache with `cached_network_image`
- **Logs** : `docker logs xtremflow` (les URLs y sont redactées)
- **Mot de passe admin perdu** : supprimer le volume `xtremflow-data` recrée la base et affiche un nouveau mot de passe (⚠ efface utilisateurs et historique d'enregistrements)
- **EPG vide** : vérifier `EPG_XMLTV_URLS` et que la chaîne a un `epg_channel_id` ; l'en-tête `X-Epg-Source` des réponses `/api/epg/<id>` indique la source utilisée
- **Enregistrement échoué** : bouton « Logs » sur la ligne de l'enregistrement ; la raison (`error_reason`) est affichée sous le titre
### Network Optimization
- **Dio Cache Interceptor**: 1-hour cache for API responses
- **EPG Cache**: 5-minute refresh for program data
- **Hive Disk Store**: Persistent cache across sessions
## Licence
### Rendering (60fps Target)
- `ListView.builder` with fixed `itemExtent`
- `AutomaticKeepAliveClientMixin` for tab state
- Expansion panels for category navigation
- Grid with fixed `crossAxisCount` and `childAspectRatio`
## Xtream API Integration
### Supported Endpoints
| Endpoint | Purpose | Caching |
|----------|---------|---------|
| `player_api.php` | Authentication | 1 hour |
| `get_live_streams` | Live TV channels | 1 hour |
| `get_vod_streams` | Movies | 1 hour |
| `get_series` | Series | 1 hour |
| `get_short_epg` | EPG data | 5 minutes |
### Stream URL Formats
```dart
// Live TV
http://[dns]/live/[username]/[password]/[stream_id].m3u8
// Movies
http://[dns]/movie/[username]/[password]/[stream_id].[container_extension]
// Series
http://[dns]/series/[username]/[password]/[stream_id].[container_extension]
```
## Docker Configuration
### Dockerfile (Multi-Stage)
**Stage 1: Builder**
- Base: `cirrusci/flutter:stable`
- Build: `flutter build web --release --web-renderer html`
**Stage 2: Runtime**
- Base: `dart:stable`
- Server: `dhttpd --host 0.0.0.0 --port 8080`
- Size: ~150MB (compressed)
### docker-compose.yml
```yaml
services:
iptv-web:
build: .
container_name: xtremflow
restart: unless-stopped
networks:
- nginx_default
networks:
nginx_default:
external: true
```
**No port mapping** - Access via reverse proxy only.
## Troubleshooting
### Container won't start
```bash
# Check logs
docker logs xtremflow
# Verify network exists
docker network ls | grep nginx_default
# Create network if missing
docker network create nginx_default
```
### Login fails with admin/admin
- Check Hive database initialization in logs
- Verify `HiveService.init()` completed successfully
- Default admin is seeded only if `users` box is empty
### EPG not displaying
- EPG is optional and gracefully degrades
- Check if Xtream server supports `get_short_epg`
- Verify stream has `epg_channel_id`
### Performance issues (FPS drops)
- Reduce `_itemsPerPage` constant (currently 100 for Live TV)
- Disable image caching temporarily
- Check browser DevTools Performance tab
## License
Proprietary - Private Use Only
## Support
For Xtream API documentation, consult your IPTV provider.
Propriétaire — usage privé uniquement.
+99 -4
View File
@@ -94,7 +94,11 @@ List<String> _liveVideoArgs(String quality, bool gpu) {
'-g', '50',
]
: [
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency',
// veryfast au lieu de medium : en live, l'encodeur doit tenir
// le temps réel ET produire le premier segment vite ; medium
// ajoutait plusieurs secondes au zap sans gain visible à ce
// débit.
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-profile:v', 'high', '-level', '4.0',
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
'-pix_fmt', 'yuv420p', '-g', '50',
@@ -318,6 +322,11 @@ Handler createLiveStreamHandler(
// Abort reads stuck for 30s so a stalled upstream triggers the
// reconnect logic instead of wedging the process forever.
'-rw_timeout', '30000000',
// Démarrage rapide : sans borne, FFmpeg peut passer plusieurs
// secondes à sonder le flux avant d'écrire le premier segment.
'-fflags', 'nobuffer',
'-probesize', '1000000',
'-analyzeduration', '1000000',
'-i', targetUrl,
..._liveVideoArgs(quality, useNvidiaGpu),
..._audioArgs(withFilters: false),
@@ -364,6 +373,85 @@ Handler createLiveStreamHandler(
return Response.found('/api/live/$streamId/$quality/playlist.m3u8');
});
// Route: /api/live/{streamId}/turbo.ts — flux MPEG-TS continu pour le
// player web (mpegts.js) : vidéo copiée telle quelle, audio TOUJOURS
// réencodé en AAC.
//
// POURQUOI : mpegts.js ne démuxe que l'AAC et le MP3. Les chaînes qui
// diffusent en AC-3/E-AC-3 (0x81/0x87) ou en MPEG-1 Layer II passaient par
// le proxy brut → image sans aucun son. Le réencodage audio seul coûte
// quelques % de CPU (pas de transcodage vidéo) et garde le zapping
// instantané : un seul flux continu, pas de segmentation HLS à amorcer.
//
// Latence : -fflags nobuffer + probesize réduit → première image en
// ~0,5-1,5 s au lieu des 2-6 s d'analyse par défaut de FFmpeg.
router.get('/<streamId>/turbo.ts', (Request request, String streamId) async {
if (!_isValidStreamId(streamId)) {
return Response.badRequest(body: 'Invalid stream ID');
}
final playlist = await getPlaylist(request);
if (playlist == null) return Response.forbidden('No playlist');
final targetUrl =
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
print(
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
);
final process = await Process.start(_getFFmpegPath(), [
'-hide_banner', '-loglevel', 'warning',
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
'-reconnect', '1', '-reconnect_streamed', '1',
'-reconnect_at_eof', '1',
'-reconnect_delay_max', '10',
'-rw_timeout', '30000000',
// Démarrage rapide : ne pas bufferiser l'analyse, sonde réduite.
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-probesize', '1000000',
'-analyzeduration', '1000000',
'-i', targetUrl,
'-c:v', 'copy',
'-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000',
'-af', 'aresample=async=1',
// Pas de délai de mux : les paquets partent dès qu'ils existent.
'-muxdelay', '0', '-muxpreload', '0',
'-f', 'mpegts', 'pipe:1',
]);
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
final trimmed = line.trim();
if (trimmed.isNotEmpty) {
print('[Live Turbo] $streamId ffmpeg: '
'${LogRedactor.redactUrl(trimmed)}');
}
});
// Relayer stdout vers le client ; tuer FFmpeg dès que le client zappe
// ou ferme l'onglet (sinon les processus s'accumulent à chaque zap).
final controller = StreamController<List<int>>();
final subscription = process.stdout.listen(
controller.add,
onError: controller.addError,
onDone: controller.close,
);
controller.onCancel = () {
subscription.cancel();
process.kill(ProcessSignal.sigterm);
};
return Response(
200,
body: controller.stream,
headers: {
'Content-Type': 'video/mp2t',
'Cache-Control': 'no-store',
'Connection': 'keep-alive',
},
);
});
// Route: /api/live/{streamId}.ts (Direct Proxy for recordings/raw playback)
router.get('/<streamId>.ts', (Request request, String streamId) async {
final playlist = await getPlaylist(request);
@@ -464,8 +552,11 @@ Handler createVodStreamHandler(
'-reconnect_delay_max', '10',
'-rw_timeout', '30000000',
'-timeout', '30000000',
'-analyzeduration', '5000000',
'-probesize', '10000000',
// Sonde réduite (10 Mo → 5 Mo) : sur un upstream lent, télécharger
// 10 Mo avant la première image ajoutait plusieurs secondes au
// démarrage de chaque film.
'-analyzeduration', '2000000',
'-probesize', '5000000',
'-i', targetUrl,
..._vodVideoArgs(quality, useNvidiaGpu),
..._audioArgs(withFilters: quality != 'source'),
@@ -586,7 +677,11 @@ Handler createRecordingStreamHandler(
'-bufsize', '6000k',
'-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p',
] else ...[
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
// veryfast/crf 20 au lieu de medium/crf 18 : à ce niveau le rendu
// est visuellement identique, mais medium ne tenait pas le temps
// réel en 1080p sur un CPU modeste → lecture d'enregistrement qui
// démarre lentement puis bufferise.
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
'-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p',
'-g', '48', '-threads', '0',
],
+477
View File
@@ -0,0 +1,477 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19"
url: "https://pub.dev"
source: hosted
version: "105.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9"
url: "https://pub.dev"
source: hosted
version: "14.1.0"
args:
dependency: "direct main"
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
bcrypt:
dependency: "direct main"
description:
name: bcrypt
sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.dev"
source: hosted
version: "1.15.1"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
equatable:
dependency: "direct main"
description:
name: equatable
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hive:
dependency: "direct main"
description:
name: hive
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_methods:
dependency: transitive
description:
name: http_methods
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shelf:
dependency: "direct main"
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_router:
dependency: "direct main"
description:
name: shelf_router
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
url: "https://pub.dev"
source: hosted
version: "1.1.4"
shelf_static:
dependency: "direct main"
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898"
url: "https://pub.dev"
source: hosted
version: "0.10.14"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqlite3:
dependency: "direct main"
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
url: "https://pub.dev"
source: hosted
version: "1.31.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
test_core:
dependency: transitive
description:
name: test_core
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
url: "https://pub.dev"
source: hosted
version: "0.6.19"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
xml:
dependency: "direct main"
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
+15 -1
View File
@@ -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,
@@ -277,7 +291,7 @@ void main(List<String> args) async {
final isVendored = path.startsWith('vendor/');
if (!isVendored &&
(path.isEmpty ||
path == 'index.html' ||
path.endsWith('.html') ||
path.endsWith('.js') ||
path.endsWith('.json'))) {
return response.change(
+3 -1
View File
@@ -165,7 +165,9 @@ class FfmpegSessionManager {
'${session.recentStderr.join().trim()}'
);
}
await Future.delayed(const Duration(milliseconds: 500));
// 100 ms : à 500 ms, on ajoutait en moyenne un quart de seconde de
// latence pure entre la disponibilité de la playlist et sa réponse.
await Future.delayed(const Duration(milliseconds: 100));
}
return (ready: false, error: 'Timeout waiting for transcoder');
}
+4 -1
View File
@@ -13,8 +13,11 @@ services:
# Pour changer: modifiez le chemin avant les deux points ":"
# Exemple Windows : - D:\MesVideos\TV:/app/recordings
# Exemple Linux : - /mnt/nas/videos:/app/recordings
- ${RECORDINGS_PATH:-/mnt/user/Data/Sport}:/app/recordings
- ${RECORDINGS_PATH:-./data/recordings}:/app/recordings
environment:
# Fuseau horaire du conteneur (affichage des logs ; les enregistrements
# sont stockés en UTC quoi qu'il arrive)
- TZ=${TZ:-Europe/Paris}
# Origine externe autorisée pour CORS (optionnel — l'app est servie
# same-origin, ne définir que si un autre domaine doit appeler l'API)
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-}
File renamed without changes.
+12
View File
@@ -0,0 +1,12 @@
# Archives — documents historiques
⚠️ **Les documents de ce dossier sont historiques et ne décrivent pas l'état actuel du code.**
Ils datent de phases de conception ou de refontes antérieures. Plusieurs se déclarent « COMPLETE » ou « production-ready » alors que le travail décrit n'a jamais été intégré (ou a été remplacé depuis) :
- `RECORDING_SYSTEM_UPGRADE.txt` — décrit un remplacement du scheduler par `SimpleRecorder` qui n'a jamais été branché ; le système réel est `bin/services/recording_scheduler.dart`.
- `THEME_REDESIGN_SUMMARY.md` — la « Phase 4: Integration » n'a pas eu lieu ; les widgets qu'il liste n'existent plus.
- `ANALYSIS_AND_IMPROVEMENTS.md` — plan d'améliorations dont une partie seulement a été réalisée.
- `DEPLOYMENT_CHECKLIST.md` — référence des fichiers et versions de dépendances qui n'ont jamais existé dans le dépôt.
Pour l'état actuel du projet, se référer au `README.md` racine et au `CHANGELOG.md`.
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_provider.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart';
@@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Spacer(),
// Settings / Profile
// Profil : nom d'utilisateur + déconnexion. L'avatar était
// un bouton mort (onTap vide) — aucune déconnexion possible
// depuis le dashboard desktop.
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: TvFocusableCard(
onTap: () {},
borderRadius: 50,
scaleFactor: 1.1,
child: PopupMenuButton<String>(
tooltip: 'Profil',
color: AppColors.surfaceContainerHigh,
offset: const Offset(56, -12),
itemBuilder: (context) => [
PopupMenuItem<String>(
enabled: false,
child: Row(
children: [
const Icon(
Icons.person,
size: 18,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Text(
ref.read(authProvider).currentUser?.username ??
'Utilisateur',
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
),
),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(
Icons.logout,
size: 18,
color: AppColors.textSecondary,
),
SizedBox(width: 8),
Text(
'Déconnexion',
style: TextStyle(color: AppColors.onSurface),
),
],
),
),
],
onSelected: (value) {
if (value == 'logout') {
ref.read(authProvider.notifier).logout();
}
},
child: const CircleAvatar(
radius: 20,
backgroundColor: AppColors.surfaceContainerHigh,
+8 -7
View File
@@ -190,18 +190,17 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// - Live TV: Player Lite (simple TS playback with mpegts.js)
// - VOD/Series: Player Standard (fuller controls, HLS support)
// Cache buster to force reload of updated player.html
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
final streamTypeParam =
widget.streamType == StreamType.live ? 'live' : 'vod';
final playerFile = isMobile
? 'player_mobile.html'
: (isLiveTV ? 'player_lite.html' : 'player.html');
// Inject Turbo-Start flag
// Plus de cache-buster « &v=timestamp » : il forçait le re-téléchargement
// et le re-parse de player.html à CHAQUE zap. Le serveur sert désormais
// les .html en no-cache, la fraîcheur est garantie sans pénaliser le zap.
var playerSrc =
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true';
if (widget.streamType == StreamType.recording) {
playerSrc += '&is_recording=true';
@@ -271,8 +270,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
});
}
// Update watch history if relevant
if (currentTime > 0) {
// 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,
currentTime,
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/playback_positions_provider.dart';
import '../providers/watch_history_provider.dart';
import '../../../core/theme/app_colors.dart';
import 'player_screen.dart';
@@ -287,7 +288,14 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
}
if (!context.mounted) return;
// Reprise de lecture : le player sauvegarde la position sous
// episode.id, on la relit ici pour reprendre où on s'était arrêté.
final positions = ref.read(playbackPositionsProvider);
final resumeAt = positions.hasPosition(episode.id)
? positions.getPosition(episode.id)
: null;
Navigator.push(
context,
MaterialPageRoute(
@@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
duration: episodeDuration,
startTime: resumeAt,
),
),
);
@@ -176,8 +176,11 @@ class XtreamService {
/// Faster zapping on compatible players (mpegts.js)
String getLiveStreamUrlTs(String streamId) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
// Backend route injects the Xtream credentials server-side
return '$_backendBaseUrl/api/live/$streamId.ts';
// Route « turbo » : vidéo copiée, audio réencodé en AAC côté serveur.
// Le proxy brut ($_backendBaseUrl/api/live/$streamId.ts) livrait l'audio
// d'origine — AC-3/E-AC-3/MP2 que mpegts.js ne décode pas → chaînes
// muettes. Il reste utilisé tel quel par le scheduler d'enregistrement.
return '$_backendBaseUrl/api/live/$streamId/turbo.ts';
}
/// Generate stream URL for VOD (movies)
+54 -2
View File
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
body: channelsAsync.when(
loading: () => const ThemedLoading(),
error: (e, s) => Center(
child: Text('Error: $e',
style: const TextStyle(color: AppColors.onSurface)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Impossible de charger les chaînes',
style: TextStyle(color: AppColors.onSurface),
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Réessayer'),
onPressed: () => ref.invalidate(
liveChannelsByPlaylistProvider(widget.playlist),
),
),
],
),
),
data: (groupedChannels) {
var categories = groupedChannels.keys.toList();
@@ -515,6 +530,43 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
),
],
),
// Favori (Positioned top left) — toggleFavorite n'était appelé
// nulle part : le filtre « Favoris » affichait toujours vide.
Positioned(
top: 8,
left: 8,
child: Consumer(
builder: (context, ref, _) {
final isFav = ref
.watch(favoritesProvider)
.contains(channel.streamId);
return TvFocusableCard(
onTap: () => ref
.read(favoritesProvider.notifier)
.toggleFavorite(channel.streamId),
borderRadius: 20,
scaleFactor: 1.2,
semanticLabel: isFav
? 'Retirer ${channel.name} des favoris'
: 'Ajouter ${channel.name} aux favoris',
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav
? AppColors.primary
: AppColors.onSurface54,
size: 16,
),
),
);
},
),
),
// Record Button Icon Overlay (Positioned top right)
Positioned(
top: 8,
@@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/hero_carousel.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../providers/playback_positions_provider.dart';
import '../providers/watch_history_provider.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
@@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
if (!mounted) return;
// Reprise de lecture : les positions étaient sauvegardées par le player
// mais jamais relues — le film repartait systématiquement de zéro.
final positions = ref.read(playbackPositionsProvider);
final resumeAt = positions.hasPosition(movie.streamId)
? positions.getPosition(movie.streamId)
: null;
Navigator.push(
context,
MaterialPageRoute(
@@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
duration: movieDuration,
startTime: resumeAt,
),
),
);
+119 -19
View File
@@ -15,6 +15,10 @@ import '../screens/player_screen.dart';
// ═══════════════════════════════════════════════════════════════════════════
// ENTRÉE — Onglet "Enregistrements"
// Trois vues : Guide TV (programmer depuis l'EPG), Enregistrements (liste),
// Season Passes (enregistrements récurrents). _EpgGuideView et
// _SeasonPassesView existaient déjà mais n'étaient plus instanciés depuis
// une refonte : les fonctions étaient codées mais inaccessibles.
// ═══════════════════════════════════════════════════════════════════════════
class RecordingsTab extends StatefulWidget {
@@ -25,7 +29,24 @@ class RecordingsTab extends StatefulWidget {
State<RecordingsTab> createState() => _RecordingsTabState();
}
class _RecordingsTabState extends State<RecordingsTab> {
class _RecordingsTabState extends State<RecordingsTab>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
// Ouvrir sur la liste des enregistrements (onglet du milieu), l'usage le
// plus fréquent ; le guide sert à en programmer de nouveaux.
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
@@ -33,25 +54,59 @@ class _RecordingsTabState extends State<RecordingsTab> {
child: Column(
children: [
Container(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
color: Colors.grey[900],
child: const Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
SizedBox(width: 12),
Text(
'Enregistrements',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: AppColors.onSurface,
),
const Row(
children: [
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
SizedBox(width: 12),
Text(
'Enregistrements',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: AppColors.onSurface,
),
),
],
),
const SizedBox(height: 8),
TabBar(
controller: _tabController,
isScrollable: true,
indicatorColor: AppColors.primary,
labelColor: AppColors.onSurface,
unselectedLabelColor: AppColors.onSurface54,
tabs: const [
Tab(
icon: Icon(Icons.calendar_month, size: 18),
text: 'Guide TV',
),
Tab(
icon: Icon(Icons.fiber_manual_record, size: 18),
text: 'Enregistrements',
),
Tab(
icon: Icon(Icons.repeat, size: 18),
text: 'Season Passes',
),
],
),
],
),
),
Expanded(
child: _RecordingsListView(playlist: widget.playlist),
child: TabBarView(
controller: _tabController,
children: [
_EpgGuideView(playlist: widget.playlist),
_RecordingsListView(playlist: widget.playlist),
const _SeasonPassesView(),
],
),
),
],
),
@@ -825,7 +880,39 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
}
}
Future<void> _deleteRecording(String id) async {
/// Demande confirmation avant suppression : l'ancienne corbeille supprimait
/// immédiatement, sans retour ni possibilité d'annuler.
Future<void> _deleteRecording(String id, String title) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surfaceContainer,
title: Text(
'Supprimer l\'enregistrement ?',
style: GoogleFonts.fraunces(color: AppColors.onSurface, fontSize: 18),
),
content: Text(
'« $title » et son fichier seront définitivement supprimés.',
style: const TextStyle(color: AppColors.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.errorContainer,
foregroundColor: AppColors.onErrorContainer,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed != true) return;
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
_fetchRecordings();
}
@@ -986,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Text(
_error ?? 'Erreur',
style: const TextStyle(color: AppColors.live),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Impossible de charger les enregistrements',
style: TextStyle(color: AppColors.live),
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Réessayer'),
onPressed: _fetchRecordings,
),
],
),
)
: _recordings.isEmpty
@@ -1157,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
size: 20,
),
tooltip: 'Supprimer',
onPressed: () =>
_deleteRecording(rec['id']),
onPressed: () => _deleteRecording(
rec['id'],
rec['title'] ?? '',
),
),
],
),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../widgets/mobile_scaffold.dart';
import '../../../theme/mobile_theme.dart';
import '../../../../features/iptv/widgets/recordings_tab.dart';
import '../widgets/mobile_live_tv_tab.dart';
import '../widgets/mobile_movies_tab.dart';
import '../widgets/mobile_series_tab.dart';
@@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
_currentIndex = index;
});
},
child: _buildActiveTab(),
// IndexedStack conserve l'état des onglets (position de scroll,
// catalogues chargés) : l'ancien switch reconstruisait tout à chaque
// changement d'onglet. Même approche que le dashboard desktop.
child: IndexedStack(
index: _currentIndex,
children: [
MobileLiveTVTab(playlist: widget.playlist),
MobileMoviesTab(playlist: widget.playlist),
MobileSeriesTab(playlist: widget.playlist),
RecordingsTab(playlist: widget.playlist),
const MobileSettingsTab(),
],
),
),
);
}
Widget _buildActiveTab() {
switch (_currentIndex) {
case 0:
return MobileLiveTVTab(playlist: widget.playlist);
case 1:
return MobileMoviesTab(playlist: widget.playlist);
case 2:
return MobileSeriesTab(playlist: widget.playlist);
case 3:
return const MobileSettingsTab();
default:
return MobileLiveTVTab(playlist: widget.playlist);
}
}
}
@@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
}
class _MobileChannelTile extends StatelessWidget {
class _MobileChannelTile extends ConsumerWidget {
final Channel channel;
final VoidCallback onTap;
@@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// Sans ce bouton, aucun moyen d'ajouter un favori : le filtre « Favoris »
// de l'en-tête affichait toujours une liste vide.
final isFav = ref.watch(favoritesProvider).contains(channel.streamId);
final iconUrl =
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
@@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: isFav ? 'Retirer des favoris' : 'Ajouter aux favoris',
icon: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? AppColors.primary : AppColors.onSurface54,
size: 20,
),
onPressed: () => ref
.read(favoritesProvider.notifier)
.toggleFavorite(channel.streamId),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+5
View File
@@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget {
activeIcon: Icon(Icons.video_library_rounded),
label: 'Series',
),
BottomNavigationBarItem(
icon: Icon(Icons.videocam_outlined),
activeIcon: Icon(Icons.videocam_rounded),
label: 'REC',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
+1266
View File
File diff suppressed because it is too large. Load diff
+29 -1
View File
@@ -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>
+29 -1
View File
@@ -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
View File
@@ -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>
+3 -1
View File
@@ -1,2 +1,4 @@
!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,f(i.key),i)}}function i(e,t,i){return t&&r(e.prototype,t),i&&r(e,i),Object.defineProperty(e,"prototype",{writable:!1}),e}function n(e,t,r){return(t=f(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function a(){return a=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)({}).hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},a.apply(null,arguments)}function s(e){return s=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},s(e)}function o(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,h(e,t)}function l(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(e){}return(l=function(){return!!e})()}function u(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function d(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?u(Object(r),!0).forEach((function(t){n(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):u(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function h(e,t){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},h(e,t)}function f(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function c(e){var t="function"==typeof Map?new Map:void 0;return c=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(l())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&h(n,r.prototype),n}(e,arguments,s(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),h(r,e)},c(e)}function g(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var v,m,p={exports:{}},y=(v||(v=1,function(e){var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;return s},o.prototype.listenerCount=function(e){var t=r?r+e:e,i=this._events[t];return i?i.fn?1:i.length:0},o.prototype.emit=function(e,t,i,n,a,s){var o=r?r+e:e;if(!this._events[o])return!1;var l,u,d=this._events[o],h=arguments.length;if(d.fn){switch(d.once&&this.removeListener(e,d.fn,void 0,!0),h){case 1:return d.fn.call(d.context),!0;case 2:return d.fn.call(d.context,t),!0;case 3:return d.fn.call(d.context,t,i),!0;case 4:return d.fn.call(d.context,t,i,n),!0;case 5:return d.fn.call(d.context,t,i,n,a),!0;case 6:return d.fn.call(d.context,t,i,n,a,s),!0}for(u=1,l=new Array(h-1);u<h;u++)l[u-1]=arguments[u];d.fn.apply(d.context,l)}else{var f,c=d.length;for(u=0;u<c;u++)switch(d[u].once&&this.removeListener(e,d[u].fn,void 0,!0),h){case 1:d[u].fn.call(d[u].context);break;case 2:d[u].fn.call(d[u].context,t);break;case 3:d[u].fn.call(d[u].context,t,i);break;case 4:d[u].fn.call(d[u].context,t,i,n);break;default:if(!l)for(f=1,l=new Array(h-1);f<h;f++)l[f-1]=arguments[f];d[u].fn.apply(d[u].context,l)}}return!0},o.prototype.on=function(e,t,r){return a(this,e,t,r,!1)},o.prototype.once=function(e,t,r){return a(this,e,t,r,!0)},o.protLine truncated
!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,i=Array(t);r<t;r++)i[r]=e[r];return i}function i(e,t,r,i,n,a,s){try{var o=e[a](s),l=o.value}catch(e){return void r(e)}o.done?t(l):Promise.resolve(l).then(i,n)}function n(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,y(i.key),i)}}function a(e,t,r){return t&&n(e.prototype,t),r&&n(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function s(e,t){var i="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(i)return(i=i.call(e)).next.bind(i);if(Array.isArray(e)||(i=function(e,t){if(e){if("string"==typeof e)return r(e,t);var i={}.toString.call(e).slice(8,-1);return"Object"===i&&e.constructor&&(i=e.constructor.name),"Map"===i||"Set"===i?Array.from(e):"Arguments"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?r(e,t):void 0}}(e))||t){i&&(e=i);var n=0;return function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function o(e,t,r){return(t=y(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)({}).hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},l.apply(null,arguments)}function u(e){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},u(e)}function d(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,p(e,t)}function h(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(e){}return(h=function(){return!!e})()}function f(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function c(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?f(Object(r),!0).forEach((function(t){o(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):f(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function g(e,t){if(null==e)return{};var r={};for(var i in e)if({}.hasOwnProperty.call(e,i)){if(-1!==t.indexOf(i))continue;r[i]=e[i]}return r}function v(){
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
var e,t,r="function"==typeof Symbol?Symbol:{},i=r.iterator||"@@iterator",n=r.toStringTag||"@@toStringTag";function a(r,i,n,a){var l=i&&i.prototype instanceof o?i:o,u=Object.create(l.prototype);return m(u,"_invoke",function(r,i,n){var a,o,l,u=0,d=n||[],h=!1,f={p:0,n:0,v:e,a:c,f:c.bind(e,4),d:function(t,r){return a=t,o=0,l=e,f.n=r,s}};function c(r,i){for(o=r,l=i,t=0;!h&&u&&!n&&t<d.length;t++){var n,a=d[t],c=f.p,g=a[2];r>3?(n=g===i)&&(l=a[(o=a[4])?5:(o=3,3)],a[4]=a[5]=e):a[0]<=c&&((n=r<2&&c<a[1])?(o=0,f.v=i,f.n=a[1]):c<g&&(n=r<3||a[0]>i||i>g)&&(a[4]=r,a[5]=i,f.n=g,o=0))}if(n||r>1)return s;throw h=!0,i}return function(n,d,g){if(u>1)throw TypeError("Generator is already running");for(h&&1===d&&c(d,g),o=d,l=g;(t=o<2?e:l)||!h;){a||(o?o<3?(o>1&&(f.n=-1),c(o,l)):f.n=l:f.v=l);try{if(u=2,a){if(o||(n="next"),t=a[n]){if(!(t=t.call(a,l)))throw TypeError("iterator result is not an object");if(!t.done)return t;l=t.value,o<2&&(o=0)}else 1===o&&(t=a.return)&&t.call(a),o<2&&(l=TypeError("The iterator does not provide a '"+n+"' method"),o=1);a=e}else if((t=(h=f.n<0)?l:r.call(i,f))!==s)break}catch(t){a=e,o=1,l=t}finally{u=1}}return{value:t,done:h}}}(r,n,a),!0),u}var s={};function o(){}function l(){}function u(){}t=Object.getPrototypeOf;var d=[][i]?t(t([][i]())):(m(t={},i,(function(){return this})),t),h=u.prototype=o.prototype=Object.create(d);function f(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,u):(e.__proto__=u,m(e,n,"GeneratorFunction")),e.prototype=Object.create(h),e}return l.prototype=u,m(h,"constructor",u),m(u,"constructor",l),l.displayName="GeneratorFunction",m(u,n,"GeneratorFunction"),m(h),m(h,n,"Generator"),m(h,i,(function(){return this})),m(h,"toString",(function(){return"[object Generator]"})),(v=function(){return{w:a,m:f}})()}function m(e,t,r,i){var n=Object.defineProperty;try{n({},"",{})}catch(e){n=0}m=function(e,t,r,i){function a(t,r){m(e,t,(function(e){return this._invoke(t,r,e)}))}t?n?n(e,t,{value:r,enumerable:!i,configurable:!i,writable:!i}):e[t]=r:(a("next",0),a("throw",1),a("return",2))},m(e,t,r,i)}function p(e,t){return p=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},p(e,t)}function y(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function E(e){var t="function"==typeof Map?new Map:void 0;return E=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(h())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&p(n,r.prototype),n}(e,arguments,u(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),p(r,e)},E(e)}function T(e){return e instanceof ArrayBuffer||"undefined"!=typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer}var S="utf-16",A="utf-16be",I="utf-16le",L="utf-8";function R(e,t){if(e<0)return-R(-e,t);var r=Math.pow(10,t);if(Math.abs(e*r%1-.5)<Number.EPSILON){var i=Math.floor(e*r);return(i%2==0?i:i+1)/r}return Math.round(e*r)/r}function b(){try{return crypto.randomUUID()}catch(i){try{var e=URL.createObjectURL(new Blob),t=e.toString();return URL.revokeObjectURL(e),t.slice(t.lastIndexOf("/")+1)}catch(e){var r=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(e){var t=(r+16*Math.random())%16|0;return r=Math.floor(r/16),("x"==e?t:3&t|8).toString(16)}))}}}function k(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var D,P,_={exports:{}},C=(D||(D=1,function(e){var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;retLine truncated
//# sourceMappingURL=hls.min.js.map
+2 -8
View File
@@ -1,9 +1,3 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.mpegts=t():e.mpegts=t()}(window,(function(){return function(e){var t={};function i(n){if(t[n])return t[n].exports;var a=t[n]={i:n,l:!1,exports:{}};return e[n].call(a.exports,a,a.exports,i),a.l=!0,a.exports}return i.m=e,i.c=t,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)i.d(n,a,function(t){return e[t]}.bind(null,a));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="",i(i.s=14)}([function(e,t,i){"use strict";var n=i(6),a=i.n(n),r=function(){function e(){}return e.e=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","error",n),e.ENABLE_ERROR&&(console.error?console.error(n):console.warn?console.warn(n):console.log(n))},e.i=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","info",n),e.ENABLE_INFO&&(console.info?console.info(n):console.log(n))},e.w=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","warn",n),e.ENABLE_WARN&&(console.warn?console.warn(n):console.log(n))},e.d=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","debug",n),e.ENABLE_DEBUG&&(console.debug?console.debug(n):console.log(n))},e.v=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","verbose",n),e.ENABLE_VERBOSE&&console.log(n)},e}();r.GLOBAL_TAG="mpegts.js",r.FORCE_GLOBAL_TAG=!1,r.ENABLE_ERROR=!0,r.ENABLE_INFO=!0,r.ENABLE_WARN=!0,r.ENABLE_DEBUG=!0,r.ENABLE_VERBOSE=!0,r.ENABLE_CALLBACK=!1,r.emitter=new a.a,t.a=r},function(e,t,i){"use strict";t.a={IO_ERROR:"io_error",DEMUX_ERROR:"demux_error",INIT_SEGMENT:"init_segment",MEDIA_SEGMENT:"media_segment",LOADING_COMPLETE:"loading_complete",RECOVERED_EARLY_EOF:"recovered_early_eof",MEDIA_INFO:"media_info",METADATA_ARRIVED:"metadata_arrived",SCRIPTDATA_ARRIVED:"scriptdata_arrived",TIMED_ID3_METADATA_ARRIVED:"timed_id3_metadata_arrived",SMPTE2038_METADATA_ARRIVED:"smpte2038_metadata_arrived",SCTE35_METADATA_ARRIVED:"scte35_metadata_arrived",PES_PRIVATE_DATA_DESCRIPTOR:"pes_private_data_descriptor",PES_PRIVATE_DATA_ARRIVED:"pes_private_data_arrived",STATISTICS_INFO:"statistics_info",RECOMMEND_SEEKPOINT:"recommend_seekpoint"}},function(e,t,i){"use strict";i.d(t,"c",(function(){return a})),i.d(t,"b",(function(){return r})),i.d(t,"a",(function(){return s}));var n=i(3),a={kIdle:0,kConnecting:1,kBuffering:2,kError:3,kComplete:4},r={OK:"OK",EXCEPTION:"Exception",HTTP_STATUS_CODE_INVALID:"HttpStatusCodeInvalid",CONNECTING_TIMEOUT:"ConnectingTimeout",EARLY_EOF:"EarlyEof",UNRECOVERABLE_EARLY_EOF:"UnrecoverableEarlyEof"},s=function(){function e(e){this._type=e||"undefined",this._status=a.kIdle,this._needStash=!1,this._onContentLengthKnown=null,this._onURLRedirect=null,this._onDataArrival=null,this._onError=null,this._onComplete=null}return e.prototype.destroy=function(){this._status=a.kIdle,this._onContentLengthKnown=null,this._onURLRedirect=null,this._onDataArrival=null,this._onError=null,this._onComplete=null},e.prototype.isWorking=function(){return this._status===a.kConnecting||this._status===a.kBuffering},Object.defineProperty(e.prototype,"type",{get:function(){return this._type},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"status",{get:function(){return this._status},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"needStashBuffer",{get:function(){return this._needStash},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onContentLengthKnown",{get:function(){return this._onContentLengthKnown},set:function(e){this._onContentLengthKnown=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onURLRedirect",{get:function(){return this._onURLRedirect},set:function(e){this._onURLRedirect=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onDataArrival",{get:function(){return this._onDataArrival},set:function(e){this._onDataArrival=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onError",{get:function(){return this._onError},set:function(e){this._onError=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onComplete",{get:fuLine truncated
/*!
* @overview es6-promise - a tiny implementation of Promises/A+.
* @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald)
* @license Licensed under MIT license
* See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE
* @version v4.2.8+1e68dce6
*/var n;n=function(){"use strict";function e(e){return"function"==typeof e}var n=Array.isArray?Array.isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},a=0,r=void 0,s=void 0,o=function(e,t){f[a]=e,f[a+1]=t,2===(a+=2)&&(s?s(p):b())},d="undefined"!=typeof window?window:void 0,_=d||{},h=_.MutationObserver||_.WebKitMutationObserver,c="undefined"==typeof self&&void 0!==t&&"[object process]"==={}.toString.call(t),u="undefined"!=typeof Uint8ClampedArray&&"undefined"!=typeof importScripts&&"undefined"!=typeof MessageChannel;function l(){var e=setTimeout;return function(){return e(p,1)}}var f=new Array(1e3);function p(){for(var e=0;e<a;e+=2)(0,f[e])(f[e+1]),f[e]=void 0,f[e+1]=void 0;a=0}var m,g,v,y,b=void 0;function S(e,t){var i=this,n=new this.constructor(R);void 0===n[A]&&P(n);var a=i._state;if(a){var r=arguments[a-1];o((function(){return I(a,n,r,i._result)}))}else C(i,n,e,t);return n}function E(e){if(e&&"object"==typeof e&&e.constructor===this)return e;var t=new this(R);return L(t,e),t}c?b=function(){return t.nextTick(p)}:h?(g=0,v=new h(p),y=document.createTextNode(""),v.observe(y,{characterData:!0}),b=function(){y.data=g=++g%2}):u?((m=new MessageChannel).port1.onmessage=p,b=function(){return m.port2.postMessage(0)}):b=void 0===d?function(){try{var e=Function("return this")().require("vertx");return void 0!==(r=e.runOnLoop||e.runOnContext)?function(){r(p)}:l()}catch(e){return l()}}():l();var A=Math.random().toString(36).substring(2);function R(){}function T(t,i,n){i.constructor===t.constructor&&n===S&&i.constructor.resolve===E?function(e,t){1===t._state?k(e,t._result):2===t._state?D(e,t._result):C(t,void 0,(function(t){return L(e,t)}),(function(t){return D(e,t)}))}(t,i):void 0===n?k(t,i):e(n)?function(e,t,i){o((function(e){var n=!1,a=function(e,t,i,n){try{e.call(t,i,n)}catch(e){return e}}(i,t,(function(i){n||(n=!0,t!==i?L(e,i):k(e,i))}),(function(t){n||(n=!0,D(e,t))}),e._label);!n&&a&&(n=!0,D(e,a))}),e)}(t,i,n):k(t,i)}function L(e,t){if(e===t)D(e,new TypeError("You cannot resolve a promise with itself"));else if(a=typeof(n=t),null===n||"object"!==a&&"function"!==a)k(e,t);else{var i=void 0;try{i=t.then}catch(t){return void D(e,t)}T(e,t,i)}var n,a}function w(e){e._onerror&&e._onerror(e._result),B(e)}function k(e,t){void 0===e._state&&(e._result=t,e._state=1,0!==e._subscribers.length&&o(B,e))}function D(e,t){void 0===e._state&&(e._state=2,e._result=t,o(w,e))}function C(e,t,i,n){var a=e._subscribers,r=a.length;e._onerror=null,a[r]=t,a[r+1]=i,a[r+2]=n,0===r&&e._state&&o(B,e)}function B(e){var t=e._subscribers,i=e._state;if(0!==t.length){for(var n=void 0,a=void 0,r=e._result,s=0;s<t.length;s+=3)n=t[s],a=t[s+i],n?I(i,n,a,r):a(r);e._subscribers.length=0}}function I(t,i,n,a){var r=e(n),s=void 0,o=void 0,d=!0;if(r){try{s=n(a)}catch(e){d=!1,o=e}if(i===s)return void D(i,new TypeError("A promises callback cannot return that same promise."))}else s=a;void 0!==i._state||(r&&d?L(i,s):!1===d?D(i,o):1===t?k(i,s):2===t&&D(i,s))}var O=0;function P(e){e[A]=O++,e._state=void 0,e._result=void 0,e._subscribers=[]}var M=function(){function e(e,t){this._instanceConstructor=e,this.promise=new e(R),this.promise[A]||P(this.promise),n(t)?(this.length=t.length,this._remaining=t.length,this._result=new Array(this.length),0===this.length?k(this.promise,this._result):(this.length=this.length||0,this._enumerate(t),0===this._remaining&&k(this.promise,this._result))):D(this.promise,new Error("Array Methods must be provided an Array"))}return e.prototype._enumerate=function(e){for(var t=0;void 0===this._state&&t<e.length;t++)this._eachEntry(e[t],t)},e.prototype._eachEntry=function(e,t){var i=this._instanceConstructor,n=i.resolve;if(n===E){var a=void 0,r=void 0,s=!1;try{a=e.then}catch(e){s=!0,r=e}if(a===S&&void 0!==e._state)this._settledAt(e._state,t,e._result);else if("function"!=typeof a)this._remaining--,this._result[t]=e;else if(i===x){var o=new i(R);s?D(o,r):T(o,e,a),this._willSettleAt(o,t)}else this._willSettleAt(new i((function(t){return t(e)})),t)}else this._willSettleAt(n(e),t)},e.prototype._settledAt=function(e,t,i){var n=this.promise;void 0===n._state&&(this._remaining--,2===e?D(n,i):this._result[t]=i),0===this._remaining&&k(n,this._result)},e.prototype._willSettleAt=function(e,t){var i=this;C(e,void 0,(function(e){return i._settledAt(1,t,e)}),(function(e){return i._settledAt(2,t,e)}))},e}(),x=function(){function t(e){this[A]=O++,this._result=this._state=void 0,this._subscribers=[],R!==e&&("function"!=typeof e&&function(){throw new TypeError("You must pass a resolver function as the first argument to the promise constructor")}(),this instanceof t?function(e,t){try{t((function(t){L(e,t)}),(function(t){D(e,t)}))}catch(t){D(e,t)}}(this,e):function(){throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.")}())}return t.prototype.catch=function(e){return this.then(null,e)},t.prototype.finally=function(t){var i=this.constructor;returLine truncated
/*! For license information please see mpegts.js.LICENSE.txt */
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.mpegts=t():e.mpegts=t()}(this,function(){return function(){var e={964:function(e,t,i){e.exports=function(){"use strict";function e(e){return"function"==typeof e}var t=Array.isArray?Array.isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},a=0,n=void 0,r=void 0,o=function(e,t){h[a]=e,h[a+1]=t,2===(a+=2)&&(r?r(f):y())};var s="undefined"!=typeof window?window:void 0,d=s||{},_=d.MutationObserver||d.WebKitMutationObserver,l="undefined"==typeof self&&"undefined"!=typeof process&&"[object process]"==={}.toString.call(process),u="undefined"!=typeof Uint8ClampedArray&&"undefined"!=typeof importScripts&&"undefined"!=typeof MessageChannel;function c(){var e=setTimeout;return function(){return e(f,1)}}var h=new Array(1e3);function f(){for(var e=0;e<a;e+=2)(0,h[e])(h[e+1]),h[e]=void 0,h[e+1]=void 0;a=0}var p,m,g,v,y=void 0;function S(e,t){var i=this,a=new this.constructor(b);void 0===a[A]&&x(a);var n=i._state;if(n){var r=arguments[n-1];o(function(){return O(n,a,r,i._result)})}else I(i,a,e,t);return a}function E(e){if(e&&"object"==typeof e&&e.constructor===this)return e;var t=new this(b);return D(t,e),t}y=l?function(){return process.nextTick(f)}:_?(m=0,g=new _(f),v=document.createTextNode(""),g.observe(v,{characterData:!0}),function(){v.data=m=++m%2}):u?((p=new MessageChannel).port1.onmessage=f,function(){return p.port2.postMessage(0)}):void 0===s?function(){try{var e=Function("return this")().require("vertx");return void 0!==(n=e.runOnLoop||e.runOnContext)?function(){n(f)}:c()}catch(e){return c()}}():c();var A=Math.random().toString(36).substring(2);function b(){}var R=void 0,T=1,L=2;function k(t,i,a){i.constructor===t.constructor&&a===S&&i.constructor.resolve===E?function(e,t){t._state===T?C(e,t._result):t._state===L?w(e,t._result):I(t,void 0,function(t){return D(e,t)},function(t){return w(e,t)})}(t,i):void 0===a?C(t,i):e(a)?function(e,t,i){o(function(e){var a=!1,n=function(e,t,i,a){try{e.call(t,i,a)}catch(e){return e}}(i,t,function(i){a||(a=!0,t!==i?D(e,i):C(e,i))},function(t){a||(a=!0,w(e,t))},e._label);!a&&n&&(a=!0,w(e,n))},e)}(t,i,a):C(t,i)}function D(e,t){if(e===t)w(e,new TypeError("You cannot resolve a promise with itself"));else if(n=typeof(a=t),null===a||"object"!==n&&"function"!==n)C(e,t);else{var i=void 0;try{i=t.then}catch(t){return void w(e,t)}k(e,t,i)}var a,n}function M(e){e._onerror&&e._onerror(e._result),B(e)}function C(e,t){e._state===R&&(e._result=t,e._state=T,0!==e._subscribers.length&&o(B,e))}function w(e,t){e._state===R&&(e._state=L,e._result=t,o(M,e))}function I(e,t,i,a){var n=e._subscribers,r=n.length;e._onerror=null,n[r]=t,n[r+T]=i,n[r+L]=a,0===r&&e._state&&o(B,e)}function B(e){var t=e._subscribers,i=e._state;if(0!==t.length){for(var a=void 0,n=void 0,r=e._result,o=0;o<t.length;o+=3)a=t[o],n=t[o+i],a?O(i,a,n,r):n(r);e._subscribers.length=0}}function O(t,i,a,n){var r=e(a),o=void 0,s=void 0,d=!0;if(r){try{o=a(n)}catch(e){d=!1,s=e}if(i===o)return void w(i,new TypeError("A promises callback cannot return that same promise."))}else o=n;i._state!==R||(r&&d?D(i,o):!1===d?w(i,s):t===T?C(i,o):t===L&&w(i,o))}var P=0;function x(e){e[A]=P++,e._state=void 0,e._result=void 0,e._subscribers=[]}var U=function(){function e(e,i){this._instanceConstructor=e,this.promise=new e(b),this.promise[A]||x(this.promise),t(i)?(this.length=i.length,this._remaining=i.length,this._result=new Array(this.length),0===this.length?C(this.promise,this._result):(this.length=this.length||0,this._enumerate(i),0===this._remaining&&C(this.promise,this._result))):w(this.promise,new Error("Array Methods must be provided an Array"))}return e.prototype._enumerate=function(e){for(var t=0;this._state===R&&t<e.length;t++)this._eachEntry(e[t],t)},e.prototype._eachEntry=function(e,t){var i=this._instanceConstructor,a=i.resolve;if(a===E){var n=void 0,r=void 0,o=!1;try{n=e.then}catch(e){o=!0,r=e}if(n===S&&e._state!==R)this._settledAt(e._state,t,e._result);else if("function"!=typeof n)this._remaining--,this._result[t]=e;else if(i===N){var s=new i(b);o?w(s,r):k(s,e,n),this._willSettleAt(s,t)}else this._willSettleAt(new i(function(t){return t(e)}),t)}else this._willSettleAt(a(e),t)},e.prototype._settledAt=function(e,t,i){var a=this.promise;a._state===R&&(this._remaining--,e===L?w(a,i):this._result[t]=i),0===this._remaining&&C(a,this._result)},e.prototype._willSettleAt=function(e,t){var i=this;I(e,void 0,function(e){return i._settledAt(T,t,e)},function(e){return i._settledAt(L,t,e)})},e}();var N=function(){function t(e){this[A]=P++,this._result=this._state=void 0,this._subscribers=[],b!==e&&("function"!=typeof e&&function(){throw new TypeError("You must pass a resolver function as the first argument to the promise constructor")}(),this instanceof t?function(e,t){try{t(function(t){D(e,t)},function(t){w(e,t)})}catch(t){w(e,t)}}(this,e):function(){throw new TypeELine truncated
//# sourceMappingURL=mpegts.js.map
+92 -11
View File
@@ -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. */
@@ -253,6 +303,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 +322,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);
});
};
@@ -284,7 +357,13 @@
// 64 Ko en profil « fast » contre 512 Ko auparavant.
stashInitialSize: this.profile.mpegtsStash,
maxStashSize: 30 * 1024 * 1024,
liveBufferLatencyChasing: false,
// Rattrapage du direct : sans lui, chaque micro-coupure fait dériver
// la lecture derrière le direct (latence qui s'accumule). Activé
// uniquement en profil « fast » — les profils élargis privilégient
// la stabilité du buffer.
liveBufferLatencyChasing: isLive && this.profile.name === 'fast',
liveBufferLatencyMaxLatency: 5,
liveBufferLatencyMinRemain: 1,
lazyLoad: false
}
);
@@ -341,13 +420,15 @@
this._attemptPlay();
};
/// `/api/live/<id>.ts` → `/api/live/<id>/source/playlist.m3u8`.
/// `/api/live/<id>/turbo.ts` (ou l'ancien `/api/live/<id>.ts`)
/// → `/api/live/<id>/source/playlist.m3u8`.
///
/// Le préréglage `source` garde la vidéo en copie de flux (`-c:v copy`) :
/// seul l'audio est réencodé, le coût processeur reste négligeable.
/// Filet de sécurité : la route turbo réencode déjà l'audio en AAC côté
/// serveur, donc ce fallback ne devrait plus jamais se déclencher — il
/// reste pour couvrir un flux sans piste audio exploitable du tout.
/// Renvoie `null` si l'URL n'est pas un flux live direct — rien à tenter.
XFPlayer.prototype._hlsEquivalent = function (url) {
var match = /^(.*\/api\/live\/)([^/?#]+)\.ts(\?.*)?$/.exec(url);
var match = /^(.*\/api\/live\/)([^/?#]+)(?:\/turbo)?\.ts(\?.*)?$/.exec(url);
if (!match) return null;
return match[1] + match[2] + '/source/playlist.m3u8';
};