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 '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.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<String>("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()
}
}

View File

@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable
data class Image(
val height: 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
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(", ") ?: "<No Artist>"
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 ?: "<No Track>"
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
)

View File

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

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_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;

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/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);

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("/playback/toggle-playback", playbackRoutes.togglePlayback);
router.get("/playback/previous", playbackRoutes.previousTrack);
router.get("/playback/next", playbackRoutes.nextTrack);
router.all("/ws", connectRoutes.websocket);
return router;

View File

@ -188,6 +188,27 @@ class ServerPlaybackRoutes {
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 =