diff --git a/README.md b/README.md index d7d3caa..b446738 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,15 @@ Play counts, activity heatmaps, time-of-day distributions, per-artist breakdowns +

## Features ### Playback - Seamless background playback with lock-screen and notification controls +- **Android Home Screen Widget:** Real-time state synchronization (title, artist, album art, play/pause state) +- **Cold Start Support:** Start playback directly from the widget even if the app is closed - Full-screen player with queue manager and sleep timer - Android 13+ permission handling (`READ_MEDIA_AUDIO`) @@ -82,6 +85,7 @@ Each feature owns its own `data`, `domain`, and `presentation` layers. Cross-fea | Dependency Injection | `get_it` | Service locator; features register their own modules | | Navigation | `auto_route` | Strongly-typed, declarative routing | | Audio Playback | `just_audio` + `audio_service` | Core engine + background service handler | +| OS Widget | `home_widget` | Bridge for Android Home Screen Widget state | | Media Querying | `on_audio_query` (forked) | Optimized local media retrieval | | Database | `sqflite` | SQLite for analytics (star schema, daily rollups) | | Error Handling | `fpdart` | `Either` types throughout the domain layer | diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b6583e8..5820306 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -52,6 +52,12 @@ + + + + + + @@ -65,6 +71,20 @@ + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/osserva/app/MusicPlayerWidget.kt b/android/app/src/main/kotlin/com/osserva/app/MusicPlayerWidget.kt new file mode 100644 index 0000000..a332e96 --- /dev/null +++ b/android/app/src/main/kotlin/com/osserva/app/MusicPlayerWidget.kt @@ -0,0 +1,141 @@ +package com.osserva.app + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.BitmapFactory +import android.media.AudioManager +import android.net.Uri +import android.os.SystemClock +import android.view.KeyEvent +import android.widget.RemoteViews +import android.app.PendingIntent +import es.antonborri.home_widget.HomeWidgetProvider +import es.antonborri.home_widget.HomeWidgetLaunchIntent + +class MusicPlayerWidget : HomeWidgetProvider() { + + companion object { + private const val ACTION_PLAY_PAUSE = "com.osserva.app.WIDGET_PLAY_PAUSE" + private const val ACTION_NEXT = "com.osserva.app.WIDGET_NEXT" + private const val ACTION_PREV = "com.osserva.app.WIDGET_PREV" + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + widgetData: SharedPreferences + ) { + for (widgetId in appWidgetIds) { + updateWidget(context, appWidgetManager, widgetId, widgetData) + } + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + when (intent.action) { + ACTION_PLAY_PAUSE -> sendMediaButton(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + ACTION_NEXT -> sendMediaButton(context, KeyEvent.KEYCODE_MEDIA_NEXT) + ACTION_PREV -> sendMediaButton(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS) + } + } + + private fun sendMediaButton(context: Context, keyCode: Int) { + val eventTime = SystemClock.uptimeMillis() + + // ACTION_DOWN + val downIntent = Intent(Intent.ACTION_MEDIA_BUTTON) + downIntent.setClassName(context.packageName, "com.ryanheise.audioservice.MediaButtonReceiver") + downIntent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0)) + context.sendBroadcast(downIntent) + + // ACTION_UP + val upIntent = Intent(Intent.ACTION_MEDIA_BUTTON) + upIntent.setClassName(context.packageName, "com.ryanheise.audioservice.MediaButtonReceiver") + upIntent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, keyCode, 0)) + context.sendBroadcast(upIntent) + } + + private fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + widgetId: Int, + widgetData: SharedPreferences + ) { + val title = widgetData.getString("song_title", "") ?: "" + val artist = widgetData.getString("artist", "") ?: "" + val isPlaying = widgetData.getBoolean("is_playing", false) + val isShuffle = widgetData.getBoolean("is_shuffle", false) + val artPath = widgetData.getString("art_path", "") ?: "" + + val views = RemoteViews(context.packageName, R.layout.music_widget) + + // Text + views.setTextViewText(R.id.widget_song_title, if (title.isEmpty()) "Not Playing" else title) + views.setTextViewText(R.id.widget_artist, artist) + + // Album art + if (artPath.isNotEmpty()) { + val bitmap = BitmapFactory.decodeFile(artPath) + if (bitmap != null) { + views.setImageViewBitmap(R.id.widget_album_art, bitmap) + } else { + views.setImageViewResource(R.id.widget_album_art, R.drawable.ic_default_art) + } + } else { + views.setImageViewResource(R.id.widget_album_art, R.drawable.ic_default_art) + } + + // Play/Pause icon + val playPauseIcon = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play + views.setImageViewResource(R.id.widget_btn_play_pause, playPauseIcon) + + // Shuffle tint (active = accent color, inactive = white with opacity) + val shuffleTint = if (isShuffle) 0xFF1DB954.toInt() else 0x99FFFFFF.toInt() + views.setInt(R.id.widget_btn_shuffle, "setColorFilter", shuffleTint) + + // Button intents + views.setOnClickPendingIntent( + R.id.widget_btn_play_pause, + broadcastIntent(context, ACTION_PLAY_PAUSE, widgetId), + ) + views.setOnClickPendingIntent( + R.id.widget_btn_next, + broadcastIntent(context, ACTION_NEXT, widgetId), + ) + views.setOnClickPendingIntent( + R.id.widget_btn_prev, + broadcastIntent(context, ACTION_PREV, widgetId), + ) + // Shuffle -> launches Flutter via HomeWidget URI + views.setOnClickPendingIntent( + R.id.widget_btn_shuffle, + shuffleLaunchIntent(context), + ) + + appWidgetManager.updateAppWidget(widgetId, views) + } + + private fun broadcastIntent(context: Context, action: String, widgetId: Int): PendingIntent { + val intent = Intent(context, MusicPlayerWidget::class.java).apply { + this.action = action + } + return PendingIntent.getBroadcast( + context, + widgetId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun shuffleLaunchIntent(context: Context): PendingIntent { + val intent = HomeWidgetLaunchIntent.getActivity( + context, + MainActivity::class.java, + Uri.parse("audiography://widget/shuffle") + ) + return intent + } +} diff --git a/android/app/src/main/res/drawable/art_rounded_bg.xml b/android/app/src/main/res/drawable/art_rounded_bg.xml new file mode 100644 index 0000000..ca0e439 --- /dev/null +++ b/android/app/src/main/res/drawable/art_rounded_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_default_art.xml b/android/app/src/main/res/drawable/ic_default_art.xml new file mode 100644 index 0000000..14d0f6e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_default_art.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_pause.xml b/android/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..d804594 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_play.xml b/android/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..f037022 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_play_arrow.xml b/android/app/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000..6fa1247 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_skip_next.xml b/android/app/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 0000000..6688eff --- /dev/null +++ b/android/app/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_skip_previous.xml b/android/app/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 0000000..56db98c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/play_button_bg.xml b/android/app/src/main/res/drawable/play_button_bg.xml new file mode 100644 index 0000000..3d8471f --- /dev/null +++ b/android/app/src/main/res/drawable/play_button_bg.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..6f98adc --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/music_widget.xml b/android/app/src/main/res/layout/music_widget.xml new file mode 100644 index 0000000..94f2cc2 --- /dev/null +++ b/android/app/src/main/res/layout/music_widget.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2bcdf2c --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Osserva + Osserva music controls + \ No newline at end of file diff --git a/android/app/src/main/res/xml/music_widget_info.xml b/android/app/src/main/res/xml/music_widget_info.xml new file mode 100644 index 0000000..f13d7fc --- /dev/null +++ b/android/app/src/main/res/xml/music_widget_info.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/assets/screenshots/os-widget.jpg b/assets/screenshots/os-widget.jpg new file mode 100644 index 0000000..346b53e Binary files /dev/null and b/assets/screenshots/os-widget.jpg differ diff --git a/docs/features/android_widget.md b/docs/features/android_widget.md new file mode 100644 index 0000000..4df7fc8 --- /dev/null +++ b/docs/features/android_widget.md @@ -0,0 +1,47 @@ +# Android Home Screen Widget + +The **Android Home Screen Widget** provides a convenient way for users to control playback and view current song information directly from their device's home screen, without needing to open the app. + +## Overview + +The widget is built using a hybrid approach: +1. **Native Android UI:** A `RemoteViews` layout (`music_widget.xml`) defines the visual structure. +2. **HomeWidget Bridge:** Uses the `home_widget` package to sync state between Flutter and Android SharedPreferences. +3. **Direct Signaling:** To ensure responsiveness when the app is "killed," the native widget sends explicit broadcasts directly to the `AudioService`'s `MediaButtonReceiver`. + +## Key Capabilities + +- **Real-time State Sync:** Automatically updates the song title, artist, and play/pause icon. +- **Album Art Integration:** Displays the current song's album art using Android's `content://` MediaStore URIs. +- **Cold Start Playback:** If no music is playing and the app is closed, pressing "Play" on the widget triggers the `AudioHandler` to load the local library and start playback from the first song. +- **Instant Wake-up:** Uses explicit intents to bypass the OS's media session registration delays, ensuring the background engine wakes up immediately on interaction. + +## Architecture + +### 1. State Synchronization (`WidgetSyncService`) +The `WidgetSyncService` listens to the `MusicPlayerBloc` stream. Whenever the state (current song, playing status, shuffle mode) changes, it saves this data to the `home_widget` SharedPreferences and triggers a widget update. + +### 2. Interaction Handling (`MusicPlayerWidget.kt`) +The native Kotlin side handles interactions via `onReceive`: +- **Play/Pause, Next, Previous:** Sends an `ACTION_MEDIA_BUTTON` intent directly to `com.ryanheise.audioservice.MediaButtonReceiver`. This is more reliable than generic media key dispatch for cold starts. +- **Shuffle:** Launches the app via a specialized deep link (`audiography://widget/shuffle`) which is caught by the `WidgetSyncService` in the foreground. + +### 3. Background Isolate Initialization +When a widget button is pressed while the app is closed: +1. The `MediaButtonReceiver` starts the `AudioService`. +2. The Flutter background isolate is spawned. +3. `initDependencies()` is called, registering the `GetLocalSongsUseCase` and initializing the `MusicPlayerHandler`. +4. The `AudioHandler` receives the play command and, seeing an empty queue, loads the library to start playback. + +## Configuration & Assets + +- **Layout:** `android/app/src/main/res/layout/music_widget.xml` +- **Metadata:** `android/app/src/main/res/xml/music_widget_info.xml` +- **Kotlin Provider:** `android/app/src/main/kotlin/com/osserva/app/MusicPlayerWidget.kt` +- **Flutter Bridge:** `lib/core/services/widget_sync_service.dart` + +## Performance & Reliability + +- **Memory:** The native widget consumes minimal resources as it uses `RemoteViews`. +- **Efficiency:** The `WidgetSyncService` uses a "change detection" check to avoid redundant SharedPreferences writes and widget updates if the data hasn't changed. +- **Resilience:** The explicit intent targeting ensures the widget remains functional even if the app's process is reclaimed by the OS. diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index bf3c4eb..87d9799 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -36,6 +36,12 @@ @import flutter_native_splash; #endif +#if __has_include() +#import +#else +@import home_widget; +#endif + #if __has_include() #import #else @@ -80,6 +86,7 @@ + (void)registerWithRegistry:(NSObject*)registry { [AudiotagsPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudiotagsPlugin"]]; [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; [FlutterNativeSplashPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeSplashPlugin"]]; + [HomeWidgetPlugin registerWithRegistrar:[registry registrarForPlugin:@"HomeWidgetPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]]; [OnAudioQueryPlugin registerWithRegistrar:[registry registrarForPlugin:@"OnAudioQueryPlugin"]]; diff --git a/lib/core/di/init_dependencies.dart b/lib/core/di/init_dependencies.dart index d280618..828059b 100644 --- a/lib/core/di/init_dependencies.dart +++ b/lib/core/di/init_dependencies.dart @@ -11,6 +11,7 @@ import 'package:osserva/core/di/modules/onboarding_module.dart'; import 'package:osserva/core/di/modules/playlists_module.dart'; import 'package:osserva/core/di/modules/profile_module.dart'; import 'package:osserva/core/router/app_router.dart'; +import 'package:osserva/core/services/widget_sync_service.dart'; import 'package:osserva/features/analytics/data/datasources/audio_analytics_tracker.dart'; import 'package:osserva/features/background_notification/data/datasources/audio_handler.dart'; import 'package:osserva/features/home/presentation/bloc/home_bloc/home_bloc.dart'; @@ -32,19 +33,8 @@ Future initDependencies() async { serviceLocator.registerSingleton(audioPlayer); serviceLocator.registerSingleton(AppRouter()); - final audioHandler = await AudioService.init( - builder: () => MusicPlayerHandler(player: audioPlayer), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.osserva.app.channel.audio', - androidNotificationChannelName: 'Music Playback', - androidNotificationOngoing: true, - androidShowNotificationBadge: false, - ), - ); - serviceLocator.registerSingleton(audioHandler); - // ========================================================= - // 2. Features + // 2. Features (Must register before AudioHandler if it depends on them) // ========================================================= registerLocalMusicDependencies(serviceLocator); registerMusicPlayerDependencies(serviceLocator); @@ -55,13 +45,36 @@ Future initDependencies() async { registerFavoritesDependencies(serviceLocator); registerArtistsDependencies(serviceLocator); + final audioHandler = await AudioService.init( + builder: + () => MusicPlayerHandler( + player: audioPlayer, + getLocalSongsUseCase: serviceLocator(), + ), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.osserva.app.channel.audio', + androidNotificationChannelName: 'Music Playback', + androidNotificationOngoing: true, + androidShowNotificationBadge: false, + ), + ); + serviceLocator.registerSingleton(audioHandler); + // Home has no repository — registered inline, no module needed serviceLocator.registerFactory( () => HomeBloc(musicRepository: serviceLocator()), ); + serviceLocator.registerLazySingleton( + () => WidgetSyncService( + playerBloc: serviceLocator(), + audioQuery: serviceLocator(), + ), + ); + // ========================================================= // 3. Post-registration init // ========================================================= serviceLocator().init(); + serviceLocator().init(); } diff --git a/lib/core/di/modules/music_player_module.dart b/lib/core/di/modules/music_player_module.dart index 5a08391..c7e1760 100644 --- a/lib/core/di/modules/music_player_module.dart +++ b/lib/core/di/modules/music_player_module.dart @@ -8,5 +8,6 @@ void registerMusicPlayerDependencies(GetIt sl) { sl.registerLazySingleton( () => AudioPlayerRepositoryImpl(sl()), ); - sl.registerFactory(() => MusicPlayerBloc(sl(), sl(), sl())); + // registerFactory → registerLazySingleton + sl.registerLazySingleton(() => MusicPlayerBloc(sl(), sl(), sl())); } diff --git a/lib/core/os_widget/os_widget_manager.dart b/lib/core/os_widget/os_widget_manager.dart new file mode 100644 index 0000000..5722737 --- /dev/null +++ b/lib/core/os_widget/os_widget_manager.dart @@ -0,0 +1,24 @@ +import 'dart:async'; +import 'package:audio_service/audio_service.dart'; +import 'package:home_widget/home_widget.dart'; + +class OsWidgetManager { + static Future initialize(AudioHandler audioHandler) async { + HomeWidget.registerInteractivityCallback((uri) async { + if (uri == null) return; + + if (uri.host == 'playPause') { + final playing = audioHandler.playbackState.value.playing; + if (playing) { + await audioHandler.pause(); + } else { + await audioHandler.play(); + } + } else if (uri.host == 'next') { + await audioHandler.skipToNext(); + } else if (uri.host == 'previous') { + await audioHandler.skipToPrevious(); + } + }); + } +} diff --git a/lib/core/services/widget_sync_service.dart b/lib/core/services/widget_sync_service.dart new file mode 100644 index 0000000..0196e47 --- /dev/null +++ b/lib/core/services/widget_sync_service.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:home_widget/home_widget.dart'; +import 'package:on_audio_query/on_audio_query.dart'; +import 'package:osserva/features/music_player/presentation/bloc/music_player_bloc.dart'; +import 'package:osserva/features/music_player/presentation/bloc/music_player_event.dart'; +import 'package:osserva/features/music_player/presentation/bloc/music_player_state.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Bridges MusicPlayerBloc state → Android home screen widget. +/// Register as a singleton in DI and call [init] after the BLoC is ready. +class WidgetSyncService { + String? _lastSongTitle; + bool? _lastIsPlaying; + bool? _lastIsShuffle; + final MusicPlayerBloc _playerBloc; + final OnAudioQuery _audioQuery; + + StreamSubscription? _stateSub; + StreamSubscription? _clickSub; + + static const _androidWidgetName = 'MusicPlayerWidget'; + + WidgetSyncService({ + required MusicPlayerBloc playerBloc, + required OnAudioQuery audioQuery, + }) : _playerBloc = playerBloc, + _audioQuery = audioQuery; + + void init() { + // Register background callback (required by home_widget even if unused) + HomeWidget.registerInteractivityCallback(_widgetBackgroundCallback); + + // Handle shuffle button taps (other controls go direct via MediaKey) + _clickSub = HomeWidget.widgetClicked.listen(_onWidgetClicked); + + // Sync state changes to the widget + _stateSub = _playerBloc.stream.listen(_onPlayerStateChanged); + + // Push the current state immediately on init + _onPlayerStateChanged(_playerBloc.state); + } + + Future _onPlayerStateChanged(MusicPlayerState state) async { + final title = state.currentSong?.title; + final isPlaying = state.isPlaying; + final isShuffle = state.isShuffling; + + // Skip if nothing meaningful changed + if (title == _lastSongTitle && + isPlaying == _lastIsPlaying && + isShuffle == _lastIsShuffle) { + return; + } + + _lastSongTitle = title; + _lastIsPlaying = isPlaying; + _lastIsShuffle = isShuffle; + debugPrint( + '[WidgetSync] song=${state.currentSong?.title}, playing=${state.isPlaying}', + ); + + await Future.wait([ + HomeWidget.saveWidgetData( + 'song_title', + state.currentSong?.title ?? '', + ), + HomeWidget.saveWidgetData( + 'artist', + state.currentSong?.artist ?? '', + ), + HomeWidget.saveWidgetData('is_playing', state.isPlaying), + HomeWidget.saveWidgetData('is_shuffle', state.isShuffling), + ]); + + if (state.currentSong != null) { + await _saveArtwork(state.currentSong!.id); + } else { + await HomeWidget.saveWidgetData('art_path', ''); + } + + await HomeWidget.updateWidget(androidName: _androidWidgetName); + } + + Future _saveArtwork(int songId) async { + try { + final art = await _audioQuery.queryArtwork( + songId, + ArtworkType.AUDIO, + size: 200, + quality: 90, + ); + if (art != null && art.isNotEmpty) { + final dir = await getApplicationSupportDirectory(); + final file = File('${dir.path}/widget_art.png'); + await file.writeAsBytes(art); + await HomeWidget.saveWidgetData('art_path', file.path); + return; + } + } catch (_) {} + await HomeWidget.saveWidgetData('art_path', ''); + } + + void _onWidgetClicked(Uri? uri) { + if (uri == null) return; + if (uri.host == 'shuffle') { + _playerBloc.add(const MusicPlayerEvent.toggleShuffle()); + } + } + + void dispose() { + _stateSub?.cancel(); + _clickSub?.cancel(); + } +} + +/// Background callback — runs in a separate isolate when the app is not active. +/// Shuffle is handled in the foreground via [_onWidgetClicked]. +/// Play/Pause/Next/Prev are handled natively via MediaKey, so nothing needed here. +@pragma('vm:entry-point') +Future _widgetBackgroundCallback(Uri? uri) async { + // intentionally empty — see architecture comment above +} diff --git a/lib/features/background_notification/data/datasources/audio_handler.dart b/lib/features/background_notification/data/datasources/audio_handler.dart index 85041b0..dade636 100644 --- a/lib/features/background_notification/data/datasources/audio_handler.dart +++ b/lib/features/background_notification/data/datasources/audio_handler.dart @@ -3,19 +3,25 @@ import 'dart:developer'; import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:osserva/core/usecases/usecase.dart'; +import 'package:osserva/features/local_music/domain/usecases/get_local_songs_use_case.dart'; // This class isolates the "Background Service" logic from the rest of the app. // It is the Single Source of Truth for the OS. class MusicPlayerHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final AudioPlayer _player; + final GetLocalSongsUseCase _getLocalSongsUseCase; /// the [_subscriptions] variable to manage add/dispose of streams /// it holds a list of subscriptions final _subscriptions = []; - MusicPlayerHandler({AudioPlayer? player}) - : _player = player ?? AudioPlayer() { + MusicPlayerHandler({ + required AudioPlayer player, + required GetLocalSongsUseCase getLocalSongsUseCase, + }) : _player = player, + _getLocalSongsUseCase = getLocalSongsUseCase { _init(); } @@ -257,7 +263,39 @@ class MusicPlayerHandler extends BaseAudioHandler } @override - Future play() => _player.play(); + Future play() async { + if (_player.sequence.isEmpty) { + final result = await _getLocalSongsUseCase.call(NoParams()); + await result.fold( + (failure) async => log("Failed to load library on play: $failure"), + (songs) async { + if (songs.isNotEmpty) { + final items = songs.asMap().entries.map( + (entry) { + final index = entry.key; + final s = entry.value; + return MediaItem( + id: s.id.toString(), + album: s.album, + title: s.title, + artist: s.artist, + duration: Duration(milliseconds: s.duration.toInt()), + artUri: s.albumId != null + ? Uri.parse( + "content://media/external/audio/albumart/${s.albumId}", + ) + : null, + extras: {'url': s.path, 'uniqueId': '${s.id}_$index'}, + ); + }, + ).toList(); + await setQueueItems(items: items, initialIndex: 0); + } + }, + ); + } + return _player.play(); + } @override Future pause() => _player.pause(); diff --git a/lib/main.dart b/lib/main.dart index 97706ae..8c232e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -65,7 +65,9 @@ class _MyAppState extends State { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => serviceLocator()), + // BlocProvider.value → GetIt owns the instance, BlocProvider just exposes it + BlocProvider.value(value: serviceLocator()), + // BlocProvider(create: (_) => serviceLocator()), BlocProvider( create: (_) => serviceLocator() diff --git a/pubspec.lock b/pubspec.lock index 18659c0..64222bf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -560,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: d794a73894012459a4c63b94a6dc2cb3ccaa6eb08fb15b974aa7ac642594aed5 + url: "https://pub.dev" + source: hosted + version: "0.9.0" hooks: dependency: transitive description: @@ -963,7 +971,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/pubspec.yaml b/pubspec.yaml index 7becd17..b7dc59d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,8 @@ dependencies: stream_transform: ^2.1.1 media_store_plus: ^0.1.3 audiotags: ^1.1.3 + home_widget: ^0.9.0 + path_provider: ^2.1.5 #flutter_launcher_icons: ^0.14.4 dev_dependencies: diff --git a/test/features/background-notification-feature/data/datasources/music_player_handler_test.dart b/test/features/background-notification-feature/data/datasources/music_player_handler_test.dart index a0e965a..b9e66fd 100644 --- a/test/features/background-notification-feature/data/datasources/music_player_handler_test.dart +++ b/test/features/background-notification-feature/data/datasources/music_player_handler_test.dart @@ -3,14 +3,18 @@ import 'package:mocktail/mocktail.dart'; import 'package:just_audio/just_audio.dart'; import 'package:audio_service/audio_service.dart'; import 'package:osserva/features/background_notification/data/datasources/audio_handler.dart'; +import 'package:osserva/features/local_music/domain/usecases/get_local_songs_use_case.dart'; class MockAudioPlayer extends Mock implements AudioPlayer {} +class MockGetLocalSongsUseCase extends Mock implements GetLocalSongsUseCase {} + class FakeAudioSource extends Fake implements AudioSource {} void main() { late MusicPlayerHandler handler; late MockAudioPlayer mockPlayer; + late MockGetLocalSongsUseCase mockGetLocalSongsUseCase; setUpAll(() { registerFallbackValue(FakeAudioSource()); @@ -19,6 +23,7 @@ void main() { setUp(() { mockPlayer = MockAudioPlayer(); + mockGetLocalSongsUseCase = MockGetLocalSongsUseCase(); // Helper to allow void callbacks when( () => mockPlayer.playbackEventStream, @@ -63,7 +68,10 @@ void main() { ), ).thenAnswer((_) async => null); - handler = MusicPlayerHandler(player: mockPlayer); + handler = MusicPlayerHandler( + player: mockPlayer, + getLocalSongsUseCase: mockGetLocalSongsUseCase, + ); }); group('MusicPlayerHandler', () {