import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../core/env.dart'; import '../core/models.dart'; import '../core/search_normalize.dart'; class Catalog { Catalog({required List tracks, required List artists, List albums = const []}) : tracks = List.unmodifiable(tracks), artistsById = {for (final a in artists) a.id: a}, albumsById = {for (final a in albums) a.id: a}; /// Newest first. final List tracks; final Map artistsById; final Map albumsById; /// Albums with songs, biggest first. late final List albums = albumsById.values.where((a) => byAlbum(a.id).isNotEmpty).toList() ..sort((a, b) => byAlbum(b.id).length.compareTo(byAlbum(a.id).length)); late final Map> _byAlbum = () { final map = >{}; // Oldest first, so an album plays in the order it was imported (its track order). for (final t in tracks.reversed) { if (t.albumId != null) map.putIfAbsent(t.albumId!, () => []).add(t); } return map; }(); List byAlbum(String albumId) => _byAlbum[albumId] ?? const []; Album? albumOf(Track t) => t.albumId == null ? null : albumsById[t.albumId]; List get artists => artistsById.values.toList(); List artistsOf(Track t) => t.artistIds.map((id) => artistsById[id]).whereType().toList(); String artistLine(Track t) => artistsOf(t).map((a) => a.name).join(', '); /// For display: many legacy songs have no singer credited yet. String creditLine(Track t) { final line = artistLine(t); return line.isEmpty ? 'Singer not credited yet' : line; } List byOccasion(Occasion o) => tracks.where((t) => t.occasion == o).toList(); List byArtist(String artistId) => tracks.where((t) => t.artistIds.contains(artistId)).toList(); Track? trackById(String id) { for (final t in tracks) { if (t.id == id) return t; } return null; } List searchTracks(String query) => tracks .where((t) => matchesSearch(query, [ t.titleLatin, t.titleTamil, t.meaning, t.occasion.label, artistLine(t), albumOf(t)?.title, ])) .toList(); List searchAlbums(String query) => albums.where((a) => matchesSearch(query, [a.title])).toList(); List searchArtists(String query) => artists.where((a) => matchesSearch(query, [a.name, a.hatti])).toList(); } abstract interface class CatalogRepository { Future load(); } /// Reads the bundled demo catalog. Used until Supabase is configured. class AssetCatalogRepository implements CatalogRepository { const AssetCatalogRepository([this.path = 'assets/catalog/demo_catalog.json']); final String path; @override Future load() async => parseCatalogJson(await rootBundle.loadString(path)); } Catalog parseCatalogJson(String raw) { final json = jsonDecode(raw) as Map; return Catalog( tracks: (json['tracks'] as List).map((e) => Track.fromJson(e as Map)).toList(), artists: (json['artists'] as List).map((e) => Artist.fromJson(e as Map)).toList(), ); } /// Reads approved songs from Supabase (schema: supabase/migrations/0001_init.sql). /// Row-level security already hides anything that is not approved. class SupabaseCatalogRepository implements CatalogRepository { SupabaseCatalogRepository(this.client); final SupabaseClient client; @override Future load() async { final artistRows = await client.from('artists').select('id, name_latin, hatti:hattis(name_latin), photo_url, bio'); final albumRows = await client.from('albums').select('id, title_latin, year, cover_url'); final trackRows = await client .from('tracks') .select('id, title_latin, title_tamil, title_en_meaning, album_id, occasion, duration_ms, ' 'audio_key_128, cover_url, tier, track_artists(artist_id, position, role)') // Admins may read unapproved songs too; the app only ever lists approved ones. .eq('status', 'approved') .order('created_at', ascending: false); final artists = [ for (final r in artistRows) Artist( id: r['id'] as String, name: r['name_latin'] as String, hatti: (r['hatti'] as Map?)?['name_latin'] as String?, photoUrl: r['photo_url'] as String?, bio: r['bio'] as String?, ), ]; final tracks = [ for (final r in trackRows) Track( id: r['id'] as String, titleLatin: r['title_latin'] as String, titleTamil: r['title_tamil'] as String?, meaning: r['title_en_meaning'] as String?, artistIds: ([...(r['track_artists'] as List).where((ta) => ta['role'] == 'singer')] ..sort((a, b) => (a['position'] as int).compareTo(b['position'] as int))) .map((ta) => ta['artist_id'] as String) .toList(), albumId: r['album_id'] as String?, occasion: Occasion.fromId(r['occasion'] as String?), duration: Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0), audioUrl: '${Env.mediaBaseUrl}/${r['audio_key_128']}', coverUrl: r['cover_url'] as String?, tier: r['tier'] == 'premium' ? Tier.premium : Tier.free, ), ]; final albums = [ for (final r in albumRows) Album( id: r['id'] as String, title: r['title_latin'] as String, year: (r['year'] as num?)?.toInt(), coverUrl: r['cover_url'] as String?, ), ]; return Catalog(tracks: tracks, artists: artists, albums: albums); } } final catalogRepositoryProvider = Provider((ref) { if (Env.hasSupabase) return SupabaseCatalogRepository(Supabase.instance.client); return const AssetCatalogRepository(); }); final catalogProvider = FutureProvider((ref) => ref.watch(catalogRepositoryProvider).load());