diff --git a/bin/server.dart b/bin/server.dart index 45ad62e..e69611f 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -153,21 +153,34 @@ Handler _createXtreamProxyHandler() { final targetUrl = Uri.parse(fullUrl); final baseUrl = '${targetUrl.scheme}://${targetUrl.host}${targetUrl.hasPort ? ':${targetUrl.port}' : ''}'; + + // Check if this is a video file that needs streaming + final lowerPath = targetUrl.path.toLowerCase(); + final isVideoFile = lowerPath.endsWith('.mp4') || + lowerPath.endsWith('.mkv') || + lowerPath.endsWith('.avi') || + lowerPath.endsWith('.ts') || + lowerPath.endsWith('.m4v') || + lowerPath.contains('/movie/') || + lowerPath.contains('/series/'); - print('Proxying request to: $targetUrl'); + print('Proxying request to: $targetUrl (video streaming: $isVideoFile)'); + + // For video files, use streaming to avoid loading entire file in memory + if (isVideoFile) { + return _streamVideoFile(targetUrl); + } // Headers to simulate a legitimate IPTV client (VLC/Kodi style) - // Many IPTV servers check User-Agent to prevent web playback final proxyHeaders = { - // Use VLC Media Player User-Agent - most IPTV servers whitelist this 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', 'Accept': '*/*', 'Accept-Encoding': 'identity', 'Connection': 'keep-alive', - 'Icy-MetaData': '1', // Common for IPTV streams + 'Icy-MetaData': '1', }; - // Use simple http.get/post with timeout and proper headers + // Use simple http.get/post for non-video content http.Response response; if (request.method == 'GET') { response = await http.get(targetUrl, headers: proxyHeaders).timeout( @@ -231,6 +244,43 @@ Handler _createXtreamProxyHandler() { }; } +/// Stream video file using HttpClient (avoids loading entire file in memory) +Future _streamVideoFile(Uri targetUrl) async { + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 30); + + final req = await client.getUrl(targetUrl); + req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18'); + req.headers.set('Accept', '*/*'); + req.headers.set('Connection', 'keep-alive'); + + final response = await req.close(); + + // Get content type from response + final contentType = response.headers.contentType?.mimeType ?? 'video/mp4'; + + // Create a streaming response + return Response( + response.statusCode, + body: response, + headers: { + 'content-type': contentType, + 'access-control-allow-origin': '*', + 'accept-ranges': 'bytes', + if (response.contentLength > 0) + 'content-length': response.contentLength.toString(), + }, + ); + } catch (e) { + print('Video streaming error: $e'); + return Response.internalServerError( + body: jsonEncode({'error': 'Video streaming error', 'message': e.toString()}), + headers: {'content-type': 'application/json'}, + ); + } +} + /// Rewrite URLs in M3U8 playlist to go through the proxy /// /// Handles both relative and absolute URLs in HLS playlists @@ -355,14 +405,22 @@ Handler _createStreamHandler() { // Start FFmpeg process with VLC User-Agent // Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome) // Using fast preset for lower CPU usage + // Added resilience flags for corrupted/discontinuous streams final process = await Process.start('ffmpeg', [ '-y', // Overwrite output files '-loglevel', 'warning', // Reduce verbose output + '-err_detect', 'ignore_err', // Ignore decoding errors + '-fflags', '+genpts+discardcorrupt+igndts', // Generate PTS, discard corrupt frames, ignore DTS + '-flags', 'low_delay', // Low delay mode + '-analyzeduration', '2000000', // Limit analysis time (2 seconds) + '-probesize', '1000000', // Limit probe size (1MB) '-user_agent', 'VLC/3.0.18 LibVLC/3.0.18', '-headers', 'Accept: */*\r\nConnection: keep-alive\r\n', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5', + '-reconnect_on_network_error', '1', // Reconnect on network errors + '-reconnect_on_http_error', '4xx,5xx', // Reconnect on HTTP errors '-i', iptvUrl, // Video: transcode to H.264 for browser compatibility '-c:v', 'libx264', @@ -373,10 +431,13 @@ Handler _createStreamHandler() { '-b:v', '2500k', // 2.5 Mbps video bitrate '-maxrate', '3000k', '-bufsize', '6000k', + '-vsync', 'passthrough', // Pass through timestamps as-is (fixes discontinuity) + '-avoid_negative_ts', 'make_zero', // Handle negative timestamps // Audio: transcode to AAC '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', + '-async', '1', // Audio sync (stretch/compression allowed) // HLS output settings '-f', 'hls', '-hls_time', '2', // 2 second segments diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index 741a2ca..e35642d 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -1,55 +1,167 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -/// Settings state for IPTV preferences +/// Keys for SharedPreferences storage +class _SettingsKeys { + static const String liveTvFilter = 'filter_live_tv'; + static const String moviesFilter = 'filter_movies'; + static const String seriesFilter = 'filter_series'; +} + +/// Settings state for IPTV preferences with persistence class IptvSettings { - /// Category filter keywords (comma-separated) - /// Example: "FR,FRANCE,HD" will show only categories containing one of these words - final String categoryFilter; + /// Category filter keywords for Live TV (comma-separated) + final String liveTvCategoryFilter; + + /// Category filter keywords for Movies (comma-separated) + final String moviesCategoryFilter; + + /// Category filter keywords for Series (comma-separated) + final String seriesCategoryFilter; const IptvSettings({ - this.categoryFilter = '', + this.liveTvCategoryFilter = '', + this.moviesCategoryFilter = '', + this.seriesCategoryFilter = '', }); IptvSettings copyWith({ - String? categoryFilter, + String? liveTvCategoryFilter, + String? moviesCategoryFilter, + String? seriesCategoryFilter, }) { return IptvSettings( - categoryFilter: categoryFilter ?? this.categoryFilter, + liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, + moviesCategoryFilter: moviesCategoryFilter ?? this.moviesCategoryFilter, + seriesCategoryFilter: seriesCategoryFilter ?? this.seriesCategoryFilter, ); } - /// Get list of filter keywords (trimmed and non-empty) - List get filterKeywords { - if (categoryFilter.isEmpty) return []; - return categoryFilter + /// Get list of filter keywords for a given filter string + static List _parseKeywords(String filter) { + if (filter.isEmpty) return []; + return filter .split(',') .map((s) => s.trim().toUpperCase()) .where((s) => s.isNotEmpty) .toList(); } - /// Check if a category name matches the filter - /// Returns true if no filter is set or if the category contains any keyword - bool matchesFilter(String categoryName) { - final keywords = filterKeywords; + /// Get live TV filter keywords + List get liveTvKeywords => _parseKeywords(liveTvCategoryFilter); + + /// Get movies filter keywords + List get moviesKeywords => _parseKeywords(moviesCategoryFilter); + + /// Get series filter keywords + List get seriesKeywords => _parseKeywords(seriesCategoryFilter); + + /// Check if a category name matches the Live TV filter + bool matchesLiveTvFilter(String categoryName) { + return _matchesFilter(categoryName, liveTvKeywords); + } + + /// Check if a category name matches the Movies filter + bool matchesMoviesFilter(String categoryName) { + return _matchesFilter(categoryName, moviesKeywords); + } + + /// Check if a category name matches the Series filter + bool matchesSeriesFilter(String categoryName) { + return _matchesFilter(categoryName, seriesKeywords); + } + + /// Generic filter matching + bool _matchesFilter(String categoryName, List keywords) { if (keywords.isEmpty) return true; - final upperName = categoryName.toUpperCase(); return keywords.any((keyword) => upperName.contains(keyword)); } + + /// Legacy method for backwards compatibility + bool matchesFilter(String categoryName) => matchesLiveTvFilter(categoryName); } -/// IPTV Settings notifier for managing user preferences +/// IPTV Settings notifier with SharedPreferences persistence class IptvSettingsNotifier extends StateNotifier { - IptvSettingsNotifier() : super(const IptvSettings()); + SharedPreferences? _prefs; + bool _initialized = false; - void setCategoryFilter(String filter) { - state = state.copyWith(categoryFilter: filter); + IptvSettingsNotifier() : super(const IptvSettings()) { + _loadSettings(); } - void clearCategoryFilter() { - state = state.copyWith(categoryFilter: ''); + /// Load settings from SharedPreferences + Future _loadSettings() async { + if (_initialized) return; + + try { + _prefs = await SharedPreferences.getInstance(); + + final liveTv = _prefs?.getString(_SettingsKeys.liveTvFilter) ?? ''; + final movies = _prefs?.getString(_SettingsKeys.moviesFilter) ?? ''; + final series = _prefs?.getString(_SettingsKeys.seriesFilter) ?? ''; + + state = IptvSettings( + liveTvCategoryFilter: liveTv, + moviesCategoryFilter: movies, + seriesCategoryFilter: series, + ); + + _initialized = true; + print('Settings loaded: LiveTV="$liveTv", Movies="$movies", Series="$series"'); + } catch (e) { + print('Error loading settings: $e'); + } } + + /// Save a setting to SharedPreferences + Future _saveString(String key, String value) async { + try { + _prefs ??= await SharedPreferences.getInstance(); + await _prefs?.setString(key, value); + } catch (e) { + print('Error saving setting $key: $e'); + } + } + + /// Set Live TV category filter + void setLiveTvFilter(String filter) { + state = state.copyWith(liveTvCategoryFilter: filter); + _saveString(_SettingsKeys.liveTvFilter, filter); + } + + /// Set Movies category filter + void setMoviesFilter(String filter) { + state = state.copyWith(moviesCategoryFilter: filter); + _saveString(_SettingsKeys.moviesFilter, filter); + } + + /// Set Series category filter + void setSeriesFilter(String filter) { + state = state.copyWith(seriesCategoryFilter: filter); + _saveString(_SettingsKeys.seriesFilter, filter); + } + + /// Clear Live TV filter + void clearLiveTvFilter() => setLiveTvFilter(''); + + /// Clear Movies filter + void clearMoviesFilter() => setMoviesFilter(''); + + /// Clear Series filter + void clearSeriesFilter() => setSeriesFilter(''); + + /// Clear all filters + void clearAllFilters() { + clearLiveTvFilter(); + clearMoviesFilter(); + clearSeriesFilter(); + } + + // Legacy methods for backwards compatibility + void setCategoryFilter(String filter) => setLiveTvFilter(filter); + void clearCategoryFilter() => clearLiveTvFilter(); } /// Provider for IPTV settings diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index ffb0438..9c8081b 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -70,8 +70,8 @@ class _LiveTVTabState extends ConsumerState // Filter categories based on keywords var categories = groupedChannels.keys.toList(); - if (settings.filterKeywords.isNotEmpty) { - categories = categories.where((cat) => settings.matchesFilter(cat)).toList(); + if (settings.liveTvKeywords.isNotEmpty) { + categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); } categories.sort(); @@ -88,7 +88,7 @@ class _LiveTVTabState extends ConsumerState ), const SizedBox(height: 8), Text( - 'Filter: ${settings.categoryFilter}', + 'Filter: ${settings.liveTvCategoryFilter}', style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), ), ], diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 3a26fbd..5e0ba2d 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; +import '../providers/settings_provider.dart'; import '../screens/player_screen.dart'; class MoviesTab extends ConsumerStatefulWidget { @@ -78,10 +79,38 @@ class _MoviesTabState extends ConsumerState { @override Widget build(BuildContext context) { + final settings = ref.watch(iptvSettingsProvider); + + // Filter movies by category + final filteredMovies = settings.moviesKeywords.isEmpty + ? _movies + : _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); + if (_movies.isEmpty && !_isLoading) { return const Center(child: Text('No movies available')); } + if (filteredMovies.isEmpty && _movies.isNotEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.filter_list_off, size: 48, color: Colors.grey), + const SizedBox(height: 16), + Text( + 'No movies match the filter', + style: GoogleFonts.roboto(color: Colors.grey), + ), + const SizedBox(height: 8), + Text( + 'Filter: ${settings.moviesCategoryFilter}', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ); + } + return GridView.builder( controller: _scrollController, padding: const EdgeInsets.all(8), @@ -91,13 +120,13 @@ class _MoviesTabState extends ConsumerState { mainAxisSpacing: 6, childAspectRatio: 0.65, ), - itemCount: _movies.length + (_hasMore ? 1 : 0), + itemCount: filteredMovies.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { - if (index >= _movies.length) { + if (index >= filteredMovies.length) { return const Center(child: CircularProgressIndicator()); } - final movie = _movies[index]; + final movie = filteredMovies[index]; return _MovieCard( movie: movie, playlist: widget.playlist, diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index 89c89b3..d0e904b 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; +import '../providers/settings_provider.dart'; import '../screens/series_detail_screen.dart'; class SeriesTab extends ConsumerStatefulWidget { @@ -78,10 +79,38 @@ class _SeriesTabState extends ConsumerState { @override Widget build(BuildContext context) { + final settings = ref.watch(iptvSettingsProvider); + + // Filter series by category + final filteredSeries = settings.seriesKeywords.isEmpty + ? _series + : _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); + if (_series.isEmpty && !_isLoading) { return const Center(child: Text('No series available')); } + if (filteredSeries.isEmpty && _series.isNotEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.filter_list_off, size: 48, color: Colors.grey), + const SizedBox(height: 16), + Text( + 'No series match the filter', + style: GoogleFonts.roboto(color: Colors.grey), + ), + const SizedBox(height: 8), + Text( + 'Filter: ${settings.seriesCategoryFilter}', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ); + } + return GridView.builder( controller: _scrollController, padding: const EdgeInsets.all(8), @@ -91,13 +120,13 @@ class _SeriesTabState extends ConsumerState { mainAxisSpacing: 6, childAspectRatio: 0.65, ), - itemCount: _series.length + (_hasMore ? 1 : 0), + itemCount: filteredSeries.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { - if (index >= _series.length) { + if (index >= filteredSeries.length) { return const Center(child: CircularProgressIndicator()); } - final serie = _series[index]; + final serie = filteredSeries[index]; return _SeriesCard(series: serie, playlist: widget.playlist); }, ); diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart index 181e41f..37edd41 100644 --- a/lib/features/iptv/widgets/settings_tab.dart +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -13,29 +13,49 @@ class SettingsTab extends ConsumerStatefulWidget { } class _SettingsTabState extends ConsumerState { - late TextEditingController _filterController; + late TextEditingController _liveTvController; + late TextEditingController _moviesController; + late TextEditingController _seriesController; + bool _initialized = false; @override void initState() { super.initState(); - _filterController = TextEditingController(); + _liveTvController = TextEditingController(); + _moviesController = TextEditingController(); + _seriesController = TextEditingController(); } @override void dispose() { - _filterController.dispose(); + _liveTvController.dispose(); + _moviesController.dispose(); + _seriesController.dispose(); super.dispose(); } + void _syncControllers(IptvSettings settings) { + if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) { + _liveTvController.text = settings.liveTvCategoryFilter; + } + if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) { + _moviesController.text = settings.moviesCategoryFilter; + } + if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) { + _seriesController.text = settings.seriesCategoryFilter; + } + _initialized = true; + } + @override Widget build(BuildContext context) { final currentUser = ref.watch(authProvider).currentUser; final settings = ref.watch(iptvSettingsProvider); - // Sync controller with state - if (_filterController.text != settings.categoryFilter) { - _filterController.text = settings.categoryFilter; - } + // Sync controllers with persisted state (only on first load) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_initialized) _syncControllers(settings); + }); return ListView( padding: const EdgeInsets.all(16), @@ -62,84 +82,70 @@ class _SettingsTabState extends ConsumerState { ), const SizedBox(height: 16), - // Category filter section - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.filter_list, size: 20), - const SizedBox(width: 8), - Text( - 'Live TV Category Filter', - style: GoogleFonts.roboto( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Enter keywords separated by commas. Only categories containing one of these words will be shown.', - style: GoogleFonts.roboto( - fontSize: 12, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 12), - TextField( - controller: _filterController, - decoration: InputDecoration( - hintText: 'Ex: FR,FRANCE,HD,SPORT', - hintStyle: GoogleFonts.roboto(fontSize: 13), - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - suffixIcon: _filterController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () { - _filterController.clear(); - ref.read(iptvSettingsProvider.notifier) - .clearCategoryFilter(); - }, - ) - : null, - ), - style: GoogleFonts.roboto(fontSize: 13), - onChanged: (value) { - ref.read(iptvSettingsProvider.notifier) - .setCategoryFilter(value); - }, - ), - if (settings.filterKeywords.isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 4, - runSpacing: 4, - children: settings.filterKeywords.map((keyword) { - return Chip( - label: Text( - keyword, - style: GoogleFonts.roboto(fontSize: 11), - ), - padding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ); - }).toList(), - ), - ], - ], - ), + // Category filters section + Text( + 'Category Filters', + style: GoogleFonts.roboto( + fontWeight: FontWeight.bold, + fontSize: 16, ), ), + const SizedBox(height: 4), + Text( + 'Only categories containing one of these keywords will be shown. Separate with commas.', + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 12), + + // Live TV Filter + _buildFilterCard( + title: 'Live TV Filter', + icon: Icons.live_tv, + controller: _liveTvController, + keywords: settings.liveTvKeywords, + onChanged: (value) { + ref.read(iptvSettingsProvider.notifier).setLiveTvFilter(value); + }, + onClear: () { + _liveTvController.clear(); + ref.read(iptvSettingsProvider.notifier).clearLiveTvFilter(); + }, + ), + const SizedBox(height: 8), + + // Movies Filter + _buildFilterCard( + title: 'Movies Filter', + icon: Icons.movie, + controller: _moviesController, + keywords: settings.moviesKeywords, + onChanged: (value) { + ref.read(iptvSettingsProvider.notifier).setMoviesFilter(value); + }, + onClear: () { + _moviesController.clear(); + ref.read(iptvSettingsProvider.notifier).clearMoviesFilter(); + }, + ), + const SizedBox(height: 8), + + // Series Filter + _buildFilterCard( + title: 'Series Filter', + icon: Icons.tv, + controller: _seriesController, + keywords: settings.seriesKeywords, + onChanged: (value) { + ref.read(iptvSettingsProvider.notifier).setSeriesFilter(value); + }, + onClear: () { + _seriesController.clear(); + ref.read(iptvSettingsProvider.notifier).clearSeriesFilter(); + }, + ), const SizedBox(height: 16), // Admin panel @@ -195,4 +201,79 @@ class _SettingsTabState extends ConsumerState { ], ); } + + Widget _buildFilterCard({ + required String title, + required IconData icon, + required TextEditingController controller, + required List keywords, + required ValueChanged onChanged, + required VoidCallback onClear, + }) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Text( + title, + style: GoogleFonts.roboto( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'Ex: FR,FRANCE,HD,SPORT', + hintStyle: GoogleFonts.roboto(fontSize: 12), + border: const OutlineInputBorder(), + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: onClear, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ) + : null, + ), + style: GoogleFonts.roboto(fontSize: 12), + onChanged: onChanged, + ), + if (keywords.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 4, + children: keywords.map((keyword) { + return Chip( + label: Text( + keyword, + style: GoogleFonts.roboto(fontSize: 10), + ), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ], + ], + ), + ), + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 6d31fe1..6f3fb97 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: # Utilities intl: ^0.19.0 equatable: ^2.0.5 + shared_preferences: ^2.2.2 # Server dependencies (for backend proxy) shelf: ^1.4.1 diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..cac21c3 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..cac21c3 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..cac21c3 Binary files /dev/null and b/web/icons/Icon-512.png differ