mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-15 11:17:29 +02:00
feat: Implement a web-based IPTV player with HLS/MPEG-TS support and Flutter communication.
This commit is contained in:
1 parent
2ca92d8425
commit
55acbbc135
28 files changed
+2075
-1448
No files matched your search
@@ -7,14 +7,15 @@ import 'package:dio/io.dart';
|
||||
/// Useful for emulators or restricted networks
|
||||
class DnsFallbackInterceptor extends Interceptor {
|
||||
final Dio _dio;
|
||||
|
||||
|
||||
// Cache for resolved IPs
|
||||
final Map<String, String> _dnsCache = {};
|
||||
|
||||
DnsFallbackInterceptor(this._dio);
|
||||
|
||||
@override
|
||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
Future<void> onError(
|
||||
DioException err, ErrorInterceptorHandler handler) async {
|
||||
// Basic check for connection/host lookup errors
|
||||
if (_isDnsError(err)) {
|
||||
final uri = err.requestOptions.uri;
|
||||
@@ -30,12 +31,12 @@ class DnsFallbackInterceptor extends Interceptor {
|
||||
// Attempt resolution attempts
|
||||
print('DnsFallback: Attempting DoH resolution...');
|
||||
ip = await _resolveWithDoH(host);
|
||||
|
||||
|
||||
if (ip != null) {
|
||||
print('DnsFallback: Resolved $host to $ip');
|
||||
_dnsCache[host] = ip;
|
||||
} else {
|
||||
print('DnsFallback: Failed to resolve $host');
|
||||
print('DnsFallback: Failed to resolve $host');
|
||||
}
|
||||
} catch (e) {
|
||||
print('DnsFallback: Resolution error: $e');
|
||||
@@ -48,30 +49,32 @@ class DnsFallbackInterceptor extends Interceptor {
|
||||
// Clone options but replace host with IP
|
||||
final newUri = uri.replace(host: ip);
|
||||
final options = err.requestOptions.copyWith(path: newUri.toString());
|
||||
|
||||
|
||||
// IMPORTANT: Set Host header so the server handles it correctly
|
||||
options.headers['Host'] = host;
|
||||
|
||||
|
||||
// Retry request
|
||||
print('DnsFallback: Retrying request to $ip');
|
||||
final response = await _dio.fetch(options);
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
print('DnsFallback: Retry failed: $e');
|
||||
return handler.next(err);
|
||||
print('DnsFallback: Retry failed: $e');
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
bool _isDnsError(DioException err) {
|
||||
if (err.type == DioExceptionType.connectionError || err.type == DioExceptionType.unknown) {
|
||||
if (err.error is SocketException) {
|
||||
final msg = (err.error as SocketException).message;
|
||||
return msg.contains('Failed host lookup') || msg.contains('No address associated with hostname');
|
||||
}
|
||||
if (err.type == DioExceptionType.connectionError ||
|
||||
err.type == DioExceptionType.unknown) {
|
||||
if (err.error is SocketException) {
|
||||
final msg = (err.error as SocketException).message;
|
||||
return msg.contains('Failed host lookup') ||
|
||||
msg.contains('No address associated with hostname');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -79,14 +82,17 @@ class DnsFallbackInterceptor extends Interceptor {
|
||||
/// Resolve hostname using multiple DoH providers
|
||||
Future<String?> _resolveWithDoH(String hostname) async {
|
||||
// 1. Google DNS (8.8.8.8)
|
||||
String? ip = await _queryDoH('https://8.8.8.8/resolve?name=$hostname&type=A');
|
||||
String? ip =
|
||||
await _queryDoH('https://8.8.8.8/resolve?name=$hostname&type=A');
|
||||
if (ip != null) return ip;
|
||||
|
||||
// 2. Cloudflare DNS (1.1.1.1)
|
||||
ip = await _queryDoH('https://1.1.1.1/dns-query?name=$hostname&type=A',
|
||||
headers: {'accept': 'application/dns-json'});
|
||||
ip = await _queryDoH(
|
||||
'https://1.1.1.1/dns-query?name=$hostname&type=A',
|
||||
headers: {'accept': 'application/dns-json'},
|
||||
);
|
||||
if (ip != null) return ip;
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -94,26 +100,28 @@ class DnsFallbackInterceptor extends Interceptor {
|
||||
try {
|
||||
// Create a raw HttpClient to avoid loop and control SSL
|
||||
final client = HttpClient()
|
||||
..badCertificateCallback = (cert, host, port) => true; // IGNORE SSL ERRORS just for DoH IP connectivity
|
||||
|
||||
..badCertificateCallback = (cert, host, port) =>
|
||||
true; // IGNORE SSL ERRORS just for DoH IP connectivity
|
||||
|
||||
final request = await client.getUrl(Uri.parse(url));
|
||||
if (headers != null) {
|
||||
headers.forEach((k, v) => request.headers.add(k, v));
|
||||
}
|
||||
|
||||
|
||||
final response = await request.close();
|
||||
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final json = jsonDecode(body);
|
||||
|
||||
|
||||
// Google/Cloudflare JSON format
|
||||
if (json['Status'] == 0 && json['Answer'] != null) {
|
||||
final answers = json['Answer'] as List;
|
||||
for (final ans in answers) {
|
||||
if (ans['type'] == 1) { // Type A record
|
||||
return ans['data'];
|
||||
}
|
||||
if (ans['type'] == 1) {
|
||||
// Type A record
|
||||
return ans['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,10 @@ class DnsResolver {
|
||||
);
|
||||
|
||||
// Fallback to Cloudflare via IP (1.1.1.1)
|
||||
if (ip == null) {
|
||||
ip = await _queryDoH(
|
||||
'https://1.1.1.1/dns-query?name=$hostname&type=A',
|
||||
headers: {'accept': 'application/dns-json'},
|
||||
);
|
||||
}
|
||||
ip ??= await _queryDoH(
|
||||
'https://1.1.1.1/dns-query?name=$hostname&type=A',
|
||||
headers: {'accept': 'application/dns-json'},
|
||||
);
|
||||
|
||||
if (ip != null) {
|
||||
print('DnsResolver: Resolved $hostname -> $ip');
|
||||
@@ -40,7 +38,8 @@ class DnsResolver {
|
||||
return ip;
|
||||
}
|
||||
|
||||
static Future<String?> _queryDoH(String url, {Map<String, String>? headers}) async {
|
||||
static Future<String?> _queryDoH(String url,
|
||||
{Map<String, String>? headers}) async {
|
||||
try {
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 10)
|
||||
|
||||
@@ -8,7 +8,7 @@ import '../models/playlist_config.dart';
|
||||
class HiveService {
|
||||
static const String _usersBoxName = 'users';
|
||||
static const String _playlistsBoxName = 'playlists';
|
||||
|
||||
|
||||
static bool _initialized = false;
|
||||
static List<int>? _encryptionKey;
|
||||
|
||||
@@ -45,27 +45,27 @@ class HiveService {
|
||||
|
||||
// Generate new 256-bit key
|
||||
_encryptionKey = Hive.generateSecureKey();
|
||||
|
||||
|
||||
return _encryptionKey!;
|
||||
}
|
||||
|
||||
/// 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: [],
|
||||
assignedPlaylistIds: const [],
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
|
||||
await usersBox.put(adminId, admin);
|
||||
}
|
||||
}
|
||||
@@ -88,12 +88,12 @@ class HiveService {
|
||||
final legacyHash = sha256.convert(utf8.encode(password)).toString();
|
||||
return legacyHash == storedHash;
|
||||
}
|
||||
|
||||
|
||||
final salt = parts[0];
|
||||
final expectedHash = parts[1];
|
||||
final bytes = utf8.encode(password + salt);
|
||||
final actualHash = sha256.convert(bytes).toString();
|
||||
|
||||
|
||||
return actualHash == expectedHash;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -103,12 +103,11 @@ class HiveService {
|
||||
/// 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 =>
|
||||
static Box<PlaylistConfig> get playlistsBox =>
|
||||
Hive.box<PlaylistConfig>(_playlistsBoxName);
|
||||
|
||||
/// Close all boxes (cleanup)
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import '../api/api_client.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../database/hive_service.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
|
||||
/// Service for managing playlists via API
|
||||
/// Service for managing playlists via local storage (Hive)
|
||||
class PlaylistApiService {
|
||||
final ApiClient _api = ApiClient();
|
||||
|
||||
/// Get all playlists for current user
|
||||
Future<List<PlaylistConfig>> getPlaylists() async {
|
||||
try {
|
||||
final response = await _api.get('/api/playlists');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final playlistsData = data['playlists'] as List<dynamic>;
|
||||
|
||||
return playlistsData.map((p) {
|
||||
final playlist = p as Map<String, dynamic>;
|
||||
return PlaylistConfig(
|
||||
id: playlist['id'] as String,
|
||||
name: playlist['name'] as String,
|
||||
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
|
||||
username: playlist['username'] as String,
|
||||
password: playlist['password'] as String,
|
||||
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}).toList();
|
||||
if (!HiveService.playlistsBox.isOpen) {
|
||||
await HiveService.init();
|
||||
}
|
||||
return HiveService.playlistsBox.values.toList();
|
||||
} catch (e) {
|
||||
print('Error fetching playlists: $e');
|
||||
print('Error fetching playlists from Hive: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -37,30 +25,20 @@ class PlaylistApiService {
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _api.post('/api/playlists', data: {
|
||||
'name': name,
|
||||
'serverUrl': dns,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'dns': dns,
|
||||
},);
|
||||
final id = const Uuid().v4();
|
||||
final playlist = PlaylistConfig(
|
||||
id: id,
|
||||
name: name,
|
||||
dns: dns,
|
||||
username: username,
|
||||
password: password,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
|
||||
if (data['success'] == true) {
|
||||
final playlist = data['playlist'] as Map<String, dynamic>;
|
||||
return PlaylistConfig(
|
||||
id: playlist['id'] as String,
|
||||
name: playlist['name'] as String,
|
||||
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
|
||||
username: playlist['username'] as String,
|
||||
password: playlist['password'] as String,
|
||||
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
await HiveService.playlistsBox.put(id, playlist);
|
||||
return playlist;
|
||||
} catch (e) {
|
||||
print('Error creating playlist: $e');
|
||||
print('Error creating playlist in Hive: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -74,30 +52,26 @@ class PlaylistApiService {
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _api.put('/api/playlists/$id', data: {
|
||||
'name': name,
|
||||
'serverUrl': dns,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'dns': dns,
|
||||
},);
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
|
||||
if (data['success'] == true) {
|
||||
final playlist = data['playlist'] as Map<String, dynamic>;
|
||||
return PlaylistConfig(
|
||||
id: playlist['id'] as String,
|
||||
name: playlist['name'] as String,
|
||||
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
|
||||
username: playlist['username'] as String,
|
||||
password: playlist['password'] as String,
|
||||
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
|
||||
);
|
||||
if (!HiveService.playlistsBox.containsKey(id)) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
final existing = HiveService.playlistsBox.get(id);
|
||||
if (existing == null) return null;
|
||||
|
||||
final updated = PlaylistConfig(
|
||||
id: id,
|
||||
name: name,
|
||||
dns: dns,
|
||||
username: username,
|
||||
password: password,
|
||||
createdAt: existing.createdAt,
|
||||
);
|
||||
|
||||
await HiveService.playlistsBox.put(id, updated);
|
||||
return updated;
|
||||
} catch (e) {
|
||||
print('Error updating playlist: $e');
|
||||
print('Error updating playlist in Hive: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -105,11 +79,10 @@ class PlaylistApiService {
|
||||
/// Delete a playlist
|
||||
Future<bool> deletePlaylist(String id) async {
|
||||
try {
|
||||
final response = await _api.delete('/api/playlists/$id');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['success'] == true;
|
||||
await HiveService.playlistsBox.delete(id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Error deleting playlist: $e');
|
||||
print('Error deleting playlist from Hive: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// XtremFlow Apple TV Style Palette
|
||||
///
|
||||
///
|
||||
/// Ultra minimalist, focus-driven palette.
|
||||
/// - Background: Pure Black (#000000)
|
||||
/// - Focus/Active: Pure White (#FFFFFF)
|
||||
@@ -13,20 +13,20 @@ class AppColors {
|
||||
// ============ BACKGROUNDS ============
|
||||
/// Pure black for that infinite depth look (OLED friendly)
|
||||
static const Color background = Color(0xFF000000);
|
||||
|
||||
|
||||
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
|
||||
static const Color surface = Color(0xFF1C1C1E);
|
||||
|
||||
|
||||
/// Slightly lighter grey for secondary surfaces or hover states
|
||||
static const Color surfaceVariant = Color(0xFF3A3A3C);
|
||||
|
||||
|
||||
/// Focused element background (often white in tvOS for text, or bright accent)
|
||||
static const Color focusColor = Color(0xFFFFFFFF);
|
||||
|
||||
// ============ ACCENTS ============
|
||||
/// Minimal white accent. In Apple TV, color is used sparingly.
|
||||
/// We keep a subtle blue only for specific indicators if needed.
|
||||
static const Color primary = Color(0xFFFFFFFF);
|
||||
static const Color primary = Color(0xFFFFFFFF);
|
||||
static const Color accent = Color(0xFFE5E5E5);
|
||||
|
||||
/// Primary Gradient (Subtle silver/white glow)
|
||||
@@ -60,7 +60,7 @@ class AppColors {
|
||||
|
||||
// ============ BORDERS ============
|
||||
// Borders are usually hidden until focused
|
||||
static const Color border = Color(0xFF1F1F1F);
|
||||
static const Color border = Color(0xFF1F1F1F);
|
||||
static const Color focusBorder = Color(0xFFFFFFFF);
|
||||
|
||||
// ============ GLASSMORPHISM ============
|
||||
@@ -70,28 +70,27 @@ class AppColors {
|
||||
|
||||
// ============ THEME SCHEMES ============
|
||||
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
||||
primary: primary,
|
||||
secondary: accent,
|
||||
surface: surface,
|
||||
background: background,
|
||||
error: error,
|
||||
onPrimary: Colors.black, // White text on buttons -> Black text on White buttons
|
||||
onSecondary: Colors.black,
|
||||
onSurface: textPrimary,
|
||||
onError: Colors.white,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
primary: primary,
|
||||
secondary: accent,
|
||||
surface: surface,
|
||||
error: error,
|
||||
onPrimary: Colors
|
||||
.black, // White text on buttons -> Black text on White buttons
|
||||
onSecondary: Colors.black,
|
||||
onSurface: textPrimary,
|
||||
onError: Colors.white,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
|
||||
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
||||
primary: Colors.black,
|
||||
secondary: Colors.grey,
|
||||
surface: Color(0xFFF2F2F7),
|
||||
background: Colors.white,
|
||||
error: error,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Colors.white,
|
||||
onSurface: Colors.black,
|
||||
onError: Colors.white,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
primary: Colors.black,
|
||||
secondary: Colors.grey,
|
||||
surface: Color(0xFFF2F2F7),
|
||||
error: error,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Colors.white,
|
||||
onSurface: Colors.black,
|
||||
onError: Colors.white,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class ThemedLoading extends StatelessWidget {
|
||||
const ThemedLoading({super.key});
|
||||
|
||||
Reference in new issue
Block a user