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:
zarzet
2026-09-25 01:22:58 +07:00
parent 64445a09fc
commit 040c328479
34 changed files with 1601 additions and 16 deletions
@@ -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)
}
}
}
+21
View File
@@ -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" />