mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-07 05:07:59 +02:00
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:
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user