Songs without their own cover use their album's. Covers live in a public 'covers' bucket that only admins can write to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
6.3 KiB
Dart
173 lines
6.3 KiB
Dart
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<Track> tracks, required List<Artist> artists, List<Album> 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<Track> tracks;
|
|
final Map<String, Artist> artistsById;
|
|
final Map<String, Album> albumsById;
|
|
|
|
/// Albums with songs, biggest first.
|
|
late final List<Album> 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<String, List<Track>> _byAlbum = () {
|
|
final map = <String, List<Track>>{};
|
|
// 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<Track> byAlbum(String albumId) => _byAlbum[albumId] ?? const [];
|
|
|
|
Album? albumOf(Track t) => t.albumId == null ? null : albumsById[t.albumId];
|
|
|
|
List<Artist> get artists => artistsById.values.toList();
|
|
|
|
List<Artist> artistsOf(Track t) =>
|
|
t.artistIds.map((id) => artistsById[id]).whereType<Artist>().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<Track> byOccasion(Occasion o) => tracks.where((t) => t.occasion == o).toList();
|
|
|
|
List<Track> 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<Track> searchTracks(String query) => tracks
|
|
.where((t) => matchesSearch(query, [
|
|
t.titleLatin,
|
|
t.titleTamil,
|
|
t.meaning,
|
|
t.occasion.label,
|
|
artistLine(t),
|
|
albumOf(t)?.title,
|
|
]))
|
|
.toList();
|
|
|
|
List<Album> searchAlbums(String query) =>
|
|
albums.where((a) => matchesSearch(query, [a.title])).toList();
|
|
|
|
List<Artist> searchArtists(String query) =>
|
|
artists.where((a) => matchesSearch(query, [a.name, a.hatti])).toList();
|
|
}
|
|
|
|
abstract interface class CatalogRepository {
|
|
Future<Catalog> 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<Catalog> load() async => parseCatalogJson(await rootBundle.loadString(path));
|
|
}
|
|
|
|
Catalog parseCatalogJson(String raw) {
|
|
final json = jsonDecode(raw) as Map<String, dynamic>;
|
|
return Catalog(
|
|
tracks: (json['tracks'] as List).map((e) => Track.fromJson(e as Map<String, dynamic>)).toList(),
|
|
artists: (json['artists'] as List).map((e) => Artist.fromJson(e as Map<String, dynamic>)).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<Catalog> 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?,
|
|
),
|
|
];
|
|
// Songs without their own cover show their album's.
|
|
final albumCovers = {for (final r in albumRows) r['id'] as String: r['cover_url'] 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? ?? albumCovers[r['album_id']],
|
|
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<CatalogRepository>((ref) {
|
|
if (Env.hasSupabase) return SupabaseCatalogRepository(Supabase.instance.client);
|
|
return const AssetCatalogRepository();
|
|
});
|
|
|
|
final catalogProvider = FutureProvider<Catalog>((ref) => ref.watch(catalogRepositoryProvider).load());
|