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 @@ - -