diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d4b694c..745d21c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,30 @@ + + + + + + + + + + + + + + + + + + + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> - - + + - - + + - + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/groovybox/MainActivity.kt b/android/app/src/main/kotlin/com/example/groovybox/MainActivity.kt index 0fd0dd4..0242d8c 100644 --- a/android/app/src/main/kotlin/com/example/groovybox/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/groovybox/MainActivity.kt @@ -1,5 +1,5 @@ package dev.solsynth.groovybox -import io.flutter.embedding.android.FlutterActivity +import com.ryanheise.audioservice.AudioServiceActivity -class MainActivity : FlutterActivity() +class MainActivity : AudioServiceActivity() diff --git a/lib/logic/audio_handler.dart b/lib/logic/audio_handler.dart index 87e22ef..af1b3d6 100644 --- a/lib/logic/audio_handler.dart +++ b/lib/logic/audio_handler.dart @@ -1,26 +1,200 @@ -import 'package:media_kit/media_kit.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:media_kit/media_kit.dart' as media_kit; +import 'package:groovybox/data/db.dart'; -class AudioHandler { - final Player _player; +class AudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { + final media_kit.Player _player; + List _queue = []; + int _queueIndex = 0; - AudioHandler() : _player = Player() { + AudioHandler() : _player = media_kit.Player() { // Configure for audio // _player.setPlaylistMode(PlaylistMode.loop); // Optional + + // Listen to player state changes and broadcast to audio_service + _player.stream.playing.listen((playing) { + _broadcastPlaybackState(); + }); + + _player.stream.position.listen((position) { + _broadcastPlaybackState(); + }); + + _player.stream.duration.listen((duration) { + _broadcastPlaybackState(); + }); + + _player.stream.playlist.listen((playlist) { + if (playlist.medias.isNotEmpty) { + final currentIndex = playlist.index; + if (currentIndex >= 0 && currentIndex < _queue.length) { + _queueIndex = currentIndex; + mediaItem.add(_queue[_queueIndex]); + } + } + }); } - Player get player => _player; + media_kit.Player get player => _player; + // AudioService callbacks + @override Future play() => _player.play(); + + @override Future pause() => _player.pause(); + + @override Future stop() => _player.stop(); + + @override Future seek(Duration position) => _player.seek(position); - Future setSource(String path) async { - await _player.open(Media(path)); + @override + Future skipToNext() async { + if (_queueIndex < _queue.length - 1) { + _queueIndex++; + await _player.jump(_queueIndex); + } } - Future openPlaylist(List medias, {int initialIndex = 0}) async { - await _player.open(Playlist(medias, index: initialIndex), play: true); + @override + Future skipToPrevious() async { + if (_queueIndex > 0) { + _queueIndex--; + await _player.jump(_queueIndex); + } + } + + @override + Future skipToQueueItem(int index) async { + if (index >= 0 && index < _queue.length) { + _queueIndex = index; + await _player.jump(index); + } + } + + @override + Future addQueueItem(MediaItem mediaItem) async { + _queue.add(mediaItem); + queue.add(_queue); + await _updatePlaylist(); + } + + @override + Future insertQueueItem(int index, MediaItem mediaItem) async { + if (index >= 0 && index <= _queue.length) { + _queue.insert(index, mediaItem); + queue.add(_queue); + await _updatePlaylist(); + } + } + + @override + Future removeQueueItem(MediaItem mediaItem) async { + _queue.remove(mediaItem); + queue.add(_queue); + await _updatePlaylist(); + } + + @override + Future updateQueue(List queue) async { + _queue = List.from(queue); + this.queue.add(_queue); + await _updatePlaylist(); + } + + Future _updatePlaylist() async { + final medias = _queue.map((item) => media_kit.Media(item.id)).toList(); + if (medias.isNotEmpty) { + await _player.open(media_kit.Playlist(medias, index: _queueIndex)); + } + } + + void _broadcastPlaybackState() { + final playing = _player.state.playing; + final position = _player.state.position; + final duration = _player.state.duration; + + playbackState.add( + PlaybackState( + controls: [ + MediaControl.skipToPrevious, + playing ? MediaControl.pause : MediaControl.play, + MediaControl.stop, + MediaControl.skipToNext, + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + androidCompactActionIndices: const [0, 1, 3], + processingState: AudioProcessingState.ready, + playing: playing, + updatePosition: position, + bufferedPosition: duration, + speed: 1.0, + queueIndex: _queueIndex, + ), + ); + } + + // New methods that accept Track objects with proper metadata + Future playTrack(Track track) async { + final mediaItem = _trackToMediaItem(track); + await updateQueue([mediaItem]); + } + + Future playTracks(List tracks, {int initialIndex = 0}) async { + final mediaItems = tracks.map(_trackToMediaItem).toList(); + _queueIndex = initialIndex; + await updateQueue(mediaItems); + } + + MediaItem _trackToMediaItem(Track track) { + return MediaItem( + id: track.path, + album: track.album, + title: track.title, + artist: track.artist, + duration: track.duration != null + ? Duration(milliseconds: track.duration!) + : null, + artUri: track.artUri != null ? Uri.file(track.artUri!) : null, + ); + } + + // Legacy methods for backward compatibility + Future setSource(String path) async { + final mediaItem = MediaItem( + id: path, + album: 'Unknown Album', + title: _extractTitleFromPath(path), + artist: 'Unknown Artist', + ); + await updateQueue([mediaItem]); + } + + Future openPlaylist( + List medias, { + int initialIndex = 0, + }) async { + final mediaItems = medias.map((media) { + return MediaItem( + id: media.uri, + album: 'Unknown Album', + title: _extractTitleFromPath(media.uri), + artist: 'Unknown Artist', + ); + }).toList(); + + _queueIndex = initialIndex; + await updateQueue(mediaItems); + } + + String _extractTitleFromPath(String path) { + return path.split('/').last.split('.').first; } void dispose() { diff --git a/lib/main.dart b/lib/main.dart index e344d81..1cc2393 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,30 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; +import 'package:audio_service/audio_service.dart' as audio_service; +import 'logic/audio_handler.dart'; +import 'providers/audio_provider.dart'; import 'ui/shell.dart'; -void main() { +late AudioHandler _audioHandler; + +Future main() async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); + + // Initialize AudioService + _audioHandler = await audio_service.AudioService.init( + builder: () => AudioHandler(), + config: const audio_service.AudioServiceConfig( + androidNotificationChannelId: 'dev.solsynth.groovybox.channel.audio', + androidNotificationChannelName: 'GroovyBox Audio', + androidNotificationOngoing: true, + ), + ); + + // Set the audio handler for the provider + setAudioHandler(_audioHandler); + runApp(const ProviderScope(child: MyApp())); } diff --git a/lib/providers/audio_provider.dart b/lib/providers/audio_provider.dart index 6747110..ab017ee 100644 --- a/lib/providers/audio_provider.dart +++ b/lib/providers/audio_provider.dart @@ -3,9 +3,15 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'audio_provider.g.dart'; +// This should be set after AudioService.init in main.dart +late AudioHandler _audioHandler; + @Riverpod(keepAlive: true) AudioHandler audioHandler(Ref ref) { - final handler = AudioHandler(); - ref.onDispose(() => handler.dispose()); - return handler; + return _audioHandler; +} + +// Function to set the audio handler after initialization +void setAudioHandler(AudioHandler handler) { + _audioHandler = handler; } diff --git a/lib/ui/screens/album_detail_screen.dart b/lib/ui/screens/album_detail_screen.dart index f303162..e2a0547 100644 --- a/lib/ui/screens/album_detail_screen.dart +++ b/lib/ui/screens/album_detail_screen.dart @@ -4,7 +4,6 @@ import 'package:groovybox/data/db.dart'; import 'package:groovybox/data/playlist_repository.dart'; import 'package:groovybox/providers/audio_provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:media_kit/media_kit.dart' hide Track; class AlbumDetailScreen extends HookConsumerWidget { final AlbumData album; @@ -102,8 +101,7 @@ class AlbumDetailScreen extends HookConsumerWidget { void _playAlbum(WidgetRef ref, List tracks, {int initialIndex = 0}) { final audioHandler = ref.read(audioHandlerProvider); - final medias = tracks.map((t) => Media(t.path)).toList(); - audioHandler.openPlaylist(medias, initialIndex: initialIndex); + audioHandler.playTracks(tracks, initialIndex: initialIndex); } String _formatDuration(int? durationMs) { diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index e030777..9af17bc 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -300,8 +300,7 @@ class LibraryScreen extends HookConsumerWidget { ), onTap: () { final audio = ref.read(audioHandlerProvider); - audio.setSource(track.path); - audio.play(); + audio.playTrack(track); }, onLongPress: () { // Enter selection mode diff --git a/lib/ui/screens/playlist_detail_screen.dart b/lib/ui/screens/playlist_detail_screen.dart index 730fe0a..9982e13 100644 --- a/lib/ui/screens/playlist_detail_screen.dart +++ b/lib/ui/screens/playlist_detail_screen.dart @@ -3,7 +3,6 @@ import 'package:groovybox/data/db.dart'; import 'package:groovybox/data/playlist_repository.dart'; import 'package:groovybox/providers/audio_provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:media_kit/media_kit.dart' hide Track, Playlist; class PlaylistDetailScreen extends HookConsumerWidget { final Playlist playlist; @@ -114,8 +113,7 @@ class PlaylistDetailScreen extends HookConsumerWidget { int initialIndex = 0, }) { final audioHandler = ref.read(audioHandlerProvider); - final medias = tracks.map((t) => Media(t.path)).toList(); - audioHandler.openPlaylist(medias, initialIndex: initialIndex); + audioHandler.playTracks(tracks, initialIndex: initialIndex); } String _formatDuration(int? durationMs) { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ab9d2b0..cfe7e62 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,16 +5,22 @@ import FlutterMacOS import Foundation +import audio_service +import audio_session import file_picker import flutter_media_metadata import media_kit_libs_macos_audio import path_provider_foundation +import sqflite_darwin import sqlite3_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterMediaMetadataPlugin.register(with: registry.registrar(forPlugin: "FlutterMediaMetadataPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ee4155d..6e3e74e 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,9 @@ PODS: + - audio_service (0.0.1): + - Flutter + - FlutterMacOS + - audio_session (0.0.1): + - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS - flutter_media_metadata (0.0.1): @@ -9,6 +14,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS - sqlite3 (3.51.1): - sqlite3/common (= 3.51.1) - sqlite3/common (3.51.1) @@ -36,11 +44,14 @@ PODS: - sqlite3/session DEPENDENCIES: + - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/darwin`) + - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - flutter_media_metadata (from `Flutter/ephemeral/.symlinks/plugins/flutter_media_metadata/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - media_kit_libs_macos_audio (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_audio/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) SPEC REPOS: @@ -48,6 +59,10 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + audio_service: + :path: Flutter/ephemeral/.symlinks/plugins/audio_service/darwin + audio_session: + :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos flutter_media_metadata: @@ -58,15 +73,20 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_audio/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin sqlite3_flutter_libs: :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin SPEC CHECKSUMS: + audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd + audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a flutter_media_metadata: cd8641d1242ce33b60b3deae0c533ee0acc47535 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 media_kit_libs_macos_audio: 06f3cf88d6d89c7c3c87eae57689d1c6adb335b2 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41 diff --git a/pubspec.lock b/pubspec.lock index d014cd8..4058ed3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + 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: transitive + description: + name: audio_session + sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" boolean_selector: dependency: transitive description: @@ -326,6 +358,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_hooks: dependency: "direct main" description: @@ -792,6 +832,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" safe_local_storage: dependency: transitive description: @@ -869,6 +917,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" sqlite3: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a32fb85..a2b0359 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: styled_widget: ^0.4.1 super_sliver_list: ^0.4.1 http: ^1.0.0 + audio_service: ^0.18.18 dev_dependencies: flutter_test: