From 0e3e46293c090245ea7aa2ce8638c98ff8b98c83 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:45:31 -0400 Subject: [PATCH] widgets --- modules/astra-widget/android/build.gradle | 18 + .../android/src/main/AndroidManifest.xml | 15 + .../AstraNowPlayingWidgetProvider.kt | 61 +++ .../modules/astrawidget/AstraWidgetModule.kt | 82 ++++ .../modules/astrawidget/AstraWidgetState.kt | 93 ++++ .../modules/astrawidget/AstraWidgetUpdater.kt | 401 ++++++++++++++++++ .../drawable/astra_widget_art_placeholder.xml | 5 + .../res/drawable/astra_widget_background.xml | 5 + .../astra_widget_control_background.xml | 5 + .../res/drawable/astra_widget_ic_next.xml | 9 + .../res/drawable/astra_widget_ic_pause.xml | 9 + .../res/drawable/astra_widget_ic_play.xml | 9 + .../res/drawable/astra_widget_ic_previous.xml | 9 + .../src/main/res/layout/astra_widget_3x1.xml | 51 +++ .../src/main/res/layout/astra_widget_3x3.xml | 71 ++++ .../src/main/res/layout/astra_widget_4x1.xml | 73 ++++ .../src/main/res/layout/astra_widget_4x2.xml | 149 +++++++ .../src/main/res/layout/astra_widget_4x3.xml | 324 ++++++++++++++ .../src/main/res/layout/astra_widget_5x1.xml | 84 ++++ .../android/src/main/res/values/strings.xml | 4 + .../res/xml/astra_now_playing_widget_info.xml | 12 + modules/astra-widget/expo-module.config.json | 6 + modules/astra-widget/index.ts | 29 ++ src/audio/playbackService.ts | 35 +- src/audio/usePlaybackSync.ts | 11 + src/audio/widgetSync.ts | 83 ++++ 26 files changed, 1644 insertions(+), 9 deletions(-) create mode 100644 modules/astra-widget/android/build.gradle create mode 100644 modules/astra-widget/android/src/main/AndroidManifest.xml create mode 100644 modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraNowPlayingWidgetProvider.kt create mode 100644 modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt create mode 100644 modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetState.kt create mode 100644 modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt create mode 100644 modules/astra-widget/android/src/main/res/drawable/astra_widget_art_placeholder.xml create mode 100644 modules/astra-widget/android/src/main/res/drawable/astra_widget_background.xml create mode 100644 modules/astra-widget/android/src/main/res/drawable/astra_widget_control_background.xml create mode 100644 modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_next.xml create mode 100644 modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_pause.xml create mode 100644 modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_play.xml create mode 100644 modules/astra-widget/android/src/main/res/drawable/astra_widget_ic_previous.xml create mode 100644 modules/astra-widget/android/src/main/res/layout/astra_widget_3x1.xml create mode 100644 modules/astra-widget/android/src/main/res/layout/astra_widget_3x3.xml create mode 100644 modules/astra-widget/android/src/main/res/layout/astra_widget_4x1.xml create mode 100644 modules/astra-widget/android/src/main/res/layout/astra_widget_4x2.xml create mode 100644 modules/astra-widget/android/src/main/res/layout/astra_widget_4x3.xml create mode 100644 modules/astra-widget/android/src/main/res/layout/astra_widget_5x1.xml create mode 100644 modules/astra-widget/android/src/main/res/values/strings.xml create mode 100644 modules/astra-widget/android/src/main/res/xml/astra_now_playing_widget_info.xml create mode 100644 modules/astra-widget/expo-module.config.json create mode 100644 modules/astra-widget/index.ts create mode 100644 src/audio/widgetSync.ts 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 | null, + playbackState: PlaybackState, + recentlyPlayed?: AstraWidgetRecentItem[], +): void { + AstraWidget.setNowPlaying({ + title: track?.title ?? null, + artist: track?.artist ?? null, + artworkUri: track?.artworkData ?? null, + playbackState, + hasTrack: Boolean(track), + ...(recentlyPlayed === undefined + ? {} + : { + recentlyPlayed, + replaceRecentlyPlayed: true, + }), + }); +} + +export function setWidgetNowPlayingFromRntpTrack( + track: RntpTrack | null | undefined, + playbackState: PlaybackState, +): void { + setWidgetNowPlaying( + track + ? { + title: track.title ?? 'Unknown title', + artist: track.artist ?? 'Unknown artist', + artworkData: typeof track.artwork === 'string' ? track.artwork : undefined, + } + : null, + playbackState, + ); +} + +export function buildWidgetRecentItems( + tracks: readonly DbTrack[], + currentPath?: string, +): AstraWidgetRecentItem[] { + return tracks + .filter((track) => track.path !== currentPath) + .slice(0, 8) + .map((track) => ({ + title: track.title, + artist: track.artist, + artworkUri: track.artwork_hash ? artworkThumbUri(track.artwork_hash) : null, + })); +} + +export async function syncWidgetNowPlayingFromTrackPlayer(): Promise { + try { + const [activeTrack, playbackState] = await Promise.all([ + TrackPlayer.getActiveTrack(), + TrackPlayer.getPlaybackState(), + ]); + setWidgetNowPlayingFromRntpTrack(activeTrack, mapRntpState(playbackState.state)); + } catch { + setWidgetNowPlaying(null, 'stopped'); + } +}