- 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>
233 lines
7.2 KiB
Dart
233 lines
7.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../core/models.dart';
|
|
import 'encrypted_audio_source.dart';
|
|
import 'song_cipher.dart';
|
|
|
|
/// What we keep about a downloaded song, so the Downloads tab works with no internet.
|
|
class DownloadEntry {
|
|
const DownloadEntry({
|
|
required this.trackId,
|
|
required this.title,
|
|
required this.credit,
|
|
required this.occasion,
|
|
required this.durationMs,
|
|
required this.bytes,
|
|
this.album,
|
|
});
|
|
|
|
final String trackId;
|
|
final String title;
|
|
final String credit;
|
|
final String? album;
|
|
final String occasion;
|
|
final int durationMs;
|
|
final int bytes;
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'id': trackId,
|
|
'title': title,
|
|
'credit': credit,
|
|
'album': album,
|
|
'occasion': occasion,
|
|
'durationMs': durationMs,
|
|
'bytes': bytes,
|
|
};
|
|
|
|
static DownloadEntry fromJson(Map<String, dynamic> j) => DownloadEntry(
|
|
trackId: j['id'] as String,
|
|
title: j['title'] as String,
|
|
credit: j['credit'] as String,
|
|
album: j['album'] as String?,
|
|
occasion: j['occasion'] as String,
|
|
durationMs: (j['durationMs'] as num).toInt(),
|
|
bytes: (j['bytes'] as num).toInt(),
|
|
);
|
|
}
|
|
|
|
class DownloadSnapshot {
|
|
const DownloadSnapshot(this.entries, this.progress);
|
|
|
|
/// Newest first.
|
|
final List<DownloadEntry> entries;
|
|
|
|
/// Track id -> 0..1 for songs downloading or waiting (0).
|
|
final Map<String, double> progress;
|
|
|
|
bool isDownloaded(String id) => entries.any((e) => e.trackId == id);
|
|
int get totalBytes => entries.fold(0, (sum, e) => sum + e.bytes);
|
|
}
|
|
|
|
/// Downloads songs into the app's private storage, encrypted with a key that
|
|
/// lives only in this phone's secure keystore. Other apps, file managers and
|
|
/// USB access can't read them, and a copied file is useless without the key.
|
|
class DownloadStore {
|
|
DownloadStore._(this._dir, this._cipher, this._entries);
|
|
|
|
static const _keyName = 'nk_download_key';
|
|
|
|
final Directory _dir;
|
|
final SongCipher _cipher;
|
|
final Map<String, DownloadEntry> _entries;
|
|
final Map<String, double> _progress = {};
|
|
final _queue = <(Track, DownloadEntry Function(int bytes))>[];
|
|
final _changes = StreamController<void>.broadcast();
|
|
bool _working = false;
|
|
|
|
Stream<void> get changes => _changes.stream;
|
|
|
|
static Future<DownloadStore> open() async {
|
|
const storage = FlutterSecureStorage();
|
|
var keyText = await storage.read(key: _keyName);
|
|
if (keyText == null) {
|
|
keyText = base64Encode(SongCipher.newKey());
|
|
await storage.write(key: _keyName, value: keyText);
|
|
}
|
|
final dir = Directory('${(await getApplicationSupportDirectory()).path}/downloads');
|
|
await dir.create(recursive: true);
|
|
final entries = <String, DownloadEntry>{};
|
|
final index = File('${dir.path}/index.json');
|
|
if (await index.exists()) {
|
|
try {
|
|
for (final j in jsonDecode(await index.readAsString()) as List) {
|
|
final e = DownloadEntry.fromJson(j as Map<String, dynamic>);
|
|
if (await File('${dir.path}/${e.trackId}.nks').exists()) entries[e.trackId] = e;
|
|
}
|
|
} catch (_) {
|
|
// A damaged index only loses the list; files are re-downloadable.
|
|
}
|
|
}
|
|
return DownloadStore._(dir, SongCipher(base64Decode(keyText)), entries);
|
|
}
|
|
|
|
DownloadSnapshot snapshot() =>
|
|
DownloadSnapshot(_entries.values.toList().reversed.toList(), Map.unmodifiable(_progress));
|
|
|
|
File _file(String id) => File('${_dir.path}/$id.nks');
|
|
|
|
/// An audio source for a downloaded song, or null to stream it.
|
|
AudioSource? sourceFor(String trackId, {Object? tag}) {
|
|
if (!_entries.containsKey(trackId)) return null;
|
|
return EncryptedFileAudioSource(_file(trackId), _cipher, tag: tag);
|
|
}
|
|
|
|
void enqueue(Track track, {required String credit, String? album}) {
|
|
if (_entries.containsKey(track.id) || _progress.containsKey(track.id)) return;
|
|
_progress[track.id] = 0;
|
|
_queue.add((
|
|
track,
|
|
(bytes) => DownloadEntry(
|
|
trackId: track.id,
|
|
title: track.titleLatin,
|
|
credit: credit,
|
|
album: album,
|
|
occasion: track.occasion.id,
|
|
durationMs: track.duration.inMilliseconds,
|
|
bytes: bytes,
|
|
),
|
|
));
|
|
_changes.add(null);
|
|
_pump();
|
|
}
|
|
|
|
Future<void> _pump() async {
|
|
if (_working) return;
|
|
_working = true;
|
|
while (_queue.isNotEmpty) {
|
|
final (track, entryFor) = _queue.removeAt(0);
|
|
try {
|
|
final bytes = await _download(track);
|
|
_entries[track.id] = entryFor(bytes);
|
|
await _saveIndex();
|
|
} catch (_) {
|
|
// Failed downloads simply disappear from the queue; the user can tap again.
|
|
}
|
|
_progress.remove(track.id);
|
|
_changes.add(null);
|
|
}
|
|
_working = false;
|
|
}
|
|
|
|
Future<int> _download(Track track) async {
|
|
final client = http.Client();
|
|
final part = File('${_dir.path}/${track.id}.part');
|
|
try {
|
|
final res = await client.send(http.Request('GET', Uri.parse(track.audioUrl)));
|
|
if (res.statusCode != 200) throw HttpException('HTTP ${res.statusCode}');
|
|
final total = res.contentLength ?? 0;
|
|
final nonce = SongCipher.newNonce();
|
|
final sink = part.openWrite()..add(nonce);
|
|
var offset = 0;
|
|
var lastReport = 0.0;
|
|
await for (final chunk in res.stream) {
|
|
final data = Uint8List.fromList(chunk);
|
|
_cipher.apply(data, offset, nonce);
|
|
sink.add(data);
|
|
offset += data.length;
|
|
if (total > 0) {
|
|
final p = offset / total;
|
|
if (p - lastReport > 0.02) {
|
|
lastReport = p;
|
|
_progress[track.id] = p;
|
|
_changes.add(null);
|
|
}
|
|
}
|
|
}
|
|
await sink.close();
|
|
await part.rename(_file(track.id).path);
|
|
return offset;
|
|
} catch (_) {
|
|
if (await part.exists()) await part.delete();
|
|
rethrow;
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
Future<void> remove(String trackId) async {
|
|
_entries.remove(trackId);
|
|
final f = _file(trackId);
|
|
if (await f.exists()) await f.delete();
|
|
await _saveIndex();
|
|
_changes.add(null);
|
|
}
|
|
|
|
Future<void> removeAll() async {
|
|
for (final id in _entries.keys.toList()) {
|
|
final f = _file(id);
|
|
if (await f.exists()) await f.delete();
|
|
}
|
|
_entries.clear();
|
|
await _saveIndex();
|
|
_changes.add(null);
|
|
}
|
|
|
|
Future<void> _saveIndex() => File('${_dir.path}/index.json')
|
|
.writeAsString(jsonEncode([for (final e in _entries.values) e.toJson()]));
|
|
}
|
|
|
|
final downloadStoreProvider = Provider<DownloadStore>(
|
|
(ref) => throw UnimplementedError('downloadStoreProvider must be overridden in main()'),
|
|
);
|
|
|
|
class Downloads extends Notifier<DownloadSnapshot> {
|
|
@override
|
|
DownloadSnapshot build() {
|
|
final store = ref.watch(downloadStoreProvider);
|
|
final sub = store.changes.listen((_) => state = store.snapshot());
|
|
ref.onDispose(sub.cancel);
|
|
return store.snapshot();
|
|
}
|
|
}
|
|
|
|
final downloadsProvider = NotifierProvider<Downloads, DownloadSnapshot>(Downloads.new);
|