diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index a4dc253c..f5aebad1 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -62,6 +62,8 @@ android {
buildTypes {
getByName("debug") {
+ applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d70b105e..301ba0a0 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -114,6 +114,23 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
+
+
+
+
+
+
+
+
+
@@ -130,6 +147,10 @@
android:name="flutterEmbedding"
android:value="2" />
+
+
+
+
+
diff --git a/lib/models/settings.dart b/lib/models/settings.dart
index e242e6d5..b209e6ed 100644
--- a/lib/models/settings.dart
+++ b/lib/models/settings.dart
@@ -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,
);
}
diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart
index 83647667..5228d009 100644
--- a/lib/models/settings.g.dart
+++ b/lib/models/settings.g.dart
@@ -83,6 +83,7 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson(
@@ -149,4 +150,5 @@ Map _$AppSettingsToJson(
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
+ 'playerMode': instance.playerMode,
};
diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart
new file mode 100644
index 00000000..8f056654
--- /dev/null
+++ b/lib/providers/music_player_provider.dart
@@ -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((ref) {
+ return musicPlayerMediaItemEvents();
+});
+
+final playbackStateProvider = StreamProvider((ref) {
+ return musicPlayerPlaybackStateEvents();
+});
+
+final playQueueProvider = StreamProvider>((ref) {
+ return musicPlayerQueueEvents();
+});
+
+class MusicPlayerController {
+ const MusicPlayerController();
+
+ MusicPlayerHandler? get _handler => musicPlayerHandler;
+
+ bool get isAvailable => _handler != null;
+
+ Future ensureInitialized() async {
+ try {
+ return await initMusicPlayer();
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Future playAll(
+ List items, {
+ int initialIndex = 0,
+ }) async {
+ final handler = await ensureInitialized();
+ await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
+ }
+
+ Future playSingle(PlayableMedia item) => playAll([item]);
+
+ Future playHistory(
+ List 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 playLocal(
+ List 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 play() async => _handler?.play();
+ Future pause() async => _handler?.pause();
+ Future stop() async => _handler?.stop();
+ Future seek(Duration position) async => _handler?.seek(position);
+ Future next() async => _handler?.skipToNext();
+ Future previous() async => _handler?.skipToPrevious();
+
+ Future togglePlayPause(bool isPlaying) async {
+ if (isPlaying) {
+ await pause();
+ } else {
+ await play();
+ }
+ }
+
+ Future setShuffle(bool enabled) async {
+ await _handler?.setShuffleMode(
+ enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
+ );
+ }
+
+ Future playNext(PlayableMedia item) async =>
+ (await ensureInitialized())?.enqueue(item, playNext: true);
+
+ Future addToQueue(PlayableMedia item) async =>
+ (await ensureInitialized())?.enqueue(item);
+
+ Future playNextHistory(DownloadHistoryItem item) async =>
+ playNext(playableFromHistory(item));
+
+ Future addToQueueHistory(DownloadHistoryItem item) async =>
+ addToQueue(playableFromHistory(item));
+
+ Future playNextLocal(LocalLibraryItem item) async =>
+ playNext(playableFromLocal(item));
+
+ Future addToQueueLocal(LocalLibraryItem item) async =>
+ addToQueue(playableFromLocal(item));
+
+ Future jumpTo(int index) async => _handler?.skipToQueueItem(index);
+}
+
+final musicPlayerControllerProvider = Provider(
+ (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,
+ );
+}
diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart
index ea5114e6..5a926cfe 100644
--- a/lib/providers/settings_provider.dart
+++ b/lib/providers/settings_provider.dart
@@ -645,6 +645,12 @@ class SettingsNotifier extends Notifier {
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(
diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart
new file mode 100644
index 00000000..cb899856
--- /dev/null
+++ b/lib/screens/now_playing_screen.dart
@@ -0,0 +1,1105 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:audio_service/audio_service.dart';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart' show ScrollDirection;
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:spotiflac_android/providers/music_player_provider.dart';
+import 'package:spotiflac_android/services/library_database.dart';
+import 'package:spotiflac_android/services/platform_bridge.dart';
+import 'package:spotiflac_android/services/cover_cache_manager.dart';
+import 'package:spotiflac_android/utils/file_access.dart';
+import 'package:spotiflac_android/utils/lyrics_parser.dart';
+import 'package:spotiflac_android/utils/logger.dart';
+import 'package:spotiflac_android/widgets/settings_group.dart';
+
+final _log = AppLogger('NowPlaying');
+
+class NowPlayingScreen extends ConsumerStatefulWidget {
+ const NowPlayingScreen({super.key});
+
+ @override
+ ConsumerState createState() => _NowPlayingScreenState();
+}
+
+class _NowPlayingScreenState extends ConsumerState {
+ final PageController _pageController = PageController();
+ ProviderSubscription>? _mediaItemSub;
+ String? _loadedSource;
+ Map? _metadata;
+ ParsedLyrics _lyrics = ParsedLyrics.empty;
+ bool _loadingMeta = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _mediaItemSub = ref.listenManual>(
+ currentMediaItemProvider,
+ (previous, next) => _loadMetadataForItem(next.value),
+ );
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ _loadMetadataForItem(ref.read(currentMediaItemProvider).value);
+ });
+ }
+
+ @override
+ void dispose() {
+ _mediaItemSub?.close();
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ void _loadMetadataForItem(MediaItem? item) {
+ if (item == null) return;
+ final source = item.extras?['source']?.toString() ?? '';
+ if (source.isEmpty) return;
+ final resolvedSource = item.extras?['resolvedSource']?.toString();
+ unawaited(_loadMetadataFor(source, resolvedSource: resolvedSource));
+ }
+
+ Future _loadMetadataFor(String source, {String? resolvedSource}) async {
+ if (source == _loadedSource) return;
+ _loadedSource = source;
+ setState(() {
+ _loadingMeta = true;
+ _metadata = null;
+ _lyrics = ParsedLyrics.empty;
+ });
+ try {
+ String path = (resolvedSource != null && resolvedSource.isNotEmpty)
+ ? resolvedSource
+ : source;
+ if (path == source && source.startsWith('content://')) {
+ final temp = await PlatformBridge.copyContentUriToTemp(source);
+ if (temp == null || temp.isEmpty) {
+ throw Exception('Cannot resolve content URI');
+ }
+ path = temp;
+ }
+ final meta = await PlatformBridge.readFileMetadata(path);
+ if (!mounted || _loadedSource != source) return;
+ setState(() {
+ _metadata = meta;
+ _lyrics = LyricsParser.parse((meta['lyrics'] ?? '').toString());
+ _loadingMeta = false;
+ });
+ } catch (e) {
+ _log.w('Failed to read metadata: $e');
+ if (!mounted || _loadedSource != source) return;
+ setState(() {
+ _metadata = null;
+ _lyrics = ParsedLyrics.empty;
+ _loadingMeta = false;
+ });
+ }
+ }
+
+ String _fmt(Duration d) {
+ final m = d.inMinutes;
+ final s = d.inSeconds % 60;
+ return '$m:${s.toString().padLeft(2, '0')}';
+ }
+
+ String? _qualityLabel() {
+ final meta = _metadata;
+ if (meta == null) return null;
+
+ final parts = [];
+ final format = (meta['format'] ?? meta['audio_codec'] ?? '')
+ .toString()
+ .trim()
+ .toUpperCase();
+ if (format.isNotEmpty) parts.add(format);
+
+ final bitDepth = (meta['bit_depth'] as num?)?.toInt() ?? 0;
+ if (bitDepth > 0) parts.add('$bitDepth-bit');
+
+ final sampleRate = (meta['sample_rate'] as num?)?.toDouble() ?? 0;
+ if (sampleRate > 0) {
+ final khz = sampleRate / 1000;
+ final khzStr = khz == khz.roundToDouble()
+ ? khz.toStringAsFixed(0)
+ : khz.toStringAsFixed(1);
+ parts.add('$khzStr kHz');
+ }
+
+ final bitrate = (meta['bitrate'] as num?)?.toInt() ?? 0;
+ if (bitDepth == 0 && bitrate > 0) parts.add('$bitrate kbps');
+
+ if (parts.isEmpty) return null;
+ return parts.join(' ยท ');
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final mediaItem = ref.watch(currentMediaItemProvider).value;
+ final controller = ref.read(musicPlayerControllerProvider);
+
+ if (mediaItem == null) {
+ return Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ icon: const Icon(Icons.keyboard_arrow_down),
+ onPressed: () => Navigator.of(context).maybePop(),
+ ),
+ ),
+ body: const Center(child: Text('Nothing is playing')),
+ );
+ }
+
+ final source = mediaItem.extras?['source']?.toString() ?? '';
+
+ return Scaffold(
+ backgroundColor: colorScheme.surface,
+ appBar: AppBar(
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ title: const Text('Now Playing'),
+ centerTitle: true,
+ leading: IconButton(
+ tooltip: 'Minimize',
+ icon: const Icon(Icons.keyboard_arrow_down),
+ onPressed: () => Navigator.of(context).maybePop(),
+ ),
+ actions: [
+ IconButton(
+ tooltip: 'Up next',
+ icon: const Icon(Icons.queue_music),
+ onPressed: () => _showQueueSheet(colorScheme),
+ ),
+ PopupMenuButton(
+ icon: const Icon(Icons.more_vert),
+ onSelected: (value) {
+ switch (value) {
+ case 'details':
+ _showDetailsSheet(colorScheme);
+ break;
+ case 'external':
+ _openExternally(source);
+ break;
+ }
+ },
+ itemBuilder: (context) => const [
+ PopupMenuItem(
+ value: 'details',
+ child: ListTile(
+ leading: Icon(Icons.info_outline),
+ title: Text('Details'),
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ PopupMenuItem(
+ value: 'external',
+ child: ListTile(
+ leading: Icon(Icons.open_in_new),
+ title: Text('Open in external player'),
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ body: SafeArea(
+ child: Column(
+ children: [
+ Expanded(
+ child: PageView(
+ controller: _pageController,
+ children: [
+ _playerPage(mediaItem, controller, colorScheme),
+ _lyricsSection(colorScheme),
+ ],
+ ),
+ ),
+ _PageTabBar(
+ controller: _pageController,
+ colorScheme: colorScheme,
+ labels: const ['Player', 'Lyrics'],
+ ),
+ const SizedBox(height: 8),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _playerPage(
+ MediaItem mediaItem,
+ MusicPlayerController controller,
+ ColorScheme colorScheme,
+ ) {
+ final playback = ref.watch(playbackStateProvider).value;
+ final isPlaying = playback?.playing ?? false;
+ final position = playback?.position ?? Duration.zero;
+ final duration = mediaItem.duration ?? Duration.zero;
+ final maxMs = duration.inMilliseconds > 0
+ ? duration.inMilliseconds.toDouble()
+ : 1.0;
+ final posMs = position.inMilliseconds
+ .clamp(0, duration.inMilliseconds > 0 ? duration.inMilliseconds : 0)
+ .toDouble();
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final artSize = (constraints.maxWidth - 64).clamp(0.0, 360.0);
+ return SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight - 32),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Center(
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(20),
+ child: SizedBox(
+ width: artSize,
+ height: artSize,
+ child: _Artwork(
+ artUri: mediaItem.artUri?.toString(),
+ colorScheme: colorScheme,
+ cacheWidth:
+ (artSize * MediaQuery.devicePixelRatioOf(context))
+ .round(),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 32),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 28),
+ child: Column(
+ children: [
+ Text(
+ mediaItem.title,
+ style: Theme.of(context).textTheme.headlineSmall
+ ?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ textAlign: TextAlign.center,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 6),
+ Text(
+ mediaItem.artist ?? '',
+ style: Theme.of(context).textTheme.titleMedium
+ ?.copyWith(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Column(
+ children: [
+ SliderTheme(
+ data: SliderThemeData(
+ trackHeight: 4,
+ activeTrackColor: colorScheme.primary,
+ inactiveTrackColor: colorScheme.onSurface.withValues(
+ alpha: 0.18,
+ ),
+ thumbColor: colorScheme.primary,
+ thumbShape: const RoundSliderThumbShape(
+ enabledThumbRadius: 7,
+ ),
+ overlayShape: const RoundSliderOverlayShape(
+ overlayRadius: 16,
+ ),
+ ),
+ child: Slider(
+ value: posMs.clamp(0, maxMs),
+ max: maxMs,
+ onChanged: duration.inMilliseconds > 0
+ ? (value) => controller.seek(
+ Duration(milliseconds: value.round()),
+ )
+ : null,
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ child: Row(
+ children: [
+ Text(
+ _fmt(position),
+ style: Theme.of(context).textTheme.bodySmall
+ ?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ Expanded(
+ child: Center(
+ child: _QualityBadge(
+ label: _qualityLabel(),
+ colorScheme: colorScheme,
+ ),
+ ),
+ ),
+ Text(
+ _fmt(duration),
+ style: Theme.of(context).textTheme.bodySmall
+ ?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ IconButton(
+ iconSize: 44,
+ icon: const Icon(Icons.skip_previous),
+ onPressed: controller.previous,
+ ),
+ const SizedBox(width: 20),
+ Container(
+ decoration: BoxDecoration(
+ color: colorScheme.primary,
+ shape: BoxShape.circle,
+ ),
+ child: IconButton(
+ iconSize: 44,
+ padding: const EdgeInsets.all(12),
+ color: colorScheme.onPrimary,
+ icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
+ onPressed: () => controller.togglePlayPause(isPlaying),
+ ),
+ ),
+ const SizedBox(width: 20),
+ IconButton(
+ iconSize: 44,
+ icon: const Icon(Icons.skip_next),
+ onPressed: controller.next,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _lyricsSection(ColorScheme colorScheme) {
+ if (_loadingMeta) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (_lyrics.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.lyrics_outlined,
+ size: 40,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'No lyrics in this file',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+ if (_lyrics.synced) {
+ return _SyncedLyricsView(lyrics: _lyrics, colorScheme: colorScheme);
+ }
+ return SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
+ child: Text(
+ _lyrics.plainText,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ height: 1.6,
+ color: colorScheme.onSurface,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ );
+ }
+
+ Future _openExternally(String source) async {
+ if (source.isEmpty) return;
+ try {
+ await openFile(source);
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
+ }
+ }
+
+ Future _shuffleLibrary(MusicPlayerController controller) async {
+ try {
+ final rows = await LibraryDatabase.instance.getAll();
+ final media = rows
+ .map(LocalLibraryItem.fromJson)
+ .where((i) => i.filePath.trim().isNotEmpty)
+ .map(playableFromLocal)
+ .toList();
+ if (media.isEmpty) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('Your library is empty')));
+ return;
+ }
+ media.shuffle();
+ await controller.setShuffle(true);
+ await controller.playAll(media);
+ if (mounted) Navigator.of(context).maybePop();
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Could not shuffle library: $e')));
+ }
+ }
+
+ void _showQueueSheet(ColorScheme colorScheme) {
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ showDragHandle: true,
+ backgroundColor: colorScheme.surfaceContainerHigh,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
+ ),
+ builder: (context) {
+ return DraggableScrollableSheet(
+ expand: false,
+ initialChildSize: 0.6,
+ maxChildSize: 0.9,
+ minChildSize: 0.4,
+ builder: (context, scrollController) {
+ return Consumer(
+ builder: (context, ref, _) {
+ final queue = ref.watch(playQueueProvider).value ?? const [];
+ final current = ref.watch(currentMediaItemProvider).value;
+ final controller = ref.read(musicPlayerControllerProvider);
+ final shuffleOn =
+ ref.watch(playbackStateProvider).value?.shuffleMode ==
+ 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,
+ ),
+ ),
+ 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'),
+ ),
+ ),
+ );
+ }
+ if (queue.isEmpty) {
+ return Padding(
+ padding: const EdgeInsets.all(24),
+ child: Center(
+ child: Text(
+ 'Queue is empty',
+ style: textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ );
+ }
+
+ 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,
+ ),
+ ),
+ subtitle: Text(
+ item.artist ?? '',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ onTap: () => controller.jumpTo(i),
+ );
+ },
+ );
+ },
+ );
+ },
+ );
+ },
+ );
+ }
+
+ void _showDetailsSheet(ColorScheme colorScheme) {
+ final meta = _metadata;
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ showDragHandle: true,
+ backgroundColor: colorScheme.surfaceContainerHigh,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
+ ),
+ builder: (context) {
+ return DraggableScrollableSheet(
+ expand: false,
+ initialChildSize: 0.6,
+ maxChildSize: 0.9,
+ minChildSize: 0.4,
+ builder: (context, scrollController) {
+ if (meta == null) {
+ return Center(
+ child: Text(
+ 'No metadata available',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+ return _MetadataList(
+ meta: meta,
+ colorScheme: colorScheme,
+ scrollController: scrollController,
+ );
+ },
+ );
+ },
+ );
+ }
+}
+
+class _SyncedLyricsView extends ConsumerStatefulWidget {
+ final ParsedLyrics lyrics;
+ final ColorScheme colorScheme;
+
+ const _SyncedLyricsView({required this.lyrics, required this.colorScheme});
+
+ @override
+ ConsumerState<_SyncedLyricsView> createState() => _SyncedLyricsViewState();
+}
+
+class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
+ final ScrollController _scroll = ScrollController();
+ int _active = -1;
+ bool _userScrolling = false;
+ static const double _estimatedLyricExtent = 64;
+
+ @override
+ void dispose() {
+ _scroll.dispose();
+ super.dispose();
+ }
+
+ void _maybeAutoScroll(int index) {
+ if (_userScrolling || index < 0 || !_scroll.hasClients) return;
+ final position = _scroll.position;
+ final target =
+ (index * _estimatedLyricExtent) -
+ (position.viewportDimension * 0.42) +
+ 24;
+ final clamped = target.clamp(
+ position.minScrollExtent,
+ position.maxScrollExtent,
+ );
+ _scroll.animateTo(
+ clamped.toDouble(),
+ duration: const Duration(milliseconds: 380),
+ curve: Curves.easeOutCubic,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final position =
+ ref.watch(playbackStateProvider).value?.position ?? Duration.zero;
+ final lines = widget.lyrics.lines;
+ final active = LyricsParser.activeIndex(lines, position);
+
+ if (active != _active) {
+ _active = active;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) _maybeAutoScroll(active);
+ });
+ }
+
+ return NotificationListener(
+ onNotification: (notification) {
+ if (notification.direction != ScrollDirection.idle) {
+ _userScrolling = true;
+ Future.delayed(const Duration(seconds: 4), () {
+ if (mounted) _userScrolling = false;
+ });
+ }
+ return false;
+ },
+ child: ListView.builder(
+ controller: _scroll,
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 80),
+ itemCount: lines.length,
+ itemBuilder: (context, index) {
+ final line = lines[index];
+ final isActive = index == active;
+ final isPast = index < active;
+
+ final color = isActive
+ ? widget.colorScheme.onSurface
+ : isPast
+ ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
+ : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8);
+
+ final text = line.text.trim().isEmpty
+ ? '\u00b7\u00b7\u00b7'
+ : line.text;
+
+ Widget content;
+ if (isActive && line.hasWordTiming) {
+ content = _wordHighlightedLine(line, position);
+ } else {
+ content = Text(
+ text,
+ textAlign: TextAlign.center,
+ style:
+ (isActive
+ ? Theme.of(context).textTheme.headlineSmall
+ : Theme.of(context).textTheme.titleLarge)
+ ?.copyWith(
+ height: 1.4,
+ fontWeight: isActive
+ ? FontWeight.bold
+ : FontWeight.w500,
+ color: color,
+ ),
+ );
+ }
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 10),
+ child: GestureDetector(
+ onTap: () =>
+ ref.read(musicPlayerControllerProvider).seek(line.time),
+ child: AnimatedScale(
+ scale: isActive ? 1.0 : 0.96,
+ alignment: Alignment.center,
+ duration: const Duration(milliseconds: 280),
+ curve: Curves.easeOutCubic,
+ child: AnimatedOpacity(
+ opacity: isActive ? 1.0 : (isPast ? 0.55 : 0.85),
+ duration: const Duration(milliseconds: 280),
+ child: content,
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _wordHighlightedLine(LyricLine line, Duration position) {
+ final spans = [];
+ for (final word in line.words) {
+ final sung = position >= word.time;
+ spans.add(
+ TextSpan(
+ text: word.text,
+ style: TextStyle(
+ color: sung
+ ? widget.colorScheme.onSurface
+ : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
+ ),
+ ),
+ );
+ }
+ return RichText(
+ textAlign: TextAlign.center,
+ text: TextSpan(
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(
+ height: 1.4,
+ fontWeight: FontWeight.bold,
+ ),
+ children: spans,
+ ),
+ );
+ }
+}
+
+class _MetadataList extends StatelessWidget {
+ final Map meta;
+ final ColorScheme colorScheme;
+ final ScrollController scrollController;
+
+ const _MetadataList({
+ required this.meta,
+ required this.colorScheme,
+ required this.scrollController,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ String s(Object? v) => (v ?? '').toString();
+ final rows = <(String, String)>[
+ ('Title', s(meta['title'])),
+ ('Artist', s(meta['artist'])),
+ ('Album', s(meta['album'])),
+ ('Album artist', s(meta['album_artist'])),
+ ('Genre', s(meta['genre'])),
+ ('Composer', s(meta['composer'])),
+ ('Date', s(meta['date'])),
+ ('Track', s(meta['track_number'])),
+ ('Disc', s(meta['disc_number'])),
+ ('ISRC', s(meta['isrc'])),
+ ('Label', s(meta['label'])),
+ ('Copyright', s(meta['copyright'])),
+ ('Format', s(meta['format']).toUpperCase()),
+ ('Codec', s(meta['audio_codec'])),
+ (
+ 'Sample rate',
+ meta['sample_rate'] != null && (meta['sample_rate'] as num? ?? 0) > 0
+ ? '${((meta['sample_rate'] as num) / 1000).toStringAsFixed(1)} kHz'
+ : '',
+ ),
+ (
+ 'Bit depth',
+ (meta['bit_depth'] as num? ?? 0) > 0 ? '${meta['bit_depth']}-bit' : '',
+ ),
+ ].where((r) => r.$2.trim().isNotEmpty && r.$2 != '0').toList();
+
+ final textTheme = Theme.of(context).textTheme;
+
+ return ListView(
+ controller: scrollController,
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
+ children: [
+ Card(
+ elevation: 0,
+ color: settingsGroupColor(context),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(20),
+ side: BorderSide(
+ color: colorScheme.outlineVariant.withValues(alpha: 0.5),
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ Icons.info_outline,
+ size: 20,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ 'Details',
+ style: textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ ...rows.map(
+ (row) => Padding(
+ padding: const EdgeInsets.symmetric(
+ vertical: 6,
+ horizontal: 4,
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: 100,
+ child: Text(
+ row.$1,
+ style: textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ Expanded(
+ child: Text(
+ row.$2,
+ style: textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _PageTabBar extends StatelessWidget {
+ final PageController controller;
+ final ColorScheme colorScheme;
+ final List labels;
+
+ const _PageTabBar({
+ required this.controller,
+ required this.colorScheme,
+ required this.labels,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: controller,
+ builder: (context, _) {
+ double page = 0;
+ if (controller.hasClients && controller.position.haveDimensions) {
+ page = controller.page ?? controller.initialPage.toDouble();
+ }
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final tabWidth = constraints.maxWidth / labels.length;
+ final indicatorWidth = (tabWidth * 0.5).clamp(28.0, 80.0);
+ final base =
+ Theme.of(context).textTheme.labelLarge ?? const TextStyle();
+
+ return SizedBox(
+ height: 38,
+ child: Stack(
+ children: [
+ Row(
+ children: List.generate(labels.length, (i) {
+ // Distance of this tab from the current page position,
+ // used to interpolate color/weight as the user swipes.
+ final t = (1.0 - (page - i).abs()).clamp(0.0, 1.0);
+ return Expanded(
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => controller.animateToPage(
+ i,
+ duration: const Duration(milliseconds: 320),
+ curve: Curves.easeOutCubic,
+ ),
+ child: Center(
+ child: Text(
+ labels[i],
+ style: base.copyWith(
+ fontWeight: FontWeight.lerp(
+ FontWeight.w500,
+ FontWeight.bold,
+ t,
+ ),
+ color: Color.lerp(
+ colorScheme.onSurfaceVariant.withValues(
+ alpha: 0.55,
+ ),
+ colorScheme.primary,
+ t,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }),
+ ),
+ // Sliding underline that tracks the swipe in real time.
+ Positioned(
+ bottom: 0,
+ left:
+ page.clamp(0, (labels.length - 1).toDouble()) *
+ tabWidth +
+ (tabWidth - indicatorWidth) / 2,
+ child: Container(
+ width: indicatorWidth,
+ height: 3,
+ decoration: BoxDecoration(
+ color: colorScheme.primary,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ );
+ }
+}
+
+class _QualityBadge extends StatelessWidget {
+ final String? label;
+ final ColorScheme colorScheme;
+
+ const _QualityBadge({required this.label, required this.colorScheme});
+
+ @override
+ Widget build(BuildContext context) {
+ final text = label;
+ if (text == null || text.isEmpty) return const SizedBox.shrink();
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.graphic_eq, size: 11, color: colorScheme.onSurfaceVariant),
+ const SizedBox(width: 5),
+ Flexible(
+ child: Text(
+ text,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ fontSize: 10.5,
+ color: colorScheme.onSurfaceVariant,
+ letterSpacing: 0.2,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Artwork extends StatelessWidget {
+ final String? artUri;
+ final ColorScheme colorScheme;
+ final int? cacheWidth;
+
+ const _Artwork({
+ required this.artUri,
+ required this.colorScheme,
+ this.cacheWidth,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final placeholder = Container(
+ color: colorScheme.surfaceContainerHighest,
+ child: Icon(
+ Icons.music_note,
+ size: 40,
+ 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: cacheWidth,
+ fadeInDuration: const Duration(milliseconds: 150),
+ fadeOutDuration: const Duration(milliseconds: 0),
+ placeholder: (_, _) => placeholder,
+ errorWidget: (_, _, _) => placeholder,
+ );
+ }
+ if (uri.startsWith('file://')) {
+ final path = Uri.parse(uri).toFilePath();
+ return Image.file(
+ File(path),
+ fit: BoxFit.cover,
+ cacheWidth: cacheWidth,
+ errorBuilder: (_, _, _) => placeholder,
+ );
+ }
+ return placeholder;
+ }
+}
diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart
index 83bdf4a3..eaa4c941 100644
--- a/lib/screens/settings/library_settings_page.dart
+++ b/lib/screens/settings/library_settings_page.dart
@@ -452,6 +452,73 @@ class _LibrarySettingsPageState extends ConsumerState {
),
),
+ 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),
diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart
new file mode 100644
index 00000000..eefa5f8b
--- /dev/null
+++ b/lib/services/music_player_service.dart
@@ -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 _media = [];
+ final List _queueItems = [];
+ final Map _resolvedPathCache = {};
+ final List _resolvedPathOrder = [];
+ final List> _subscriptions = [];
+ int _index = -1;
+ bool _initialized = false;
+
+ bool _shuffle = false;
+ final Random _random = Random();
+ final List _recent = [];
+ final List _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 _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 _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 _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 _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 setQueueAndPlay(
+ List 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.unmodifiable(_queueItems));
+ await _playIndex(initialIndex.clamp(0, items.length - 1));
+ }
+
+ Future 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.unmodifiable(_queueItems));
+ _broadcastState();
+ }
+
+ Future enqueueAll(
+ List 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.unmodifiable(_queueItems));
+ _broadcastState();
+ }
+
+ Future _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 _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.delayed(const Duration(milliseconds: 300));
+ }
+ }
+
+ int _pickNextShuffle() {
+ if (_media.length <= 1) return _index;
+ final pool = [];
+ 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 _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 _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 play() async {
+ _pausedByInterruption = false;
+ _interruptionActive = false;
+ _userPaused = false;
+ await _activateAudioSession();
+ await _player.resume();
+ _broadcastState(playerState: PlayerState.playing);
+ }
+
+ @override
+ Future pause() async {
+ _userPaused = true;
+ _pausedByInterruption = false;
+ _ignoreCompleteFor(const Duration(seconds: 3));
+ await _player.pause();
+ _broadcastState(playerState: PlayerState.paused);
+ }
+
+ @override
+ Future seek(Duration position) async {
+ await _player.seek(position);
+ _broadcastPosition(position, force: true);
+ }
+
+ @override
+ Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
+ _shuffle = shuffleMode == AudioServiceShuffleMode.all;
+ _broadcastState();
+ }
+
+ @override
+ Future 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 skipToNext() async {
+ if (_shuffle) {
+ if (_media.length > 1) await _playIndex(_pickNextShuffle());
+ return;
+ }
+ if (_index < _media.length - 1) await _playIndex(_index + 1);
+ }
+
+ @override
+ Future 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 skipToQueueItem(int index) => _playIndex(index);
+
+ @override
+ Future> getChildren(
+ String parentMediaId, [
+ Map? options,
+ ]) async {
+ if (parentMediaId == AudioService.browsableRootId ||
+ parentMediaId == AudioService.recentRootId) {
+ return List.unmodifiable(_queueItems);
+ }
+ return const [];
+ }
+
+ @override
+ Future getMediaItem(String mediaId) async {
+ final index = _media.indexWhere((m) => m.id == mediaId);
+ if (index < 0) return null;
+ return _queueItems[index];
+ }
+
+ @override
+ Future playFromMediaId(
+ String mediaId, [
+ Map? extras,
+ ]) async {
+ final index = _media.indexWhere((m) => m.id == mediaId);
+ if (index >= 0) await _playIndex(index);
+ }
+
+ @override
+ Future 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 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 = [];
+ 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.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 dispose() async {
+ for (final sub in _subscriptions) {
+ await sub.cancel();
+ }
+ _subscriptions.clear();
+ await _player.dispose();
+ }
+}
+
+MusicPlayerHandler? _handler;
+Future? _initFuture;
+final StreamController _handlerReadyController =
+ StreamController.broadcast();
+
+MusicPlayerHandler? get musicPlayerHandler => _handler;
+
+Future Function()? musicPlayerExclusiveAudioHook;
+
+Future initMusicPlayer() async {
+ if (_handler != null) return _handler!;
+ final existingFuture = _initFuture;
+ if (existingFuture != null) return existingFuture;
+
+ final future = _doInitMusicPlayer();
+ _initFuture = future;
+ return future;
+}
+
+Future _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 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 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> 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;
+ }
+}
diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart
new file mode 100644
index 00000000..89341405
--- /dev/null
+++ b/lib/utils/lyrics_parser.dart
@@ -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 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 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}))?\]',
+ );
+
+ // 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('[];
+ final plainBuffer = [];
+ 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 _parseWords(String content) {
+ final matches = _wordTimeTag.allMatches(content).toList();
+ if (matches.isEmpty) return const [];
+
+ final words = [];
+ 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 = [];
+ final plain = [];
+ 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 = [];
+ 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 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;
+ }
+}
diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart
new file mode 100644
index 00000000..df656ac0
--- /dev/null
+++ b/lib/widgets/mini_player.dart
@@ -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(
+ 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;
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index c0f27b48..a813c78e 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -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:
diff --git a/pubspec.yaml b/pubspec.yaml
index ed15873c..4beb662b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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: