diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3c1c3243..571bbb74 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7039,6 +7039,13 @@ "relatedLibraryDescription": "Songs you already have with related artist, album or genre tags. No online music is searched.", "relatedLibraryEmpty": "No related songs found in your Library.", "relatedLibraryLoadError": "Could not find related songs. Try again.", + "listeningStatsDays": "{days} days", + "@listeningStatsDays": { "placeholders": { "days": { "type": "int" } } }, + "weeklyReleases": "New releases", + "weeklyReleasesDescription": "Recent albums and singles from your favorite artists, supplied by installed metadata extensions.", + "weeklyReleasesEmpty": "No dated releases found in this period. Add favorite artists or try a longer period.", + "weeklyReleasesUnavailable": "Some artists could not be checked. Their saved results are kept. Providers may not supply artist catalogs or exact release dates.", + "weeklyReleasesLoadError": "Could not check new releases. Pull to refresh.", "motionArtworkDescription": "Loop video covers supplied by metadata providers. Turn off to use still artwork and reduce battery use.", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index d6781a44..a77101a5 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6958,5 +6958,11 @@ "relatedLibraryDescription": "Lagu yang sudah Anda miliki dengan tag artis, album, atau genre terkait. Tidak mencari musik online.", "relatedLibraryEmpty": "Tidak ada lagu terkait di Library Anda.", "relatedLibraryLoadError": "Tidak dapat mencari lagu terkait. Coba lagi.", + "listeningStatsDays": "{days} hari", + "weeklyReleases": "Rilis baru", + "weeklyReleasesDescription": "Album dan single terbaru dari artis favorit, melalui extension metadata yang terpasang.", + "weeklyReleasesEmpty": "Tidak ditemukan rilis dengan tanggal dalam periode ini. Tambahkan artis favorit atau coba periode lebih panjang.", + "weeklyReleasesUnavailable": "Beberapa artis tidak dapat diperiksa. Hasil tersimpan tetap dipertahankan. Penyedia mungkin tidak memberikan katalog artis atau tanggal rilis lengkap.", + "weeklyReleasesLoadError": "Rilis baru tidak dapat diperiksa. Tarik untuk memuat ulang.", "motionArtworkDescription": "Putar berulang cover video dari penyedia metadata. Matikan untuk memakai cover diam dan menghemat baterai." } diff --git a/lib/providers/weekly_releases_provider.dart b/lib/providers/weekly_releases_provider.dart new file mode 100644 index 00000000..9a625ff2 --- /dev/null +++ b/lib/providers/weekly_releases_provider.dart @@ -0,0 +1,81 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/weekly_releases.dart'; +import 'package:spotiflac_android/utils/provider_resource_ids.dart'; + +final weeklyReleasesProvider = FutureProvider.autoDispose + .family((ref, refresh) async { + final collections = ref.watch(libraryCollectionsProvider); + final extensions = ref.watch(extensionProvider); + final preferred = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); + if (!collections.isLoaded || !extensions.isInitialized) { + return const WeeklyReleaseFeed([]); + } + final providers = extensions.extensions + .where( + (extension) => extension.enabled && extension.hasMetadataProvider, + ) + .toList(growable: false); + final service = WeeklyReleaseService( + preferences: SharedPreferences.getInstance(), + loadArtist: (artist) async { + final directProvider = artist.providerId; + if (directProvider != null && + providers.any((p) => p.id == directProvider)) { + return ( + providerId: directProvider, + metadata: await PlatformBridge.getProviderMetadata( + directProvider, + 'artist', + stripPrefixedResourceId(artist.artistId), + ), + ); + } + // Older favorites may not have a provider ID. Resolve them by an + // exact artist name in a search-capable metadata extension. + final candidates = providers.where((p) => p.hasCustomSearch).toList() + ..sort( + (a, b) => a.id == preferred + ? -1 + : b.id == preferred + ? 1 + : 0, + ); + for (final provider in candidates) { + try { + final matches = await PlatformBridge.customSearchWithExtension( + provider.id, + artist.name, + options: {'filter': 'artist', 'limit': 10}, + ); + for (final match in matches) { + if (match['name']?.toString().trim().toLowerCase() != + artist.name.trim().toLowerCase()) { + continue; + } + final id = match['id']?.toString() ?? ''; + if (id.isEmpty) continue; + return ( + providerId: provider.id, + metadata: await PlatformBridge.getProviderMetadata( + provider.id, + 'artist', + stripPrefixedResourceId(id), + ), + ); + } + } catch (_) { + // Try the next installed metadata provider. + } + } + throw StateError('No installed provider can resolve this artist'); + }, + ); + return service.load(collections.favoriteArtists, refresh: refresh); + }); diff --git a/lib/screens/favorite_artists_screen.dart b/lib/screens/favorite_artists_screen.dart index a655b3e1..ec5366e4 100644 --- a/lib/screens/favorite_artists_screen.dart +++ b/lib/screens/favorite_artists_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/weekly_releases_screen.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; @@ -25,6 +26,17 @@ class FavoriteArtistsScreen extends ConsumerWidget { body: CustomScrollView( slivers: [ AppSliverHeader.page(title: context.l10n.collectionFavoriteArtists), + SliverToBoxAdapter( + child: ListTile( + leading: const Icon(Icons.new_releases_outlined), + title: Text(context.l10n.weeklyReleases), + subtitle: Text(context.l10n.weeklyReleasesDescription), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of( + context, + ).push(slidePageRoute(page: const WeeklyReleasesScreen())), + ), + ), if (artists.isEmpty) SliverFillRemaining( hasScrollBody: false, diff --git a/lib/screens/weekly_releases_screen.dart b/lib/screens/weekly_releases_screen.dart new file mode 100644 index 00000000..373a5d49 --- /dev/null +++ b/lib/screens/weekly_releases_screen.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/weekly_releases_provider.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; + +class WeeklyReleasesScreen extends ConsumerStatefulWidget { + const WeeklyReleasesScreen({super.key}); + + @override + ConsumerState createState() => + _WeeklyReleasesScreenState(); +} + +class _WeeklyReleasesScreenState extends ConsumerState { + int _days = 7; + bool _refresh = false; + + Future _reload() async { + if (_refresh) return; + setState(() => _refresh = true); + try { + await ref.refresh(weeklyReleasesProvider(true).future).then((_) {}); + if (mounted) ref.invalidate(weeklyReleasesProvider(false)); + } catch (_) { + // The provider displays the failure without leaving refresh latched on. + } finally { + if (mounted) setState(() => _refresh = false); + } + } + + @override + Widget build(BuildContext context) { + final feed = ref.watch(weeklyReleasesProvider(_refresh)); + return Scaffold( + body: RefreshIndicator( + onRefresh: _reload, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + AppSliverHeader.page(title: context.l10n.weeklyReleases), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.weeklyReleasesDescription), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + for (final days in [7, 30]) + AppChoiceChip( + label: Text(context.l10n.listeningStatsDays(days)), + selected: _days == days, + onSelected: (_) => setState(() => _days = days), + ), + ], + ), + ], + ), + ), + ), + ...feed.when( + loading: () => [ + const SliverToBoxAdapter( + child: Center(child: AppLoadingIndicator()), + ), + ], + error: (_, _) => [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(context.l10n.weeklyReleasesLoadError), + ), + ), + ], + data: (data) { + final releases = data.inPeriod(DateTime.now(), _days); + return [ + if (data.unavailableArtists.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + '${context.l10n.weeklyReleasesUnavailable}\n' + '${data.unavailableArtists.join(', ')}', + ), + ), + ), + if (releases.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(context.l10n.weeklyReleasesEmpty), + ), + ), + SliverList.list( + children: [ + for (final release in releases) + ListTile( + leading: release.coverUrl == null + ? const SizedBox( + width: 56, + height: 56, + child: Icon(Icons.album_outlined), + ) + : CachedCoverImage( + imageUrl: release.coverUrl!, + width: 56, + height: 56, + borderRadius: BorderRadius.circular(8), + ), + title: Text(release.name), + subtitle: Text( + '${release.artist}\n${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(release.date)}', + ), + onTap: () => navigateToAlbum( + context, + albumName: release.name, + albumId: release.id, + artistName: release.artist, + coverUrl: release.coverUrl, + extensionId: release.providerId, + ), + ), + ], + ), + ]; + }, + ), + SliverToBoxAdapter( + child: SizedBox(height: context.navBarBottomInset), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/weekly_releases.dart b/lib/services/weekly_releases.dart new file mode 100644 index 00000000..df7d8828 --- /dev/null +++ b/lib/services/weekly_releases.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +class WeeklyRelease { + const WeeklyRelease({ + required this.id, + required this.name, + required this.artist, + required this.providerId, + required this.date, + this.coverUrl, + }); + + final String id; + final String name; + final String artist; + final String providerId; + final DateTime date; + final String? coverUrl; + + Map toJson() => { + 'id': id, + 'name': name, + 'artists': artist, + 'provider_id': providerId, + 'release_date': date.toIso8601String(), + 'cover_url': coverUrl, + }; + + static WeeklyRelease? fromMetadata( + Map metadata, { + required String providerId, + required String artist, + }) { + final rawDate = metadata['release_date']?.toString() ?? ''; + // Year/month-only dates are not precise enough for a weekly feed. + if (!RegExp(r'^\d{4}-\d{2}-\d{2}(?:$|T)').hasMatch(rawDate)) return null; + final parsed = DateTime.tryParse(rawDate); + if (parsed == null) return null; + final date = DateTime(parsed.year, parsed.month, parsed.day); + if (rawDate.substring(0, 10) != date.toIso8601String().substring(0, 10)) { + return null; + } + final id = metadata['id']?.toString().trim() ?? ''; + final name = metadata['name']?.toString().trim() ?? ''; + if (id.isEmpty || name.isEmpty) return null; + return WeeklyRelease( + id: id, + name: name, + artist: metadata['artists']?.toString().trim().isNotEmpty == true + ? metadata['artists'].toString() + : artist, + providerId: metadata['provider_id']?.toString() ?? providerId, + date: date, + coverUrl: normalizeCoverReference( + (metadata['cover_url'] ?? metadata['images'])?.toString(), + ), + ); + } +} + +class WeeklyReleaseFeed { + const WeeklyReleaseFeed(this.releases, {this.unavailableArtists = const []}); + + final List releases; + final List unavailableArtists; + + List inPeriod(DateTime now, int days) { + final tomorrow = DateTime(now.year, now.month, now.day + 1); + final start = DateTime(now.year, now.month, now.day - days + 1); + return releases + .where( + (release) => + !release.date.isBefore(start) && release.date.isBefore(tomorrow), + ) + .toList(growable: false); + } +} + +typedef ArtistReleaseLoader = + Future<({String providerId, Map metadata})> Function( + CollectionArtistEntry artist, + ); + +/// No provider code is embedded in the app. The existing artist metadata +/// contract supplies the catalog; failures keep the last successful catalog. +class WeeklyReleaseService { + WeeklyReleaseService({required this.loadArtist, required this.preferences}); + + final ArtistReleaseLoader loadArtist; + final Future preferences; + + Future load( + List artists, { + bool refresh = false, + DateTime? now, + }) async { + final date = now ?? DateTime.now(); + final prefs = await preferences; + final releases = {}; + final unavailable = []; + var next = 0; + Future worker() async { + while (next < artists.length) { + final artist = artists[next++]; + final key = 'weekly_releases_v1:${artist.key}'; + var cached = []; + DateTime? checkedAt; + try { + final raw = prefs.getString(key); + if (raw != null) { + final json = jsonDecode(raw) as Map; + checkedAt = DateTime.tryParse(json['checked_at'] as String? ?? ''); + cached = _parseCatalog( + json, + artist.name, + json['provider_id']?.toString() ?? '', + ); + } + } catch (_) { + // An invalid cache is rebuilt; it must not break other artists. + } + var catalog = cached; + final age = checkedAt == null ? null : date.difference(checkedAt); + if (refresh || age == null || age.isNegative || age.inHours >= 24) { + try { + final result = await loadArtist( + artist, + ).timeout(const Duration(seconds: 25)); + final metadata = result.metadata; + // Missing catalogs mean unsupported, rather than an empty result + // that silently replaces a previously successful cache. + if (metadata['albums'] is! List && metadata['releases'] is! List) { + throw StateError('Artist catalog is unavailable'); + } + catalog = _parseCatalog(metadata, artist.name, result.providerId); + await prefs.setString( + key, + jsonEncode({ + 'checked_at': date.toIso8601String(), + 'provider_id': result.providerId, + 'albums': catalog.map((release) => release.toJson()).toList(), + }), + ); + } catch (_) { + unavailable.add(artist.name); + } + } + for (final release in catalog) { + releases['${release.providerId}:${release.id}'] = release; + } + } + } + + // Keep large favorite lists from issuing a burst of extension requests. + await Future.wait([worker(), worker()]); + final sorted = releases.values.toList() + ..sort((a, b) => b.date.compareTo(a.date)); + return WeeklyReleaseFeed(sorted, unavailableArtists: unavailable); + } + + List _parseCatalog( + Map data, + String artist, + String providerId, + ) { + final releases = {}; + for (final field in ['albums', 'releases']) { + final raw = data[field]; + if (raw is! List) continue; + for (final value in raw.take(300)) { + if (value is! Map) continue; + final release = WeeklyRelease.fromMetadata( + Map.from(value), + providerId: providerId, + artist: artist, + ); + if (release != null) { + releases['${release.providerId}:${release.id}'] = release; + } + } + } + return releases.values.toList(growable: false); + } +} diff --git a/test/weekly_releases_test.dart b/test/weekly_releases_test.dart new file mode 100644 index 00000000..860bf43b --- /dev/null +++ b/test/weekly_releases_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/weekly_releases.dart'; + +CollectionArtistEntry _artist(String id) => CollectionArtistEntry( + key: 'provider-a:$id', + artistId: id, + providerId: 'provider-a', + name: id, + addedAt: DateTime(2026), +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('weekly feed rejects incomplete/invalid dates and future releases', () { + WeeklyRelease? parse(String date) => WeeklyRelease.fromMetadata( + {'id': date, 'name': 'Release', 'release_date': date}, + providerId: 'provider-a', + artist: 'Artist', + ); + expect(parse('2026'), isNull); + expect(parse('2026-10'), isNull); + expect(parse('2026-02-31'), isNull); + final feed = WeeklyReleaseFeed([ + parse('2026-09-26')!, + parse('2026-10-02')!, + parse('2026-09-25')!, + parse('2026-10-03')!, + ]); + expect(feed.inPeriod(DateTime(2026, 10, 2), 7).length, 2); + }); + + test( + 'catalog is deduplicated, cached daily and retained on refresh failure', + () async { + var calls = 0; + var fail = false; + final service = WeeklyReleaseService( + preferences: SharedPreferences.getInstance(), + loadArtist: (artist) async { + calls++; + if (fail) throw StateError('provider unavailable'); + final album = { + 'id': 'release', + 'name': 'Release', + 'release_date': '2026-10-02', + }; + return ( + providerId: 'provider-a', + metadata: { + 'albums': [album], + 'releases': [album], + }, + ); + }, + ); + final artists = [_artist('Artist')]; + final date = DateTime(2026, 10, 2); + expect((await service.load(artists, now: date)).releases.length, 1); + expect((await service.load(artists, now: date)).releases.length, 1); + expect(calls, 1); + fail = true; + final offline = await service.load(artists, now: date, refresh: true); + expect(offline.releases.length, 1); + expect(offline.unavailableArtists, ['Artist']); + expect(calls, 2); + }, + ); + + test('unsupported artist catalogs do not erase saved releases', () async { + var unsupported = false; + final service = WeeklyReleaseService( + preferences: SharedPreferences.getInstance(), + loadArtist: (_) async => ( + providerId: 'provider-a', + metadata: { + if (!unsupported) + 'albums': [ + { + 'id': 'release', + 'name': 'Release', + 'release_date': '2026-10-02', + }, + ], + }, + ), + ); + await service.load([_artist('Artist')]); + unsupported = true; + final result = await service.load([_artist('Artist')], refresh: true); + expect(result.releases.length, 1); + expect(result.unavailableArtists, ['Artist']); + }); + + test('limits concurrent requests for a large favorites list', () async { + var running = 0; + var peak = 0; + final service = WeeklyReleaseService( + preferences: SharedPreferences.getInstance(), + loadArtist: (_) async { + running++; + if (running > peak) peak = running; + await Future.delayed(Duration.zero); + running--; + return ( + providerId: 'provider-a', + metadata: {'albums': []}, + ); + }, + ); + await service.load([for (var i = 0; i < 10; i++) _artist('artist-$i')]); + expect(peak, 2); + }); +}