From fba1876535ff1a5b324f7be639e452f25b33e1a4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Dec 2024 12:31:07 +0600 Subject: [PATCH] feat: responsive and working android home widget --- android/app/build.gradle | 3 - .../spotube/glance/HomePlayerWidget.kt | 43 ++--- .../krtirtho/spotube/glance/models/Image.kt | 2 +- .../glance/widgets/Base64ImageProvider.kt | 14 ++ .../glance/widgets/TrackDetailsView.kt | 32 ++-- .../spotube/glance/widgets/TrackProgress.kt | 108 ++++++------ lib/main.dart | 6 + lib/pages/root/root_app.dart | 3 + lib/provider/glance/glance.dart | 156 ++++++++++++++++++ lib/provider/server/router.dart | 4 + lib/provider/server/routes/playback.dart | 21 +++ 11 files changed, 288 insertions(+), 104 deletions(-) create mode 100644 android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt create mode 100644 lib/provider/glance/glance.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 7dc5fbee..74f6efea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -133,7 +133,4 @@ dependencies { 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/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt index 46a3e64b..2493fab2 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 @@ -5,6 +5,7 @@ import HomeWidgetGlanceStateDefinition import android.content.Context import android.graphics.drawable.Icon import android.net.Uri +import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -35,14 +36,9 @@ import androidx.glance.preview.Preview import androidx.glance.state.GlanceStateDefinition 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") @@ -83,6 +79,7 @@ class HomePlayerWidget : GlanceAppWidget() { 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 @@ -99,17 +96,10 @@ class HomePlayerWidget : GlanceAppWidget() { GlanceTheme { Scaffold { Column( - modifier = GlanceModifier.padding( - horizontal = if (size != Breakpoints.SMALL_SQUARE) 8.dp else 2.dp, - vertical = 16.dp - ) + modifier = GlanceModifier.padding(top = 10.dp) ) { - if (size == Breakpoints.HORIZONTAL_RECTANGLE) { - Row( - verticalAlignment = Alignment.Vertical.CenterVertically, - ) { TrackDetailsView(activeTrack) } - } else { - TrackDetailsView(activeTrack) + Row(verticalAlignment = Alignment.Vertical.CenterVertically) { + TrackDetailsView(activeTrack) } Spacer(modifier = GlanceModifier.size(6.dp)) if (size != Breakpoints.SMALL_SQUARE) { @@ -154,7 +144,7 @@ class HomePlayerWidget : GlanceAppWidget() { } } -class PlayPauseAction : InteractiveAction("toggle_playback") +class PlayPauseAction : InteractiveAction("toggle-playback") class NextAction : InteractiveAction("next") class PreviousAction : InteractiveAction("previous") @@ -165,22 +155,19 @@ abstract class InteractiveAction(val command: String) : ActionCallback { glanceId: GlanceId, parameters: ActionParameters ) { - val serverAddress = parameters[serverAddressKey] + val serverAddress = parameters[serverAddressKey] ?: "" + + Log.d("HomePlayerWidget", "Sending command $command to $serverAddress") + 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") - } - } - } + val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast( + context, + Uri.parse("spotube://playback/$command?serverAddress=$serverAddress") + ) + backgroundIntent.send() } } 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 index 3d4d3af6..de7d5521 100644 --- 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 @@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable data class Image( val height: Int?, val width: Int?, - val url: String? + val path: String, ) \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt new file mode 100644 index 00000000..79339cea --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt @@ -0,0 +1,14 @@ +package oss.krtirtho.spotube.glance.widgets + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.glance.ImageProvider + +@Suppress("FunctionName") +fun Base64ImageProvider(base64: String): ImageProvider { + var bytes = Base64.decode(base64, Base64.DEFAULT); + + var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size); + + return ImageProvider(bitmap) +} \ No newline at end of file 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 index adb6b350..fdfe8e4b 100644 --- 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 @@ -1,15 +1,20 @@ package oss.krtirtho.spotube.glance.widgets +import android.graphics.BitmapFactory import android.net.Uri +import android.util.Log 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.ImageProvider +import androidx.glance.LocalContext import androidx.glance.LocalSize -import androidx.glance.appwidget.ImageProvider import androidx.glance.appwidget.cornerRadius +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row import androidx.glance.layout.Column import androidx.glance.layout.ContentScale import androidx.glance.layout.Spacer @@ -22,25 +27,30 @@ import oss.krtirtho.spotube.glance.models.Track @Composable fun TrackDetailsView(activeTrack: Track?) { + val context = LocalContext.current + 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 imgLocalPath = activeTrack?.album?.images?.get(0)?.path; val title = activeTrack?.name ?: "" - + Image( - provider = ImageProvider(uri = Uri.parse(imgUri)), + provider = + if (imgLocalPath == null) + ImageProvider( + BitmapFactory.decodeResource( + context.resources, + android.R.drawable.ic_delete + ) + ) + else ImageProvider(BitmapFactory.decodeFile(imgLocalPath)), 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 - } + if (size.height < 200.dp) 50.dp + else 100.dp ), contentScale = ContentScale.Fit ) 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 index ded25123..b54059b1 100644 --- 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 @@ -7,85 +7,71 @@ 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.math.max import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import oss.krtirtho.spotube.glance.Breakpoints 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" - } + 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 + val size = LocalSize.current + val position = prefs.getInt("position", 0).seconds + var duration = prefs.getInt("duration", 0).seconds - var startingTime = (duration.inWholeSeconds * progress).toLong().seconds + var progress = position.inWholeSeconds.toFloat() / max(duration.inWholeSeconds.toFloat(), 1.0f) - var textStyle = TextStyle( - color = GlanceTheme.colors.onBackground, - ) + 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 - ) - } - } + if (size == Breakpoints.HORIZONTAL_RECTANGLE) { + Row(modifier = GlanceModifier.fillMaxWidth()) { + Text(text = position.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 = position.format(), style = textStyle) + Spacer(modifier = GlanceModifier.defaultWeight()) + Text(text = duration.format(), style = textStyle) + } + } + } } diff --git a/lib/main.dart b/lib/main.dart index f13991e2..1aaa5d1f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hive/hive.dart'; +import 'package:home_widget/home_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_notifier/local_notifier.dart'; import 'package:media_kit/media_kit.dart'; @@ -27,6 +28,7 @@ import 'package:spotube/hooks/configurators/use_has_touch.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/glance/glance.dart'; import 'package:spotube/provider/server/bonsoir.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart'; @@ -161,6 +163,10 @@ class Spotube extends HookConsumerWidget { useEffect(() { FlutterNativeSplash.remove(); + if (kIsMobile) { + HomeWidget.registerInteractivityCallback(glanceBackgroundCallback); + } + return () { /// For enabling hot reload for audio player if (!kDebugMode) return; diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 0274de00..2a6c36f0 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -18,6 +18,7 @@ import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/glance/glance.dart'; import 'package:spotube/provider/server/routes/connect.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/platform.dart'; @@ -39,6 +40,8 @@ class RootApp extends HookConsumerWidget { final scaffoldMessenger = ScaffoldMessenger.of(context); final connectRoutes = ref.watch(serverConnectRoutesProvider); + ref.listen(glanceProvider, (_, __) {}); + useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { ServiceUtils.checkForUpdates(context, ref); diff --git a/lib/provider/glance/glance.dart b/lib/provider/glance/glance.dart new file mode 100644 index 00000000..4f0c6333 --- /dev/null +++ b/lib/provider/glance/glance.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:home_widget/home_widget.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:logger/logger.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/server/server.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; + +@pragma("vm:entry-point") +Future glanceBackgroundCallback(Uri? data) async { + final logger = Logger(); + try { + if (data == null || + data.host != "playback" || + data.pathSegments.isEmpty || + data.queryParameters["serverAddress"] == null) { + return; + } + + final command = data.pathSegments.first; + final res = await get( + Uri.parse( + "http://${data.queryParameters["serverAddress"]}/playback/$command", + ), + ); + + if (res.statusCode != 200) { + throw Exception("Failed to execute command: $command\nBody: ${res.body}"); + } + } catch (e) { + logger.e("[GlanceBackgroundCallback] $e"); + } +} + +Future _saveWidgetData(String key, T? value) async { + try { + if (!kIsMobile) return null; + + return await HomeWidget.saveWidgetData(key, value); + } catch (e, stack) { + AppLogger.reportError(e, stack); + return null; + } +} + +Future _updateWidget() async { + try { + if (!kIsMobile) return; + + if (kIsAndroid) { + await HomeWidget.updateWidget( + androidName: 'HomePlayerWidgetReceiver', + qualifiedAndroidName: + 'oss.krtirtho.spotube.glance.HomePlayerWidgetReceiver', + ); + } + if (kIsIOS) { + await HomeWidget.updateWidget( + name: 'HomePlayerWidgetReceiver', + iOSName: 'HomePlayerWidget', + ); + } + } on Exception catch (e, stack) { + AppLogger.reportError(e, stack); + } +} + +final glanceProvider = Provider((ref) { + final server = ref.read(serverProvider); + + server.whenData( + (value) async { + final (:server, :port) = value; + + await _saveWidgetData( + "playbackServerAddress", + "${server.address.host}:$port", + ); + await _updateWidget(); + }, + ); + + ref.listen(serverProvider, (prev, next) async { + next.whenData( + (value) async { + final (:server, :port) = value; + + await _saveWidgetData( + "playbackServerAddress", + "${server.address.host}:$port", + ); + await _updateWidget(); + }, + ); + }); + + ref.listen( + audioPlayerProvider, + (previous, next) async { + try { + if (previous?.activeTrack != next.activeTrack && + next.activeTrack != null) { + final jsonTrack = next.activeTrack!.toJson(); + + final image = next.activeTrack!.album?.images?.first; + final cachedImage = + await DefaultCacheManager().getSingleFile(image!.url!); + final data = { + ...jsonTrack, + "album": { + ...jsonTrack["album"], + "images": [ + { + ...image.toJson(), + "path": cachedImage.path, + } + ] + } + }; + + await _saveWidgetData("activeTrack", jsonEncode(data)); + + await _updateWidget(); + } + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + }, + ); + + final subscriptions = [ + audioPlayer.playingStream.listen((playing) async { + await _saveWidgetData("isPlaying", playing); + await _updateWidget(); + }), + audioPlayer.positionStream.listen((position) async { + await _saveWidgetData("position", position.inSeconds); + await _updateWidget(); + }), + audioPlayer.durationStream.listen((duration) async { + await _saveWidgetData("duration", duration.inSeconds); + await _updateWidget(); + }), + ]; + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); +}); diff --git a/lib/provider/server/router.dart b/lib/provider/server/router.dart index e2a579cc..06ff4a24 100644 --- a/lib/provider/server/router.dart +++ b/lib/provider/server/router.dart @@ -14,6 +14,10 @@ final serverRouterProvider = Provider((ref) { router.get("/stream/", playbackRoutes.getStreamTrackId); + router.get("/playback/toggle-playback", playbackRoutes.togglePlayback); + router.get("/playback/previous", playbackRoutes.previousTrack); + router.get("/playback/next", playbackRoutes.nextTrack); + router.all("/ws", connectRoutes.websocket); return router; diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index 34317aa1..289da0e3 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -188,6 +188,27 @@ class ServerPlaybackRoutes { return Response.internalServerError(); } } + + /// @get('/playback/toggle-playback') + Future togglePlayback(Request request) async { + audioPlayer.isPlaying + ? await audioPlayer.pause() + : await audioPlayer.resume(); + + return Response.ok("Playback toggled"); + } + + /// @get('/playback/previous') + Future previousTrack(Request request) async { + await audioPlayer.skipToPrevious(); + return Response.ok("Previous track"); + } + + /// @get('/playback/next') + Future nextTrack(Request request) async { + await audioPlayer.skipToNext(); + return Response.ok("Next track"); + } } final serverPlaybackRoutesProvider =