commit 2a28eb44d85f5f51e610968eb076f20adad9fe6a Author: Michael SCHAL Date: Fri Dec 5 09:17:11 2025 +0100 feat: Initialize XtremFlow project with core structure, IPTV features, authentication, and video player. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a5bb18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Docker +.dockerignore + +# Hive data (local development) +*.hive +*.lock diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0807ddc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Multi-stage Dockerfile for Flutter Web IPTV Application +# Stage 1: Build Flutter Web Application +FROM ghcr.io/cirruslabs/flutter:stable AS builder + +WORKDIR /app + +# Copy pubspec files +COPY pubspec.yaml pubspec.lock ./ + +# Get dependencies +RUN flutter pub get + +# Copy entire project +COPY . . + +# Build Flutter Web with CanvasKit renderer for 60fps performance +RUN flutter build web --release --web-renderer canvaskit + +# Stage 2: Serve with dhttpd (Dart HTTP Server) +FROM dart:stable AS runtime + +# Install dhttpd globally +RUN dart pub global activate dhttpd + +# Create app directory +WORKDIR /app + +# Copy built web files from builder stage +COPY --from=builder /app/build/web /app/web + +# Create data directory for Hive persistence +RUN mkdir -p /app/data + +# 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 +CMD ["dart", "pub", "global", "run", "dhttpd", "--host", "0.0.0.0", "--port", "8080", "--path", "/app/web"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..30fa194 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# XtremFlow IPTV Web Application + +High-performance Flutter Web IPTV application with Xtream Codes API integration, local authentication, and Docker deployment. + +## Features + +- **Local Authentication**: Private admin panel (default: `admin/admin`) +- **Multi-Playlist Support**: Manage multiple Xtream providers +- **Admin Panel**: Full CRUD for users and playlists +- **Live TV / Movies / Series**: Lazy-loaded grids with infinite scroll +- **Memory Optimized**: Handles 20k+ channels without freezing +- **Docker Deployment**: Single command deployment with external network support + +## Quick Start + +### Local Development (Requires Flutter SDK) + +```bash +# Install dependencies +flutter pub get + +# Run web app +flutter run -d chrome +``` + +### Docker Deployment + +```bash +# Build and run +docker-compose up -d + +# View logs +docker-compose logs -f iptv-web +``` + +**Prerequisites**: +- Docker external network `nginx_default` must exist +- Configure reverse proxy to route traffic to container + +## Default Credentials + +- Username: `admin` +- Password: `admin` + +**Important**: Change default password after first login via Admin Panel. + +## Architecture + +- **Frontend**: Flutter Web (CanvasKit renderer) +- **State Management**: Riverpod +- **Database**: Hive with AES encryption +- **Networking**: Dio with caching +- **Video Player**: MediaKit +- **Deployment**: Multi-stage Docker with dhttpd + +## Admin Panel + +Access at `/admin` route (admin users only): +- Create/Edit/Delete users +- Manage Xtream playlist credentials +- Assign playlists to users + +## Performance + +- 60fps target with CanvasKit renderer +- Pagination: 100 items per request +- Image caching with `cached_network_image` +- Request caching: 10-minute TTL + +## Security + +- SHA-256 password hashing +- AES-256 Hive encryption +- Secure storage for encryption keys +- No credentials in code + +## License + +Private use only. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f97b24f --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + - prefer_const_constructors + - prefer_const_literals_to_create_immutables + - avoid_print + - require_trailing_commas diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e435217 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + iptv-web: + build: + context: . + dockerfile: Dockerfile + container_name: iptv-web + + # Volume for Hive data persistence + volumes: + - iptv_data:/app/data + + # Use external nginx network + networks: + - nginx_default + + # Environment variables + environment: + - HIVE_STORAGE_PATH=/app/data + + # Restart policy + restart: unless-stopped + + # Health check + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +# 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 new file mode 100644 index 0000000..6864749 --- /dev/null +++ b/lib/core/database/hive_service.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '../models/app_user.dart'; +import '../models/playlist_config.dart'; + +class HiveService { + static const String _usersBoxName = 'users'; + static const String _playlistsBoxName = 'playlists'; + static const String _encryptionKeyName = 'hive_encryption_key'; + + static const _storage = FlutterSecureStorage(); + static bool _initialized = false; + + /// Initialize Hive for Web with encryption + static Future init() async { + if (_initialized) return; + + // Initialize Hive for Web (uses IndexedDB) + await Hive.initFlutter(); + + // Generate or retrieve encryption key + final encryptionKey = await _getOrCreateEncryptionKey(); + + // Register adapters + Hive.registerAdapter(AppUserAdapter()); + Hive.registerAdapter(PlaylistConfigAdapter()); + + // Open encrypted boxes + await Hive.openBox( + _usersBoxName, + encryptionCipher: HiveAesCipher(encryptionKey), + ); + + await Hive.openBox( + _playlistsBoxName, + encryptionCipher: HiveAesCipher(encryptionKey), + ); + + // Seed default admin if no users exist + await _seedDefaultAdmin(); + + _initialized = true; + } + + /// Get or create 256-bit encryption key + static Future> _getOrCreateEncryptionKey() async { + try { + final existingKey = await _storage.read(key: _encryptionKeyName); + + if (existingKey != null) { + return base64Decode(existingKey); + } + } catch (e) { + // Storage might not be available in some web contexts + // Fall back to session-based key (not persisted) + } + + // Generate new 256-bit key + final key = Hive.generateSecureKey(); + + try { + await _storage.write( + key: _encryptionKeyName, + value: base64Encode(key), + ); + } catch (e) { + // Ignore if storage fails - key will be regenerated next session + } + + return key; + } + + /// Seed default admin user (admin/admin) + static Future _seedDefaultAdmin() async { + final usersBox = Hive.box(_usersBoxName); + + if (usersBox.isEmpty) { + final adminId = const Uuid().v4(); + final passwordHash = _hashPassword('admin'); + + final admin = AppUser( + id: adminId, + username: 'admin', + passwordHash: passwordHash, + isAdmin: true, + assignedPlaylistIds: [], + createdAt: DateTime.now(), + ); + + await usersBox.put(adminId, admin); + } + } + + /// Hash password using SHA-256 + static String _hashPassword(String password) { + final bytes = utf8.encode(password); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + /// 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); + + /// Get playlists box + static Box get playlistsBox => + Hive.box(_playlistsBoxName); + + /// Close all boxes (cleanup) + static Future dispose() async { + await Hive.close(); + _initialized = false; + } +} diff --git a/lib/core/models/app_user.dart b/lib/core/models/app_user.dart new file mode 100644 index 0000000..5fdcd26 --- /dev/null +++ b/lib/core/models/app_user.dart @@ -0,0 +1,51 @@ +import 'package:hive/hive.dart'; + +part 'app_user.g.dart'; + +@HiveType(typeId: 0) +class AppUser { + @HiveField(0) + final String id; + + @HiveField(1) + final String username; + + @HiveField(2) + final String passwordHash; // SHA-256 hash + + @HiveField(3) + final bool isAdmin; + + @HiveField(4) + final List assignedPlaylistIds; + + @HiveField(5) + final DateTime createdAt; + + const AppUser({ + required this.id, + required this.username, + required this.passwordHash, + required this.isAdmin, + required this.assignedPlaylistIds, + required this.createdAt, + }); + + AppUser copyWith({ + String? id, + String? username, + String? passwordHash, + bool? isAdmin, + List? assignedPlaylistIds, + DateTime? createdAt, + }) { + return AppUser( + id: id ?? this.id, + username: username ?? this.username, + passwordHash: passwordHash ?? this.passwordHash, + isAdmin: isAdmin ?? this.isAdmin, + assignedPlaylistIds: assignedPlaylistIds ?? this.assignedPlaylistIds, + createdAt: createdAt ?? this.createdAt, + ); + } +} diff --git a/lib/core/models/app_user.g.dart b/lib/core/models/app_user.g.dart new file mode 100644 index 0000000..6029c34 --- /dev/null +++ b/lib/core/models/app_user.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_user.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AppUserAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + AppUser read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AppUser( + id: fields[0] as String, + username: fields[1] as String, + passwordHash: fields[2] as String, + isAdmin: fields[3] as bool, + assignedPlaylistIds: (fields[4] as List).cast(), + createdAt: fields[5] as DateTime, + ); + } + + @override + void write(BinaryWriter writer, AppUser obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.username) + ..writeByte(2) + ..write(obj.passwordHash) + ..writeByte(3) + ..write(obj.isAdmin) + ..writeByte(4) + ..write(obj.assignedPlaylistIds) + ..writeByte(5) + ..write(obj.createdAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppUserAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/core/models/playlist_config.dart b/lib/core/models/playlist_config.dart new file mode 100644 index 0000000..c546a1a --- /dev/null +++ b/lib/core/models/playlist_config.dart @@ -0,0 +1,60 @@ +import 'package:hive/hive.dart'; + +part 'playlist_config.g.dart'; + +@HiveType(typeId: 1) +class PlaylistConfig { + @HiveField(0) + final String id; + + @HiveField(1) + final String name; + + @HiveField(2) + final String dns; // Xtream server URL (without trailing slash) + + @HiveField(3) + final String username; + + @HiveField(4) + final String password; // Will be encrypted by Hive + + @HiveField(5) + final DateTime createdAt; + + @HiveField(6) + final bool isActive; + + const PlaylistConfig({ + required this.id, + required this.name, + required this.dns, + required this.username, + required this.password, + required this.createdAt, + this.isActive = true, + }); + + PlaylistConfig copyWith({ + String? id, + String? name, + String? dns, + String? username, + String? password, + DateTime? createdAt, + bool? isActive, + }) { + return PlaylistConfig( + id: id ?? this.id, + name: name ?? this.name, + dns: dns ?? this.dns, + username: username ?? this.username, + password: password ?? this.password, + createdAt: createdAt ?? this.createdAt, + isActive: isActive ?? this.isActive, + ); + } + + /// Generate Xtream API base URL + String get apiBaseUrl => '$dns/player_api.php'; +} diff --git a/lib/core/models/playlist_config.g.dart b/lib/core/models/playlist_config.g.dart new file mode 100644 index 0000000..d86f834 --- /dev/null +++ b/lib/core/models/playlist_config.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'playlist_config.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class PlaylistConfigAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + PlaylistConfig read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PlaylistConfig( + id: fields[0] as String, + name: fields[1] as String, + dns: fields[2] as String, + username: fields[3] as String, + password: fields[4] as String, + createdAt: fields[5] as DateTime, + isActive: fields[6] as bool, + ); + } + + @override + void write(BinaryWriter writer, PlaylistConfig obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.dns) + ..writeByte(3) + ..write(obj.username) + ..writeByte(4) + ..write(obj.password) + ..writeByte(5) + ..write(obj.createdAt) + ..writeByte(6) + ..write(obj.isActive); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaylistConfigAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..3fe8196 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../features/auth/providers/auth_provider.dart'; +import '../../features/auth/screens/login_screen.dart'; +import '../../features/iptv/screens/playlist_selection_screen.dart'; +import '../../features/iptv/screens/dashboard_screen.dart'; +import '../../features/admin/screens/admin_panel.dart'; +import '../models/playlist_config.dart'; + +final routerProvider = Provider((ref) { + final authNotifier = ref.watch(authProvider.notifier); + final authState = ref.watch(authProvider); + + return GoRouter( + initialLocation: '/login', + redirect: (context, state) { + final isLoggedIn = authState.isAuthenticated; + final isLoginRoute = state.matchedLocation == '/login'; + + // Redirect to login if not authenticated + if (!isLoggedIn && !isLoginRoute) { + return '/login'; + } + + // Redirect to playlists if already logged in and trying to access login + if (isLoggedIn && isLoginRoute) { + return '/playlists'; + } + + // Admin guard for /admin route + if (state.matchedLocation == '/admin' && !authState.isAdmin) { + return '/playlists'; + } + + return null; + }, + routes: [ + GoRoute( + path: '/login', + builder: (context, state) => const LoginScreen(), + ), + GoRoute( + path: '/playlists', + builder: (context, state) => const PlaylistSelectionScreen(), + ), + GoRoute( + path: '/dashboard', + builder: (context, state) { + final playlist = state.extra as PlaylistConfig?; + if (playlist == null) { + // Redirect back if no playlist provided + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/playlists'); + }); + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + return DashboardScreen(playlist: playlist); + }, + ), + GoRoute( + path: '/admin', + builder: (context, state) => const AdminPanel(), + ), + ], + ); +}); diff --git a/lib/features/admin/screens/admin_panel.dart b/lib/features/admin/screens/admin_panel.dart new file mode 100644 index 0000000..9c5f3f7 --- /dev/null +++ b/lib/features/admin/screens/admin_panel.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:uuid/uuid.dart'; +import '../../../core/database/hive_service.dart'; +import '../../../core/models/app_user.dart'; +import '../../../core/models/playlist_config.dart'; + +class AdminPanel extends ConsumerStatefulWidget { + const AdminPanel({super.key}); + + @override + ConsumerState createState() => _AdminPanelState(); +} + +class _AdminPanelState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + 'Admin Panel', + style: GoogleFonts.roboto(fontWeight: FontWeight.w600), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/playlists'), + ), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Users'), + Tab(text: 'Playlists'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ + _UsersTab(), + _PlaylistsTab(), + ], + ), + ); + } +} + +// ========== USERS TAB ========== +class _UsersTab extends ConsumerWidget { + const _UsersTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usersBox = HiveService.usersBox; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: () => _showUserDialog(context, ref), + icon: const Icon(Icons.add), + label: const Text('Add User'), + ), + ), + Expanded( + child: ListView.builder( + itemCount: usersBox.length, + itemBuilder: (context, index) { + final user = usersBox.getAt(index)!; + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: ListTile( + leading: CircleAvatar( + child: Icon(user.isAdmin ? Icons.admin_panel_settings : Icons.person), + ), + title: Text(user.username), + subtitle: Text( + user.isAdmin + ? 'Administrator' + : '${user.assignedPlaylistIds.length} playlist(s) assigned', + style: GoogleFonts.roboto(fontSize: 12), + ), + trailing: user.username == 'admin' + ? null + : IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _deleteUser(context, user), + ), + ), + ); + }, + ), + ), + ], + ); + } + + void _showUserDialog(BuildContext context, WidgetRef ref, [AppUser? user]) { + final usernameController = TextEditingController(text: user?.username); + final passwordController = TextEditingController(); + bool isAdmin = user?.isAdmin ?? false; + List selectedPlaylists = List.from(user?.assignedPlaylistIds ?? []); + final playlistsBox = HiveService.playlistsBox; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(user == null ? 'Add User' : 'Edit User'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: usernameController, + decoration: const InputDecoration(labelText: 'Username'), + ), + const SizedBox(height: 8), + TextField( + controller: passwordController, + decoration: InputDecoration( + labelText: user == null ? 'Password' : 'New Password (leave empty to keep)', + ), + obscureText: true, + ), + const SizedBox(height: 8), + CheckboxListTile( + title: const Text('Administrator'), + value: isAdmin, + onChanged: (value) { + setState(() { + isAdmin = value ?? false; + }); + }, + ), + const Divider(), + const SizedBox(height: 8), + // Playlist Assignment + if (playlistsBox.isNotEmpty) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Assigned Playlists', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + ), + const SizedBox(height: 8), + ...playlistsBox.values.map((playlist) { + final isSelected = selectedPlaylists.contains(playlist.id); + return CheckboxListTile( + dense: true, + title: Text(playlist.name), + subtitle: Text( + Uri.parse(playlist.dns).host, + style: const TextStyle(fontSize: 11), + ), + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + selectedPlaylists.add(playlist.id); + } else { + selectedPlaylists.remove(playlist.id); + } + }); + }, + ); + }).toList(), + ] else + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + 'No playlists available. Create playlists first.', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (usernameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Username required')), + ); + return; + } + + if (user == null && passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password required')), + ); + return; + } + + final usersBox = HiveService.usersBox; + final newUser = AppUser( + id: user?.id ?? const Uuid().v4(), + username: usernameController.text.trim(), + passwordHash: passwordController.text.isNotEmpty + ? HiveService.hashPassword(passwordController.text) + : user!.passwordHash, + isAdmin: isAdmin, + assignedPlaylistIds: selectedPlaylists, + createdAt: user?.createdAt ?? DateTime.now(), + ); + + usersBox.put(newUser.id, newUser); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ), + ); + } + + void _deleteUser(BuildContext context, AppUser user) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete User'), + content: Text('Delete user "${user.username}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + HiveService.usersBox.delete(user.id); + Navigator.pop(context); + }, + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } +} + +// ========== PLAYLISTS TAB ========== +class _PlaylistsTab extends ConsumerWidget { + const _PlaylistsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playlistsBox = HiveService.playlistsBox; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: () => _showPlaylistDialog(context, ref), + icon: const Icon(Icons.add), + label: const Text('Add Playlist'), + ), + ), + Expanded( + child: ListView.builder( + itemCount: playlistsBox.length, + itemBuilder: (context, index) { + final playlist = playlistsBox.getAt(index)!; + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: ListTile( + leading: const CircleAvatar( + child: Icon(Icons.playlist_play), + ), + title: Text(playlist.name), + subtitle: Text( + playlist.dns, + style: GoogleFonts.roboto(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _showPlaylistDialog(context, ref, playlist), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _deletePlaylist(context, playlist), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } + + void _showPlaylistDialog(BuildContext context, WidgetRef ref, [PlaylistConfig? playlist]) { + final nameController = TextEditingController(text: playlist?.name); + final dnsController = TextEditingController(text: playlist?.dns); + final usernameController = TextEditingController(text: playlist?.username); + final passwordController = TextEditingController(text: playlist?.password); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(playlist == null ? 'Add Playlist' : 'Edit Playlist'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Playlist Name'), + ), + const SizedBox(height: 8), + TextField( + controller: dnsController, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'http://server.com:8080', + ), + ), + const SizedBox(height: 8), + TextField( + controller: usernameController, + decoration: const InputDecoration(labelText: 'Username'), + ), + const SizedBox(height: 8), + TextField( + controller: passwordController, + decoration: const InputDecoration(labelText: 'Password'), + obscureText: true, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (nameController.text.trim().isEmpty || + dnsController.text.trim().isEmpty || + usernameController.text.trim().isEmpty || + passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('All fields required')), + ); + return; + } + + final playlistsBox = HiveService.playlistsBox; + final newPlaylist = PlaylistConfig( + id: playlist?.id ?? const Uuid().v4(), + name: nameController.text.trim(), + dns: dnsController.text.trim().replaceAll(RegExp(r'/$'), ''), + username: usernameController.text.trim(), + password: passwordController.text, + createdAt: playlist?.createdAt ?? DateTime.now(), + ); + + playlistsBox.put(newPlaylist.id, newPlaylist); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + void _deletePlaylist(BuildContext context, PlaylistConfig playlist) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Playlist'), + content: Text('Delete playlist "${playlist.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + HiveService.playlistsBox.delete(playlist.id); + Navigator.pop(context); + }, + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart new file mode 100644 index 0000000..9f89ea6 --- /dev/null +++ b/lib/features/auth/providers/auth_provider.dart @@ -0,0 +1,74 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/database/hive_service.dart'; +import '../../../core/models/app_user.dart'; + +/// Auth state +class AuthState { + final AppUser? currentUser; + final bool isLoading; + final String? errorMessage; + + const AuthState({ + this.currentUser, + this.isLoading = false, + this.errorMessage, + }); + + bool get isAuthenticated => currentUser != null; + bool get isAdmin => currentUser?.isAdmin ?? false; + + AuthState copyWith({ + AppUser? currentUser, + bool? isLoading, + String? errorMessage, + }) { + return AuthState( + currentUser: currentUser ?? this.currentUser, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); + } +} + +/// Auth notifier +class AuthNotifier extends StateNotifier { + AuthNotifier() : super(const AuthState()); + + /// Login with username and password + Future login(String username, String password) async { + state = state.copyWith(isLoading: true, errorMessage: null); + + try { + final usersBox = HiveService.usersBox; + final passwordHash = HiveService.hashPassword(password); + + // Search for user with matching credentials + final user = usersBox.values.firstWhere( + (user) => user.username == username && user.passwordHash == passwordHash, + orElse: () => throw Exception('Invalid credentials'), + ); + + state = AuthState(currentUser: user, isLoading: false); + return true; + } catch (e) { + state = AuthState( + isLoading: false, + errorMessage: 'Invalid username or password', + ); + return false; + } + } + + /// Logout current user + void logout() { + state = const AuthState(); + } + + /// Get current user + AppUser? get currentUser => state.currentUser; +} + +/// Auth provider +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier(); +}); diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart new file mode 100644 index 0000000..4d82f19 --- /dev/null +++ b/lib/features/auth/screens/login_screen.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../providers/auth_provider.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) return; + + final auth = ref.read(authProvider.notifier); + final success = await auth.login( + _usernameController.text.trim(), + _passwordController.text, + ); + + if (success && mounted) { + context.go('/playlists'); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.blue.shade900, + Colors.black, + ], + ), + ), + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Card( + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(32.0), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.live_tv_rounded, + size: 64, + color: Colors.blue.shade700, + ), + const SizedBox(height: 16), + Text( + 'XtremFlow IPTV', + style: GoogleFonts.roboto( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Admin Access', + style: GoogleFonts.roboto( + fontSize: 14, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + labelText: 'Username', + prefixIcon: const Icon(Icons.person), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Username required'; + } + return null; + }, + autofocus: true, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password required'; + } + return null; + }, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _handleLogin(), + ), + if (authState.errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + authState.errorMessage!, + style: TextStyle(color: Colors.red.shade700), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 24), + ElevatedButton( + onPressed: authState.isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: authState.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + 'Login', + style: TextStyle(fontSize: 16), + ), + ), + const SizedBox(height: 16), + Text( + 'Default credentials: admin / admin', + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.grey.shade500, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/iptv/models/xtream_models.dart b/lib/features/iptv/models/xtream_models.dart new file mode 100644 index 0000000..126a34b --- /dev/null +++ b/lib/features/iptv/models/xtream_models.dart @@ -0,0 +1,163 @@ +/// Xtream API Data Models (lightweight for memory optimization) + +class LiveChannel { + final String streamId; + final String name; + final String? streamIcon; + final String? categoryId; + final String streamType; + final String? epgChannelId; + + const LiveChannel({ + required this.streamId, + required this.name, + this.streamIcon, + this.categoryId, + required this.streamType, + this.epgChannelId, + }); + + factory LiveChannel.fromJson(Map json) { + return LiveChannel( + streamId: json['stream_id']?.toString() ?? '', + name: json['name']?.toString() ?? 'Unknown', + streamIcon: json['stream_icon']?.toString(), + categoryId: json['category_id']?.toString(), + streamType: json['stream_type']?.toString() ?? 'live', + epgChannelId: json['epg_channel_id']?.toString(), + ); + } + + String getStreamUrl(String dns, String username, String password) { + return '$dns/live/$username/$password/$streamId.m3u8'; + } +} + +class Movie { + final String streamId; + final String name; + final String? streamIcon; + final String? categoryId; + final String? containerExtension; + final String? rating; + + const Movie({ + required this.streamId, + required this.name, + this.streamIcon, + this.categoryId, + this.containerExtension, + this.rating, + }); + + factory Movie.fromJson(Map json) { + return Movie( + streamId: json['stream_id']?.toString() ?? '', + name: json['name']?.toString() ?? 'Unknown', + streamIcon: json['stream_icon']?.toString(), + categoryId: json['category_id']?.toString(), + containerExtension: json['container_extension']?.toString() ?? 'mp4', + rating: json['rating']?.toString(), + ); + } + + String getStreamUrl(String dns, String username, String password) { + return '$dns/movie/$username/$password/$streamId.$containerExtension'; + } +} + +class Series { + final String seriesId; + final String name; + final String? cover; + final String? categoryId; + final String? rating; + + const Series({ + required this.seriesId, + required this.name, + this.cover, + this.categoryId, + this.rating, + }); + + factory Series.fromJson(Map json) { + return Series( + seriesId: json['series_id']?.toString() ?? '', + name: json['name']?.toString() ?? 'Unknown', + cover: json['cover']?.toString(), + categoryId: json['category_id']?.toString(), + rating: json['rating']?.toString(), + ); + } +} + +class Category { + final String categoryId; + final String categoryName; + + const Category({ + required this.categoryId, + required this.categoryName, + }); + + factory Category.fromJson(Map json) { + return Category( + categoryId: json['category_id']?.toString() ?? '', + categoryName: json['category_name']?.toString() ?? 'Unknown', + ); + } +} + +class ShortEPG { + final String? nowPlaying; + final String? nextPlaying; + final double? progress; // 0.0 to 1.0 + + const ShortEPG({ + this.nowPlaying, + this.nextPlaying, + this.progress, + }); + + factory ShortEPG.fromJson(Map json) { + final epgListings = json['epg_listings'] as List?; + + if (epgListings == null || epgListings.isEmpty) { + return const ShortEPG(); + } + + String? nowTitle; + String? nextTitle; + double? currentProgress; + + final now = DateTime.now(); + + for (var i = 0; i < epgListings.length; i++) { + final listing = epgListings[i]; + final start = DateTime.tryParse(listing['start']?.toString() ?? ''); + final stop = DateTime.tryParse(listing['stop']?.toString() ?? ''); + + if (start != null && stop != null) { + if (now.isAfter(start) && now.isBefore(stop)) { + nowTitle = listing['title']?.toString(); + final duration = stop.difference(start).inSeconds; + final elapsed = now.difference(start).inSeconds; + currentProgress = elapsed / duration; + + // Get next item + if (i + 1 < epgListings.length) { + nextTitle = epgListings[i + 1]['title']?.toString(); + } + break; + } + } + } + + return ShortEPG( + nowPlaying: nowTitle, + nextPlaying: nextTitle, + progress: currentProgress, + ); + } +} diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart new file mode 100644 index 0000000..745414c --- /dev/null +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../core/models/playlist_config.dart'; +import '../widgets/live_tv_tab.dart'; +import '../widgets/movies_tab.dart'; +import '../widgets/series_tab.dart'; +import '../widgets/settings_tab.dart'; + +class DashboardScreen extends ConsumerStatefulWidget { + final PlaylistConfig playlist; + + const DashboardScreen({ + super.key, + required this.playlist, + }); + + @override + ConsumerState createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends ConsumerState { + int _selectedIndex = 0; + + late final List _tabs; + + @override + void initState() { + super.initState(); + _tabs = [ + LiveTVTab(playlist: widget.playlist), + MoviesTab(playlist: widget.playlist), + SeriesTab(playlist: widget.playlist), + const SettingsTab(), + ]; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + widget.playlist.name, + style: GoogleFonts.roboto(fontWeight: FontWeight.w600), + ), + centerTitle: false, + ), + body: _tabs[_selectedIndex], + bottomNavigationBar: NavigationBar( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() { + _selectedIndex = index; + }); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.live_tv), + label: 'Live TV', + ), + NavigationDestination( + icon: Icon(Icons.movie), + label: 'Movies', + ), + NavigationDestination( + icon: Icon(Icons.tv), + label: 'Series', + ), + NavigationDestination( + icon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } +} diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart new file mode 100644 index 0000000..94c2786 --- /dev/null +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../core/database/hive_service.dart'; +import '../../auth/providers/auth_provider.dart'; + +class PlaylistSelectionScreen extends ConsumerWidget { + const PlaylistSelectionScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentUser = ref.watch(authProvider).currentUser; + final playlistsBox = HiveService.playlistsBox; + + // Filter playlists assigned to current user (or all if admin) + final availablePlaylists = playlistsBox.values.where((playlist) { + if (currentUser == null) return false; + if (currentUser.isAdmin) return playlist.isActive; + return currentUser.assignedPlaylistIds.contains(playlist.id) && + playlist.isActive; + }).toList(); + + return Scaffold( + appBar: AppBar( + title: Text( + 'Select Playlist', + style: GoogleFonts.roboto(fontWeight: FontWeight.w600), + ), + actions: [ + if (currentUser?.isAdmin ?? false) + IconButton( + icon: const Icon(Icons.admin_panel_settings), + onPressed: () => context.go('/admin'), + tooltip: 'Admin Panel', + ), + IconButton( + icon: const Icon(Icons.logout), + onPressed: () { + ref.read(authProvider.notifier).logout(); + context.go('/login'); + }, + tooltip: 'Logout', + ), + ], + ), + body: availablePlaylists.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.playlist_remove, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'No playlists available', + style: GoogleFonts.roboto( + fontSize: 18, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + currentUser?.isAdmin ?? false + ? 'Add playlists in Admin Panel' + : 'Contact administrator', + style: GoogleFonts.roboto( + fontSize: 14, + color: Colors.grey.shade500, + ), + ), + ], + ), + ) + : GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 1.5, + ), + itemCount: availablePlaylists.length, + itemBuilder: (context, index) { + final playlist = availablePlaylists[index]; + return _PlaylistCard( + playlist: playlist, + onTap: () { + context.go('/dashboard', extra: playlist); + }, + ); + }, + ), + ); + } +} + +class _PlaylistCard extends StatelessWidget { + final dynamic playlist; + final VoidCallback onTap; + + const _PlaylistCard({ + required this.playlist, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.blue.shade700, + Colors.blue.shade900, + ], + ), + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.playlist_play, + size: 48, + color: Colors.white.withOpacity(0.9), + ), + const SizedBox(height: 12), + Text( + playlist.name, + style: GoogleFonts.roboto( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + Uri.parse(playlist.dns).host, + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.white.withOpacity(0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/iptv/screens/video_player_screen.dart b/lib/features/iptv/screens/video_player_screen.dart new file mode 100644 index 0000000..325e11f --- /dev/null +++ b/lib/features/iptv/screens/video_player_screen.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class VideoPlayerScreen extends StatefulWidget { + final String streamUrl; + final String title; + final String? posterUrl; + + const VideoPlayerScreen({ + super.key, + required this.streamUrl, + required this.title, + this.posterUrl, + }); + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + late final Player _player; + late final VideoController _controller; + bool _isFullscreen = false; + bool _showControls = true; + + @override + void initState() { + super.initState(); + + // Initialize media_kit player + _player = Player(); + _controller = VideoController(_player); + + // Load stream + _player.open(Media(widget.streamUrl)); + _player.play(); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + void _toggleFullscreen() { + setState(() { + _isFullscreen = !_isFullscreen; + }); + } + + void _toggleControls() { + setState(() { + _showControls = !_showControls; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: _isFullscreen + ? null + : AppBar( + title: Text( + widget.title, + style: GoogleFonts.roboto(fontWeight: FontWeight.w600), + ), + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + body: GestureDetector( + onTap: _toggleControls, + child: Stack( + children: [ + // Video display + Center( + child: Video( + controller: _controller, + controls: NoVideoControls, + ), + ), + + // Custom controls overlay + if (_showControls) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black54, + Colors.transparent, + Colors.transparent, + Colors.black87, + ], + stops: const [0.0, 0.2, 0.7, 1.0], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Top bar with title + if (!_isFullscreen) + const SizedBox.shrink() + else + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.title, + style: GoogleFonts.roboto( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + + // Bottom controls + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Progress bar + StreamBuilder( + stream: _player.stream.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: _player.stream.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + final progress = duration.inMilliseconds > 0 + ? position.inMilliseconds / duration.inMilliseconds + : 0.0; + + return Column( + children: [ + LinearProgressIndicator( + value: progress.clamp(0.0, 1.0), + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation( + Colors.red, + ), + ), + const SizedBox(height: 8), + if (duration.inSeconds > 0) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ], + ); + }, + ); + }, + ), + const SizedBox(height: 16), + + // Playback controls + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Rewind + IconButton( + icon: const Icon(Icons.replay_10, size: 32), + color: Colors.white, + onPressed: () { + final currentPos = _player.state.position; + _player.seek(currentPos - const Duration(seconds: 10)); + }, + ), + const SizedBox(width: 24), + + // Play/Pause + StreamBuilder( + stream: _player.stream.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + size: 48, + ), + color: Colors.white, + onPressed: () { + _player.playOrPause(); + }, + ); + }, + ), + const SizedBox(width: 24), + + // Forward + IconButton( + icon: const Icon(Icons.forward_10, size: 32), + color: Colors.white, + onPressed: () { + final currentPos = _player.state.position; + _player.seek(currentPos + const Duration(seconds: 10)); + }, + ), + const Spacer(), + + // Fullscreen toggle + IconButton( + icon: Icon( + _isFullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + size: 32, + ), + color: Colors.white, + onPressed: _toggleFullscreen, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + + // Loading indicator + StreamBuilder( + stream: _player.stream.buffering, + builder: (context, snapshot) { + final isBuffering = snapshot.data ?? false; + if (!isBuffering) return const SizedBox.shrink(); + + return const Center( + child: CircularProgressIndicator( + color: Colors.red, + strokeWidth: 3, + ), + ); + }, + ), + ], + ), + ), + ); + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${twoDigits(minutes)}:${twoDigits(seconds)}'; + } + return '${twoDigits(minutes)}:${twoDigits(seconds)}'; + } +} diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart new file mode 100644 index 0000000..e591ff3 --- /dev/null +++ b/lib/features/iptv/services/xtream_service.dart @@ -0,0 +1,201 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/models/playlist_config.dart'; +import '../models/xtream_models.dart'; + +class XtreamService { + final PlaylistConfig config; + late final Dio _dio; + + // Cache for reducing API calls + final Map _cache = {}; + static const _cacheDuration = Duration(minutes: 10); + final Map _cacheTimestamps = {}; + + 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, + }, + ), + ); + + // 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); + }, + ), + ); + } + + /// Authenticate and get server info + Future> authenticate() async { + try { + final response = await _dio.get('', queryParameters: { + 'username': config.username, + 'password': config.password, + }); + 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 { + try { + final params = { + 'username': config.username, + 'password': config.password, + 'action': 'get_live_streams', + if (categoryId != null) 'category_id': categoryId, + }; + + final response = await _dio.get('', queryParameters: params); + final data = response.data as List; + + // Apply manual pagination (Xtream doesn't support it natively) + final paginatedData = data.skip(offset).take(limit).toList(); + + return paginatedData + .map((json) => LiveChannel.fromJson(json as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to load live streams: $e'); + } + } + + /// 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(); + } +} + +/// Provider for Xtream service (requires playlist config) +final xtreamServiceProvider = Provider.family( + (ref, config) => XtreamService(config), +); diff --git a/lib/features/iptv/widgets/epg_widget.dart b/lib/features/iptv/widgets/epg_widget.dart new file mode 100644 index 0000000..692c135 --- /dev/null +++ b/lib/features/iptv/widgets/epg_widget.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../core/models/playlist_config.dart'; +import '../services/xtream_service.dart'; +import '../models/xtream_models.dart'; + +class EPGWidget extends ConsumerStatefulWidget { + final String channelId; + final PlaylistConfig playlist; + + const EPGWidget({ + super.key, + required this.channelId, + required this.playlist, + }); + + @override + ConsumerState createState() => _EPGWidgetState(); +} + +class _EPGWidgetState extends ConsumerState { + ShortEPG? _epg; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadEPG(); + } + + Future _loadEPG() async { + try { + final service = ref.read(xtreamServiceProvider(widget.playlist)); + final epg = await service.getShortEPG(widget.channelId); + + if (mounted) { + setState(() { + _epg = epg; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Container( + padding: const EdgeInsets.all(8), + child: const Row( + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + SizedBox(width: 8), + Text('Loading EPG...', style: TextStyle(fontSize: 10)), + ], + ), + ); + } + + if (_epg == null || + (_epg!.nowPlaying == null && _epg!.nextPlaying == null)) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Now Playing + if (_epg!.nowPlaying != null) ...[ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + 'LIVE', + style: GoogleFonts.roboto( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + _epg!.nowPlaying!, + style: GoogleFonts.roboto( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + // Progress bar + if (_epg!.progress != null) ...[ + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: _epg!.progress!.clamp(0.0, 1.0), + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.red), + minHeight: 3, + ), + ), + ], + ], + + // Next Playing + if (_epg!.nextPlaying != null) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.upcoming, + size: 12, + color: Colors.grey.shade400, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Next: ${_epg!.nextPlaying}', + style: GoogleFonts.roboto( + fontSize: 10, + color: Colors.grey.shade400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart new file mode 100644 index 0000000..8118330 --- /dev/null +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -0,0 +1,197 @@ +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'; + +class LiveTVTab extends ConsumerStatefulWidget { + final PlaylistConfig playlist; + + const LiveTVTab({super.key, required this.playlist}); + + @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; + + @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')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_channels.isEmpty && !_isLoading) { + return const Center(child: Text('No channels available')); + } + + 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 channel = _channels[index]; + return _ChannelCard( + channel: channel, + playlist: widget.playlist, + ); + }, + ); + } +} + +class _ChannelCard extends StatelessWidget { + final LiveChannel channel; + final PlaylistConfig playlist; + + const _ChannelCard({ + required this.channel, + required this.playlist, + }); + + @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, + children: [ + Text( + channel.name, + style: GoogleFonts.roboto( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + // EPG Display + if (channel.epgChannelId != null) + EPGWidget( + channelId: channel.streamId, + playlist: playlist, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart new file mode 100644 index 0000000..9b7a11f --- /dev/null +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -0,0 +1,208 @@ +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}); + + @override + ConsumerState createState() => _MoviesTabState(); +} + +class _MoviesTabState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final List _movies = []; + bool _isLoading = false; + bool _hasMore = true; + int _currentOffset = 0; + static const int _pageSize = 100; + + @override + void initState() { + super.initState(); + _loadMoreMovies(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent * 0.8) { + _loadMoreMovies(); + } + } + + Future _loadMoreMovies() async { + if (_isLoading || !_hasMore) return; + + setState(() { + _isLoading = true; + }); + + try { + final service = ref.read(xtreamServiceProvider(widget.playlist)); + final newMovies = await service.getMovies( + offset: _currentOffset, + limit: _pageSize, + ); + + setState(() { + _movies.addAll(newMovies); + _currentOffset += _pageSize; + _hasMore = newMovies.length == _pageSize; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load movies: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_movies.isEmpty && !_isLoading) { + return const Center(child: Text('No movies available')); + } + + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.7, + ), + itemCount: _movies.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _movies.length) { + return const Center(child: CircularProgressIndicator()); + } + + final movie = _movies[index]; + return _MovieCard( + movie: movie, + playlist: widget.playlist, + ); + }, + ); + } +} + +class _MovieCard extends StatelessWidget { + final Movie movie; + final PlaylistConfig playlist; + + const _MovieCard({ + required this.movie, + required this.playlist, + }); + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + final streamUrl = movie.getStreamUrl( + playlist.dns, + playlist.username, + playlist.password, + ); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + streamUrl: streamUrl, + title: movie.name, + posterUrl: movie.streamIcon, + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: movie.streamIcon != null + ? CachedNetworkImage( + imageUrl: movie.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.movie, size: 48), + ), + ) + : Container( + color: Colors.grey.shade800, + child: const Icon(Icons.movie, size: 48), + ), + ), + Container( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + movie.name, + style: GoogleFonts.roboto( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (movie.rating != null) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.star, + size: 12, + color: Colors.amber.shade700, + ), + const SizedBox(width: 2), + Text( + movie.rating!, + style: GoogleFonts.roboto( + fontSize: 10, + color: Colors.grey.shade600, + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart new file mode 100644 index 0000000..95cf6e0 --- /dev/null +++ b/lib/features/iptv/widgets/series_tab.dart @@ -0,0 +1,202 @@ +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'; + +class SeriesTab extends ConsumerStatefulWidget { + final PlaylistConfig playlist; + + const SeriesTab({super.key, required this.playlist}); + + @override + ConsumerState createState() => _SeriesTabState(); +} + +class _SeriesTabState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final List _series = []; + bool _isLoading = false; + bool _hasMore = true; + int _currentOffset = 0; + static const int _pageSize = 100; + + @override + void initState() { + super.initState(); + _loadMoreSeries(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent * 0.8) { + _loadMoreSeries(); + } + } + + Future _loadMoreSeries() async { + if (_isLoading || !_hasMore) return; + + setState(() { + _isLoading = true; + }); + + try { + final service = ref.read(xtreamServiceProvider(widget.playlist)); + final newSeries = await service.getSeries( + offset: _currentOffset, + limit: _pageSize, + ); + + setState(() { + _series.addAll(newSeries); + _currentOffset += _pageSize; + _hasMore = newSeries.length == _pageSize; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load series: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_series.isEmpty && !_isLoading) { + return const Center(child: Text('No series available')); + } + + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.7, + ), + itemCount: _series.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _series.length) { + return const Center(child: CircularProgressIndicator()); + } + + final serie = _series[index]; + return _SeriesCard(series: serie); + }, + ); + } +} + +class _SeriesCard extends StatelessWidget { + final Series series; + + const _SeriesCard({required this.series}); + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + // Series require episode selection screen (not implemented) + // For now, show info dialog + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(series.name), + content: const Text( + 'Series playback requires episode selection.\n\n' + 'Use Xtream API endpoint get_series_info to fetch episodes.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: series.cover != null + ? CachedNetworkImage( + imageUrl: series.cover!, + 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.tv, size: 48), + ), + ) + : Container( + color: Colors.grey.shade800, + child: const Icon(Icons.tv, size: 48), + ), + ), + Container( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + series.name, + style: GoogleFonts.roboto( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (series.rating != null) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.star, + size: 12, + color: Colors.amber.shade700, + ), + const SizedBox(width: 2), + Text( + series.rating!, + style: GoogleFonts.roboto( + fontSize: 10, + color: Colors.grey.shade600, + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart new file mode 100644 index 0000000..6b9b39c --- /dev/null +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../auth/providers/auth_provider.dart'; + +class SettingsTab extends ConsumerWidget { + const SettingsTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentUser = ref.watch(authProvider).currentUser; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.person), + title: Text( + 'Logged in as', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey), + ), + subtitle: Text( + currentUser?.username ?? 'Unknown', + style: GoogleFonts.roboto(fontWeight: FontWeight.w600), + ), + trailing: currentUser?.isAdmin ?? false + ? Chip( + label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11)), + backgroundColor: Colors.blue.shade100, + ) + : null, + ), + ), + const SizedBox(height: 16), + if (currentUser?.isAdmin ?? false) ...[ + Card( + child: ListTile( + leading: const Icon(Icons.admin_panel_settings), + title: Text('Admin Panel', style: GoogleFonts.roboto()), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go('/admin'), + ), + ), + const SizedBox(height: 8), + ], + Card( + child: ListTile( + leading: const Icon(Icons.playlist_play), + title: Text('Change Playlist', style: GoogleFonts.roboto()), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go('/playlists'), + ), + ), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.info_outline), + title: Text('About', style: GoogleFonts.roboto()), + subtitle: Text( + 'XtremFlow IPTV v1.0.0', + style: GoogleFonts.roboto(fontSize: 12), + ), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () { + ref.read(authProvider.notifier).logout(); + context.go('/login'); + }, + icon: const Icon(Icons.logout), + label: const Text('Logout'), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..8674d6e --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; +import 'core/database/hive_service.dart'; +import 'core/router/app_router.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize MediaKit for video playback + MediaKit.ensureInitialized(); + + // Initialize Hive database with encryption + await HiveService.init(); + + runApp(const ProviderScope(child: MyApp())); +} + +class MyApp extends ConsumerWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + title: 'XtremFlow IPTV', + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.light, + ), + cardTheme: CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, + ), + cardTheme: CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + themeMode: ThemeMode.dark, // Default to dark mode for IPTV + routerConfig: router, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..c2d2ecf --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,51 @@ +name: xtremflow +description: High-performance IPTV Web Application with Xtream Codes API +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + # State Management + flutter_riverpod: ^2.6.1 + + # Local Database + hive: ^2.2.3 + hive_flutter: ^1.1.0 + + # Networking + dio: ^5.7.0 + + # Routing + go_router: ^14.6.2 + + # Video Player + media_kit: ^1.1.11 + media_kit_video: ^1.2.5 + media_kit_libs_video: ^1.0.5 + + # UI + google_fonts: ^6.2.1 + cached_network_image: ^3.4.1 + + # Security + flutter_secure_storage: ^9.2.2 + crypto: ^3.0.6 + + # Utils + uuid: ^4.5.1 + intl: ^0.19.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + hive_generator: ^2.0.1 + build_runner: ^2.4.13 + +flutter: + uses-material-design: true diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f81116d --- /dev/null +++ b/web/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + XtremFlow IPTV + + + + +
Loading XtremFlow...
+ + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..71c2580 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "XtremFlow IPTV", + "short_name": "XtremFlow", + "start_url": ".", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#1976D2", + "description": "High-performance IPTV Web Application", + "orientation": "landscape-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +}