nkbmusic/lib/offline/download_store.dart
metatroncubeswdev 178d2d00ab Nanga Kathai: Flutter app, self-hosted backend and song tools
- 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>
2026-10-30 10:39:26 -04:00

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);