feat(android): home widget support (#2148)

* feat: add android home widget support

* feat: style widget player and add intent and callbacks on action

* feat: responsive and working android home widget

* fix(android): models stripping causing it to not work for release apks

* chore: ios lockfile update

* feat: config for iOS widget

* cd: upgrade xcode

* cd: reduce xcode version

* feat: add a christmas background
This commit is contained in:
Kingkor Roy Tirtho 2024-12-16 22:47:44 +06:00 committed by GitHub
parent 4595eb169f
commit b52bf0f448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1789 additions and 42 deletions

View File

@ -88,6 +88,12 @@ jobs:
with:
toolchain: stable
- name: Install Xcode
if: ${{matrix.platform == 'ios'}}
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.1'
- name: Install ${{matrix.platform}} dependencies
run: |
flutter pub get

2
.gitignore vendored
View File

@ -80,3 +80,5 @@ tm.json
# FVM Version Cache
.fvm/
android/build

View File

@ -27,5 +27,5 @@
"README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md",
"*.dart": "${capture}.g.dart,${capture}.freezed.dart"
},
"dart.flutterSdkPath": ".fvm/flutter_sdk"
"dart.flutterSdkPath": ".fvm/versions/3.27.0"
}

View File

@ -28,8 +28,10 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
def composeVersion = "1.4.8"
android {
namespace "dev.krtirtho.spotube"
namespace "oss.krtirtho.spotube"
compileSdkVersion 35
@ -48,6 +50,14 @@ android {
main.java.srcDirs += 'src/main/kotlin'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion "$composeVersion" // Correlates with org.jetbrains.kotlin.android plugin in settings.gradle
}
defaultConfig {
applicationId "oss.krtirtho.spotube"
minSdkVersion 24
@ -65,6 +75,7 @@ android {
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
@ -98,15 +109,28 @@ android {
}
}
packagingOptions {
resources.excludes += "DebugProbesKt.bin"
}
}
flutter {
source '../..'
}
def glanceVersion = "1.1.1"
dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
// other deps so just ignore
implementation 'com.android.support:multidex:2.0.1'
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'
}

View File

@ -1 +1,8 @@
-keep class androidx.lifecycle.DefaultLifecycleObserver
-keepnames class kotlinx.serialization.** { *; }
-keepnames class oss.krtirtho.spotube.glance.models.** { *; }
-keep @kotlinx.serialization.Serializable class *
-keepclassmembers class ** {
@kotlinx.serialization.* <fields>;
}

View File

@ -17,38 +17,36 @@
</queries>
<application
android:name="${applicationName}"
android:allowBackup="false"
android:fullBackupContent="false"
android:label="@string/app_name_en"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:label="@string/app_name_en"
android:requestLegacyExternalStorage="true"
>
android:usesCleartextTraffic="true">
<!-- Enable Impeller -->
<!-- <meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleInstance"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
>
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.
-->
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.
-->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@ -56,12 +54,13 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="open.spotify.com"
/>
android:scheme="https" />
</intent-filter>
<intent-filter>
@ -72,23 +71,30 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with "spotify:// -->
<data android:scheme="spotify" />
<data android:scheme="spotube" />
</intent-filter>
<intent-filter>
<action android:name="es.antonborri.home_widget.action.LAUNCH" />
</intent-filter>
</activity>
<!-- AudioService Config -->
<service android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
@ -96,11 +102,40 @@
</receiver>
<!-- =================== -->
<meta-data android:name="com.google.android.gms.car.application"
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- Home Widget config -->
<receiver
android:name=".glance.HomePlayerWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/home_player_widget_config" />
</receiver>
<receiver
android:name="es.antonborri.home_widget.HomeWidgetBackgroundReceiver"
android:exported="true">
<intent-filter>
<action android:name="es.antonborri.home_widget.action.BACKGROUND" />
</intent-filter>
</receiver>
<service
android:name="es.antonborri.home_widget.HomeWidgetBackgroundService"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE" />
<!-- =================== -->
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View File

@ -0,0 +1,216 @@
package oss.krtirtho.spotube.glance
import HomeWidgetGlanceState
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
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.background
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.background
import androidx.glance.appwidget.components.CircleIconButton
import androidx.glance.appwidget.components.Scaffold
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
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 com.google.gson.Gson
import es.antonborri.home_widget.HomeWidgetBackgroundIntent
import es.antonborri.home_widget.actionStartActivity
import oss.krtirtho.spotube.MainActivity
import oss.krtirtho.spotube.glance.models.Track
import oss.krtirtho.spotube.glance.widgets.FlutterAssetImageProvider
import oss.krtirtho.spotube.glance.widgets.TrackDetailsView
import oss.krtirtho.spotube.glance.widgets.TrackProgress
val gson = Gson()
val serverAddressKey = ActionParameters.Key<String>("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()
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceContent(context, currentState())
}
}
@OptIn(ExperimentalGlancePreviewApi::class)
@Preview(widthDp = 100, heightDp = 100)
@Composable
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
val prefs = currentState.preferences
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, R.drawable.ic_media_play);
val pauseIcon = Icon.createWithResource(context, R.drawable.ic_media_pause);
val previousIcon = Icon.createWithResource(context, R.drawable.ic_media_previous);
val nextIcon = Icon.createWithResource(context, R.drawable.ic_media_next);
GlanceTheme {
Box(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(8.dp)
.background(
color = GlanceTheme.colors.surface.getColor(context)
)
.clickable {
actionStartActivity<MainActivity>(context)
}
,
) {
Image(
provider = FlutterAssetImageProvider(
context,
"assets/backgrounds/xmas-effect.png"
),
contentDescription = "Background",
modifier = GlanceModifier
.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = GlanceModifier
.background(
color =
GlanceTheme.colors.surface.getColor(context)
.copy(alpha = 0.5f),
)
.fillMaxSize(),
) {}
Column(
modifier = GlanceModifier.padding(top = 10.dp, start = 10.dp, end = 10.dp)
) {
Row(verticalAlignment = Alignment.Vertical.CenterVertically) {
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<PreviousAction>(
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<PlayPauseAction>(
parameters = actionParametersOf(serverAddressKey to playbackServerAddress)
)
)
Spacer(modifier = GlanceModifier.size(6.dp))
CircleIconButton(
imageProvider = ImageProvider(nextIcon),
contentDescription = "Previous",
onClick = actionRunCallback<NextAction>(
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] ?: ""
Log.d("HomePlayerWidget", "Sending command $command to $serverAddress")
if (serverAddress == null || serverAddress.isEmpty()) {
return
}
val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast(
context,
Uri.parse("spotube://playback/$command?serverAddress=$serverAddress")
)
backgroundIntent.send()
}
}

View File

@ -0,0 +1,7 @@
package oss.krtirtho.spotube.glance
import HomeWidgetGlanceWidgetReceiver
class HomePlayerWidgetReceiver : HomeWidgetGlanceWidgetReceiver<HomePlayerWidget>() {
override val glanceAppWidget = HomePlayerWidget()
}

View File

@ -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<Market>?,
val href: String?,
val id: String?,
val images: List<Image>?,
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
}

View File

@ -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<String>?,
val images: List<Image>?,
@SerializedName("popularity")
val popularity: Int?
)
@Serializable
data class Followers(
val total: Int?
)

View File

@ -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 path: String,
)

View File

@ -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<Artist>?,
@SerializedName("available_markets") val availableMarkets: List<Market>?,
@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,
}

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

@ -0,0 +1,14 @@
package oss.krtirtho.spotube.glance.widgets
import android.content.Context
import android.graphics.BitmapFactory
import androidx.glance.ImageProvider
@Suppress("FunctionName")
fun FlutterAssetImageProvider(context: Context, path: String): ImageProvider {
var inputStream = context.assets.open("flutter_assets/$path")
return ImageProvider(
BitmapFactory.decodeStream(inputStream)
)
}

View File

@ -0,0 +1,78 @@
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.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
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 context = LocalContext.current
val size = LocalSize.current
val artistStr = activeTrack?.artists?.map { it.name }?.joinToString(", ") ?: "<No Artist>"
val imgLocalPath = activeTrack?.album?.images?.get(0)?.path;
val title = activeTrack?.name ?: "<No Track>"
Image(
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(
if (size.height < 200.dp) 50.dp
else 100.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
),
)
}
}
}

View File

@ -0,0 +1,77 @@
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.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.size
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
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"
}
}
}
@Composable
fun TrackProgress(prefs: SharedPreferences) {
val size = LocalSize.current
val position = prefs.getInt("position", 0).seconds
var duration = prefs.getInt("duration", 0).seconds
var progress = position.inWholeSeconds.toFloat() / max(duration.inWholeSeconds.toFloat(), 1.0f)
var textStyle =
TextStyle(
color = GlanceTheme.colors.onBackground,
)
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

@ -0,0 +1,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="100dp"
android:minHeight="100dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="10000">
</appwidget-provider>

View File

@ -18,8 +18,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
id "com.android.application" version '8.7.0' apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"
include ':app'

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,86 @@
//
// HomePlayerWidget.swift
// HomePlayerWidget
//
// Created by Kingkor Roy Tirtho on 15/12/24.
//
import WidgetKit
import SwiftUI
private let widgetGroupId = "group.spotube_home_player_widget"
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), emoji: "😀")
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), emoji: "😀")
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, emoji: "😀")
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
// func relevances() async -> WidgetRelevances<Void> {
// // Generate a list containing the contexts this widget is relevant in.
// }
}
struct SimpleEntry: TimelineEntry {
let date: Date
let emoji: String
}
struct HomePlayerWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
Text("Time:")
Text(entry.date, style: .time)
Text("Emoji:")
Text(entry.emoji)
}
}
}
struct HomePlayerWidget: Widget {
let kind: String = "HomePlayerWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
if #available(iOS 17.0, *) {
HomePlayerWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
HomePlayerWidgetEntryView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
#Preview(as: .systemSmall) {
HomePlayerWidget()
} timeline: {
SimpleEntry(date: .now, emoji: "😀")
SimpleEntry(date: .now, emoji: "🤩")
}

