mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
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:
1 parent
31ef7c7c88
commit
5b519070a3
23 files changed
+2074
-298
No files matched your search
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user