feat(player): add beat-matched AutoMix and refine Mornye navigation

Add default-off local beat analysis, pitch-preserving tempo matching, crossfade fallback, and cancellation-safe two-deck playback. Expose AutoMix in settings and the Mornye queue.

Separate Search from Home, remove the Mornye greeting, and keep Home/Search icons moving continuously between full and compact navigation. Restore expansion on upward Library scrolling.

Validate beat analysis, transport handoff and cancellation, settings persistence, and navigation behavior. Flutter analysis and the signed iOS simulator build pass.
This commit is contained in:
zarzet committed 2026-09-20 20:36:26 +07:00
1 parent 31ef7c7c88
commit 5b519070a3
23 files changed
+2074 -298

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
import 'dart:math' as math;
import 'dart:typed_data';
/// A local beat grid, measured in seconds within the decoded audio window.
class AutoMixBeatGrid {
const AutoMixBeatGrid({
required this.bpm,
required this.phase,
required this.confidence,
required this.firstSound,
});
final double bpm;
final double phase;
final double confidence;
final double firstSound;
bool get reliable => confidence >= 0.55 && bpm >= 60 && bpm <= 180;
double get period => 60 / bpm;
double beatAtOrAfter(double seconds) =>
phase + ((seconds - phase) / period).ceil() * period;
}
/// Bounded, original onset/autocorrelation detector. Runs in an isolate on
/// 24-second mono PCM windows, not on the UI isolate or an entire music file.
/// The onset -> autocorrelation -> phase approach is described in DAFx-09,
/// "Real-time beat-synchronous analysis of musical audio":
/// https://www.dafx.de/paper-archive/2009/papers/paper_65.pdf
AutoMixBeatGrid analyzeAutoMixPcm(Uint8List pcm) {
const sampleRate = 11025;
const hop = 128;
const framesPerSecond = sampleRate / hop;
final samples = ByteData.sublistView(pcm);
final frames = pcm.length ~/ (2 * hop);
const unknown = AutoMixBeatGrid(
bpm: 0,
phase: 0,
confidence: 0,
firstSound: 0,
);
if (frames < framesPerSecond * 6) return unknown;
final onset = Float64List(frames);
final energy = Float64List(frames);
var low = 0.0;
var mid = 0.0;
var previousLow = 0.0;
var previousMid = 0.0;
var previousHigh = 0.0;
for (var frame = 0; frame < frames; frame++) {
var loEnergy = 0.0;
var midEnergy = 0.0;
var hiEnergy = 0.0;
for (var i = 0; i < hop; i++) {
final value =
samples.getInt16((frame * hop + i) * 2, Endian.little) / 32768;
low += 0.075 * (value - low);
mid += 0.45 * (value - mid);
loEnergy += low * low;
midEnergy += (mid - low) * (mid - low);
hiEnergy += (value - mid) * (value - mid);
energy[frame] += value * value / hop;
}
final lo = math.log(1 + loEnergy * 100 / hop);
final mi = math.log(1 + midEnergy * 100 / hop);
final hi = math.log(1 + hiEnergy * 100 / hop);
onset[frame] =
math.max(0, lo - previousLow) +
math.max(0, mi - previousMid) +
0.5 * math.max(0, hi - previousHigh);
previousLow = lo;
previousMid = mi;
previousHigh = hi;
}
final peakEnergy = energy.reduce(math.max);
if (peakEnergy < 0.00001) return unknown;
final firstAudible = energy.indexWhere(
(e) => e > math.max(0.00001, peakEnergy * 0.01),
);
final firstSound = math.max(0, firstAudible) / framesPerSecond;
// Suppress slow volume changes and constant noise before finding the pulse.
final prefix = Float64List(frames + 1);
for (var i = 0; i < frames; i++) {
prefix[i + 1] = prefix[i] + onset[i];
}
var total = 0.0;
for (var i = 0; i < frames; i++) {
final begin = math.max(0, i - 8);
final end = math.min(frames, i + 9);
onset[i] = math.max(
0,
onset[i] - (prefix[end] - prefix[begin]) / (end - begin),
);
total += onset[i] * onset[i];
}
if (total < 0.000001) {
return AutoMixBeatGrid(
bpm: 0,
phase: 0,
confidence: 0,
firstSound: firstSound,
);
}
double correlation(double lag) {
var dot = 0.0;
var left = 0.0;
var right = 0.0;
for (var i = lag.ceil(); i < frames; i++) {
final source = i - lag;
final j = source.floor();
final fraction = source - j;
final delayed =
onset[j] * (1 - fraction) +
onset[math.min(j + 1, frames - 1)] * fraction;
dot += onset[i] * delayed;
left += onset[i] * onset[i];
right += delayed * delayed;
}
return dot / math.sqrt(math.max(0.000000001, left * right));
}
var bestBpm = 0.0;
var bestScore = 0.0;
for (var bpm = 60.0; bpm <= 180; bpm += 0.25) {
final score = correlation(framesPerSecond * 60 / bpm);
// Resolve equally strong half-time candidates toward common musical tempi.
final weighted =
score * (0.96 + 0.04 * math.exp(-math.pow((bpm - 120) / 45, 2)));
if (weighted > bestScore) {
bestScore = weighted;
bestBpm = bpm;
}
}
if (bestBpm == 0) return unknown;
final period = framesPerSecond * 60 / bestBpm;
var bestPhase = 0.0;
var phaseScore = 0.0;
for (var phase = 0.0; phase < period; phase += 0.5) {
var score = 0.0;
var count = 0;
for (var beat = phase; beat < frames - 1; beat += period) {
final i = beat.round();
score += onset[i];
count++;
}
score /= math.max(1, count);
if (score > phaseScore) {
phaseScore = score;
bestPhase = phase;
}
}
var hits = 0;
var beats = 0;
for (var beat = bestPhase; beat < frames - 2; beat += period) {
final i = beat.round();
final peak = math.max(
onset[i],
math.max(onset[math.max(0, i - 1)], onset[i + 1]),
);
if (peak >= phaseScore * 0.3) hits++;
beats++;
}
final coverage = hits / math.max(1, beats);
return AutoMixBeatGrid(
bpm: bestBpm,
phase: bestPhase / framesPerSecond,
confidence: math.min(bestScore, coverage),
firstSound: firstSound,
);
}
class AutoMixPlan {
const AutoMixPlan({
required this.start,
required this.incomingStart,
required this.duration,
required this.rate,
required this.beatMatched,
});
final Duration start;
final Duration incomingStart;
final Duration duration;
final double rate;
final bool beatMatched;
static AutoMixPlan? create({
required Duration outgoingDuration,
required Duration incomingDuration,
AutoMixBeatGrid? outro,
AutoMixBeatGrid? intro,
double outroOffset = 0,
}) {
final end = outgoingDuration.inMicroseconds / 1e6;
final nextLength = incomingDuration.inMicroseconds / 1e6;
if (end < 20 || nextLength < 20) return null;
var fade = 5.0;
var start = end - fade;
var incomingStart = 0.0;
var rate = 1.0;
var matched = false;
if (outro?.reliable == true && intro?.reliable == true) {
final outgoing = outro!;
final incoming = intro!;
final candidates =
[
0.5,
1.0,
2.0,
].map((factor) => outgoing.bpm / (incoming.bpm * factor)).toList()
..sort((a, b) => (a - 1).abs().compareTo((b - 1).abs()));
final candidate = candidates.first;
final firstBeat = incoming.beatAtOrAfter(incoming.firstSound);
// Never stretch wildly or discard a long musical intro to force a mix.
if ((candidate - 1).abs() <= 0.08 && firstBeat <= 3) {
rate = candidate;
fade = (8 * outgoing.period).clamp(3.0, 8.0);
final phase = outroOffset + outgoing.phase;
start =
phase +
((end - fade - phase) / outgoing.period).floor() * outgoing.period;
incomingStart = firstBeat;
matched = true;
}
}
return AutoMixPlan(
start: Duration(microseconds: (start * 1e6).round()),
incomingStart: Duration(microseconds: (incomingStart * 1e6).round()),
duration: Duration(microseconds: (fade * 1e6).round()),
rate: rate,
beatMatched: matched,
);
}
}
/// Equal-power gain ramps; tempo returns gradually after the outgoing song ends.
({double outgoing, double incoming, double rate, bool complete})
autoMixEnvelope(Duration elapsed, AutoMixPlan plan) {
final t = (elapsed.inMicroseconds / plan.duration.inMicroseconds).clamp(
0.0,
1.0,
);
final recovery = ((elapsed - plan.duration).inMicroseconds / 8000000).clamp(
0.0,
1.0,
);
final smooth = recovery * recovery * (3 - 2 * recovery);
return (
outgoing: math.cos(t * math.pi / 2),
incoming: math.sin(t * math.pi / 2),
rate: plan.rate + (1 - plan.rate) * smooth,
complete: recovery >= 1 || (t >= 1 && plan.rate == 1),
);
}
+115
View File
@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
import 'package:spotiflac_android/services/automix_analysis.dart';
/// Only short head/tail windows are decoded. Summaries are bounded in memory;
/// raw PCM is deleted immediately and is never added to the user's library.
class AutoMixAnalyzer {
static const windowSeconds = 24.0;
final _cache = <String, AutoMixBeatGrid>{};
final _jobs = AudioAnalysisJobs<bool>(
start: (arguments) async {
final done = Completer<bool>();
final session = await FFmpegKit.executeWithArgumentsAsync(arguments, (
session,
) async {
final success = ReturnCode.isSuccess(await session.getReturnCode());
if (!done.isCompleted) done.complete(success);
});
final id = session.getSessionId();
if (id == null) throw StateError('AutoMix analysis session has no ID');
final timeout = Timer(const Duration(seconds: 20), () {
unawaited(FFmpegKit.cancel(id));
});
return (id: id, completed: done.future.whenComplete(timeout.cancel));
},
cancel: FFmpegKit.cancel,
);
int _generation = 0;
void cancel() {
_generation++;
_jobs.invalidate();
}
void dispose() {
_generation++;
_jobs.dispose();
_cache.clear();
}
Future<AutoMixBeatGrid?> analyze(String path, {double offset = 0}) async {
final generation = _generation;
Directory? work;
try {
final stat = await File(path).stat();
if (stat.type != FileSystemEntityType.file) return null;
final key =
'$path:${stat.size}:${stat.modified.microsecondsSinceEpoch}:$offset';
final cached = _cache.remove(key);
if (cached != null) {
_cache[key] = cached;
return cached;
}
if (generation != _generation) return null;
final temp = await getTemporaryDirectory();
work = await Directory(temp.path).createTemp('automix-');
final output = File('${work.path}/window.pcm');
final success = await _jobs.run([
'-hide_banner',
'-loglevel',
'error',
'-nostdin',
'-y',
'-threads',
'1',
'-filter_threads',
'1',
'-ss',
offset.toStringAsFixed(6),
'-i',
path,
'-t',
'$windowSeconds',
'-vn',
'-sn',
'-dn',
'-ac',
'1',
'-ar',
'11025',
'-c:a',
'pcm_s16le',
'-f',
's16le',
output.path,
]);
if (!success || generation != _generation || !await output.exists()) {
return null;
}
if (await output.length() > 11025 * 2 * 25) return null;
final grid = await compute(analyzeAutoMixPcm, await output.readAsBytes());
if (generation != _generation) return null;
_cache[key] = grid;
while (_cache.length > 16) {
_cache.remove(_cache.keys.first);
}
return grid;
} on AudioAnalysisCancelled {
return null;
} on Object {
// Unsupported/corrupt input must not interrupt ordinary playback.
return null;
} finally {
if (work != null && await work.exists()) {
await work.delete(recursive: true);
}
}
}
}
+303
View File
@@ -0,0 +1,303 @@
part of 'music_player_service.dart';
/// Owns only the extra deck and transition work. The ordinary player remains
/// the sole transport when AutoMix is disabled or a transition cannot be made.
class _MusicAutoMix {
_MusicAutoMix(this.handler, this.analyzer);
final MusicPlayerHandler handler;
final AutoMixAnalyzer analyzer;
final _preparationPins = <int, Set<String>>{};
final _mixPins = <String>{};
AudioPlayer? _prepared;
AudioPlayer? _outgoing;
AutoMixPlan? _plan;
PlayableMedia? _next;
String? _nextPath;
Duration? _nextDuration;
String? _attempt;
int _nextIndex = -1;
int _queueRevision = -1;
int _playGeneration = -1;
int _generation = 0;
int _cancelling = 0;
bool _preparing = false;
bool _starting = false;
double _incomingVolume = 1;
double rate = 1;
Timer? _startTimer;
Timer? _fadeTimer;
Future<void> _fadeWrite = Future<void>.value();
bool _writing = false;
Set<String> get pinnedPaths => {
..._mixPins,
for (final paths in _preparationPins.values) ...paths,
};
bool get _canMix =>
_autoMixEnabled &&
!handler._disposed &&
handler._sourceReady &&
!handler._userPaused &&
!handler._interruptionActive &&
handler._player.state == PlayerState.playing &&
handler._repeatMode != AudioServiceRepeatMode.one &&
handler._media.length > 1;
bool _current(int generation) =>
generation == _generation &&
_canMix &&
_playGeneration == handler._playRequestGeneration &&
_queueRevision == handler._sessionQueueRevision;
void onPosition(Duration position) {
if (!_canMix || _cancelling > 0 || _starting || _fadeTimer != null) return;
final plan = _plan;
if (plan != null) {
if (!_current(_generation)) {
unawaited(cancel());
return;
}
final delay = plan.start - position;
_startTimer?.cancel();
if (delay < const Duration(milliseconds: -350)) return;
final generation = _generation;
_startTimer = Timer(delay.isNegative ? Duration.zero : delay, () {
if (_current(generation)) unawaited(_start(generation));
});
return;
}
if (_preparing) return;
final duration = handler.mediaItem.value?.duration;
if (duration == null || duration < const Duration(seconds: 20)) return;
final key =
'${handler._playRequestGeneration}:${handler._sessionQueueRevision}';
if (_attempt == key) return;
_attempt = key;
unawaited(_prepare(duration));
}
Future<void> _prepare(Duration duration) async {
_preparing = true;
final generation = ++_generation;
_playGeneration = handler._playRequestGeneration;
_queueRevision = handler._sessionQueueRevision;
final index = handler._index;
final nextIndex = handler._shuffle
? handler._pickNextShuffle()
: index + 1 < handler._media.length
? index + 1
: handler._repeatMode == AudioServiceRepeatMode.all
? 0
: -1;
final pins = <String>{};
_preparationPins[generation] = pins;
AudioPlayer? deck;
try {
if (nextIndex < 0 || nextIndex == index || index < 0) return;
final current = handler._media[index];
final next = handler._media[nextIndex];
final currentPath = await handler._resolveSource(current);
if (currentPath == null || !_current(generation)) return;
pins.add(currentPath);
final nextPath = await handler._resolveSource(next);
if (nextPath == null || !_current(generation)) return;
pins.add(nextPath);
deck = AudioPlayer(
playerId:
'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}',
);
handler._listenToPlayer(deck);
await deck.setReleaseMode(ReleaseMode.stop);
await deck.setAudioContext(_musicAudioContext);
await deck.setVolume(0);
if (!_current(generation)) return;
await deck.setSource(DeviceFileSource(nextPath));
if (!_current(generation)) return;
final nextDuration =
await deck.getDuration() ?? next.duration ?? Duration.zero;
final offset = max(
0.0,
duration.inMicroseconds / 1e6 - AutoMixAnalyzer.windowSeconds,
);
final outro = await analyzer.analyze(currentPath, offset: offset);
if (!_current(generation)) return;
final intro = await analyzer.analyze(nextPath);
if (!_current(generation)) return;
final plan = AutoMixPlan.create(
outgoingDuration: duration,
incomingDuration: nextDuration,
outro: outro,
intro: intro,
outroOffset: offset,
);
if (plan == null) return;
await deck.seek(plan.incomingStart);
await deck.setPlaybackRate(plan.rate);
final volume = await handler._normalizationVolumeFor(
nextPath,
cacheKey: next.isContentUri ? next.source : null,
);
if (!_current(generation)) return;
_prepared = deck;
deck = null;
_next = next;
_nextIndex = nextIndex;
_nextPath = nextPath;
_nextDuration = nextDuration;
_incomingVolume = volume;
_mixPins.addAll(pins);
_plan = plan;
_log.d(
'AutoMix prepared (${plan.beatMatched ? 'beat matched' : 'crossfade'}, rate=${plan.rate.toStringAsFixed(3)})',
);
final position = await handler._player.getCurrentPosition();
if (position != null && _current(generation)) onPosition(position);
} catch (error) {
_log.w('AutoMix preparation skipped: $error');
} finally {
if (deck != null) await handler._disposeDeck(deck);
_preparationPins.remove(generation);
if (generation == _generation) _preparing = false;
await handler._cleanupPendingResolvedPaths();
}
}
Future<void> _start(int generation) async {
final incoming = _prepared;
final next = _next;
final plan = _plan;
if (incoming == null ||
next == null ||
plan == null ||
!_current(generation)) {
return;
}
if (_nextIndex >= handler._media.length ||
!identical(handler._media[_nextIndex], next)) {
return;
}
_starting = true;
try {
final position = await handler._player.getCurrentPosition();
if (!_current(generation) || position == null) return;
final delta = position - plan.start;
if (delta < const Duration(milliseconds: -40)) {
_starting = false;
onPosition(position);
return;
}
if (delta > const Duration(milliseconds: 350)) return;
await incoming.resume();
if (!_current(generation)) return;
final outgoing = handler._player;
final outgoingVolume = handler._normalizationVolume;
_outgoing = outgoing;
_prepared = null;
_plan = null;
handler._player = incoming;
handler._index = _nextIndex;
handler._playRequestGeneration++;
handler._recordPlayHistory(_nextIndex);
handler._normalizationVolume = _incomingVolume;
handler._activeResolvedPath = next.isContentUri ? _nextPath : null;
handler._pendingRestorePosition = null;
rate = plan.rate;
handler.mediaItem.add(
next
.toMediaItem(resolvedSource: next.isContentUri ? _nextPath : null)
.copyWith(duration: _nextDuration),
);
handler._broadcastPosition(plan.incomingStart, force: true);
handler._broadcastState(playerState: PlayerState.playing);
unawaited(handler._persistSession(position: plan.incomingStart));
final clock = Stopwatch()..start();
_fadeTimer = Timer.periodic(const Duration(milliseconds: 40), (_) {
if (_writing || generation != _generation) return;
_writing = true;
_fadeWrite = () async {
try {
final levels = autoMixEnvelope(clock.elapsed, plan);
if (_outgoing != null) {
await outgoing.setVolume(outgoingVolume * levels.outgoing);
}
if (generation != _generation) return;
await incoming.setVolume(_incomingVolume * levels.incoming);
if (generation != _generation) return;
if ((levels.rate - rate).abs() > 0.001 || levels.complete) {
await incoming.setPlaybackRate(levels.rate);
rate = levels.rate;
}
if (generation != _generation) return;
if (clock.elapsed >= plan.duration && _outgoing != null) {
_outgoing = null;
await handler._disposeDeck(outgoing);
}
if (levels.complete) {
_fadeTimer?.cancel();
_fadeTimer = null;
rate = 1;
_mixPins.clear();
await handler._cleanupPendingResolvedPaths();
handler._broadcastState();
}
} catch (error) {
_log.w('AutoMix transition ended early: $error');
// Defer cancellation until this volume write has unwound.
scheduleMicrotask(() => unawaited(cancel()));
} finally {
_writing = false;
}
}();
});
} catch (error) {
_log.w('AutoMix start skipped: $error');
} finally {
if (generation == _generation) _starting = false;
}
}
Future<void> cancel() async {
_generation++;
_cancelling++;
analyzer.cancel();
_startTimer?.cancel();
_startTimer = null;
_fadeTimer?.cancel();
_fadeTimer = null;
final prepared = _prepared;
final outgoing = _outgoing;
final restore = outgoing != null || rate != 1;
_prepared = null;
_outgoing = null;
_plan = null;
_next = null;
_nextPath = null;
_nextDuration = null;
_preparing = false;
_starting = false;
_attempt = null;
try {
await _fadeWrite;
if (prepared != null) await handler._disposeDeck(prepared);
if (outgoing != null) await handler._disposeDeck(outgoing);
if (restore && !handler._disposed) {
await handler._player.setPlaybackRate(1);
await handler._player.setVolume(handler._normalizationVolume);
}
rate = 1;
_mixPins.clear();
await handler._cleanupPendingResolvedPaths();
} catch (error) {
_log.w('AutoMix cleanup: $error');
} finally {
_cancelling--;
}
}
Future<void> dispose() async {
await cancel();
analyzer.dispose();
}
}
+106 -25
View File
@@ -12,12 +12,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/playback_normalization.dart';
import 'package:spotiflac_android/services/automix_analysis.dart';
import 'package:spotiflac_android/services/automix_analyzer.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/ios_container_paths.dart';
import 'package:spotiflac_android/utils/playback_artwork.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
part 'music_player_automix.dart';
final _log = AppLogger('MusicPlayer');
String _playbackUnknownTitle = 'Unknown title';
@@ -32,6 +36,7 @@ void updateMusicPlayerStrings({
}
bool _playbackNormalizationEnabled = false;
bool _autoMixEnabled = false;
MusicPlayerHandler? _activeMusicPlayerHandler;
/// Enables/disables ReplayGain volume normalization and re-applies it to the
@@ -42,6 +47,13 @@ void setPlaybackNormalizationEnabled(bool enabled) {
_activeMusicPlayerHandler?.reapplyNormalization();
}
void setAutoMixEnabled(bool enabled) {
if (_autoMixEnabled == enabled) return;
_autoMixEnabled = enabled;
final handler = _activeMusicPlayerHandler;
if (handler != null) unawaited(handler._autoMix.cancel());
}
/// Refreshes gain tags after a successful file update, including SAF copies.
void refreshPlaybackNormalization(String source) {
final handler = _activeMusicPlayerHandler;
@@ -289,7 +301,11 @@ Duration normalizedPlaybackResumePosition(
class MusicPlayerHandler extends BaseAudioHandler
with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer(playerId: 'music-player');
AudioPlayer _player = AudioPlayer(playerId: 'music-player');
late final _MusicAutoMix _autoMix;
double _normalizationVolume = 1;
final _playerSubscriptions =
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
AudioSession? _audioSession;
final List<PlayableMedia> _media = [];
final List<MediaItem> _queueItems = [];
@@ -339,7 +355,8 @@ class MusicPlayerHandler extends BaseAudioHandler
DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt;
MusicPlayerHandler() {
MusicPlayerHandler({AutoMixAnalyzer? autoMixAnalyzer}) {
_autoMix = _MusicAutoMix(this, autoMixAnalyzer ?? AutoMixAnalyzer());
_activeMusicPlayerHandler = this;
_init();
}
@@ -351,8 +368,20 @@ class MusicPlayerHandler extends BaseAudioHandler
unawaited(_player.setAudioContext(_musicAudioContext));
unawaited(_configureAudioSession());
_subscriptions.addAll([
_player.onPlayerStateChanged.listen((state) {
_listenToPlayer(_player);
}
void _listenToPlayer(AudioPlayer player) {
// Position updates must continue with the display asleep. UI progress is
// interpolated between updates; querying native playback every frame is
// unnecessary and would stop preparing AutoMix when there are no frames.
player.positionUpdater = TimerPositionUpdater(
interval: const Duration(milliseconds: 200),
getPosition: player.getCurrentPosition,
);
_playerSubscriptions[player] = [
player.onPlayerStateChanged.listen((state) {
if (!identical(player, _player)) return;
if (_switchingGeneration != 0 &&
(state == PlayerState.stopped ||
state == PlayerState.completed ||
@@ -367,17 +396,29 @@ class MusicPlayerHandler extends BaseAudioHandler
}
_broadcastState(playerState: state);
}),
_player.onPositionChanged.listen(_handlePositionChanged),
_player.onDurationChanged.listen((duration) {
player.onPositionChanged.listen((position) {
if (identical(player, _player)) _handlePositionChanged(position);
}),
player.onDurationChanged.listen((duration) {
if (!identical(player, _player)) return;
final current = mediaItem.value;
if (current != null && duration > Duration.zero) {
mediaItem.add(current.copyWith(duration: duration));
}
}),
_player.onPlayerComplete.listen((_) {
unawaited(_handlePlayerComplete());
player.onPlayerComplete.listen((_) {
if (identical(player, _player)) unawaited(_handlePlayerComplete());
}),
]);
];
}
Future<void> _disposeDeck(AudioPlayer player) async {
final subscriptions = _playerSubscriptions.remove(player);
if (subscriptions == null) return;
for (final subscription in subscriptions) {
await subscription.cancel();
}
await player.dispose();
}
/// Configures the OS audio session and reacts to interruptions (e.g. another
@@ -445,6 +486,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_log.i('Pausing internal player because of $reason');
_playRequestGeneration++;
_switchingGeneration = 0;
await _autoMix.cancel();
try {
await _player.pause();
} catch (e) {
@@ -534,6 +576,8 @@ class MusicPlayerHandler extends BaseAudioHandler
? AudioProcessingState.loading
: _mapProcessingState(state),
playing: playing,
queueIndex: _index >= 0 ? _index : null,
speed: _autoMix.rate,
shuffleMode: _shuffle
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
@@ -555,11 +599,17 @@ class MusicPlayerHandler extends BaseAudioHandler
}
_lastPositionBroadcastAt = now;
_lastBroadcastPosition = position;
playbackState.add(playbackState.value.copyWith(updatePosition: position));
playbackState.add(
playbackState.value.copyWith(
updatePosition: position,
speed: _autoMix.rate,
),
);
}
void _handlePositionChanged(Duration position) {
_broadcastPosition(position);
_autoMix.onPosition(position);
if (_restoringSession ||
_player.state != PlayerState.playing ||
_media.isEmpty ||
@@ -622,6 +672,13 @@ class MusicPlayerHandler extends BaseAudioHandler
final normalizationGeneration = ++_normalizationGeneration;
if (index < 0 || index >= _media.length) return;
unawaited(() async {
await _autoMix.cancel();
if (generation != _playRequestGeneration ||
index != _index ||
index >= _media.length ||
_disposed) {
return;
}
final media = _media[index];
var resolved = media.isContentUri
? _resolvedPathCache[media.source]
@@ -652,6 +709,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
try {
await _player.setVolume(volume);
_normalizationVolume = volume;
} catch (e) {
_log.w('Failed to apply normalization volume: $e');
}
@@ -718,7 +776,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
Future<void> _discardResolvedPath(String path) async {
if (path == _activeResolvedPath) {
if (path == _activeResolvedPath || _autoMix.pinnedPaths.contains(path)) {
_pendingResolvedPathDeletes.add(path);
return;
}
@@ -732,7 +790,11 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _cleanupPendingResolvedPaths() async {
final deletable = _pendingResolvedPathDeletes
.where((path) => path != _activeResolvedPath)
.where(
(path) =>
path != _activeResolvedPath &&
!_autoMix.pinnedPaths.contains(path),
)
.toList(growable: false);
for (final path in deletable) {
_pendingResolvedPathDeletes.remove(path);
@@ -1022,23 +1084,15 @@ class MusicPlayerHandler extends BaseAudioHandler
_pendingRestorePosition = null;
}
final generation = ++_playRequestGeneration;
_sourceReady = false;
await _autoMix.cancel();
if (generation != _playRequestGeneration || _disposed) return;
_index = index;
_pausedByInterruption = false;
_interruptionActive = false;
_userPaused = false;
if (recordHistory) {
_playHistory.add(index);
if (_playHistory.length > 200) _playHistory.removeAt(0);
_recent.add(index);
final maxRecent = ((_media.length - 1) * 0.6).floor().clamp(
1,
_media.length > 1 ? _media.length - 1 : 1,
);
while (_recent.length > maxRecent) {
_recent.removeAt(0);
}
}
if (recordHistory) _recordPlayHistory(index);
final media = _media[index];
final effectiveStartPosition = normalizedPlaybackResumePosition(
@@ -1112,6 +1166,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_sourceReady = false;
if (!_isCurrentPlayRequest(generation, media)) return;
await _player.setVolume(normalizationVolume);
_normalizationVolume = normalizationVolume;
if (!_isCurrentPlayRequest(generation, media)) return;
final startAt = effectiveStartPosition > Duration.zero
? effectiveStartPosition
@@ -1146,6 +1201,7 @@ class MusicPlayerHandler extends BaseAudioHandler
cacheKey: media.source,
);
await _player.setVolume(normalizationVolume);
_normalizationVolume = normalizationVolume;
await _player.play(DeviceFileSource(fallback), position: startAt);
}
if (!_isCurrentPlayRequest(generation, media)) return;
@@ -1219,6 +1275,19 @@ class MusicPlayerHandler extends BaseAudioHandler
return pool[_random.nextInt(pool.length)];
}
void _recordPlayHistory(int index) {
_playHistory.add(index);
if (_playHistory.length > 200) _playHistory.removeAt(0);
_recent.add(index);
final maxRecent = ((_media.length - 1) * 0.6).floor().clamp(
1,
_media.length > 1 ? _media.length - 1 : 1,
);
while (_recent.length > maxRecent) {
_recent.removeAt(0);
}
}
Future<void> _onComplete() async {
if (_repeatMode == AudioServiceRepeatMode.one &&
_index >= 0 &&
@@ -1323,6 +1392,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_switchingGeneration = 0;
_userPaused = true;
_pausedByInterruption = false;
await _autoMix.cancel();
await _player.pause();
_broadcastState(playerState: PlayerState.paused);
await _persistSession(position: await _currentPositionForPersist());
@@ -1330,12 +1400,14 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> seek(Duration position) async {
await _autoMix.cancel();
await _player.seek(position);
_broadcastPosition(position, force: true);
}
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
await _autoMix.cancel();
_shuffle = shuffleMode == AudioServiceShuffleMode.all;
_broadcastState();
if (_media.isNotEmpty && _index >= 0) {
@@ -1345,6 +1417,7 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
await _autoMix.cancel();
// Group repeat has no meaning for a flat queue; treat it as all.
_repeatMode = repeatMode == AudioServiceRepeatMode.group
? AudioServiceRepeatMode.all
@@ -1361,6 +1434,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_playRequestGeneration++;
_switchingGeneration = 0;
_userPaused = true;
await _autoMix.cancel();
await _player.stop();
_sourceReady = false;
_activeResolvedPath = null;
@@ -1393,6 +1467,7 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> skipToPrevious() async {
await _autoMix.cancel();
if (playbackState.value.position > const Duration(seconds: 3)) {
await _player.seek(Duration.zero);
_broadcastPosition(Duration.zero, force: true);
@@ -1505,14 +1580,20 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> dispose() async {
_disposed = true;
if (identical(_activeMusicPlayerHandler, this)) {
_activeMusicPlayerHandler = null;
}
cancelSleepTimer();
_playRequestGeneration++;
await _autoMix.dispose();
for (final sub in _subscriptions) {
await sub.cancel();
}
_subscriptions.clear();
_sourceReady = false;
await _player.dispose();
for (final player in _playerSubscriptions.keys.toList()) {
await _disposeDeck(player);
}
_activeResolvedPath = null;
final tempPaths = <String>{
..._resolvedPathCache.values,
+26 -13
View File
@@ -1,9 +1,9 @@
import 'package:flutter/widgets.dart';
enum ShellTab { home, library, repository, settings }
enum ShellTab { home, library, repository, settings, search }
class ShellNavigationService {
static final homeSearchRequests = ValueNotifier<int>(0);
static final searchRequests = ValueNotifier<int>(0);
static final chromeBrightness = ValueNotifier<Brightness?>(null);
static final chromeSurface = ValueNotifier<Color?>(null);
static final _visiblePages = <GlobalKey<NavigatorState>, Route<dynamic>?>{};
@@ -13,7 +13,7 @@ class ShellNavigationService {
({ModalRoute<dynamic> route, Brightness brightness, Color? surface})
>{};
static bool _chromeUpdateScheduled = false;
static int _homeSearchGeneration = 0;
static int _searchGeneration = 0;
static void setChromeBrightness({
required Object owner,
@@ -52,21 +52,24 @@ class ShellNavigationService {
WidgetsBinding.instance.ensureVisualUpdate();
}
static void requestHomeSearch() {
if (!requestTab(ShellTab.home)) return;
final generation = ++_homeSearchGeneration;
static void requestSearch() {
if (!requestTab(ShellTab.search)) return;
final generation = ++_searchGeneration;
final owner = _tabSelectionOwner;
// PageView creates Home lazily. Deliver the request after its navigator
// PageView creates Search lazily. Deliver the request after its navigator
// and focus listener mount, rather than losing the first tap on another tab.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (generation != _homeSearchGeneration ||
if (generation != _searchGeneration ||
!identical(owner, _tabSelectionOwner) ||
_currentTabIndex != 0) {
_currentTabIndex != (_showSearchTab ? (_showRepoTab ? 4 : 3) : 0)) {
return;
}
homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst);
homeSearchRequests.value++;
// Home scrolls to its search field and requests focus after layout.
final navigatorKey = _showSearchTab
? searchTabNavigatorKey
: homeTabNavigatorKey;
navigatorKey.currentState?.popUntil((route) => route.isFirst);
searchRequests.value++;
// Search scrolls to its field and requests focus after layout.
// Scheduling that frame avoids waiting for another tap to wake it up.
WidgetsBinding.instance.ensureVisualUpdate();
});
@@ -79,9 +82,12 @@ class ShellNavigationService {
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> repoTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> searchTabNavigatorKey =
GlobalKey<NavigatorState>();
static int _currentTabIndex = 0;
static bool _showRepoTab = false;
static bool _showSearchTab = false;
static Object? _tabSelectionOwner;
static ValueChanged<ShellTab>? _tabSelectionHandler;
@@ -109,12 +115,16 @@ class ShellNavigationService {
static void syncState({
required int currentTabIndex,
required bool showRepoTab,
bool showSearchTab = false,
}) {
if (_currentTabIndex == currentTabIndex && _showRepoTab == showRepoTab) {
if (_currentTabIndex == currentTabIndex &&
_showRepoTab == showRepoTab &&
_showSearchTab == showSearchTab) {
return;
}
_currentTabIndex = currentTabIndex;
_showRepoTab = showRepoTab;
_showSearchTab = showSearchTab;
_scheduleChromeUpdate();
}
@@ -124,6 +134,9 @@ class ShellNavigationService {
if (_showRepoTab && _currentTabIndex == 2) {
return repoTabNavigatorKey;
}
if (_showSearchTab && _currentTabIndex == (_showRepoTab ? 4 : 3)) {
return searchTabNavigatorKey;
}
return null;
}