View File

@ -0,0 +1,16 @@
//
// HomePlayerWidgetBundle.swift
// HomePlayerWidget
//
// Created by Kingkor Roy Tirtho on 15/12/24.
//
import WidgetKit
import SwiftUI
@main
struct HomePlayerWidgetBundle: WidgetBundle {
var body: some Widget {
HomePlayerWidget()
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.spotube_home_player_widget</string>
</array>
</dict>
</plist>

View File

@ -64,6 +64,8 @@ PODS:
- Flutter
- flutter_sharing_intent (0.0.1):
- Flutter
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
@ -106,12 +108,15 @@ PODS:
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- sqlite3 (~> 3.47.0)
- FlutterMacOS
- sqlite3 (~> 3.47.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.4)
- system_theme (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
@ -130,6 +135,7 @@ DEPENDENCIES:
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`)
@ -141,7 +147,8 @@ DEPENDENCIES:
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- system_theme (from `.symlinks/plugins/system_theme/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS:
@ -182,6 +189,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_sharing_intent:
:path: ".symlinks/plugins/flutter_sharing_intent/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
@ -205,7 +214,9 @@ EXTERNAL SOURCES:
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios"
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
system_theme:
:path: ".symlinks/plugins/system_theme/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
@ -226,6 +237,7 @@ SPEC CHECKSUMS:
flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
@ -240,8 +252,9 @@ SPEC CHECKSUMS:
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: 1e522f0938463e44b7faf50393b40bdc1e1e456d
sqlite3_flutter_libs: b55ef23cfafea5318ae5081e0bf3fbbce8417c94
sqlite3_flutter_libs: 1b4e98da20ebd4e9b1240269b78cdcf492dbe9f3
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
system_theme: bfc1b0913d08f38d8c6bbe94b202a58df599d9f7
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.spotube_home_player_widget</string>
</array>
</dict>
</plist>

10
ios/dev.entitlements Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.spotube_home_player_widget</string>
</array>
</dict>
</plist>

10
ios/nightly.entitlements Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.spotube_home_player_widget</string>
</array>
</dict>
</plist>

10
ios/stable.entitlements Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.spotube_home_player_widget</string>
</array>
</dict>
</plist>

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';
@ -115,6 +117,10 @@ Future<void> main(List<String> rawArgs) async {
await WindowManagerTools.initialize();
}
if (kIsIOS) {
HomeWidget.setAppGroupId("group.spotube_home_player_widget");
}
runApp(
ProviderScope(
overrides: [
@ -161,6 +167,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,169 @@
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:spotify/spotify.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: 'HomePlayerWidget',
iOSName: 'HomePlayerWidget',
);
}
} on Exception catch (e, stack) {
AppLogger.reportError(e, stack);
}
}
Future<void> _sendActiveTrack(Track? track) async {
if (track == null) {
await _saveWidgetData("activeTrack", null);
await _updateWidget();
return;
}
final jsonTrack = track.toJson();
final image = track.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();
}
final glanceProvider = Provider((ref) {
final server = ref.read(serverProvider);
final activeTrack = ref.read(audioPlayerProvider).activeTrack;
server.whenData(
(value) async {
final (:server, :port) = value;
await _saveWidgetData(
"playbackServerAddress",
"${server.address.host}:$port",
);
await _updateWidget();
},
);
_sendActiveTrack(activeTrack);
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) {
await _sendActiveTrack(next.activeTrack);
}
} 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 =

View File

@ -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:
@ -1095,6 +1095,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
home_widget:
dependency: "direct main"
description:
name: home_widget
sha256: b313e3304c0429669fddf1286e1fbf61a64b873f38ba30b3eb890ef0d7560b12
url: "https://pub.dev"
source: hosted
version: "0.7.0"
hooks_riverpod:
dependency: "direct main"
description:

View File

@ -71,6 +71,7 @@ dependencies:
google_fonts: ^6.2.1
hive: ^2.2.3
hive_flutter: ^1.1.0
home_widget: ^0.7.0
hooks_riverpod: ^2.5.1
html: ^0.15.1
html_unescape: ^2.0.0
@ -162,6 +163,7 @@ flutter:
- assets/
- assets/tutorial/
- assets/logos/
- assets/backgrounds/
- LICENSE
flutter_gen: