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 = >{}; final _mixPins = {}; 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 _fadeWrite = Future.value(); bool _writing = false; Set 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 _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 = {}; _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 _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 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 dispose() async { await cancel(); analyzer.dispose(); } }