mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
feat: responsive and working android home widget
This commit is contained in:
parent
a33974cd1e
commit
fba1876535
@ -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"
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable
|
||||
data class Image(
|
||||
val height: Int?,
|
||||
val width: Int?,
|
||||
val url: String?
|
||||
val path: String,
|
||||
)
|
@ -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)
|
||||
}
|
@ -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
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
156
lib/provider/glance/glance.dart
Normal file
156
lib/provider/glance/glance.dart
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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 =
|
||||
|
Loading…
Reference in New Issue
Block a user