This commit is contained in:
Boof2015
2026-06-19 17:45:31 -04:00
parent 96702ba7f0
commit 0e3e46293c
26 changed files with 1644 additions and 9 deletions
+18
View File
@@ -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
}
}
@@ -0,0 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name="expo.modules.astrawidget.AstraNowPlayingWidgetProvider"
android:exported="true"
android:label="@string/astra_widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/astra_now_playing_widget_info" />
</receiver>
</application>
</manifest>
@@ -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"
}
}
@@ -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<AstraWidgetRecentItemState> = 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()
}
@@ -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<AstraWidgetRecentItem>,
)
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<AstraWidgetRecentItem>): 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<AstraWidgetRecentItem> {
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() }
}
}
@@ -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<AstraWidgetRecentItem>,
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
}
}
}
}
@@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#151b2b" />
<stroke android:width="1dp" android:color="#2c3958" />
<corners android:radius="10dp" />
</shape>
@@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#101522" />
<stroke android:width="1dp" android:color="#25304a" />
<corners android:radius="18dp" />
</shape>
@@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1a2133" />
<stroke android:width="1dp" android:color="#2c3958" />
<corners android:radius="18dp" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#e2e8f4"
android:pathData="M6,5 L15,12 L6,19 Z M17,5 L19,5 L19,19 L17,19 Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#e2e8f4"
android:pathData="M7,5 L10,5 L10,19 L7,19 Z M14,5 L17,5 L17,19 L14,19 Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#e2e8f4"
android:pathData="M8,5 L19,12 L8,19 Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#e2e8f4"
android:pathData="M5,5 L7,5 L7,19 L5,19 Z M18,5 L9,12 L18,19 Z" />
</vector>
@@ -0,0 +1,51 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/astra_widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/astra_widget_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="7dp"
android:paddingEnd="10dp"
android:paddingBottom="7dp">
<ImageView
android:id="@+id/astra_widget_art"
android:layout_width="52dp"
android:layout_height="52dp"
android:contentDescription="Current artwork"
android:scaleType="centerCrop"
android:src="@drawable/astra_widget_art_placeholder" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/astra_widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Astra"
android:textColor="#e2e8f4"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/astra_widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Tap to open"
android:textColor="#8a98b8"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,71 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/astra_widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/astra_widget_background"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/astra_widget_art"
android:layout_width="76dp"
android:layout_height="76dp"
android:contentDescription="Current artwork"
android:scaleType="centerCrop"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:text="Astra"
android:textColor="#e2e8f4"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/astra_widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:text="Tap to open"
android:textColor="#8a98b8"
android:textSize="11sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="34dp"
android:layout_marginTop="6dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/astra_widget_play_pause"
android:layout_width="40dp"
android:layout_height="34dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Play"
android:padding="7dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_play" />
<ImageButton
android:id="@+id/astra_widget_next"
android:layout_width="40dp"
android:layout_height="34dp"
android:layout_marginStart="8dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Next"
android:padding="7dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_next" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,73 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/astra_widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/astra_widget_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="7dp"
android:paddingEnd="8dp"
android:paddingBottom="7dp">
<ImageView
android:id="@+id/astra_widget_art"
android:layout_width="52dp"
android:layout_height="52dp"
android:contentDescription="Current artwork"
android:scaleType="centerCrop"
android:src="@drawable/astra_widget_art_placeholder" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/astra_widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Astra"
android:textColor="#e2e8f4"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/astra_widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Tap to open"
android:textColor="#8a98b8"
android:textSize="12sp" />
</LinearLayout>
<ImageButton
android:id="@+id/astra_widget_play_pause"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Play"
android:padding="9dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_play" />
<ImageButton
android:id="@+id/astra_widget_next"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Next"
android:padding="9dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_next" />
</LinearLayout>
@@ -0,0 +1,149 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/astra_widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/astra_widget_background"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingTop="10dp"
android:paddingEnd="12dp"
android:paddingBottom="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="82dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/astra_widget_art"
android:layout_width="82dp"
android:layout_height="82dp"
android:contentDescription="Current artwork"
android:scaleType="centerCrop"
android:src="@drawable/astra_widget_art_placeholder" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="12dp"
android:layout_marginEnd="10dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/astra_widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Astra"
android:textColor="#e2e8f4"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/astra_widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Tap to open"
android:textColor="#8a98b8"
android:textSize="12sp" />
</LinearLayout>
<ImageButton
android:id="@+id/astra_widget_previous"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Previous"
android:padding="8dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_previous" />
<ImageButton
android:id="@+id/astra_widget_play_pause"
android:layout_width="42dp"
android:layout_height="36dp"
android:layout_marginStart="8dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Play"
android:padding="8dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_play" />
<ImageButton
android:id="@+id/astra_widget_next"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="8dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Next"
android:padding="8dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_next" />
</LinearLayout>
<LinearLayout
android:id="@+id/astra_widget_recent_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/astra_widget_recent_1_image"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<ImageView
android:id="@+id/astra_widget_recent_2_image"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<ImageView
android:id="@+id/astra_widget_recent_3_image"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<ImageView
android:id="@+id/astra_widget_recent_4_image"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<ImageView
android:id="@+id/astra_widget_recent_5_image"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,324 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/astra_widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/astra_widget_background"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="90dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/astra_widget_art"
android:layout_width="90dp"
android:layout_height="90dp"
android:contentDescription="Current artwork"
android:scaleType="centerCrop"
android:src="@drawable/astra_widget_art_placeholder" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="12dp"
android:layout_marginEnd="10dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/astra_widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Astra"
android:textColor="#e2e8f4"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/astra_widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Tap to open"
android:textColor="#8a98b8"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="44dp"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageButton
android:id="@+id/astra_widget_previous"
android:layout_width="40dp"
android:layout_height="28dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Previous"
android:padding="6dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_previous" />
<ImageButton
android:id="@+id/astra_widget_play_pause"
android:layout_width="40dp"
android:layout_height="28dp"
android:layout_marginTop="3dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Play"
android:padding="6dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_play" />
<ImageButton
android:id="@+id/astra_widget_next"
android:layout_width="40dp"
android:layout_height="28dp"
android:layout_marginTop="3dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Next"
android:padding="6dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_next" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/astra_widget_recent_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_1_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_1_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_2_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_2_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_3_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_3_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_4_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_4_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_5_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_5_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_6_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_6_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_7_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_7_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<ImageView
android:id="@+id/astra_widget_recent_8_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Recently played"
android:scaleType="fitCenter"
android:src="@drawable/astra_widget_art_placeholder" />
<TextView
android:id="@+id/astra_widget_recent_8_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Recent"
android:textColor="#8a98b8"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,84 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/astra_widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/astra_widget_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="7dp"
android:paddingEnd="8dp"
android:paddingBottom="7dp">
<ImageView
android:id="@+id/astra_widget_art"
android:layout_width="52dp"
android:layout_height="52dp"
android:contentDescription="Current artwork"
android:scaleType="centerCrop"
android:src="@drawable/astra_widget_art_placeholder" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/astra_widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Astra"
android:textColor="#e2e8f4"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/astra_widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Tap to open"
android:textColor="#8a98b8"
android:textSize="12sp" />
</LinearLayout>
<ImageButton
android:id="@+id/astra_widget_previous"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Previous"
android:padding="9dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_previous" />
<ImageButton
android:id="@+id/astra_widget_play_pause"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Play"
android:padding="9dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_play" />
<ImageButton
android:id="@+id/astra_widget_next"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:background="@drawable/astra_widget_control_background"
android:contentDescription="Next"
android:padding="9dp"
android:scaleType="center"
android:src="@drawable/astra_widget_ic_next" />
</LinearLayout>
@@ -0,0 +1,4 @@
<resources>
<string name="astra_widget_name">Astra Now Playing</string>
<string name="astra_widget_description">Astra playback controls</string>
</resources>
@@ -0,0 +1,12 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/astra_widget_description"
android:initialLayout="@layout/astra_widget_3x1"
android:minWidth="180dp"
android:minHeight="40dp"
android:minResizeWidth="180dp"
android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.astrawidget.AstraWidgetModule"]
}
}
+29
View File
@@ -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<AstraWidgetModuleType>('AstraWidget');
export const AstraWidget = (native ?? {
setNowPlaying: () => {},
}) as AstraWidgetModuleType;