feat(network): enable audio and hi-res analysis for network tracks

This commit is contained in:
zarzet committed 2026-10-11 14:34:04 +07:00
1 parent 49aeedbdcf
commit e22e934525
8 files changed
+705 -8

No files matched your search

@@ -0,0 +1,229 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/hires_check_service.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/hires_check_card.dart';
Uint8List _hiResWav() {
const sampleRate = 96000;
const channels = 2;
const seconds = 5;
const frameBytes = channels * 3;
final bytes = ByteData(44 + sampleRate * seconds * frameBytes);
void text(int offset, String value) {
for (var i = 0; i < value.length; i++) {
bytes.setUint8(offset + i, value.codeUnitAt(i));
}
}
text(0, 'RIFF');
bytes.setUint32(4, bytes.lengthInBytes - 8, Endian.little);
text(8, 'WAVEfmt ');
bytes.setUint32(16, 16, Endian.little);
bytes.setUint16(20, 1, Endian.little);
bytes.setUint16(22, channels, Endian.little);
bytes.setUint32(24, sampleRate, Endian.little);
bytes.setUint32(28, sampleRate * frameBytes, Endian.little);
bytes.setUint16(32, frameBytes, Endian.little);
bytes.setUint16(34, 24, Endian.little);
text(36, 'data');
bytes.setUint32(40, bytes.lengthInBytes - 44, Endian.little);
for (var frame = 0; frame < sampleRate * seconds; frame++) {
for (var channel = 0; channel < channels; channel++) {
final value =
(3000000 *
math.sin(
2 * math.pi * (1000 + 23000 * channel) * frame / sampleRate,
))
.round();
final offset = 44 + frame * frameBytes + channel * 3;
bytes.setUint8(offset, value & 255);
bytes.setUint8(offset + 1, (value >> 8) & 255);
bytes.setUint8(offset + 2, (value >> 16) & 255);
}
}
return bytes.buffer.asUint8List();
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const smbAddress = String.fromEnvironment('SMB_TEST_URL');
testWidgets('network cards run real FFmpeg and Rust analysers', (
tester,
) async {
final local = await Directory.systemTemp.createTemp('network-reference-');
var file = File('${local.path}/reference.wav');
await file.writeAsBytes(_hiResWav());
if (smbAddress.isEmpty) {
final flac = File('${local.path}/reference.flac');
final session = await FFmpegKit.executeWithArguments([
'-i',
file.path,
'-c:a',
'flac',
flac.path,
]);
expect(ReturnCode.isSuccess(await session.getReturnCode()), true);
file = flac;
}
final audio = await file.readAsBytes();
final reference = await HiResCheckService.check(file.path);
final storage = NetworkStorageService.instance;
final id = 'analysis-fixture-${DateTime.now().microsecondsSinceEpoch}';
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
var fullReads = 0;
final requests = <({String method, String? authorization})>[];
server.listen((request) async {
requests.add((
method: request.method,
authorization: request.headers.value(HttpHeaders.authorizationHeader),
));
request.response.contentLength = audio.length;
if (request.method == 'GET') {
fullReads++;
request.response.add(audio);
}
await request.response.close();
});
try {
await storage.save(
NetworkConnection(
id: id,
name: 'Analysis fixture',
protocol: smbAddress.isEmpty
? NetworkProtocol.webdav
: NetworkProtocol.smb,
address: smbAddress.isEmpty
? 'http://127.0.0.1:${server.port}/'
: smbAddress,
username: 'test',
password: 'secret',
),
);
final source = NetworkStorageService.source(
id,
smbAddress.isEmpty ? 'Rock & Roll.flac' : 'A B.wav',
);
final remote = await HiResCheckService.check(source);
expect(remote.supported, true);
expect(remote.declaredSampleRate, reference.declaredSampleRate);
expect(remote.declaredBitDepth, reference.declaredBitDepth);
expect(remote.effectiveBitDepth, reference.effectiveBitDepth);
expect(remote.cutoffFrequencyHz, reference.cutoffFrequencyHz);
expect(remote.verdict, reference.verdict);
fullReads = 0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark(useMaterial3: true),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
HiResCheckCard(filePath: source),
AudioAnalysisCard(filePath: source),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(fullReads, 0, reason: 'opening cards must not fetch audio');
await tester.tap(find.text('Check'));
for (
var i = 0;
i < 300 && find.text('96.0 kHz').evaluate().isEmpty;
i++
) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(find.text('96.0 kHz'), findsOneWidget);
if (smbAddress.isEmpty) expect(fullReads, 1);
await tester.ensureVisible(find.byType(AudioAnalysisCard));
await tester.tap(find.text('Audio Quality Analysis'));
for (var i = 0; i < 600 && find.text('24-bit').evaluate().isEmpty; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(find.text('24-bit'), findsOneWidget);
expect(find.text('96.0 kHz'), findsNWidgets(2));
expect(find.text('Dynamic Range: '), findsOneWidget);
expect(find.text('Ch 1'), findsOneWidget);
if (smbAddress.isEmpty) expect(fullReads, 2);
await tester.ensureVisible(find.text('Ch 1'));
await tester.tap(find.text('Ch 1'));
await tester.pump();
for (
var i = 0;
i < 600 && find.byType(LinearProgressIndicator).evaluate().isNotEmpty;
i++
) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(find.byType(LinearProgressIndicator), findsNothing);
if (smbAddress.isEmpty) expect(fullReads, 3);
expect(requests.every((r) => ['GET', 'HEAD'].contains(r.method)), true);
expect(
requests.every(
(r) =>
r.authorization ==
'Basic ${base64Encode(utf8.encode('test:secret'))}',
),
true,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: Scaffold(
body: SingleChildScrollView(
child: AudioAnalysisCard(filePath: source),
),
),
),
);
for (var i = 0; i < 300 && find.text('24-bit').evaluate().isEmpty; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(find.text('24-bit'), findsOneWidget);
expect(find.text('Ch 1'), findsOneWidget);
if (smbAddress.isEmpty) {
expect(fullReads, 3, reason: 'reopen uses cached analysis');
}
final temporary = await getTemporaryDirectory();
expect(
await temporary
.list()
.where(
(entry) => entry.uri.pathSegments
.where((part) => part.isNotEmpty)
.last
.startsWith('network-analysis-'),
)
.toList(),
isEmpty,
);
await tester.pumpWidget(const SizedBox());
} finally {
await storage.remove(id);
await server.close(force: true);
await local.delete(recursive: true);
}
});
}
+1 -1
View File
@@ -91,7 +91,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
_buildLyricsCard(context, colorScheme),
if (_fileExists && !_isNetworkItem) ...[
if (_fileExists) ...[
const SizedBox(height: 16),
AudioAnalysisCard(
filePath: _filePath,
+11 -1
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
class HiResOriginalRestoreException implements Exception {
const HiResOriginalRestoreException(this.backupPath, this.cause);
@@ -111,7 +112,16 @@ class HiResCheckService {
/// Runs the check; throws on failure. `supported == false` for formats
/// the Rust checker cannot decode (anything but FLAC and PCM WAV).
static Future<HiResCheckResult> check(String filePath) async {
static Future<HiResCheckResult> check(
String filePath, {
NetworkAudioAnalysisInput? networkInput,
}) async {
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
return (networkInput ?? NetworkAudioAnalysisInput()).withFile(
filePath,
(path) => check(path),
);
}
final json = await PlatformBridge.checkHiResAuthenticity(filePath);
final error = json['error'];
if (error != null) throw StateError(error.toString());
@@ -0,0 +1,125 @@
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
/// Supplies seekable, unmodified audio to the existing analysers. Credentials
/// stay in the storage proxy; only an owned temporary file reaches the decoder.
class NetworkAudioAnalysisInput {
NetworkAudioAnalysisInput({
NetworkStorageService? storage,
Future<Directory> Function()? temporaryDirectory,
this.timeout = const Duration(seconds: 20),
}) : _storage = storage ?? NetworkStorageService.instance,
_temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
final NetworkStorageService _storage;
final Future<Directory> Function() _temporaryDirectory;
final Duration timeout;
final Set<HttpClient> _clients = {};
int _generation = 0;
static bool isNetwork(String source) => source.startsWith('network://');
/// Cancels only transfers owned by this analyser, leaving playback intact.
void cancel() {
_generation++;
for (final client in _clients) {
client.close(force: true);
}
}
Future<T> _withResponse<T>(
String source,
String method,
Future<T> Function(HttpClientResponse, void Function()) consume,
) async {
final generation = _generation;
void checkpoint() {
if (generation != _generation) throw const AudioAnalysisCancelled();
}
final client = HttpClient()
..connectionTimeout = timeout
..autoUncompress = false;
_clients.add(client);
try {
final url = await _storage.resolve(source);
checkpoint();
final request = await client
.openUrl(method, Uri.parse(url))
.timeout(timeout);
request.followRedirects = false;
request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
checkpoint();
final response = await request.close().timeout(timeout);
checkpoint();
if (response.statusCode != HttpStatus.ok) {
throw HttpException('Audio server returned ${response.statusCode}');
}
return await consume(response, checkpoint);
} catch (_) {
checkpoint();
rethrow;
} finally {
_clients.remove(client);
client.close(force: true);
}
}
Future<int> length(String source) =>
_withResponse(source, 'HEAD', (response, checkpoint) async {
await response.drain<void>().timeout(timeout);
checkpoint();
return response.contentLength;
});
/// Streams to disk with backpressure, then retains the file until all native
/// reads finish. A partial/failed/cancelled transfer never reaches analysis.
Future<T> withFile<T>(
String source,
Future<T> Function(String path) analyze,
) => _withResponse(source, 'GET', (response, checkpoint) async {
Directory? directory;
try {
final root = await _temporaryDirectory();
checkpoint();
directory = await root.createTemp('network-analysis-');
checkpoint();
final name = Uri.parse(source).pathSegments.last;
final file = File(p.join(directory.path, 'audio${p.extension(name)}'));
var received = 0;
final sink = file.openWrite();
try {
await sink.addStream(
response.timeout(timeout).map((chunk) {
checkpoint();
received += chunk.length;
return chunk;
}),
);
} finally {
await sink.close();
}
checkpoint();
if (received == 0 ||
(response.contentLength >= 0 && received != response.contentLength)) {
throw const HttpException('Incomplete network audio file');
}
final result = await analyze(file.path);
checkpoint();
return result;
} finally {
if (directory != null) {
try {
await directory.delete(recursive: true);
} on FileSystemException {
// Temporary cleanup must not replace the analysis result or error.
}
}
}
});
}
+37 -5
View File
@@ -14,6 +14,7 @@ import 'package:flutter/services.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
import 'package:spotiflac_android/services/audio_analysis_cache.dart';
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/app_content_card.dart';
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
@@ -609,6 +610,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
bool _spectrogramChannelLoading = false;
int _spectrogramRequestId = 0;
String? _unsupportedCodec;
final _networkInput = NetworkAudioAnalysisInput();
final _analysisJobs = AudioAnalysisJobs<FFmpegSession>(
start: (arguments) async {
final completed = Completer<FFmpegSession>();
@@ -697,6 +699,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
_spectrogramRequestId++;
_analysisJobs.invalidate();
_networkInput.cancel();
_cancelNativeAnalysis();
_spectrogramImage?.dispose();
_spectrogramImage = null;
@@ -717,6 +720,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
void dispose() {
_spectrogramRequestId++;
_analysisJobs.dispose();
_networkInput.cancel();
_cancelNativeAnalysis();
_spectrogramImage?.dispose();
super.dispose();
@@ -753,6 +757,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
expectedPath,
channel: _spectrogramChannel,
);
if (image == null &&
NetworkAudioAnalysisInput.isNetwork(expectedPath)) {
return; // Opening metadata must not fetch the complete remote audio.
}
image ??= await _generateAndCacheSpectrogram(
filePath: expectedPath,
analysisData: cached,
@@ -806,6 +814,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
final releaseCache = _cacheBudget.retain(_cacheKey(sourcePath));
final requestId = ++_spectrogramRequestId;
_analysisJobs.invalidate();
_networkInput.cancel();
_cancelNativeAnalysis();
setState(() {
_analyzing = true;
@@ -938,7 +947,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
}
}
static Future<AudioAnalysisData?> _loadFromCache(String filePath) async {
Future<AudioAnalysisData?> _loadFromCache(String filePath) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
@@ -953,7 +962,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
}
final cachedSize = json['fileSize'] as int;
if (!filePath.startsWith('content://')) {
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
final currentSize = await _networkInput.length(filePath);
if (currentSize <= 0 || currentSize != cachedSize) return null;
} else if (!filePath.startsWith('content://')) {
final currentSize = await File(filePath).length();
if (currentSize != cachedSize) return null;
} else {
@@ -1027,9 +1039,16 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
Future<_AudioAnalysisRunResult> _runAnalysis(
String filePath,
int requestId,
) async {
int requestId, {
String? sourcePath,
}) async {
_checkAnalysisRequest(requestId);
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
return _networkInput.withFile(
filePath,
(path) => _runAnalysis(path, requestId, sourcePath: filePath),
);
}
String workingPath = filePath;
String? tempCopy;
if (filePath.startsWith('content://')) {
@@ -1078,7 +1097,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
return _AudioAnalysisRunResult(
data: AudioAnalysisData(
filePath: filePath,
filePath: sourcePath ?? filePath,
fileSize: info.fileSize,
codec: info.codec,
container: info.container,
@@ -1126,6 +1145,19 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
int? channels,
}) async {
_checkAnalysisRequest(requestId);
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
return _networkInput.withFile(
filePath,
(path) => _generateSpectrogramForFile(
path,
requestId: requestId,
channel: channel,
durationSeconds: durationSeconds,
sampleRate: sampleRate,
channels: channels,
),
);
}
String workingPath = filePath;
String? tempCopy;
if (filePath.startsWith('content://')) {
+17 -1
View File
@@ -2,6 +2,8 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/hires_check_service.dart';
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
import 'package:spotiflac_android/theme/mornye_icons.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
@@ -32,18 +34,27 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
bool _checking = false;
String? _error;
int _requestId = 0;
final _networkInput = NetworkAudioAnalysisInput();
@override
void didUpdateWidget(covariant HiResCheckCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filePath != widget.filePath) {
_requestId++;
_networkInput.cancel();
_result = null;
_error = null;
_checking = false;
}
}
@override
void dispose() {
_requestId++;
_networkInput.cancel();
super.dispose();
}
Future<void> _check() async {
final requestId = ++_requestId;
setState(() {
@@ -53,7 +64,12 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
String? error;
HiResCheckResult? result;
try {
result = await HiResCheckService.check(widget.filePath);
result = await HiResCheckService.check(
widget.filePath,
networkInput: _networkInput,
);
} on AudioAnalysisCancelled {
return;
} on StateError catch (e) {
error = e.message;
} catch (e) {
+224
View File
@@ -0,0 +1,224 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
import 'package:spotiflac_android/services/hires_check_service.dart';
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = null;
late Directory temporary;
late HttpServer server;
late NetworkStorageService storage;
late NetworkAudioAnalysisInput input;
final requests = <String>[];
const name = 'हिन्दी & Rock + #1.wav';
final audio = List<int>.generate(128 * 1024, (i) => i % 256);
var status = HttpStatus.ok;
var empty = false;
Completer<void>? release;
Completer<void>? started;
setUp(() async {
requests.clear();
status = HttpStatus.ok;
empty = false;
release = null;
started = null;
temporary = await Directory.systemTemp.createTemp('network-analysis-test-');
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) async {
try {
requests.add(request.method);
expect(request.uri.pathSegments.last, name);
expect(
request.headers.value(HttpHeaders.authorizationHeader),
'Basic ${base64Encode(utf8.encode('tester:secret'))}',
);
request.response.statusCode = status;
if (status != HttpStatus.ok) return;
request.response.contentLength = empty ? 0 : audio.length;
if (request.method == 'GET' && !empty) {
request.response.add(audio.sublist(0, 1024));
await request.response.flush();
started?.complete();
if (release != null) await release!.future;
request.response.add(audio.sublist(1024));
}
} finally {
try {
await request.response.close();
} on IOException {
// Cancellation intentionally closes the client mid-transfer.
}
}
});
storage = NetworkStorageService(
read: () async => null,
write: (_) async {},
);
await storage.save(
NetworkConnection(
id: 'fixture',
name: 'Fixture',
protocol: NetworkProtocol.webdav,
address: 'http://127.0.0.1:${server.port}/music/',
username: 'tester',
password: 'secret',
),
);
input = NetworkAudioAnalysisInput(
storage: storage,
temporaryDirectory: () async => temporary,
);
});
tearDown(() async {
if (release != null && !release!.isCompleted) release!.complete();
input.cancel();
await storage.dispose();
await server.close(force: true);
await temporary.delete(recursive: true);
});
String source() => NetworkStorageService.source('fixture', name);
test('authenticated staging preserves bytes and is read-only', () async {
expect(await input.length(source()), audio.length);
String? stagedPath;
final result = await input.withFile(source(), (path) async {
stagedPath = path;
expect(path.endsWith('.wav'), true);
expect(await File(path).readAsBytes(), audio);
// Native analysis may reopen/seek the input more than once.
final handle = await File(path).open();
try {
await handle.setPosition(1234);
expect(await handle.read(10), audio.sublist(1234, 1244));
} finally {
await handle.close();
}
return 'analysed';
});
expect(result, 'analysed');
expect(await File(stagedPath!).exists(), false);
expect(await temporary.list().toList(), isEmpty);
expect(requests, ['HEAD', 'GET']);
});
test('Hi-Res checker receives the staged file, never a proxy URL', () async {
const channel = MethodChannel('com.zarz.spotiflac/backend');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'checkHiResAuthenticity');
final path = (call.arguments as Map)['file_path'] as String;
expect(await File(path).readAsBytes(), audio);
return {'supported': true, 'declared_sample_rate': 96000};
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
final result = await HiResCheckService.check(source(), networkInput: input);
expect(result.supported, true);
expect(result.declaredSampleRate, 96000);
expect(await temporary.list().toList(), isEmpty);
});
test('decoder errors clean up the input', () async {
await expectLater(
input.withFile<void>(source(), (_) async => throw StateError('decoder')),
throwsStateError,
);
expect(await temporary.list().toList(), isEmpty);
});
test('server errors and empty audio never run a decoder', () async {
var decoded = false;
for (final code in [404, 401, 500, 206, 200]) {
status = code;
empty = true;
await expectLater(
input.withFile<void>(source(), (_) async => decoded = true),
throwsA(isA<HttpException>()),
);
expect(decoded, false);
expect(await temporary.list().toList(), isEmpty);
}
});
test('cancelling a transfer leaves another analyser usable', () async {
release = Completer<void>();
started = Completer<void>();
var decoded = false;
final transfer = input.withFile<void>(
source(),
(_) async => decoded = true,
);
final cancelled = expectLater(
transfer,
throwsA(isA<AudioAnalysisCancelled>()),
);
await started!.future;
input.cancel();
await cancelled.timeout(const Duration(seconds: 2));
expect(decoded, false);
expect(await temporary.list().toList(), isEmpty);
release!.complete();
release = null;
started = null;
final other = NetworkAudioAnalysisInput(
storage: storage,
temporaryDirectory: () async => temporary,
);
expect(await other.length(source()), audio.length);
expect(await input.withFile(source(), (_) async => 7), 7);
});
test(
'cancel waits for an active decoder before deleting its input',
() async {
final entered = Completer<void>();
final completed = Completer<void>();
String? stagedPath;
final task = input.withFile<void>(source(), (path) async {
stagedPath = path;
entered.complete();
await completed.future;
expect(await File(path).exists(), true);
});
final cancelled = expectLater(
task,
throwsA(isA<AudioAnalysisCancelled>()),
);
await entered.future;
input.cancel();
expect(await File(stagedPath!).exists(), true);
completed.complete();
await cancelled;
expect(await temporary.list().toList(), isEmpty);
},
);
test('a stalled stream times out and removes partial audio', () async {
release = Completer<void>();
started = Completer<void>();
final bounded = NetworkAudioAnalysisInput(
storage: storage,
temporaryDirectory: () async => temporary,
timeout: const Duration(milliseconds: 100),
);
var decoded = false;
await expectLater(
bounded.withFile<void>(source(), (_) async => decoded = true),
throwsA(isA<TimeoutException>()),
);
expect(decoded, false);
expect(await temporary.list().toList(), isEmpty);
});
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/hires_check_card.dart';
void main() {
for (final mornye in [false, true]) {
for (final format in ['flac', 'wav', 'mp3']) {
testWidgets(
'network $format exposes supported analysers (Mornye=$mornye)',
(tester) async {
final item = LocalLibraryItem(
id: 'network://fixture/track.$format',
filePath: 'network://fixture/track.$format',
trackName: 'Network track',
artistName: 'Artist',
albumName: 'Album',
scannedAt: DateTime(2026),
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.dark)
: ThemeData.dark(useMaterial3: true),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: TrackMetadataScreen(localItem: item),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.scrollUntilVisible(
find.byType(AudioAnalysisCard),
300,
scrollable: find.byType(Scrollable).first,
);
expect(find.byType(AudioAnalysisCard), findsOneWidget);
expect(
tester
.widget<AudioAnalysisCard>(find.byType(AudioAnalysisCard))
.filePath,
item.filePath,
);
expect(
find.byType(HiResCheckCard),
format == 'mp3' ? findsNothing : findsOneWidget,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
},
);
}
}
}