diff --git a/modules/astra-widget/android/build.gradle b/modules/astra-widget/android/build.gradle
new file mode 100644
index 0000000..00126ad
--- /dev/null
+++ b/modules/astra-widget/android/build.gradle
@@ -0,0 +1,18 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.astrawidget'
+version = '0.1.0'
+
+android {
+ namespace "expo.modules.astrawidget"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
diff --git a/modules/astra-widget/android/src/main/AndroidManifest.xml b/modules/astra-widget/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a7e0bb2
--- /dev/null
+++ b/modules/astra-widget/android/src/main/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraNowPlayingWidgetProvider.kt b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraNowPlayingWidgetProvider.kt
new file mode 100644
index 0000000..4b90151
--- /dev/null
+++ b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraNowPlayingWidgetProvider.kt
@@ -0,0 +1,61 @@
+package expo.modules.astrawidget
+
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
+import android.content.Intent
+import android.media.AudioManager
+import android.os.Build
+import android.os.Bundle
+import android.os.SystemClock
+import android.view.KeyEvent
+
+class AstraNowPlayingWidgetProvider : AppWidgetProvider() {
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ AstraWidgetUpdater.updateWidgets(context, appWidgetManager, appWidgetIds)
+ }
+
+ override fun onAppWidgetOptionsChanged(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ newOptions: Bundle,
+ ) {
+ AstraWidgetUpdater.updateWidget(context, appWidgetManager, appWidgetId, newOptions)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+
+ when (intent.action) {
+ ACTION_PREVIOUS -> dispatchMediaKey(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
+ ACTION_PLAY_PAUSE -> dispatchMediaKey(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
+ ACTION_NEXT -> dispatchMediaKey(context, KeyEvent.KEYCODE_MEDIA_NEXT)
+ }
+ }
+
+ private fun dispatchMediaKey(context: Context, keyCode: Int) {
+ val audioManager =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ context.getSystemService(AudioManager::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
+ } ?: return
+
+ val eventTime = SystemClock.uptimeMillis()
+ audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
+ audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, keyCode, 0))
+ }
+
+ companion object {
+ private const val ACTION_PREFIX = "com.astra.mobile.widget"
+ const val ACTION_PREVIOUS = "$ACTION_PREFIX.PREVIOUS"
+ const val ACTION_PLAY_PAUSE = "$ACTION_PREFIX.PLAY_PAUSE"
+ const val ACTION_NEXT = "$ACTION_PREFIX.NEXT"
+ }
+}
diff --git a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt
new file mode 100644
index 0000000..77d9d9c
--- /dev/null
+++ b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt
@@ -0,0 +1,82 @@
+package expo.modules.astrawidget
+
+import android.content.Context
+import expo.modules.kotlin.exception.Exceptions
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.records.Field
+import expo.modules.kotlin.records.Record
+
+class AstraWidgetNowPlayingState : Record {
+ @Field
+ val title: String? = null
+
+ @Field
+ val artist: String? = null
+
+ @Field
+ val artworkUri: String? = null
+
+ @Field
+ val playbackState: String = "stopped"
+
+ @Field
+ val hasTrack: Boolean = false
+
+ @Field
+ val recentlyPlayed: List = emptyList()
+
+ @Field
+ val replaceRecentlyPlayed: Boolean = false
+}
+
+class AstraWidgetRecentItemState : Record {
+ @Field
+ val title: String? = null
+
+ @Field
+ val artist: String? = null
+
+ @Field
+ val artworkUri: String? = null
+}
+
+class AstraWidgetModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("AstraWidget")
+
+ Function("setNowPlaying") { state: AstraWidgetNowPlayingState ->
+ val context = requireContext()
+ val previous = AstraWidgetStateStore.load(context)
+ val recentlyPlayed =
+ if (state.replaceRecentlyPlayed) {
+ state.recentlyPlayed
+ .map {
+ AstraWidgetRecentItem(
+ title = it.title,
+ artist = it.artist,
+ artworkUri = it.artworkUri,
+ )
+ }
+ .take(8)
+ } else {
+ previous.recentlyPlayed
+ }
+ AstraWidgetStateStore.save(
+ context,
+ AstraWidgetState(
+ title = state.title,
+ artist = state.artist,
+ artworkUri = if (state.hasTrack) state.artworkUri else null,
+ playbackState = state.playbackState,
+ hasTrack = state.hasTrack,
+ recentlyPlayed = recentlyPlayed,
+ ),
+ )
+ AstraWidgetUpdater.updateAll(context)
+ }
+ }
+
+ private fun requireContext(): Context =
+ appContext.reactContext ?: throw Exceptions.ReactContextLost()
+}
diff --git a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetState.kt b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetState.kt
new file mode 100644
index 0000000..b6437ec
--- /dev/null
+++ b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetState.kt
@@ -0,0 +1,93 @@
+package expo.modules.astrawidget
+
+import android.content.Context
+import org.json.JSONArray
+import org.json.JSONObject
+
+data class AstraWidgetState(
+ val title: String?,
+ val artist: String?,
+ val artworkUri: String?,
+ val playbackState: String,
+ val hasTrack: Boolean,
+ val recentlyPlayed: List,
+)
+
+data class AstraWidgetRecentItem(
+ val title: String?,
+ val artist: String?,
+ val artworkUri: String?,
+)
+
+object AstraWidgetStateStore {
+ private const val PREFS_NAME = "astra_widget_now_playing"
+ private const val KEY_TITLE = "title"
+ private const val KEY_ARTIST = "artist"
+ private const val KEY_ARTWORK_URI = "artwork_uri"
+ private const val KEY_PLAYBACK_STATE = "playback_state"
+ private const val KEY_HAS_TRACK = "has_track"
+ private const val KEY_RECENTLY_PLAYED = "recently_played"
+
+ fun save(context: Context, state: AstraWidgetState) {
+ context.applicationContext
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_TITLE, state.title)
+ .putString(KEY_ARTIST, state.artist)
+ .putString(KEY_ARTWORK_URI, state.artworkUri)
+ .putString(KEY_PLAYBACK_STATE, state.playbackState)
+ .putBoolean(KEY_HAS_TRACK, state.hasTrack)
+ .putString(KEY_RECENTLY_PLAYED, encodeRecentlyPlayed(state.recentlyPlayed))
+ .apply()
+ }
+
+ fun load(context: Context): AstraWidgetState {
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ return AstraWidgetState(
+ title = prefs.getString(KEY_TITLE, null),
+ artist = prefs.getString(KEY_ARTIST, null),
+ artworkUri = prefs.getString(KEY_ARTWORK_URI, null),
+ playbackState = prefs.getString(KEY_PLAYBACK_STATE, "stopped") ?: "stopped",
+ hasTrack = prefs.getBoolean(KEY_HAS_TRACK, false),
+ recentlyPlayed = decodeRecentlyPlayed(prefs.getString(KEY_RECENTLY_PLAYED, null)),
+ )
+ }
+
+ private fun encodeRecentlyPlayed(items: List): String {
+ val array = JSONArray()
+ items.take(8).forEach { item ->
+ array.put(
+ JSONObject()
+ .put("title", item.title)
+ .put("artist", item.artist)
+ .put("artworkUri", item.artworkUri),
+ )
+ }
+ return array.toString()
+ }
+
+ private fun decodeRecentlyPlayed(value: String?): List {
+ if (value.isNullOrBlank()) return emptyList()
+
+ return runCatching {
+ val array = JSONArray(value)
+ buildList {
+ for (index in 0 until minOf(array.length(), 8)) {
+ val item = array.optJSONObject(index) ?: continue
+ add(
+ AstraWidgetRecentItem(
+ title = item.optNullableString("title"),
+ artist = item.optNullableString("artist"),
+ artworkUri = item.optNullableString("artworkUri"),
+ ),
+ )
+ }
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ private fun JSONObject.optNullableString(key: String): String? {
+ if (!has(key) || isNull(key)) return null
+ return optString(key).trim().takeIf { it.isNotEmpty() }
+ }
+}
diff --git a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt
new file mode 100644
index 0000000..a09fd05
--- /dev/null
+++ b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt
@@ -0,0 +1,401 @@
+package expo.modules.astrawidget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.graphics.BitmapShader
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Shader
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.util.Base64
+import android.util.SizeF
+import android.view.View
+import android.widget.RemoteViews
+import java.io.File
+
+object AstraWidgetUpdater {
+ private const val REQUEST_OPEN_APP = 100
+ private const val REQUEST_OPEN_RECENTS = 101
+
+ private const val ONE_ROW_MAX_HEIGHT_DP = 110
+ private const val THREE_ROW_MIN_HEIGHT_DP = 300
+ private const val FOUR_CELL_MIN_WIDTH_DP = 300
+ private const val FIVE_CELL_MIN_WIDTH_DP = 370
+ private const val ARTWORK_CORNER_RADIUS_DP = 8f
+
+ private val RECENT_IMAGE_IDS = intArrayOf(
+ R.id.astra_widget_recent_1_image,
+ R.id.astra_widget_recent_2_image,
+ R.id.astra_widget_recent_3_image,
+ R.id.astra_widget_recent_4_image,
+ R.id.astra_widget_recent_5_image,
+ R.id.astra_widget_recent_6_image,
+ R.id.astra_widget_recent_7_image,
+ R.id.astra_widget_recent_8_image,
+ )
+
+ private val RECENT_LABEL_IDS = intArrayOf(
+ R.id.astra_widget_recent_1_label,
+ R.id.astra_widget_recent_2_label,
+ R.id.astra_widget_recent_3_label,
+ R.id.astra_widget_recent_4_label,
+ R.id.astra_widget_recent_5_label,
+ R.id.astra_widget_recent_6_label,
+ R.id.astra_widget_recent_7_label,
+ R.id.astra_widget_recent_8_label,
+ )
+
+ fun updateAll(context: Context) {
+ val appContext = context.applicationContext
+ val manager = AppWidgetManager.getInstance(appContext)
+ val component = ComponentName(appContext, AstraNowPlayingWidgetProvider::class.java)
+ val appWidgetIds = manager.getAppWidgetIds(component)
+ updateWidgets(appContext, manager, appWidgetIds)
+ }
+
+ fun updateWidgets(context: Context, manager: AppWidgetManager, appWidgetIds: IntArray) {
+ if (appWidgetIds.isEmpty()) return
+
+ val state = AstraWidgetStateStore.load(context)
+ appWidgetIds.forEach { appWidgetId ->
+ val options = manager.getAppWidgetOptions(appWidgetId)
+ manager.updateAppWidget(appWidgetId, buildRemoteViews(context, state, options))
+ }
+ }
+
+ fun updateWidget(context: Context, manager: AppWidgetManager, appWidgetId: Int, options: Bundle) {
+ val state = AstraWidgetStateStore.load(context)
+ manager.updateAppWidget(appWidgetId, buildRemoteViews(context, state, options))
+ }
+
+ private fun buildRemoteViews(context: Context, state: AstraWidgetState, options: Bundle): RemoteViews {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ return buildResponsiveRemoteViews(context, state)
+ }
+
+ return buildBucketRemoteViews(context, state, WidgetLayoutBucket.fromOptions(options))
+ }
+
+ private fun buildResponsiveRemoteViews(
+ context: Context,
+ state: AstraWidgetState,
+ ): RemoteViews {
+ val mapping =
+ WidgetLayoutBucket.entries.associate { bucket ->
+ bucket.minSize to buildBucketRemoteViews(context, state, bucket)
+ }
+
+ return RemoteViews(mapping)
+ }
+
+ private fun buildBucketRemoteViews(
+ context: Context,
+ state: AstraWidgetState,
+ bucket: WidgetLayoutBucket,
+ ): RemoteViews {
+ val views = RemoteViews(context.packageName, bucket.layoutRes)
+ val title = if (state.hasTrack) state.title.cleanOrFallback("Unknown title") else "Astra"
+ val artist = if (state.hasTrack) state.artist.cleanOrFallback("Unknown artist") else "Tap to open"
+ val isPlaying = state.hasTrack && state.playbackState == "playing"
+
+ views.setTextViewText(R.id.astra_widget_title, title)
+ views.setTextViewText(R.id.astra_widget_artist, artist)
+ setImageView(context, views, R.id.astra_widget_art, state.artworkUri, 320)
+ views.setOnClickPendingIntent(R.id.astra_widget_root, openAppPendingIntent(context))
+
+ if (bucket.hasPlayPause) {
+ bindPlayPause(views, context, isPlaying, state.hasTrack)
+ }
+ if (bucket.hasNext) {
+ bindControl(views, context, R.id.astra_widget_next, AstraNowPlayingWidgetProvider.ACTION_NEXT, 3, state.hasTrack)
+ }
+ if (bucket.hasPrevious) {
+ bindControl(
+ views,
+ context,
+ R.id.astra_widget_previous,
+ AstraNowPlayingWidgetProvider.ACTION_PREVIOUS,
+ 1,
+ state.hasTrack,
+ )
+ }
+ if (bucket.recentCount > 0) {
+ bindRecentlyPlayed(context, views, state.recentlyPlayed, bucket)
+ }
+
+ return views
+ }
+
+ private fun bindPlayPause(
+ views: RemoteViews,
+ context: Context,
+ isPlaying: Boolean,
+ controlsEnabled: Boolean,
+ ) {
+ views.setImageViewResource(
+ R.id.astra_widget_play_pause,
+ if (isPlaying) R.drawable.astra_widget_ic_pause else R.drawable.astra_widget_ic_play,
+ )
+ views.setContentDescription(R.id.astra_widget_play_pause, if (isPlaying) "Pause" else "Play")
+ bindControl(
+ views,
+ context,
+ R.id.astra_widget_play_pause,
+ AstraNowPlayingWidgetProvider.ACTION_PLAY_PAUSE,
+ 2,
+ controlsEnabled,
+ )
+ }
+
+ private fun bindControl(
+ views: RemoteViews,
+ context: Context,
+ viewId: Int,
+ action: String,
+ requestCode: Int,
+ controlsEnabled: Boolean,
+ ) {
+ views.setBoolean(viewId, "setEnabled", controlsEnabled)
+ views.setOnClickPendingIntent(viewId, controlPendingIntent(context, action, requestCode))
+ }
+
+ private fun bindRecentlyPlayed(
+ context: Context,
+ views: RemoteViews,
+ recentlyPlayed: List,
+ bucket: WidgetLayoutBucket,
+ ) {
+ val openRecents = openRecentlyPlayedPendingIntent(context)
+ views.setOnClickPendingIntent(R.id.astra_widget_recent_container, openRecents)
+
+ for (index in 0 until bucket.recentCount) {
+ val item = recentlyPlayed.getOrNull(index)
+ val imageId = RECENT_IMAGE_IDS[index]
+ setImageView(context, views, imageId, item?.artworkUri, 128)
+ views.setContentDescription(imageId, item?.title.cleanOrFallback("Recently played"))
+ views.setOnClickPendingIntent(imageId, openRecents)
+
+ if (bucket.showRecentLabels) {
+ val labelId = RECENT_LABEL_IDS[index]
+ views.setTextViewText(labelId, item?.title.cleanOrFallback("Recently played"))
+ views.setViewVisibility(labelId, View.VISIBLE)
+ }
+ }
+ }
+
+ private fun setImageView(
+ context: Context,
+ views: RemoteViews,
+ viewId: Int,
+ uri: String?,
+ maxPx: Int,
+ ) {
+ val bitmap = decodeBitmap(context, uri, maxPx)?.let { prepareArtworkBitmap(context, it) }
+ if (bitmap != null) {
+ views.setImageViewBitmap(viewId, bitmap)
+ } else {
+ views.setImageViewResource(viewId, R.drawable.astra_widget_art_placeholder)
+ }
+ }
+
+ private fun prepareArtworkBitmap(context: Context, bitmap: Bitmap): Bitmap {
+ val square = cropCenterSquare(bitmap)
+ val radiusPx = ARTWORK_CORNER_RADIUS_DP * context.resources.displayMetrics.density
+ return roundBitmap(square, radiusPx)
+ }
+
+ private fun cropCenterSquare(bitmap: Bitmap): Bitmap {
+ val size = minOf(bitmap.width, bitmap.height)
+ if (size <= 0) return bitmap
+
+ val left = (bitmap.width - size) / 2
+ val top = (bitmap.height - size) / 2
+ return Bitmap.createBitmap(bitmap, left, top, size, size)
+ }
+
+ private fun roundBitmap(bitmap: Bitmap, radiusPx: Float): Bitmap {
+ val output = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(output)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
+ }
+ val rect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat())
+ canvas.drawRoundRect(rect, radiusPx, radiusPx, paint)
+ return output
+ }
+
+ private fun openAppPendingIntent(context: Context): PendingIntent? {
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ action = Intent.ACTION_VIEW
+ data = Uri.parse("trackplayer://notification.click")
+ }
+
+ return intent?.let {
+ PendingIntent.getActivity(
+ context,
+ REQUEST_OPEN_APP,
+ it,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ }
+
+ private fun openRecentlyPlayedPendingIntent(context: Context): PendingIntent {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("astra:///recently-played")).apply {
+ setPackage(context.packageName)
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+
+ return PendingIntent.getActivity(
+ context,
+ REQUEST_OPEN_RECENTS,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun controlPendingIntent(context: Context, action: String, requestCode: Int): PendingIntent {
+ val intent = Intent(context, AstraNowPlayingWidgetProvider::class.java).setAction(action)
+ return PendingIntent.getBroadcast(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun decodeBitmap(context: Context, uri: String?, maxPx: Int): Bitmap? {
+ if (uri.isNullOrBlank()) return null
+ val bytes = runCatching { readImageBytes(context, uri) }.getOrNull() ?: return null
+ if (bytes.isEmpty()) return null
+
+ return runCatching {
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
+ val largest = maxOf(bounds.outWidth, bounds.outHeight)
+ val sampleSize = if (largest <= maxPx || maxPx <= 0) {
+ 1
+ } else {
+ Integer.highestOneBit(largest / maxPx).coerceAtLeast(1)
+ }
+ val options = BitmapFactory.Options().apply { inSampleSize = sampleSize }
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
+ }.getOrNull()
+ }
+
+ private fun readImageBytes(context: Context, value: String): ByteArray? {
+ if (value.startsWith("data:image", ignoreCase = true)) {
+ val encoded = value.substringAfter(',', missingDelimiterValue = "")
+ if (encoded.isBlank()) return null
+ return Base64.decode(encoded, Base64.DEFAULT)
+ }
+
+ val uri = Uri.parse(value)
+ return when (uri.scheme) {
+ "file" -> uri.path?.let { File(it).readBytes() }
+ else -> context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ }
+ }
+
+ private fun String?.cleanOrFallback(fallback: String): String {
+ val value = this?.trim()
+ return if (value.isNullOrEmpty()) fallback else value
+ }
+
+ private enum class WidgetLayoutBucket(
+ val layoutRes: Int,
+ val hasPrevious: Boolean,
+ val hasPlayPause: Boolean,
+ val hasNext: Boolean,
+ val recentCount: Int,
+ val showRecentLabels: Boolean,
+ val minSize: SizeF,
+ ) {
+ Compact3x1(
+ R.layout.astra_widget_3x1,
+ hasPrevious = false,
+ hasPlayPause = false,
+ hasNext = false,
+ recentCount = 0,
+ showRecentLabels = false,
+ minSize = SizeF(180f, 40f),
+ ),
+ Compact4x1(
+ R.layout.astra_widget_4x1,
+ hasPrevious = false,
+ hasPlayPause = true,
+ hasNext = true,
+ recentCount = 0,
+ showRecentLabels = false,
+ minSize = SizeF(FOUR_CELL_MIN_WIDTH_DP.toFloat(), 40f),
+ ),
+ Wide5x1(
+ R.layout.astra_widget_5x1,
+ hasPrevious = true,
+ hasPlayPause = true,
+ hasNext = true,
+ recentCount = 0,
+ showRecentLabels = false,
+ minSize = SizeF(FIVE_CELL_MIN_WIDTH_DP.toFloat(), 40f),
+ ),
+ Tall3x3(
+ R.layout.astra_widget_3x3,
+ hasPrevious = false,
+ hasPlayPause = true,
+ hasNext = true,
+ recentCount = 0,
+ showRecentLabels = false,
+ minSize = SizeF(180f, ONE_ROW_MAX_HEIGHT_DP.toFloat()),
+ ),
+ Expanded4x2(
+ R.layout.astra_widget_4x2,
+ hasPrevious = true,
+ hasPlayPause = true,
+ hasNext = true,
+ recentCount = 5,
+ showRecentLabels = false,
+ minSize = SizeF(FOUR_CELL_MIN_WIDTH_DP.toFloat(), ONE_ROW_MAX_HEIGHT_DP.toFloat()),
+ ),
+ Expanded4x3(
+ R.layout.astra_widget_4x3,
+ hasPrevious = true,
+ hasPlayPause = true,
+ hasNext = true,
+ recentCount = 8,
+ showRecentLabels = true,
+ minSize = SizeF(FOUR_CELL_MIN_WIDTH_DP.toFloat(), THREE_ROW_MIN_HEIGHT_DP.toFloat()),
+ );
+
+ companion object {
+ fun fromOptions(options: Bundle): WidgetLayoutBucket {
+ val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, FOUR_CELL_MIN_WIDTH_DP)
+ val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, ONE_ROW_MAX_HEIGHT_DP)
+
+ return fromSize(minWidth.toFloat(), minHeight.toFloat())
+ }
+
+ fun fromSize(widthDp: Float, heightDp: Float): WidgetLayoutBucket {
+ if (heightDp < ONE_ROW_MAX_HEIGHT_DP) {
+ return when {
+ widthDp >= FIVE_CELL_MIN_WIDTH_DP -> Wide5x1
+ widthDp >= FOUR_CELL_MIN_WIDTH_DP -> Compact4x1
+ else -> Compact3x1
+ }
+ }
+
+ if (widthDp < FOUR_CELL_MIN_WIDTH_DP) return Tall3x3
+ if (heightDp < THREE_ROW_MIN_HEIGHT_DP) return Expanded4x2
+ return Expanded4x3
+ }
+ }
+ }
+}
diff --git a/modules/astra-widget/android/src/main/res/drawable/astra_widget_art_placeholder.xml b/modules/astra-widget/android/src/main/res/drawable/astra_widget_art_placeholder.xml
new file mode 100644
index 0000000..17b8324
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/drawable/astra_widget_art_placeholder.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/drawable/astra_widget_background.xml b/modules/astra-widget/android/src/main/res/drawable/astra_widget_background.xml
new file mode 100644
index 0000000..9c9872a
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/drawable/astra_widget_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/drawable/astra_widget_control_background.xml b/modules/astra-widget/android/src/main/res/drawable/astra_widget_control_background.xml
new file mode 100644
index 0000000..299d58b
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/drawable/astra_widget_control_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_next.xml b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_next.xml
new file mode 100644
index 0000000..8fb49ed
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_next.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_pause.xml b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_pause.xml
new file mode 100644
index 0000000..a341af3
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_pause.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_play.xml b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_play.xml
new file mode 100644
index 0000000..de1bd2c
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_play.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_previous.xml b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_previous.xml
new file mode 100644
index 0000000..29d494a
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_previous.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/layout/astra_widget_3x1.xml b/modules/astra-widget/android/src/main/res/layout/astra_widget_3x1.xml
new file mode 100644
index 0000000..13b609b
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/layout/astra_widget_3x1.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/layout/astra_widget_3x3.xml b/modules/astra-widget/android/src/main/res/layout/astra_widget_3x3.xml
new file mode 100644
index 0000000..af66f2d
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/layout/astra_widget_3x3.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/layout/astra_widget_4x1.xml b/modules/astra-widget/android/src/main/res/layout/astra_widget_4x1.xml
new file mode 100644
index 0000000..25c9e1f
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/layout/astra_widget_4x1.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/layout/astra_widget_4x2.xml b/modules/astra-widget/android/src/main/res/layout/astra_widget_4x2.xml
new file mode 100644
index 0000000..204f60b
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/layout/astra_widget_4x2.xml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/layout/astra_widget_4x3.xml b/modules/astra-widget/android/src/main/res/layout/astra_widget_4x3.xml
new file mode 100644
index 0000000..cae8981
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/layout/astra_widget_4x3.xml
@@ -0,0 +1,324 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/layout/astra_widget_5x1.xml b/modules/astra-widget/android/src/main/res/layout/astra_widget_5x1.xml
new file mode 100644
index 0000000..4c8e21a
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/layout/astra_widget_5x1.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/astra-widget/android/src/main/res/values/strings.xml b/modules/astra-widget/android/src/main/res/values/strings.xml
new file mode 100644
index 0000000..5726df7
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+ Astra Now Playing
+ Astra playback controls
+
diff --git a/modules/astra-widget/android/src/main/res/xml/astra_now_playing_widget_info.xml b/modules/astra-widget/android/src/main/res/xml/astra_now_playing_widget_info.xml
new file mode 100644
index 0000000..de2c195
--- /dev/null
+++ b/modules/astra-widget/android/src/main/res/xml/astra_now_playing_widget_info.xml
@@ -0,0 +1,12 @@
+
diff --git a/modules/astra-widget/expo-module.config.json b/modules/astra-widget/expo-module.config.json
new file mode 100644
index 0000000..f01485e
--- /dev/null
+++ b/modules/astra-widget/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.astrawidget.AstraWidgetModule"]
+ }
+}
diff --git a/modules/astra-widget/index.ts b/modules/astra-widget/index.ts
new file mode 100644
index 0000000..2d8e64a
--- /dev/null
+++ b/modules/astra-widget/index.ts
@@ -0,0 +1,29 @@
+import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
+
+import type { PlaybackState } from '@/types/audio';
+
+export interface AstraWidgetNowPlayingState {
+ title?: string | null;
+ artist?: string | null;
+ artworkUri?: string | null;
+ playbackState: PlaybackState;
+ hasTrack: boolean;
+ recentlyPlayed?: AstraWidgetRecentItem[];
+ replaceRecentlyPlayed?: boolean;
+}
+
+export interface AstraWidgetRecentItem {
+ title?: string | null;
+ artist?: string | null;
+ artworkUri?: string | null;
+}
+
+declare class AstraWidgetModuleType extends NativeModule {
+ setNowPlaying(state: AstraWidgetNowPlayingState): void;
+}
+
+const native = requireOptionalNativeModule('AstraWidget');
+
+export const AstraWidget = (native ?? {
+ setNowPlaying: () => {},
+}) as AstraWidgetModuleType;
diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts
index 661f222..47ae341 100644
--- a/src/audio/playbackService.ts
+++ b/src/audio/playbackService.ts
@@ -1,4 +1,5 @@
import TrackPlayer, { Event } from 'react-native-track-player';
+import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
/**
* RNTP playback service — registered in `index.js`. Runs in a headless context
@@ -6,15 +7,31 @@ import TrackPlayer, { Event } from 'react-native-track-player';
* controls to the player. Must not depend on React or the JS UI tree.
*/
export async function PlaybackService(): Promise {
- TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
- TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
- TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop());
- TrackPlayer.addEventListener(Event.RemoteNext, () =>
- TrackPlayer.skipToNext().catch(() => {}),
- );
- TrackPlayer.addEventListener(Event.RemotePrevious, () =>
- TrackPlayer.skipToPrevious().catch(() => {}),
- );
+ TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, () => {
+ void syncWidgetNowPlayingFromTrackPlayer();
+ });
+ TrackPlayer.addEventListener(Event.PlaybackState, () => {
+ void syncWidgetNowPlayingFromTrackPlayer();
+ });
+ TrackPlayer.addEventListener(Event.RemotePlay, () => {
+ void TrackPlayer.play().finally(() => syncWidgetNowPlayingFromTrackPlayer());
+ });
+ TrackPlayer.addEventListener(Event.RemotePause, () => {
+ void TrackPlayer.pause().finally(() => syncWidgetNowPlayingFromTrackPlayer());
+ });
+ TrackPlayer.addEventListener(Event.RemoteStop, () => {
+ void TrackPlayer.stop().finally(() => syncWidgetNowPlayingFromTrackPlayer());
+ });
+ TrackPlayer.addEventListener(Event.RemoteNext, () => {
+ void TrackPlayer.skipToNext()
+ .catch(() => {})
+ .finally(() => syncWidgetNowPlayingFromTrackPlayer());
+ });
+ TrackPlayer.addEventListener(Event.RemotePrevious, () => {
+ void TrackPlayer.skipToPrevious()
+ .catch(() => {})
+ .finally(() => syncWidgetNowPlayingFromTrackPlayer());
+ });
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
TrackPlayer.seekTo(position),
);
diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts
index e934292..a52aee9 100644
--- a/src/audio/usePlaybackSync.ts
+++ b/src/audio/usePlaybackSync.ts
@@ -9,6 +9,7 @@ import { usePlayerStore } from '@/stores/playerStore';
import { useLibraryStore } from '@/stores/libraryStore';
import type { PlaybackState } from '@/types/audio';
import { rntpToTrack } from './sampleTracks';
+import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync';
const RECENT_PLAY_THRESHOLD_MS = 15_000;
@@ -54,6 +55,7 @@ export function usePlaybackSync(): void {
const setProgress = usePlayerStore((s) => s.setProgress);
const setPlaybackState = usePlayerStore((s) => s.setPlaybackState);
const recordTrackPlayed = useLibraryStore((s) => s.recordTrackPlayed);
+ const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
useEffect(() => {
setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null);
@@ -67,6 +69,15 @@ export function usePlaybackSync(): void {
setPlaybackState(mappedPlaybackState);
}, [mappedPlaybackState, setPlaybackState]);
+ useEffect(() => {
+ const track = activeTrack ? rntpToTrack(activeTrack) : null;
+ setWidgetNowPlaying(
+ track,
+ mappedPlaybackState,
+ buildWidgetRecentItems(recentlyPlayedTracks, track?.path),
+ );
+ }, [activeTrack, mappedPlaybackState, recentlyPlayedTracks]);
+
useEffect(() => {
const path = activeTrack?.url ? String(activeTrack.url) : null;
const now = Date.now();
diff --git a/src/audio/widgetSync.ts b/src/audio/widgetSync.ts
new file mode 100644
index 0000000..96888fa
--- /dev/null
+++ b/src/audio/widgetSync.ts
@@ -0,0 +1,83 @@
+import TrackPlayer, { State, type Track as RntpTrack } from 'react-native-track-player';
+
+import type { PlaybackState, Track } from '@/types/audio';
+import type { DbTrack } from '@/types/library';
+import { artworkThumbUri } from '@/library/artwork';
+import { AstraWidget, type AstraWidgetRecentItem } from '../../modules/astra-widget';
+
+function mapRntpState(state?: State): PlaybackState {
+ switch (state) {
+ case State.Playing:
+ return 'playing';
+ case State.Buffering:
+ case State.Loading:
+ return 'loading';
+ case State.Paused:
+ case State.Ready:
+ return 'paused';
+ default:
+ return 'stopped';
+ }
+}
+
+export function setWidgetNowPlaying(
+ track: Pick