From 3a2481e8b24958e685b0f4e231fdea5e9b05f2d6 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 28 Jun 2026 22:22:33 +0700 Subject: [PATCH] feat: add new playback experience and media integration --- android/app/build.gradle.kts | 2 + android/app/src/main/AndroidManifest.xml | 21 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 6 + .../src/main/res/xml/automotive_app_desc.xml | 4 + lib/models/settings.dart | 5 + lib/models/settings.g.dart | 2 + lib/providers/music_player_provider.dart | 149 +++ lib/providers/settings_provider.dart | 6 + lib/screens/now_playing_screen.dart | 1105 +++++++++++++++++ .../settings/library_settings_page.dart | 67 + lib/services/music_player_service.dart | 729 +++++++++++ lib/utils/lyrics_parser.dart | 336 +++++ lib/widgets/mini_player.dart | 153 +++ pubspec.lock | 54 +- pubspec.yaml | 4 + 15 files changed, 2636 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 lib/providers/music_player_provider.dart create mode 100644 lib/screens/now_playing_screen.dart create mode 100644 lib/services/music_player_service.dart create mode 100644 lib/utils/lyrics_parser.dart create mode 100644 lib/widgets/mini_player.dart 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: