diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index aa0e1ff97..09b64a82b 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -122,6 +122,10 @@ android {
implementation(libs.androidx.lifecycle.viewmodel.compose)
+ // Glance (App Widgets)
+ implementation(libs.androidx.glance.appwidget)
+ implementation(libs.androidx.glance.material3)
+
// Shuttle Core
implementation(project(":android:core"))
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 63bc49b67..dc234f970 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -89,6 +89,18 @@
android:resource="@xml/appwidget_info_42" />
+
+
+
+
+
+
+
("widget_dark_mode")?.setOnPreferenceClickListener {
- widgetManager.updateAppWidgets(WidgetManager.UpdateReason.Unknown)
- true
- }
-
preferenceScreen.findPreference("widget_background_opacity")?.setOnPreferenceChangeListener { _, _ ->
widgetManager.updateAppWidgets(WidgetManager.UpdateReason.Unknown)
true
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/ShuttleAppWidgetProvider.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/ShuttleAppWidgetProvider.kt
deleted file mode 100644
index 703648d0a..000000000
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/ShuttleAppWidgetProvider.kt
+++ /dev/null
@@ -1,136 +0,0 @@
-package com.simplecityapps.shuttle.ui.widgets
-
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
-import android.appwidget.AppWidgetProvider
-import android.content.Context
-import android.content.Intent
-import android.graphics.Bitmap
-import android.os.Build
-import android.util.LruCache
-import android.widget.RemoteViews
-import androidx.annotation.LayoutRes
-import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader
-import com.simplecityapps.playback.PlaybackManager
-import com.simplecityapps.playback.PlaybackService
-import com.simplecityapps.playback.PlaybackState
-import com.simplecityapps.playback.queue.QueueManager
-import com.simplecityapps.shuttle.R
-import com.simplecityapps.shuttle.pendingintent.PendingIntentCompat
-import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
-import com.simplecityapps.shuttle.ui.MainActivity
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-
-@AndroidEntryPoint
-abstract class ShuttleAppWidgetProvider : AppWidgetProvider() {
- @Inject
- lateinit var playbackManager: PlaybackManager
-
- @Inject
- lateinit var queueManager: QueueManager
-
- @Inject
- lateinit var artworkCache: LruCache
-
- @Inject
- lateinit var preferenceManager: GeneralPreferenceManager
-
- @Inject
- lateinit var imageLoader: ArtworkImageLoader
-
- internal var updateReason = WidgetManager.UpdateReason.Unknown
-
- @get:LayoutRes
- abstract val layoutResIdLight: Int
-
- @get:LayoutRes
- abstract val layoutResIdDark: Int
-
- val isDarkMode: Boolean
- get() = preferenceManager.widgetDarkMode
-
- private fun getLayoutResId(): Int = if (isDarkMode) layoutResIdDark else layoutResIdLight
-
- override fun onReceive(
- context: Context?,
- intent: Intent?
- ) {
- updateReason = WidgetManager.UpdateReason.values()[intent?.extras?.getInt(WidgetManager.ARG_UPDATE_REASON) ?: WidgetManager.UpdateReason.Unknown.ordinal]
-
- super.onReceive(context, intent)
- }
-
- override fun onUpdate(
- context: Context,
- appWidgetManager: AppWidgetManager,
- appWidgetIds: IntArray
- ) {
- super.onUpdate(context, appWidgetManager, appWidgetIds)
-
- appWidgetIds.forEach { appWidgetId ->
- val contentIntent: PendingIntent = PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntentCompat.FLAG_IMMUTABLE)
-
- val views =
- RemoteViews(context.packageName, getLayoutResId()).apply {
- bind(context, appWidgetId, contentIntent, imageLoader, appWidgetManager, preferenceManager.widgetBackgroundTransparency / 100f)
- }
-
- // Tell the AppWidgetManager to perform an update on the current app widget
- appWidgetManager.updateAppWidget(appWidgetId, views)
- }
- }
-
- abstract fun RemoteViews.bind(
- context: Context,
- appWidgetId: Int,
- contentIntent: PendingIntent,
- imageLoader: ArtworkImageLoader,
- appWidgetManager: AppWidgetManager,
- backgroundTransparency: Float
- )
-
- internal fun playbackPendingIntent(context: Context): PendingIntent {
- val intent =
- Intent(context, PlaybackService::class.java).apply {
- action = PlaybackService.ACTION_TOGGLE_PLAYBACK
- }
- return getPendingIntent(context, intent)
- }
-
- internal fun prevPendingIntent(context: Context): PendingIntent {
- val intent =
- Intent(context, PlaybackService::class.java).apply {
- action = PlaybackService.ACTION_SKIP_PREV
- }
- return getPendingIntent(context, intent)
- }
-
- internal fun nextPendingIntent(context: Context): PendingIntent {
- val intent =
- Intent(context, PlaybackService::class.java).apply {
- action = PlaybackService.ACTION_SKIP_NEXT
- }
- return getPendingIntent(context, intent)
- }
-
- private fun getPendingIntent(
- context: Context,
- intent: Intent
- ): PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- PendingIntent.getForegroundService(context, 1, intent, PendingIntentCompat.FLAG_IMMUTABLE)
- } else {
- PendingIntent.getService(context, 1, intent, PendingIntentCompat.FLAG_IMMUTABLE)
- }
-
- fun getPlaybackDrawable(): Int = when (playbackManager.playbackState()) {
- is PlaybackState.Loading, PlaybackState.Playing -> {
- if (isDarkMode) R.drawable.ic_pause_white_24dp else com.simplecityapps.playback.R.drawable.ic_pause_black_24dp
- }
- else -> {
- if (isDarkMode) R.drawable.ic_play_arrow_white_24dp else com.simplecityapps.playback.R.drawable.ic_play_arrow_black_24dp
- }
- }
-
- fun getPlaceholderDrawable(): Int = if (isDarkMode) R.drawable.ic_music_note_white_24dp else com.simplecityapps.playback.R.drawable.ic_music_note_black_24dp
-}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetActions.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetActions.kt
new file mode 100644
index 000000000..ae457a255
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetActions.kt
@@ -0,0 +1,60 @@
+package com.simplecityapps.shuttle.ui.widgets
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.glance.GlanceId
+import androidx.glance.action.ActionParameters
+import androidx.glance.appwidget.action.ActionCallback
+import com.simplecityapps.playback.PlaybackService
+
+class PlayPauseAction : ActionCallback {
+ override suspend fun onAction(
+ context: Context,
+ glanceId: GlanceId,
+ parameters: ActionParameters
+ ) {
+ val intent = Intent(context, PlaybackService::class.java).apply {
+ action = PlaybackService.ACTION_TOGGLE_PLAYBACK
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+}
+
+class NextTrackAction : ActionCallback {
+ override suspend fun onAction(
+ context: Context,
+ glanceId: GlanceId,
+ parameters: ActionParameters
+ ) {
+ val intent = Intent(context, PlaybackService::class.java).apply {
+ action = PlaybackService.ACTION_SKIP_NEXT
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+}
+
+class PreviousTrackAction : ActionCallback {
+ override suspend fun onAction(
+ context: Context,
+ glanceId: GlanceId,
+ parameters: ActionParameters
+ ) {
+ val intent = Intent(context, PlaybackService::class.java).apply {
+ action = PlaybackService.ACTION_SKIP_PREV
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetEntryPoint.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetEntryPoint.kt
new file mode 100644
index 000000000..4e025964f
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetEntryPoint.kt
@@ -0,0 +1,16 @@
+package com.simplecityapps.shuttle.ui.widgets
+
+import com.simplecityapps.playback.PlaybackManager
+import com.simplecityapps.playback.queue.QueueManager
+import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface WidgetEntryPoint {
+ fun playbackManager(): PlaybackManager
+ fun queueManager(): QueueManager
+ fun preferenceManager(): GeneralPreferenceManager
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetManager.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetManager.kt
index 1beb2ed6b..51277c2a5 100644
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetManager.kt
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetManager.kt
@@ -1,8 +1,7 @@
package com.simplecityapps.shuttle.ui.widgets
-import android.appwidget.AppWidgetManager
import android.content.Context
-import android.content.Intent
+import androidx.glance.appwidget.updateAll
import com.simplecityapps.playback.PlaybackState
import com.simplecityapps.playback.PlaybackWatcher
import com.simplecityapps.playback.PlaybackWatcherCallback
@@ -10,6 +9,10 @@ import com.simplecityapps.playback.queue.QueueChangeCallback
import com.simplecityapps.playback.queue.QueueWatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
class WidgetManager
@Inject
@@ -19,6 +22,9 @@ constructor(
private val queueWatcher: QueueWatcher
) : PlaybackWatcherCallback,
QueueChangeCallback {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
enum class UpdateReason {
PlaystateChanged,
QueueChanged,
@@ -36,19 +42,12 @@ constructor(
queueWatcher.removeCallback(this)
}
- fun updateAppWidgets(updateReason: UpdateReason) {
- listOf(
- Intent(context, WidgetProvider41::class.java),
- Intent(context, WidgetProvider42::class.java)
- ).forEach { intent ->
- context.sendBroadcast(
- intent.apply {
- action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
- val ids = AppWidgetManager.getInstance(context).getAppWidgetIds(component)
- putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
- putExtra(ARG_UPDATE_REASON, updateReason.ordinal)
- }
- )
+ fun updateAppWidgets(updateReason: UpdateReason = UpdateReason.Unknown) {
+ scope.launch {
+ // Update all widgets using Glance API
+ Widget41().updateAll(context)
+ Widget42().updateAll(context)
+ Widget43().updateAll(context)
}
}
@@ -56,7 +55,6 @@ constructor(
override fun onPlaybackStateChanged(playbackState: PlaybackState) {
super.onPlaybackStateChanged(playbackState)
-
updateAppWidgets(UpdateReason.PlaystateChanged)
}
@@ -71,7 +69,6 @@ constructor(
newPosition: Int?
) {
super.onQueuePositionChanged(oldPosition, newPosition)
-
updateAppWidgets(UpdateReason.QueuePositionChanged)
}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider41.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider41.kt
index 7a725b523..9d165e4a4 100644
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider41.kt
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider41.kt
@@ -1,102 +1,171 @@
package com.simplecityapps.shuttle.ui.widgets
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
import android.content.Context
-import android.view.View
-import android.widget.RemoteViews
-import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader
-import com.simplecityapps.playback.getArtworkCacheKey
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.ContentScale
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import com.simplecityapps.playback.PlaybackManager
+import com.simplecityapps.playback.PlaybackState
+import com.simplecityapps.playback.queue.QueueManager
import com.simplecityapps.shuttle.R
+import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
+import com.simplecityapps.shuttle.ui.MainActivity
import com.simplecityapps.shuttle.ui.common.phrase.joinSafely
-import com.simplecityapps.shuttle.ui.common.utils.dp
import com.squareup.phrase.ListPhrase
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.EntryPointAccessors
-class WidgetProvider41 : ShuttleAppWidgetProvider() {
- override val layoutResIdLight: Int
- get() = R.layout.appwidget_41
+class Widget41 : GlanceAppWidget() {
- override val layoutResIdDark: Int
- get() = R.layout.appwidget_41_dark
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ // Access Hilt dependencies through EntryPoint
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ WidgetEntryPoint::class.java
+ )
- override fun RemoteViews.bind(
+ provideContent {
+ GlanceTheme {
+ Widget41Content(
+ context = context,
+ playbackManager = entryPoint.playbackManager(),
+ queueManager = entryPoint.queueManager(),
+ preferenceManager = entryPoint.preferenceManager()
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun Widget41Content(
context: Context,
- appWidgetId: Int,
- contentIntent: PendingIntent,
- imageLoader: ArtworkImageLoader,
- appWidgetManager: AppWidgetManager,
- backgroundTransparency: Float
+ playbackManager: PlaybackManager,
+ queueManager: QueueManager,
+ preferenceManager: GeneralPreferenceManager
) {
- setOnClickPendingIntent(R.id.container, contentIntent)
- setOnClickPendingIntent(R.id.playPauseButton, playbackPendingIntent(context))
- setOnClickPendingIntent(R.id.nextButton, nextPendingIntent(context))
+ val currentItem = queueManager.getCurrentItem()
+ val isDarkMode = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+ val backgroundAlpha = preferenceManager.widgetBackgroundTransparency / 100f
- setInt(R.id.background, "setImageAlpha", (backgroundTransparency * 255f).toInt())
-
- queueManager.getCurrentItem()?.let { currentItem ->
- setViewVisibility(R.id.playPauseButton, View.VISIBLE)
- setViewVisibility(R.id.nextButton, View.VISIBLE)
-
- val song = currentItem.song
+ val backgroundColor = if (isDarkMode) {
+ Color.Black.copy(alpha = backgroundAlpha)
+ } else {
+ Color.White.copy(alpha = backgroundAlpha)
+ }
- setTextViewText(R.id.title, song.name ?: context.getString(com.simplecityapps.core.R.string.unknown))
- setTextViewText(
- R.id.subtitle,
- ListPhrase.from(" • ")
- .joinSafely(listOf(song.friendlyArtistName ?: song.albumArtist, song.album), context.getString(com.simplecityapps.core.R.string.unknown))
+ Row(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(backgroundColor)
+ .cornerRadius(16.dp)
+ .padding(12.dp)
+ .clickable(actionStartActivity()),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Artwork
+ Image(
+ provider = currentItem?.song?.let {
+ // TODO: Load actual artwork - Glance has limitations with async image loading
+ ImageProvider(if (isDarkMode) R.drawable.ic_music_note_white_24dp else com.simplecityapps.playback.R.drawable.ic_music_note_black_24dp)
+ } ?: ImageProvider(com.simplecityapps.core.R.drawable.ic_shuttle_logo),
+ contentDescription = "Album artwork",
+ modifier = GlanceModifier
+ .size(48.dp)
+ .cornerRadius(4.dp),
+ contentScale = ContentScale.Crop
)
- val artworkSize = 40.dp
+ Spacer(modifier = GlanceModifier.width(12.dp))
- artworkCache[song.getArtworkCacheKey(artworkSize, artworkSize)]?.let { image ->
- setImageViewBitmap(R.id.artwork, image)
- } ?: run {
- setImageViewResource(R.id.artwork, getPlaceholderDrawable())
-
- imageLoader.loadBitmap(
- song,
- artworkSize,
- artworkSize,
- listOf(ArtworkImageLoader.Options.RoundedCorners(4.dp))
- ) { image ->
- if (image != null) {
- artworkCache.put(song.getArtworkCacheKey(artworkSize, artworkSize), image)
- }
-
- if (song.id == queueManager.getCurrentItem()?.song?.id) {
- image?.let {
- setImageViewBitmap(R.id.artwork, image)
- } ?: run {
- setImageViewResource(R.id.artwork, getPlaceholderDrawable())
- }
- appWidgetManager.updateAppWidget(appWidgetId, this)
- }
- }
+ // Song info
+ Column(
+ modifier = GlanceModifier.defaultWeight()
+ ) {
+ Text(
+ text = currentItem?.song?.name ?: context.getString(R.string.queue_empty),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Normal
+ ),
+ maxLines = 1
+ )
+ Text(
+ text = currentItem?.song?.let { song ->
+ ListPhrase.from(" • ")
+ .joinSafely(
+ listOf(song.friendlyArtistName ?: song.albumArtist, song.album),
+ context.getString(com.simplecityapps.core.R.string.unknown)
+ )
+ } ?: context.getString(com.simplecityapps.core.R.string.widget_empty_text),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp
+ ),
+ maxLines = 1
+ )
}
- // Load the next song's artwork as well
- queueManager.getNext(true)?.song?.let { song ->
- artworkCache[song.getArtworkCacheKey(artworkSize, artworkSize)] ?: imageLoader.loadBitmap(
- song,
- artworkSize,
- artworkSize,
- listOf(ArtworkImageLoader.Options.RoundedCorners(4.dp))
- ) { image ->
- if (image != null) {
- artworkCache.put(song.getArtworkCacheKey(artworkSize, artworkSize), image)
- }
- }
+ if (currentItem != null) {
+ // Play/Pause button
+ Image(
+ provider = ImageProvider(getPlayPauseIcon(playbackManager, isDarkMode)),
+ contentDescription = "Play/Pause",
+ modifier = GlanceModifier
+ .size(48.dp)
+ .clickable(actionRunCallback())
+ )
+
+ // Next button
+ Image(
+ provider = ImageProvider(if (isDarkMode) R.drawable.ic_skip_next_white_24dp else R.drawable.ic_skip_next_black_24dp),
+ contentDescription = "Next",
+ modifier = GlanceModifier
+ .size(48.dp)
+ .clickable(actionRunCallback())
+ )
}
- } ?: run {
- setTextViewText(R.id.title, context.getString(R.string.queue_empty))
- setTextViewText(R.id.subtitle, context.getString(com.simplecityapps.core.R.string.widget_empty_text))
- setImageViewResource(R.id.artwork, com.simplecityapps.core.R.drawable.ic_shuttle_logo)
- setViewVisibility(R.id.playPauseButton, View.GONE)
- setViewVisibility(R.id.nextButton, View.GONE)
}
+ }
- if (updateReason == WidgetManager.UpdateReason.PlaystateChanged || updateReason == WidgetManager.UpdateReason.Unknown) {
- setImageViewResource(R.id.playPauseButton, getPlaybackDrawable())
+ private fun getPlayPauseIcon(playbackManager: PlaybackManager, isDarkMode: Boolean): Int = when (playbackManager.playbackState()) {
+ is PlaybackState.Loading, PlaybackState.Playing -> {
+ if (isDarkMode) R.drawable.ic_pause_white_24dp else com.simplecityapps.playback.R.drawable.ic_pause_black_24dp
+ }
+ else -> {
+ if (isDarkMode) R.drawable.ic_play_arrow_white_24dp else com.simplecityapps.playback.R.drawable.ic_play_arrow_black_24dp
}
}
}
+
+@AndroidEntryPoint
+class WidgetProvider41 : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = Widget41()
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider42.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider42.kt
index 53ee4287a..812179b6c 100644
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider42.kt
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider42.kt
@@ -1,100 +1,203 @@
package com.simplecityapps.shuttle.ui.widgets
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
import android.content.Context
-import android.view.View
-import android.widget.RemoteViews
-import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader
-import com.simplecityapps.playback.getArtworkCacheKey
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.ContentScale
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import com.simplecityapps.playback.PlaybackManager
+import com.simplecityapps.playback.PlaybackState
+import com.simplecityapps.playback.queue.QueueManager
import com.simplecityapps.shuttle.R
-import com.simplecityapps.shuttle.ui.common.utils.dp
+import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
+import com.simplecityapps.shuttle.ui.MainActivity
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.EntryPointAccessors
-class WidgetProvider42 : ShuttleAppWidgetProvider() {
- override val layoutResIdLight: Int
- get() = R.layout.appwidget_42
+class Widget42 : GlanceAppWidget() {
- override val layoutResIdDark: Int
- get() = R.layout.appwidget_42_dark
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ // Access Hilt dependencies through EntryPoint
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ WidgetEntryPoint::class.java
+ )
- override fun RemoteViews.bind(
+ provideContent {
+ GlanceTheme {
+ Widget42Content(
+ context = context,
+ playbackManager = entryPoint.playbackManager(),
+ queueManager = entryPoint.queueManager(),
+ preferenceManager = entryPoint.preferenceManager()
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun Widget42Content(
context: Context,
- appWidgetId: Int,
- contentIntent: PendingIntent,
- imageLoader: ArtworkImageLoader,
- appWidgetManager: AppWidgetManager,
- backgroundTransparency: Float
+ playbackManager: PlaybackManager,
+ queueManager: QueueManager,
+ preferenceManager: GeneralPreferenceManager
) {
- setOnClickPendingIntent(R.id.container, contentIntent)
- setOnClickPendingIntent(R.id.playPauseButton, playbackPendingIntent(context))
- setOnClickPendingIntent(R.id.prevButton, prevPendingIntent(context))
- setOnClickPendingIntent(R.id.nextButton, nextPendingIntent(context))
-
- setInt(R.id.background, "setImageAlpha", (backgroundTransparency * 255f).toInt())
-
- queueManager.getCurrentItem()?.let { currentItem ->
- setViewVisibility(R.id.prevButton, View.VISIBLE)
- setViewVisibility(R.id.playPauseButton, View.VISIBLE)
- setViewVisibility(R.id.nextButton, View.VISIBLE)
-
- val song = currentItem.song
-
- setTextViewText(R.id.title, song.name)
- setTextViewText(R.id.subtitle, song.friendlyArtistName ?: song.albumArtist)
- setTextViewText(R.id.subtitle2, song.album)
-
- val artworkSize = 80.dp
-
- artworkCache[song.getArtworkCacheKey(artworkSize, artworkSize)]?.let { image ->
- setImageViewBitmap(R.id.artwork, image)
- } ?: run {
- setImageViewResource(R.id.artwork, getPlaceholderDrawable())
-
- imageLoader.loadBitmap(
- song,
- artworkSize,
- artworkSize,
- listOf(ArtworkImageLoader.Options.RoundedCorners(4.dp))
- ) { image ->
- if (image != null) {
- artworkCache.put(song.getArtworkCacheKey(artworkSize, artworkSize), image)
- }
-
- if (song.id == queueManager.getCurrentItem()?.song?.id) {
- image?.let {
- setImageViewBitmap(R.id.artwork, image)
- } ?: run {
- setImageViewResource(R.id.artwork, getPlaceholderDrawable())
- }
- appWidgetManager.updateAppWidget(appWidgetId, this)
- }
+ val currentItem = queueManager.getCurrentItem()
+ val isDarkMode = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+ val backgroundAlpha = preferenceManager.widgetBackgroundTransparency / 100f
+
+ val backgroundColor = if (isDarkMode) {
+ Color.Black.copy(alpha = backgroundAlpha)
+ } else {
+ Color.White.copy(alpha = backgroundAlpha)
+ }
+
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(backgroundColor)
+ .cornerRadius(16.dp)
+ .padding(12.dp)
+ .clickable(actionStartActivity()),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Artwork
+ Image(
+ provider = currentItem?.song?.let {
+ // TODO: Load actual artwork - Glance has limitations with async image loading
+ ImageProvider(if (isDarkMode) R.drawable.ic_music_note_white_24dp else com.simplecityapps.playback.R.drawable.ic_music_note_black_24dp)
+ } ?: ImageProvider(com.simplecityapps.core.R.drawable.ic_shuttle_logo),
+ contentDescription = "Album artwork",
+ modifier = GlanceModifier
+ .size(80.dp)
+ .cornerRadius(4.dp),
+ contentScale = ContentScale.Crop
+ )
+
+ Spacer(modifier = GlanceModifier.width(12.dp))
+
+ // Song info
+ Column(
+ modifier = GlanceModifier.defaultWeight()
+ ) {
+ Text(
+ text = currentItem?.song?.name ?: context.getString(R.string.queue_empty),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Normal
+ ),
+ maxLines = 1
+ )
+
+ Spacer(modifier = GlanceModifier.height(2.dp))
+
+ Text(
+ text = currentItem?.song?.let { song ->
+ song.friendlyArtistName ?: song.albumArtist ?: context.getString(com.simplecityapps.core.R.string.unknown)
+ } ?: context.getString(com.simplecityapps.core.R.string.widget_empty_text),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp
+ ),
+ maxLines = 1
+ )
+
+ Spacer(modifier = GlanceModifier.height(2.dp))
+
+ Text(
+ text = currentItem?.song?.album ?: "",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp
+ ),
+ maxLines = 1
+ )
}
}
- // Load the next song's artwork as well
- queueManager.getNext(true)?.song?.let { song ->
- artworkCache[song.getArtworkCacheKey(artworkSize, artworkSize)] ?: imageLoader.loadBitmap(
- song,
- artworkSize,
- artworkSize,
- listOf(ArtworkImageLoader.Options.RoundedCorners(4.dp))
- ) { image ->
- if (image != null) {
- artworkCache.put(song.getArtworkCacheKey(artworkSize, artworkSize), image)
- }
+ if (currentItem != null) {
+ Spacer(modifier = GlanceModifier.height(8.dp))
+
+ // Playback controls
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Previous button
+ Image(
+ provider = ImageProvider(if (isDarkMode) R.drawable.ic_skip_previous_white_24dp else R.drawable.ic_skip_previous_black_24dp),
+ contentDescription = "Previous",
+ modifier = GlanceModifier
+ .size(48.dp)
+ .clickable(actionRunCallback())
+ )
+
+ // Play/Pause button
+ Image(
+ provider = ImageProvider(getPlayPauseIcon(playbackManager, isDarkMode)),
+ contentDescription = "Play/Pause",
+ modifier = GlanceModifier
+ .size(48.dp)
+ .clickable(actionRunCallback())
+ )
+
+ // Next button
+ Image(
+ provider = ImageProvider(if (isDarkMode) R.drawable.ic_skip_next_white_24dp else R.drawable.ic_skip_next_black_24dp),
+ contentDescription = "Next",
+ modifier = GlanceModifier
+ .size(48.dp)
+ .clickable(actionRunCallback())
+ )
}
}
- } ?: run {
- setTextViewText(R.id.title, context.getString(R.string.queue_empty))
- setTextViewText(R.id.subtitle, context.getString(com.simplecityapps.core.R.string.widget_empty_text))
- setImageViewResource(R.id.artwork, com.simplecityapps.core.R.drawable.ic_shuttle_logo)
- setViewVisibility(R.id.prevButton, View.GONE)
- setViewVisibility(R.id.playPauseButton, View.GONE)
- setViewVisibility(R.id.nextButton, View.GONE)
}
+ }
- if (updateReason == WidgetManager.UpdateReason.PlaystateChanged || updateReason == WidgetManager.UpdateReason.Unknown) {
- setImageViewResource(R.id.playPauseButton, getPlaybackDrawable())
+ private fun getPlayPauseIcon(playbackManager: PlaybackManager, isDarkMode: Boolean): Int = when (playbackManager.playbackState()) {
+ is PlaybackState.Loading, PlaybackState.Playing -> {
+ if (isDarkMode) R.drawable.ic_pause_white_24dp else com.simplecityapps.playback.R.drawable.ic_pause_black_24dp
+ }
+ else -> {
+ if (isDarkMode) R.drawable.ic_play_arrow_white_24dp else com.simplecityapps.playback.R.drawable.ic_play_arrow_black_24dp
}
}
}
+
+@AndroidEntryPoint
+class WidgetProvider42 : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = Widget42()
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider43.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider43.kt
new file mode 100644
index 000000000..e2701a6f3
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/widgets/WidgetProvider43.kt
@@ -0,0 +1,208 @@
+package com.simplecityapps.shuttle.ui.widgets
+
+import android.content.Context
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.ContentScale
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import com.simplecityapps.playback.PlaybackManager
+import com.simplecityapps.playback.PlaybackState
+import com.simplecityapps.playback.queue.QueueManager
+import com.simplecityapps.shuttle.R
+import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
+import com.simplecityapps.shuttle.ui.MainActivity
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.EntryPointAccessors
+
+class Widget43 : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ // Access Hilt dependencies through EntryPoint
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ WidgetEntryPoint::class.java
+ )
+
+ provideContent {
+ GlanceTheme {
+ Widget43Content(
+ context = context,
+ playbackManager = entryPoint.playbackManager(),
+ queueManager = entryPoint.queueManager(),
+ preferenceManager = entryPoint.preferenceManager()
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun Widget43Content(
+ context: Context,
+ playbackManager: PlaybackManager,
+ queueManager: QueueManager,
+ preferenceManager: GeneralPreferenceManager
+ ) {
+ val currentItem = queueManager.getCurrentItem()
+ val isDarkMode = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+ val backgroundAlpha = preferenceManager.widgetBackgroundTransparency / 100f
+
+ val backgroundColor = if (isDarkMode) {
+ Color.Black.copy(alpha = backgroundAlpha)
+ } else {
+ Color.White.copy(alpha = backgroundAlpha)
+ }
+
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(backgroundColor)
+ .cornerRadius(16.dp)
+ .padding(16.dp)
+ .clickable(actionStartActivity()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Large artwork - this is the main focus of the 4x3 widget
+ Image(
+ provider = currentItem?.song?.let {
+ // TODO: Load actual artwork - Glance has limitations with async image loading
+ ImageProvider(if (isDarkMode) R.drawable.ic_music_note_white_24dp else com.simplecityapps.playback.R.drawable.ic_music_note_black_24dp)
+ } ?: ImageProvider(com.simplecityapps.core.R.drawable.ic_shuttle_logo),
+ contentDescription = "Album artwork",
+ modifier = GlanceModifier
+ .size(200.dp)
+ .cornerRadius(8.dp),
+ contentScale = ContentScale.Crop
+ )
+
+ Spacer(modifier = GlanceModifier.height(16.dp))
+
+ // Song info
+ Column(
+ modifier = GlanceModifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = currentItem?.song?.name ?: context.getString(R.string.queue_empty),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center
+ ),
+ maxLines = 1
+ )
+
+ Spacer(modifier = GlanceModifier.height(4.dp))
+
+ Text(
+ text = currentItem?.song?.let { song ->
+ song.friendlyArtistName ?: song.albumArtist ?: context.getString(com.simplecityapps.core.R.string.unknown)
+ } ?: context.getString(com.simplecityapps.core.R.string.widget_empty_text),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center
+ ),
+ maxLines = 1
+ )
+
+ Spacer(modifier = GlanceModifier.height(2.dp))
+
+ Text(
+ text = currentItem?.song?.album ?: "",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center
+ ),
+ maxLines = 1
+ )
+ }
+
+ Spacer(modifier = GlanceModifier.height(16.dp))
+
+ if (currentItem != null) {
+ // Playback controls
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Previous button
+ Image(
+ provider = ImageProvider(if (isDarkMode) R.drawable.ic_skip_previous_white_24dp else com.simplecityapps.playback.R.drawable.ic_skip_previous_black_24dp),
+ contentDescription = "Previous",
+ modifier = GlanceModifier
+ .size(56.dp)
+ .clickable(actionRunCallback())
+ )
+
+ Spacer(modifier = GlanceModifier.size(8.dp))
+
+ // Play/Pause button (larger for emphasis)
+ Image(
+ provider = ImageProvider(getPlayPauseIcon(playbackManager, isDarkMode)),
+ contentDescription = "Play/Pause",
+ modifier = GlanceModifier
+ .size(64.dp)
+ .clickable(actionRunCallback())
+ )
+
+ Spacer(modifier = GlanceModifier.size(8.dp))
+
+ // Next button
+ Image(
+ provider = ImageProvider(if (isDarkMode) R.drawable.ic_skip_next_white_24dp else R.drawable.ic_skip_next_black_24dp),
+ contentDescription = "Next",
+ modifier = GlanceModifier
+ .size(56.dp)
+ .clickable(actionRunCallback())
+ )
+ }
+ }
+ }
+ }
+
+ private fun getPlayPauseIcon(playbackManager: PlaybackManager, isDarkMode: Boolean): Int = when (playbackManager.playbackState()) {
+ is PlaybackState.Loading, PlaybackState.Playing -> {
+ if (isDarkMode) R.drawable.ic_pause_white_24dp else com.simplecityapps.playback.R.drawable.ic_pause_black_24dp
+ }
+ else -> {
+ if (isDarkMode) R.drawable.ic_play_arrow_white_24dp else com.simplecityapps.playback.R.drawable.ic_play_arrow_black_24dp
+ }
+ }
+}
+
+@AndroidEntryPoint
+class WidgetProvider43 : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = Widget43()
+}
diff --git a/android/app/src/main/res/layout/appwidget_41.xml b/android/app/src/main/res/layout/appwidget_41.xml
deleted file mode 100644
index d3f54a2e3..000000000
--- a/android/app/src/main/res/layout/appwidget_41.xml
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/appwidget_41_dark.xml b/android/app/src/main/res/layout/appwidget_41_dark.xml
deleted file mode 100644
index eeadfe0ae..000000000
--- a/android/app/src/main/res/layout/appwidget_41_dark.xml
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/appwidget_42.xml b/android/app/src/main/res/layout/appwidget_42.xml
deleted file mode 100644
index 8f5fb656c..000000000
--- a/android/app/src/main/res/layout/appwidget_42.xml
+++ /dev/null
@@ -1,111 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/appwidget_42_dark.xml b/android/app/src/main/res/layout/appwidget_42_dark.xml
deleted file mode 100644
index e357a4ea5..000000000
--- a/android/app/src/main/res/layout/appwidget_42_dark.xml
+++ /dev/null
@@ -1,114 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/appwidget_placeholder.xml b/android/app/src/main/res/layout/appwidget_placeholder.xml
new file mode 100644
index 000000000..08c5388cf
--- /dev/null
+++ b/android/app/src/main/res/layout/appwidget_placeholder.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/appwidget_info_41.xml b/android/app/src/main/res/xml/appwidget_info_41.xml
index 763deb878..af3f2785c 100644
--- a/android/app/src/main/res/xml/appwidget_info_41.xml
+++ b/android/app/src/main/res/xml/appwidget_info_41.xml
@@ -2,7 +2,7 @@
android:minWidth="250dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
- android:initialLayout="@layout/appwidget_41"
+ android:initialLayout="@layout/appwidget_placeholder"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/appwidget_info_42.xml b/android/app/src/main/res/xml/appwidget_info_42.xml
index 0590fb0ec..78670a5d6 100644
--- a/android/app/src/main/res/xml/appwidget_info_42.xml
+++ b/android/app/src/main/res/xml/appwidget_info_42.xml
@@ -2,7 +2,7 @@
android:minWidth="250dp"
android:minHeight="110dp"
android:updatePeriodMillis="0"
- android:initialLayout="@layout/appwidget_42"
+ android:initialLayout="@layout/appwidget_placeholder"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/appwidget_info_43.xml b/android/app/src/main/res/xml/appwidget_info_43.xml
new file mode 100644
index 000000000..df2fb772c
--- /dev/null
+++ b/android/app/src/main/res/xml/appwidget_info_43.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/android/app/src/main/res/xml/preferences_widget.xml b/android/app/src/main/res/xml/preferences_widget.xml
index 0e89d49a4..aa76e1757 100644
--- a/android/app/src/main/res/xml/preferences_widget.xml
+++ b/android/app/src/main/res/xml/preferences_widget.xml
@@ -2,12 +2,6 @@
-
-