diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 8f056654..4e21299e 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -105,6 +105,10 @@ class MusicPlayerController { addToQueue(playableFromLocal(item)); Future jumpTo(int index) async => _handler?.skipToQueueItem(index); + + void moveQueueItem(int oldIndex, int newIndex) { + _handler?.moveQueueItem(oldIndex, newIndex); + } } final musicPlayerControllerProvider = Provider( diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index cb899856..83af7db8 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -505,56 +505,46 @@ class _NowPlayingScreenState extends ConsumerState { 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 { ), ), ), - ); - } - - 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, diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index eefa5f8b..141e910f 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -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 _pauseForFocusLoss() async { try { @@ -180,7 +198,12 @@ class MusicPlayerHandler extends BaseAudioHandler Future _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.unmodifiable(_queueItems)); + _broadcastState(); + } + Future _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 _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 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 stop() async { - _ignoreCompleteFor(const Duration(seconds: 3)); + _userPaused = true; await _player.stop(); _index = -1; _pausedByInterruption = false;