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 toJson() => { 'id': trackId, 'title': title, 'credit': credit, 'album': album, 'occasion': occasion, 'durationMs': durationMs, 'bytes': bytes, }; static DownloadEntry fromJson(Map 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 entries; /// Track id -> 0..1 for songs downloading or waiting (0). final Map 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 _entries; final Map _progress = {}; final _queue = <(Track, DownloadEntry Function(int bytes))>[]; final _changes = StreamController.broadcast(); bool _working = false; Stream get changes => _changes.stream; static Future 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 = {}; 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); 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 _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 _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 remove(String trackId) async { _entries.remove(trackId); final f = _file(trackId); if (await f.exists()) await f.delete(); await _saveIndex(); _changes.add(null); } Future 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 _saveIndex() => File('${_dir.path}/index.json') .writeAsString(jsonEncode([for (final e in _entries.values) e.toJson()])); } final downloadStoreProvider = Provider( (ref) => throw UnimplementedError('downloadStoreProvider must be overridden in main()'), ); class Downloads extends Notifier { @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.new);