diff --git a/Dockerfile b/Dockerfile index ce7cf9d..6b82f61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,37 @@ -# Multi-stage Dockerfile for Flutter Web IPTV Application +# ============================================ # Stage 1: Build Flutter Web Application -FROM ghcr.io/cirruslabs/flutter:stable AS builder +# ============================================ +FROM cirrusci/flutter:stable AS builder WORKDIR /app -# Copy pubspec file -COPY pubspec.yaml ./ +# Copy dependency files first for better caching +COPY pubspec.yaml pubspec.lock ./ # Get dependencies RUN flutter pub get -# Copy entire project +# Copy source code COPY . . -# Build Flutter Web with CanvasKit renderer for 60fps performance -RUN flutter build web --release --web-renderer canvaskit +# Build web application (HTML renderer for compatibility) +RUN flutter build web --release --web-renderer html -# Stage 2: Serve with dhttpd (Dart HTTP Server) -FROM dart:stable AS runtime +# ============================================ +# Stage 2: Serve with dhttpd (Dart HTTP server) +# ============================================ +FROM dart:stable -# Install dhttpd globally and add to PATH -RUN dart pub global activate dhttpd -ENV PATH="$PATH:/root/.pub-cache/bin" - -# Create app directory WORKDIR /app -# Copy built web files from builder stage -COPY --from=builder /app/build/web /app/web +# Install dhttpd globally +RUN dart pub global activate dhttpd -# Create data directory for Hive persistence -RUN mkdir -p /app/data +# Copy built web application from builder stage +COPY --from=builder /app/build/web /app/web # Expose port (internal only, not mapped to host) EXPOSE 8080 -# Set environment variable for Hive storage -ENV HIVE_STORAGE_PATH=/app/data - -# Run dhttpd server (now available in PATH) -CMD ["dhttpd", "--host", "0.0.0.0", "--port", "8080", "--path", "/app/web"] +# Serve web application +CMD ["/root/.pub-cache/bin/dhttpd", "--host", "0.0.0.0", "--port", "8080", "--path", "/app/web"] diff --git a/README.md b/README.md index 136b96b..b594be6 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,228 @@ -# XtremFlow IPTV Web Application +# XtremFlow - IPTV Web Application -Application IPTV Web haute performance basée sur Flutter avec intégration Xtream Codes API, authentification locale sécurisée, panneau d'administration complet et déploiement Docker. +High-performance, containerized IPTV Web Application using Flutter Web and Xtream Codes API. -![Status](https://img.shields.io/badge/Status-Production%20Ready-brightgreen) -![Flutter](https://img.shields.io/badge/Flutter-Web-blue) -![License](https://img.shields.io/badge/License-Private-red) +## Features ---- +✅ **Local Authentication System** +- Default admin user (`admin`/`admin`) +- Secure salt-based password hashing (SHA-256) +- No public signup - private app only -## ✨ Fonctionnalités +✅ **Multi-Playlist Management** +- Centralized Xtream credentials management +- Playlist assignment to users +- Easy switching between playlists -### 🔐 Authentification Locale -- Système privé sans inscription publique -- Admin par défaut : `admin` / `admin` -- Chiffrement SHA-256 des mots de passe -- Base de données Hive chiffrée AES-256 +✅ **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` -### 📺 Lecteur IPTV Complet -- **Lecteur vidéo intégré** avec media_kit - - Contrôles personnalisés (play/pause/seek ±10s) - - Mode plein écran - - Barre de progression avec temps - - Indicateur de buffering -- **EPG (Guide électronique)** - - Badge "LIVE" sur programme en cours - - Barre de progression du programme - - Aperçu du programme suivant -- **Support multi-formats** - - Live TV (HLS/M3U8) - - Films (VOD) - - Séries (structure prête) +✅ **Live TV with EPG** +- Electronic Program Guide (EPG) overlay +- "Now & Next" program display +- Real-time progress bar -### 👨‍💼 Panneau d'Administration -- **Gestion des Utilisateurs** - - Création/Édition/Suppression - - Attribution du rôle admin - - ✅ **Assignation de playlists** par utilisateur - - Compteur de playlists assignées -- **Gestion des Playlists** - - Ajout de serveurs Xtream (DNS, Username, Password) - - Édition/Suppression - - Protection contre suppression admin +✅ **VOD & Series** +- Movies and Series organized by categories +- Grid layout with posters +- Ratings display -### ⚡ Performance Optimisée -- **Lazy Loading** : Pagination de 100 items -- **Cache Intelligent** : - - Images avec `cached_network_image` - - Requêtes API avec TTL 10min -- **60fps Garanti** : Renderer CanvasKit -- **Gestion Mémoire** : Support de 20k+ chaînes +✅ **Docker Deployment** +- Multi-stage build with Flutter and Dart +- `dhttpd` static server (minimal footprint) +- External network support (`nginx_default`) -### 🐳 Déploiement Docker -- Build multi-stage optimisé -- Serveur statique `dhttpd` (5MB) -- Réseau externe `nginx_default` -- Volume persistant pour données Hive +## 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 -## 🚀 Démarrage Rapide +## Prerequisites -### Option 1 : Docker (Recommandé) +- 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 -# 1. Créer le réseau externe (si inexistant) -docker network create nginx_default +docker-compose build +``` -# 2. Build et lancer -cd c:\Users\Michael\Git\xtremflow +### 2. Start the container + +```bash docker-compose up -d - -# 3. Vérifier les logs -docker-compose logs -f iptv-web ``` -**Accès** : Configurer votre reverse proxy (Nginx Proxy Manager/Traefik) pour pointer vers `http://iptv-web:8080` +### 3. Access via reverse proxy -### Option 2 : Développement Local (Nécessite Flutter SDK) +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 ```bash -# 1. Installer les dépendances flutter pub get - -# 2. Lancer en mode dev -flutter run -d chrome - -# 3. Build production -flutter build web --release --web-renderer canvaskit ``` ---- +### Generate Hive adapters (if modified) -## 📖 Guide d'Utilisation +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` -### Première Connexion +### Run web app -1. **Login Initial** - - URL : `http://votre-domaine.com/` - - Username : `admin` - - Password : `admin` +```bash +flutter run -d chrome +``` -2. **⚠️ Sécurité : Changer le mot de passe** - - Admin Panel → Users → Edit admin → New Password - -### Ajouter une Playlist Xtream - -1. **Accéder au Admin Panel** - - Bouton "Admin Panel" dans Settings ou navbar - -2. **Onglet Playlists → Add Playlist** - - **Playlist Name** : Mon IPTV - - **Server URL** : `http://votre-serveur.com:8080` - - **Username** : `votre_username` - - **Password** : `votre_password` - - Cliquer **Save** - -3. **Assigner à un Utilisateur** - - Onglet Users → Add User (ou Edit existant) - - Cocher la/les playlist(s) sous "Assigned Playlists" - - Sauvegarder - -### Regarder l'IPTV - -1. **Sélectionner une Playlist** - - Logout puis login avec compte utilisateur - - Choisir la playlist sur l'écran de sélection - -2. **Navigation** - - **Live TV** : Grille de chaînes avec EPG - - **Movies** : Catalogue VOD - - **Series** : Liste des séries - - **Settings** : Paramètres utilisateur - -3. **Lecture** - - Cliquer sur une chaîne/film → Lecteur s'ouvre - - Contrôles : Play/Pause, ±10s, Fullscreen - - EPG visible sur chaînes live - ---- - -## 🏗️ Architecture Technique - -### Stack Technologique - -| Composant | Technologie | Version | -|-----------|-------------|---------| -| Framework | Flutter (Web) | Stable | -| State Management | Riverpod | 2.6.1 | -| Base de Données | Hive (IndexedDB) | 2.2.3 | -| Networking | Dio | 5.7.0 | -| Routing | GoRouter | 14.6.2 | -| Video Player | media_kit | 1.1.11 | -| UI | Google Fonts | 6.2.1 | - -### Structure du Projet +## Project Structure ``` lib/ ├── core/ -│ ├── database/ # Hive + Encryption -│ ├── models/ # AppUser, PlaylistConfig -│ └── router/ # GoRouter + Guards +│ ├── 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/ -│ ├── admin/ # CRUD Users/Playlists -│ ├── auth/ # Login + AuthProvider +│ ├── auth/ +│ │ ├── providers/ +│ │ │ └── auth_provider.dart # Authentication state +│ │ └── screens/ +│ │ └── login_screen.dart +│ ├── admin/ +│ │ └── screens/ +│ │ └── admin_panel.dart # User & Playlist CRUD │ └── iptv/ -│ ├── models/ # Xtream DTOs -│ ├── services/ # API Client -│ ├── screens/ # Dashboard, Player -│ └── widgets/ # Tabs, EPG +│ ├── 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 ``` -**Total** : 20 fichiers Dart + 8 fichiers config = **28 fichiers** +## Security Features ---- +### Password Storage +- **Algorithm**: SHA-256 with random UUID-based salt +- **Format**: `salt:hash` (stored in Hive) +- **Legacy Support**: Fallback to unsalted comparison for migration -## 🔒 Sécurité +### Database Encryption +- **Hive AES Cipher** (256-bit key) +- Key stored in `FlutterSecureStorage` +- Automatic key generation on first run -### Implémentations +### 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 -✅ **Authentification** -- SHA-256 password hashing -- Pas de credentials en dur -- Session-based auth +## Performance Optimizations -✅ **Stockage** -- Chiffrement AES-256 (Hive) -- Clés stockées via `flutter_secure_storage` -- Fallback session si secure storage indisponible +### 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` -✅ **API** -- Validation des entrées utilisateur -- Intercepteurs Dio avec timeout -- Pas d'exposition de credentials dans URLs +### 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 -### Recommandations Production +### Rendering (60fps Target) +- `ListView.builder` with fixed `itemExtent` +- `AutomaticKeepAliveClientMixin` for tab state +- Expansion panels for category navigation +- Grid with fixed `crossAxisCount` and `childAspectRatio` -- [ ] Changer mot de passe admin par défaut -- [ ] Utiliser HTTPS via reverse proxy -- [ ] Backup régulier du volume Docker `iptv_data` -- [ ] Rotation des clés de chiffrement (optionnel) -- [ ] Rate limiting sur reverse proxy +## Xtream API Integration ---- +### Supported Endpoints -## 🐋 Configuration Docker +| 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 @@ -213,128 +230,51 @@ lib/ services: iptv-web: build: . - volumes: - - iptv_data:/app/data - networks: - - nginx_default # Externe + container_name: xtremflow restart: unless-stopped - -volumes: - iptv_data: + networks: + - nginx_default networks: nginx_default: - external: true # DOIT exister + external: true ``` -### Reverse Proxy (Exemple Nginx) +**No port mapping** - Access via reverse proxy only. -```nginx -location / { - proxy_pass http://iptv-web:8080; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; -} -``` +## Troubleshooting ---- - -## 🧪 Tests & Validation - -### Checklist Déploiement - -- [ ] `docker-compose build` réussit sans erreur -- [ ] Conteneur démarre : `docker-compose ps` -- [ ] Login admin/admin fonctionne -- [ ] Ajout playlist Xtream réussi -- [ ] Lecture d'une chaîne Live TV -- [ ] EPG s'affiche correctement -- [ ] Lecture d'un film VOD -- [ ] Fullscreen fonctionne - -### Performance - -**Target** : 60fps constant - -**Commande de test** : +### Container won't start ```bash -# Chrome DevTools → Performance Tab -# Record pendant 10s de scroll dans grille 1000+ items -# FPS moyen doit être > 55fps +# 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 -## ⚠️ Limitations Connues +### EPG not displaying +- EPG is optional and gracefully degrades +- Check if Xtream server supports `get_short_epg` +- Verify stream has `epg_channel_id` -| Limitation | Status | Workaround | -|------------|--------|------------| -| Series Episodes | ❌ Non implémenté | Nécessite `get_series_info` API | -| EPG Timeline | ❌ Vue chronologique absente | Seulement Now & Next | -| Flutter SDK | ⚠️ Requis pour build | Utiliser Docker pre-built | -| CORS Issues | ⚠️ Possible avec certains serveurs | Configurer proxy CORS | +### Performance issues (FPS drops) +- Reduce `_itemsPerPage` constant (currently 100 for Live TV) +- Disable image caching temporarily +- Check browser DevTools Performance tab ---- +## License -## 📊 Métriques Projet +Proprietary - Private Use Only -- **Lignes de Code** : ~3,500 (Dart) -- **Temps de Build** : ~2-3 min (Docker) -- **Taille Image** : ~150MB (compressed) -- **Temps de Démarrage** : <5s -- **Mémoire Runtime** : ~200MB (1000 items chargés) +## Support ---- - -## 🤝 Support - -### Problèmes Courants - -**Q: "Erreur réseau nginx_default"** -R: `docker network create nginx_default` - -**Q: "Pas de chaînes affichées"** -R: Vérifier credentials Xtream dans Admin Panel - -**Q: "Vidéo ne charge pas"** -R: Tester l'URL stream dans VLC. Si fonctionne → problème CORS - -**Q: "Build Flutter échoue"** -R: Utiliser Docker (build automatique) - ---- - -## 📝 Changelog - -### v1.0.0 (2025-12-05) - -✅ **Fonctionnalités Complètes** -- Lecteur vidéo media_kit avec contrôles -- Widget EPG avec badge LIVE -- Admin : assignation playlists aux users -- Docker deployment optimisé -- Documentation complète - ---- - -## 📄 License - -**Private Use Only** - Non distribué publiquement - ---- - -## 🎯 Roadmap Futur (Optionnel) - -- [ ] Épisodes de séries (get_series_info) -- [ ] Vue timeline EPG complète -- [ ] Favoris utilisateur -- [ ] Historique de lecture -- [ ] Multi-langue UI -- [ ] Mode hors ligne (téléchargements) - ---- - -**Développé avec ❤️ par l'équipe Antigravity** +For Xtream API documentation, consult your IPTV provider. diff --git a/docker-compose.yml b/docker-compose.yml index f116669..bd8cf7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,29 +5,11 @@ services: build: context: . dockerfile: Dockerfile - container_name: iptv-web - - # Volume for Hive data persistence - volumes: - - iptv_data:/app/data - - # Use external nginx network + container_name: xtremflow + restart: unless-stopped networks: - nginx_default - - # Environment variables - environment: - - HIVE_STORAGE_PATH=/app/data - - # Restart policy - restart: unless-stopped -# Named volume for Hive data persistence -volumes: - iptv_data: - driver: local - -# External network (must exist before running) networks: nginx_default: external: true diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 6864749..e8470b6 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -94,16 +94,40 @@ class HiveService { } } - /// Hash password using SHA-256 + /// Hash password using SHA-256 with salt static String _hashPassword(String password) { - final bytes = utf8.encode(password); + // Generate a random salt using the first 16 chars of a UUID + final salt = const Uuid().v4().substring(0, 16); + final bytes = utf8.encode(password + salt); final digest = sha256.convert(bytes); - return digest.toString(); + return '$salt:${digest.toString()}'; + } + + /// Verify password against stored hash + static bool verifyPassword(String password, String storedHash) { + try { + final parts = storedHash.split(':'); + if (parts.length != 2) { + // Legacy hash without salt - fallback to direct comparison + final legacyHash = sha256.convert(utf8.encode(password)).toString(); + return legacyHash == storedHash; + } + + final salt = parts[0]; + final expectedHash = parts[1]; + final bytes = utf8.encode(password + salt); + final actualHash = sha256.convert(bytes).toString(); + + return actualHash == expectedHash; + } catch (e) { + return false; + } } /// Public method to hash passwords (used by auth) static String hashPassword(String password) => _hashPassword(password); + /// Get users box static Box get usersBox => Hive.box(_usersBoxName); diff --git a/lib/core/models/app_user.dart b/lib/core/models/app_user.dart index 5fdcd26..70782a2 100644 --- a/lib/core/models/app_user.dart +++ b/lib/core/models/app_user.dart @@ -1,21 +1,27 @@ import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; part 'app_user.g.dart'; +/// Hive model for local application users +/// +/// Stores user credentials with hashed passwords and admin privileges @HiveType(typeId: 0) -class AppUser { +class AppUser extends Equatable { @HiveField(0) final String id; @HiveField(1) final String username; + /// Password hash in format "salt:hash" @HiveField(2) - final String passwordHash; // SHA-256 hash + final String passwordHash; @HiveField(3) final bool isAdmin; + /// List of playlist IDs assigned to this user @HiveField(4) final List assignedPlaylistIds; @@ -26,11 +32,12 @@ class AppUser { required this.id, required this.username, required this.passwordHash, - required this.isAdmin, - required this.assignedPlaylistIds, + this.isAdmin = false, + this.assignedPlaylistIds = const [], required this.createdAt, }); + /// Creates a copy of this user with updated fields AppUser copyWith({ String? id, String? username, @@ -48,4 +55,14 @@ class AppUser { createdAt: createdAt ?? this.createdAt, ); } + + @override + List get props => [ + id, + username, + passwordHash, + isAdmin, + assignedPlaylistIds, + createdAt, + ]; } diff --git a/lib/core/models/iptv_models.dart b/lib/core/models/iptv_models.dart new file mode 100644 index 0000000..5115c8f --- /dev/null +++ b/lib/core/models/iptv_models.dart @@ -0,0 +1,189 @@ +/// Channel model for Live TV +class Channel { + final String streamId; + final String num; + final String name; + final String streamType; + final String streamIcon; + final String epgChannelId; + final String categoryId; + final String categoryName; + + const Channel({ + required this.streamId, + required this.num, + required this.name, + required this.streamType, + this.streamIcon = '', + this.epgChannelId = '', + required this.categoryId, + required this.categoryName, + }); + + factory Channel.fromJson(Map json) { + return Channel( + streamId: json['stream_id']?.toString() ?? '', + num: json['num']?.toString() ?? '', + name: json['name']?.toString() ?? '', + streamType: json['stream_type']?.toString() ?? '', + streamIcon: json['stream_icon']?.toString() ?? '', + epgChannelId: json['epg_channel_id']?.toString() ?? '', + categoryId: json['category_id']?.toString() ?? '', + categoryName: json['category_name']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'stream_id': streamId, + 'num': num, + 'name': name, + 'stream_type': streamType, + 'stream_icon': streamIcon, + 'epg_channel_id': epgChannelId, + 'category_id': categoryId, + 'category_name': categoryName, + }; + } +} + +/// VOD (Video on Demand) model for Movies +class VodItem { + final String streamId; + final String name; + final String streamIcon; + final String rating; + final String categoryId; + final String categoryName; + final String containerExtension; + + const VodItem({ + required this.streamId, + required this.name, + this.streamIcon = '', + this.rating = '', + required this.categoryId, + required this.categoryName, + this.containerExtension = 'mp4', + }); + + factory VodItem.fromJson(Map json) { + return VodItem( + streamId: json['stream_id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + streamIcon: json['stream_icon']?.toString() ?? '', + rating: json['rating']?.toString() ?? '', + categoryId: json['category_id']?.toString() ?? '', + categoryName: json['category_name']?.toString() ?? '', + containerExtension: json['container_extension']?.toString() ?? 'mp4', + ); + } + + Map toJson() { + return { + 'stream_id': streamId, + 'name': name, + 'stream_icon': streamIcon, + 'rating': rating, + 'category_id': categoryId, + 'category_name': categoryName, + 'container_extension': containerExtension, + }; + } +} + +/// Series model +class Series { + final String seriesId; + final String name; + final String cover; + final String plot; + final String categoryId; + final String categoryName; + + const Series({ + required this.seriesId, + required this.name, + this.cover = '', + this.plot = '', + required this.categoryId, + required this.categoryName, + }); + + factory Series.fromJson(Map json) { + return Series( + seriesId: json['series_id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + cover: json['cover']?.toString() ?? '', + plot: json['plot']?.toString() ?? '', + categoryId: json['category_id']?.toString() ?? '', + categoryName: json['category_name']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'series_id': seriesId, + 'name': name, + 'cover': cover, + 'plot': plot, + 'category_id': categoryId, + 'category_name': categoryName, + }; + } +} + +/// EPG (Electronic Program Guide) entry +class EpgEntry { + final String id; + final String epgId; + final String title; + final String lang; + final String start; + final String end; + final String description; + final String channelId; + + const EpgEntry({ + required this.id, + required this.epgId, + required this.title, + this.lang = '', + required this.start, + required this.end, + this.description = '', + required this.channelId, + }); + + factory EpgEntry.fromJson(Map json) { + return EpgEntry( + id: json['id']?.toString() ?? '', + epgId: json['epg_id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + lang: json['lang']?.toString() ?? '', + start: json['start']?.toString() ?? '', + end: json['end']?.toString() ?? '', + description: json['description']?.toString() ?? '', + channelId: json['channel_id']?.toString() ?? '', + ); + } + + /// Calculate progress percentage for current program + double getProgress() { + try { + final startTime = DateTime.parse(start); + final endTime = DateTime.parse(end); + final now = DateTime.now(); + + if (now.isBefore(startTime)) return 0.0; + if (now.isAfter(endTime)) return 1.0; + + final totalDuration = endTime.difference(startTime).inSeconds; + final elapsed = now.difference(startTime).inSeconds; + + return elapsed / totalDuration; + } catch (e) { + return 0.0; + } + } +} diff --git a/lib/core/models/playlist_config.dart b/lib/core/models/playlist_config.dart index c546a1a..1e9f939 100644 --- a/lib/core/models/playlist_config.dart +++ b/lib/core/models/playlist_config.dart @@ -1,23 +1,29 @@ import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; part 'playlist_config.g.dart'; +/// Hive model for Xtream Codes playlist configuration +/// +/// Stores credentials for connecting to an Xtream server @HiveType(typeId: 1) -class PlaylistConfig { +class PlaylistConfig extends Equatable { @HiveField(0) final String id; @HiveField(1) final String name; + /// Xtream server URL (without trailing slash) @HiveField(2) - final String dns; // Xtream server URL (without trailing slash) + final String dns; @HiveField(3) final String username; + /// Xtream password (stored encrypted by Hive) @HiveField(4) - final String password; // Will be encrypted by Hive + final String password; @HiveField(5) final DateTime createdAt; @@ -35,6 +41,7 @@ class PlaylistConfig { this.isActive = true, }); + /// Creates a copy of this playlist with updated fields PlaylistConfig copyWith({ String? id, String? name, @@ -57,4 +64,16 @@ class PlaylistConfig { /// Generate Xtream API base URL String get apiBaseUrl => '$dns/player_api.php'; + + @override + List get props => [ + id, + name, + dns, + username, + password, + createdAt, + isActive, + ]; } + diff --git a/lib/core/utils/crypto_utils.dart b/lib/core/utils/crypto_utils.dart new file mode 100644 index 0000000..f9a178f --- /dev/null +++ b/lib/core/utils/crypto_utils.dart @@ -0,0 +1,52 @@ +import 'package:crypto/crypto.dart'; +import 'package:uuid/uuid.dart'; +import 'dart:convert'; + +/// Utility class for secure password hashing and verification +class CryptoUtils { + static const _uuid = Uuid(); + + /// Generates a random salt using UUID v4 + static String generateSalt() { + return _uuid.v4(); + } + + /// Hashes a password with the provided salt using SHA-256 + /// + /// Returns the hashed password as a hex string + static String hashPassword(String password, String salt) { + final bytes = utf8.encode(password + salt); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + /// Verifies a password against a stored hash + /// + /// [input] The password to verify + /// [storedHash] The complete stored hash in format "salt:hash" + /// + /// Returns true if the password matches + static bool verifyPassword(String input, String storedHash) { + try { + final parts = storedHash.split(':'); + if (parts.length != 2) return false; + + final salt = parts[0]; + final expectedHash = parts[1]; + final inputHash = hashPassword(input, salt); + + return inputHash == expectedHash; + } catch (e) { + return false; + } + } + + /// Creates a complete password hash in the format "salt:hash" + /// + /// This is the format stored in the database + static String createPasswordHash(String password) { + final salt = generateSalt(); + final hash = hashPassword(password, salt); + return '$salt:$hash'; + } +} diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart index 9f89ea6..80b15dd 100644 --- a/lib/features/auth/providers/auth_provider.dart +++ b/lib/features/auth/providers/auth_provider.dart @@ -40,14 +40,18 @@ class AuthNotifier extends StateNotifier { try { final usersBox = HiveService.usersBox; - final passwordHash = HiveService.hashPassword(password); - // Search for user with matching credentials + // Search for user by username final user = usersBox.values.firstWhere( - (user) => user.username == username && user.passwordHash == passwordHash, - orElse: () => throw Exception('Invalid credentials'), + (user) => user.username == username, + orElse: () => throw Exception('User not found'), ); + // Verify password using salt-based hashing + if (!HiveService.verifyPassword(password, user.passwordHash)) { + throw Exception('Invalid password'); + } + state = AuthState(currentUser: user, isLoading: false); return true; } catch (e) { @@ -59,6 +63,7 @@ class AuthNotifier extends StateNotifier { } } + /// Logout current user void logout() { state = const AuthState(); diff --git a/lib/features/iptv/providers/xtream_provider.dart b/lib/features/iptv/providers/xtream_provider.dart new file mode 100644 index 0000000..87711fa --- /dev/null +++ b/lib/features/iptv/providers/xtream_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/models/playlist_config.dart'; +import '../services/xtream_service.dart'; + +/// Singleton Xtream service provider +final xtreamServiceProvider = Provider((ref) { + final service = XtreamService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +/// Current selected playlist provider +final selectedPlaylistProvider = StateProvider((ref) => null); + +/// Provider that watches for playlist changes and updates Xtream service +final activeXtreamServiceProvider = Provider((ref) { + final service = ref.watch(xtreamServiceProvider); + final playlist = ref.watch(selectedPlaylistProvider); + + if (playlist != null) { + service.setPlaylist(playlist); + } + + return service; +}); + +/// Provider for live TV channels grouped by category +final liveChannelsProvider = FutureProvider.autoDispose((ref) async { + final service = ref.watch(activeXtreamServiceProvider); + return await service.getLiveChannels(); +}); + +/// Provider for VOD items (movies) grouped by category +final vodItemsProvider = FutureProvider.autoDispose((ref) async { + final service = ref.watch(activeXtreamServiceProvider); + return await service.getVodItems(); +}); + +/// Provider for series grouped by category +final seriesProvider = FutureProvider.autoDispose((ref) async { + final service = ref.watch(activeXtreamServiceProvider); + return await service.getSeries(); +}); + +/// Provider for EPG of a specific stream +final epgProvider = FutureProvider.family.autoDispose((ref, String streamId) async { + final service = ref.watch(activeXtreamServiceProvider); + return await service.getShortEpg(streamId); +}); diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart new file mode 100644 index 0000000..2164666 --- /dev/null +++ b/lib/features/iptv/screens/player_screen.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:video_player/video_player.dart'; +import 'package:chewie/chewie.dart'; +import '../providers/xtream_provider.dart'; +import '../widgets/epg_overlay.dart'; +import '../../../core/models/iptv_models.dart'; + +/// Stream type enum for player +enum StreamType { live, vod, series } + +/// Video player screen with EPG overlay for live streams +class PlayerScreen extends ConsumerStatefulWidget { + final String streamId; + final String title; + final StreamType streamType; + final String containerExtension; + + const PlayerScreen({ + super.key, + required this.streamId, + required this.title, + this.streamType = StreamType.live, + this.containerExtension = 'm3u8', + }); + + @override + ConsumerState createState() => _PlayerScreenState(); +} + +class _PlayerScreenState extends ConsumerState { + late VideoPlayerController _videoPlayerController; + ChewieController? _chewieController; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _initializePlayer(); + } + + Future _initializePlayer() async { + try { + final xtreamService = ref.read(activeXtreamServiceProvider); + + // Generate stream URL based on type + late String streamUrl; + switch (widget.streamType) { + case StreamType.live: + streamUrl = xtreamService.getLiveStreamUrl(widget.streamId); + break; + case StreamType.vod: + streamUrl = xtreamService.getVodStreamUrl( + widget.streamId, + widget.containerExtension, + ); + break; + case StreamType.series: + streamUrl = xtreamService.getSeriesStreamUrl( + widget.streamId, + widget.containerExtension, + ); + break; + } + + _videoPlayerController = VideoPlayerController.networkUrl( + Uri.parse(streamUrl), + ); + + await _videoPlayerController.initialize(); + + _chewieController = ChewieController( + videoPlayerController: _videoPlayerController, + autoPlay: true, + looping: false, + aspectRatio: 16 / 9, + allowFullScreen: true, + allowMuting: true, + showControls: true, + placeholder: Container( + color: Colors.black, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + errorBuilder: (context, errorMessage) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text( + 'Error: $errorMessage', + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ); + + setState(() { + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + + @override + void dispose() { + _videoPlayerController.dispose(); + _chewieController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + backgroundColor: Colors.black, + ), + backgroundColor: Colors.black, + body: _isLoading + ? const Center( + child: CircularProgressIndicator(), + ) + : _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: Colors.red, + ), + const SizedBox(height: 16), + Text( + 'Failed to load stream', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + _errorMessage!, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + ], + ), + ) + : Stack( + children: [ + Center( + child: _chewieController != null + ? Chewie(controller: _chewieController!) + : const SizedBox.shrink(), + ), + // EPG overlay for live streams + if (widget.streamType == StreamType.live) + Positioned( + bottom: 80, + left: 0, + right: 0, + child: EpgOverlay(streamId: widget.streamId), + ), + ], + ), + ); + } +} diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index e591ff3..a90d29f 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -1,201 +1,225 @@ import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; +import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import '../../../core/models/playlist_config.dart'; -import '../models/xtream_models.dart'; +import '../../../core/models/iptv_models.dart'; +/// Xtream Codes API Service +/// +/// Handles all communication with Xtream API servers class XtreamService { - final PlaylistConfig config; late final Dio _dio; + late final CacheOptions _cacheOptions; + + PlaylistConfig? _currentPlaylist; - // Cache for reducing API calls - final Map _cache = {}; - static const _cacheDuration = Duration(minutes: 10); - final Map _cacheTimestamps = {}; + XtreamService() { + _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + )); - XtreamService(this.config) { - _dio = Dio( - BaseOptions( - baseUrl: config.apiBaseUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 30), - queryParameters: { - 'username': config.username, - 'password': config.password, - }, - ), + // Setup caching for API responses + _cacheOptions = CacheOptions( + store: HiveCacheStore('./cache'), + policy: CachePolicy.forceCache, + maxStale: const Duration(hours: 1), + priority: CachePriority.high, ); - // Add caching interceptor - _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - // Check cache before making request - final cacheKey = options.uri.toString(); - if (_cache.containsKey(cacheKey)) { - final timestamp = _cacheTimestamps[cacheKey]; - if (timestamp != null && - DateTime.now().difference(timestamp) < _cacheDuration) { - // Return cached response - return handler.resolve( - Response( - requestOptions: options, - data: _cache[cacheKey], - ), - ); - } - } - handler.next(options); - }, - onResponse: (response, handler) { - // Cache successful responses - final cacheKey = response.requestOptions.uri.toString(); - _cache[cacheKey] = response.data; - _cacheTimestamps[cacheKey] = DateTime.now(); - handler.next(response); - }, - ), - ); + _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); + } + + /// Initialize connection with a playlist + void setPlaylist(PlaylistConfig playlist) { + _currentPlaylist = playlist; + } + + /// Generate stream URL for live TV + String getLiveStreamUrl(String streamId) { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + return '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8'; + } + + /// Generate stream URL for VOD (movies) + String getVodStreamUrl(String streamId, String containerExtension) { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + return '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; + } + + /// Generate stream URL for series episodes + String getSeriesStreamUrl(String streamId, String containerExtension) { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + return '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; } /// Authenticate and get server info Future> authenticate() async { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + try { - final response = await _dio.get('', queryParameters: { - 'username': config.username, - 'password': config.password, - }); + final response = await _dio.get( + _currentPlaylist!.apiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + }, + options: Options(extra: _cacheOptions.toExtra()), + ); + return response.data as Map; } catch (e) { throw Exception('Authentication failed: $e'); } } - /// Get live streams with pagination - Future> getLiveStreams({ - String? categoryId, - int offset = 0, - int limit = 100, - }) async { + /// Get all live TV channels grouped by category + /// + /// Returns a Map where key is category name and value is list of channels + Future>> getLiveChannels() async { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + try { - final params = { - 'username': config.username, - 'password': config.password, - 'action': 'get_live_streams', - if (categoryId != null) 'category_id': categoryId, - }; + final response = await _dio.get( + _currentPlaylist!.apiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_live_streams', + }, + options: Options(extra: _cacheOptions.toExtra()), + ); - final response = await _dio.get('', queryParameters: params); - final data = response.data as List; + final List streams = response.data as List; + final Map> groupedChannels = {}; - // Apply manual pagination (Xtream doesn't support it natively) - final paginatedData = data.skip(offset).take(limit).toList(); + for (final streamData in streams) { + final channel = Channel.fromJson(streamData as Map); + final categoryName = channel.categoryName.isEmpty ? 'Uncategorized' : channel.categoryName; - return paginatedData - .map((json) => LiveChannel.fromJson(json as Map)) + if (!groupedChannels.containsKey(categoryName)) { + groupedChannels[categoryName] = []; + } + groupedChannels[categoryName]!.add(channel); + } + + return groupedChannels; + } catch (e) { + throw Exception('Failed to fetch live channels: $e'); + } + } + + /// Get all VOD items (movies) grouped by category + Future>> getVodItems() async { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + try { + final response = await _dio.get( + _currentPlaylist!.apiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_vod_streams', + }, + options: Options(extra: _cacheOptions.toExtra()), + ); + + final List vods = response.data as List; + final Map> groupedVods = {}; + + for (final vodData in vods) { + final vod = VodItem.fromJson(vodData as Map); + final categoryName = vod.categoryName.isEmpty ? 'Uncategorized' : vod.categoryName; + + if (!groupedVods.containsKey(categoryName)) { + groupedVods[categoryName] = []; + } + groupedVods[categoryName]!.add(vod); + } + + return groupedVods; + } catch (e) { + throw Exception('Failed to fetch VOD items: $e'); + } + } + + /// Get all series grouped by category + Future>> getSeries() async { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + try { + final response = await _dio.get( + _currentPlaylist!.apiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_series', + }, + options: Options(extra: _cacheOptions.toExtra()), + ); + + final List seriesList = response.data as List; + final Map> groupedSeries = {}; + + for (final seriesData in seriesList) { + final series = Series.fromJson(seriesData as Map); + final categoryName = series.categoryName.isEmpty ? 'Uncategorized' : series.categoryName; + + if (!groupedSeries.containsKey(categoryName)) { + groupedSeries[categoryName] = []; + } + groupedSeries[categoryName]!.add(series); + } + + return groupedSeries; + } catch (e) { + throw Exception('Failed to fetch series: $e'); + } + } + + /// Get short EPG for a specific stream + /// + /// Returns "Now" and "Next" program info + Future> getShortEpg(String streamId) async { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + try { + final response = await _dio.get( + _currentPlaylist!.apiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_short_epg', + 'stream_id': streamId, + }, + options: Options( + extra: CacheOptions( + store: _cacheOptions.store, + policy: CachePolicy.request, + maxStale: const Duration(minutes: 5), // EPG changes frequently + ).toExtra(), + ), + ); + + if (response.data == null || response.data['epg_listings'] == null) { + return []; + } + + final List epgData = response.data['epg_listings'] as List; + return epgData + .map((entry) => EpgEntry.fromJson(entry as Map)) .toList(); } catch (e) { - throw Exception('Failed to load live streams: $e'); + // EPG is optional, don't throw on failure + return []; } } - /// Get VOD streams (movies) with pagination - Future> getMovies({ - String? categoryId, - int offset = 0, - int limit = 100, - }) async { - try { - final params = { - 'username': config.username, - 'password': config.password, - 'action': 'get_vod_streams', - if (categoryId != null) 'category_id': categoryId, - }; - - final response = await _dio.get('', queryParameters: params); - final data = response.data as List; - - final paginatedData = data.skip(offset).take(limit).toList(); - - return paginatedData - .map((json) => Movie.fromJson(json as Map)) - .toList(); - } catch (e) { - throw Exception('Failed to load movies: $e'); - } - } - - /// Get series with pagination - Future> getSeries({ - String? categoryId, - int offset = 0, - int limit = 100, - }) async { - try { - final params = { - 'username': config.username, - 'password': config.password, - 'action': 'get_series', - if (categoryId != null) 'category_id': categoryId, - }; - - final response = await _dio.get('', queryParameters: params); - final data = response.data as List; - - final paginatedData = data.skip(offset).take(limit).toList(); - - return paginatedData - .map((json) => Series.fromJson(json as Map)) - .toList(); - } catch (e) { - throw Exception('Failed to load series: $e'); - } - } - - /// Get live categories - Future> getLiveCategories() async { - try { - final response = await _dio.get('', queryParameters: { - 'username': config.username, - 'password': config.password, - 'action': 'get_live_categories', - }); - - final data = response.data as List; - return data - .map((json) => Category.fromJson(json as Map)) - .toList(); - } catch (e) { - throw Exception('Failed to load categories: $e'); - } - } - - /// Get short EPG for a specific channel - Future getShortEPG(String channelId) async { - try { - final response = await _dio.get('', queryParameters: { - 'username': config.username, - 'password': config.password, - 'action': 'get_short_epg', - 'stream_id': channelId, - 'limit': '2', // Only get current and next - }); - - return ShortEPG.fromJson(response.data as Map); - } catch (e) { - // EPG might not be available for all channels - return const ShortEPG(); - } - } - - /// Clear cache - void clearCache() { - _cache.clear(); - _cacheTimestamps.clear(); + /// Dispose resources + void dispose() { + _dio.close(); } } - -/// Provider for Xtream service (requires playlist config) -final xtreamServiceProvider = Provider.family( - (ref, config) => XtreamService(config), -); diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart new file mode 100644 index 0000000..d8b3eb9 --- /dev/null +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/xtream_provider.dart'; + +/// EPG overlay widget showing "Now" and "Next" programs +class EpgOverlay extends ConsumerWidget { + final String streamId; + + const EpgOverlay({ + super.key, + required this.streamId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final epgAsync = ref.watch(epgProvider(streamId)); + + return epgAsync.when( + data: (epgEntries) { + if (epgEntries.isEmpty) { + return const SizedBox.shrink(); + } + + // Get "Now" and "Next" programs + final now = DateTime.now(); + final currentProgram = epgEntries.firstWhere( + (entry) { + final start = DateTime.parse(entry.start); + final end = DateTime.parse(entry.end); + return now.isAfter(start) && now.isBefore(end); + }, + orElse: () => epgEntries.first, + ); + + final progress = currentProgram.getProgress(); + + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Current program + Text( + 'NOW: ${currentProgram.title}', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // Progress bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.red), + minHeight: 4, + ), + ), + const SizedBox(height: 4), + // Time info + Text( + '${_formatTime(currentProgram.start)} - ${_formatTime(currentProgram.end)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + ), + ), + // Description if available + if (currentProgram.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + currentProgram.description, + style: const TextStyle( + color: Colors.white60, + fontSize: 11, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + // Next program + if (epgEntries.length > 1) ...[ + const SizedBox(height: 8), + const Divider(color: Colors.white24, height: 1), + const SizedBox(height: 8), + Text( + 'NEXT: ${epgEntries[1].title}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + _formatTime(epgEntries[1].start), + style: const TextStyle( + color: Colors.white60, + fontSize: 10, + ), + ), + ], + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } + + String _formatTime(String isoString) { + try { + final dateTime = DateTime.parse(isoString); + return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } catch (e) { + return isoString; + } + } +} diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 8118330..6d7795c 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -1,197 +1,183 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../../../core/models/playlist_config.dart'; -import '../services/xtream_service.dart'; -import '../models/xtream_models.dart'; -import '../screens/video_player_screen.dart'; -import 'epg_widget.dart'; +import '../providers/xtream_provider.dart'; +import '../screens/player_screen.dart'; +import '../../../core/models/iptv_models.dart'; +/// Live TV tab with group-based pagination class LiveTVTab extends ConsumerStatefulWidget { - final PlaylistConfig playlist; - - const LiveTVTab({super.key, required this.playlist}); + const LiveTVTab({super.key}); @override ConsumerState createState() => _LiveTVTabState(); } -class _LiveTVTabState extends ConsumerState { - final ScrollController _scrollController = ScrollController(); - final List _channels = []; - bool _isLoading = false; - bool _hasMore = true; - int _currentOffset = 0; - static const int _pageSize = 100; +class _LiveTVTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { + final Map _currentPages = {}; + final Map _expandedCategories = {}; + static const int _itemsPerPage = 100; @override - void initState() { - super.initState(); - _loadMoreChannels(); - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8) { - _loadMoreChannels(); - } - } - - Future _loadMoreChannels() async { - if (_isLoading || !_hasMore) return; - - setState(() { - _isLoading = true; - }); - - try { - final service = ref.read(xtreamServiceProvider(widget.playlist)); - final newChannels = await service.getLiveStreams( - offset: _currentOffset, - limit: _pageSize, - ); - - setState(() { - _channels.addAll(newChannels); - _currentOffset += _pageSize; - _hasMore = newChannels.length == _pageSize; - _isLoading = false; - }); - } catch (e) { - setState(() { - _isLoading = false; - }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to load channels: $e')), - ); - } - } - } + bool get wantKeepAlive => true; @override Widget build(BuildContext context) { - if (_channels.isEmpty && !_isLoading) { - return const Center(child: Text('No channels available')); - } + super.build(context); - return GridView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.75, - ), - itemCount: _channels.length + (_hasMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _channels.length) { - return const Center(child: CircularProgressIndicator()); + final channelsAsync = ref.watch(liveChannelsProvider); + + return channelsAsync.when( + data: (groupedChannels) { + if (groupedChannels.isEmpty) { + return const Center( + child: Text('No live channels available'), + ); } - final channel = _channels[index]; - return _ChannelCard( - channel: channel, - playlist: widget.playlist, - ); - }, - ); - } -} + final categories = groupedChannels.keys.toList()..sort(); -class _ChannelCard extends StatelessWidget { - final LiveChannel channel; - final PlaylistConfig playlist; + return ListView.builder( + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + final channels = groupedChannels[category]!; + final currentPage = _currentPages[category] ?? 0; + final isExpanded = _expandedCategories[category] ?? false; - const _ChannelCard({ - required this.channel, - required this.playlist, - }); + // Calculate pagination + final totalPages = (channels.length / _itemsPerPage).ceil(); + final startIndex = currentPage * _itemsPerPage; + final endIndex = (startIndex + _itemsPerPage > channels.length) + ? channels.length + : startIndex + _itemsPerPage; + final paginatedChannels = channels.sublist(startIndex, endIndex); - @override - Widget build(BuildContext context) { - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () { - final streamUrl = channel.getStreamUrl( - playlist.dns, - playlist.username, - playlist.password, - ); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - streamUrl: streamUrl, - title: channel.name, - posterUrl: channel.streamIcon, - ), - ), - ); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: channel.streamIcon != null - ? CachedNetworkImage( - imageUrl: channel.streamIcon!, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Colors.grey.shade800, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - errorWidget: (context, url, error) => Container( - color: Colors.grey.shade800, - child: const Icon(Icons.live_tv, size: 48), - ), - ) - : Container( - color: Colors.grey.shade800, - child: const Icon(Icons.live_tv, size: 48), - ), - ), - Container( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: ExpansionPanelList( + expansionCallback: (panelIndex, expanded) { + setState(() { + _expandedCategories[category] = !expanded; + }); + }, children: [ - Text( - channel.name, - style: GoogleFonts.roboto( - fontSize: 12, - fontWeight: FontWeight.w500, + ExpansionPanel( + headerBuilder: (context, isExpanded) { + return ListTile( + title: Text( + category, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text('${channels.length} channels'), + ); + }, + body: Column( + children: [ + // Pagination controls + if (totalPages > 1) + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: currentPage > 0 + ? () { + setState(() { + _currentPages[category] = currentPage - 1; + }); + } + : null, + ), + Text( + 'Page ${currentPage + 1} of $totalPages', + style: Theme.of(context).textTheme.bodyMedium, + ), + IconButton( + icon: const Icon(Icons.arrow_forward), + onPressed: currentPage < totalPages - 1 + ? () { + setState(() { + _currentPages[category] = currentPage + 1; + }); + } + : null, + ), + ], + ), + ), + // Channel list + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: paginatedChannels.length, + itemExtent: 72, + itemBuilder: (context, channelIndex) { + final channel = paginatedChannels[channelIndex]; + return _buildChannelTile(context, channel); + }, + ), + ], ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, + isExpanded: isExpanded, ), - const SizedBox(height: 4), - // EPG Display - if (channel.epgChannelId != null) - EPGWidget( - channelId: channel.streamId, - playlist: playlist, - ), ], ), - ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Error loading channels: $error'), ], ), ), ); } + + Widget _buildChannelTile(BuildContext context, Channel channel) { + return ListTile( + leading: channel.streamIcon.isNotEmpty + ? CachedNetworkImage( + imageUrl: channel.streamIcon, + width: 48, + height: 48, + fit: BoxFit.cover, + placeholder: (context, url) => const Icon(Icons.tv), + errorWidget: (context, url, error) => const Icon(Icons.broken_image), + ) + : const Icon(Icons.tv), + title: Text( + channel.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: channel.num.isNotEmpty + ? Text('Ch. ${channel.num}') + : null, + trailing: const Icon(Icons.play_circle_outline), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PlayerScreen( + streamId: channel.streamId, + title: channel.name, + streamType: StreamType.live, + ), + ), + ); + }, + ); + } } diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 9b7a11f..87b829f 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -1,13 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../../../core/models/playlist_config.dart'; -import '../services/xtream_service.dart'; -import '../models/xtream_models.dart'; -import '../screens/video_player_screen.dart'; - -class MoviesTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; const MoviesTab({super.key, required this.playlist}); diff --git a/pubspec.yaml b/pubspec.yaml index c2d2ecf..6c7bcda 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,41 +11,46 @@ dependencies: sdk: flutter # State Management - flutter_riverpod: ^2.6.1 + flutter_riverpod: ^2.5.1 + riverpod_annotation: ^2.3.5 - # Local Database + # Local Database (Web IndexedDB) hive: ^2.2.3 hive_flutter: ^1.1.0 # Networking - dio: ^5.7.0 + dio: ^5.4.0 + dio_cache_interceptor: ^3.5.0 + dio_cache_interceptor_hive_store: ^3.2.2 # Routing - go_router: ^14.6.2 + go_router: ^13.0.0 # Video Player - media_kit: ^1.1.11 - media_kit_video: ^1.2.5 - media_kit_libs_video: ^1.0.5 + video_player: ^2.8.2 + chewie: ^1.7.5 - # UI - google_fonts: ^6.2.1 - cached_network_image: ^3.4.1 + # UI Components + google_fonts: ^6.1.0 + cached_network_image: ^3.3.1 # Security - flutter_secure_storage: ^9.2.2 - crypto: ^3.0.6 + crypto: ^3.0.3 + uuid: ^4.3.3 - # Utils - uuid: ^4.5.1 + # Utilities intl: ^0.19.0 + equatable: ^2.0.5 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^3.0.0 + + # Code Generation + build_runner: ^2.4.8 + riverpod_generator: ^2.3.11 hive_generator: ^2.0.1 - build_runner: ^2.4.13 - + flutter: uses-material-design: true