Skip to content

Commit 9af8ddf

Browse files
authored
Merge pull request #18 from fadyphil/feat/mobile-widget
Feat/mobile widget
2 parents cbe6387 + d963bf4 commit 9af8ddf

27 files changed

Lines changed: 660 additions & 19 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,15 @@ Play counts, activity heatmaps, time-of-day distributions, per-artist breakdowns
2929
<img src="assets/screenshots/top-genres-osserva.jpg" width="200" />
3030
<img src="assets/screenshots/top-songs-seeded-osserva.jpg" width="200" />
3131
<img src="assets/screenshots/top-artists-seeded-osserva.jpg" width="200" />
32+
<img src="assets/screenshots/os-widget.jpg" width="400" />
3233
</p>
3334

3435
## Features
3536

3637
### Playback
3738
- Seamless background playback with lock-screen and notification controls
39+
- **Android Home Screen Widget:** Real-time state synchronization (title, artist, album art, play/pause state)
40+
- **Cold Start Support:** Start playback directly from the widget even if the app is closed
3841
- Full-screen player with queue manager and sleep timer
3942
- Android 13+ permission handling (`READ_MEDIA_AUDIO`)
4043

@@ -82,6 +85,7 @@ Each feature owns its own `data`, `domain`, and `presentation` layers. Cross-fea
8285
| Dependency Injection | `get_it` | Service locator; features register their own modules |
8386
| Navigation | `auto_route` | Strongly-typed, declarative routing |
8487
| Audio Playback | `just_audio` + `audio_service` | Core engine + background service handler |
88+
| OS Widget | `home_widget` | Bridge for Android Home Screen Widget state |
8589
| Media Querying | `on_audio_query` (forked) | Optimized local media retrieval |
8690
| Database | `sqflite` | SQLite for analytics (star schema, daily rollups) |
8791
| Error Handling | `fpdart` | `Either` types throughout the domain layer |

android/app/src/main/AndroidManifest.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
<action android:name="android.intent.action.MAIN"/>
5353
<category android:name="android.intent.category.LAUNCHER"/>
5454
</intent-filter>
55+
<intent-filter>
56+
<action android:name="android.intent.action.VIEW"/>
57+
<category android:name="android.intent.category.DEFAULT"/>
58+
<category android:name="android.intent.category.BROWSABLE"/>
59+
<data android:scheme="audiography" android:host="widget"/>
60+
</intent-filter>
5561
</activity>
5662

5763
<!-- 3. AUDIO SERVICE CONFIGURATION -->
@@ -65,6 +71,20 @@
6571
</intent-filter>
6672
</service>
6773

