import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('TrackProvider'); const _extensionInitRetryTimeout = Duration(seconds: 30); class TrackState { final List tracks; final bool isLoading; final String? error; final String? albumId; final String? albumName; final String? playlistName; final String? artistId; final String? artistName; final String? coverUrl; final String? headerImageUrl; final String? headerVideoUrl; final int? monthlyListeners; final List? artistAlbums; final List? artistTopTracks; final bool hasSearchText; final bool isShowingRecentAccess; final String? searchExtensionId; final String? selectedSearchFilter; final String? searchSource; const TrackState({ this.tracks = const [], this.isLoading = false, this.error, this.albumId, this.albumName, this.playlistName, this.artistId, this.artistName, this.coverUrl, this.headerImageUrl, this.headerVideoUrl, this.monthlyListeners, this.artistAlbums, this.artistTopTracks, this.hasSearchText = false, this.isShowingRecentAccess = false, this.searchExtensionId, this.selectedSearchFilter, this.searchSource, }); bool get hasContent => tracks.isNotEmpty || artistAlbums != null; TrackState copyWith({ List? tracks, bool? isLoading, String? error, String? albumId, String? albumName, String? playlistName, String? artistId, String? artistName, String? coverUrl, String? headerImageUrl, String? headerVideoUrl, int? monthlyListeners, List? artistAlbums, List? artistTopTracks, bool? hasSearchText, bool? isShowingRecentAccess, String? searchExtensionId, String? selectedSearchFilter, bool clearSelectedSearchFilter = false, String? searchSource, bool clearSearchSource = false, }) { return TrackState( tracks: tracks ?? this.tracks, isLoading: isLoading ?? this.isLoading, error: error, albumId: albumId ?? this.albumId, albumName: albumName ?? this.albumName, playlistName: playlistName ?? this.playlistName, artistId: artistId ?? this.artistId, artistName: artistName ?? this.artistName, coverUrl: coverUrl ?? this.coverUrl, headerImageUrl: headerImageUrl ?? this.headerImageUrl, headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl, monthlyListeners: monthlyListeners ?? this.monthlyListeners, artistAlbums: artistAlbums ?? this.artistAlbums, artistTopTracks: artistTopTracks ?? this.artistTopTracks, hasSearchText: hasSearchText ?? this.hasSearchText, isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, searchExtensionId: searchExtensionId, selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter), searchSource: clearSearchSource ? null : (searchSource ?? this.searchSource), ); } } class ArtistAlbum { final String id; final String name; final String releaseDate; final int totalTracks; final String? coverUrl; final String albumType; final String artists; final String? providerId; const ArtistAlbum({ required this.id, required this.name, required this.releaseDate, required this.totalTracks, this.coverUrl, required this.albumType, required this.artists, this.providerId, }); } class TrackNotifier extends Notifier { int _currentRequestId = 0; @override TrackState build() { return const TrackState(); } bool _isRequestValid(int requestId) => requestId == _currentRequestId; Future fetchFromUrl(String url, {bool useDeezerFallback = true}) async { final requestId = ++_currentRequestId; state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { var extensionHandler = await PlatformBridge.findURLHandler(url); if (extensionHandler == null) { final extensionState = ref.read(extensionProvider); if (!extensionState.isInitialized && extensionState.isLoading) { _log.i( 'Extension URL handlers not ready yet, waiting for initialization...', ); await ref .read(extensionProvider.notifier) .waitForInitialization(timeout: _extensionInitRetryTimeout); if (!_isRequestValid(requestId)) return; extensionHandler = await PlatformBridge.findURLHandler(url); } } if (extensionHandler == null) { state = TrackState( isLoading: false, error: 'url_not_recognized', hasSearchText: state.hasSearchText, ); return; } _log.i('Found extension URL handler: $extensionHandler for URL: $url'); Map? result; for (int attempt = 1; attempt <= 3; attempt++) { result = await PlatformBridge.handleURLWithExtension(url); if (!_isRequestValid(requestId)) return; if (result != null && result['type'] == 'track' && result['track'] != null) { final trackData = result['track'] as Map; final name = trackData['name']?.toString() ?? ''; if (name.isNotEmpty) { break; } } else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) { break; } else if (result != null && result['type'] == 'artist') { break; } if (attempt < 3) { await Future.delayed(const Duration(milliseconds: 500)); } } if (result != null) { final type = result['type'] as String?; final extensionId = result['extension_id'] as String?; if (type == 'track' && result['track'] != null) { final trackData = result['track'] as Map; final track = Track.fromBackendMap(trackData, source: extensionId); if (track.name.isEmpty) { state = TrackState( isLoading: false, error: 'Failed to load track metadata from extension', ); return; } state = TrackState( tracks: [track], isLoading: false, coverUrl: track.coverUrl, searchExtensionId: extensionId, ); return; } else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) { final trackList = result['tracks'] as List; final tracks = trackList .map( (t) => Track.fromBackendMap( t as Map, source: extensionId, ), ) .toList(); state = TrackState( tracks: tracks, isLoading: false, albumId: (result['album'] as Map?)?['id'] as String?, albumName: result['name'] as String? ?? (result['album'] as Map?)?['name'] as String?, playlistName: type == 'playlist' ? result['name'] as String? : null, coverUrl: normalizeCoverReference(result['cover_url']?.toString()), headerVideoUrl: normalizeRemoteHttpUrl( result['header_video']?.toString(), ), searchExtensionId: extensionId, ); return; } else if (type == 'artist' && result['artist'] != null) { final artistData = result['artist'] as Map; final albumsList = artistData['albums'] as List? ?? []; final albums = albumsList .map((a) => _parseArtistAlbum(a as Map)) .toList(); final topTracksList = artistData['top_tracks'] as List? ?? []; final topTracks = topTracksList .map( (t) => Track.fromBackendMap( t as Map, source: extensionId, ), ) .toList(); state = TrackState( tracks: [], isLoading: false, artistId: artistData['id'] as String?, artistName: artistData['name'] as String?, coverUrl: normalizeRemoteHttpUrl( (artistData['image_url'] ?? artistData['images'])?.toString(), ), headerImageUrl: normalizeRemoteHttpUrl( artistData['header_image']?.toString(), ), headerVideoUrl: normalizeRemoteHttpUrl( artistData['header_video']?.toString(), ), monthlyListeners: artistData['listeners'] as int?, artistAlbums: albums, artistTopTracks: topTracks.isNotEmpty ? topTracks : null, searchExtensionId: extensionId, ); return; } } state = TrackState( isLoading: false, error: 'url_not_recognized', hasSearchText: state.hasSearchText, ); } catch (e) { if (!_isRequestValid(requestId)) return; state = TrackState( isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, ); } } Future search(String query, {String? filterOverride}) async { final requestId = ++_currentRequestId; final currentFilter = filterOverride ?? state.selectedSearchFilter; final requestFilter = currentFilter == 'all' ? null : currentFilter; final settings = ref.read(settingsProvider); final extensionState = ref.read(extensionProvider); String? resolvedProvider; final explicitProvider = settings.searchProvider?.trim(); if (explicitProvider != null && explicitProvider.isNotEmpty) { resolvedProvider = explicitProvider; } else { resolvedProvider = defaultSearchExtension(extensionState.extensions)?.id; } if (resolvedProvider != null && resolvedProvider.isNotEmpty && !extensionState.extensions.any( (ext) => ext.enabled && ext.id == resolvedProvider, ) && settings.searchProvider?.trim() == resolvedProvider) { ref.read(settingsProvider.notifier).setSearchProvider(null); resolvedProvider = defaultSearchExtension(extensionState.extensions)?.id; } final isEnabledExtensionProvider = resolvedProvider != null && resolvedProvider.isNotEmpty && extensionState.extensions.any( (ext) => ext.enabled && ext.id == resolvedProvider, ); if (resolvedProvider != null && resolvedProvider.isNotEmpty && isEnabledExtensionProvider) { final resolvedFilter = requestFilter ?? 'track'; Map? options; options = {'filter': resolvedFilter}; await customSearch( resolvedProvider, query, options: options, selectedFilter: resolvedFilter, ); return; } state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); try { final includeExtensions = settings.useExtensionProviders; _log.i( 'Search started: provider=metadata_extensions, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter', ); _log.d('Calling metadata provider track search API...'); final metadataTrackResults = await PlatformBridge.searchTracksWithMetadataProviders( query, limit: 20, includeExtensions: includeExtensions, ); _log.i( 'metadata_extensions returned ${metadataTrackResults.length} tracks', ); if (!_isRequestValid(requestId)) { _log.w('Search request cancelled (requestId=$requestId)'); return; } final tracks = []; for (int i = 0; i < metadataTrackResults.length; i++) { final t = metadataTrackResults[i]; try { tracks.add(Track.fromBackendMap(t)); } catch (e) { _log.e('Failed to parse track[$i]: $e', e); } } _log.i('Search complete: ${tracks.length} tracks parsed successfully'); state = TrackState( tracks: tracks, isLoading: false, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, searchSource: resolvedProvider, ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; _log.e('Search failed: $e', e, stackTrace); state = TrackState( isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); } } Future customSearch( String extensionId, String query, { Map? options, String? selectedFilter, bool allowVerificationRetry = true, }) async { final requestId = ++_currentRequestId; final currentFilter = selectedFilter ?? state.selectedSearchFilter; state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); try { _log.i('Custom search started: extension=$extensionId, query="$query"'); final results = await PlatformBridge.customSearchWithExtension( extensionId, query, options: options, cancelPrevious: true, ); if (!_isRequestValid(requestId)) { _log.w('Custom search request cancelled (requestId=$requestId)'); return; } _log.i('Custom search returned ${results.length} tracks'); final tracks = []; for (int i = 0; i < results.length; i++) { final t = results[i]; try { tracks.add(Track.fromBackendMap(t, source: extensionId)); } catch (e) { _log.e('Failed to parse custom search track[$i]: $e', e); } } _log.i( 'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)', ); final previewCount = tracks.where((t) => t.hasPreview).length; _log.d( 'Custom search preview availability: $previewCount/${tracks.length} tracks have preview_url' '${results.isNotEmpty ? '; first raw keys=${(results.first).keys.toList()}' : ''}', ); state = TrackState( tracks: tracks, isLoading: false, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, searchExtensionId: extensionId, selectedSearchFilter: currentFilter, ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; _log.e('Custom search failed: $e', e, stackTrace); if (allowVerificationRetry && isExtensionVerificationRequired(e)) { _log.i( 'Custom search requires verification; waiting for $extensionId grant', ); state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, searchExtensionId: extensionId, selectedSearchFilter: currentFilter, ); final verified = await openVerificationAndAwaitGrant( extensionId, browserMode: ref .read(settingsProvider) .extensionVerificationBrowserMode, ); if (!_isRequestValid(requestId)) return; if (verified) { _log.i( 'Verification complete for $extensionId; retrying custom search', ); await customSearch( extensionId, query, options: options, selectedFilter: currentFilter, allowVerificationRetry: false, ); return; } } state = TrackState( isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); } } void clear() { state = const TrackState(); } void setSearchFilter(String? filter) { if (state.selectedSearchFilter == filter) return; state = state.copyWith( selectedSearchFilter: filter, clearSelectedSearchFilter: filter == null, ); } void setSearchText(bool hasText) { if (state.hasSearchText == hasText) { return; } state = state.copyWith(hasSearchText: hasText); } void setShowingRecentAccess(bool showing) { if (state.isShowingRecentAccess == showing) { return; } state = state.copyWith(isShowingRecentAccess: showing); } void setTracksFromCollection({ required List tracks, String? albumName, String? playlistName, String? coverUrl, }) { state = TrackState( tracks: tracks, isLoading: false, albumName: albumName, playlistName: playlistName, coverUrl: coverUrl, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, ); } ArtistAlbum _parseArtistAlbum(Map data) { return ArtistAlbum( id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '', totalTracks: data['total_tracks'] as int? ?? 0, coverUrl: normalizeCoverReference( (data['cover_url'] ?? data['images'])?.toString(), ), albumType: data['album_type'] as String? ?? 'album', artists: data['artists'] as String? ?? '', providerId: data['provider_id']?.toString(), ); } } final trackProvider = NotifierProvider( TrackNotifier.new, );