diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74f3194..217da0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -290,7 +290,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libfuse2 + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libfuse2 libwebkit2gtk-4.1-dev - name: Set up Flutter uses: subosito/flutter-action@v2 diff --git a/lib/main.dart b/lib/main.dart index 71e93e0..bcd2cd1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'package:openlib/services/files.dart' show moveFilesToAndroidInternalStorage; import 'package:openlib/services/download_manager.dart'; import 'package:openlib/services/download_notification.dart'; +import 'package:openlib/services/instance_manager.dart'; import 'package:openlib/state/state.dart' show selectedIndexProvider, @@ -33,8 +34,14 @@ import 'package:openlib/state/state.dart' openPdfWithExternalAppProvider, openEpubWithExternalAppProvider, showManualDownloadButtonProvider, + autoRankInstancesProvider, userAgentProvider, - cookieProvider; + cookieProvider, + selectedTypeState, + selectedSortState, + selectedFileTypeState, + selectedLanguageState, + selectedYearState; void main(List args) async { // Required for desktop_webview_window on Linux - must be called before ensureInitialized @@ -81,6 +88,28 @@ void main(List args) async { String browserUserAgent = await dataBase.getBrowserOptions('userAgent'); String browserCookie = await dataBase.getBrowserOptions('cookie'); + // Load search filter preferences + String savedType = await dataBase + .getPreference('filterType') + .catchError((e) => 'All') as String? ?? + 'All'; + String savedSort = await dataBase + .getPreference('filterSort') + .catchError((e) => 'Most Relevant') as String? ?? + 'Most Relevant'; + String savedFileType = await dataBase + .getPreference('filterFileType') + .catchError((e) => 'All') as String? ?? + 'All'; + String savedLanguage = await dataBase + .getPreference('filterLanguage') + .catchError((e) => 'All') as String? ?? + 'All'; + String savedYear = await dataBase + .getPreference('filterYear') + .catchError((e) => 'All') as String? ?? + 'All'; + if (Platform.isAndroid) { // Android-specific setup for system UI overlay colors SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( @@ -102,6 +131,11 @@ void main(List args) async { .overrideWith((ref) => showManualDownloadButton), userAgentProvider.overrideWith((ref) => browserUserAgent), cookieProvider.overrideWith((ref) => browserCookie), + selectedTypeState.overrideWith((ref) => savedType), + selectedSortState.overrideWith((ref) => savedSort), + selectedFileTypeState.overrideWith((ref) => savedFileType), + selectedLanguageState.overrideWith((ref) => savedLanguage), + selectedYearState.overrideWith((ref) => savedYear), ], child: const MyApp(), ), @@ -161,6 +195,29 @@ class _MainScreenState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _checkForUpdatesOnStartup(); }); + // Auto-rank instances on startup if enabled + WidgetsBinding.instance.addPostFrameCallback((_) { + _autoRankInstancesOnStartup(); + }); + } + + Future _autoRankInstancesOnStartup() async { + // Small delay to let the UI settle first + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + + try { + final instanceManager = InstanceManager(); + final didRank = await instanceManager.rankOnStartupIfNeeded(); + if (didRank) { + debugPrint("Instances auto-ranked on startup"); + // Update the provider state + ref.read(autoRankInstancesProvider.notifier).state = true; + } + } catch (e) { + // Silently fail - don't interrupt user flow + debugPrint("Auto-ranking failed: $e"); + } } Future _checkForUpdatesOnStartup() async { diff --git a/lib/services/annas_archieve.dart b/lib/services/annas_archieve.dart index 49cd514..17359db 100644 --- a/lib/services/annas_archieve.dart +++ b/lib/services/annas_archieve.dart @@ -1,3 +1,6 @@ +// Dart imports: +import 'dart:async'; + // Package imports: import 'package:dio/dio.dart'; import 'package:html/parser.dart' show parse; @@ -58,51 +61,74 @@ class AnnasArchieve { final Dio dio = Dio(); final InstanceManager _instanceManager = InstanceManager(); final AppLogger _logger = AppLogger(); - static const int maxRetries = 2; // Check each server 2x as per requirements - static const int retryDelayMs = 500; // Delay between retries in milliseconds + + // Optimized retry settings for faster response + static const int maxRetriesPerInstance = + 1; // Only 1 retry per instance for speed + static const int requestTimeoutSeconds = 8; // Shorter timeout per request + static const int retryDelayMs = 200; // Shorter delay between retries Map defaultDioHeaders = { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", }; - // Try request with retry logic across multiple instances + // Try request with optimized retry logic - fast failure and fallback Future _requestWithRetry( Future Function(String baseUrl) requestFn, ) async { final instances = await _instanceManager.getEnabledInstances(); - + if (instances.isEmpty) { // Use default if no instances are enabled return await requestFn(baseUrl); } Exception? lastException; - List failedInstances = []; // Track failed instances for logging - - // Try each instance - for (final instance in instances) { - // Try each instance up to maxRetries times - for (int attempt = 0; attempt < maxRetries; attempt++) { + + // Try each instance - they should already be sorted by speed from auto-ranking + for (int i = 0; i < instances.length; i++) { + final instance = instances[i]; + + // Fewer retries for subsequent instances (they're slower) + final retriesForThis = i == 0 ? maxRetriesPerInstance : 0; + + for (int attempt = 0; attempt <= retriesForThis; attempt++) { try { - return await requestFn(instance.baseUrl); + // Apply timeout to the request function + final result = await requestFn(instance.baseUrl).timeout( + Duration(seconds: requestTimeoutSeconds), + onTimeout: () { + throw TimeoutException( + "Request timed out after ${requestTimeoutSeconds}s"); + }, + ); + + // Success - log which instance worked + _logger.debug('Request succeeded on attempt ${attempt + 1}', + tag: 'AnnasArchive', metadata: {'instance': instance.name}); + + return result; } catch (e) { lastException = e is Exception ? e : Exception(e.toString()); - // Log the failure - final attemptInfo = '${instance.name} (${instance.baseUrl}) - Attempt ${attempt + 1}/$maxRetries: ${e.toString()}'; - failedInstances.add(attemptInfo); - // Instance failed: $attemptInfo - - // If this is not the last attempt for this instance, wait before retrying - if (attempt < maxRetries - 1) { + + _logger.debug('Instance failed', tag: 'AnnasArchive', metadata: { + 'instance': instance.name, + 'attempt': attempt + 1, + 'error': e.toString().substring( + 0, (e.toString().length > 50) ? 50 : e.toString().length), + }); + + // Short delay before retry (only if we're retrying this instance) + if (attempt < retriesForThis) { await Future.delayed(const Duration(milliseconds: retryDelayMs)); } } } } - - // If all instances failed, throw the last exception with context - // All instances failed. Attempted: ${failedInstances.join(", ")} + + // All instances failed + _logger.error('All instances failed', tag: 'AnnasArchive'); throw lastException ?? Exception('All instances failed'); } @@ -112,6 +138,19 @@ class AnnasArchieve { return pathSegments.isNotEmpty ? pathSegments.last : ''; } + // Remove emojis, icons and non-standard characters from text + String cleanText(String text) { + return text + .replaceAll(RegExp(r'[\u{1F300}-\u{1F9FF}]', unicode: true), '') + .replaceAll(RegExp(r'[\u{2600}-\u{26FF}]', unicode: true), '') + .replaceAll(RegExp(r'[\u{2700}-\u{27BF}]', unicode: true), '') + .replaceAll(RegExp(r'[\u{1F600}-\u{1F64F}]', unicode: true), '') + .replaceAll(RegExp(r'[\u{1F680}-\u{1F6FF}]', unicode: true), '') + .replaceAll(RegExp(r'🔍'), '') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + String getFormat(String info) { final infoLower = info.toLowerCase(); if (infoLower.contains('pdf')) { @@ -123,7 +162,7 @@ class AnnasArchieve { } return "epub"; } - + // -------------------------------------------------------------------- // _parser FUNCTION (Search Results - Fixed nth-of-type issue) // -------------------------------------------------------------------- @@ -140,11 +179,12 @@ class AnnasArchieve { container.querySelector('a.line-clamp-\\[3\\].js-vim-focus'); final thumbnailElement = container.querySelector('a[href^="/md5/"] img'); - if (mainLinkElement == null || mainLinkElement.attributes['href'] == null) { + if (mainLinkElement == null || + mainLinkElement.attributes['href'] == null) { continue; } - final String title = mainLinkElement.text.trim(); + final String title = cleanText(mainLinkElement.text.trim()); final String link = currentBaseUrl + mainLinkElement.attributes['href']!; final String md5 = getMd5(mainLinkElement.attributes['href']!); final String? thumbnail = thumbnailElement?.attributes['src']; @@ -152,27 +192,33 @@ class AnnasArchieve { // Fix: Use sequential traversal instead of :nth-of-type dom.Element? authorLinkElement = mainLinkElement.nextElementSibling; dom.Element? publisherLinkElement = authorLinkElement?.nextElementSibling; - - if (authorLinkElement?.attributes['href']?.startsWith('/search?q=') != true) { - authorLinkElement = null; + + if (authorLinkElement?.attributes['href']?.startsWith('/search?q=') != + true) { + authorLinkElement = null; } - if (publisherLinkElement?.attributes['href']?.startsWith('/search?q=') != true) { - publisherLinkElement = null; + if (publisherLinkElement?.attributes['href']?.startsWith('/search?q=') != + true) { + publisherLinkElement = null; } final String? authorRaw = authorLinkElement?.text.trim(); final String? author = (authorRaw != null && authorRaw.contains('icon-')) - ? authorRaw.split(' ').skip(1).join(' ').trim() - : authorRaw; - - final String? publisher = publisherLinkElement?.text.trim(); - + ? cleanText(authorRaw.split(' ').skip(1).join(' ').trim()) + : (authorRaw != null ? cleanText(authorRaw) : null); + + final String? publisherRaw = publisherLinkElement?.text.trim(); + final String? publisher = + publisherRaw != null ? cleanText(publisherRaw) : null; + final infoElement = container.querySelector('div.text-gray-800'); // No need for _safeParse here if we only treat info as a string - final String? info = infoElement?.text.trim(); - + final String? info = infoElement?.text.trim(); + final bool hasMatchingFileType = fileType.isEmpty - ? (info?.contains(RegExp(r'(PDF|EPUB|CBR|CBZ)', caseSensitive: false)) == true) + ? (info?.contains( + RegExp(r'(PDF|EPUB|CBR|CBZ)', caseSensitive: false)) == + true) : info?.toLowerCase().contains(fileType.toLowerCase()) == true; if (hasMatchingFileType) { @@ -195,46 +241,52 @@ class AnnasArchieve { // -------------------------------------------------------------------- // _bookInfoParser FUNCTION (Detail Page - Fixed 'unable to get data' error) // -------------------------------------------------------------------- - Future _bookInfoParser(resData, url, String currentBaseUrl) async { + Future _bookInfoParser( + resData, url, String currentBaseUrl) async { var document = parse(resData.toString()); - final main = document.querySelector('div.main-inner'); + final main = document.querySelector('div.main-inner'); if (main == null) return null; // --- Mirror Link Extraction --- String? mirror; - final slowDownloadLinks = main.querySelectorAll('ul.list-inside a[href*="/slow_download/"]'); - if (slowDownloadLinks.isNotEmpty && slowDownloadLinks.first.attributes['href'] != null) { - mirror = currentBaseUrl + slowDownloadLinks.first.attributes['href']!; + final slowDownloadLinks = + main.querySelectorAll('ul.list-inside a[href*="/slow_download/"]'); + if (slowDownloadLinks.isNotEmpty && + slowDownloadLinks.first.attributes['href'] != null) { + mirror = currentBaseUrl + slowDownloadLinks.first.attributes['href']!; } // -------------------------------- - // --- Core Info Extraction --- - + // Title - final titleElement = main.querySelector('div.font-semibold.text-2xl'); - + final titleElement = main.querySelector('div.font-semibold.text-2xl'); + // Author - final authorLinkElement = main.querySelector('a[href^="/search?q="].text-base'); - + final authorLinkElement = + main.querySelector('a[href^="/search?q="].text-base'); + // Publisher dom.Element? publisherLinkElement = authorLinkElement?.nextElementSibling; - if (publisherLinkElement?.localName != 'a' || publisherLinkElement?.attributes['href']?.startsWith('/search?q=') != true) { - publisherLinkElement = null; + if (publisherLinkElement?.localName != 'a' || + publisherLinkElement?.attributes['href']?.startsWith('/search?q=') != + true) { + publisherLinkElement = null; } // Thumbnail final thumbnailElement = main.querySelector('div[id^="list_cover_"] img'); - + // Info/Metadata final infoElement = main.querySelector('div.text-gray-800'); - + // Description dom.Element? descriptionElement; - final descriptionLabel = main.querySelector('div.js-md5-top-box-description div.text-xs.text-gray-500.uppercase'); - + final descriptionLabel = main.querySelector( + 'div.js-md5-top-box-description div.text-xs.text-gray-500.uppercase'); + if (descriptionLabel?.text.trim().toLowerCase() == 'description') { - descriptionElement = descriptionLabel?.nextElementSibling; + descriptionElement = descriptionLabel?.nextElementSibling; } String description = descriptionElement?.text.trim() ?? " "; @@ -242,14 +294,17 @@ class AnnasArchieve { return null; } - final String title = titleElement.text.trim().split('> searchBooks( @@ -285,80 +377,98 @@ class AnnasArchieve { String content = "", String sort = "", String fileType = "", + String language = "", + String year = "", bool enableFilters = true}) async { _logger.info('Searching books', tag: 'AnnasArchive', metadata: { 'query': searchQuery, 'content': content, 'sort': sort, 'fileType': fileType, + 'language': language, + 'year': year, 'filtersEnabled': enableFilters, }); - + try { - final books = await _requestWithRetry>((currentBaseUrl) async { + final books = + await _requestWithRetry>((currentBaseUrl) async { final String encodedURL = urlEncoder( searchQuery: searchQuery, content: content, sort: sort, fileType: fileType, + language: language, + year: year, enableFilters: enableFilters, currentBaseUrl: currentBaseUrl); - _logger.debug('Fetching search results', tag: 'AnnasArchive', metadata: {'url': encodedURL}); + _logger.debug('Fetching search results', + tag: 'AnnasArchive', metadata: {'url': encodedURL}); final response = await dio.get(encodedURL, options: Options(headers: defaultDioHeaders)); return _parser(response.data, fileType, currentBaseUrl); }); - - _logger.info('Search completed', tag: 'AnnasArchive', metadata: {'results': books.length}); + + _logger.info('Search completed', + tag: 'AnnasArchive', metadata: {'results': books.length}); return books; } on DioException catch (e) { - _logger.error('Search failed', tag: 'AnnasArchive', error: e.message ?? e.error); - if (e.type == DioExceptionType.unknown) { - throw "socketException"; - } - rethrow; + _logger.error('Search failed', + tag: 'AnnasArchive', error: e.message ?? e.error); + if (e.type == DioExceptionType.unknown) { + throw "socketException"; + } + rethrow; } } Future bookInfo({required String url}) async { - _logger.info('Fetching book info', tag: 'AnnasArchive', metadata: {'url': url}); - + _logger.info('Fetching book info', + tag: 'AnnasArchive', metadata: {'url': url}); + try { - final data = await _requestWithRetry((currentBaseUrl) async { + final data = + await _requestWithRetry((currentBaseUrl) async { // Replace the base URL in the url parameter if it contains a different one String adjustedUrl = url; final urlParsed = Uri.parse(url); final currentParsed = Uri.parse(currentBaseUrl); - + // If the URL has a different host, replace it with current instance's host if (urlParsed.host != currentParsed.host) { - adjustedUrl = '$currentBaseUrl${urlParsed.path}${urlParsed.query.isNotEmpty ? "?${urlParsed.query}" : ""}'; + adjustedUrl = + '$currentBaseUrl${urlParsed.path}${urlParsed.query.isNotEmpty ? "?${urlParsed.query}" : ""}'; } - - _logger.debug('Fetching book details', tag: 'AnnasArchive', metadata: {'url': adjustedUrl}); - final response = await dio.get(adjustedUrl, + + _logger.debug('Fetching book details', + tag: 'AnnasArchive', metadata: {'url': adjustedUrl}); + final response = await dio.get(adjustedUrl, options: Options(headers: defaultDioHeaders)); - BookInfoData? data = await _bookInfoParser(response.data, adjustedUrl, currentBaseUrl); + BookInfoData? data = + await _bookInfoParser(response.data, adjustedUrl, currentBaseUrl); if (data != null) { return data; } else { throw 'unable to get data'; } }); - - _logger.info('Book info retrieved successfully', tag: 'AnnasArchive', metadata: { - 'title': data.title, - 'format': data.format, - 'hasMirror': data.mirror != null, - }); + + _logger.info('Book info retrieved successfully', + tag: 'AnnasArchive', + metadata: { + 'title': data.title, + 'format': data.format, + 'hasMirror': data.mirror != null, + }); return data; } on DioException catch (e) { - _logger.error('Failed to fetch book info', tag: 'AnnasArchive', error: e.message ?? e.error); + _logger.error('Failed to fetch book info', + tag: 'AnnasArchive', error: e.message ?? e.error); if (e.type == DioExceptionType.unknown) { throw "socketException"; } rethrow; } } -} \ No newline at end of file +} diff --git a/lib/services/database.dart b/lib/services/database.dart index ec6b339..ae3aac0 100644 --- a/lib/services/database.dart +++ b/lib/services/database.dart @@ -14,6 +14,7 @@ class MyBook { final String? info; final String? description; final String? format; + final String? fileName; MyBook( {required this.id, @@ -24,7 +25,8 @@ class MyBook { required this.publisher, required this.info, required this.format, - required this.description}); + required this.description, + this.fileName}); Map toMap() { return { @@ -36,13 +38,22 @@ class MyBook { 'publisher': publisher, 'info': info, 'format': format, - 'description': description + 'description': description, + 'fileName': fileName }; } @override String toString() { - return 'MyBook{id: $id,title: $title,author: $author,thumbnail: $thumbnail,link: $link,publisher: $publisher,info: $info,format: $format,description:$description}'; + return 'MyBook{id: $id,title: $title,author: $author,thumbnail: $thumbnail,link: $link,publisher: $publisher,info: $info,format: $format,description:$description,fileName:$fileName}'; + } + + // Get actual filename - uses fileName if available, otherwise falls back to id.format + String getFileName() { + if (fileName != null && fileName!.isNotEmpty) { + return fileName!; + } + return "$id.$format"; } } @@ -65,10 +76,10 @@ class MyLibraryDb { return await openDatabase( path, - version: 5, + version: 6, onCreate: (Database db, int version) async { await db.execute( - 'CREATE TABLE mybooks (id TEXT PRIMARY KEY, title TEXT,author TEXT,thumbnail TEXT,link TEXT,publisher TEXT,info TEXT,format TEXT,description TEXT)'); + 'CREATE TABLE mybooks (id TEXT PRIMARY KEY, title TEXT,author TEXT,thumbnail TEXT,link TEXT,publisher TEXT,info TEXT,format TEXT,description TEXT,fileName TEXT)'); await db.execute( 'CREATE TABLE preferences (name TEXT PRIMARY KEY,value TEXT)'); // Create these tables for all platforms (both mobile and desktop) @@ -98,6 +109,14 @@ class MyLibraryDb { await db.execute( 'CREATE TABLE browserOptions (name TEXT PRIMARY KEY,value TEXT)'); } + // Add fileName column if upgrading from version < 6 + if (oldVersion < 6) { + try { + await db.execute('ALTER TABLE mybooks ADD COLUMN fileName TEXT'); + } catch (_) { + // Column might already exist + } + } }, onOpen: (db) async { final bookStorageDefaultDirectory = @@ -176,7 +195,8 @@ class MyLibraryDb { publisher: maps[i]['publisher'], info: maps[i]['info'], format: maps[i]['format'], - description: maps[i]['description']); + description: maps[i]['description'], + fileName: maps[i]['fileName']); }); return myBookList.reversed.toList(); } diff --git a/lib/services/download_file.dart b/lib/services/download_file.dart index 8c1ff00..e1bc1a7 100644 --- a/lib/services/download_file.dart +++ b/lib/services/download_file.dart @@ -7,6 +7,7 @@ import 'package:dio/dio.dart'; // Project imports: import 'package:openlib/services/database.dart' show MyLibraryDb; +import 'package:openlib/services/files.dart' show generateBookFileName; MyLibraryDb dataBase = MyLibraryDb.instance; @@ -57,28 +58,36 @@ Future downloadFile( {required List mirrors, required String md5, required String format, + required String title, + String? author, + String? info, required Function onStart, required Function onProgress, required Function cancelDownlaod, required Function mirrorStatus, + required Function(String) onFileName, required Function onDownlaodFailed}) async { if (mirrors.isEmpty) { onDownlaodFailed('No mirrors available!'); } else { Dio dio = Dio(); - String path = await _getFilePath('$md5.$format'); + // Generate proper filename: title_author_info.extension + String bookFileName = generateBookFileName( + title: title, + author: author, + info: info, + format: format, + md5: md5, + ); + String path = await _getFilePath(bookFileName); List orderedMirrors = _reorderMirrors(mirrors); String? workingMirror = await _getAliveMirror(orderedMirrors); - // print(workingMirror); - // print(path); - // print(orderedMirrors); - // print(orderedMirrors[0]); - if (workingMirror != null) { onStart(); + onFileName(bookFileName); try { CancelToken cancelToken = CancelToken(); dio.download( @@ -117,11 +126,11 @@ Future downloadFile( } Future verifyFileCheckSum( - {required String md5Hash, required String format}) async { + {required String md5Hash, required String fileName}) async { try { final bookStorageDirectory = await dataBase.getPreference('bookStorageDirectory'); - final filePath = '$bookStorageDirectory/$md5Hash.$format'; + final filePath = '$bookStorageDirectory/$fileName'; final file = File(filePath); final stream = file.openRead(); final hash = await md5.bind(stream).first; diff --git a/lib/services/download_manager.dart b/lib/services/download_manager.dart index 457a0f9..d7d0d10 100644 --- a/lib/services/download_manager.dart +++ b/lib/services/download_manager.dart @@ -9,6 +9,7 @@ import 'package:crypto/crypto.dart'; // Project imports: import 'package:openlib/services/database.dart' show MyLibraryDb, MyBook; import 'package:openlib/services/download_notification.dart'; +import 'package:openlib/services/files.dart' show generateBookFileName; import 'package:openlib/services/logger.dart'; import 'package:openlib/services/mirror_fetcher.dart'; @@ -116,7 +117,8 @@ class DownloadManager { // Tasks are removed 30 seconds after completion: 3s for notification clear, then 27s additional delay static const Duration _notificationClearDelay = Duration(seconds: 3); static const Duration _totalCompletionTime = Duration(seconds: 30); - static final Duration _taskRemovalDelay = _totalCompletionTime - _notificationClearDelay; + static final Duration _taskRemovalDelay = + _totalCompletionTime - _notificationClearDelay; Stream> get downloadsStream => _downloadsController.stream; @@ -162,8 +164,7 @@ class DownloadManager { for (var url in mirrors) { try { final response = await dio.head(url, - options: - Options(receiveTimeout: const Duration(seconds: timeOut))); + options: Options(receiveTimeout: const Duration(seconds: timeOut))); if (response.statusCode == 200) { dio.close(); return url; @@ -174,11 +175,11 @@ class DownloadManager { } Future _verifyFileCheckSum( - {required String md5Hash, required String format}) async { + {required String md5Hash, required String fileName}) async { try { final bookStorageDirectory = await _database.getPreference('bookStorageDirectory'); - final filePath = '$bookStorageDirectory/$md5Hash.$format'; + final filePath = '$bookStorageDirectory/$fileName'; final file = File(filePath); final stream = file.openRead(); final hash = await md5.bind(stream).first; @@ -193,11 +194,13 @@ class DownloadManager { Future addDownload(DownloadTask task) async { if (_activeDownloads.containsKey(task.id)) { - _logger.warning('Download already exists: ${task.title}', tag: 'DownloadManager'); + _logger.warning('Download already exists: ${task.title}', + tag: 'DownloadManager'); return; } - _logger.info('Adding download: ${task.title} (${task.format})', tag: 'DownloadManager'); + _logger.info('Adding download: ${task.title} (${task.format})', + tag: 'DownloadManager'); _activeDownloads[task.id] = task; _notifyListeners(); @@ -211,14 +214,18 @@ class DownloadManager { _startDownload(task); } - Future addDownloadWithMirrorUrl(DownloadTask task, String mirrorUrl) async { + Future addDownloadWithMirrorUrl( + DownloadTask task, String mirrorUrl) async { if (_activeDownloads.containsKey(task.id)) { - _logger.warning('Download already exists: ${task.title}', tag: 'DownloadManager'); + _logger.warning('Download already exists: ${task.title}', + tag: 'DownloadManager'); return; } - _logger.info('Adding download with mirror URL: ${task.title} (${task.format})', tag: 'DownloadManager'); - + _logger.info( + 'Adding download with mirror URL: ${task.title} (${task.format})', + tag: 'DownloadManager'); + // Store the mirror URL in the task for potential retry final taskWithMirrorUrl = task.copyWith(mirrorUrl: mirrorUrl); _activeDownloads[task.id] = taskWithMirrorUrl; @@ -237,28 +244,43 @@ class DownloadManager { Future _startDownload(DownloadTask task) async { Dio? dio; try { - _logger.info('Starting download for: ${task.title} (${task.format})', tag: 'DownloadManager', metadata: { - 'taskId': task.id, - 'md5': task.md5, - 'mirrors': task.mirrors.length, - }); - + _logger.info('Starting download for: ${task.title} (${task.format})', + tag: 'DownloadManager', + metadata: { + 'taskId': task.id, + 'md5': task.md5, + 'mirrors': task.mirrors.length, + }); + if (task.mirrors.isEmpty) { - _logger.warning('No mirrors available for: ${task.title}', tag: 'DownloadManager'); + _logger.warning('No mirrors available for: ${task.title}', + tag: 'DownloadManager'); _updateTaskStatus(task.id, DownloadStatus.failed, errorMessage: 'No mirrors available!'); return; } dio = Dio(); - - String path = await _getFilePath('${task.md5}.${task.format}'); + + // Generate proper filename: title_author_info.extension + String bookFileName = generateBookFileName( + title: task.title, + author: task.author, + info: task.info, + format: task.format, + md5: task.md5, + ); + String path = await _getFilePath(bookFileName); List orderedMirrors = _reorderMirrors(task.mirrors); - - _logger.debug('Reordered mirrors for: ${task.title}', tag: 'DownloadManager', metadata: { - 'ipfs_count': orderedMirrors.where((m) => m.contains('ipfs')).length, - 'https_count': orderedMirrors.where((m) => !m.contains('ipfs')).length, - }); + + _logger.debug('Reordered mirrors for: ${task.title}', + tag: 'DownloadManager', + metadata: { + 'ipfs_count': + orderedMirrors.where((m) => m.contains('ipfs')).length, + 'https_count': + orderedMirrors.where((m) => !m.contains('ipfs')).length, + }); _updateTaskStatus(task.id, DownloadStatus.downloadingMirrors); await _notificationService.showDownloadNotification( @@ -271,9 +293,11 @@ class DownloadManager { String? workingMirror = await _getAliveMirror(orderedMirrors); if (workingMirror == null) { - _logger.error('No working mirrors found for: ${task.title}', tag: 'DownloadManager', metadata: { - 'checked_mirrors': orderedMirrors.length, - }); + _logger.error('No working mirrors found for: ${task.title}', + tag: 'DownloadManager', + metadata: { + 'checked_mirrors': orderedMirrors.length, + }); _updateTaskStatus(task.id, DownloadStatus.failed, errorMessage: 'No working mirrors available!'); await _notificationService.showDownloadNotification( @@ -284,23 +308,25 @@ class DownloadManager { ); return; } - - _logger.info('Found working mirror for: ${task.title}', tag: 'DownloadManager', metadata: { - 'mirror': workingMirror, - }); + + _logger.info('Found working mirror for: ${task.title}', + tag: 'DownloadManager', + metadata: { + 'mirror': workingMirror, + }); // Try to download from each mirror until successful bool downloadSuccessful = false; int mirrorIndex = orderedMirrors.indexOf(workingMirror); - + // Create a single cancel token for the entire mirror retry sequence CancelToken cancelToken = CancelToken(); _activeDownloads[task.id] = _activeDownloads[task.id]!.copyWith(cancelToken: cancelToken); - + while (mirrorIndex < orderedMirrors.length && !downloadSuccessful) { final currentMirror = orderedMirrors[mirrorIndex]; - + try { _updateTaskStatus(task.id, DownloadStatus.downloading); @@ -332,14 +358,13 @@ class DownloadManager { // Download completed successfully downloadSuccessful = true; - } on DioException catch (e) { if (e.type == DioExceptionType.cancel) { _updateTaskStatus(task.id, DownloadStatus.cancelled); await _notificationService.cancelNotification(task.id.hashCode); return; } - + // Try next mirror if available mirrorIndex++; if (mirrorIndex < orderedMirrors.length) { @@ -350,7 +375,7 @@ class DownloadManager { body: 'Retrying with alternate mirror...', progress: 0, ); - + // Wait up to 2 seconds before retrying, but check for cancellation const totalDelay = Duration(seconds: 2); const stepDelay = Duration(milliseconds: 100); @@ -360,7 +385,7 @@ class DownloadManager { elapsed += stepDelay; // Check if task was cancelled during the delay - if (!_activeDownloads.containsKey(task.id) || + if (!_activeDownloads.containsKey(task.id) || _activeDownloads[task.id]?.cancelToken?.isCancelled == true) { _updateTaskStatus(task.id, DownloadStatus.cancelled); await _notificationService.cancelNotification(task.id.hashCode); @@ -395,7 +420,7 @@ class DownloadManager { ); bool checkSumValid = - await _verifyFileCheckSum(md5Hash: task.md5, format: task.format); + await _verifyFileCheckSum(md5Hash: task.md5, fileName: bookFileName); await _database.insert(MyBook( id: task.md5, @@ -407,6 +432,7 @@ class DownloadManager { info: task.info, format: task.format, description: task.description, + fileName: bookFileName, )); _updateTaskStatus(task.id, DownloadStatus.completed); @@ -462,11 +488,13 @@ class DownloadManager { } } - Future _startDownloadWithMirrorUrl(DownloadTask task, String mirrorUrl) async { + Future _startDownloadWithMirrorUrl( + DownloadTask task, String mirrorUrl) async { Dio? dio; try { - _logger.info('Starting download with mirror URL for: ${task.title}', tag: 'DownloadManager'); - + _logger.info('Starting download with mirror URL for: ${task.title}', + tag: 'DownloadManager'); + // Update status to fetching mirrors _updateTaskStatus(task.id, DownloadStatus.fetchingMirrors); await _notificationService.showDownloadNotification( @@ -481,12 +509,14 @@ class DownloadManager { List fetchedMirrors = await mirrorFetcher.fetchMirrors(mirrorUrl); if (!_activeDownloads.containsKey(task.id)) { - _logger.warning('Task cancelled while fetching mirrors: ${task.title}', tag: 'DownloadManager'); + _logger.warning('Task cancelled while fetching mirrors: ${task.title}', + tag: 'DownloadManager'); return; // Task was cancelled while fetching mirrors } if (fetchedMirrors.isEmpty) { - _logger.error('Background mirror fetching failed for: ${task.title}', tag: 'DownloadManager'); + _logger.error('Background mirror fetching failed for: ${task.title}', + tag: 'DownloadManager'); // Background fetching failed - keep task for manual retry _updateTaskStatus(task.id, DownloadStatus.failed, errorMessage: 'Manual verification required'); @@ -496,15 +526,17 @@ class DownloadManager { body: 'Manual verification needed', progress: -1, ); - + // Clear notification after configured delay but keep task in UI for manual retry await Future.delayed(_notificationClearDelay); await _notificationService.cancelNotification(task.id.hashCode); - + return; } - _logger.info('Successfully fetched ${fetchedMirrors.length} mirrors for: ${task.title}', tag: 'DownloadManager'); + _logger.info( + 'Successfully fetched ${fetchedMirrors.length} mirrors for: ${task.title}', + tag: 'DownloadManager'); // Update task with fetched mirrors final updatedTask = task.copyWith(mirrors: fetchedMirrors); @@ -512,8 +544,16 @@ class DownloadManager { // Now proceed with the regular download flow dio = Dio(); - - String path = await _getFilePath('${updatedTask.md5}.${updatedTask.format}'); + + // Generate proper filename: title_author_info.extension + String bookFileName = generateBookFileName( + title: updatedTask.title, + author: updatedTask.author, + info: updatedTask.info, + format: updatedTask.format, + md5: updatedTask.md5, + ); + String path = await _getFilePath(bookFileName); List orderedMirrors = _reorderMirrors(updatedTask.mirrors); _updateTaskStatus(updatedTask.id, DownloadStatus.downloadingMirrors); @@ -541,15 +581,15 @@ class DownloadManager { // Try to download from each mirror until successful bool downloadSuccessful = false; int mirrorIndex = orderedMirrors.indexOf(workingMirror); - + // Create a single cancel token for the entire mirror retry sequence CancelToken cancelToken = CancelToken(); _activeDownloads[updatedTask.id] = _activeDownloads[updatedTask.id]!.copyWith(cancelToken: cancelToken); - + while (mirrorIndex < orderedMirrors.length && !downloadSuccessful) { final currentMirror = orderedMirrors[mirrorIndex]; - + try { _updateTaskStatus(updatedTask.id, DownloadStatus.downloading); @@ -581,25 +621,26 @@ class DownloadManager { // Download completed successfully downloadSuccessful = true; - } on DioException catch (e) { if (e.type == DioExceptionType.cancel) { _updateTaskStatus(updatedTask.id, DownloadStatus.cancelled); - await _notificationService.cancelNotification(updatedTask.id.hashCode); + await _notificationService + .cancelNotification(updatedTask.id.hashCode); return; } - + // Try next mirror if available mirrorIndex++; if (mirrorIndex < orderedMirrors.length) { - _updateTaskStatus(updatedTask.id, DownloadStatus.downloadingMirrors); + _updateTaskStatus( + updatedTask.id, DownloadStatus.downloadingMirrors); await _notificationService.showDownloadNotification( id: updatedTask.id.hashCode, title: updatedTask.title, body: 'Retrying with alternate mirror...', progress: 0, ); - + // Wait up to 2 seconds before retrying, but check for cancellation const totalDelay = Duration(seconds: 2); const stepDelay = Duration(milliseconds: 100); @@ -609,10 +650,12 @@ class DownloadManager { elapsed += stepDelay; // Check if task was cancelled during the delay - if (!_activeDownloads.containsKey(updatedTask.id) || - _activeDownloads[updatedTask.id]?.cancelToken?.isCancelled == true) { + if (!_activeDownloads.containsKey(updatedTask.id) || + _activeDownloads[updatedTask.id]?.cancelToken?.isCancelled == + true) { _updateTaskStatus(updatedTask.id, DownloadStatus.cancelled); - await _notificationService.cancelNotification(updatedTask.id.hashCode); + await _notificationService + .cancelNotification(updatedTask.id.hashCode); return; } } @@ -643,8 +686,8 @@ class DownloadManager { progress: 100, ); - bool checkSumValid = - await _verifyFileCheckSum(md5Hash: updatedTask.md5, format: updatedTask.format); + bool checkSumValid = await _verifyFileCheckSum( + md5Hash: updatedTask.md5, fileName: bookFileName); await _database.insert(MyBook( id: updatedTask.md5, @@ -656,6 +699,7 @@ class DownloadManager { info: updatedTask.info, format: updatedTask.format, description: updatedTask.description, + fileName: bookFileName, )); _updateTaskStatus(updatedTask.id, DownloadStatus.completed); diff --git a/lib/services/files.dart b/lib/services/files.dart index 3e0c584..bc3fb91 100644 --- a/lib/services/files.dart +++ b/lib/services/files.dart @@ -11,6 +11,100 @@ import 'package:openlib/state/state.dart' show myLibraryProvider; MyLibraryDb dataBase = MyLibraryDb.instance; +// Generate a safe filename: Title.extension +String generateBookFileName({ + required String title, + String? author, + String? info, + required String format, + required String md5, +}) { + // Remove emojis, icons and non-ASCII characters + String removeSpecialChars(String text) { + return text + .replaceAll(RegExp(r'[\u{1F300}-\u{1F9FF}]', unicode: true), '') + .replaceAll(RegExp(r'[\u{2600}-\u{26FF}]', unicode: true), '') + .replaceAll(RegExp(r'[\u{2700}-\u{27BF}]', unicode: true), '') + .replaceAll(RegExp(r'[^\x00-\x7F]+'), '') + .trim(); + } + + // Extract text from parentheses and combine with main title + String extractAndCombineTitle(String text) { + String cleaned = removeSpecialChars(text); + + // Extract content inside parentheses + List parentheticalContent = []; + RegExp parenRegex = RegExp(r'\(([^)]+)\)'); + for (var match in parenRegex.allMatches(cleaned)) { + String content = match.group(1)?.trim() ?? ''; + if (content.isNotEmpty) { + parentheticalContent.add(content); + } + } + + // Remove parentheses and their content from main title + String mainTitle = cleaned.replaceAll(parenRegex, ' '); + + // Remove brackets and their content + mainTitle = mainTitle.replaceAll(RegExp(r'\[[^\]]*\]'), ''); + + // Remove numbering patterns like "- 184" + mainTitle = mainTitle.replaceAll(RegExp(r'\s*-\s*\d+\s*'), ' '); + + // Remove special characters + mainTitle = mainTitle.replaceAll(RegExp(r'[<>:"/\\|?*·,;]'), '').trim(); + + // Convert main title to PascalCase + String toPascalCase(String input) { + List words = input.split(RegExp(r'\s+')); + words = words.where((w) => w.isNotEmpty).map((w) { + String lower = w.toLowerCase(); + if (lower.length > 1) { + return lower[0].toUpperCase() + lower.substring(1); + } + return lower.toUpperCase(); + }).toList(); + return words.join(''); + } + + String result = toPascalCase(mainTitle); + + // Add parenthetical content as separate parts + for (var content in parentheticalContent) { + String cleanedContent = + content.replaceAll(RegExp(r'[<>:"/\\|?*·,;]'), '').trim(); + String pascalContent = toPascalCase(cleanedContent); + if (pascalContent.isNotEmpty) { + result = "${result}_$pascalContent"; + } + } + + return result; + } + + // Truncate string to max length + String truncate(String text, int maxLength) { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength); + } + + String safeTitle = extractAndCombineTitle(title); + + // Ensure filename is not too long (max 200 chars before extension) + String baseName = truncate(safeTitle, 200); + + // Remove trailing underscores + baseName = baseName.replaceAll(RegExp(r'_+$'), ''); + + // Fallback to md5 if title is empty + if (baseName.isEmpty) { + baseName = md5.substring(0, 8); + } + + return "$baseName.$format"; +} + Future get getBookStorageDefaultDirectory async { if (Platform.isAndroid) { final directory = await getExternalStorageDirectory(); @@ -78,15 +172,16 @@ Future getFilePath(String fileName) async { throw "File Not Exists"; } -Future deleteFileWithDbData( - Ref ref, String md5, String format) async { +Future deleteFileWithDbData(Ref ref, String md5, String format, + {String? fileName}) async { try { - String fileName = '$md5.$format'; + // Use provided fileName or fall back to md5.format + String actualFileName = fileName ?? '$md5.$format'; final bookStorageDirectory = await dataBase.getPreference('bookStorageDirectory'); - await deleteFile('$bookStorageDirectory/$fileName'); + await deleteFile('$bookStorageDirectory/$actualFileName'); await dataBase.delete(md5); - await dataBase.deleteBookState(fileName); + await dataBase.deleteBookState(actualFileName); // ignore: unused_result ref.refresh(myLibraryProvider); } catch (e) { @@ -104,14 +199,14 @@ Future syncLibraryWithDisk() async { final bookStorageDirectory = await dataBase.getPreference('bookStorageDirectory'); final directory = Directory(bookStorageDirectory.toString()); - + if (!await directory.exists()) { return 0; } - + // Get all books from database final booksInDb = await dataBase.getAll(); - + // Get all book files on disk final filesOnDisk = {}; final files = directory.listSync(recursive: false); @@ -119,31 +214,46 @@ Future syncLibraryWithDisk() async { if (entity is File) { final fileName = entity.path.split('/').last; final extension = fileName.split('.').last.toLowerCase(); - if (extension == 'epub' || extension == 'pdf' || extension == 'cbr' || extension == 'cbz') { + if (extension == 'epub' || + extension == 'pdf' || + extension == 'cbr' || + extension == 'cbz') { filesOnDisk.add(fileName); } } } - + // Remove database entries for files that no longer exist for (var book in booksInDb) { - final fileName = "${book.id}.${book.format}"; + // Use actual fileName from database if available, otherwise fall back to id.format + final fileName = book.getFileName(); if (!filesOnDisk.contains(fileName)) { await dataBase.delete(book.id); await dataBase.deleteBookState(fileName); changes++; } } - - // Add new files that are not in database - final idsInDb = booksInDb.map((b) => b.id).toSet(); + + // Add new files that are not in database (only for legacy md5.format named files) + final existingFileNames = booksInDb.map((b) => b.getFileName()).toSet(); for (var fileName in filesOnDisk) { - final parts = fileName.split('.'); - if (parts.length >= 2) { - final extension = parts.last.toLowerCase(); - final md5 = parts.sublist(0, parts.length - 1).join('.'); - - if (!idsInDb.contains(md5)) { + if (!existingFileNames.contains(fileName)) { + final parts = fileName.split('.'); + if (parts.length >= 2) { + final extension = parts.last.toLowerCase(); + // Try to extract md5 from filename (either pure md5 or as suffix after last underscore) + String md5 = parts.sublist(0, parts.length - 1).join('.'); + + // For new format files, try to extract md5 suffix + if (md5.contains('_')) { + final lastUnderscore = md5.lastIndexOf('_'); + final possibleMd5 = md5.substring(lastUnderscore + 1); + // MD5 is 32 characters, but we only store 8 in the filename + if (possibleMd5.length == 8) { + md5 = possibleMd5; + } + } + // Create a minimal book entry for the new file final book = MyBook( id: md5, @@ -155,6 +265,7 @@ Future syncLibraryWithDisk() async { info: "", description: "", format: extension, + fileName: fileName, ); await dataBase.insert(book); changes++; diff --git a/lib/services/instance_manager.dart b/lib/services/instance_manager.dart index c99e2dc..b595062 100644 --- a/lib/services/instance_manager.dart +++ b/lib/services/instance_manager.dart @@ -1,8 +1,13 @@ // Dart imports: import 'dart:convert'; +import 'dart:async'; + +// Package imports: +import 'package:dio/dio.dart'; // Project imports: import 'package:openlib/services/database.dart'; +import 'package:openlib/services/logger.dart'; // ==================================================================== // INSTANCE DATA MODEL @@ -71,7 +76,7 @@ class ArchiveInstance { // ==================================================================== /// Manages archive instances (mirrors) with CRUD operations and priority management. -/// +/// /// This singleton service handles: /// - Loading and storing instance configurations in the database /// - Managing instance priority ordering @@ -138,10 +143,11 @@ class InstanceManager { Future> getInstances() async { try { final stored = await _database.getPreference(_storageKey); - + final List jsonList = jsonDecode(stored); - final instances = jsonList.map((json) => ArchiveInstance.fromJson(json)).toList(); - + final instances = + jsonList.map((json) => ArchiveInstance.fromJson(json)).toList(); + // Sort by priority instances.sort((a, b) => a.priority.compareTo(b.priority)); return instances; @@ -170,17 +176,23 @@ class InstanceManager { Future addInstance(String name, String baseUrl) async { final instances = await getInstances(); final newId = 'custom_${DateTime.now().millisecondsSinceEpoch}'; - final newPriority = instances.isEmpty ? 0 : instances.map((i) => i.priority).fold(0, (max, priority) => priority > max ? priority : max) + 1; - + final newPriority = instances.isEmpty + ? 0 + : instances.map((i) => i.priority).fold( + 0, (max, priority) => priority > max ? priority : max) + + 1; + final newInstance = ArchiveInstance( id: newId, name: name, - baseUrl: baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl, + baseUrl: baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl, priority: newPriority, enabled: true, isCustom: true, ); - + instances.add(newInstance); await _saveInstances(instances); } @@ -191,17 +203,17 @@ class InstanceManager { Future removeInstance(String id) async { final instances = await getInstances(); final index = instances.indexWhere((i) => i.id == id); - + if (index == -1) { return false; // Instance not found } final instance = instances[index]; - + if (!instance.isCustom) { return false; // Cannot remove default instances } - + instances.removeAt(index); await _saveInstances(instances); return true; @@ -213,7 +225,7 @@ class InstanceManager { Future toggleInstance(String id, bool enabled) async { final instances = await getInstances(); final index = instances.indexWhere((i) => i.id == id); - + if (index != -1) { instances[index] = instances[index].copyWith(enabled: enabled); await _saveInstances(instances); @@ -222,7 +234,8 @@ class InstanceManager { /// Reorder instances by updating their priority based on new order. /// [reorderedInstances] List of instances in new order. - Future reorderInstances(List reorderedInstances) async { + Future reorderInstances( + List reorderedInstances) async { // Update priorities based on new order for (int i = 0; i < reorderedInstances.length; i++) { reorderedInstances[i] = reorderedInstances[i].copyWith(priority: i); @@ -258,12 +271,12 @@ class InstanceManager { Future getCurrentInstance() async { final selectedId = await getSelectedInstanceId(); final instances = await getEnabledInstances(); - + if (instances.isEmpty) { // Return default if no enabled instances return _defaultInstances.first; } - + if (selectedId != null) { final selected = instances.firstWhere( (i) => i.id == selectedId, @@ -271,7 +284,7 @@ class InstanceManager { ); return selected; } - + return instances.first; } @@ -286,7 +299,164 @@ class InstanceManager { /// Reset to default instances, clearing all custom instances. Future resetToDefaults() async { await _saveInstances(_defaultInstances); - // Don't save null, just leave the preference as-is or empty - // The getCurrentInstance() will handle missing selected ID gracefully + } + + // ==================================================================== + // INSTANCE RANKING / SPEED TESTING + // ==================================================================== + + static const String _autoRankKey = 'auto_rank_instances'; + static const String _lastRankTimeKey = 'last_instance_rank_time'; + final AppLogger _logger = AppLogger(); + + /// Check if auto-ranking is enabled (default: true) + Future isAutoRankEnabled() async { + try { + final value = await _database.getPreference(_autoRankKey); + return value == 1; + } catch (e) { + // Default to enabled if preference doesn't exist + return true; + } + } + + /// Enable or disable auto-ranking + Future setAutoRankEnabled(bool enabled) async { + await _database.savePreference(_autoRankKey, enabled); + } + + /// Get the timestamp of the last ranking + Future getLastRankTime() async { + try { + final value = await _database.getPreference(_lastRankTimeKey); + return value as int?; + } catch (e) { + return null; + } + } + + /// Ping a single instance and return response time in milliseconds + /// Returns null if the instance is unreachable + Future _pingInstance(ArchiveInstance instance) async { + final dio = Dio(); + dio.options.connectTimeout = const Duration(seconds: 5); + dio.options.receiveTimeout = const Duration(seconds: 5); + + final stopwatch = Stopwatch()..start(); + try { + final response = await dio.head( + instance.baseUrl, + options: Options( + headers: { + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + ), + ); + stopwatch.stop(); + dio.close(); + + if (response.statusCode == 200 || + response.statusCode == 301 || + response.statusCode == 302) { + return stopwatch.elapsedMilliseconds; + } + return null; + } catch (e) { + stopwatch.stop(); + dio.close(); + return null; + } + } + + /// Rank all enabled instances by response time in parallel + /// Updates instance priorities and saves the new order + /// Returns a map of instance ID to response time (null = unreachable) + Future> rankInstancesBySpeed() async { + _logger.info('Starting instance ranking', tag: 'InstanceManager'); + + final instances = await getInstances(); + final enabledInstances = instances.where((i) => i.enabled).toList(); + + if (enabledInstances.isEmpty) { + _logger.warning('No enabled instances to rank', tag: 'InstanceManager'); + return {}; + } + + // Ping all instances in parallel + final futures = enabledInstances.map((instance) async { + final responseTime = await _pingInstance(instance); + return MapEntry(instance.id, responseTime); + }); + + final results = await Future.wait(futures); + final responseTimeMap = Map.fromEntries(results); + + _logger.info('Ranking results', tag: 'InstanceManager', metadata: { + for (final entry in responseTimeMap.entries) + entry.key: entry.value != null ? "${entry.value}ms" : "unreachable" + }); + + // Sort enabled instances by response time (reachable first, then by speed) + enabledInstances.sort((a, b) { + final timeA = responseTimeMap[a.id]; + final timeB = responseTimeMap[b.id]; + + // Both unreachable - keep original order + if (timeA == null && timeB == null) + return a.priority.compareTo(b.priority); + // A unreachable - B comes first + if (timeA == null) return 1; + // B unreachable - A comes first + if (timeB == null) return -1; + // Both reachable - sort by speed + return timeA.compareTo(timeB); + }); + + // Rebuild the full list maintaining disabled instances at their positions + final disabledInstances = instances.where((i) => !i.enabled).toList(); + final allSorted = [...enabledInstances, ...disabledInstances]; + + // Update priorities + for (int i = 0; i < allSorted.length; i++) { + allSorted[i] = allSorted[i].copyWith(priority: i); + } + + await _saveInstances(allSorted); + + // Save the ranking timestamp + await _database.savePreference( + _lastRankTimeKey, DateTime.now().millisecondsSinceEpoch); + + _logger + .info('Instance ranking completed', tag: 'InstanceManager', metadata: { + 'fastest': + enabledInstances.isNotEmpty ? enabledInstances.first.name : 'none', + }); + + return responseTimeMap; + } + + /// Rank instances on startup if auto-rank is enabled + /// Only ranks if more than 1 hour has passed since last ranking + Future rankOnStartupIfNeeded() async { + final autoRankEnabled = await isAutoRankEnabled(); + if (!autoRankEnabled) { + _logger.debug('Auto-ranking disabled', tag: 'InstanceManager'); + return false; + } + + final lastRankTime = await getLastRankTime(); + final now = DateTime.now().millisecondsSinceEpoch; + + // Skip if ranked less than 1 hour ago + if (lastRankTime != null && (now - lastRankTime) < 3600000) { + _logger.debug('Skipping ranking - ranked recently', + tag: 'InstanceManager'); + return false; + } + + await rankInstancesBySpeed(); + return true; } } diff --git a/lib/state/state.dart b/lib/state/state.dart index af4a9e8..4659c00 100644 --- a/lib/state/state.dart +++ b/lib/state/state.dart @@ -48,6 +48,97 @@ Map sortValues = { List fileType = ["All", "PDF", "Epub", "Cbr", "Cbz"]; +// Language filter values (display name: code) +Map languageValues = { + "All": "", + "English": "en", + "Spanish": "es", + "French": "fr", + "German": "de", + "Italian": "it", + "Portuguese": "pt", + "Russian": "ru", + "Chinese": "zh", + "Japanese": "ja", + "Korean": "ko", + "Arabic": "ar", + "Hindi": "hi", + "Dutch": "nl", + "Polish": "pl", + "Turkish": "tr", + "Swedish": "sv", + "Indonesian": "id", + "Vietnamese": "vi", + "Czech": "cs", + "Greek": "el", + "Romanian": "ro", + "Hungarian": "hu", + "Ukrainian": "uk", + "Hebrew": "he", + "Thai": "th", + "Persian": "fa", + "Bengali": "bn", + "Finnish": "fi", + "Norwegian": "no", + "Danish": "da", +}; + +// Reverse map: language code to uppercase display code +Map languageCodeToDisplay = { + "en": "EN", + "es": "ES", + "fr": "FR", + "de": "DE", + "it": "IT", + "pt": "PT", + "ru": "RU", + "zh": "ZH", + "ja": "JA", + "ko": "KO", + "ar": "AR", + "hi": "HI", + "nl": "NL", + "pl": "PL", + "tr": "TR", + "sv": "SV", + "id": "ID", + "vi": "VI", + "cs": "CS", + "el": "EL", + "ro": "RO", + "hu": "HU", + "uk": "UK", + "he": "HE", + "th": "TH", + "fa": "FA", + "bn": "BN", + "fi": "FI", + "no": "NO", + "da": "DA", +}; + +// Year filter values for publishing year range +List yearValues = [ + "All", + "2025", + "2024", + "2023", + "2022", + "2021", + "2020", + "2019", + "2018", + "2017", + "2016", + "2015", + "2010-2014", + "2005-2009", + "2000-2004", + "1990-1999", + "1980-1989", + "Before 1980", +]; + // ==================================================================== // ENUMS AND DATA CLASSES // ==================================================================== @@ -59,8 +150,9 @@ enum CheckSumProcessState { waiting, running, failed, success } class FileName { final String md5; final String format; + final String? fileName; - FileName({required this.md5, required this.format}); + FileName({required this.md5, required this.format, this.fileName}); } // ==================================================================== @@ -75,6 +167,8 @@ final themeModeProvider = StateProvider((ref) => ThemeMode.light); final selectedTypeState = StateProvider((ref) => "All"); final selectedSortState = StateProvider((ref) => "Most Relevant"); final selectedFileTypeState = StateProvider((ref) => "All"); +final selectedLanguageState = StateProvider((ref) => "All"); +final selectedYearState = StateProvider((ref) => "All"); final searchQueryProvider = StateProvider((ref) => ""); final enableFiltersState = StateProvider((ref) => true); @@ -82,12 +176,15 @@ final enableFiltersState = StateProvider((ref) => true); final cookieProvider = StateProvider((ref) => ""); final userAgentProvider = StateProvider((ref) => ""); final webViewLoadingState = StateProvider.autoDispose((ref) => true); -final downloadProgressProvider = StateProvider.autoDispose((ref) => 0.0); +final downloadProgressProvider = + StateProvider.autoDispose((ref) => 0.0); final mirrorStatusProvider = StateProvider.autoDispose((ref) => false); final totalFileSizeInBytes = StateProvider.autoDispose((ref) => 0); final downloadedFileSizeInBytes = StateProvider.autoDispose((ref) => 0); -final downloadState = StateProvider.autoDispose((ref) => ProcessState.waiting); -final checkSumState = StateProvider.autoDispose((ref) => CheckSumProcessState.waiting); +final downloadState = + StateProvider.autoDispose((ref) => ProcessState.waiting); +final checkSumState = StateProvider.autoDispose( + (ref) => CheckSumProcessState.waiting); final cancelCurrentDownload = StateProvider((ref) { return CancelToken(); }); @@ -101,8 +198,12 @@ final openEpubWithExternalAppProvider = StateProvider((ref) => false); // Download Settings final showManualDownloadButtonProvider = StateProvider((ref) => false); +// Instance Auto-Ranking Setting (default: enabled) +final autoRankInstancesProvider = StateProvider((ref) => true); + // Instance Management States -final instanceManagerProvider = Provider((ref) => InstanceManager()); +final instanceManagerProvider = + Provider((ref) => InstanceManager()); // Download Manager States final downloadManagerProvider = Provider((ref) { @@ -111,17 +212,20 @@ final downloadManagerProvider = Provider((ref) { return manager; }); -final activeDownloadsProvider = StreamProvider>((ref) { +final activeDownloadsProvider = + StreamProvider>((ref) { final manager = ref.watch(downloadManagerProvider); return manager.downloadsStream; }); -final archiveInstancesProvider = FutureProvider>((ref) async { +final archiveInstancesProvider = + FutureProvider>((ref) async { final manager = ref.watch(instanceManagerProvider); return await manager.getInstances(); }); -final enabledInstancesProvider = FutureProvider>((ref) async { +final enabledInstancesProvider = + FutureProvider>((ref) async { final manager = ref.watch(instanceManagerProvider); return await manager.getEnabledInstances(); }); @@ -148,6 +252,16 @@ final getFileTypeValue = Provider.autoDispose((ref) { return selectedFile == "All" ? '' : selectedFile.toLowerCase(); }); +final getLanguageValue = Provider.autoDispose((ref) { + return languageValues[ref.watch(selectedLanguageState)] ?? ''; +}); + +final getYearValue = Provider.autoDispose((ref) { + return ref.watch(selectedYearState) == "All" + ? '' + : ref.watch(selectedYearState); +}); + // Helper function to convert bytes to readable file size String bytesToFileSize(int bytes) { const int decimals = 1; @@ -175,20 +289,22 @@ final getTrendingBooks = FutureProvider>((ref) async { GoodReads goodReads = GoodReads(); // Assuming these classes are available from your project imports // ignore: prefer_const_constructors - final penguinTrending = PenguinRandomHouse(); + final penguinTrending = PenguinRandomHouse(); // ignore: prefer_const_constructors final bookDigits = BookDigits(); - List trendingBooks = await Future.wait>([ + List trendingBooks = + await Future.wait>([ goodReads.trendingBooks(), penguinTrending.trendingBooks(), // openLibrary.trendingBooks(), // Commented out as in the original bookDigits.trendingBooks(), ]).then((List> listOfData) => - listOfData.expand((element) => element).toList()); + listOfData.expand((element) => element).toList()); if (trendingBooks.isEmpty) { - throw Exception('Nothing Trending Today :('); // Use Exception instead of String + throw Exception( + 'Nothing Trending Today :('); // Use Exception instead of String } trendingBooks.shuffle(); return trendingBooks; @@ -210,7 +326,8 @@ final getSubCategoryTypeList = FutureProvider.family // Provider for Anna's Archive Search Results final searchProvider = FutureProvider.family .autoDispose, String>((ref, searchQuery) async { - if (searchQuery.isEmpty) return []; // Return empty list if search query is empty + if (searchQuery.isEmpty) + return []; // Return empty list if search query is empty final AnnasArchieve annasArchieve = AnnasArchieve(); List data = await annasArchieve.searchBooks( @@ -218,6 +335,8 @@ final searchProvider = FutureProvider.family content: ref.watch(getTypeValue), sort: ref.watch(getSortValue), fileType: ref.watch(getFileTypeValue), + language: ref.watch(getLanguageValue), + year: ref.watch(getYearValue), enableFilters: ref.watch(enableFiltersState)); return data; }); @@ -240,10 +359,15 @@ final checkIdExists = return await dataBase.checkIdExists(id); }); +final getBookByIdProvider = + FutureProvider.family.autoDispose((ref, id) async { + return await dataBase.getId(id); +}); + final deleteFileFromMyLib = FutureProvider.family((ref, fileName) async { - // NOTE: Assuming deleteFileWithDbData is a function in files.dart - return await deleteFileWithDbData(ref, fileName.md5, fileName.format); + return await deleteFileWithDbData(ref, fileName.md5, fileName.format, + fileName: fileName.fileName); }); final filePathProvider = @@ -271,4 +395,4 @@ Future saveEpubState( String fileName, String? position, WidgetRef ref) async { String pos = position ?? ''; await dataBase.saveBookState(fileName, pos); -} \ No newline at end of file +} diff --git a/lib/ui/book_info_page.dart b/lib/ui/book_info_page.dart index 98be90a..33fef24 100644 --- a/lib/ui/book_info_page.dart +++ b/lib/ui/book_info_page.dart @@ -32,6 +32,7 @@ import 'package:openlib/state/state.dart' downloadState, checkSumState, checkIdExists, + getBookByIdProvider, myLibraryProvider, showManualDownloadButtonProvider, downloadManagerProvider; @@ -110,22 +111,28 @@ class _ActionButtonWidgetState extends ConsumerState { @override Widget build(BuildContext context) { final isBookExist = ref.watch(checkIdExists(widget.data.md5)); + final bookData = ref.watch(getBookByIdProvider(widget.data.md5)); return isBookExist.when( data: (isExists) { if (isExists) { + // Get fileName from database for existing books + final fileName = bookData.whenOrNull(data: (book) => book?.fileName); return FileOpenAndDeleteButtons( id: widget.data.md5, format: widget.data.format!, + fileName: fileName, onDelete: () async { await Future.delayed(const Duration(seconds: 1)); // ignore: unused_result ref.refresh(checkIdExists(widget.data.md5)); + // ignore: unused_result + ref.refresh(getBookByIdProvider(widget.data.md5)); }, ); } else { final showManualButton = ref.watch(showManualDownloadButtonProvider); - + return Padding( padding: const EdgeInsets.only(top: 21, bottom: 21), child: Wrap( @@ -157,10 +164,13 @@ class _ActionButtonWidgetState extends ConsumerState { Webview(url: widget.data.mirror!), ), ); - - if (mirrors != null && mirrors.isNotEmpty && context.mounted) { + + if (mirrors != null && + mirrors.isNotEmpty && + context.mounted) { // Start download with fetched mirrors - final downloadManager = ref.read(downloadManagerProvider); + final downloadManager = + ref.read(downloadManagerProvider); final task = DownloadTask( id: '${widget.data.md5}_${DateTime.now().millisecondsSinceEpoch}', md5: widget.data.md5, @@ -174,9 +184,9 @@ class _ActionButtonWidgetState extends ConsumerState { link: widget.data.link, mirrors: mirrors, ); - + await downloadManager.addDownload(task); - + if (context.mounted) { showSnackBar( context: context, @@ -187,7 +197,8 @@ class _ActionButtonWidgetState extends ConsumerState { } } else { // Other platforms: use background mirror fetcher - final downloadManager = ref.read(downloadManagerProvider); + final downloadManager = + ref.read(downloadManagerProvider); final task = DownloadTask( id: '${widget.data.md5}_${DateTime.now().millisecondsSinceEpoch}', md5: widget.data.md5, @@ -200,14 +211,15 @@ class _ActionButtonWidgetState extends ConsumerState { description: widget.data.description, link: widget.data.link, mirrors: [], // Will be fetched in background - mirrorUrl: widget.data.mirror, // Store mirror URL for retry + mirrorUrl: + widget.data.mirror, // Store mirror URL for retry ); - + await downloadManager.addDownloadWithMirrorUrl( task, widget.data.mirror!, ); - + if (context.mounted) { showSnackBar( context: context, @@ -227,9 +239,12 @@ class _ActionButtonWidgetState extends ConsumerState { if (showManualButton) TextButton( style: TextButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.2), - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + backgroundColor: Theme.of(context) + .colorScheme + .tertiary + .withValues(alpha: 0.2), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), textStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w900, @@ -246,10 +261,13 @@ class _ActionButtonWidgetState extends ConsumerState { Webview(url: widget.data.mirror!), ), ); - - if (mirrors != null && mirrors.isNotEmpty && context.mounted) { + + if (mirrors != null && + mirrors.isNotEmpty && + context.mounted) { // Start download in background with fetched mirrors - final downloadManager = ref.read(downloadManagerProvider); + final downloadManager = + ref.read(downloadManagerProvider); final task = DownloadTask( id: '${widget.data.md5}_${DateTime.now().millisecondsSinceEpoch}', md5: widget.data.md5, @@ -263,9 +281,9 @@ class _ActionButtonWidgetState extends ConsumerState { link: widget.data.link, mirrors: mirrors, // Use manually fetched mirrors ); - + await downloadManager.addDownload(task); - + if (context.mounted) { showSnackBar( context: context, @@ -312,14 +330,22 @@ Future downloadFileWidget(WidgetRef ref, BuildContext context, builder: (BuildContext context) { return _ShowDialog(title: data.title); }); - // print(mirrors); + + String? downloadedFileName; + downloadFile( mirrors: mirrors, md5: data.md5, format: data.format!, + title: data.title, + author: data.author, + info: data.info, onStart: () { ref.read(downloadState.notifier).state = ProcessState.running; }, + onFileName: (String fileName) { + downloadedFileName = fileName; + }, onProgress: (int rcv, int total) async { if (ref.read(totalFileSizeInBytes) != total) { ref.read(totalFileSizeInBytes.notifier).state = total; @@ -339,14 +365,16 @@ Future downloadFileWidget(WidgetRef ref, BuildContext context, publisher: data.publisher, info: data.info, format: data.format, - description: data.description)); + description: data.description, + fileName: downloadedFileName)); ref.read(downloadState.notifier).state = ProcessState.complete; ref.read(checkSumState.notifier).state = CheckSumProcessState.running; try { final checkSum = await verifyFileCheckSum( - md5Hash: data.md5, format: data.format!); + md5Hash: data.md5, + fileName: downloadedFileName ?? "${data.md5}.${data.format}"); if (checkSum == true) { ref.read(checkSumState.notifier).state = CheckSumProcessState.success; @@ -710,4 +738,4 @@ Future _showWarningFileDialog(BuildContext context) async { ); }, ); -} \ No newline at end of file +} diff --git a/lib/ui/components/active_downloads_widget.dart b/lib/ui/components/active_downloads_widget.dart index 61f61df..0880d8c 100644 --- a/lib/ui/components/active_downloads_widget.dart +++ b/lib/ui/components/active_downloads_widget.dart @@ -62,12 +62,16 @@ class ActiveDownloadsWidget extends ConsumerWidget { color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), width: 1.5, ), boxShadow: [ BoxShadow( - color: Theme.of(context).colorScheme.shadow.withValues(alpha: 0.08), + color: Theme.of(context) + .colorScheme + .shadow + .withValues(alpha: 0.08), blurRadius: 10, offset: const Offset(0, 3), ), @@ -79,7 +83,10 @@ class ActiveDownloadsWidget extends ConsumerWidget { Container( padding: const EdgeInsets.all(14.0), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), + color: Theme.of(context) + .colorScheme + .secondary + .withValues(alpha: 0.1), borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), @@ -103,7 +110,8 @@ class ActiveDownloadsWidget extends ConsumerWidget { ), const SizedBox(width: 6), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, borderRadius: BorderRadius.circular(12), @@ -132,7 +140,10 @@ class ActiveDownloadsWidget extends ConsumerWidget { separatorBuilder: (context, index) => Divider( height: 1, thickness: 0.5, - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2), ), itemBuilder: (context, index) { final task = downloads.values.elementAt(index); @@ -154,7 +165,7 @@ class ActiveDownloadsWidget extends ConsumerWidget { } } -class _DownloadItem extends ConsumerWidget { +class _DownloadItem extends ConsumerStatefulWidget { final DownloadTask task; final String Function(int) bytesToFileSize; final String Function(DownloadStatus) getStatusText; @@ -166,8 +177,76 @@ class _DownloadItem extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_DownloadItem> createState() => _DownloadItemState(); +} + +class _DownloadItemState extends ConsumerState<_DownloadItem> { + // Track if auto-verification has been triggered + bool _autoVerificationTriggered = false; + + @override + void initState() { + super.initState(); + // Auto-trigger verification if manual verification is required + _checkAndTriggerAutoVerification(); + } + + @override + void didUpdateWidget(_DownloadItem oldWidget) { + super.didUpdateWidget(oldWidget); + // Check again if task status changed + if (oldWidget.task.status != widget.task.status) { + _checkAndTriggerAutoVerification(); + } + } + + // Automatically trigger verification when manual verification is required + void _checkAndTriggerAutoVerification() { + if (_autoVerificationTriggered) return; + + final task = widget.task; + if (task.status == DownloadStatus.failed && + task.errorMessage?.contains('Manual verification required') == true && + task.mirrorUrl != null) { + _autoVerificationTriggered = true; + // Use post-frame callback to ensure context is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _triggerVerification(); + } + }); + } + } + + // Open webview for manual verification with visible countdown/CAPTCHA + Future _triggerVerification() async { + final task = widget.task; + if (task.mirrorUrl == null) return; + + final downloadManager = ref.read(downloadManagerProvider); + + final List? mirrors = await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => Webview( + url: task.mirrorUrl!, + showOverlay: false, // Show full page for CAPTCHA interaction + ), + ), + ); + + if (mirrors != null && mirrors.isNotEmpty && mounted) { + // Update task with fetched mirrors and restart download + final updatedTask = task.copyWith(mirrors: mirrors); + downloadManager.removeDownload(task.id); + await downloadManager.addDownload(updatedTask); + } + } + + @override + Widget build(BuildContext context) { final downloadManager = ref.read(downloadManagerProvider); + final task = widget.task; return Padding( padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), @@ -182,7 +261,8 @@ class _DownloadItem extends ConsumerWidget { width: 36, height: 36, decoration: BoxDecoration( - color: _getStatusColor(task.status, context).withValues(alpha: 0.15), + color: _getStatusColor(task.status, context) + .withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Center( @@ -208,7 +288,7 @@ class _DownloadItem extends ConsumerWidget { Row( children: [ Text( - getStatusText(task.status), + widget.getStatusText(task.status), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, @@ -254,7 +334,8 @@ class _DownloadItem extends ConsumerWidget { icon: Icon( Icons.close_rounded, size: 20, - color: Theme.of(context).colorScheme.tertiary.withAlpha(170), + color: + Theme.of(context).colorScheme.tertiary.withAlpha(170), ), onPressed: () { downloadManager.cancelDownload(task.id); @@ -290,11 +371,12 @@ class _DownloadItem extends ConsumerWidget { ), ), Text( - '${bytesToFileSize(task.downloadedBytes)} / ${bytesToFileSize(task.totalBytes)}', + '${widget.bytesToFileSize(task.downloadedBytes)} / ${widget.bytesToFileSize(task.totalBytes)}', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.tertiary.withAlpha(140), + color: + Theme.of(context).colorScheme.tertiary.withAlpha(140), ), ), ], @@ -313,10 +395,12 @@ class _DownloadItem extends ConsumerWidget { ), ), ] else if (task.status == DownloadStatus.failed) ...[ - if (task.errorMessage?.contains('Manual verification required') == true) ...[ + if (task.errorMessage?.contains('Manual verification required') == + true) ...[ // Show "Verify" button for manual verification required error Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), @@ -346,17 +430,22 @@ class _DownloadItem extends ConsumerWidget { const SizedBox(width: 8), TextButton( onPressed: () async { - // Open webview for manual verification + // Open webview for manual verification with full page visibility if (task.mirrorUrl != null) { final List? mirrors = await Navigator.push( context, MaterialPageRoute( - builder: (BuildContext context) => - Webview(url: task.mirrorUrl!), + builder: (BuildContext context) => Webview( + url: task.mirrorUrl!, + showOverlay: + false, // Show full page for CAPTCHA + ), ), ); - - if (mirrors != null && mirrors.isNotEmpty && context.mounted) { + + if (mirrors != null && + mirrors.isNotEmpty && + context.mounted) { // Update task with fetched mirrors and restart download final updatedTask = task.copyWith(mirrors: mirrors); downloadManager.removeDownload(task.id); @@ -366,7 +455,8 @@ class _DownloadItem extends ConsumerWidget { }, style: TextButton.styleFrom( backgroundColor: Colors.orange, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -385,7 +475,8 @@ class _DownloadItem extends ConsumerWidget { ] else ...[ // Show regular error for other failures Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), diff --git a/lib/ui/components/book_card_widget.dart b/lib/ui/components/book_card_widget.dart index ea54b37..9e2e186 100644 --- a/lib/ui/components/book_card_widget.dart +++ b/lib/ui/components/book_card_widget.dart @@ -6,18 +6,45 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // Project imports: -import 'package:openlib/state/state.dart' show checkIdExists; +import 'package:openlib/state/state.dart' + show checkIdExists, languageCodeToDisplay; import 'package:openlib/ui/extensions.dart'; +// Extract file type from book info string String? getFileType(String? info) { - if (info != null && info.isNotEmpty) { - info = info.toLowerCase(); - if (info.contains('pdf')) return "PDF"; - if (info.contains('epub')) return "Epub"; - if (info.contains('cbr')) return "Cbr"; - if (info.contains('cbz')) return "Cbz"; - return null; + if (info == null || info.isEmpty) return null; + final infoLower = info.toLowerCase(); + if (infoLower.contains('pdf')) return "PDF"; + if (infoLower.contains('epub')) return "Epub"; + if (infoLower.contains('cbr')) return "Cbr"; + if (infoLower.contains('cbz')) return "Cbz"; + return null; +} + +// Extract language code from book info string +// Info format typically: "[en], pdf, 5.2MB" or "English, pdf, 5.2MB" +String? getLanguage(String? info) { + if (info == null || info.isEmpty) return null; + + // Try to match [xx] pattern for language code + final bracketMatch = + RegExp(r'\[([a-z]{2})\]', caseSensitive: false).firstMatch(info); + if (bracketMatch != null) { + final code = bracketMatch.group(1)?.toLowerCase(); + if (code != null && languageCodeToDisplay.containsKey(code)) { + return languageCodeToDisplay[code]; + } } + + // Try to find language code at start of info (common format: "en, pdf, ...") + final parts = info.split(','); + if (parts.isNotEmpty) { + final firstPart = parts[0].trim().toLowerCase(); + if (languageCodeToDisplay.containsKey(firstPart)) { + return languageCodeToDisplay[firstPart]; + } + } + return null; } @@ -46,9 +73,10 @@ class BookInfoCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { String? fileType = getFileType(info); - + String? language = getLanguage(info); + // Check if book is downloaded (only if md5 is provided) - final isDownloaded = md5 != null + final isDownloaded = md5 != null ? ref.watch(checkIdExists(md5!)) : const AsyncValue.data(false); @@ -181,6 +209,32 @@ class BookInfoCard extends ConsumerWidget { const SizedBox( width: 3, ), + // Language badge + if (language != null) + Container( + decoration: BoxDecoration( + color: "#6b8cce".toColor(), + borderRadius: BorderRadius.circular(2.5), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(3, 2, 3, 2), + child: Text( + language, + style: const TextStyle( + fontSize: 8.5, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: 0.5, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + if (language != null) + const SizedBox( + width: 3, + ), Expanded( child: Text( author, diff --git a/lib/ui/components/delete_dialog_widget.dart b/lib/ui/components/delete_dialog_widget.dart index 9552307..02adf30 100644 --- a/lib/ui/components/delete_dialog_widget.dart +++ b/lib/ui/components/delete_dialog_widget.dart @@ -11,12 +11,14 @@ import 'package:openlib/ui/components/snack_bar_widget.dart'; class ShowDeleteDialog extends ConsumerWidget { final String id; final String format; + final String? fileName; final Function onDelete; const ShowDeleteDialog( {super.key, required this.id, required this.format, + this.fileName, required this.onDelete}); @override @@ -86,8 +88,8 @@ class ShowDeleteDialog extends ConsumerWidget { ), ), onPressed: () { - ref.read(deleteFileFromMyLib( - FileName(md5: id, format: format))); + ref.read(deleteFileFromMyLib(FileName( + md5: id, format: format, fileName: fileName))); Navigator.of(context).pop(); showSnackBar( diff --git a/lib/ui/components/file_buttons_widget.dart b/lib/ui/components/file_buttons_widget.dart index 6714370..c13fa77 100644 --- a/lib/ui/components/file_buttons_widget.dart +++ b/lib/ui/components/file_buttons_widget.dart @@ -15,14 +15,19 @@ import 'package:openlib/ui/pdf_viewer.dart' show launchPdfViewer; class FileOpenAndDeleteButtons extends ConsumerWidget { final String id; final String format; + final String? fileName; final Function onDelete; const FileOpenAndDeleteButtons( {super.key, required this.id, required this.format, + this.fileName, required this.onDelete}); + // Get actual filename - uses fileName if provided, otherwise falls back to id.format + String get actualFileName => fileName ?? "$id.$format"; + @override Widget build(BuildContext context, WidgetRef ref) { return Padding( @@ -42,12 +47,12 @@ class FileOpenAndDeleteButtons extends ConsumerWidget { onPressed: () async { if (format == 'pdf') { await launchPdfViewer( - fileName: '$id.$format', context: context, ref: ref); + fileName: actualFileName, context: context, ref: ref); } else if (format == 'epub') { await launchEpubViewer( - fileName: '$id.$format', context: context, ref: ref); + fileName: actualFileName, context: context, ref: ref); } else { - await openCbrAndCbz(fileName: '$id.$format', context: context); + await openCbrAndCbz(fileName: actualFileName, context: context); } }, child: const Padding( @@ -76,6 +81,7 @@ class FileOpenAndDeleteButtons extends ConsumerWidget { return ShowDeleteDialog( id: id, format: format, + fileName: fileName, onDelete: onDelete, ); }); diff --git a/lib/ui/instances_page.dart b/lib/ui/instances_page.dart index 20c5e87..2ccbbfb 100644 --- a/lib/ui/instances_page.dart +++ b/lib/ui/instances_page.dart @@ -19,6 +19,8 @@ class InstancesPage extends ConsumerStatefulWidget { class _InstancesPageState extends ConsumerState { final TextEditingController _nameController = TextEditingController(); final TextEditingController _urlController = TextEditingController(); + Map _responseTimes = {}; + bool _isTesting = false; @override void dispose() { @@ -27,6 +29,51 @@ class _InstancesPageState extends ConsumerState { super.dispose(); } + Future _testAllInstances() async { + if (_isTesting) return; + + setState(() { + _isTesting = true; + _responseTimes = {}; + }); + + final scaffoldMessenger = ScaffoldMessenger.of(context); + + try { + final manager = ref.read(instanceManagerProvider); + final results = await manager.rankInstancesBySpeed(); + + if (mounted) { + setState(() { + _responseTimes = results; + _isTesting = false; + }); + + // Refresh the list to show new order + ref.invalidate(archiveInstancesProvider); + + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text('Instances tested and ranked by speed'), + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _isTesting = false; + }); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text('Testing failed: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + void _showAddInstanceDialog() { _nameController.clear(); _urlController.clear(); @@ -168,6 +215,20 @@ class _InstancesPageState extends ConsumerState { appBar: AppBar( title: const Text('Manage Instances'), actions: [ + IconButton( + icon: _isTesting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.speed), + onPressed: _isTesting ? null : _testAllInstances, + tooltip: 'Test & Rank All Instances', + ), IconButton( icon: const Icon(Icons.add), onPressed: _showAddInstanceDialog, @@ -233,6 +294,8 @@ class _InstancesPageState extends ConsumerState { }, itemBuilder: (context, index) { final instance = instances[index]; + final responseTime = _responseTimes[instance.id]; + return Card( key: ValueKey(instance.id), margin: const EdgeInsets.symmetric( @@ -264,6 +327,42 @@ class _InstancesPageState extends ConsumerState { fontWeight: FontWeight.bold), ), ), + // Show response time badge if available + if (_responseTimes.containsKey(instance.id)) + Container( + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: responseTime != null + ? (responseTime < 500 + ? Colors.green + .withValues(alpha: 0.2) + : responseTime < 1500 + ? Colors.orange + .withValues(alpha: 0.2) + : Colors.red + .withValues(alpha: 0.2)) + : Colors.grey.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + responseTime != null + ? '${responseTime}ms' + : 'offline', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: responseTime != null + ? (responseTime < 500 + ? Colors.green + : responseTime < 1500 + ? Colors.orange + : Colors.red) + : Colors.grey, + ), + ), + ), if (instance.isCustom) Container( padding: const EdgeInsets.symmetric( diff --git a/lib/ui/mybook_page.dart b/lib/ui/mybook_page.dart index b86538c..b98e7a4 100644 --- a/lib/ui/mybook_page.dart +++ b/lib/ui/mybook_page.dart @@ -60,6 +60,7 @@ class BookPage extends StatelessWidget { child: FileOpenAndDeleteButtons( id: snapshot.data!.id, format: snapshot.data!.format!, + fileName: snapshot.data!.fileName, onDelete: () { Navigator.of(context).pop(); }, diff --git a/lib/ui/search_page.dart b/lib/ui/search_page.dart index 4e9736b..49ef5fa 100644 --- a/lib/ui/search_page.dart +++ b/lib/ui/search_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:async'; // For Timer/Debounce // Project imports: +import 'package:openlib/services/database.dart'; import 'package:openlib/ui/components/active_downloads_widget.dart'; import 'package:openlib/ui/components/page_title_widget.dart'; import 'package:openlib/ui/results_page.dart'; @@ -19,9 +20,13 @@ import 'package:openlib/state/state.dart' selectedTypeState, selectedSortState, selectedFileTypeState, + selectedLanguageState, + selectedYearState, typeValues, fileType, sortValues, + languageValues, + yearValues, enableFiltersState; // ==================================================================== @@ -125,6 +130,8 @@ class _SearchPageState extends ConsumerState { final dropdownTypeValue = ref.watch(selectedTypeState); final dropdownSortValue = ref.watch(selectedSortState); final dropDownFileTypeValue = ref.watch(selectedFileTypeState); + final dropdownLanguageValue = ref.watch(selectedLanguageState); + final dropdownYearValue = ref.watch(selectedYearState); // Watch suggestion states final suggestions = @@ -288,6 +295,8 @@ class _SearchPageState extends ConsumerState { }).toList(), onChanged: (String? val) { ref.read(selectedTypeState.notifier).state = val ?? ''; + MyLibraryDb.instance + .savePreference('filterType', val ?? 'All'); }, ), ), @@ -330,6 +339,8 @@ class _SearchPageState extends ConsumerState { }).toList(), onChanged: (String? val) { ref.read(selectedSortState.notifier).state = val ?? ''; + MyLibraryDb.instance + .savePreference('filterSort', val ?? 'Most Relevant'); }, ), ), @@ -371,6 +382,98 @@ class _SearchPageState extends ConsumerState { onChanged: (String? val) { ref.read(selectedFileTypeState.notifier).state = val ?? 'All'; + MyLibraryDb.instance + .savePreference('filterFileType', val ?? 'All'); + }, + ), + ), + ), + // Language filter dropdown + Padding( + padding: const EdgeInsets.only(left: 7, right: 7, top: 19), + child: SizedBox( + width: 200, + child: DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Language', + labelStyle: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.secondary, + ), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey, width: 2), + borderRadius: BorderRadius.all(Radius.circular(50)), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.tertiary, + width: 2), + borderRadius: const BorderRadius.all(Radius.circular(50)), + ), + ), + initialValue: dropdownLanguageValue, + items: languageValues.keys + .toList() + .map>((String value) { + return DropdownMenuItem( + value: value, + child: Text( + value, + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.bold), + ), + ); + }).toList(), + onChanged: (String? val) { + ref.read(selectedLanguageState.notifier).state = + val ?? 'All'; + MyLibraryDb.instance + .savePreference('filterLanguage', val ?? 'All'); + }, + ), + ), + ), + // Year filter dropdown + Padding( + padding: const EdgeInsets.only(left: 7, right: 7, top: 19), + child: SizedBox( + width: 180, + child: DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Year Published', + labelStyle: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.secondary, + ), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey, width: 2), + borderRadius: BorderRadius.all(Radius.circular(50)), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.tertiary, + width: 2), + borderRadius: const BorderRadius.all(Radius.circular(50)), + ), + ), + initialValue: dropdownYearValue, + items: + yearValues.map>((String value) { + return DropdownMenuItem( + value: value, + child: Text( + value, + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.bold), + ), + ); + }).toList(), + onChanged: (String? val) { + ref.read(selectedYearState.notifier).state = val ?? 'All'; + MyLibraryDb.instance + .savePreference('filterYear', val ?? 'All'); }, ), ), diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 686ddc4..b711f5e 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -27,6 +27,7 @@ import 'package:openlib/state/state.dart' openPdfWithExternalAppProvider, openEpubWithExternalAppProvider, showManualDownloadButtonProvider, + autoRankInstancesProvider, instanceManagerProvider, currentInstanceProvider, archiveInstancesProvider, @@ -162,6 +163,7 @@ class SettingsPage extends ConsumerWidget { const Icon(Icons.settings), ], ), + const _AutoRankInstancesWidget(), const Padding( padding: EdgeInsets.only(left: 5, right: 5, top: 20, bottom: 5), child: Text( @@ -643,6 +645,213 @@ class _InstanceSelectorWidgetState } } +// Auto-rank instances widget with toggle and manual rank button +class _AutoRankInstancesWidget extends ConsumerStatefulWidget { + const _AutoRankInstancesWidget(); + + @override + ConsumerState<_AutoRankInstancesWidget> createState() => + _AutoRankInstancesWidgetState(); +} + +class _AutoRankInstancesWidgetState + extends ConsumerState<_AutoRankInstancesWidget> { + bool _isRanking = false; + bool _autoRankEnabled = true; + + @override + void initState() { + super.initState(); + _loadAutoRankSetting(); + } + + Future _loadAutoRankSetting() async { + final manager = ref.read(instanceManagerProvider); + final enabled = await manager.isAutoRankEnabled(); + if (mounted) { + setState(() { + _autoRankEnabled = enabled; + }); + ref.read(autoRankInstancesProvider.notifier).state = enabled; + } + } + + Future _toggleAutoRank(bool value) async { + final manager = ref.read(instanceManagerProvider); + await manager.setAutoRankEnabled(value); + if (mounted) { + setState(() { + _autoRankEnabled = value; + }); + ref.read(autoRankInstancesProvider.notifier).state = value; + } + } + + Future _rankNow() async { + if (_isRanking) return; + + setState(() { + _isRanking = true; + }); + + final scaffoldMessenger = ScaffoldMessenger.of(context); + + try { + final manager = ref.read(instanceManagerProvider); + final results = await manager.rankInstancesBySpeed(); + + // Refresh the instances provider to reflect new order + ref.invalidate(archiveInstancesProvider); + ref.invalidate(currentInstanceProvider); + + if (!mounted) return; + + // Find the fastest instance + String fastestName = "Unknown"; + int? fastestTime; + final instances = await manager.getInstances(); + for (final instance in instances) { + final time = results[instance.id]; + if (time != null && (fastestTime == null || time < fastestTime)) { + fastestTime = time; + fastestName = instance.name; + } + } + + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + fastestTime != null + ? "Ranked! Fastest: $fastestName (${fastestTime}ms)" + : "Ranking complete", + ), + duration: const Duration(seconds: 3), + ), + ); + } catch (e) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Ranking failed: ${e.toString()}"), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() { + _isRanking = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 5, right: 5, top: 10), + child: Container( + height: 75, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: Theme.of(context).colorScheme.tertiaryContainer, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Auto-Rank Instances", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.tertiary, + ), + ), + const SizedBox(height: 4), + Text( + "Automatically sort by speed on startup", + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .colorScheme + .tertiary + .withAlpha(140), + ), + ), + ], + ), + ), + Switch( + value: _autoRankEnabled, + thumbColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? Colors.green + : null), + onChanged: _toggleAutoRank, + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 5, right: 5, top: 10), + child: InkWell( + onTap: _isRanking ? null : _rankNow, + child: Container( + height: 61, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: Theme.of(context).colorScheme.tertiaryContainer, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Rank Instances Now", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: _isRanking + ? Theme.of(context) + .colorScheme + .tertiary + .withAlpha(100) + : Theme.of(context).colorScheme.tertiary, + ), + ), + _isRanking + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.secondary, + ), + ) + : const Icon(Icons.speed), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + // Update settings widget with prerelease toggle and check button class _UpdateSettingsWidget extends StatefulWidget { const _UpdateSettingsWidget(); diff --git a/lib/ui/webview_page.dart b/lib/ui/webview_page.dart index bb678cc..d0b1bcb 100644 --- a/lib/ui/webview_page.dart +++ b/lib/ui/webview_page.dart @@ -8,7 +8,8 @@ import 'package:flutter/material.dart'; // Package imports: import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:desktop_webview_window/desktop_webview_window.dart' as desktop_webview; +import 'package:desktop_webview_window/desktop_webview_window.dart' + as desktop_webview; import 'package:path_provider/path_provider.dart'; // Project imports: @@ -16,8 +17,16 @@ import 'package:openlib/services/platform_utils.dart'; import 'package:openlib/services/logger.dart'; class Webview extends ConsumerStatefulWidget { - const Webview({super.key, required this.url}); + const Webview({ + super.key, + required this.url, + this.showOverlay = true, + }); + final String url; + // When false, shows the full page for manual verification with CAPTCHA + final bool showOverlay; + @override // ignore: library_private_types_in_public_api _WebviewState createState() => _WebviewState(); @@ -47,8 +56,9 @@ class _WebviewState extends ConsumerState { Future _openDesktopWebview() async { try { - _logger.info("Opening desktop webview for Linux", tag: "WebView", metadata: {"url": widget.url}); - + _logger.info("Opening desktop webview for Linux", + tag: "WebView", metadata: {"url": widget.url}); + // Get app documents directory for webview data final appDir = await getApplicationSupportDirectory(); final webviewDataDir = Directory("${appDir.path}/webview_data"); @@ -71,7 +81,9 @@ class _WebviewState extends ConsumerState { // Handle webview close _desktopWebview!.onClose.then((_) { - _logger.info("Desktop webview closed by user", tag: "WebView", metadata: {"links_captured": _capturedDownloadLinks.length}); + _logger.info("Desktop webview closed by user", + tag: "WebView", + metadata: {"links_captured": _capturedDownloadLinks.length}); _pollingTimer?.cancel(); // Return whatever links we found if (mounted && !_linksFound) { @@ -85,13 +97,13 @@ class _WebviewState extends ConsumerState { // Launch the URL _desktopWebview!.launch(widget.url); - + // Start polling for download links after page loads await Future.delayed(const Duration(seconds: 2)); _startPolling(); - } catch (e, stackTrace) { - _logger.error("Failed to open desktop webview", tag: "WebView", error: e, stackTrace: stackTrace); + _logger.error("Failed to open desktop webview", + tag: "WebView", error: e, stackTrace: stackTrace); if (mounted) { Navigator.pop(context, []); } @@ -105,39 +117,43 @@ class _WebviewState extends ConsumerState { timer.cancel(); return; } - + try { // Check current URL - final currentUrl = await _desktopWebview!.evaluateJavaScript("window.location.href"); + final currentUrl = + await _desktopWebview!.evaluateJavaScript("window.location.href"); if (currentUrl == null) return; - + final urlStr = currentUrl.toString().replaceAll('"', ''); - + if (urlStr.contains("slow_download")) { // Extract slow_download link - final result = await _desktopWebview!.evaluateJavaScript( - """(function() { + final result = + await _desktopWebview!.evaluateJavaScript("""(function() { var paragraphTag = document.querySelector('p[class="mb-4 text-xl font-bold"]'); if (paragraphTag) { var anchor = paragraphTag.querySelector('a'); if (anchor && anchor.href) return anchor.href; } return null; - })()""" - ); - - if (result != null && result.toString() != "null" && result.toString().isNotEmpty) { + })()"""); + + if (result != null && + result.toString() != "null" && + result.toString().isNotEmpty) { final link = result.toString().replaceAll('"', ''); - if (link.startsWith("http") && !_capturedDownloadLinks.contains(link)) { + if (link.startsWith("http") && + !_capturedDownloadLinks.contains(link)) { _capturedDownloadLinks.add(link); - _logger.info("Extracted slow_download link", tag: "WebView", metadata: {"link": link}); + _logger.info("Extracted slow_download link", + tag: "WebView", metadata: {"link": link}); _returnLinksAndClose(); } } } else { // Extract IPFS links - final result = await _desktopWebview!.evaluateJavaScript( - """(function() { + final result = + await _desktopWebview!.evaluateJavaScript("""(function() { var linkTags = document.querySelectorAll('ul>li>a'); var links = []; linkTags.forEach(function(e) { @@ -146,27 +162,39 @@ class _WebviewState extends ConsumerState { } }); return JSON.stringify(links); - })()""" - ); - - if (result != null && result.toString() != "null" && result.toString() != "[]") { + })()"""); + + if (result != null && + result.toString() != "null" && + result.toString() != "[]") { try { final linksStr = result.toString(); - final cleanStr = linksStr.replaceAll('[', '').replaceAll(']', '').replaceAll('"', ''); - final links = cleanStr.split(',').where((l) => l.trim().isNotEmpty && l.trim().startsWith('http')).toList(); - + final cleanStr = linksStr + .replaceAll('[', '') + .replaceAll(']', '') + .replaceAll('"', ''); + final links = cleanStr + .split(',') + .where( + (l) => l.trim().isNotEmpty && l.trim().startsWith('http')) + .toList(); + for (final link in links) { final cleanLink = link.trim(); - if (cleanLink.isNotEmpty && !_capturedDownloadLinks.contains(cleanLink)) { + if (cleanLink.isNotEmpty && + !_capturedDownloadLinks.contains(cleanLink)) { _capturedDownloadLinks.add(cleanLink); } } if (_capturedDownloadLinks.isNotEmpty) { - _logger.info("Extracted mirror links", tag: "WebView", metadata: {"count": _capturedDownloadLinks.length}); + _logger.info("Extracted mirror links", + tag: "WebView", + metadata: {"count": _capturedDownloadLinks.length}); _returnLinksAndClose(); } } catch (e) { - _logger.error("Failed to parse mirror links", tag: "WebView", error: e); + _logger.error("Failed to parse mirror links", + tag: "WebView", error: e); } } } @@ -180,16 +208,17 @@ class _WebviewState extends ConsumerState { if (_capturedDownloadLinks.isNotEmpty && !_linksFound && mounted) { _linksFound = true; _pollingTimer?.cancel(); - _logger.info("Returning download links", tag: "WebView", metadata: {"count": _capturedDownloadLinks.length}); - + _logger.info("Returning download links", + tag: "WebView", metadata: {"count": _capturedDownloadLinks.length}); + // Save links before any operations final links = List.from(_capturedDownloadLinks); - + // DON'T close the webview programmatically - causes OpenGL crash on Linux // Instead, just clear reference and let user close it manually // The onClose handler will fire when user closes the window _desktopWebview = null; - + // Return the links immediately if (mounted) { Navigator.pop(context, links); @@ -238,7 +267,10 @@ class _WebviewState extends ConsumerState { textAlign: TextAlign.center, style: TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .tertiary + .withValues(alpha: 0.7), ), ), if (_capturedDownloadLinks.isNotEmpty) ...[ @@ -267,7 +299,7 @@ class _WebviewState extends ConsumerState { ), ); } - + // Mobile/Windows: Use InAppWebView return Scaffold( appBar: AppBar( @@ -310,48 +342,55 @@ class _WebviewState extends ConsumerState { } }, ), - // Loading overlay to hide countdown - Positioned.fill( - child: Container( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 40, - height: 40, - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.secondary, - strokeWidth: 3, - ), - ), - const SizedBox(height: 20), - Text( - 'Preparing download...', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.tertiary, + // Loading overlay to hide countdown - only shown when showOverlay is true + if (widget.showOverlay) + Positioned.fill( + child: Container( + color: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.9), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.secondary, + strokeWidth: 3, + ), ), - ), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), - child: Text( - 'Please wait while we verify access and fetch download links', - textAlign: TextAlign.center, + const SizedBox(height: 20), + Text( + 'Preparing download...', style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.67), + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.tertiary, ), ), - ), - ], + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + 'Please wait while we verify access and fetch download links', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .tertiary + .withValues(alpha: 0.67), + ), + ), + ), + ], + ), ), ), ), - ), ], ), ),