74+
<receiver
75+
android:name=".MusicPlayerWidget"
76+
android:exported="true">
77+
<intent-filter>
78+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
79+
<action android:name="com.osserva.app.WIDGET_PLAY_PAUSE" />
80+
<action android:name="com.osserva.app.WIDGET_NEXT" />
81+
<action android:name="com.osserva.app.WIDGET_PREV" />
82+
</intent-filter>
83+
<meta-data
84+
android:name="android.appwidget.provider"
85+
android:resource="@xml/music_widget_info" />
86+
</receiver>
87+
6888
<!-- The Receiver that handles Headphone buttons / Notification clicks -->
6989
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
7090
android:exported="true">
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.osserva.app
2+
3+
import android.appwidget.AppWidgetManager
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.SharedPreferences
7+
import android.graphics.BitmapFactory
8+
import android.media.AudioManager
9+
import android.net.Uri
10+
import android.os.SystemClock
11+
import android.view.KeyEvent
12+
import android.widget.RemoteViews
13+
import android.app.PendingIntent
14+
import es.antonborri.home_widget.HomeWidgetProvider
15+
import es.antonborri.home_widget.HomeWidgetLaunchIntent
16+
17+
class MusicPlayerWidget : HomeWidgetProvider() {
18+
19+
companion object {
20+
private const val ACTION_PLAY_PAUSE = "com.osserva.app.WIDGET_PLAY_PAUSE"
21+
private const val ACTION_NEXT = "com.osserva.app.WIDGET_NEXT"
22+
private const val ACTION_PREV = "com.osserva.app.WIDGET_PREV"
23+
}
24+
25+
override fun onUpdate(
26+
context: Context,
27+
appWidgetManager: AppWidgetManager,
28+
appWidgetIds: IntArray,
29+
widgetData: SharedPreferences
30+
) {
31+
for (widgetId in appWidgetIds) {
32+
updateWidget(context, appWidgetManager, widgetId, widgetData)
33+
}
34+
}
35+
36+
override fun onReceive(context: Context, intent: Intent) {
37+
super.onReceive(context, intent)
38+
when (intent.action) {
39+
ACTION_PLAY_PAUSE -> sendMediaButton(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
40+
ACTION_NEXT -> sendMediaButton(context, KeyEvent.KEYCODE_MEDIA_NEXT)
41+
ACTION_PREV -> sendMediaButton(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
42+
}
43+
}
44+
45+
private fun sendMediaButton(context: Context, keyCode: Int) {
46+
val eventTime = SystemClock.uptimeMillis()
47+
48+
// ACTION_DOWN
49+
val downIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
50+
downIntent.setClassName(context.packageName, "com.ryanheise.audioservice.MediaButtonReceiver")
51+
downIntent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
52+
context.sendBroadcast(downIntent)
53+
54+
// ACTION_UP
55+
val upIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
56+
upIntent.setClassName(context.packageName, "com.ryanheise.audioservice.MediaButtonReceiver")
57+
upIntent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, keyCode, 0))
58+
context.sendBroadcast(upIntent)
59+
}
60+
61+
private fun updateWidget(
62+
context: Context,
63+
appWidgetManager: AppWidgetManager,
64+
widgetId: Int,
65+
widgetData: SharedPreferences
66+
) {
67+
val title = widgetData.getString("song_title", "") ?: ""
68+
val artist = widgetData.getString("artist", "") ?: ""
69+
val isPlaying = widgetData.getBoolean("is_playing", false)
70+
val isShuffle = widgetData.getBoolean("is_shuffle", false)
71+
val artPath = widgetData.getString("art_path", "") ?: ""
72+
73+
val views = RemoteViews(context.packageName, R.layout.music_widget)
74+
75+
// Text
76+
views.setTextViewText(R.id.widget_song_title, if (title.isEmpty()) "Not Playing" else title)
77+
views.setTextViewText(R.id.widget_artist, artist)
78+
79+
// Album art
80+
if (artPath.isNotEmpty()) {
81+
val bitmap = BitmapFactory.decodeFile(artPath)
82+
if (bitmap != null) {
83+
views.setImageViewBitmap(R.id.widget_album_art, bitmap)
84+
} else {
85+
views.setImageViewResource(R.id.widget_album_art, R.drawable.ic_default_art)
86+
}
87+
} else {
88+
views.setImageViewResource(R.id.widget_album_art, R.drawable.ic_default_art)
89+
}
90+
91+
// Play/Pause icon
92+
val playPauseIcon = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play
93+
views.setImageViewResource(R.id.widget_btn_play_pause, playPauseIcon)
94+
95+
// Shuffle tint (active = accent color, inactive = white with opacity)
96+
val shuffleTint = if (isShuffle) 0xFF1DB954.toInt() else 0x99FFFFFF.toInt()
97+
views.setInt(R.id.widget_btn_shuffle, "setColorFilter", shuffleTint)
98+
99+
// Button intents
100+
views.setOnClickPendingIntent(
101+
R.id.widget_btn_play_pause,
102+
broadcastIntent(context, ACTION_PLAY_PAUSE, widgetId),
103+
)
104+
views.setOnClickPendingIntent(
105+
R.id.widget_btn_next,
106+
broadcastIntent(context, ACTION_NEXT, widgetId),
107+
)
108+
views.setOnClickPendingIntent(
109+
R.id.widget_btn_prev,
110+
broadcastIntent(context, ACTION_PREV, widgetId),
111+
)
112+
// Shuffle -> launches Flutter via HomeWidget URI
113+
views.setOnClickPendingIntent(
114+
R.id.widget_btn_shuffle,
115+
shuffleLaunchIntent(context),
116+
)
117+
118+
appWidgetManager.updateAppWidget(widgetId, views)
119+
}
120+
121+
private fun broadcastIntent(context: Context, action: String, widgetId: Int): PendingIntent {
122+
val intent = Intent(context, MusicPlayerWidget::class.java).apply {
123+
this.action = action
124+
}
125+
return PendingIntent.getBroadcast(
126+
context,
127+
widgetId,
128+
intent,
129+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
130+
)
131+
}
132+
133+
private fun shuffleLaunchIntent(context: Context): PendingIntent {
134+
val intent = HomeWidgetLaunchIntent.getActivity(
135+
context,
136+
MainActivity::class.java,
137+
Uri.parse("audiography://widget/shuffle")
138+
)
139+
return intent
140+
}
141+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android">
3+
<solid android:color="#33FFFFFF" />
4+
<corners android:radius="8dp" />
5+
</shape>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24.0"
6+
android:viewportHeight="24.0">
7+
<path
8+
android:fillColor="#99FFFFFF"
9+
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
10+
</vector>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24.0"
6+
android:viewportHeight="24.0"
7+
android:tint="#FFFFFF">
8+
<path
9+
android:fillColor="@android:color/white"
10+
android:pathData="M6,19h4V5H6v14zm8,-14v14h4V5h-4z"/>
11+
</vector>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24.0"
6+
android:viewportHeight="24.0">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M8,5v14l11,-7z"/>
10+
</vector>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24.0"
6+
android:viewportHeight="24.0"
7+
android:tint="#FFFFFF">
8+
<path
9+
android:fillColor="@android:color/white"
10+
android:pathData="M8,5v14l11,-7z"/>
11+
</vector>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24.0"
6+
android:viewportHeight="24.0"
7+
android:tint="#FFFFFF">
8+
<path
9+
android:fillColor="@android:color/white"
10+
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
11+
</vector>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24.0"
6+
android:viewportHeight="24.0"
7+
android:tint="#FFFFFF">
8+
<path
9+
android:fillColor="@android:color/white"
10+
android:pathData="M6,6h2v12H6zm3.5,6l8.5,6V6z"/>
11+
</vector>

0 commit comments

Comments
 (0)