mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-05-31 19:05:05 +07:00
492 lines
15 KiB
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();
|
|
});
|