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', () {