mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
feat(widgets): add native Android and iOS home screen players
Publish artwork, metadata, and transport state through a shared Dart bridge. Add configurable Android RemoteViews and iOS WidgetKit players with controls and links into the full player. Serialize widget commands and session restoration, and share one headless-capable iOS Flutter engine with the foreground scene. Include native integration tests, Dart bridge tests, and widget setup documentation.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.Manifest
|
||||
import android.appwidget.AppWidgetHost
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/** Applies real RemoteViews through an Android widget host, including resizing. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlayerWidgetTest {
|
||||
@Test
|
||||
fun launcherRendersEverySizeAndAppearance() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val prefs = PlayerWidgetProvider.preferences(context)
|
||||
val previousState = prefs.getString("state", null)
|
||||
val title = "Widget rendering test"
|
||||
val state = JSONObject().apply {
|
||||
put("title", title)
|
||||
put("artist", "Test artist")
|
||||
put("canPlay", true)
|
||||
put("playing", true)
|
||||
put("canNext", true)
|
||||
put("canPrevious", false)
|
||||
put("background", 0xff6b3945L)
|
||||
}
|
||||
prefs.edit().putString("state", state.toString()).commit()
|
||||
instrumentation.uiAutomation.adoptShellPermissionIdentity(Manifest.permission.BIND_APPWIDGET)
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
val host = AppWidgetHost(context, 595)
|
||||
val id = host.allocateAppWidgetId()
|
||||
try {
|
||||
assertTrue(manager.bindAppWidgetIdIfAllowed(
|
||||
id, ComponentName(context, PlayerWidgetProvider::class.java),
|
||||
))
|
||||
val info = manager.getAppWidgetInfo(id)
|
||||
assertNotNull(info)
|
||||
for ((width, height) in listOf(140 to 156, 300 to 160, 320 to 350)) {
|
||||
manager.updateAppWidgetOptions(id, Bundle().apply {
|
||||
putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, width)
|
||||
putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, width)
|
||||
putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, height)
|
||||
putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, height)
|
||||
})
|
||||
for (theme in listOf("artwork", "light", "dark")) {
|
||||
prefs.edit().putString("theme_$id", theme).commit()
|
||||
PlayerWidgetProvider.update(context, intArrayOf(id))
|
||||
val view = host.createView(context, id, info)
|
||||
val titleView = view.findViewById<TextView>(R.id.player_widget_title)
|
||||
assertNotNull("RemoteViews failed at $width x $height, $theme", titleView)
|
||||
assertEquals(title, titleView.text.toString())
|
||||
assertTrue(view.findViewById<android.view.View>(R.id.player_widget_play).isEnabled)
|
||||
assertTrue(!view.findViewById<android.view.View>(R.id.player_widget_previous).isEnabled)
|
||||
val density = context.resources.displayMetrics.density
|
||||
val pixelsWide = (width * density).toInt()
|
||||
val pixelsHigh = (height * density).toInt()
|
||||
view.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(pixelsWide, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(pixelsHigh, View.MeasureSpec.EXACTLY),
|
||||
)
|
||||
view.layout(0, 0, pixelsWide, pixelsHigh)
|
||||
val bitmap = Bitmap.createBitmap(pixelsWide, pixelsHigh, Bitmap.Config.ARGB_8888)
|
||||
view.draw(Canvas(bitmap))
|
||||
File(context.cacheDir, "player-widget-$width-$height-$theme.png").outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
host.deleteHost()
|
||||
prefs.edit().remove("theme_$id").remove("artist_$id").commit()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
instrumentation.uiAutomation.dropShellPermissionIdentity()
|
||||
prefs.edit().putString("state", previousState).commit()
|
||||
PlayerWidgetProvider.update(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,27 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Home screen player -->
|
||||
<receiver
|
||||
android:name=".PlayerWidgetProvider"
|
||||
android:label="@string/player_widget_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_player_info" />
|
||||
</receiver>
|
||||
<activity
|
||||
android:name=".PlayerWidgetConfigurationActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.DeviceDefault.Dialog">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
|
||||
@@ -782,6 +782,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleVerificationNotificationIntent(intent)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
PlayerWidgetBridge.handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -789,6 +790,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
setIntent(intent)
|
||||
handleVerificationNotificationIntent(intent)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
PlayerWidgetBridge.handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleVerificationNotificationIntent(intent: Intent?) {
|
||||
@@ -950,6 +952,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
PlayerWidgetBridge.attach(this, flutterEngine)
|
||||
com.zarz.spotiflac.discord.DiscordPresenceBridge.attach(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
// Select and initialize the runtime before Flutter can dispatch a call.
|
||||
val selectedBackend = coreBackend
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Activity
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RadioButton
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.TextView
|
||||
|
||||
class PlayerWidgetConfigurationActivity : Activity() {
|
||||
override fun onCreate(state: Bundle?) {
|
||||
super.onCreate(state)
|
||||
setResult(RESULT_CANCELED)
|
||||
val widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finish(); return }
|
||||
val prefs = PlayerWidgetProvider.preferences(this)
|
||||
val layout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val padding = (24 * resources.displayMetrics.density).toInt()
|
||||
setPadding(padding, padding, padding, padding)
|
||||
}
|
||||
layout.addView(TextView(this).apply { setText(R.string.player_widget_name); textSize = 24f })
|
||||
val group = RadioGroup(this)
|
||||
val themes = listOf("artwork" to R.string.player_widget_artwork, "light" to R.string.player_widget_light, "dark" to R.string.player_widget_dark)
|
||||
themes.forEachIndexed { index, (key, label) ->
|
||||
group.addView(RadioButton(this).apply {
|
||||
this.id = index + 1
|
||||
setText(label)
|
||||
isChecked = prefs.getString("theme_$widgetId", "artwork") == key
|
||||
})
|
||||
}
|
||||
layout.addView(group)
|
||||
val artist = CheckBox(this).apply {
|
||||
setText(R.string.player_widget_show_artist)
|
||||
isChecked = prefs.getBoolean("artist_$widgetId", true)
|
||||
}
|
||||
layout.addView(artist)
|
||||
layout.addView(Button(this).apply {
|
||||
setText(R.string.player_widget_save)
|
||||
setOnClickListener {
|
||||
prefs.edit().putString("theme_$widgetId", themes[(group.checkedRadioButtonId - 1).coerceIn(0, 2)].first)
|
||||
.putBoolean("artist_$widgetId", artist.isChecked).apply()
|
||||
PlayerWidgetProvider.update(this@PlayerWidgetConfigurationActivity, intArrayOf(widgetId))
|
||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
|
||||
finish()
|
||||
}
|
||||
})
|
||||
setContentView(layout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Shader
|
||||
import android.os.Bundle
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** Native launcher rendering; audio remains in the app's shared Flutter engine. */
|
||||
class PlayerWidgetProvider : AppWidgetProvider() {
|
||||
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
|
||||
update(context, ids)
|
||||
}
|
||||
|
||||
override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) {
|
||||
update(context, intArrayOf(id))
|
||||
}
|
||||
|
||||
override fun onDeleted(context: Context, ids: IntArray) {
|
||||
val editor = preferences(context).edit()
|
||||
ids.forEach { editor.remove("theme_$it").remove("artist_$it") }
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
if (intent.action != ACTION) return
|
||||
val command = intent.getStringExtra(COMMAND) ?: return
|
||||
if (command !in setOf("toggle", "next", "previous")) return
|
||||
val pending = goAsync()
|
||||
if (!PlayerWidgetBridge.command(command) { pending.finish() }) {
|
||||
// A killed process has no audio engine. A widget click may launch
|
||||
// the activity to restore the queue and then execute this command.
|
||||
val options = if (Build.VERSION.SDK_INT >= 34) {
|
||||
ActivityOptions.makeBasic().apply {
|
||||
// Preserve the launch privilege from this widget tap when
|
||||
// the sender is a receiver rather than a visible activity.
|
||||
setPendingIntentBackgroundActivityStartMode(
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED,
|
||||
)
|
||||
}.toBundle()
|
||||
} else null
|
||||
try {
|
||||
launchIntent(context, command).send(context, 0, null, null, null, null, options)
|
||||
} catch (error: PendingIntent.CanceledException) {
|
||||
Log.w("PlayerWidget", "Widget launch was cancelled", error)
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION = "com.zarz.spotiflac.PLAYER_WIDGET_ACTION"
|
||||
const val COMMAND = "player_widget_command"
|
||||
fun preferences(context: Context) = context.getSharedPreferences("player_widget", Context.MODE_PRIVATE)
|
||||
|
||||
fun launchIntent(context: Context, command: String): PendingIntent = PendingIntent.getActivity(
|
||||
context, 8100 + command.hashCode(),
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(COMMAND, command)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
fun update(context: Context, ids: IntArray? = null) {
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val widgets = ids ?: manager.getAppWidgetIds(ComponentName(context, PlayerWidgetProvider::class.java))
|
||||
if (widgets.isEmpty()) return
|
||||
val prefs = preferences(context)
|
||||
val state = runCatching { JSONObject(prefs.getString("state", "{}")!!) }.getOrDefault(JSONObject())
|
||||
val active = state.optBoolean("canPlay")
|
||||
val cover = if (state.optString("artworkKey").isNotEmpty()) {
|
||||
BitmapFactory.decodeFile(File(context.filesDir, "player-widget-cover.png").path)
|
||||
} else null
|
||||
for (id in widgets) {
|
||||
val options = manager.getAppWidgetOptions(id)
|
||||
val width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 280)
|
||||
val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 150)
|
||||
val horizontal = width >= 240 && height < 240
|
||||
val views = RemoteViews(context.packageName, if (horizontal) R.layout.widget_player_wide else R.layout.widget_player)
|
||||
val theme = prefs.getString("theme_$id", "artwork")
|
||||
val light = theme == "light"
|
||||
val foreground = if (light) Color.rgb(26, 24, 31) else Color.WHITE
|
||||
val background = when (theme) {
|
||||
"light" -> Color.rgb(246, 243, 248)
|
||||
"dark" -> Color.rgb(28, 27, 33)
|
||||
else -> state.optLong("background", 0xff292433).toInt()
|
||||
}
|
||||
views.setImageViewBitmap(R.id.player_widget_background, renderBackground(background, width, height))
|
||||
views.setTextViewText(R.id.player_widget_title, state.optString("title").ifEmpty { context.getString(R.string.player_widget_name) })
|
||||
views.setTextViewText(R.id.player_widget_artist, if (active) state.optString("artist") else context.getString(R.string.player_widget_empty))
|
||||
views.setTextColor(R.id.player_widget_title, foreground)
|
||||
views.setTextColor(R.id.player_widget_artist, (foreground and 0x00ffffff) or (0xb8 shl 24))
|
||||
views.setViewVisibility(R.id.player_widget_artist, if (prefs.getBoolean("artist_$id", true)) View.VISIBLE else View.GONE)
|
||||
views.setImageViewBitmap(R.id.player_widget_art, cover?.let(::roundedCover) ?: icon(context, R.drawable.ic_widget_music, foreground, false))
|
||||
val playing = state.optBoolean("playing")
|
||||
val buttons = listOf(
|
||||
Triple(R.id.player_widget_previous, R.drawable.ic_widget_previous, "previous"),
|
||||
Triple(R.id.player_widget_play, if (playing) R.drawable.ic_widget_pause else R.drawable.ic_widget_play, "toggle"),
|
||||
Triple(R.id.player_widget_next, R.drawable.ic_widget_next, "next"),
|
||||
)
|
||||
for ((view, drawable, command) in buttons) {
|
||||
val enabled = active && when (command) {
|
||||
"previous" -> state.optBoolean("canPrevious")
|
||||
"next" -> state.optBoolean("canNext")
|
||||
else -> true
|
||||
}
|
||||
views.setImageViewBitmap(view, icon(context, drawable, foreground, command == "toggle"))
|
||||
views.setFloat(view, "setAlpha", if (enabled) 1f else 0.4f)
|
||||
views.setBoolean(view, "setEnabled", enabled)
|
||||
views.setOnClickPendingIntent(view, PendingIntent.getBroadcast(
|
||||
context, 8200 + command.hashCode(),
|
||||
Intent(context, PlayerWidgetProvider::class.java).setAction(ACTION).putExtra(COMMAND, command),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
))
|
||||
}
|
||||
views.setContentDescription(R.id.player_widget_play, context.getString(if (playing) R.string.player_widget_pause else R.string.player_widget_play))
|
||||
views.setOnClickPendingIntent(R.id.player_widget_root, launchIntent(context, "open"))
|
||||
manager.updateAppWidget(id, views)
|
||||
}
|
||||
cover?.recycle()
|
||||
}
|
||||
|
||||
private fun roundedCover(source: Bitmap): Bitmap {
|
||||
val scaled = Bitmap.createScaledBitmap(source, 256, 256, true)
|
||||
val result = Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888)
|
||||
Canvas(result).drawRoundRect(0f, 0f, 256f, 256f, 20f, 20f, Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = BitmapShader(scaled, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
})
|
||||
if (scaled !== source) scaled.recycle()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun renderBackground(color: Int, width: Int, height: Int): Bitmap {
|
||||
val w = width.coerceIn(120, 640)
|
||||
val h = height.coerceIn(100, 640)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val highlight = Color.rgb((Color.red(color) + 12).coerceAtMost(255), (Color.green(color) + 12).coerceAtMost(255), (Color.blue(color) + 12).coerceAtMost(255))
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = LinearGradient(0f, 0f, w.toFloat(), h.toFloat(), highlight, color, Shader.TileMode.CLAMP)
|
||||
}
|
||||
Canvas(bitmap).drawRoundRect(0f, 0f, w.toFloat(), h.toFloat(), 26f, 26f, paint)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun icon(context: Context, resource: Int, color: Int, circle: Boolean): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
if (circle) canvas.drawCircle(48f, 48f, 46f, Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color })
|
||||
val drawable = context.getDrawable(resource)!!.mutate()
|
||||
drawable.setTint(if (circle && color == Color.WHITE) Color.rgb(35, 30, 42) else if (circle) Color.WHITE else color)
|
||||
drawable.setBounds(25, 25, 71, 71)
|
||||
drawable.draw(canvas)
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object PlayerWidgetBridge : FlutterEngine.EngineLifecycleListener {
|
||||
private var engine: FlutterEngine? = null
|
||||
private var channel: MethodChannel? = null
|
||||
private var ready = false
|
||||
private var pendingCommand: String? = null
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
fun attach(context: Context, flutterEngine: FlutterEngine) {
|
||||
if (engine !== flutterEngine) {
|
||||
engine?.removeEngineLifecycleListener(this)
|
||||
engine = flutterEngine
|
||||
flutterEngine.addEngineLifecycleListener(this)
|
||||
ready = false
|
||||
}
|
||||
val app = context.applicationContext
|
||||
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.zarz.spotiflac/player_widget").also { bridge ->
|
||||
bridge.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"ready" -> {
|
||||
ready = true
|
||||
result.success(null)
|
||||
pendingCommand?.let { command(it) {} }
|
||||
pendingCommand = null
|
||||
}
|
||||
"update" -> {
|
||||
val data = call.arguments as? Map<*, *>
|
||||
if (data == null) { result.error("bad_state", "Missing widget state", null); return@setMethodCallHandler }
|
||||
worker.execute {
|
||||
try {
|
||||
val prefs = PlayerWidgetProvider.preferences(app)
|
||||
val bytes = data["artwork"] as? ByteArray
|
||||
val old = JSONObject(prefs.getString("state", "{}")!!)
|
||||
if (bytes != null && old.optString("artworkKey") != data["artworkKey"]) {
|
||||
val temp = File(app.filesDir, "player-widget-cover.tmp")
|
||||
temp.writeBytes(bytes)
|
||||
check(temp.renameTo(File(app.filesDir, "player-widget-cover.png")))
|
||||
}
|
||||
val state = JSONObject()
|
||||
data.forEach { (key, value) -> if (key is String && key != "artwork") state.put(key, value) }
|
||||
prefs.edit().putString("state", state.toString()).commit()
|
||||
PlayerWidgetProvider.update(app)
|
||||
main.post { result.success(null) }
|
||||
} catch (error: Exception) {
|
||||
main.post { result.error("widget_update", error.message, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreEngineRestart() {
|
||||
ready = false
|
||||
}
|
||||
|
||||
override fun onEngineWillDestroy() {
|
||||
ready = false
|
||||
channel = null
|
||||
pendingCommand = null
|
||||
engine = null
|
||||
}
|
||||
|
||||
fun handleIntent(intent: Intent?) {
|
||||
val action = intent?.getStringExtra(PlayerWidgetProvider.COMMAND) ?: return
|
||||
intent.removeExtra(PlayerWidgetProvider.COMMAND)
|
||||
if (!command(action) {}) pendingCommand = action
|
||||
}
|
||||
|
||||
fun command(action: String, completion: () -> Unit): Boolean {
|
||||
val bridge = channel ?: return false
|
||||
if (!ready) return false
|
||||
var finished = false
|
||||
fun finish() { if (!finished) { finished = true; completion() } }
|
||||
main.postDelayed({ finish() }, 8500)
|
||||
bridge.invokeMethod("command", action, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) = finish()
|
||||
override fun error(code: String, message: String?, details: Any?) = finish()
|
||||
override fun notImplemented() = finish()
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M10,4 L21,2 V16 A3.5,3.5 0,1 1,19 12.84 V6 L12,7.3 V19 A3.5,3.5 0,1 1,10 15.84 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M1,4 L12,12 L1,20 Z M12,4 L23,12 L12,20 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M5,3 H10 V21 H5 Z M14,3 H19 V21 H14 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M7,3 L22,12 L7,21 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M12,4 L1,12 L12,20 Z M23,4 L12,12 L23,20 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#292433" />
|
||||
<corners android:radius="26dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_widget_root" android:layout_width="match_parent" android:layout_height="match_parent">
|
||||
<ImageView android:id="@+id/player_widget_background" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="fitXY" android:src="@drawable/widget_player_background" android:importantForAccessibility="no" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="12dp">
|
||||
<ImageView android:id="@+id/player_widget_art" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:scaleType="fitCenter" android:src="@drawable/ic_widget_music" android:importantForAccessibility="no" />
|
||||
<TextView android:id="@+id/player_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="6dp" android:singleLine="true" android:ellipsize="end" android:text="@string/player_widget_name" android:textSize="15sp" android:textStyle="bold" android:textColor="#FFFFFF" />
|
||||
<TextView android:id="@+id/player_widget_artist" android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" android:ellipsize="end" android:text="@string/player_widget_empty" android:textSize="12sp" android:textColor="#B8FFFFFF" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="44dp" android:gravity="center" android:orientation="horizontal">
|
||||
<ImageButton android:id="@+id/player_widget_previous" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_previous" android:contentDescription="@string/player_widget_previous" />
|
||||
<ImageButton android:id="@+id/player_widget_play" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="0dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_play" android:contentDescription="@string/player_widget_play" />
|
||||
<ImageButton android:id="@+id/player_widget_next" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_next" android:contentDescription="@string/player_widget_next" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_widget_root" android:layout_width="match_parent" android:layout_height="match_parent">
|
||||
<ImageView android:id="@+id/player_widget_background" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="fitXY" android:src="@drawable/widget_player_background" android:importantForAccessibility="no" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" android:padding="16dp" android:gravity="center_vertical">
|
||||
<ImageView android:id="@+id/player_widget_art" android:layout_width="96dp" android:layout_height="96dp" android:scaleType="fitCenter" android:src="@drawable/ic_widget_music" android:importantForAccessibility="no" />
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:layout_marginStart="14dp" android:orientation="vertical" android:gravity="center_vertical">
|
||||
<TextView android:id="@+id/player_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:maxLines="2" android:ellipsize="end" android:text="@string/player_widget_name" android:textSize="16sp" android:textStyle="bold" android:textColor="#FFFFFF" />
|
||||
<TextView android:id="@+id/player_widget_artist" android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" android:ellipsize="end" android:text="@string/player_widget_empty" android:textSize="13sp" android:textColor="#B8FFFFFF" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="44dp" android:layout_marginTop="4dp" android:gravity="center" android:orientation="horizontal">
|
||||
<ImageButton android:id="@+id/player_widget_previous" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_previous" android:contentDescription="@string/player_widget_previous" />
|
||||
<ImageButton android:id="@+id/player_widget_play" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="0dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_play" android:contentDescription="@string/player_widget_play" />
|
||||
<ImageButton android:id="@+id/player_widget_next" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_next" android:contentDescription="@string/player_widget_next" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,14 @@
|
||||
<resources>
|
||||
<string name="player_widget_name">Player SpotiFLAC</string>
|
||||
<string name="player_widget_description">Musik, cover, dan kontrol playback. Ubah ukuran atau edit tampilannya.</string>
|
||||
<string name="player_widget_empty">Pilih musik di aplikasi</string>
|
||||
<string name="player_widget_play">Putar</string>
|
||||
<string name="player_widget_pause">Jeda</string>
|
||||
<string name="player_widget_previous">Lagu sebelumnya</string>
|
||||
<string name="player_widget_next">Lagu berikutnya</string>
|
||||
<string name="player_widget_artwork">Warna artwork</string>
|
||||
<string name="player_widget_light">Terang</string>
|
||||
<string name="player_widget_dark">Gelap</string>
|
||||
<string name="player_widget_show_artist">Tampilkan nama artis</string>
|
||||
<string name="player_widget_save">Simpan widget</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<resources>
|
||||
<string name="player_widget_name">SpotiFLAC Player</string>
|
||||
<string name="player_widget_description">Your music, artwork, and playback controls. Resize or edit to customize.</string>
|
||||
<string name="player_widget_empty">Choose music in the app</string>
|
||||
<string name="player_widget_play">Play</string>
|
||||
<string name="player_widget_pause">Pause</string>
|
||||
<string name="player_widget_previous">Previous track</string>
|
||||
<string name="player_widget_next">Next track</string>
|
||||
<string name="player_widget_artwork">Artwork colors</string>
|
||||
<string name="player_widget_light">Light</string>
|
||||
<string name="player_widget_dark">Dark</string>
|
||||
<string name="player_widget_show_artist">Show artist name</string>
|
||||
<string name="player_widget_save">Save widget</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp" android:minHeight="120dp"
|
||||
android:minResizeWidth="120dp" android:minResizeHeight="120dp"
|
||||
android:targetCellWidth="4" android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
|
||||
android:initialLayout="@layout/widget_player_wide"
|
||||
android:previewLayout="@layout/widget_player_wide"
|
||||
android:description="@string/player_widget_description"
|
||||
android:configure="com.zarz.spotiflac.PlayerWidgetConfigurationActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:widgetCategory="home_screen" />
|
||||
Reference in New Issue
Block a user