feat: style widget player and add intent and callbacks on action

This commit is contained in:
Kingkor Roy Tirtho 2024-12-14 22:07:32 +06:00
parent c1b9de5099
commit a33974cd1e
13 changed files with 488 additions and 47 deletions

View File

@ -109,6 +109,9 @@ android {
}
}
packagingOptions {
resources.excludes += "DebugProbesKt.bin"
}
}
flutter {
@ -124,5 +127,13 @@ dependencies {
implementation "androidx.glance:glance-appwidget:$glanceVersion"
implementation "androidx.glance:glance-appwidget-preview:$glanceVersion"
implementation "androidx.glance:glance-preview:$glanceVersion"
implementation "androidx.glance:glance-material3:$glanceVersion"
implementation "androidx.glance:glance-material:$glanceVersion"
implementation "androidx.work:work-runtime-ktx:2.8.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3"
implementation 'com.google.code.gson:gson:2.11.0'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
}

View File

@ -17,38 +17,36 @@
</queries>
<application
android:name="${applicationName}"
android:allowBackup="false"
android:fullBackupContent="false"
android:label="@string/app_name_en"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:label="@string/app_name_en"
android:requestLegacyExternalStorage="true"
>
android:usesCleartextTraffic="true">
<!-- Enable Impeller -->
<!-- <meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleInstance"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
>
android:windowSoftInputMode="adjustResize">
<!--
Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI.
-->
Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI.
-->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@ -56,12 +54,13 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="open.spotify.com"
/>
android:scheme="https" />
</intent-filter>
<intent-filter>
@ -72,23 +71,27 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with "spotify:// -->
<data android:scheme="spotify" />
<data android:scheme="spotube" />
</intent-filter>
</activity>
<!-- AudioService Config -->
<service android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
@ -96,23 +99,40 @@
</receiver>
<!-- =================== -->
<meta-data android:name="com.google.android.gms.car.application"
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- Home Widget config -->
<receiver android:name=".glance.HomePlayerWidgetReceiver"
android:exported="true">
<receiver
android:name=".glance.HomePlayerWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/home_player_widget_config" />
android:name="android.appwidget.provider"
android:resource="@xml/home_player_widget_config" />
</receiver>
<receiver
android:name="es.antonborri.home_widget.HomeWidgetBackgroundReceiver"
android:exported="true">
<intent-filter>
<action android:name="es.antonborri.home_widget.action.BACKGROUND" />
</intent-filter>
</receiver>
<service
android:name="es.antonborri.home_widget.HomeWidgetBackgroundService"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE" />
<!-- =================== -->
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View File

@ -3,24 +3,68 @@ package oss.krtirtho.spotube.glance
import HomeWidgetGlanceState
import HomeWidgetGlanceStateDefinition
import android.content.Context
import android.graphics.drawable.Icon
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.components.CircleIconButton
import androidx.glance.appwidget.components.Scaffold
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Box
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.text.Text
import com.google.gson.Gson
import es.antonborri.home_widget.HomeWidgetBackgroundIntent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import oss.krtirtho.spotube.glance.models.Track
import oss.krtirtho.spotube.glance.widgets.TrackDetailsView
import oss.krtirtho.spotube.glance.widgets.TrackProgress
import java.net.URL
val gson = Gson()
val serverAddressKey = ActionParameters.Key<String>("serverAddress")
class Breakpoints {
companion object {
val SMALL_SQUARE = DpSize(100.dp, 100.dp)
val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp)
val BIG_SQUARE = DpSize(250.dp, 250.dp)
}
}
class HomePlayerWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Responsive(
setOf(
Breakpoints.SMALL_SQUARE,
Breakpoints.HORIZONTAL_RECTANGLE,
Breakpoints.BIG_SQUARE
)
)
override val stateDefinition: GlanceStateDefinition<*>?
get() = HomeWidgetGlanceStateDefinition()
@ -30,18 +74,112 @@ class HomePlayerWidget : GlanceAppWidget() {
}
}
@OptIn(ExperimentalGlancePreviewApi::class)
@Preview(widthDp = 200, heightDp = 150)
@Preview(widthDp = 100, heightDp = 100)
@Composable
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
val prefs = currentState.preferences
val counter = prefs.getInt("counter", 0)
Box(modifier = GlanceModifier.background(Color.White).padding(16.dp)) {
Column() {
Text("Counter")
Text(
counter.toString()
)
val size = LocalSize.current
val activeTrackStr = prefs.getString("activeTrack", null)
val isPlaying = prefs.getBoolean("isPlaying", false)
val playbackServerAddress = prefs.getString("playbackServerAddress", null) ?: ""
var activeTrack: Track? = null
if (activeTrackStr != null) {
activeTrack = gson.fromJson(activeTrackStr, Track::class.java)
}
val playIcon = Icon.createWithResource(context, android.R.drawable.ic_media_play);
val pauseIcon = Icon.createWithResource(context, android.R.drawable.ic_media_pause);
val previousIcon = Icon.createWithResource(context, android.R.drawable.ic_media_previous);
val nextIcon = Icon.createWithResource(context, android.R.drawable.ic_media_next);
GlanceTheme {
Scaffold {
Column(
modifier = GlanceModifier.padding(
horizontal = if (size != Breakpoints.SMALL_SQUARE) 8.dp else 2.dp,
vertical = 16.dp
)
) {
if (size == Breakpoints.HORIZONTAL_RECTANGLE) {
Row(
verticalAlignment = Alignment.Vertical.CenterVertically,
) { TrackDetailsView(activeTrack) }
} else {
TrackDetailsView(activeTrack)
}
Spacer(modifier = GlanceModifier.size(6.dp))
if (size != Breakpoints.SMALL_SQUARE) {
TrackProgress(prefs)
}
Spacer(modifier = GlanceModifier.size(6.dp))
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.Horizontal.CenterHorizontally
) {
CircleIconButton(
imageProvider = ImageProvider(previousIcon),
contentDescription = "Previous",
onClick = actionRunCallback<PreviousAction>(
parameters = actionParametersOf(serverAddressKey to playbackServerAddress)
)
)
Spacer(modifier = GlanceModifier.size(6.dp))
CircleIconButton(
imageProvider =
if (isPlaying) ImageProvider(pauseIcon)
else ImageProvider(playIcon),
contentDescription = "Play/Pause",
onClick = actionRunCallback<PlayPauseAction>(
parameters = actionParametersOf(serverAddressKey to playbackServerAddress)
)
)
Spacer(modifier = GlanceModifier.size(6.dp))
CircleIconButton(
imageProvider = ImageProvider(nextIcon),
contentDescription = "Previous",
onClick = actionRunCallback<NextAction>(
parameters = actionParametersOf(
serverAddressKey to playbackServerAddress
)
)
)
}
}
}
}
}
}
class PlayPauseAction : InteractiveAction("toggle_playback")
class NextAction : InteractiveAction("next")
class PreviousAction : InteractiveAction("previous")
abstract class InteractiveAction(val command: String) : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val serverAddress = parameters[serverAddressKey]
if (serverAddress == null || serverAddress.isEmpty()) {
return
}
withContext(Dispatchers.IO) {
val client = OkHttpClient()
val request = Request.Builder().url("http://$serverAddress/playback/$command").build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.string()
} else {
print("Failed to send command to server")
}
}
}
}

View File

@ -0,0 +1,40 @@
package oss.krtirtho.spotube.glance.models
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class AlbumSimple(
@SerializedName("album_type")
val albumType: AlbumType?,
@SerializedName("available_markets")
val availableMarkets: List<Market>?,
val href: String?,
val id: String?,
val images: List<Image>?,
val name: String?,
@SerializedName("release_date")
val releaseDate: String?,
@SerializedName("release_date_precision")
val releaseDatePrecision: DatePrecision?,
val type: String?,
val uri: String?,
)
@Serializable
enum class AlbumType {
album,
single,
compilation
}
enum class DatePrecision {
year,
month,
day
}

View File

@ -0,0 +1,25 @@
package oss.krtirtho.spotube.glance.models
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class Artist(
val href: String?,
val id: String?,
val name: String?,
val type: String?,
val uri: String?,
val followers: Followers?,
val genres: List<String>?,
val images: List<Image>?,
@SerializedName("popularity")
val popularity: Int?
)
@Serializable
data class Followers(
val total: Int?
)

View File

@ -0,0 +1,10 @@
package oss.krtirtho.spotube.glance.models
import kotlinx.serialization.Serializable
@Serializable
data class Image(
val height: Int?,
val width: Int?,
val url: String?
)

View File

@ -0,0 +1,37 @@
package oss.krtirtho.spotube.glance.models
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.milliseconds
@Serializable
data class Track(
val album: AlbumSimple?, val artists: List<Artist>?,
@SerializedName("available_markets") val availableMarkets: List<Market>?,
@SerializedName("disc_number") val discNumber: Int?,
@SerializedName("duration_ms") val durationMs: Int,
val explicit: Boolean?, val href: String?, val id: String?,
@SerializedName("is_playable") val isPlayable: Boolean?,
val name: String?,
@SerializedName("popularity") val popularity: Int?,
@SerializedName("preview_url") val previewUrl: String?,
@SerializedName("track_number") val trackNumber: Int?,
val type: String?, val uri: String?
) {
val duration: kotlin.time.Duration
get() = durationMs.toLong().milliseconds
}
enum class Market {
AD, AE, AF, AG, AI, AL, AM, AO, AQ, AR, AS, AT, AU, AW, AX, AZ, BA, BB, BD, BE, BF, BG, BH, BI, BJ, BL, BM, BN, BO, BQ, BR, BS, BT, BV, BW, BY, BZ, CA, CC, CD, CF, CG, CH, CI, CK, CL, CM, CN, CO, CR, CU, CV, CW, CX, CY, CZ, DE, DJ, DK, DM, DO, DZ, EC, EE, EG, EH, ER, ES, ET, FI, FJ, FK, FM, FO, FR, GA, GB, GD, GE, GF, GG, GH, GI, GL, GM, GN, GP, GQ, GR, GS, GT, GU, GW, GY, HK, HM, HN, HR, HT, HU, ID, IE, IL, IM, IN, IO, IQ, IR, IS, IT, JE, JM, JO, JP, KE, KG, KH, KI, KM, KN, KP, KR, KW, KY, KZ, LA, LB, LC, LI, LK, LR, LS, LT, LU, LV, LY, MA, MC, MD, ME, MF, MG, MH, MK, ML, MM, MN, MO, MP, MQ, MR, MS, MT, MU, MV, MW, MX, MY, MZ, NA, NC, NE, NF, NG, NI, NL, NO, NP, NR, NU, NZ, OM, PA, PE, PF, PG, PH, PK, PL, PM, PN, PR, PS, PT, PW, PY, QA, RE, RO, RS, RU, RW, SA, SB, SC, SD, SE, SG, SH, SI, SJ, SK, SL, SM, SN, SO, SR, SS, ST, SV, SX, SY, SZ, TC, TD, TF, TG, TH, TJ, TK, TL, TM, TN, TO, TR, TT, TV, TW, TZ, UA, UG, UM, US, UY, UZ, VA, VC, VE, VG, VI, VN, VU, WF, WS, XK, YE, YT, ZA, ZM, ZW,
}

View File

@ -0,0 +1,68 @@
package oss.krtirtho.spotube.glance.widgets
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.LocalSize
import androidx.glance.appwidget.ImageProvider
import androidx.glance.appwidget.cornerRadius
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Spacer
import androidx.glance.layout.size
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import oss.krtirtho.spotube.glance.Breakpoints
import oss.krtirtho.spotube.glance.models.Track
@Composable
fun TrackDetailsView(activeTrack: Track?) {
val size = LocalSize.current
val artistStr = activeTrack?.artists?.map { it.name }?.joinToString(", ") ?: "<No Artist>"
val imgUri = activeTrack?.album?.images?.get(0)?.url
?: "https://placehold.co/600x400/000000/FFF.jpg";
val title = activeTrack?.name ?: "<No Track>"
Image(
provider = ImageProvider(uri = Uri.parse(imgUri)),
contentDescription = "Album Art",
modifier = GlanceModifier.cornerRadius(8.dp)
.size(
when (size) {
Breakpoints.SMALL_SQUARE -> 70.dp
Breakpoints.HORIZONTAL_RECTANGLE -> 100.dp
Breakpoints.BIG_SQUARE -> 150.dp
else -> 120.dp
}
),
contentScale = ContentScale.Fit
)
Spacer(modifier = GlanceModifier.size(6.dp))
Column {
Text(
text = title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onBackground
),
)
if (size != Breakpoints.SMALL_SQUARE) {
Spacer(modifier = GlanceModifier.size(6.dp))
Text(
text = artistStr,
style = TextStyle(
fontSize = 14.sp,
color = GlanceTheme.colors.onBackground
),
)
}
}
}

View File

@ -0,0 +1,91 @@
package oss.krtirtho.spotube.glance.widgets
import android.content.SharedPreferences
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalSize
import androidx.glance.appwidget.LinearProgressIndicator
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.size
import androidx.glance.layout.wrapContentWidth
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import oss.krtirtho.spotube.glance.Breakpoints
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
fun Duration.format(): String {
return this.toComponents { hour, minutes, seconds, nanoseconds ->
var paddedSeconds = seconds.toString().padStart(2, '0')
var paddedMinutes = minutes.toString().padStart(2, '0')
var paddedHour = hour.toString().padStart(2, '0')
if (hour == 0L) {
"$paddedMinutes:$paddedSeconds"
} else {
"$paddedHour:$paddedMinutes:$paddedSeconds"
}
}
}
@Composable
fun TrackProgress(prefs: SharedPreferences) {
val size = LocalSize.current;
val progress = prefs.getFloat("progress", 0.0f)
var duration = prefs.getInt("duration", 0).seconds
var startingTime = (duration.inWholeSeconds * progress).toLong().seconds
var textStyle = TextStyle(
color = GlanceTheme.colors.onBackground,
)
if (size == Breakpoints.HORIZONTAL_RECTANGLE) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
Text(
text = startingTime.format(),
style = textStyle
)
Spacer(modifier = GlanceModifier.size(6.dp))
LinearProgressIndicator(
progress = progress,
modifier = GlanceModifier.defaultWeight(),
color = GlanceTheme.colors.primary,
backgroundColor = GlanceTheme.colors.primaryContainer,
)
Spacer(modifier = GlanceModifier.size(6.dp))
Text(
text = duration.format(),
style = textStyle
)
}
} else {
Column(modifier = GlanceModifier.fillMaxWidth()) {
LinearProgressIndicator(
progress = progress,
modifier = GlanceModifier.fillMaxWidth(),
color = GlanceTheme.colors.primary,
backgroundColor = GlanceTheme.colors.primaryContainer,
)
Spacer(modifier = GlanceModifier.size(6.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
Text(
text = startingTime.format(),
style = textStyle
)
Spacer(modifier = GlanceModifier.defaultWeight())
Text(
text = duration.format(),
style = textStyle
)
}
}
}
}

View File

@ -1,7 +1,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="40dp"
android:minHeight="40dp"
android:minWidth="100dp"
android:minHeight="100dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="10000">
</appwidget-provider>

View File

@ -15,4 +15,4 @@ subprojects {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
}

View File

@ -22,3 +22,4 @@ plugins {
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ':app'

View File

@ -42,10 +42,10 @@ packages:
dependency: "direct main"
description:
name: app_links
sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950"
sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99
url: "https://pub.dev"
source: hosted
version: "6.3.3"
version: "6.3.2"
app_links_linux:
dependency: transitive
description: