feat: add new playback experience and media integration

This commit is contained in:
zarzet
2026-06-28 22:22:33 +07:00
parent bede5ae8d7
commit 3a2481e8b2
15 changed files with 2636 additions and 7 deletions
+2
View File
@@ -62,6 +62,8 @@ android {
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
+21
View File
@@ -114,6 +114,23 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -130,6 +147,10 @@
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -1,6 +1,7 @@
package com.zarz.spotiflac
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -17,6 +18,7 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.ryanheise.audioservice.AudioServicePlugin
import gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -36,6 +38,10 @@ import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
override fun provideFlutterEngine(context: Context): FlutterEngine {
return AudioServicePlugin.getFlutterEngine(context)
}
private val CHANNEL = "com.zarz.spotiflac/backend"
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/download_progress_stream"
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+5
View File
@@ -94,6 +94,8 @@ class AppSettings {
deduplicateDownloads;
final bool saveDownloadHistory;
final String playerMode;
const AppSettings({
this.defaultService = '',
this.audioQuality = 'LOSSLESS',
@@ -155,6 +157,7 @@ class AppSettings {
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
this.saveDownloadHistory = true,
this.playerMode = 'external',
});
AppSettings copyWith({
@@ -221,6 +224,7 @@ class AppSettings {
String? lastSeenVersion,
bool? deduplicateDownloads,
bool? saveDownloadHistory,
String? playerMode,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -305,6 +309,7 @@ class AppSettings {
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
playerMode: playerMode ?? this.playerMode,
);
}
+2
View File
@@ -83,6 +83,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
playerMode: json['playerMode'] as String? ?? 'external',
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -149,4 +150,5 @@ Map<String, dynamic> _$AppSettingsToJson(
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
'playerMode': instance.playerMode,
};
+149
View File
@@ -0,0 +1,149 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
return musicPlayerMediaItemEvents();
});
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
return musicPlayerPlaybackStateEvents();
});
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
return musicPlayerQueueEvents();
});
class MusicPlayerController {
const MusicPlayerController();
MusicPlayerHandler? get _handler => musicPlayerHandler;
bool get isAvailable => _handler != null;
Future<MusicPlayerHandler?> ensureInitialized() async {
try {
return await initMusicPlayer();
} catch (_) {
return null;
}
}
Future<void> playAll(
List<PlayableMedia> items, {
int initialIndex = 0,
}) async {
final handler = await ensureInitialized();
await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
}
Future<void> playSingle(PlayableMedia item) => playAll([item]);
Future<void> playHistory(
List<DownloadHistoryItem> items, {
int initialIndex = 0,
}) async {
final media = items
.where((i) => i.filePath.trim().isNotEmpty)
.map(playableFromHistory)
.toList();
if (media.isEmpty) return;
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
}
Future<void> playLocal(
List<LocalLibraryItem> items, {
int initialIndex = 0,
}) async {
final media = items
.where((i) => i.filePath.trim().isNotEmpty)
.map(playableFromLocal)
.toList();
if (media.isEmpty) return;
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
}
Future<void> play() async => _handler?.play();
Future<void> pause() async => _handler?.pause();
Future<void> stop() async => _handler?.stop();
Future<void> seek(Duration position) async => _handler?.seek(position);
Future<void> next() async => _handler?.skipToNext();
Future<void> previous() async => _handler?.skipToPrevious();
Future<void> togglePlayPause(bool isPlaying) async {
if (isPlaying) {
await pause();
} else {
await play();
}
}
Future<void> setShuffle(bool enabled) async {
await _handler?.setShuffleMode(
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
);
}
Future<void> playNext(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item, playNext: true);
Future<void> addToQueue(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item);
Future<void> playNextHistory(DownloadHistoryItem item) async =>
playNext(playableFromHistory(item));
Future<void> addToQueueHistory(DownloadHistoryItem item) async =>
addToQueue(playableFromHistory(item));
Future<void> playNextLocal(LocalLibraryItem item) async =>
playNext(playableFromLocal(item));
Future<void> addToQueueLocal(LocalLibraryItem item) async =>
addToQueue(playableFromLocal(item));
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
}
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
(ref) => const MusicPlayerController(),
);
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
return PlayableMedia(
id: item.id,
source: item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
artUri: (item.coverUrl != null && item.coverUrl!.trim().isNotEmpty)
? item.coverUrl
: null,
duration: (item.duration != null && item.duration! > 0)
? Duration(seconds: item.duration!)
: null,
);
}
PlayableMedia playableFromLocal(LocalLibraryItem item) {
String? art;
final cover = item.coverPath;
if (cover != null && cover.trim().isNotEmpty) {
art = cover.startsWith('http') || cover.startsWith('content://')
? cover
: Uri.file(cover).toString();
}
return PlayableMedia(
id: item.id,
source: item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
artUri: art,
duration: (item.duration != null && item.duration! > 0)
? Duration(seconds: item.duration!)
: null,
);
}
+6
View File
@@ -645,6 +645,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
void setPlayerMode(String mode) {
final normalized = mode == 'internal' ? 'internal' : 'external';
state = state.copyWith(playerMode: normalized);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
File diff suppressed because it is too large Load Diff
@@ -452,6 +452,73 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Playback'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.open_in_new,
title: 'External player',
subtitle:
'Open tracks in another music app (recommended for best quality)',
trailing: settings.playerMode == 'external'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () =>
ref.read(settingsProvider.notifier).setPlayerMode('external'),
),
SettingsItem(
icon: Icons.play_circle_outline,
title: 'Built-in player',
subtitle:
'Play inside SpotiFLAC with a notification and synced lyrics',
trailing: settings.playerMode == 'internal'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () =>
ref.read(settingsProvider.notifier).setPlayerMode('internal'),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'The built-in player is intentionally simple (local files '
'only, basic playback). For higher quality, gapless audio, '
'equalizer and format support, a dedicated external player '
'is more capable and recommended.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
),
if (settings.localLibraryEnabled) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
+729
View File
@@ -0,0 +1,729 @@
import 'dart:async';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart'
show AudioSession, AudioSessionConfiguration, AudioInterruptionType;
import 'package:audioplayers/audioplayers.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MusicPlayer');
class PlayableMedia {
final String id;
final String source;
final String title;
final String artist;
final String album;
final String? artUri;
final Duration? duration;
const PlayableMedia({
required this.id,
required this.source,
required this.title,
required this.artist,
this.album = '',
this.artUri,
this.duration,
});
bool get isContentUri => source.startsWith('content://');
MediaItem toMediaItem({String? resolvedSource}) {
return MediaItem(
id: id,
title: title.isEmpty ? 'Unknown title' : title,
artist: artist.isEmpty ? 'Unknown artist' : artist,
album: album.isEmpty ? null : album,
duration: duration,
artUri: (artUri != null && artUri!.isNotEmpty)
? Uri.tryParse(artUri!)
: null,
extras: {
'source': source,
if (resolvedSource != null && resolvedSource.isNotEmpty)
'resolvedSource': resolvedSource,
},
);
}
}
class MusicPlayerHandler extends BaseAudioHandler
with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer(playerId: 'music-player');
AudioSession? _audioSession;
final List<PlayableMedia> _media = [];
final List<MediaItem> _queueItems = [];
final Map<String, String> _resolvedPathCache = {};
final List<String> _resolvedPathOrder = [];
final List<StreamSubscription<dynamic>> _subscriptions = [];
int _index = -1;
bool _initialized = false;
bool _shuffle = false;
final Random _random = Random();
final List<int> _recent = [];
final List<int> _playHistory = [];
// True when playback was paused because another app took audio focus.
bool _pausedByInterruption = false;
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(
milliseconds: 500,
);
static const int _maxResolvedPathCacheEntries = 64;
MusicPlayerHandler() {
_init();
}
void _init() {
if (_initialized) return;
_initialized = true;
_player.setReleaseMode(ReleaseMode.stop);
unawaited(_configureAudioSession());
_subscriptions.addAll([
_player.onPlayerStateChanged.listen((state) {
_broadcastState(playerState: state);
}),
_player.onPositionChanged.listen(_broadcastPosition),
_player.onDurationChanged.listen((duration) {
final current = mediaItem.value;
if (current != null && duration > Duration.zero) {
mediaItem.add(current.copyWith(duration: duration));
}
}),
_player.onPlayerComplete.listen((_) {
unawaited(_handlePlayerComplete());
}),
]);
}
/// Configures the OS audio session and reacts to interruptions (e.g. another
/// app like PowerAmp taking audio focus, or headphones unplugged) so playback
/// pauses and the UI/notification reflect the real state instead of staying
/// stuck on "playing".
Future<void> _configureAudioSession() async {
try {
final session = await AudioSession.instance;
_audioSession = session;
await session.configure(const AudioSessionConfiguration.music());
_subscriptions.add(
session.interruptionEventStream.listen((event) {
if (event.begin) {
// 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 {
// Focus returned; resume only if we paused due to a transient
// (duck/pause) interruption.
_interruptionActive = false;
if (_pausedByInterruption &&
event.type == AudioInterruptionType.pause) {
_pausedByInterruption = false;
unawaited(play());
} else {
_pausedByInterruption = false;
}
}
}),
);
_subscriptions.add(
session.becomingNoisyEventStream.listen((_) {
// Headphones unplugged / output route lost.
_ignoreCompleteFor(const Duration(seconds: 3));
unawaited(_pauseForFocusLoss());
}),
);
} catch (e) {
_log.w('Failed to configure audio session: $e');
}
}
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);
Future<void> _pauseForFocusLoss() async {
try {
await _player.pause();
} catch (e) {
_log.w('Failed to pause after audio focus loss: $e');
}
// Force the UI/notification to reflect the pause even if the engine does
// not emit a state-change event on focus loss.
_broadcastState(playerState: PlayerState.paused);
}
Future<void> _activateAudioSession() async {
try {
await _audioSession?.setActive(true);
} catch (e) {
_log.w('Failed to activate audio session: $e');
}
}
AudioProcessingState _mapProcessingState(PlayerState state) {
switch (state) {
case PlayerState.playing:
case PlayerState.paused:
return AudioProcessingState.ready;
case PlayerState.completed:
return AudioProcessingState.completed;
case PlayerState.stopped:
case PlayerState.disposed:
return AudioProcessingState.idle;
}
}
void _broadcastState({PlayerState? playerState, bool? loading}) {
final state = playerState ?? _player.state;
final playing = state == PlayerState.playing;
playbackState.add(
playbackState.value.copyWith(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: const [0, 1, 2],
processingState: (loading == true)
? AudioProcessingState.loading
: _mapProcessingState(state),
playing: playing,
shuffleMode: _shuffle
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
),
);
}
void _broadcastPosition(Duration position, {bool force = false}) {
final now = DateTime.now();
final lastAt = _lastPositionBroadcastAt;
final elapsed = lastAt == null ? null : now.difference(lastAt);
final moved = (position - _lastBroadcastPosition).abs();
if (!force &&
elapsed != null &&
elapsed < _positionBroadcastInterval &&
moved < _positionBroadcastInterval) {
return;
}
_lastPositionBroadcastAt = now;
_lastBroadcastPosition = position;
playbackState.add(playbackState.value.copyWith(updatePosition: position));
}
Future<String?> _resolveSource(PlayableMedia media) async {
if (!media.isContentUri) return media.source;
final cached = _resolvedPathCache[media.source];
if (cached != null) return cached;
try {
final tempPath = await PlatformBridge.copyContentUriToTemp(media.source);
if (tempPath != null && tempPath.isNotEmpty) {
_resolvedPathCache[media.source] = tempPath;
_resolvedPathOrder.remove(media.source);
_resolvedPathOrder.add(media.source);
while (_resolvedPathOrder.length > _maxResolvedPathCacheEntries) {
final evicted = _resolvedPathOrder.removeAt(0);
_resolvedPathCache.remove(evicted);
}
return tempPath;
}
} catch (e) {
_log.e('Failed to resolve content URI for playback: $e');
}
return null;
}
Future<void> setQueueAndPlay(
List<PlayableMedia> items, {
int initialIndex = 0,
}) async {
if (items.isEmpty) return;
_media
..clear()
..addAll(items);
_queueItems
..clear()
..addAll(items.map((m) => m.toMediaItem()));
_recent.clear();
_playHistory.clear();
queue.add(List<MediaItem>.unmodifiable(_queueItems));
await _playIndex(initialIndex.clamp(0, items.length - 1));
}
Future<void> enqueue(PlayableMedia item, {bool playNext = false}) async {
if (_media.isEmpty || _index < 0) {
await setQueueAndPlay([item]);
return;
}
final insertAt = playNext
? (_index + 1).clamp(0, _media.length)
: _media.length;
_media.insert(insertAt, item);
_queueItems.insert(insertAt, item.toMediaItem());
for (var i = 0; i < _recent.length; i++) {
if (_recent[i] >= insertAt) _recent[i]++;
}
for (var i = 0; i < _playHistory.length; i++) {
if (_playHistory[i] >= insertAt) _playHistory[i]++;
}
queue.add(List<MediaItem>.unmodifiable(_queueItems));
_broadcastState();
}
Future<void> enqueueAll(
List<PlayableMedia> items, {
bool playNext = false,
}) async {
if (items.isEmpty) return;
if (_media.isEmpty || _index < 0) {
await setQueueAndPlay(items);
return;
}
var at = playNext ? (_index + 1).clamp(0, _media.length) : _media.length;
for (final item in items) {
_media.insert(at, item);
_queueItems.insert(at, item.toMediaItem());
for (var i = 0; i < _recent.length; i++) {
if (_recent[i] >= at) _recent[i]++;
}
for (var i = 0; i < _playHistory.length; i++) {
if (_playHistory[i] >= at) _playHistory[i]++;
}
at++;
}
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;
_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);
}
}
final media = _media[index];
mediaItem.add(media.toMediaItem());
_lastBroadcastPosition = Duration.zero;
_lastPositionBroadcastAt = null;
// Claim the playing state up front (while the app is still in the
// foreground window) so audio_service can start its foreground service
// before the async source resolve below.
_broadcastState(playerState: PlayerState.playing, loading: true);
final resolved = await _resolveSource(media);
if (resolved == null) {
_log.e('No playable source for ${media.title}');
_broadcastState(playerState: PlayerState.stopped);
return;
}
try {
await musicPlayerExclusiveAudioHook?.call();
} catch (_) {}
_switchingTrack = true;
_ignoreCompleteFor(const Duration(seconds: 3));
try {
await _activateAudioSession();
await _player.stop();
await _player.play(DeviceFileSource(resolved));
mediaItem.add(media.toMediaItem(resolvedSource: resolved));
_broadcastPosition(Duration.zero, force: true);
_broadcastState(playerState: PlayerState.playing);
_log.i('Playing: ${media.title}');
// Some files do not emit onDurationChanged reliably (stuck at 0:00);
// poll the engine for the real duration as a fallback.
unawaited(_ensureDurationKnown(index));
} catch (e) {
_log.e('Playback failed for ${media.title}: $e');
_broadcastState(playerState: PlayerState.stopped);
} finally {
_switchingTrack = false;
}
}
/// Resolves the real track duration when the initial metadata had none and
/// the duration-changed event did not fire, so the seek bar and total time
/// do not get stuck at 0:00.
Future<void> _ensureDurationKnown(int index) async {
for (var attempt = 0; attempt < 15; attempt++) {
if (_index != index) return; // track changed; stop polling
final current = mediaItem.value;
final existing = current?.duration;
if (existing != null && existing > Duration.zero) return;
try {
final d = await _player.getDuration();
if (_index != index) return;
if (d != null && d > Duration.zero) {
final item = mediaItem.value;
if (item != null) {
mediaItem.add(item.copyWith(duration: d));
}
return;
}
} catch (_) {
// ignore and retry
}
await Future<void>.delayed(const Duration(milliseconds: 300));
}
}
int _pickNextShuffle() {
if (_media.length <= 1) return _index;
final pool = <int>[];
for (var i = 0; i < _media.length; i++) {
if (i != _index && !_recent.contains(i)) pool.add(i);
}
if (pool.isEmpty) {
for (var i = 0; i < _media.length; i++) {
if (i != _index) pool.add(i);
}
}
return pool[_random.nextInt(pool.length)];
}
Future<void> _onComplete() async {
if (_shuffle) {
if (_media.length > 1) {
await _playIndex(_pickNextShuffle());
} else {
_broadcastState(playerState: PlayerState.completed);
}
return;
}
if (_index >= 0 && _index < _media.length - 1) {
await _playIndex(_index + 1);
} else {
_broadcastState(playerState: PlayerState.completed);
}
}
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,
);
return;
}
await _onComplete();
}
@override
Future<void> play() async {
_pausedByInterruption = false;
_interruptionActive = false;
_userPaused = false;
await _activateAudioSession();
await _player.resume();
_broadcastState(playerState: PlayerState.playing);
}
@override
Future<void> pause() async {
_userPaused = true;
_pausedByInterruption = false;
_ignoreCompleteFor(const Duration(seconds: 3));
await _player.pause();
_broadcastState(playerState: PlayerState.paused);
}
@override
Future<void> seek(Duration position) async {
await _player.seek(position);
_broadcastPosition(position, force: true);
}
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
_shuffle = shuffleMode == AudioServiceShuffleMode.all;
_broadcastState();
}
@override
Future<void> stop() async {
_ignoreCompleteFor(const Duration(seconds: 3));
await _player.stop();
_index = -1;
_pausedByInterruption = false;
_interruptionActive = false;
_userPaused = false;
_recent.clear();
_playHistory.clear();
_broadcastState(playerState: PlayerState.stopped);
await super.stop();
}
@override
Future<void> skipToNext() async {
if (_shuffle) {
if (_media.length > 1) await _playIndex(_pickNextShuffle());
return;
}
if (_index < _media.length - 1) await _playIndex(_index + 1);
}
@override
Future<void> skipToPrevious() async {
if (playbackState.value.position > const Duration(seconds: 3)) {
await _player.seek(Duration.zero);
_broadcastPosition(Duration.zero, force: true);
return;
}
if (_shuffle) {
if (_playHistory.length >= 2) {
_playHistory.removeLast();
final prev = _playHistory.last;
await _playIndex(prev, recordHistory: false);
} else {
await _player.seek(Duration.zero);
_broadcastPosition(Duration.zero, force: true);
}
return;
}
if (_index > 0) await _playIndex(_index - 1);
}
@override
Future<void> skipToQueueItem(int index) => _playIndex(index);
@override
Future<List<MediaItem>> getChildren(
String parentMediaId, [
Map<String, dynamic>? options,
]) async {
if (parentMediaId == AudioService.browsableRootId ||
parentMediaId == AudioService.recentRootId) {
return List<MediaItem>.unmodifiable(_queueItems);
}
return const [];
}
@override
Future<MediaItem?> getMediaItem(String mediaId) async {
final index = _media.indexWhere((m) => m.id == mediaId);
if (index < 0) return null;
return _queueItems[index];
}
@override
Future<void> playFromMediaId(
String mediaId, [
Map<String, dynamic>? extras,
]) async {
final index = _media.indexWhere((m) => m.id == mediaId);
if (index >= 0) await _playIndex(index);
}
@override
Future<void> playMediaItem(MediaItem mediaItem) =>
playFromMediaId(mediaItem.id);
/// Called when a file is deleted from disk. Removes it from the queue and, if
/// it is the track currently playing, stops or advances so a deleted song can
/// no longer be played.
Future<void> onSourceDeleted(String source) async {
final target = source.trim();
if (target.isEmpty || _media.isEmpty) return;
_resolvedPathCache.remove(target);
_resolvedPathOrder.remove(target);
final wasCurrent =
_index >= 0 &&
_index < _media.length &&
_media[_index].source == target;
var removedBeforeCurrent = 0;
final kept = <PlayableMedia>[];
for (var i = 0; i < _media.length; i++) {
if (_media[i].source == target) {
if (i < _index) removedBeforeCurrent++;
continue;
}
kept.add(_media[i]);
}
if (kept.length == _media.length) return; // nothing matched
_media
..clear()
..addAll(kept);
_queueItems
..clear()
..addAll(kept.map((m) => m.toMediaItem()));
_recent.clear();
_playHistory.clear();
queue.add(List<MediaItem>.unmodifiable(_queueItems));
if (_media.isEmpty) {
await stop();
return;
}
if (wasCurrent) {
final nextIndex = _index.clamp(0, _media.length - 1);
await _playIndex(nextIndex);
} else {
_index = (_index - removedBeforeCurrent).clamp(0, _media.length - 1);
_broadcastState();
}
}
Future<void> dispose() async {
for (final sub in _subscriptions) {
await sub.cancel();
}
_subscriptions.clear();
await _player.dispose();
}
}
MusicPlayerHandler? _handler;
Future<MusicPlayerHandler>? _initFuture;
final StreamController<MusicPlayerHandler> _handlerReadyController =
StreamController<MusicPlayerHandler>.broadcast();
MusicPlayerHandler? get musicPlayerHandler => _handler;
Future<void> Function()? musicPlayerExclusiveAudioHook;
Future<MusicPlayerHandler> initMusicPlayer() async {
if (_handler != null) return _handler!;
final existingFuture = _initFuture;
if (existingFuture != null) return existingFuture;
final future = _doInitMusicPlayer();
_initFuture = future;
return future;
}
Future<MusicPlayerHandler> _doInitMusicPlayer() async {
try {
final handler = await AudioService.init(
builder: () => MusicPlayerHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.zarz.spotiflac.playback',
androidNotificationChannelName: 'Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
);
_handler = handler;
_handlerReadyController.add(handler);
return handler;
} catch (_) {
_initFuture = null;
rethrow;
}
}
Stream<MediaItem?> musicPlayerMediaItemEvents() async* {
final existing = _handler;
if (existing != null) {
yield existing.mediaItem.value;
yield* existing.mediaItem;
return;
}
yield null;
await for (final handler in _handlerReadyController.stream) {
yield handler.mediaItem.value;
yield* handler.mediaItem;
return;
}
}
Stream<PlaybackState> musicPlayerPlaybackStateEvents() async* {
final existing = _handler;
if (existing != null) {
yield existing.playbackState.value;
yield* existing.playbackState;
return;
}
await for (final handler in _handlerReadyController.stream) {
yield handler.playbackState.value;
yield* handler.playbackState;
return;
}
}
Stream<List<MediaItem>> musicPlayerQueueEvents() async* {
final existing = _handler;
if (existing != null) {
yield existing.queue.value;
yield* existing.queue;
return;
}
yield const [];
await for (final handler in _handlerReadyController.stream) {
yield handler.queue.value;
yield* handler.queue;
return;
}
}
+336
View File
@@ -0,0 +1,336 @@
import 'package:xml/xml.dart';
class LyricWord {
final Duration time;
final String text;
const LyricWord({required this.time, required this.text});
}
class LyricLine {
final Duration time;
final Duration? end;
final String text;
final List<LyricWord> words;
const LyricLine({
required this.time,
this.end,
required this.text,
this.words = const [],
});
bool get hasWordTiming => words.isNotEmpty;
}
class ParsedLyrics {
final bool synced;
final bool wordSynced;
final List<LyricLine> lines;
final String plainText;
const ParsedLyrics({
required this.synced,
required this.wordSynced,
required this.lines,
required this.plainText,
});
bool get isEmpty => lines.isEmpty && plainText.trim().isEmpty;
static const ParsedLyrics empty = ParsedLyrics(
synced: false,
wordSynced: false,
lines: [],
plainText: '',
);
}
class LyricsParser {
LyricsParser._();
// [mm:ss.xx] or [mm:ss.xxx] or [mm:ss]
static final RegExp _lineTimeTag = RegExp(
r'\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]',
);
// <mm:ss.xx> inline word timestamp (enhanced LRC).
static final RegExp _wordTimeTag = RegExp(
r'<(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?>',
);
// ID tags such as [ti:..], [ar:..], [offset:..].
static final RegExp _idTag = RegExp(
r'^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder):.*\]$',
caseSensitive: false,
);
static ParsedLyrics parse(String? raw) {
final text = (raw ?? '').trim();
if (text.isEmpty) return ParsedLyrics.empty;
if (_looksLikeTtml(text)) {
final ttml = _parseTtml(text);
if (ttml != null && ttml.lines.isNotEmpty) return ttml;
}
return _parseLrcOrPlain(text);
}
static bool _looksLikeTtml(String text) {
final head = text.trimLeft();
return head.startsWith('<?xml') ||
head.startsWith('<tt') ||
head.contains('<tt ') ||
head.contains('http://www.w3.org/ns/ttml');
}
static Duration? _toDuration(String? min, String? sec, String? frac) {
if (min == null || sec == null) return null;
final m = int.tryParse(min) ?? 0;
final s = int.tryParse(sec) ?? 0;
var ms = 0;
if (frac != null && frac.isNotEmpty) {
// Normalize to milliseconds regardless of 2 or 3 digit fractions.
final padded = frac.padRight(3, '0').substring(0, 3);
ms = int.tryParse(padded) ?? 0;
}
return Duration(minutes: m, seconds: s, milliseconds: ms);
}
static ParsedLyrics _parseLrcOrPlain(String text) {
final rawLines = text.split(RegExp(r'\r\n|\r|\n'));
final parsed = <LyricLine>[];
final plainBuffer = <String>[];
var sawTimestamp = false;
var sawWordTiming = false;
var offsetMs = 0;
for (final rawLine in rawLines) {
final line = rawLine.trimRight();
if (line.trim().isEmpty) continue;
// Capture [offset:] for timing correction, drop other ID tags.
final idMatch = _idTag.firstMatch(line.trim());
if (idMatch != null) {
final key = idMatch.group(1)!.toLowerCase();
if (key == 'offset') {
final value = line.substring(line.indexOf(':') + 1).replaceAll(']', '').trim();
offsetMs = int.tryParse(value) ?? 0;
}
continue;
}
final timeMatches = _lineTimeTag.allMatches(line).toList();
if (timeMatches.isEmpty) {
// No timestamp: treat as plain text line.
plainBuffer.add(line.trim());
continue;
}
sawTimestamp = true;
// Strip leading line timestamps to obtain the lyric content.
final lastTag = timeMatches.last;
final content = line.substring(lastTag.end).trim();
// Enhanced LRC word timestamps inside the content.
final words = _parseWords(content);
if (words.isNotEmpty) sawWordTiming = true;
final cleanContent = content.replaceAll(_wordTimeTag, '').trim();
plainBuffer.add(cleanContent);
// A line can have multiple timestamps (repeated chorus).
for (final tm in timeMatches) {
final d = _toDuration(tm.group(1), tm.group(2), tm.group(3));
if (d == null) continue;
parsed.add(
LyricLine(
time: d,
text: cleanContent,
words: words,
),
);
}
}
if (!sawTimestamp) {
// Pure plain text.
return ParsedLyrics(
synced: false,
wordSynced: false,
lines: const [],
plainText: rawLines
.map((l) => l.trim())
.where((l) => l.isNotEmpty && _idTag.firstMatch(l) == null)
.join('\n'),
);
}
parsed.sort((a, b) => a.time.compareTo(b.time));
final adjusted = offsetMs == 0
? parsed
: parsed
.map(
(l) => LyricLine(
time: _shift(l.time, offsetMs),
end: l.end,
text: l.text,
words: l.words
.map(
(w) =>
LyricWord(time: _shift(w.time, offsetMs), text: w.text),
)
.toList(),
),
)
.toList();
return ParsedLyrics(
synced: true,
wordSynced: sawWordTiming,
lines: adjusted,
plainText: plainBuffer.where((l) => l.isNotEmpty).join('\n'),
);
}
static Duration _shift(Duration d, int offsetMs) {
// LRC offset: positive value shifts lyrics earlier.
final ms = d.inMilliseconds - offsetMs;
return Duration(milliseconds: ms < 0 ? 0 : ms);
}
static List<LyricWord> _parseWords(String content) {
final matches = _wordTimeTag.allMatches(content).toList();
if (matches.isEmpty) return const [];
final words = <LyricWord>[];
for (var i = 0; i < matches.length; i++) {
final m = matches[i];
final d = _toDuration(m.group(1), m.group(2), m.group(3));
if (d == null) continue;
final start = m.end;
final end = i + 1 < matches.length ? matches[i + 1].start : content.length;
final word = content.substring(start, end);
if (word.trim().isEmpty) continue;
words.add(LyricWord(time: d, text: word));
}
return words;
}
static ParsedLyrics? _parseTtml(String text) {
try {
final doc = XmlDocument.parse(text);
final paragraphs = doc.findAllElements('p').toList();
if (paragraphs.isEmpty) return null;
final lines = <LyricLine>[];
final plain = <String>[];
var sawWords = false;
for (final p in paragraphs) {
final begin = _parseClock(p.getAttribute('begin'));
final end = _parseClock(p.getAttribute('end'));
// Word/syllable spans carry their own begin attribute.
final spans = p.findElements('span').toList();
final words = <LyricWord>[];
if (spans.isNotEmpty) {
for (final span in spans) {
final sBegin = _parseClock(span.getAttribute('begin'));
final spanText = span.innerText;
if (sBegin != null && spanText.trim().isNotEmpty) {
words.add(LyricWord(time: sBegin, text: '$spanText '));
}
}
}
if (words.isNotEmpty) sawWords = true;
final lineText = p.innerText.replaceAll(RegExp(r'\s+'), ' ').trim();
if (lineText.isEmpty && words.isEmpty) continue;
plain.add(lineText);
if (begin != null) {
lines.add(
LyricLine(
time: begin,
end: end,
text: lineText,
words: words,
),
);
}
}
if (lines.isEmpty) {
return ParsedLyrics(
synced: false,
wordSynced: false,
lines: const [],
plainText: plain.join('\n'),
);
}
lines.sort((a, b) => a.time.compareTo(b.time));
return ParsedLyrics(
synced: true,
wordSynced: sawWords,
lines: lines,
plainText: plain.where((l) => l.isNotEmpty).join('\n'),
);
} catch (_) {
return null;
}
}
// TTML clock value: "mm:ss.fff", "hh:mm:ss.fff" or "12.5s".
static Duration? _parseClock(String? value) {
if (value == null || value.isEmpty) return null;
final v = value.trim();
if (v.endsWith('s') && !v.contains(':')) {
final seconds = double.tryParse(v.substring(0, v.length - 1));
if (seconds == null) return null;
return Duration(milliseconds: (seconds * 1000).round());
}
final parts = v.split(':');
try {
if (parts.length == 3) {
final h = int.parse(parts[0]);
final m = int.parse(parts[1]);
final s = double.parse(parts[2]);
return Duration(
hours: h,
minutes: m,
milliseconds: (s * 1000).round(),
);
} else if (parts.length == 2) {
final m = int.parse(parts[0]);
final s = double.parse(parts[1]);
return Duration(minutes: m, milliseconds: (s * 1000).round());
}
} catch (_) {
return null;
}
return null;
}
static int activeIndex(List<LyricLine> lines, Duration position) {
if (lines.isEmpty) return -1;
var lo = 0;
var hi = lines.length - 1;
var result = -1;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
if (lines[mid].time <= position) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
}
+153
View File
@@ -0,0 +1,153 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class MiniPlayer extends ConsumerWidget {
const MiniPlayer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaItem = ref.watch(currentMediaItemProvider).value;
if (mediaItem == null) return const SizedBox.shrink();
final playback = ref.watch(playbackStateProvider).value;
final isPlaying = playback?.playing ?? false;
final controller = ref.read(musicPlayerControllerProvider);
final colorScheme = Theme.of(context).colorScheme;
final duration = mediaItem.duration?.inMilliseconds ?? 0;
final position = playback?.position.inMilliseconds ?? 0;
final progress = duration > 0 ? (position / duration).clamp(0.0, 1.0) : 0.0;
return DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
),
child: Material(
color: settingsGroupColor(context).withValues(alpha: 0.72),
child: InkWell(
onTap: () {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => const NowPlayingScreen(),
fullscreenDialog: true,
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 44,
height: 44,
child: _MiniArt(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
mediaItem.title,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
mediaItem.artist ?? '',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: () => controller.togglePlayPause(isPlaying),
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
],
),
),
],
),
),
),
);
}
}
class _MiniArt extends StatelessWidget {
final String? artUri;
final ColorScheme colorScheme;
const _MiniArt({required this.artUri, required this.colorScheme});
@override
Widget build(BuildContext context) {
final placeholder = Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 22,
color: colorScheme.onSurfaceVariant,
),
);
final uri = artUri;
if (uri == null || uri.isEmpty) return placeholder;
if (uri.startsWith('http')) {
return CachedNetworkImage(
imageUrl: uri,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
memCacheWidth: 132,
fadeInDuration: const Duration(milliseconds: 150),
fadeOutDuration: const Duration(milliseconds: 0),
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
);
}
if (uri.startsWith('file://')) {
return Image.file(
File(Uri.parse(uri).toFilePath()),
fit: BoxFit.cover,
cacheWidth: 132,
errorBuilder: (_, _, _) => placeholder,
);
}
return placeholder;
}
}
+47 -7
View File
@@ -65,6 +65,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
audio_service:
dependency: "direct main"
description:
name: audio_service
sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044
url: "https://pub.dev"
source: hosted
version: "0.18.18"
audio_service_platform_interface:
dependency: transitive
description:
name: audio_service_platform_interface
sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
audio_service_web:
dependency: transitive
description:
name: audio_service_web
sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df
url: "https://pub.dev"
source: hosted
version: "0.1.4"
audio_session:
dependency: "direct main"
description:
name: audio_session
sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b"
url: "https://pub.dev"
source: hosted
version: "0.2.3"
audioplayers:
dependency: "direct main"
description:
@@ -333,10 +365,10 @@ packages:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.14"
version: "0.7.13"
device_info_plus:
dependency: "direct main"
description:
@@ -657,10 +689,10 @@ packages:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev"
source: hosted
version: "4.8.0"
version: "4.9.1"
intl:
dependency: "direct main"
description:
@@ -693,6 +725,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
@@ -1547,13 +1587,13 @@ packages:
source: hosted
version: "1.1.0"
xml:
dependency: transitive
dependency: "direct main"
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "7.0.1"
yaml:
dependency: transitive
description:
+4
View File
@@ -61,9 +61,13 @@ dependencies:
video_player: ^2.8.0
xml: ^7.0.1
# Notifications
flutter_local_notifications: ^22.0.1
audioplayers: ^6.8.1
audio_service: ^0.18.18
audio_session: ^0.2.3
dev_dependencies:
flutter_test: