mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
The card ran ffprobe, both FFmpeg sessions, SAF temp copies, the native spectral-cutoff job and its cancellation inside State. Move that pipeline verbatim into AudioAnalysisRunner in lib/services/audio_analysis_runner.dart together with the private per-run records from the widget's models part, which is now empty and removed. The card keeps request ids and every setState. The runner takes a `bool Function() isCancelled` that the card builds from the same `!mounted || requestId != _spectrogramRequestId` test, polled at the same points that used to call _checkAnalysisRequest. cancel() and dispose() run the jobs invalidate/dispose, network cancel and native job cancel in the original order. Subprocess order, temp file names and cleanup, SAF copy and the native data job payload are unchanged.
570 lines
17 KiB
Dart
570 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' as ui;
|
|
import 'package:flutter/material.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/audio_analysis_ffmpeg.dart';
|
|
import 'package:spotiflac_android/services/audio_analysis_models.dart';
|
|
import 'package:spotiflac_android/services/audio_analysis_runner.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_progress_indicator.dart';
|
|
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
|
|
import 'package:spotiflac_android/widgets/app_icon_button.dart';
|
|
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
|
|
export 'package:spotiflac_android/services/audio_analysis_ffmpeg.dart';
|
|
export 'package:spotiflac_android/services/audio_analysis_models.dart';
|
|
|
|
part 'audio_analysis_info_card.dart';
|
|
part 'audio_analysis_spectrogram.dart';
|
|
|
|
class AudioAnalysisCard extends StatefulWidget {
|
|
final String filePath;
|
|
final String? codecHint;
|
|
|
|
const AudioAnalysisCard({super.key, required this.filePath, this.codecHint});
|
|
|
|
@override
|
|
State<AudioAnalysisCard> createState() => _AudioAnalysisCardState();
|
|
}
|
|
|
|
class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
|
AudioAnalysisData? _data;
|
|
bool _analyzing = false;
|
|
bool _checkingCache = true;
|
|
String? _error;
|
|
ui.Image? _spectrogramImage;
|
|
int _spectrogramChannel = -1;
|
|
bool _spectrogramChannelLoading = false;
|
|
int _spectrogramRequestId = 0;
|
|
String? _unsupportedCodec;
|
|
final _runner = AudioAnalysisRunner();
|
|
|
|
bool _isStaleRequest(int requestId) =>
|
|
!mounted || requestId != _spectrogramRequestId;
|
|
|
|
void _checkAnalysisRequest(int requestId) {
|
|
if (_isStaleRequest(requestId)) {
|
|
throw const AudioAnalysisCancelled();
|
|
}
|
|
}
|
|
|
|
static const _supportedExtensions = {
|
|
'.flac',
|
|
'.mp3',
|
|
'.m4a',
|
|
'.mp4',
|
|
'.aac',
|
|
'.ac3',
|
|
'.eac3',
|
|
'.opus',
|
|
'.ogg',
|
|
'.wav',
|
|
'.wma',
|
|
'.mka',
|
|
'.wv',
|
|
'.ape',
|
|
'.tta',
|
|
'.aif',
|
|
'.aiff',
|
|
};
|
|
|
|
bool get _isSupported {
|
|
final lower = widget.filePath.toLowerCase();
|
|
return _supportedExtensions.any((ext) => lower.endsWith(ext));
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_unsupportedCodec = unsupportedAudioAnalysisCodecLabel(widget.codecHint);
|
|
if (_isSupported) {
|
|
if (_unsupportedCodec == null) {
|
|
_tryLoadFromCache();
|
|
} else {
|
|
_checkingCache = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant AudioAnalysisCard oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.filePath == widget.filePath &&
|
|
oldWidget.codecHint == widget.codecHint) {
|
|
return;
|
|
}
|
|
|
|
_spectrogramRequestId++;
|
|
_runner.cancel();
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = null;
|
|
_spectrogramChannel = -1;
|
|
_spectrogramChannelLoading = false;
|
|
_data = null;
|
|
_error = null;
|
|
_analyzing = false;
|
|
_unsupportedCodec = unsupportedAudioAnalysisCodecLabel(widget.codecHint);
|
|
_checkingCache = _isSupported && _unsupportedCodec == null;
|
|
|
|
if (_checkingCache) {
|
|
unawaited(_tryLoadFromCache());
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_spectrogramRequestId++;
|
|
_runner.dispose();
|
|
_spectrogramImage?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _tryLoadFromCache() async {
|
|
final expectedPath = widget.filePath;
|
|
final requestId = _spectrogramRequestId;
|
|
final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain(
|
|
AudioAnalysisDiskCache.cacheKey(expectedPath),
|
|
);
|
|
bool isCurrentRequest() =>
|
|
mounted &&
|
|
widget.filePath == expectedPath &&
|
|
requestId == _spectrogramRequestId;
|
|
|
|
try {
|
|
final cached = await AudioAnalysisDiskCache.loadFromCache(
|
|
expectedPath,
|
|
_runner.networkInput,
|
|
);
|
|
if (cached != null && isCurrentRequest()) {
|
|
final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec);
|
|
if (unsupported != null) {
|
|
await AudioAnalysisDiskCache.clearCache(expectedPath);
|
|
if (isCurrentRequest()) {
|
|
setState(() {
|
|
_unsupportedCodec = unsupported;
|
|
_checkingCache = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
setState(() {
|
|
_data = cached;
|
|
_checkingCache = false;
|
|
});
|
|
var image = await AudioAnalysisDiskCache.loadSpectrogramFromCache(
|
|
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,
|
|
requestId: requestId,
|
|
);
|
|
if (isCurrentRequest()) {
|
|
setState(() {
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = image;
|
|
});
|
|
} else {
|
|
image.dispose();
|
|
}
|
|
return;
|
|
}
|
|
} catch (_) {
|
|
// Missing or unreadable cache entries fall back to the Analyze action.
|
|
} finally {
|
|
releaseCache();
|
|
unawaited(AudioAnalysisDiskCache.trimCache());
|
|
}
|
|
if (isCurrentRequest()) {
|
|
setState(() => _checkingCache = false);
|
|
}
|
|
}
|
|
|
|
Future<ui.Image> _generateAndCacheSpectrogram({
|
|
String? filePath,
|
|
AudioAnalysisData? analysisData,
|
|
required int requestId,
|
|
}) async {
|
|
_checkAnalysisRequest(requestId);
|
|
final sourcePath = filePath ?? widget.filePath;
|
|
final data = analysisData ?? _data;
|
|
final channel = _spectrogramChannel;
|
|
final artifact = await _runner.generateSpectrogramForFile(
|
|
sourcePath,
|
|
isCancelled: () => _isStaleRequest(requestId),
|
|
channel: channel,
|
|
durationSeconds: data?.duration,
|
|
sampleRate: data?.sampleRate,
|
|
channels: data?.channels,
|
|
);
|
|
await AudioAnalysisDiskCache.saveSpectrogramToCache(
|
|
sourcePath,
|
|
artifact.image,
|
|
channel: channel,
|
|
);
|
|
return artifact.image;
|
|
}
|
|
|
|
Future<void> _analyze({bool forceRefresh = false}) async {
|
|
if (_analyzing) return;
|
|
final sourcePath = widget.filePath;
|
|
final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain(
|
|
AudioAnalysisDiskCache.cacheKey(sourcePath),
|
|
);
|
|
final requestId = ++_spectrogramRequestId;
|
|
_runner.cancel();
|
|
setState(() {
|
|
_analyzing = true;
|
|
_spectrogramChannelLoading = false;
|
|
_error = null;
|
|
if (forceRefresh) {
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = null;
|
|
_data = null;
|
|
_spectrogramChannel = -1;
|
|
}
|
|
});
|
|
|
|
try {
|
|
if (forceRefresh) {
|
|
await AudioAnalysisDiskCache.clearCache(sourcePath);
|
|
}
|
|
|
|
final cached = forceRefresh
|
|
? null
|
|
: await AudioAnalysisDiskCache.loadFromCache(
|
|
sourcePath,
|
|
_runner.networkInput,
|
|
);
|
|
_checkAnalysisRequest(requestId);
|
|
AudioAnalysisData data;
|
|
ui.Image? image;
|
|
|
|
if (cached != null) {
|
|
final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec);
|
|
if (unsupported != null) {
|
|
throw UnsupportedAudioAnalysisCodecException(unsupported);
|
|
}
|
|
data = cached;
|
|
image = await AudioAnalysisDiskCache.loadSpectrogramFromCache(
|
|
sourcePath,
|
|
channel: _spectrogramChannel,
|
|
);
|
|
} else {
|
|
final result = await _runner.runAnalysis(
|
|
sourcePath,
|
|
() => _isStaleRequest(requestId),
|
|
);
|
|
data = result.data;
|
|
image = result.spectrogramImage;
|
|
await AudioAnalysisDiskCache.saveToCache(sourcePath, data);
|
|
await AudioAnalysisDiskCache.saveSpectrogramToCache(
|
|
sourcePath,
|
|
image,
|
|
channel: _spectrogramChannel,
|
|
);
|
|
}
|
|
|
|
image ??= await _generateAndCacheSpectrogram(
|
|
filePath: sourcePath,
|
|
analysisData: data,
|
|
requestId: requestId,
|
|
);
|
|
|
|
if (mounted && requestId == _spectrogramRequestId) {
|
|
setState(() {
|
|
_data = data;
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = image;
|
|
_analyzing = false;
|
|
});
|
|
} else {
|
|
image.dispose();
|
|
}
|
|
} on AudioAnalysisCancelled {
|
|
// A new file/request owns the state; cancelled native work is cleaned up.
|
|
} on UnsupportedAudioAnalysisCodecException catch (e) {
|
|
if (mounted && requestId == _spectrogramRequestId) {
|
|
setState(() {
|
|
_unsupportedCodec = e.codecLabel;
|
|
_error = null;
|
|
_analyzing = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted && requestId == _spectrogramRequestId) {
|
|
setState(() {
|
|
_error = context.friendlyError(e);
|
|
_analyzing = false;
|
|
});
|
|
}
|
|
} finally {
|
|
releaseCache();
|
|
unawaited(AudioAnalysisDiskCache.trimCache());
|
|
}
|
|
}
|
|
|
|
Future<void> _changeSpectrogramChannel(int channel) async {
|
|
final data = _data;
|
|
if (data == null ||
|
|
_spectrogramChannelLoading ||
|
|
_analyzing ||
|
|
channel == _spectrogramChannel ||
|
|
channel < -1 ||
|
|
channel >= data.channels) {
|
|
return;
|
|
}
|
|
|
|
final previousChannel = _spectrogramChannel;
|
|
final sourcePath = widget.filePath;
|
|
final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain(
|
|
AudioAnalysisDiskCache.cacheKey(sourcePath),
|
|
);
|
|
final requestId = ++_spectrogramRequestId;
|
|
setState(() {
|
|
_spectrogramChannel = channel;
|
|
_spectrogramChannelLoading = true;
|
|
});
|
|
|
|
ui.Image? image;
|
|
try {
|
|
image = await AudioAnalysisDiskCache.loadSpectrogramFromCache(
|
|
sourcePath,
|
|
channel: channel,
|
|
);
|
|
if (image == null) {
|
|
final artifact = await _runner.generateSpectrogramForFile(
|
|
sourcePath,
|
|
isCancelled: () => _isStaleRequest(requestId),
|
|
channel: channel,
|
|
durationSeconds: data.duration,
|
|
sampleRate: data.sampleRate,
|
|
channels: data.channels,
|
|
);
|
|
image = artifact.image;
|
|
await AudioAnalysisDiskCache.saveSpectrogramToCache(
|
|
sourcePath,
|
|
image,
|
|
channel: channel,
|
|
);
|
|
}
|
|
|
|
if (!mounted || requestId != _spectrogramRequestId) {
|
|
image.dispose();
|
|
return;
|
|
}
|
|
setState(() {
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = image;
|
|
_spectrogramChannelLoading = false;
|
|
});
|
|
} catch (_) {
|
|
image?.dispose();
|
|
if (mounted && requestId == _spectrogramRequestId) {
|
|
setState(() {
|
|
_spectrogramChannel = previousChannel;
|
|
_spectrogramChannelLoading = false;
|
|
});
|
|
}
|
|
} finally {
|
|
releaseCache();
|
|
unawaited(AudioAnalysisDiskCache.trimCache());
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_isSupported) return const SizedBox.shrink();
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
final l10n = context.l10n;
|
|
|
|
if (_checkingCache) return const SizedBox.shrink();
|
|
|
|
if (_unsupportedCodec != null) {
|
|
return AppContentCard(
|
|
elevation: 0,
|
|
color: settingsGroupColor(context),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.audioAnalysisTitle,
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${l10n.snackbarUnsupportedAudioFormat}: '
|
|
'$_unsupportedCodec',
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_analyzing) {
|
|
final isRescan = _data != null || _spectrogramImage != null;
|
|
return AppContentCard(
|
|
elevation: 0,
|
|
color: settingsGroupColor(context),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const AppLoadingIndicator(size: 24, strokeWidth: 2.5),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
isRescan
|
|
? l10n.audioAnalysisRescanning
|
|
: l10n.audioAnalysisAnalyzing,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_error != null) {
|
|
return AppContentCard(
|
|
preserveColor: true,
|
|
color: cs.errorContainer,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: cs.onErrorContainer),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_error!,
|
|
style: TextStyle(color: cs.onErrorContainer, fontSize: 13),
|
|
),
|
|
),
|
|
AppIconButton(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
tooltip: l10n.audioAnalysisRescan,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
|
|
color: cs.onErrorContainer,
|
|
onPressed: () => _analyze(forceRefresh: true),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_data == null) {
|
|
return AppContentCard(
|
|
elevation: 0,
|
|
color: settingsGroupColor(context),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
|
),
|
|
child: InkWell(
|
|
onTap: _analyze,
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.audioAnalysisTitle,
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l10n.audioAnalysisDescription,
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final data = _data!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_AudioInfoCard(
|
|
data: data,
|
|
onRescan: () => _analyze(forceRefresh: true),
|
|
),
|
|
if (_spectrogramImage != null) ...[
|
|
const SizedBox(height: 12),
|
|
_SpectrogramView(
|
|
image: _spectrogramImage!,
|
|
sampleRate: data.sampleRate,
|
|
maxFreq: data.sampleRate / 2,
|
|
duration: data.duration,
|
|
channels: data.channels,
|
|
selectedChannel: _spectrogramChannel,
|
|
channelLoading: _spectrogramChannelLoading,
|
|
onChannelChanged: _changeSpectrogramChannel,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|