SpotiFLAC-Mobile/lib/providers/explore_provider.dart

492 lines
15 KiB
Dart

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<String, dynamic> 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<String, dynamic> 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<ExploreItem> items;
final bool isYTMusicQuickPicks;
const ExploreSection({
required this.uri,
required this.title,
required this.items,
this.isYTMusicQuickPicks = false,
});
factory ExploreSection.fromJson(Map<String, dynamic> json) {
final itemsList = json['items'] as List<dynamic>? ?? [];
final items = itemsList
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
.toList();
final isQuickPicks = _isYTMusicQuickPicksItems(items);
return ExploreSection(
uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '',
items: items,
isYTMusicQuickPicks: isQuickPicks,
);
}
Map<String, dynamic> 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<ExploreSection> 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<ExploreSection>? 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<ExploreItem> 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<Map<String, Object?>> _normalizeExploreSectionsPayload(
dynamic rawSections,
) {
if (rawSections is! List) return const [];
final sections = <Map<String, Object?>>[];
for (final rawSection in rawSections) {
if (rawSection is! Map) continue;
final section = Map<Object?, Object?>.from(rawSection);
final rawItems = section['items'];
final items = <Map<String, Object?>>[];
if (rawItems is List) {
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
items.add(Map<String, Object?>.from(rawItem));
}
}
sections.add({
'uri': section['uri']?.toString() ?? '',
'title': section['title']?.toString() ?? '',
'items': items,
});
}
return sections;
}
List<Map<String, Object?>> _withDefaultExploreProviderId(
List<Map<String, Object?>> 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 <String, Object?>{
...section,
'items': rawItems
.map((rawItem) {
if (rawItem is! Map) return rawItem;
final item = Map<String, Object?>.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<String, Object?> _decodeExploreCache(String rawCache) {
final decoded = jsonDecode(rawCache);
if (decoded is! Map) {
return const {'provider_id': null, 'sections': <Map<String, Object?>>[]};
}
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<String, Object?> cachePayload) {
return jsonEncode(cachePayload);
}
List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
List<Map<String, Object?>> normalizedSections,
) {
return normalizedSections
.map(
(section) =>
ExploreSection.fromJson(Map<String, dynamic>.from(section)),
)
.toList(growable: false);
}
class ExploreNotifier extends Notifier<ExploreState> {
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<void> _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<Object?, Object?>>()
.map((section) => Map<String, Object?>.from(section))
.toList(growable: false)
: const <Map<String, Object?>>[];
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<void> _saveToCache(
List<Map<String, Object?>> 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<void> 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<dynamic>? ?? [];
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<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});