diff --git a/bin/api/settings_handler.dart b/bin/api/settings_handler.dart new file mode 100644 index 0000000..febeed0 --- /dev/null +++ b/bin/api/settings_handler.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import '../database/database.dart'; +import '../models/user.dart'; + +class SettingsHandler { + final AppDatabase db; + + SettingsHandler(this.db); + + Router get router { + final router = Router(); + + // GET /api/settings + router.get('/', (Request req) { + final user = req.context['user'] as User?; + if (user == null) { + return Response.forbidden(jsonEncode({'error': 'Authentication required'})); + } + + final settingsJson = db.getUserSettings(user.id); + + // If no settings exist, return empty object or default + return Response.ok( + settingsJson ?? '{}', + headers: {'content-type': 'application/json'}, + ); + }); + + // POST /api/settings + router.post('/', (Request req) async { + final user = req.context['user'] as User?; + if (user == null) { + return Response.forbidden(jsonEncode({'error': 'Authentication required'})); + } + + try { + final body = await req.readAsString(); + // Validate JSON + try { + jsonDecode(body); + } catch (e) { + return Response.badRequest(body: jsonEncode({'error': 'Invalid JSON'})); + } + + db.updateUserSettings(user.id, body); + + return Response.ok( + body, + headers: {'content-type': 'application/json'}, + ); + } catch (e) { + return Response.internalServerError( + body: jsonEncode({'error': 'Failed to save settings: $e'}), + ); + } + }); + + return router; + } +} diff --git a/bin/database/database.dart b/bin/database/database.dart index e4254b0..2a980f5 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -68,6 +68,16 @@ class AppDatabase { ) '''); + // User Settings table + _db.execute(''' + CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY, + settings_json TEXT NOT NULL, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + '''); + // Indexes _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); @@ -312,6 +322,33 @@ class AppDatabase { _db.execute('DELETE FROM playlists WHERE id = ?', [playlistId]); } + // ==================== User Settings ==================== + + /// Get user settings as JSON string + String? getUserSettings(String userId) { + final result = _db.select( + 'SELECT settings_json FROM user_settings WHERE user_id = ?', + [userId], + ); + + if (result.isEmpty) return null; + return result.first['settings_json'] as String; + } + + /// Update user settings + void updateUserSettings(String userId, String settingsJson) { + final now = DateTime.now().toIso8601String(); + + // UPSERT (Insert or Replace) + _db.execute(''' + INSERT INTO user_settings (user_id, settings_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + settings_json = excluded.settings_json, + updated_at = excluded.updated_at + ''', [userId, settingsJson, now]); + } + /// Close database connection void close() { _db.dispose(); diff --git a/bin/server.dart b/bin/server.dart index dccaf00..8dffec7 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -11,7 +11,10 @@ import 'database/database.dart'; import 'models/user.dart'; import 'api/auth_handler.dart'; import 'api/users_handler.dart'; +import 'api/auth_handler.dart'; +import 'api/users_handler.dart'; import 'api/playlists_handler.dart'; +import 'api/settings_handler.dart'; import 'middleware/auth_middleware.dart'; import 'middleware/security_middleware.dart'; import 'services/cleanup_service.dart'; @@ -35,6 +38,7 @@ void main(List args) async { final authHandler = AuthHandler(db); final playlistsHandler = PlaylistsHandler(db); final usersHandler = UsersHandler(db); + final settingsHandler = SettingsHandler(db); // Setup router final apiRouter = Router() @@ -43,11 +47,15 @@ void main(List args) async { // Playlists endpoints (with auth middleware) ..mount('/api/playlists', Pipeline() .addMiddleware(authMiddleware(db)) - .addHandler(playlistsHandler.router.call),) + .addHandler(playlistsHandler.router.call)) // Users endpoints (with auth middleware) ..mount('/api/users', Pipeline() .addMiddleware(authMiddleware(db)) - .addHandler(usersHandler.router.call),); + .addHandler(usersHandler.router.call)) + // Settings endpoints (with auth middleware) + ..mount('/api/settings', Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(settingsHandler.router.call)); // Initialize Cleanup Service final cleanupService = CleanupService(); diff --git a/lib/core/services/settings_api_service.dart b/lib/core/services/settings_api_service.dart new file mode 100644 index 0000000..0ff1045 --- /dev/null +++ b/lib/core/services/settings_api_service.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'dart:html' as html; +import '../models/app_user.dart'; + +class SettingsApiService { + final String _baseUrl = html.window.location.origin; + + Future?> getSettings() async { + final token = html.window.localStorage['auth_token']; + if (token == null) return null; + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/settings'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + ); + + if (response.statusCode == 200) { + if (response.body.isEmpty) return {}; + return jsonDecode(response.body) as Map; + } + return null; + } catch (e) { + return null; + } + } + + Future saveSettings(Map settings) async { + final token = html.window.localStorage['auth_token']; + if (token == null) return false; + + try { + final response = await http.post( + Uri.parse('$_baseUrl/api/settings'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode(settings), + ); + + return response.statusCode == 200; + } catch (e) { + return false; + } + } +} diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index 82f4b1e..92dc4a7 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../core/services/settings_api_service.dart'; +import '../../auth/providers/auth_provider.dart'; /// Streaming quality presets for Live TV enum StreamQuality { @@ -225,28 +227,29 @@ class IptvSettings { bool matchesFilter(String categoryName) => matchesLiveTvFilter(categoryName); } -/// IPTV Settings notifier with SharedPreferences persistence +/// IPTV Settings notifier with SharedPreferences and API persistence class IptvSettingsNotifier extends StateNotifier { SharedPreferences? _prefs; + final SettingsApiService _apiService = SettingsApiService(); + final Ref _ref; bool _initialized = false; - IptvSettingsNotifier() : super(const IptvSettings()) { + IptvSettingsNotifier(this._ref) : super(const IptvSettings()) { _loadSettings(); } - /// Load settings from SharedPreferences + /// Load settings from SharedPreferences and then API Future _loadSettings() async { if (_initialized) return; try { _prefs = await SharedPreferences.getInstance(); - // Load filters + // Load local settings first (fastest) final liveTv = _prefs?.getString(_SettingsKeys.liveTvFilter) ?? ''; final movies = _prefs?.getString(_SettingsKeys.moviesFilter) ?? ''; final series = _prefs?.getString(_SettingsKeys.seriesFilter) ?? ''; - // Load streaming settings final quality = StreamQuality.values[ _prefs?.getInt(_SettingsKeys.streamQuality) ?? StreamQuality.medium.index ]; @@ -265,7 +268,6 @@ class IptvSettingsNotifier extends StateNotifier { ]; final directPlay = _prefs?.getBool(_SettingsKeys.preferDirectPlay) ?? false; - // Load Player Display Settings final showClock = _prefs?.getBool(_SettingsKeys.showClock) ?? false; final aspectRatio = _prefs?.getString(_SettingsKeys.preferredAspectRatio) ?? 'contain'; @@ -285,18 +287,84 @@ class IptvSettingsNotifier extends StateNotifier { ); _initialized = true; - print('Settings loaded: Clock=$showClock, Ratio=$aspectRatio'); + + // Sync from API if logged in + final authState = _ref.read(authProvider); + if (authState.isAuthenticated) { + await _fetchFromApi(); + } + } catch (e) { print('Error loading settings: $e'); } } + Future _fetchFromApi() async { + try { + final remoteSettings = await _apiService.getSettings(); + if (remoteSettings != null && remoteSettings.isNotEmpty) { + // Update state with remote settings if they exist + T? getValue(String key) => remoteSettings[key] as T?; + + state = state.copyWith( + liveTvCategoryFilter: getValue(_SettingsKeys.liveTvFilter), + moviesCategoryFilter: getValue(_SettingsKeys.moviesFilter), + seriesCategoryFilter: getValue(_SettingsKeys.seriesFilter), + + streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null + ? StreamQuality.values[remoteSettings[_SettingsKeys.streamQuality] as int] : null, + bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null + ? BufferSize.values[remoteSettings[_SettingsKeys.bufferSize] as int] : null, + connectionTimeout: remoteSettings[_SettingsKeys.connectionTimeout] != null + ? ConnectionTimeout.values[remoteSettings[_SettingsKeys.connectionTimeout] as int] : null, + autoReconnect: getValue(_SettingsKeys.autoReconnect), + epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != null + ? EpgCacheDuration.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] : null, + transcodingMode: remoteSettings[_SettingsKeys.transcodingMode] != null + ? TranscodingMode.values[remoteSettings[_SettingsKeys.transcodingMode] as int] : null, + preferDirectPlay: getValue(_SettingsKeys.preferDirectPlay), + showClock: getValue(_SettingsKeys.showClock), + preferredAspectRatio: getValue(_SettingsKeys.preferredAspectRatio), + ); + + // Save to local prefs to keep in sync + _saveToPrefs(); + } + } catch (e) { + print('Error syncing settings from API: $e'); + } + } + + Future _saveToApi() async { + final authState = _ref.read(authProvider); + if (!authState.isAuthenticated) return; + + // Create map from state + final settingsMap = { + _SettingsKeys.liveTvFilter: state.liveTvCategoryFilter, + _SettingsKeys.moviesFilter: state.moviesCategoryFilter, + _SettingsKeys.seriesFilter: state.seriesCategoryFilter, + _SettingsKeys.streamQuality: state.streamQuality.index, + _SettingsKeys.bufferSize: state.bufferSize.index, + _SettingsKeys.connectionTimeout: state.connectionTimeout.index, + _SettingsKeys.autoReconnect: state.autoReconnect, + _SettingsKeys.epgCacheDuration: state.epgCacheDuration.index, + _SettingsKeys.transcodingMode: state.transcodingMode.index, + _SettingsKeys.preferDirectPlay: state.preferDirectPlay, + _SettingsKeys.showClock: state.showClock, + _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, + }; + + await _apiService.saveSettings(settingsMap); + } + // ===== Save Helpers ===== Future _saveString(String key, String value) async { try { _prefs ??= await SharedPreferences.getInstance(); await _prefs?.setString(key, value); + await _saveToApi(); } catch (e) { print('Error saving setting $key: $e'); } @@ -306,6 +374,7 @@ class IptvSettingsNotifier extends StateNotifier { try { _prefs ??= await SharedPreferences.getInstance(); await _prefs?.setInt(key, value); + await _saveToApi(); } catch (e) { print('Error saving setting $key: $e'); } @@ -315,11 +384,31 @@ class IptvSettingsNotifier extends StateNotifier { try { _prefs ??= await SharedPreferences.getInstance(); await _prefs?.setBool(key, value); + await _saveToApi(); } catch (e) { print('Error saving setting $key: $e'); } } + Future _saveToPrefs() async { + // Helper to bulk save state to prefs (after API fetch) + _prefs ??= await SharedPreferences.getInstance(); + await _prefs?.setString(_SettingsKeys.liveTvFilter, state.liveTvCategoryFilter); + await _prefs?.setString(_SettingsKeys.moviesFilter, state.moviesCategoryFilter); + await _prefs?.setString(_SettingsKeys.seriesFilter, state.seriesCategoryFilter); + + await _prefs?.setInt(_SettingsKeys.streamQuality, state.streamQuality.index); + await _prefs?.setInt(_SettingsKeys.bufferSize, state.bufferSize.index); + await _prefs?.setInt(_SettingsKeys.connectionTimeout, state.connectionTimeout.index); + await _prefs?.setBool(_SettingsKeys.autoReconnect, state.autoReconnect); + await _prefs?.setInt(_SettingsKeys.epgCacheDuration, state.epgCacheDuration.index); + await _prefs?.setInt(_SettingsKeys.transcodingMode, state.transcodingMode.index); + await _prefs?.setBool(_SettingsKeys.preferDirectPlay, state.preferDirectPlay); + + await _prefs?.setBool(_SettingsKeys.showClock, state.showClock); + await _prefs?.setString(_SettingsKeys.preferredAspectRatio, state.preferredAspectRatio); + } + // ===== Filter Setters ===== void setLiveTvFilter(String filter) { @@ -404,5 +493,13 @@ class IptvSettingsNotifier extends StateNotifier { /// Provider for IPTV settings final iptvSettingsProvider = StateNotifierProvider((ref) { - return IptvSettingsNotifier(); + // Watch auth provider to re-fetch settings on login/logout + final authState = ref.watch(authProvider); + final notifier = IptvSettingsNotifier(ref); + + // Note: Since we watch authProvider, this provider will be disposed and recreated + // whenever authState changes. This automatically handles fetching settings for + // the new user on creation. + + return notifier; }); diff --git a/verify_settings_migration.txt b/verify_settings_migration.txt new file mode 100644 index 0000000..c5cfe41 Binary files /dev/null and b/verify_settings_migration.txt differ diff --git a/verify_settings_migration_2.txt b/verify_settings_migration_2.txt new file mode 100644 index 0000000..fbfab3d Binary files /dev/null and b/verify_settings_migration_2.txt differ diff --git a/verify_settings_migration_3.txt b/verify_settings_migration_3.txt new file mode 100644 index 0000000..54a74f9 Binary files /dev/null and b/verify_settings_migration_3.txt differ