- 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>
56 lines
1.6 KiB
Dart
56 lines
1.6 KiB
Dart
// StreamAudioSource is marked experimental in just_audio but is its supported way to play custom byte streams.
|
|
// ignore_for_file: experimental_member_use
|
|
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:just_audio/just_audio.dart';
|
|
|
|
import 'song_cipher.dart';
|
|
|
|
/// Plays a downloaded song straight from its encrypted file, decrypting only the
|
|
/// bytes the player asks for. The plain audio never touches the disk.
|
|
class EncryptedFileAudioSource extends StreamAudioSource {
|
|
EncryptedFileAudioSource(this.file, this.cipher, {this.contentType = 'audio/mpeg', super.tag});
|
|
|
|
final File file;
|
|
final SongCipher cipher;
|
|
final String contentType;
|
|
|
|
@override
|
|
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
|
final raf = await file.open();
|
|
final total = await raf.length() - SongCipher.headerLength;
|
|
final nonce = Uint8List(SongCipher.headerLength);
|
|
await raf.readInto(nonce);
|
|
start ??= 0;
|
|
end = (end == null || end > total) ? total : end;
|
|
|
|
Stream<List<int>> body() async* {
|
|
try {
|
|
const chunk = 64 * 1024;
|
|
var pos = start!;
|
|
await raf.setPosition(SongCipher.headerLength + pos);
|
|
while (pos < end!) {
|
|
final len = end - pos < chunk ? end - pos : chunk;
|
|
final data = await raf.read(len);
|
|
if (data.isEmpty) break;
|
|
cipher.apply(data, pos, nonce);
|
|
yield data;
|
|
pos += data.length;
|
|
}
|
|
} finally {
|
|
await raf.close();
|
|
}
|
|
}
|
|
|
|
return StreamAudioResponse(
|
|
sourceLength: total,
|
|
contentLength: end - start,
|
|
offset: start,
|
|
stream: body(),
|
|
contentType: contentType,
|
|
);
|
|
}
|
|
}
|