mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-24 15:38:04 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1295bd12ef | ||
|
|
dfc8a33916 | ||
|
|
0541e1b0e3 | ||
|
|
8ba57a7367 | ||
|
|
8bed6cf7b8 | ||
|
|
7fa9897d5c | ||
|
|
aacfa54604 |
No files matched your search
@@ -8,7 +8,6 @@
|
|||||||
build/
|
build/
|
||||||
bin/.dart_tool
|
bin/.dart_tool
|
||||||
bin/.packages
|
bin/.packages
|
||||||
bin/pubspec.lock
|
|
||||||
ios/
|
ios/
|
||||||
android/
|
android/
|
||||||
windows/
|
windows/
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
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:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -13,7 +19,13 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
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
|
channel: stable
|
||||||
|
cache: true
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
# Infos (pre-existing withOpacity/dart:html deprecations) are not
|
# Infos (pre-existing withOpacity/dart:html deprecations) are not
|
||||||
# fatal; errors and warnings still fail the build.
|
# fatal; errors and warnings still fail the build.
|
||||||
@@ -30,7 +42,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dart-lang/setup-dart@v1
|
- uses: dart-lang/setup-dart@v1
|
||||||
with:
|
with:
|
||||||
sdk: stable
|
sdk: 3.13.2
|
||||||
- run: dart pub get
|
- run: dart pub get
|
||||||
- run: dart analyze
|
- run: dart analyze
|
||||||
- run: dart test
|
- run: dart test
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
name: Docker Publish
|
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:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -11,11 +16,18 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
github.event.workflow_run.conclusion == 'success'
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -34,6 +46,6 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_NAME }}:latest
|
${{ 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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
@@ -49,4 +49,7 @@ app.*.map.json
|
|||||||
# Hive data (local development)
|
# Hive data (local development)
|
||||||
*.hive
|
*.hive
|
||||||
*.lock
|
*.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
|
.claude/settings.local.json
|
||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
## Non publié
|
## Non publié
|
||||||
|
|
||||||
|
### 🧰 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
|
### 🔧 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é
|
- **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)
|
- **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
@@ -12,9 +12,10 @@ ENV FLUTTER_NO_ANALYTICS=1
|
|||||||
RUN flutter config --enable-web && flutter precache --web
|
RUN flutter config --enable-web && flutter precache --web
|
||||||
|
|
||||||
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
|
# 2. Dependency Resolution (ONLY UPDATES IF PUBSPEC CHANGES)
|
||||||
COPY pubspec.yaml ./
|
# Les lockfiles sont versionnés : le build résout exactement les versions
|
||||||
COPY bin/pubspec.yaml ./bin/
|
# committées au lieu d'en chercher de nouvelles à chaque build.
|
||||||
# Note: No .lock files found locally, so we fetch fresh ones here
|
COPY pubspec.yaml pubspec.lock ./
|
||||||
|
COPY bin/pubspec.yaml bin/pubspec.lock ./bin/
|
||||||
RUN flutter pub get && cd bin && dart pub get
|
RUN flutter pub get && cd bin && dart pub get
|
||||||
|
|
||||||
# 3. Source Code Copy (CHANGES OFTEN)
|
# 3. Source Code Copy (CHANGES OFTEN)
|
||||||
|
|||||||
@@ -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**
|
- **Live TV, Films, Séries** : catalogues Xtream avec catégories, recherche, favoris, reprise de lecture
|
||||||
- Default admin user (`admin`/`admin`)
|
- **Guide TV (EPG)** : panneau de l'abonné en priorité, repli automatique sur un dump XMLTV quand le panneau est figé
|
||||||
- Secure salt-based password hashing (SHA-256)
|
- **Enregistrements TV** : planification depuis le guide, capture FFmpeg (`-c copy`), reprise après coupure amont ou redémarrage du serveur, fusion automatique des parties
|
||||||
- No public signup - private app only
|
- **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**
|
## Stack
|
||||||
- Centralized Xtream credentials management
|
|
||||||
- Playlist assignment to users
|
|
||||||
- Easy switching between playlists
|
|
||||||
|
|
||||||
✅ **High-Performance Dashboard (60fps)**
|
| Couche | Techno |
|
||||||
- Category-based pagination (100 items/page for Live TV, 50 for Movies)
|
|---|---|
|
||||||
- Lazy loading with `ListView.builder` / `GridView.builder`
|
| Frontend | Flutter Web (Riverpod, GoRouter), players HTML (hls.js / mpegts.js vendorisés) |
|
||||||
- Image caching with `cached_network_image`
|
| 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**
|
## Démarrage rapide (Docker)
|
||||||
- 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose build
|
docker-compose up -d --build
|
||||||
|
# Application sur http://localhost:8089
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Start the container
|
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.
|
||||||
|
|
||||||
```bash
|
### Variables d'environnement (docker-compose.yml)
|
||||||
docker-compose up -d
|
|
||||||
```
|
| Variable | Défaut | Rôle |
|
||||||
|
|---|---|---|
|
||||||
### 3. Access via reverse proxy
|
| `RECORDINGS_PATH` | `./data/recordings` | Dossier hôte des enregistrements |
|
||||||
|
| `TZ` | `Europe/Paris` | Fuseau du conteneur (les enregistrements sont stockés en UTC) |
|
||||||
Configure your reverse proxy (Nginx/Traefik) to route traffic to:
|
| `MAX_CONCURRENT_RECORDINGS` | `2` | Enregistrements simultanés |
|
||||||
- **Container**: `xtremflow`
|
| `EPG_XMLTV_URLS` | dump FR | Sources XMLTV de repli (vide = aucun appel sortant) |
|
||||||
- **Internal Port**: `8080`
|
| `NVIDIA_GPU` | `false` | Transcodage NVENC |
|
||||||
- **Network**: `nginx_default`
|
| `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 |
|
||||||
Example Nginx configuration:
|
| `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é |
|
||||||
```nginx
|
|
||||||
location /iptv {
|
## Développement local
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Frontend
|
||||||
flutter pub get
|
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
|
## Structure du projet
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run web app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
flutter run -d chrome
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
bin/ Serveur Dart
|
||||||
├── core/
|
├── server.dart Point d'entrée, routage, arrêt gracieux
|
||||||
│ ├── database/
|
├── api/ Handlers HTTP (auth, playlists, recordings, EPG, proxy…)
|
||||||
│ │ └── hive_service.dart # Hive initialization & encryption
|
├── services/ Scheduler d'enregistrement, sessions FFmpeg, XMLTV
|
||||||
│ ├── models/
|
├── database/ SQLite (schéma, migrations)
|
||||||
│ │ ├── app_user.dart # User model (Hive)
|
├── middleware/ Auth (session), sécurité (rate limit, honeypot, logs redactés)
|
||||||
│ │ ├── playlist_config.dart # Playlist credentials (Hive)
|
└── test/ Tests backend (dart test)
|
||||||
│ │ └── iptv_models.dart # Channel, VOD, Series, EPG models
|
|
||||||
│ ├── router/
|
lib/ Frontend Flutter
|
||||||
│ │ └── app_router.dart # GoRouter configuration
|
├── core/ Modèles, thème, router, clients API
|
||||||
│ └── utils/
|
├── features/ auth / admin / iptv (desktop)
|
||||||
│ └── crypto_utils.dart # Password hashing utilities
|
└── mobile/ Variantes d'écrans mobiles
|
||||||
├── features/
|
|
||||||
│ ├── auth/
|
web/ Players HTML + libs vendorisées (hls.js, mpegts.js)
|
||||||
│ │ ├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Features
|
## Sécurité
|
||||||
|
|
||||||
### Password Storage
|
- Mots de passe **bcrypt** (migration lazy depuis les anciens hashes au login)
|
||||||
- **Algorithm**: SHA-256 with random UUID-based salt
|
- Les credentials Xtream ne quittent jamais le serveur : passerelle `/api/xtream-api` et proxy `/api/xtream` (authentifié par session) avec injection côté serveur
|
||||||
- **Format**: `salt:hash` (stored in Hive)
|
- Redaction des credentials dans tous les logs ; anti-SSRF avec revalidation des redirections
|
||||||
- **Legacy Support**: Fallback to unsalted comparison for migration
|
- Cookie de session HttpOnly ; rate limiting global + limite de tentatives de login par IP
|
||||||
|
|
||||||
### Database Encryption
|
## CI / Publication
|
||||||
- **Hive AES Cipher** (256-bit key)
|
|
||||||
- Key stored in `FlutterSecureStorage`
|
|
||||||
- Automatic key generation on first run
|
|
||||||
|
|
||||||
### Authentication Flow
|
`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.
|
||||||
1. User enters credentials
|
|
||||||
2. System retrieves stored hash
|
|
||||||
3. Input password is hashed with same salt
|
|
||||||
4. Constant-time comparison prevents timing attacks
|
|
||||||
|
|
||||||
## Performance Optimizations
|
## Dépannage
|
||||||
|
|
||||||
### Memory Management (20k+ channels)
|
- **Logs** : `docker logs xtremflow` (les URLs y sont redactées)
|
||||||
- **Grouping**: Channels organized by category
|
- **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)
|
||||||
- **Pagination**: 100 items per page (Live TV), 50 per page (Movies)
|
- **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
|
||||||
- **Lazy Loading**: Only render visible items
|
- **Enregistrement échoué** : bouton « Logs » sur la ligne de l'enregistrement ; la raison (`error_reason`) est affichée sous le titre
|
||||||
- **Image Caching**: Disk/memory cache with `cached_network_image`
|
|
||||||
|
|
||||||
### Network Optimization
|
## Licence
|
||||||
- **Dio Cache Interceptor**: 1-hour cache for API responses
|
|
||||||
- **EPG Cache**: 5-minute refresh for program data
|
|
||||||
- **Hive Disk Store**: Persistent cache across sessions
|
|
||||||
|
|
||||||
### Rendering (60fps Target)
|
Propriétaire — usage privé uniquement.
|
||||||
- `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.
|
|
||||||
@@ -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"
|
||||||
+4
-1
@@ -13,8 +13,11 @@ services:
|
|||||||
# Pour changer: modifiez le chemin avant les deux points ":"
|
# Pour changer: modifiez le chemin avant les deux points ":"
|
||||||
# Exemple Windows : - D:\MesVideos\TV:/app/recordings
|
# Exemple Windows : - D:\MesVideos\TV:/app/recordings
|
||||||
# Exemple Linux : - /mnt/nas/videos:/app/recordings
|
# Exemple Linux : - /mnt/nas/videos:/app/recordings
|
||||||
- ${RECORDINGS_PATH:-/mnt/user/Data/Sport}:/app/recordings
|
- ${RECORDINGS_PATH:-./data/recordings}:/app/recordings
|
||||||
environment:
|
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
|
# 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)
|
# same-origin, ne définir que si un autre domaine doit appeler l'API)
|
||||||
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-}
|
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-}
|
||||||
|
|||||||
File renamed without changes.
@@ -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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../auth/providers/auth_provider.dart';
|
||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
@@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
const Spacer(),
|
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(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
child: TvFocusableCard(
|
child: PopupMenuButton<String>(
|
||||||
onTap: () {},
|
tooltip: 'Profil',
|
||||||
borderRadius: 50,
|
color: AppColors.surfaceContainerHigh,
|
||||||
scaleFactor: 1.1,
|
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(
|
child: const CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: AppColors.surfaceContainerHigh,
|
backgroundColor: AppColors.surfaceContainerHigh,
|
||||||
|
|||||||
@@ -271,8 +271,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update watch history if relevant
|
// Update watch history if relevant. Jamais en live : la « position »
|
||||||
if (currentTime > 0) {
|
// 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(
|
ref.read(playbackPositionsProvider.notifier).savePosition(
|
||||||
widget.streamId,
|
widget.streamId,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
|
import '../providers/playback_positions_provider.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
import '../providers/watch_history_provider.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import 'player_screen.dart';
|
import 'player_screen.dart';
|
||||||
@@ -287,7 +288,14 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
streamType: StreamType.series,
|
streamType: StreamType.series,
|
||||||
containerExtension: episode.containerExtension ?? 'mkv',
|
containerExtension: episode.containerExtension ?? 'mkv',
|
||||||
duration: episodeDuration,
|
duration: episodeDuration,
|
||||||
|
startTime: resumeAt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
body: channelsAsync.when(
|
body: channelsAsync.when(
|
||||||
loading: () => const ThemedLoading(),
|
loading: () => const ThemedLoading(),
|
||||||
error: (e, s) => Center(
|
error: (e, s) => Center(
|
||||||
child: Text('Error: $e',
|
child: Column(
|
||||||
style: const TextStyle(color: AppColors.onSurface)),
|
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) {
|
data: (groupedChannels) {
|
||||||
var categories = groupedChannels.keys.toList();
|
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)
|
// Record Button Icon Overlay (Positioned top right)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart';
|
|||||||
import '../../../core/widgets/hero_carousel.dart';
|
import '../../../core/widgets/hero_carousel.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/tv_focusable_card.dart';
|
import '../../../core/widgets/tv_focusable_card.dart';
|
||||||
|
import '../providers/playback_positions_provider.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
import '../providers/watch_history_provider.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
@@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
streamType: StreamType.vod,
|
streamType: StreamType.vod,
|
||||||
containerExtension: movie.containerExtension ?? 'mp4',
|
containerExtension: movie.containerExtension ?? 'mp4',
|
||||||
duration: movieDuration,
|
duration: movieDuration,
|
||||||
|
startTime: resumeAt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import '../screens/player_screen.dart';
|
|||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// ENTRÉE — Onglet "Enregistrements"
|
// 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 {
|
class RecordingsTab extends StatefulWidget {
|
||||||
@@ -25,7 +29,24 @@ class RecordingsTab extends StatefulWidget {
|
|||||||
State<RecordingsTab> createState() => _RecordingsTabState();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -33,25 +54,59 @@ class _RecordingsTabState extends State<RecordingsTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
color: Colors.grey[900],
|
color: Colors.grey[900],
|
||||||
child: const Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
const Row(
|
||||||
SizedBox(width: 12),
|
children: [
|
||||||
Text(
|
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
||||||
'Enregistrements',
|
SizedBox(width: 12),
|
||||||
style: TextStyle(
|
Text(
|
||||||
fontSize: 26,
|
'Enregistrements',
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
color: AppColors.onSurface,
|
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(
|
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'));
|
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
|
||||||
_fetchRecordings();
|
_fetchRecordings();
|
||||||
}
|
}
|
||||||
@@ -986,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _error != null
|
: _error != null
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Column(
|
||||||
_error ?? 'Erreur',
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: const TextStyle(color: AppColors.live),
|
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
|
: _recordings.isEmpty
|
||||||
@@ -1157,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
|||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
tooltip: 'Supprimer',
|
tooltip: 'Supprimer',
|
||||||
onPressed: () =>
|
onPressed: () => _deleteRecording(
|
||||||
_deleteRecording(rec['id']),
|
rec['id'],
|
||||||
|
rec['title'] ?? '',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/models/playlist_config.dart';
|
import '../../../../core/models/playlist_config.dart';
|
||||||
import '../../../widgets/mobile_scaffold.dart';
|
import '../../../widgets/mobile_scaffold.dart';
|
||||||
import '../../../theme/mobile_theme.dart';
|
import '../../../theme/mobile_theme.dart';
|
||||||
|
import '../../../../features/iptv/widgets/recordings_tab.dart';
|
||||||
import '../widgets/mobile_live_tv_tab.dart';
|
import '../widgets/mobile_live_tv_tab.dart';
|
||||||
import '../widgets/mobile_movies_tab.dart';
|
import '../widgets/mobile_movies_tab.dart';
|
||||||
import '../widgets/mobile_series_tab.dart';
|
import '../widgets/mobile_series_tab.dart';
|
||||||
@@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
|
|||||||
_currentIndex = index;
|
_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 Channel channel;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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 =
|
final iconUrl =
|
||||||
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
||||||
? '/api/xtream/${channel.streamIcon}'
|
? '/api/xtream/${channel.streamIcon}'
|
||||||
@@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget {
|
|||||||
activeIcon: Icon(Icons.video_library_rounded),
|
activeIcon: Icon(Icons.video_library_rounded),
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.videocam_outlined),
|
||||||
|
activeIcon: Icon(Icons.videocam_rounded),
|
||||||
|
label: 'REC',
|
||||||
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: Icon(Icons.settings_outlined),
|
||||||
activeIcon: Icon(Icons.settings_rounded),
|
activeIcon: Icon(Icons.settings_rounded),
|
||||||
|
|||||||
+1266
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user