Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ Play counts, activity heatmaps, time-of-day distributions, per-artist breakdowns
<img src="assets/screenshots/top-genres-osserva.jpg" width="200" />
<img src="assets/screenshots/top-songs-seeded-osserva.jpg" width="200" />
<img src="assets/screenshots/top-artists-seeded-osserva.jpg" width="200" />
<img src="assets/screenshots/os-widget.jpg" width="400" />
</p>

## 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`)

Expand Down Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="audiography" android:host="widget"/>
</intent-filter>
</activity>

<!-- 3. AUDIO SERVICE CONFIGURATION -->
Expand All @@ -65,6 +71,20 @@
</intent-filter>
</service>

<receiver
android:name=".MusicPlayerWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.osserva.app.WIDGET_PLAY_PAUSE" />
<action android:name="com.osserva.app.WIDGET_NEXT" />
<action android:name="com.osserva.app.WIDGET_PREV" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/music_widget_info" />
</receiver>

<!-- The Receiver that handles Headphone buttons / Notification clicks -->
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
Expand Down
141 changes: 141 additions & 0 deletions android/app/src/main/kotlin/com/osserva/app/MusicPlayerWidget.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions android/app/src/main/res/drawable/art_rounded_bg.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#33FFFFFF" />
<corners android:radius="8dp" />
</shape>
10 changes: 10 additions & 0 deletions android/app/src/main/res/drawable/ic_default_art.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#99FFFFFF"
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"/>
</vector>
11 changes: 11 additions & 0 deletions android/app/src/main/res/drawable/ic_pause.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4V5H6v14zm8,-14v14h4V5h-4z"/>
</vector>
10 changes: 10 additions & 0 deletions android/app/src/main/res/drawable/ic_play.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z"/>
</vector>
11 changes: 11 additions & 0 deletions android/app/src/main/res/drawable/ic_play_arrow.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z"/>
</vector>
11 changes: 11 additions & 0 deletions android/app/src/main/res/drawable/ic_skip_next.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
</vector>
11 changes: 11 additions & 0 deletions android/app/src/main/res/drawable/ic_skip_previous.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h2v12H6zm3.5,6l8.5,6V6z"/>
</vector>
5 changes: 5 additions & 0 deletions android/app/src/main/res/drawable/play_button_bg.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFFFF" />
</shape>
5 changes: 5 additions & 0 deletions android/app/src/main/res/drawable/widget_background.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E6121212" />
<corners android:radius="16dp" />
</shape>
105 changes: 105 additions & 0 deletions android/app/src/main/res/layout/music_widget.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/widget_background"
android:padding="8dp">

<!-- Album Art -->
<ImageView
android:id="@+id/widget_album_art"
android:layout_width="52dp"
android:layout_height="52dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_default_art"
android:background="@drawable/art_rounded_bg"
android:clipToOutline="true"
android:contentDescription="Album art" />

<!-- Title + Artist -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingEnd="4dp">

<TextView
android:id="@+id/widget_song_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Not Playing"
android:textColor="#FFFFFFFF"
android:textSize="13sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:focusable="true"
android:focusableInTouchMode="true"
android:scrollHorizontally="true" />

<TextView
android:id="@+id/widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textColor="#99FFFFFF"
android:textSize="11sp"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>

<!-- Shuffle -->
<ImageButton
android:id="@+id/widget_btn_shuffle"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_shuffle"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:tint="#99FFFFFF"
android:contentDescription="Shuffle"
android:scaleType="fitCenter"
android:padding="6dp" />

<!-- Previous -->
<ImageButton
android:id="@+id/widget_btn_prev"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_skip_previous"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:tint="#FFFFFFFF"
android:contentDescription="Previous"
android:scaleType="fitCenter"
android:padding="4dp" />

<!-- Play/Pause -->
<ImageButton
android:id="@+id/widget_btn_play_pause"
android:layout_width="44dp"
android:layout_height="44dp"
android:src="@drawable/ic_play"
android:background="@drawable/play_button_bg"
android:tint="#FF000000"
android:contentDescription="Play/Pause"
android:scaleType="fitCenter"
android:padding="8dp" />

<!-- Next -->
<ImageButton
android:id="@+id/widget_btn_next"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_skip_next"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:tint="#FFFFFFFF"
android:contentDescription="Next"
android:scaleType="fitCenter"
android:padding="4dp" />

</LinearLayout>
Loading
Loading