diff --git a/bin/server.dart b/bin/server.dart index 797003e..c8e3077 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:async'; +import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_static/shelf_static.dart'; @@ -136,48 +137,54 @@ Handler _createXtreamProxyHandler() { ); } - final targetUrl = Uri.parse(apiPath + '?' + (request.url.query)); + // Reconstruct the full target URL with query parameters + String fullUrl = apiPath; + if (request.url.query.isNotEmpty) { + // If the target URL already has query params, append with & + if (fullUrl.contains('?')) { + fullUrl = '$fullUrl&${request.url.query}'; + } else { + fullUrl = '$fullUrl?${request.url.query}'; + } + } + + final targetUrl = Uri.parse(fullUrl); print('Proxying request to: $targetUrl'); - // Forward the request - final client = http.Client(); - try { - final proxyRequest = http.Request(request.method, targetUrl); - - // Copy headers (excluding host) - request.headers.forEach((key, value) { - if (key.toLowerCase() != 'host') { - proxyRequest.headers[key] = value; - } - }); - - // Copy body if present - if (request.method != 'GET' && request.method != 'HEAD') { - proxyRequest.bodyBytes = await request.read().toList() - .then((chunks) => chunks.expand((chunk) => chunk).toList()); - } - - final response = await client.send(proxyRequest); - final responseBody = await response.stream.toBytes(); - - return Response( - response.statusCode, - body: responseBody, - headers: { - 'content-type': response.headers['content-type'] ?? 'application/json', - ...response.headers, - }, + // Use simple http.get/post with timeout + http.Response response; + if (request.method == 'GET') { + response = await http.get(targetUrl).timeout( + const Duration(seconds: 30), + onTimeout: () => http.Response('Request timeout', 504), ); - } finally { - client.close(); + } else if (request.method == 'POST') { + final body = await request.readAsString(); + response = await http.post(targetUrl, body: body).timeout( + const Duration(seconds: 30), + onTimeout: () => http.Response('Request timeout', 504), + ); + } else { + return Response(405, body: 'Method not allowed'); } + + return Response( + response.statusCode, + body: response.bodyBytes, + headers: { + 'content-type': response.headers['content-type'] ?? 'application/json', + 'access-control-allow-origin': '*', + }, + ); } catch (e, stackTrace) { print('Proxy error: $e'); print(stackTrace); return Response.internalServerError( - body: 'Proxy error: $e', + body: jsonEncode({'error': 'Proxy error', 'message': e.toString()}), + headers: {'content-type': 'application/json'}, ); } }; } + diff --git a/lib/features/admin/screens/admin_panel.dart b/lib/features/admin/screens/admin_panel.dart index 9c5f3f7..1648d63 100644 --- a/lib/features/admin/screens/admin_panel.dart +++ b/lib/features/admin/screens/admin_panel.dart @@ -2,10 +2,9 @@ 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/services/playlist_api_service.dart'; import '../../../core/models/playlist_config.dart'; +import '../../iptv/screens/playlist_selection_screen.dart'; class AdminPanel extends ConsumerStatefulWidget { const AdminPanel({super.key}); @@ -14,22 +13,7 @@ class AdminPanel extends ConsumerStatefulWidget { 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(); - } - +class _AdminPanelState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( @@ -42,229 +26,8 @@ class _AdminPanelState extends ConsumerState 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'), - ), - ], ), + body: const _PlaylistsTab(), ); } } @@ -275,7 +38,7 @@ class _PlaylistsTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final playlistsBox = HiveService.playlistsBox; + final playlistsAsync = ref.watch(playlistsProvider); return Column( children: [ @@ -288,38 +51,76 @@ class _PlaylistsTab extends ConsumerWidget { ), ), 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), + child: playlistsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red.shade400), + const SizedBox(height: 16), + Text('Error loading playlists: $error'), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => ref.refresh(playlistsProvider), + child: const Text('Retry'), ), - title: Text(playlist.name), - subtitle: Text( - playlist.dns, - style: GoogleFonts.roboto(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, + ], + ), + ), + data: (playlists) { + if (playlists.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => _showPlaylistDialog(context, ref, playlist), + Icon(Icons.playlist_remove, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'No playlists yet', + style: GoogleFonts.roboto(fontSize: 18, color: Colors.grey.shade600), ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _deletePlaylist(context, playlist), + const SizedBox(height: 8), + Text( + 'Click "Add Playlist" to create one', + style: GoogleFonts.roboto(fontSize: 14, color: Colors.grey.shade500), ), ], ), - ), + ); + } + + return ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[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, ref, playlist), + ), + ], + ), + ), + ); + }, ); }, ), @@ -333,93 +134,147 @@ class _PlaylistsTab extends ConsumerWidget { final dnsController = TextEditingController(text: playlist?.dns); final usernameController = TextEditingController(text: playlist?.username); final passwordController = TextEditingController(text: playlist?.password); + final service = PlaylistApiService(); + bool isLoading = false; 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', + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => 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: usernameController, - decoration: const InputDecoration(labelText: 'Username'), - ), - const SizedBox(height: 8), - TextField( - controller: passwordController, - decoration: const InputDecoration(labelText: 'Password'), - obscureText: true, - ), - ], + 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: isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: isLoading + ? null + : () async { + 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; + } + + setState(() => isLoading = true); + + final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); + + PlaylistConfig? result; + if (playlist == null) { + // Create new playlist + result = await service.createPlaylist( + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text, + ); + } else { + // Update existing playlist + result = await service.updatePlaylist( + id: playlist.id, + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text, + ); + } + + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + + // Refresh playlists list + ref.invalidate(playlistsProvider); + + if (result == null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to save playlist'), + backgroundColor: Colors.red, + ), + ); + } + }, + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('Save'), + ), + ], ), - 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) { + void _deletePlaylist(BuildContext context, WidgetRef ref, PlaylistConfig playlist) { + final service = PlaylistApiService(); + showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Delete Playlist'), content: Text('Delete playlist "${playlist.name}"?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel'), ), FilledButton( - onPressed: () { - HiveService.playlistsBox.delete(playlist.id); - Navigator.pop(context); + onPressed: () async { + Navigator.pop(dialogContext); + + final success = await service.deletePlaylist(playlist.id); + + // Refresh playlists list + ref.invalidate(playlistsProvider); + + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to delete playlist'), + backgroundColor: Colors.red, + ), + ); + } }, style: FilledButton.styleFrom(backgroundColor: Colors.red), child: const Text('Delete'),