mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-25 09:14:17 +02:00
6388f3a5b8
- Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access) - Pre-compute download counts in queue provider to avoid repeated filtering - Add identical() caching for RecentAccessView in HomeTab - Use selective watching for exploreProvider (sections, greeting, isLoading only) - Move isYTMusicQuickPicks computation to ExploreSection.fromJson() - Hoist static RegExp patterns to avoid repeated compilation - Use batch operations for iOS path migration in history_database - Replace containsKey+lookup with single lookup in palette_service - Pre-compute lowercase strings outside filter loops in logger - Fix _isLoaded race condition in DownloadHistoryNotifier
266 lines
7.5 KiB
Dart
266 lines
7.5 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
|
|
final _log = AppLogger('ExploreProvider');
|
|
|
|
/// Represents an item in a Spotify home section
|
|
class ExploreItem {
|
|
final String id;
|
|
final String uri;
|
|
final String type; // track, album, playlist, artist, station
|
|
final String name;
|
|
final String artists;
|
|
final String? description;
|
|
final String? coverUrl;
|
|
final String? providerId;
|
|
final String? albumId;
|
|
final String? albumName;
|
|
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.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?,
|
|
durationMs: json['duration_ms'] as int? ?? 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Represents a section in Spotify home feed
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// State for explore/home feed
|
|
class ExploreState {
|
|
final bool isLoading;
|
|
final String? error;
|
|
final String? greeting;
|
|
final List<ExploreSection> sections;
|
|
final DateTime? lastFetched;
|
|
|
|
const ExploreState({
|
|
this.isLoading = false,
|
|
this.error,
|
|
this.greeting,
|
|
this.sections = const [],
|
|
this.lastFetched,
|
|
});
|
|
|
|
bool get hasContent => sections.isNotEmpty;
|
|
|
|
ExploreState copyWith({
|
|
bool? isLoading,
|
|
String? error,
|
|
String? greeting,
|
|
List<ExploreSection>? sections,
|
|
DateTime? lastFetched,
|
|
}) {
|
|
return ExploreState(
|
|
isLoading: isLoading ?? this.isLoading,
|
|
error: error,
|
|
greeting: greeting ?? this.greeting,
|
|
sections: sections ?? this.sections,
|
|
lastFetched: lastFetched ?? this.lastFetched,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Calculate greeting based on local device time
|
|
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;
|
|
}
|
|
|
|
/// Provider for explore/home feed state
|
|
class ExploreNotifier extends Notifier<ExploreState> {
|
|
@override
|
|
ExploreState build() {
|
|
return const ExploreState();
|
|
}
|
|
|
|
/// Fetch home feed from spotify-web extension
|
|
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
|
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
|
|
|
// Don't refetch if we have data and it's less than 5 minutes old
|
|
if (!forceRefresh &&
|
|
state.hasContent &&
|
|
state.lastFetched != null &&
|
|
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
|
_log.d('Using cached home feed');
|
|
return;
|
|
}
|
|
|
|
if (state.isLoading) {
|
|
_log.d('Home feed fetch already in progress');
|
|
return;
|
|
}
|
|
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
|
|
try {
|
|
// Find any extension with homeFeed capability
|
|
final extState = ref.read(extensionProvider);
|
|
_log.d('Extensions count: ${extState.extensions.length}');
|
|
|
|
// Look for extensions with homeFeed capability (prefer spotify-web)
|
|
Extension? targetExt;
|
|
for (final extension in extState.extensions) {
|
|
if (!extension.enabled || !extension.hasHomeFeed) {
|
|
continue;
|
|
}
|
|
if (targetExt == null || extension.id == 'spotify-web') {
|
|
targetExt = extension;
|
|
if (extension.id == 'spotify-web') {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (targetExt == null) {
|
|
_log.w('No extension with homeFeed capability found');
|
|
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);
|
|
|
|
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 sections = sectionsData
|
|
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
_log.i('Fetched ${sections.length} sections');
|
|
|
|
// Debug: log first section items
|
|
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}');
|
|
}
|
|
|
|
// Always use local device time for greeting to avoid timezone issues
|
|
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
|
|
final localGreeting = _getLocalGreeting();
|
|
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
|
|
|
state = ExploreState(
|
|
isLoading: false,
|
|
greeting: localGreeting,
|
|
sections: sections,
|
|
lastFetched: DateTime.now(),
|
|
);
|
|
} catch (e, stack) {
|
|
_log.e('Error fetching home feed: $e', e, stack);
|
|
state = state.copyWith(
|
|
isLoading: false,
|
|
error: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Clear cached data
|
|
void clear() {
|
|
state = const ExploreState();
|
|
}
|
|
|
|
/// Refresh home feed
|
|
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
|
}
|
|
|
|
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
|
return ExploreNotifier();
|
|
});
|