feat: reorderable up-next queue + playback stability

- Up Next sheet supports drag-to-reorder via ReorderableListView with
  drag handles; MusicPlayerHandler.moveQueueItem keeps current track active
- Active lyric line centers higher in viewport (0.35) so it sits visually
  centered instead of leaning low
- Audio focus rework: explicit music AudioContext, ignore duck
  interruptions, drop the fragile ignore-complete timer, recover playback
  on transient stop/complete during track switches
This commit is contained in:
zarzet
2026-06-29 06:46:31 +07:00
parent b2074dfd02
commit 1550eedc12
3 changed files with 190 additions and 118 deletions
+4
View File
@@ -105,6 +105,10 @@ class MusicPlayerController {
addToQueue(playableFromLocal(item));
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
void moveQueueItem(int oldIndex, int newIndex) {
_handler?.moveQueueItem(oldIndex, newIndex);
}
}
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
+106 -84
View File
@@ -505,56 +505,46 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
AudioServiceShuffleMode.all;
final textTheme = Theme.of(context).textTheme;
const headerCount = 2;
final emptyCount = queue.isEmpty ? 1 : 0;
return ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 4, 12, 24),
itemCount: headerCount + emptyCount + queue.length,
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(left: 4, right: 4),
child: Row(
children: [
Text(
'Up next',
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 16, 8),
child: Row(
children: [
Text(
'Up next',
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
const Spacer(),
IconButton(
tooltip: shuffleOn
? 'Shuffle on'
: 'Play in order',
isSelected: shuffleOn,
icon: const Icon(Icons.shuffle),
color: shuffleOn ? colorScheme.primary : null,
onPressed: () =>
controller.setShuffle(!shuffleOn),
),
],
),
);
}
if (index == 1) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 8),
child: SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: () => _shuffleLibrary(controller),
icon: const Icon(Icons.shuffle, size: 18),
label: const Text('Shuffle library'),
),
const Spacer(),
IconButton(
tooltip: shuffleOn
? 'Shuffle on'
: 'Play in order',
isSelected: shuffleOn,
icon: const Icon(Icons.shuffle),
color: shuffleOn ? colorScheme.primary : null,
onPressed: () =>
controller.setShuffle(!shuffleOn),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: () => _shuffleLibrary(controller),
icon: const Icon(Icons.shuffle, size: 18),
label: const Text('Shuffle library'),
),
);
}
if (queue.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
),
),
if (queue.isEmpty)
Expanded(
child: Center(
child: Text(
'Queue is empty',
@@ -563,44 +553,76 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
),
),
);
}
final i = index - headerCount;
final item = queue[i];
final isCurrent = current?.id == item.id;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: Icon(
isCurrent ? Icons.equalizer : Icons.music_note,
color: isCurrent
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge?.copyWith(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
color: isCurrent
? colorScheme.primary
: colorScheme.onSurface,
)
else
Expanded(
child: ReorderableListView.builder(
scrollController: scrollController,
padding: const EdgeInsets.fromLTRB(8, 0, 8, 24),
itemCount: queue.length,
onReorderItem: (oldIndex, newIndex) {
controller.moveQueueItem(oldIndex, newIndex);
},
proxyDecorator: (child, index, animation) {
return Material(
elevation: 4,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: child,
);
},
itemBuilder: (context, i) {
final item = queue[i];
final isCurrent = current?.id == item.id;
return ListTile(
key: ValueKey('${item.id}_$i'),
contentPadding:
const EdgeInsets.only(left: 16, right: 4),
leading: Icon(
isCurrent
? Icons.equalizer
: Icons.music_note,
color: isCurrent
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge?.copyWith(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
color: isCurrent
? colorScheme.primary
: colorScheme.onSurface,
),
),
subtitle: Text(
item.artist ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
trailing: ReorderableDragStartListener(
index: i,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
),
),
),
onTap: () => controller.jumpTo(i),
);
},
),
),
subtitle: Text(
item.artist ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => controller.jumpTo(i),
);
},
],
);
},
);
@@ -676,7 +698,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
final position = _scroll.position;
final target =
(index * _estimatedLyricExtent) -
(position.viewportDimension * 0.42) +
(position.viewportDimension * 0.35) +
24;
final clamped = target.clamp(
position.minScrollExtent,
+80 -34
View File
@@ -10,6 +10,15 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MusicPlayer');
final AudioContext _musicAudioContext = AudioContext(
android: const AudioContextAndroid(
audioFocus: AndroidAudioFocus.none,
contentType: AndroidContentType.music,
usageType: AndroidUsageType.media,
stayAwake: true,
),
);
class PlayableMedia {
final String id;
final String source;
@@ -72,7 +81,6 @@ class MusicPlayerHandler extends BaseAudioHandler
bool _interruptionActive = false;
bool _userPaused = false;
bool _switchingTrack = false;
DateTime _ignoreCompleteUntil = DateTime.fromMillisecondsSinceEpoch(0);
Duration _lastBroadcastPosition = Duration.zero;
DateTime? _lastPositionBroadcastAt;
static const Duration _positionBroadcastInterval = Duration(
@@ -88,10 +96,24 @@ class MusicPlayerHandler extends BaseAudioHandler
if (_initialized) return;
_initialized = true;
_player.setReleaseMode(ReleaseMode.stop);
unawaited(_player.setAudioContext(_musicAudioContext));
unawaited(_configureAudioSession());
_subscriptions.addAll([
_player.onPlayerStateChanged.listen((state) {
if (_switchingTrack &&
(state == PlayerState.stopped ||
state == PlayerState.completed ||
state == PlayerState.disposed)) {
_log.d('Ignoring transient $state event while switching tracks');
return;
}
if (state == PlayerState.completed && _shouldIgnoreComplete) {
if (_userPaused || _interruptionActive) {
_broadcastState(playerState: PlayerState.paused);
}
return;
}
_broadcastState(playerState: state);
}),
_player.onPositionChanged.listen(_broadcastPosition),
@@ -120,14 +142,21 @@ class MusicPlayerHandler extends BaseAudioHandler
_subscriptions.add(
session.interruptionEventStream.listen((event) {
if (event.begin) {
if (event.type == AudioInterruptionType.duck) {
return;
}
// Another app took focus or a transient interruption began.
_interruptionActive = true;
_pausedByInterruption =
_player.state == PlayerState.playing ||
playbackState.value.playing;
_ignoreCompleteFor(const Duration(seconds: 3));
unawaited(_pauseForFocusLoss());
} else {
if (event.type == AudioInterruptionType.duck) {
return;
}
// Focus returned; resume only if we paused due to a transient
// (duck/pause) interruption.
_interruptionActive = false;
@@ -145,7 +174,6 @@ class MusicPlayerHandler extends BaseAudioHandler
_subscriptions.add(
session.becomingNoisyEventStream.listen((_) {
// Headphones unplugged / output route lost.
_ignoreCompleteFor(const Duration(seconds: 3));
unawaited(_pauseForFocusLoss());
}),
);
@@ -154,18 +182,8 @@ class MusicPlayerHandler extends BaseAudioHandler
}
}
void _ignoreCompleteFor(Duration duration) {
final until = DateTime.now().add(duration);
if (until.isAfter(_ignoreCompleteUntil)) {
_ignoreCompleteUntil = until;
}
}
bool get _shouldIgnoreComplete =>
_switchingTrack ||
_interruptionActive ||
_userPaused ||
DateTime.now().isBefore(_ignoreCompleteUntil);
_switchingTrack || _interruptionActive || _userPaused;
Future<void> _pauseForFocusLoss() async {
try {
@@ -180,7 +198,12 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _activateAudioSession() async {
try {
await _audioSession?.setActive(true);
final session = _audioSession ?? await AudioSession.instance;
_audioSession = session;
final granted = await session.setActive(true);
if (!granted) {
_log.w('Audio focus request was not granted');
}
} catch (e) {
_log.w('Failed to activate audio session: $e');
}
@@ -332,6 +355,36 @@ class MusicPlayerHandler extends BaseAudioHandler
_broadcastState();
}
void moveQueueItem(int oldIndex, int newIndex) {
if (oldIndex < 0 ||
oldIndex >= _media.length ||
newIndex < 0 ||
newIndex >= _media.length ||
oldIndex == newIndex) {
return;
}
final media = _media.removeAt(oldIndex);
final qi = _queueItems.removeAt(oldIndex);
_media.insert(newIndex, media);
_queueItems.insert(newIndex, qi);
if (_index == oldIndex) {
_index = newIndex;
} else {
if (oldIndex < _index && newIndex >= _index) {
_index--;
} else if (oldIndex > _index && newIndex <= _index) {
_index++;
}
}
_recent.clear();
_playHistory.clear();
queue.add(List<MediaItem>.unmodifiable(_queueItems));
_broadcastState();
}
Future<void> _playIndex(int index, {bool recordHistory = true}) async {
if (index < 0 || index >= _media.length) return;
_index = index;
@@ -373,8 +426,8 @@ class MusicPlayerHandler extends BaseAudioHandler
} catch (_) {}
_switchingTrack = true;
_ignoreCompleteFor(const Duration(seconds: 3));
try {
await _player.setAudioContext(_musicAudioContext);
await _activateAudioSession();
await _player.stop();
await _player.play(DeviceFileSource(resolved));
@@ -453,22 +506,9 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _handlePlayerComplete() async {
if (_shouldIgnoreComplete) {
_log.d('Ignoring non-terminal player complete event');
return;
}
final duration = mediaItem.value?.duration ?? await _player.getDuration();
final position =
await _player.getCurrentPosition() ?? playbackState.value.position;
if (duration != null &&
duration > Duration.zero &&
position < duration - const Duration(milliseconds: 1500)) {
_log.d('Ignoring early player complete at $position / $duration');
final state = _player.state;
_broadcastState(
playerState: state == PlayerState.playing
? PlayerState.playing
: PlayerState.paused,
);
if (_userPaused || _interruptionActive) {
_broadcastState(playerState: PlayerState.paused);
}
return;
}
@@ -480,6 +520,13 @@ class MusicPlayerHandler extends BaseAudioHandler
_pausedByInterruption = false;
_interruptionActive = false;
_userPaused = false;
if ((_player.state == PlayerState.stopped ||
_player.state == PlayerState.completed) &&
_index >= 0 &&
_index < _media.length) {
await _playIndex(_index, recordHistory: false);
return;
}
await _activateAudioSession();
await _player.resume();
_broadcastState(playerState: PlayerState.playing);
@@ -489,7 +536,6 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> pause() async {
_userPaused = true;
_pausedByInterruption = false;
_ignoreCompleteFor(const Duration(seconds: 3));
await _player.pause();
_broadcastState(playerState: PlayerState.paused);
}
@@ -508,7 +554,7 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> stop() async {
_ignoreCompleteFor(const Duration(seconds: 3));
_userPaused = true;
await _player.stop();
_index = -1;
_pausedByInterruption = false;