import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; final _log = AppLogger('ExploreProvider'); class ExploreItem { final String id; final String uri; final String type; final String name; final String artists; final String? description; final String? coverUrl; final String? providerId; final String? albumId; final String? albumName; final String? releaseDate; final int durationMs; const ExploreItem({ required this.id, required this.uri, required this.type, required this.name, required this.artists, this.description, this.coverUrl, this.providerId, this.albumId, this.albumName, this.releaseDate, this.durationMs = 0, }); factory ExploreItem.fromJson(Map json) { return ExploreItem( id: json['id'] as String? ?? '', uri: json['uri'] as String? ?? '', type: json['type'] as String? ?? 'track', name: json['name'] as String? ?? '', artists: json['artists'] as String? ?? '', description: json['description'] as String?, coverUrl: json['cover_url'] as String?, providerId: json['provider_id'] as String?, albumId: json['album_id'] as String?, albumName: json['album_name'] as String?, releaseDate: json['release_date']?.toString(), durationMs: json['duration_ms'] as int? ?? 0, ); } Map toJson() => { 'id': id, 'uri': uri, 'type': type, 'name': name, 'artists': artists, 'description': description, 'cover_url': coverUrl, 'provider_id': providerId, 'album_id': albumId, 'album_name': albumName, 'release_date': releaseDate, 'duration_ms': durationMs, }; } class ExploreSection { final String uri; final String title; final List items; final bool isYTMusicQuickPicks; const ExploreSection({ required this.uri, required this.title, required this.items, this.isYTMusicQuickPicks = false, }); factory ExploreSection.fromJson(Map json) { final itemsList = json['items'] as List? ?? []; final items = itemsList .map((item) => ExploreItem.fromJson(item as Map)) .toList(); final isQuickPicks = _isYTMusicQuickPicksItems(items); return ExploreSection( uri: json['uri'] as String? ?? '', title: json['title'] as String? ?? '', items: items, isYTMusicQuickPicks: isQuickPicks, ); } Map toJson() => { 'uri': uri, 'title': title, 'items': items.map((i) => i.toJson()).toList(), }; } class ExploreState { final bool isLoading; final String? error; final String? greeting; final String? providerId; final List sections; final DateTime? lastFetched; const ExploreState({ this.isLoading = false, this.error, this.greeting, this.providerId, this.sections = const [], this.lastFetched, }); bool get hasContent => sections.isNotEmpty; ExploreState copyWith({ bool? isLoading, String? error, String? greeting, String? providerId, bool clearProviderId = false, List? sections, DateTime? lastFetched, }) { return ExploreState( isLoading: isLoading ?? this.isLoading, error: error, greeting: greeting ?? this.greeting, providerId: clearProviderId ? null : (providerId ?? this.providerId), sections: sections ?? this.sections, lastFetched: lastFetched ?? this.lastFetched, ); } } String _getLocalGreeting() { final hour = DateTime.now().hour; if (hour >= 5 && hour < 12) { return 'Good morning'; } else if (hour >= 12 && hour < 17) { return 'Good afternoon'; } else if (hour >= 17 && hour < 21) { return 'Good evening'; } else { return 'Good night'; } } bool _isYTMusicQuickPicksItems(List items) { if (items.isEmpty) return false; if (items.first.providerId != 'ytmusic-spotiflac') return false; for (final item in items) { if (item.type != 'track') { return false; } } return true; } List> _normalizeExploreSectionsPayload( dynamic rawSections, ) { if (rawSections is! List) return const []; final sections = >[]; for (final rawSection in rawSections) { if (rawSection is! Map) continue; final section = Map.from(rawSection); final rawItems = section['items']; final items = >[]; if (rawItems is List) { for (final rawItem in rawItems) { if (rawItem is! Map) continue; items.add(Map.from(rawItem)); } } sections.add({ 'uri': section['uri']?.toString() ?? '', 'title': section['title']?.toString() ?? '', 'items': items, }); } return sections; } List> _withDefaultExploreProviderId( List> normalizedSections, String providerId, ) { final normalizedProviderId = providerId.trim(); if (normalizedProviderId.isEmpty) return normalizedSections; return normalizedSections .map((section) { final rawItems = section['items']; if (rawItems is! List) return section; return { ...section, 'items': rawItems .map((rawItem) { if (rawItem is! Map) return rawItem; final item = Map.from(rawItem); final itemProviderId = item['provider_id']?.toString().trim() ?? ''; if (itemProviderId.isEmpty) { item['provider_id'] = normalizedProviderId; } return item; }) .toList(growable: false), }; }) .toList(growable: false); } Map _decodeExploreCache(String rawCache) { final decoded = jsonDecode(rawCache); if (decoded is! Map) { return const {'provider_id': null, 'sections': >[]}; } final providerId = decoded['provider_id']?.toString().trim(); var sections = _normalizeExploreSectionsPayload(decoded['sections']); if (providerId != null && providerId.isNotEmpty) { sections = _withDefaultExploreProviderId(sections, providerId); } return {'provider_id': providerId, 'sections': sections}; } String _encodeExploreCache(Map cachePayload) { return jsonEncode(cachePayload); } List _buildExploreSectionsFromNormalizedPayload( List> normalizedSections, ) { return normalizedSections .map( (section) => ExploreSection.fromJson(Map.from(section)), ) .toList(growable: false); } class ExploreNotifier extends Notifier { static const _cacheKey = 'explore_home_feed_cache'; static const _cacheTsKey = 'explore_home_feed_ts'; int _homeFeedRequestId = 0; @override ExploreState build() { _restoreFromCache(); return const ExploreState(); } Future _restoreFromCache() async { try { if (ref.read(settingsProvider).homeFeedProvider == AppSettings.homeFeedProviderOff) { _log.d('Home feed disabled, skipping cache restore'); return; } final prefs = await SharedPreferences.getInstance(); final cached = prefs.getString(_cacheKey); final cachedTs = prefs.getInt(_cacheTsKey); if (cached == null || cached.isEmpty) return; final cachePayload = await compute(_decodeExploreCache, cached); final providerId = cachePayload['provider_id']?.toString().trim(); final rawSections = cachePayload['sections']; var normalizedSections = rawSections is List ? rawSections .whereType>() .map((section) => Map.from(section)) .toList(growable: false) : const >[]; final resolvedProviderId = providerId?.isNotEmpty == true ? providerId : _resolveHomeFeedExtension()?.id; if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) { normalizedSections = _withDefaultExploreProviderId( normalizedSections, resolvedProviderId, ); } final sections = _buildExploreSectionsFromNormalizedPayload( normalizedSections, ); if (sections.isEmpty) return; final lastFetched = cachedTs != null ? DateTime.fromMillisecondsSinceEpoch(cachedTs) : null; _log.i('Restored ${sections.length} cached explore sections'); state = ExploreState( greeting: _getLocalGreeting(), providerId: resolvedProviderId, sections: sections, lastFetched: lastFetched, ); } catch (e) { _log.w('Failed to restore explore cache: $e'); try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_cacheKey); await prefs.remove(_cacheTsKey); _log.d('Removed invalid explore cache'); } catch (clearError) { _log.w('Failed to remove invalid explore cache: $clearError'); } } } Extension? _resolveHomeFeedExtension() { final settings = ref.read(settingsProvider); final preferredId = settings.homeFeedProvider; final enabledHomeFeedExtensions = ref .read(extensionProvider) .extensions .where((extension) => extension.enabled && extension.hasHomeFeed) .toList(growable: false); if (preferredId != null && preferredId.isNotEmpty) { return enabledHomeFeedExtensions .where((extension) => extension.id == preferredId) .firstOrNull; } return enabledHomeFeedExtensions.firstOrNull; } Future _saveToCache( List> normalizedSections, String providerId, ) async { try { final prefs = await SharedPreferences.getInstance(); final encoded = await compute(_encodeExploreCache, { 'provider_id': providerId, 'sections': normalizedSections, }); await prefs.setString(_cacheKey, encoded); await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); _log.d('Saved ${normalizedSections.length} explore sections to cache'); } catch (e) { _log.w('Failed to save explore cache: $e'); } } Future fetchHomeFeed({bool forceRefresh = false}) async { _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); if (ref.read(settingsProvider).homeFeedProvider == AppSettings.homeFeedProviderOff) { _log.d('Home feed disabled by user setting'); _homeFeedRequestId++; PlatformBridge.cancelExtensionHomeFeedRequests(); state = const ExploreState(); return; } if (!forceRefresh && state.hasContent && state.lastFetched != null && DateTime.now().difference(state.lastFetched!).inMinutes < 5) { _log.d('Using cached home feed (fresh enough)'); return; } if (state.isLoading && !forceRefresh) { _log.d('Home feed fetch already in progress'); return; } final requestId = ++_homeFeedRequestId; final showLoading = !state.hasContent; state = state.copyWith(isLoading: showLoading, error: null); try { final extState = ref.read(extensionProvider); final settings = ref.read(settingsProvider); final preferredId = settings.homeFeedProvider; _log.d( 'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId', ); final targetExt = _resolveHomeFeedExtension(); if (targetExt == null) { _log.w('No extension with homeFeed capability found'); if (requestId != _homeFeedRequestId) return; state = state.copyWith( isLoading: false, error: 'No extension with home feed support enabled', ); return; } _log.i('Fetching home feed from ${targetExt.id}...'); final result = await PlatformBridge.getExtensionHomeFeed( targetExt.id, cancelPrevious: forceRefresh, ); if (requestId != _homeFeedRequestId) return; if (result == null) { state = state.copyWith( isLoading: false, error: 'Failed to fetch home feed', ); return; } final success = result['success'] as bool? ?? false; _log.d('getExtensionHomeFeed success=$success'); if (!success) { final error = result['error'] as String? ?? 'Unknown error'; state = state.copyWith(isLoading: false, error: error); return; } final greeting = result['greeting'] as String?; final sectionsData = result['sections'] as List? ?? []; final normalizedSectionsWithoutProvider = await compute( _normalizeExploreSectionsPayload, sectionsData, ); final normalizedSections = _withDefaultExploreProviderId( normalizedSectionsWithoutProvider, targetExt.id, ); if (requestId != _homeFeedRequestId) return; final sections = _buildExploreSectionsFromNormalizedPayload( normalizedSections, ); _log.i('Fetched ${sections.length} sections'); if (sections.isNotEmpty && sections.first.items.isNotEmpty) { final firstItem = sections.first.items.first; _log.d( 'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}', ); } final localGreeting = _getLocalGreeting(); _log.d('Greeting from extension: $greeting, using local: $localGreeting'); state = ExploreState( isLoading: false, greeting: localGreeting, providerId: targetExt.id, sections: sections, lastFetched: DateTime.now(), ); _saveToCache(normalizedSections, targetExt.id); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); if (requestId != _homeFeedRequestId) return; state = state.copyWith(isLoading: false, error: e.toString()); } } void clear() { _homeFeedRequestId++; PlatformBridge.cancelExtensionHomeFeedRequests(); state = const ExploreState(); } Future refresh() => fetchHomeFeed(forceRefresh: true); } final exploreProvider = NotifierProvider(() { return ExploreNotifier(); });