feat: Initialize XtremFlow project with core structure, IPTV features, authentication, and video player.

This commit is contained in:
Michael committed 2025-12-05 09:17:11 +01:00
commit 2a28eb44d8
28 files changed
+3279

No files matched your search

+52
View File
@@ -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
+41
View File
@@ -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"]
+79
View File
@@ -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.
+8
View File
@@ -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
+41
View File
@@ -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
+119
View File
@@ -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<void> 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<AppUser>(
_usersBoxName,
encryptionCipher: HiveAesCipher(encryptionKey),
);
await Hive.openBox<PlaylistConfig>(
_playlistsBoxName,
encryptionCipher: HiveAesCipher(encryptionKey),
);
// Seed default admin if no users exist
await _seedDefaultAdmin();
_initialized = true;
}
/// Get or create 256-bit encryption key
static Future<List<int>> _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<void> _seedDefaultAdmin() async {
final usersBox = Hive.box<AppUser>(_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<AppUser> get usersBox => Hive.box<AppUser>(_usersBoxName);
/// Get playlists box
static Box<PlaylistConfig> get playlistsBox =>
Hive.box<PlaylistConfig>(_playlistsBoxName);
/// Close all boxes (cleanup)
static Future<void> dispose() async {
await Hive.close();
_initialized = false;
}
}
+51
View File
@@ -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<String> 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<String>? 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,
);
}
}
+56
View File
@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_user.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppUserAdapter extends TypeAdapter<AppUser> {
@override
final int typeId = 0;
@override
AppUser read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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<String>(),
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;
}
+60
View File
@@ -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';
}
+59
View File
@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'playlist_config.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PlaylistConfigAdapter extends TypeAdapter<PlaylistConfig> {
@override
final int typeId = 1;
@override
PlaylistConfig read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}
+69
View File
@@ -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<GoRouter>((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(),
),
],
);
});
+431
View File
@@ -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<AdminPanel> createState() => _AdminPanelState();
}
class _AdminPanelState extends ConsumerState<AdminPanel>
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<String> 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'),
),
],
),
);
}
}
@@ -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<AuthState> {
AuthNotifier() : super(const AuthState());
/// Login with username and password
Future<bool> 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<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});
+211
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _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,
),
],
),
),
),
),
),
),
),
);
}
}
+163
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
);
}
}
@@ -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<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
int _selectedIndex = 0;
late final List<Widget> _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',
),
],
),
);
}
}
@@ -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,
),
],
),
),
),
);
}
}
@@ -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<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
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<Duration>(
stream: _player.stream.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
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<Color>(
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<bool>(
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<bool>(
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)}';
}
}
@@ -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<String, dynamic> _cache = {};
static const _cacheDuration = Duration(minutes: 10);
final Map<String, DateTime> _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<Map<String, dynamic>> authenticate() async {
try {
final response = await _dio.get('', queryParameters: {
'username': config.username,
'password': config.password,
});
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Authentication failed: $e');
}
}
/// Get live streams with pagination
Future<List<LiveChannel>> 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<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to load live streams: $e');
}
}
/// Get VOD streams (movies) with pagination
Future<List<Movie>> 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<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to load movies: $e');
}
}
/// Get series with pagination
Future<List<Series>> 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<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to load series: $e');
}
}
/// Get live categories
Future<List<Category>> 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<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to load categories: $e');
}
}
/// Get short EPG for a specific channel
Future<ShortEPG> 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<String, dynamic>);
} 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<XtreamService, PlaylistConfig>(
(ref, config) => XtreamService(config),
);
+168
View File
@@ -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<EPGWidget> createState() => _EPGWidgetState();
}
class _EPGWidgetState extends ConsumerState<EPGWidget> {
ShortEPG? _epg;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadEPG();
}
Future<void> _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<Color>(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,
),
),
],
),
],
],
),
);
}
}
+197
View File
@@ -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<LiveTVTab> createState() => _LiveTVTabState();
}
class _LiveTVTabState extends ConsumerState<LiveTVTab> {
final ScrollController _scrollController = ScrollController();
final List<LiveChannel> _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<void> _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,
),
],
),
),
],
),
),
);
}
}
+208
View File
@@ -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<MoviesTab> createState() => _MoviesTabState();
}
class _MoviesTabState extends ConsumerState<MoviesTab> {
final ScrollController _scrollController = ScrollController();
final List<Movie> _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<void> _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,
),
),
],
),
],
],
),
),
],
),
),
);
}
}
+202
View File
@@ -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<SeriesTab> createState() => _SeriesTabState();
}
class _SeriesTabState extends ConsumerState<SeriesTab> {
final ScrollController _scrollController = ScrollController();
final List<Series> _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<void> _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,
),
),
],
),
],
],
),
),
],
),
),
);
}
}
@@ -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),
),
),
],
);
}
}
+59
View File
@@ -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,
);
}
}
+51
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="IPTV Web Application">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="XtremFlow">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>XtremFlow IPTV</title>
<link rel="manifest" href="manifest.json">
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: 'Roboto', sans-serif;
}
.loading {
color: #fff;
font-size: 18px;
}
</style>
</head>
<body>
<div class="loading">Loading XtremFlow...</div>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+23
View File
@@ -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"
}
]
}