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: