diff --git a/android/app/build.gradle b/android/app/build.gradle
index 3221ef1d..7dc5fbee 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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"
}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index eab0f448..a2f586bc 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -17,38 +17,36 @@
+ android:usesCleartextTraffic="true">
+ android:name="io.flutter.embedding.android.EnableImpeller"
+ android:value="true" /> -->
+ 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.
+ -->
+ android:resource="@style/NormalTheme" />
+
@@ -56,12 +54,13 @@
+
+
+ android:scheme="https" />
@@ -72,23 +71,27 @@
+
+
-
+
-
@@ -96,23 +99,40 @@
-
-
+
+ android:name="android.appwidget.provider"
+ android:resource="@xml/home_player_widget_config" />
+
+
+
+
+
+
+
+
-
+ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt
index 2444c3fc..46a3e64b 100644
--- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt
@@ -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("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(
+ 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(
+ parameters = actionParametersOf(serverAddressKey to playbackServerAddress)
+ )
+ )
+ Spacer(modifier = GlanceModifier.size(6.dp))
+ CircleIconButton(
+ imageProvider = ImageProvider(nextIcon),
+ contentDescription = "Previous",
+ onClick = actionRunCallback(
+ 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")
+ }
}
}
}
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt
new file mode 100644
index 00000000..4edd69f6
--- /dev/null
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt
@@ -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?,
+
+ val href: String?,
+ val id: String?,
+ val images: List?,
+ 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
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt
new file mode 100644
index 00000000..ef43ecc8
--- /dev/null
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt
@@ -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?,
+ val images: List?,
+
+ @SerializedName("popularity")
+ val popularity: Int?
+)
+
+@Serializable
+data class Followers(
+ val total: Int?
+)
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt
new file mode 100644
index 00000000..3d4d3af6
--- /dev/null
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt
@@ -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?
+)
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt
new file mode 100644
index 00000000..717b790f
--- /dev/null
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt
@@ -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?,
+
+ @SerializedName("available_markets") val availableMarkets: List?,
+
+ @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,
+}
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt
new file mode 100644
index 00000000..adb6b350
--- /dev/null
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt
@@ -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(", ") ?: ""
+ val imgUri = activeTrack?.album?.images?.get(0)?.url
+ ?: "https://placehold.co/600x400/000000/FFF.jpg";
+ val title = activeTrack?.name ?: ""
+
+
+ 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
+ ),
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt
new file mode 100644
index 00000000..ded25123
--- /dev/null
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt
@@ -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
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/res/xml/home_player_widget_config.xml b/android/app/src/main/res/xml/home_player_widget_config.xml
index 0b33ead7..c8ec7048 100644
--- a/android/app/src/main/res/xml/home_player_widget_config.xml
+++ b/android/app/src/main/res/xml/home_player_widget_config.xml
@@ -1,7 +1,7 @@
diff --git a/android/build.gradle b/android/build.gradle
index bc157bd1..8f31e8ca 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -15,4 +15,4 @@ subprojects {
tasks.register("clean", Delete) {
delete rootProject.buildDir
-}
+}
\ No newline at end of file
diff --git a/android/settings.gradle b/android/settings.gradle
index b3629757..1e8ffbe3 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -22,3 +22,4 @@ plugins {
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
+include ':app'
\ No newline at end of file
diff --git a/pubspec.lock b/pubspec.lock
index 33913eb1..5aa4f9b4 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -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: