- Flutter app (Android/iOS): home, explore, albums, search, player with equalizer, mini player, sign up/sign in with email codes, profile, encrypted offline downloads, About page - server/: one-command deploy of self-hosted Supabase behind HestiaCP/nginx (or Caddy), migrations, backups, email templates - supabase/migrations: schema, row-level security, profiles - tools/: song scanner and importer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
3.8 KiB
Dart
118 lines
3.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
/// Life events Badaga songs belong to. `id` matches the `occasion` column in Supabase.
|
|
enum Occasion {
|
|
huttu('huttu', 'Huttu', 'Birth & naming', Color(0xFF3B6E8F), Icons.spa_outlined),
|
|
habba('habba', 'Habba', 'Festival celebrations', Color(0xFFB5541E), Icons.celebration_outlined),
|
|
madhuvae('madhuvae', 'Madhuvae', 'Wedding songs', Color(0xFF9C2F4A), Icons.favorite_border),
|
|
devotional('devotional', 'Devotional', 'Hethai & temple songs', Color(0xFF6B4FA0), Icons.temple_hindu_outlined),
|
|
folk('folk', 'Folk songs', 'Traditional melodies', Color(0xFF2E7D5B), Icons.music_note_outlined),
|
|
saavu('saavu', 'Saavu', 'Remembrance', Color(0xFF4A4F57), Icons.local_florist_outlined),
|
|
instrumental('instrumental', 'Instrumental', 'Flute, drums and more', Color(0xFF8A5A2B), Icons.graphic_eq);
|
|
|
|
const Occasion(this.id, this.label, this.subtitle, this.color, this.icon);
|
|
|
|
final String id;
|
|
final String label;
|
|
final String subtitle;
|
|
final Color color;
|
|
final IconData icon;
|
|
|
|
static Occasion fromId(String? id) =>
|
|
Occasion.values.firstWhere((o) => o.id == id, orElse: () => Occasion.folk);
|
|
}
|
|
|
|
enum Tier { free, premium }
|
|
|
|
class Artist {
|
|
const Artist({required this.id, required this.name, this.hatti, this.photoUrl, this.bio});
|
|
|
|
final String id;
|
|
final String name;
|
|
final String? hatti;
|
|
|
|
/// Only set once the singer (or family) has given photo permission.
|
|
final String? photoUrl;
|
|
final String? bio;
|
|
|
|
String get initials {
|
|
final parts = name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
|
|
if (parts.isEmpty) return '?';
|
|
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
|
|
return (parts.first[0] + parts.last[0]).toUpperCase();
|
|
}
|
|
|
|
factory Artist.fromJson(Map<String, dynamic> j) => Artist(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String,
|
|
hatti: j['hatti'] as String?,
|
|
photoUrl: j['photoUrl'] as String?,
|
|
bio: j['bio'] as String?,
|
|
);
|
|
}
|
|
|
|
/// A cassette, CD or folder from the collection.
|
|
class Album {
|
|
const Album({required this.id, required this.title, this.year, this.coverUrl});
|
|
|
|
final String id;
|
|
final String title;
|
|
final int? year;
|
|
final String? coverUrl;
|
|
}
|
|
|
|
class Track {
|
|
const Track({
|
|
required this.id,
|
|
required this.titleLatin,
|
|
required this.artistIds,
|
|
required this.occasion,
|
|
required this.duration,
|
|
required this.audioUrl,
|
|
this.titleTamil,
|
|
this.meaning,
|
|
this.albumId,
|
|
this.coverAsset,
|
|
this.coverUrl,
|
|
this.tier = Tier.free,
|
|
});
|
|
|
|
final String id;
|
|
final String titleLatin;
|
|
final String? titleTamil;
|
|
|
|
/// English meaning of the title, also searchable.
|
|
final String? meaning;
|
|
final List<String> artistIds;
|
|
final String? albumId;
|
|
final Occasion occasion;
|
|
final Duration duration;
|
|
|
|
/// `asset:///...` for bundled audio, otherwise an https URL (signed R2 URL later).
|
|
final String audioUrl;
|
|
final String? coverAsset;
|
|
final String? coverUrl;
|
|
final Tier tier;
|
|
|
|
factory Track.fromJson(Map<String, dynamic> j) => Track(
|
|
id: j['id'] as String,
|
|
titleLatin: j['titleLatin'] as String,
|
|
titleTamil: j['titleTamil'] as String?,
|
|
meaning: j['meaning'] as String?,
|
|
artistIds: (j['artistIds'] as List).cast<String>(),
|
|
albumId: j['albumId'] as String?,
|
|
occasion: Occasion.fromId(j['occasion'] as String?),
|
|
duration: Duration(milliseconds: (j['durationMs'] as num?)?.toInt() ?? 0),
|
|
audioUrl: j['audioUrl'] as String,
|
|
coverAsset: j['coverAsset'] as String?,
|
|
coverUrl: j['coverUrl'] as String?,
|
|
tier: j['tier'] == 'premium' ? Tier.premium : Tier.free,
|
|
);
|
|
}
|
|
|
|
String formatDuration(Duration d) {
|
|
final m = d.inMinutes;
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return '$m:$s';
|
|
}
|