feat: responsive and working android home widget

This commit is contained in:
Kingkor Roy Tirtho 2024-12-15 12:31:07 +06:00
parent a33974cd1e
commit fba1876535
11 changed files with 288 additions and 104 deletions

View File

@ -133,7 +133,4 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3"
implementation 'com.google.code.gson:gson:2.11.0' 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

@ -5,6 +5,7 @@ import HomeWidgetGlanceStateDefinition
import android.content.Context import android.content.Context
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -35,14 +36,9 @@ import androidx.glance.preview.Preview
import androidx.glance.state.GlanceStateDefinition import androidx.glance.state.GlanceStateDefinition
import com.google.gson.Gson import com.google.gson.Gson
import es.antonborri.home_widget.HomeWidgetBackgroundIntent 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.models.Track
import oss.krtirtho.spotube.glance.widgets.TrackDetailsView import oss.krtirtho.spotube.glance.widgets.TrackDetailsView
import oss.krtirtho.spotube.glance.widgets.TrackProgress import oss.krtirtho.spotube.glance.widgets.TrackProgress
import java.net.URL
val gson = Gson() val gson = Gson()
val serverAddressKey = ActionParameters.Key<String>("serverAddress") val serverAddressKey = ActionParameters.Key<String>("serverAddress")
@ -83,6 +79,7 @@ class HomePlayerWidget : GlanceAppWidget() {
val size = LocalSize.current val size = LocalSize.current
val activeTrackStr = prefs.getString("activeTrack", null) val activeTrackStr = prefs.getString("activeTrack", null)
val isPlaying = prefs.getBoolean("isPlaying", false) val isPlaying = prefs.getBoolean("isPlaying", false)
val playbackServerAddress = prefs.getString("playbackServerAddress", null) ?: "" val playbackServerAddress = prefs.getString("playbackServerAddress", null) ?: ""
var activeTrack: Track? = null var activeTrack: Track? = null
@ -99,16 +96,9 @@ class HomePlayerWidget : GlanceAppWidget() {
GlanceTheme { GlanceTheme {
Scaffold { Scaffold {
Column( Column(
modifier = GlanceModifier.padding( modifier = GlanceModifier.padding(top = 10.dp)
horizontal = if (size != Breakpoints.SMALL_SQUARE) 8.dp else 2.dp,
vertical = 16.dp
)
) { ) {
if (size == Breakpoints.HORIZONTAL_RECTANGLE) { Row(verticalAlignment = Alignment.Vertical.CenterVertically) {
Row(
verticalAlignment = Alignment.Vertical.CenterVertically,
) { TrackDetailsView(activeTrack) }
} else {
TrackDetailsView(activeTrack) TrackDetailsView(activeTrack)
} }
Spacer(modifier = GlanceModifier.size(6.dp)) Spacer(modifier = GlanceModifier.size(6.dp))
@ -154,7 +144,7 @@ class HomePlayerWidget : GlanceAppWidget() {
} }
} }
class PlayPauseAction : InteractiveAction("toggle_playback") class PlayPauseAction : InteractiveAction("toggle-playback")
class NextAction : InteractiveAction("next") class NextAction : InteractiveAction("next")
class PreviousAction : InteractiveAction("previous") class PreviousAction : InteractiveAction("previous")
@ -165,22 +155,19 @@ abstract class InteractiveAction(val command: String) : ActionCallback {
glanceId: GlanceId, glanceId: GlanceId,
parameters: ActionParameters parameters: ActionParameters
) { ) {
val serverAddress = parameters[serverAddressKey] val serverAddress = parameters[serverAddressKey] ?: ""
Log.d("HomePlayerWidget", "Sending command $command to $serverAddress")
if (serverAddress == null || serverAddress.isEmpty()) { if (serverAddress == null || serverAddress.isEmpty()) {
return return
} }
withContext(Dispatchers.IO) {
val client = OkHttpClient()
val request = Request.Builder().url("http://$serverAddress/playback/$command").build()
client.newCall(request).execute().use { response -> val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast(
if (response.isSuccessful) { context,
response.body?.string() Uri.parse("spotube://playback/$command?serverAddress=$serverAddress")
} else { )
print("Failed to send command to server") backgroundIntent.send()
}
}
}
} }
} }

View File

@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable
data class Image( data class Image(
val height: Int?, val height: Int?,
val width: Int?, val width: Int?,
val url: String? val path: String,
) )

View File

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

View File

@ -1,15 +1,20 @@
package oss.krtirtho.spotube.glance.widgets package oss.krtirtho.spotube.glance.widgets
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.Image import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.appwidget.ImageProvider
import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.cornerRadius
import androidx.glance.layout.Alignment
import androidx.glance.layout.Row
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale import androidx.glance.layout.ContentScale
import androidx.glance.layout.Spacer import androidx.glance.layout.Spacer
@ -22,25 +27,30 @@ import oss.krtirtho.spotube.glance.models.Track
@Composable @Composable
fun TrackDetailsView(activeTrack: Track?) { fun TrackDetailsView(activeTrack: Track?) {
val context = LocalContext.current
val size = LocalSize.current val size = LocalSize.current
val artistStr = activeTrack?.artists?.map { it.name }?.joinToString(", ") ?: "<No Artist>" val artistStr = activeTrack?.artists?.map { it.name }?.joinToString(", ") ?: "<No Artist>"
val imgUri = activeTrack?.album?.images?.get(0)?.url val imgLocalPath = activeTrack?.album?.images?.get(0)?.path;
?: "https://placehold.co/600x400/000000/FFF.jpg";
val title = activeTrack?.name ?: "<No Track>" val title = activeTrack?.name ?: "<No Track>"
Image( 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", contentDescription = "Album Art",
modifier = GlanceModifier.cornerRadius(8.dp) modifier = GlanceModifier.cornerRadius(8.dp)
.size( .size(
when (size) { if (size.height < 200.dp) 50.dp
Breakpoints.SMALL_SQUARE -> 70.dp else 100.dp
Breakpoints.HORIZONTAL_RECTANGLE -> 100.dp
Breakpoints.BIG_SQUARE -> 150.dp
else -> 120.dp
}
), ),
contentScale = ContentScale.Fit contentScale = ContentScale.Fit
) )

View File

@ -7,20 +7,17 @@ import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.appwidget.LinearProgressIndicator import androidx.glance.appwidget.LinearProgressIndicator
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.Row import androidx.glance.layout.Row
import androidx.glance.layout.Spacer import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.size import androidx.glance.layout.size
import androidx.glance.layout.wrapContentWidth
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import oss.krtirtho.spotube.glance.Breakpoints import kotlin.math.max
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import oss.krtirtho.spotube.glance.Breakpoints
fun Duration.format(): String { fun Duration.format(): String {
return this.toComponents { hour, minutes, seconds, nanoseconds -> return this.toComponents { hour, minutes, seconds, nanoseconds ->
@ -37,22 +34,20 @@ fun Duration.format(): String {
@Composable @Composable
fun TrackProgress(prefs: SharedPreferences) { fun TrackProgress(prefs: SharedPreferences) {
val size = LocalSize.current; val size = LocalSize.current
val progress = prefs.getFloat("progress", 0.0f) val position = prefs.getInt("position", 0).seconds
var duration = prefs.getInt("duration", 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( var textStyle =
TextStyle(
color = GlanceTheme.colors.onBackground, color = GlanceTheme.colors.onBackground,
) )
if (size == Breakpoints.HORIZONTAL_RECTANGLE) { if (size == Breakpoints.HORIZONTAL_RECTANGLE) {
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
Text( Text(text = position.format(), style = textStyle)
text = startingTime.format(),
style = textStyle
)
Spacer(modifier = GlanceModifier.size(6.dp)) Spacer(modifier = GlanceModifier.size(6.dp))
LinearProgressIndicator( LinearProgressIndicator(
progress = progress, progress = progress,
@ -61,10 +56,7 @@ fun TrackProgress(prefs: SharedPreferences) {
backgroundColor = GlanceTheme.colors.primaryContainer, backgroundColor = GlanceTheme.colors.primaryContainer,
) )
Spacer(modifier = GlanceModifier.size(6.dp)) Spacer(modifier = GlanceModifier.size(6.dp))
Text( Text(text = duration.format(), style = textStyle)
text = duration.format(),
style = textStyle
)
} }
} else { } else {
Column(modifier = GlanceModifier.fillMaxWidth()) { Column(modifier = GlanceModifier.fillMaxWidth()) {
@ -76,15 +68,9 @@ fun TrackProgress(prefs: SharedPreferences) {
) )
Spacer(modifier = GlanceModifier.size(6.dp)) Spacer(modifier = GlanceModifier.size(6.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
Text( Text(text = position.format(), style = textStyle)
text = startingTime.format(),
style = textStyle
)
Spacer(modifier = GlanceModifier.defaultWeight()) Spacer(modifier = GlanceModifier.defaultWeight())
Text( Text(text = duration.format(), style = textStyle)
text = duration.format(),
style = textStyle
)
} }
} }
} }

View File

@ -9,6 +9,7 @@ import 'package:flutter_discord_rpc/flutter_discord_rpc.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:local_notifier/local_notifier.dart'; import 'package:local_notifier/local_notifier.dart';
import 'package:media_kit/media_kit.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/models/database/database.dart';
import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart';
import 'package:spotube/provider/database/database.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/bonsoir.dart';
import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/server/server.dart';
import 'package:spotube/provider/tray_manager/tray_manager.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart';
@ -161,6 +163,10 @@ class Spotube extends HookConsumerWidget {
useEffect(() { useEffect(() {
FlutterNativeSplash.remove(); FlutterNativeSplash.remove();
if (kIsMobile) {
HomeWidget.registerInteractivityCallback(glanceBackgroundCallback);
}
return () { return () {
/// For enabling hot reload for audio player /// For enabling hot reload for audio player
if (!kDebugMode) return; if (!kDebugMode) return;

View File

@ -18,6 +18,7 @@ import 'package:spotube/hooks/configurators/use_endless_playback.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.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/provider/server/routes/connect.dart';
import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/services/connectivity_adapter.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -39,6 +40,8 @@ class RootApp extends HookConsumerWidget {
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context);
final connectRoutes = ref.watch(serverConnectRoutesProvider); final connectRoutes = ref.watch(serverConnectRoutesProvider);
ref.listen(glanceProvider, (_, __) {});
useEffect(() { useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
ServiceUtils.checkForUpdates(context, ref); ServiceUtils.checkForUpdates(context, ref);

View File

@ -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<void> 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<bool?> _saveWidgetData<T>(String key, T? value) async {
try {
if (!kIsMobile) return null;
return await HomeWidget.saveWidgetData<T>(key, value);
} catch (e, stack) {
AppLogger.reportError(e, stack);
return null;
}
}
Future<void> _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();
}
});
});

View File

@ -14,6 +14,10 @@ final serverRouterProvider = Provider((ref) {
router.get("/stream/<trackId>", playbackRoutes.getStreamTrackId); router.get("/stream/<trackId>", 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); router.all("/ws", connectRoutes.websocket);
return router; return router;

View File

@ -188,6 +188,27 @@ class ServerPlaybackRoutes {
return Response.internalServerError(); return Response.internalServerError();
} }
} }
/// @get('/playback/toggle-playback')
Future<Response> togglePlayback(Request request) async {
audioPlayer.isPlaying
? await audioPlayer.pause()
: await audioPlayer.resume();
return Response.ok("Playback toggled");
}
/// @get('/playback/previous')
Future<Response> previousTrack(Request request) async {
await audioPlayer.skipToPrevious();
return Response.ok("Previous track");
}
/// @get('/playback/next')
Future<Response> nextTrack(Request request) async {
await audioPlayer.skipToNext();
return Response.ok("Next track");
}
} }
final serverPlaybackRoutesProvider = final serverPlaybackRoutesProvider =