Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- NotificationListenerService does not need extra manifest permissions;
|
||||
the user grants access via system settings (Notification Access). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<application
|
||||
android:label="OpenVinyl"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
|
||||
<!-- Main Flutter activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:screenOrientation="portrait">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- NotificationListenerService -->
|
||||
<service
|
||||
android:name=".MediaListenerService"
|
||||
android:label="OpenVinyl Media Listener"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.notification.default_filter_types"
|
||||
android:resource="@xml/notification_listener_service" />
|
||||
</service>
|
||||
|
||||
<!-- Static vinyl widget -->
|
||||
<receiver
|
||||
android:name=".VinylWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="OpenVinyl — Vinyl">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/vinyl_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Animated vinyl widget -->
|
||||
<receiver
|
||||
android:name=".VinylAnimatedWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="OpenVinyl — Vinyl (Animated)">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/vinyl_widget_animated_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Foreground service that drives the animated widget frame loop -->
|
||||
<service
|
||||
android:name=".VinylAnimatedWidgetService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,56 @@
|
||||
// android/app/src/main/kotlin/pt/ruifpb/openvinyl/MainActivity.kt
|
||||
//
|
||||
// MethodChannel pt.ruifpb.openvinyl/media_control
|
||||
// hasPermission() → Boolean
|
||||
// requestPermission() → Unit
|
||||
// getCurrentMedia() → Map?
|
||||
// playPause() → Unit ← NEW: sends play/pause to active MediaSession
|
||||
|
||||
package pt.ruifpb.openvinyl
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val methodChannelName = "pt.ruifpb.openvinyl/media_control"
|
||||
private val eventChannelName = "pt.ruifpb.openvinyl/media_events"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, methodChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"hasPermission" -> result.success(MediaListenerService.isEnabled(this))
|
||||
"requestPermission" -> {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
"getCurrentMedia" -> result.success(MediaListenerService.lastMediaMap)
|
||||
"playPause" -> {
|
||||
MediaListenerService.sendPlayPause()
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, eventChannelName)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(args: Any?, sink: EventChannel.EventSink) {
|
||||
MediaListenerService.eventSink = sink
|
||||
}
|
||||
override fun onCancel(args: Any?) {
|
||||
MediaListenerService.eventSink = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// android/app/src/main/kotlin/pt/ruifpb/openvinyl/MediaListenerService.kt
|
||||
|
||||
package pt.ruifpb.openvinyl
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class MediaListenerService : NotificationListenerService() {
|
||||
|
||||
companion object {
|
||||
var eventSink: EventChannel.EventSink? = null
|
||||
var lastMediaMap: Map<String, Any?>? = null
|
||||
|
||||
// Hold the active MediaController so MainActivity can send
|
||||
// play/pause commands through it
|
||||
var activeController: MediaController? = null
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val flat = android.provider.Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
"enabled_notification_listeners"
|
||||
) ?: return false
|
||||
val cn = ComponentName(context, MediaListenerService::class.java)
|
||||
return flat.contains(cn.flattenToString())
|
||||
}
|
||||
|
||||
// Called by MainActivity via the platform channel
|
||||
fun sendPlayPause() {
|
||||
val ctrl = activeController ?: return
|
||||
val state = ctrl.playbackState?.state
|
||||
if (state == PlaybackState.STATE_PLAYING) {
|
||||
ctrl.transportControls.pause()
|
||||
} else {
|
||||
ctrl.transportControls.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
processNotification(sbn)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||
if (sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_SESSION)) {
|
||||
activeController = null
|
||||
emitMap(buildIdleMap())
|
||||
}
|
||||
}
|
||||
|
||||
private fun processNotification(sbn: StatusBarNotification) {
|
||||
val extras = sbn.notification.extras
|
||||
val tokenObj = extras.getParcelable<MediaSession.Token>(
|
||||
Notification.EXTRA_MEDIA_SESSION
|
||||
) ?: return
|
||||
|
||||
try {
|
||||
val controller = MediaController(applicationContext, tokenObj)
|
||||
val metadata = controller.metadata ?: return
|
||||
val playbackState = controller.playbackState
|
||||
|
||||
// Keep a reference so play/pause commands can be sent
|
||||
activeController = controller
|
||||
|
||||
val isPlaying = playbackState?.state == PlaybackState.STATE_PLAYING
|
||||
val title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "Unknown Title"
|
||||
val artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
|
||||
?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)
|
||||
?: "Unknown Artist"
|
||||
val album = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: ""
|
||||
|
||||
val albumArtBytes = metadata
|
||||
.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
|
||||
?.let { bitmapToBytes(it) }
|
||||
?: metadata
|
||||
.getBitmap(MediaMetadata.METADATA_KEY_ART)
|
||||
?.let { bitmapToBytes(it) }
|
||||
|
||||
emitMap(mapOf(
|
||||
"title" to title,
|
||||
"artist" to artist,
|
||||
"album" to album,
|
||||
"albumArt" to albumArtBytes,
|
||||
"isPlaying" to isPlaying
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildIdleMap(): Map<String, Any?> = mapOf(
|
||||
"title" to "Nothing Playing", "artist" to "", "album" to "",
|
||||
"albumArt" to null, "isPlaying" to false
|
||||
)
|
||||
|
||||
private fun emitMap(map: Map<String, Any?>) {
|
||||
lastMediaMap = map
|
||||
val artBytes = map["albumArt"] as? ByteArray
|
||||
val isPlaying = map["isPlaying"] as? Boolean ?: false
|
||||
|
||||
// Push to static widget
|
||||
VinylWidgetProvider.pushUpdate(applicationContext, artBytes)
|
||||
|
||||
// Drive animated widget: update art + play state
|
||||
VinylAnimatedWidgetService.instance?.updateArt(artBytes)
|
||||
VinylAnimatedWidgetService.isPlaying = isPlaying
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
eventSink?.success(map)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bitmapToBytes(bitmap: Bitmap): ByteArray {
|
||||
val scaled = if (bitmap.width > 512 || bitmap.height > 512)
|
||||
Bitmap.createScaledBitmap(bitmap, 512, 512, true) else bitmap
|
||||
return ByteArrayOutputStream().also { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||
}.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// android/app/src/main/kotlin/pt/ruifpb/openvinyl/MediaListenerService.kt
|
||||
|
||||
package pt.ruifpb.openvinyl
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class MediaListenerService : NotificationListenerService() {
|
||||
|
||||
companion object {
|
||||
var eventSink: EventChannel.EventSink? = null
|
||||
var lastMediaMap: Map<String, Any?>? = null
|
||||
|
||||
// Hold the active MediaController so MainActivity can send
|
||||
// play/pause commands through it
|
||||
var activeController: MediaController? = null
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val flat = android.provider.Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
"enabled_notification_listeners"
|
||||
) ?: return false
|
||||
val cn = ComponentName(context, MediaListenerService::class.java)
|
||||
return flat.contains(cn.flattenToString())
|
||||
}
|
||||
|
||||
// Called by MainActivity via the platform channel
|
||||
fun sendPlayPause() {
|
||||
val ctrl = activeController ?: return
|
||||
val state = ctrl.playbackState?.state
|
||||
if (state == PlaybackState.STATE_PLAYING) {
|
||||
ctrl.transportControls.pause()
|
||||
} else {
|
||||
ctrl.transportControls.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
processNotification(sbn)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||
if (sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_SESSION)) {
|
||||
activeController = null
|
||||
emitMap(buildIdleMap())
|
||||
}
|
||||
}
|
||||
|
||||
private fun processNotification(sbn: StatusBarNotification) {
|
||||
val extras = sbn.notification.extras
|
||||
val tokenObj = extras.getParcelable<MediaSession.Token>(
|
||||
Notification.EXTRA_MEDIA_SESSION
|
||||
) ?: return
|
||||
|
||||
try {
|
||||
val controller = MediaController(applicationContext, tokenObj)
|
||||
val metadata = controller.metadata ?: return
|
||||
val playbackState = controller.playbackState
|
||||
|
||||
// Keep a reference so play/pause commands can be sent
|
||||
activeController = controller
|
||||
|
||||
val isPlaying = playbackState?.state == PlaybackState.STATE_PLAYING
|
||||
val title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "Unknown Title"
|
||||
val artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
|
||||
?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)
|
||||
?: "Unknown Artist"
|
||||
val album = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: ""
|
||||
|
||||
val albumArtBytes = metadata
|
||||
.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
|
||||
?.let { bitmapToBytes(it) }
|
||||
?: metadata
|
||||
.getBitmap(MediaMetadata.METADATA_KEY_ART)
|
||||
?.let { bitmapToBytes(it) }
|
||||
|
||||
emitMap(mapOf(
|
||||
"title" to title,
|
||||
"artist" to artist,
|
||||
"album" to album,
|
||||
"albumArt" to albumArtBytes,
|
||||
"isPlaying" to isPlaying
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildIdleMap(): Map<String, Any?> = mapOf(
|
||||
"title" to "Nothing Playing", "artist" to "", "album" to "",
|
||||
"albumArt" to null, "isPlaying" to false
|
||||
)
|
||||
|
||||
private fun emitMap(map: Map<String, Any?>) {
|
||||
lastMediaMap = map
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
eventSink?.success(map)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bitmapToBytes(bitmap: Bitmap): ByteArray {
|
||||
val scaled = if (bitmap.width > 512 || bitmap.height > 512)
|
||||
Bitmap.createScaledBitmap(bitmap, 512, 512, true) else bitmap
|
||||
return ByteArrayOutputStream().also { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||
}.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// android/app/src/main/kotlin/pt/ruifpb/openvinyl/VinylAnimatedWidgetProvider.kt
|
||||
//
|
||||
// AppWidgetProvider for the animated 2x2 vinyl widget.
|
||||
// All it does is start/stop VinylAnimatedWidgetService — the service
|
||||
// owns the frame loop and pushes frames itself.
|
||||
|
||||
package pt.ruifpb.openvinyl
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
|
||||
class VinylAnimatedWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
// Widget added or screen rebooted — start the service
|
||||
VinylAnimatedWidgetService.start(context)
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
// First animated widget placed on screen
|
||||
VinylAnimatedWidgetService.start(context)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Last animated widget removed — stop the service entirely
|
||||
VinylAnimatedWidgetService.stop(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// android/app/src/main/kotlin/pt/ruifpb/openvinyl/VinylAnimatedWidgetService.kt
|
||||
//
|
||||
// Foreground Service that drives the spinning animation for the animated widget.
|
||||
//
|
||||
// Design:
|
||||
// - Starts when the animated widget exists AND music is playing.
|
||||
// - Stops (but doesn't destroy itself) when music pauses — loop idles.
|
||||
// - Fully stops when no animated widgets are on screen.
|
||||
// - Runs at 12fps — smooth enough for a vinyl spin, easy on battery.
|
||||
// - Each tick rotates the pre-rendered base disc bitmap by a fixed
|
||||
// angle delta and pushes it to VinylAnimatedWidgetProvider via
|
||||
// AppWidgetManager.updateAppWidget().
|
||||
//
|
||||
// Frame rendering is intentionally lightweight:
|
||||
// - The grooves/sheen/label are pre-composed once into a base bitmap.
|
||||
// - Each frame only applies a Canvas rotation transform — no full redraw.
|
||||
|
||||
package pt.ruifpb.openvinyl
|
||||
|
||||
import android.app.*
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.*
|
||||
import android.graphics.*
|
||||
import android.os.*
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class VinylAnimatedWidgetService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "vinyl_widget_anim"
|
||||
private const val NOTIF_ID = 42
|
||||
private const val FPS = 12
|
||||
private const val FRAME_MS = 1000L / FPS
|
||||
// Degrees per frame for 33⅓ RPM feel at 12fps
|
||||
// 33.33 RPM = 0.555 RPS = 200ms/rotation → 360/200ms * 83ms/frame ≈ 2°/frame
|
||||
private const val DEG_PER_FRAME = 2f
|
||||
private const val BITMAP_SIZE = 300
|
||||
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
instance?.onPlayStateChanged()
|
||||
}
|
||||
|
||||
internal var instance: VinylAnimatedWidgetService? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, VinylAnimatedWidgetService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, VinylAnimatedWidgetService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var baseBitmap: Bitmap? = null // pre-rendered disc (no rotation)
|
||||
private var currentAngle = 0f
|
||||
private var running = false
|
||||
|
||||
private val frameRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isPlaying) return // music paused — stop ticking, keep service alive
|
||||
tick()
|
||||
handler.postDelayed(this, FRAME_MS)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
startForegroundWithNotification()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Rebuild base bitmap in case art changed since last start
|
||||
val prefs = getSharedPreferences("VinylWidgetPrefs", MODE_PRIVATE)
|
||||
val artB64 = prefs.getString("albumArtB64", null)
|
||||
val artBytes = artB64?.let { android.util.Base64.decode(it, android.util.Base64.DEFAULT) }
|
||||
baseBitmap = VinylWidgetRenderer.render(this, BITMAP_SIZE, artBytes)
|
||||
|
||||
if (isPlaying) startLoop()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
instance = null
|
||||
stopLoop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
// ── Called by companion when play state or art changes ────────────────────
|
||||
|
||||
fun onPlayStateChanged() {
|
||||
if (isPlaying) startLoop() else stopLoop()
|
||||
}
|
||||
|
||||
fun updateArt(artBytes: ByteArray?) {
|
||||
baseBitmap = VinylWidgetRenderer.render(this, BITMAP_SIZE, artBytes)
|
||||
currentAngle = 0f
|
||||
}
|
||||
|
||||
// ── Animation loop ────────────────────────────────────────────────────────
|
||||
|
||||
private fun startLoop() {
|
||||
if (running) return
|
||||
running = true
|
||||
handler.post(frameRunnable)
|
||||
}
|
||||
|
||||
private fun stopLoop() {
|
||||
running = false
|
||||
handler.removeCallbacks(frameRunnable)
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
currentAngle = (currentAngle + DEG_PER_FRAME) % 360f
|
||||
val base = baseBitmap ?: return
|
||||
pushFrame(rotateBitmap(base, currentAngle))
|
||||
}
|
||||
|
||||
// ── Bitmap rotation ───────────────────────────────────────────────────────
|
||||
|
||||
private fun rotateBitmap(src: Bitmap, degrees: Float): Bitmap {
|
||||
val size = src.width
|
||||
val result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
val matrix = Matrix().apply { postRotate(degrees, size / 2f, size / 2f) }
|
||||
canvas.drawBitmap(src, matrix, Paint(Paint.ANTI_ALIAS_FLAG))
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Push frame to all animated widgets ───────────────────────────────────
|
||||
|
||||
private fun pushFrame(bitmap: Bitmap) {
|
||||
val manager = AppWidgetManager.getInstance(this)
|
||||
val component = ComponentName(this, VinylAnimatedWidgetProvider::class.java)
|
||||
val ids = manager.getAppWidgetIds(component)
|
||||
if (ids.isEmpty()) {
|
||||
// No animated widgets on screen — shut down to save battery
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val views = RemoteViews(packageName, R.layout.vinyl_widget_layout).apply {
|
||||
setImageViewBitmap(R.id.widget_vinyl_image, bitmap)
|
||||
setOnClickPendingIntent(R.id.widget_vinyl_image, pendingIntent)
|
||||
}
|
||||
for (id in ids) manager.updateAppWidget(id, views)
|
||||
}
|
||||
|
||||
// ── Foreground notification (required for foreground service) ─────────────
|
||||
|
||||
private fun startForegroundWithNotification() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Vinyl Widget Animation",
|
||||
NotificationManager.IMPORTANCE_MIN // silent, no sound/vibration
|
||||
).apply {
|
||||
description = "Keeps the animated vinyl widget spinning"
|
||||
setShowBadge(false)
|
||||
}
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val notif = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("OpenVinyl")
|
||||
.setContentText("Animated widget active")
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
|
||||
startForeground(NOTIF_ID, notif)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// android/app/src/main/kotlin/pt/ruifpb/openvinyl/VinylWidgetProvider.kt
|
||||
//
|
||||
// AppWidgetProvider for the 2x2 vinyl home screen widget.
|
||||
//
|
||||
// Data flow:
|
||||
// 1. MediaListenerService detects a track change and calls
|
||||
// VinylWidgetProvider.pushUpdate() with the latest album art bytes.
|
||||
// 2. pushUpdate() stores the bytes in SharedPreferences and calls
|
||||
// AppWidgetManager.updateAppWidget() directly — no polling needed.
|
||||
// 3. onUpdate() is also called by Android on reboot/add-to-screen,
|
||||
// and reads from SharedPreferences to restore the last known state.
|
||||
|
||||
package pt.ruifpb.openvinyl
|
||||
|
||||
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.util.Base64
|
||||
import android.widget.RemoteViews
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
class VinylWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "VinylWidgetPrefs"
|
||||
private const val KEY_ART = "albumArtB64"
|
||||
|
||||
/**
|
||||
* Called by MediaListenerService whenever the track or art changes.
|
||||
* Stores art in SharedPreferences then forces a widget redraw.
|
||||
*/
|
||||
fun pushUpdate(context: Context, albumArtBytes: ByteArray?) {
|
||||
// Store art bytes as Base64 in SharedPreferences
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().apply {
|
||||
if (albumArtBytes != null) {
|
||||
putString(KEY_ART, Base64.encodeToString(albumArtBytes, Base64.DEFAULT))
|
||||
} else {
|
||||
remove(KEY_ART)
|
||||
}
|
||||
apply()
|
||||
}
|
||||
|
||||
// Force redraw on main thread
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val component = ComponentName(context, VinylWidgetProvider::class.java)
|
||||
val widgetIds = manager.getAppWidgetIds(component)
|
||||
if (widgetIds.isNotEmpty()) {
|
||||
updateWidgets(context, manager, widgetIds, albumArtBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateWidgets(
|
||||
context: Context,
|
||||
manager: AppWidgetManager,
|
||||
widgetIds: IntArray,
|
||||
albumArtBytes: ByteArray?
|
||||
) {
|
||||
// Determine a reasonable pixel size for the bitmap.
|
||||
// 300px is plenty for a 2x2 widget on any density.
|
||||
val size = 300
|
||||
val bitmap = VinylWidgetRenderer.render(context, size, albumArtBytes)
|
||||
|
||||
// Tap widget → open app
|
||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val views = RemoteViews(context.packageName, R.layout.vinyl_widget_layout).apply {
|
||||
setImageViewBitmap(R.id.widget_vinyl_image, bitmap)
|
||||
setOnClickPendingIntent(R.id.widget_vinyl_image, pendingIntent)
|
||||
}
|
||||
|
||||
for (id in widgetIds) {
|
||||
manager.updateAppWidget(id, views)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── AppWidgetProvider callbacks ───────────────────────────────────────────
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
// Restore last known album art from prefs (e.g. after reboot)
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val artB64 = prefs.getString(KEY_ART, null)
|
||||
val artBytes = artB64?.let { Base64.decode(it, Base64.DEFAULT) }
|
||||
updateWidgets(context, appWidgetManager, appWidgetIds, artBytes)
|
||||
}
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
// Nothing to clean up per-instance
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Last widget removed — clear stored art to free memory
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// android/app/src/main/kotlin/pt/ruifpb/openvinyl/VinylWidgetRenderer.kt
|
||||
//
|
||||
// Draws a vinyl disc bitmap entirely in Canvas — no View inflation needed.
|
||||
// Called by VinylWidgetProvider to produce the ImageView bitmap.
|
||||
//
|
||||
// The disc has:
|
||||
// - Dark base circle
|
||||
// - Concentric groove rings
|
||||
// - Radial edge fade (matches the in-app vinyl look)
|
||||
// - Centre label circle filled with album art (or a music note if none)
|
||||
// - Subtle sheen highlight
|
||||
|
||||
package pt.ruifpb.openvinyl
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import kotlin.math.min
|
||||
|
||||
object VinylWidgetRenderer {
|
||||
|
||||
/**
|
||||
* Renders a vinyl disc into a [size]x[size] Bitmap.
|
||||
* [albumArtBytes] is JPEG/PNG bytes, or null for a placeholder.
|
||||
*/
|
||||
fun render(context: Context, size: Int, albumArtBytes: ByteArray?): Bitmap {
|
||||
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
val cx = size / 2f
|
||||
val cy = size / 2f
|
||||
val radius = size / 2f - 4f // slight inset so edge isn't clipped
|
||||
|
||||
drawDisc(canvas, cx, cy, radius)
|
||||
drawGrooves(canvas, cx, cy, radius)
|
||||
drawEdgeFade(canvas, cx, cy, radius)
|
||||
drawSheen(canvas, cx, cy, radius)
|
||||
drawLabel(canvas, cx, cy, radius, albumArtBytes)
|
||||
|
||||
return bmp
|
||||
}
|
||||
|
||||
// ── Disc base ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun drawDisc(canvas: Canvas, cx: Float, cy: Float, r: Float) {
|
||||
canvas.drawCircle(cx, cy, r, Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFF0D0D0D.toInt()
|
||||
})
|
||||
}
|
||||
|
||||
// ── Groove rings ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun drawGrooves(canvas: Canvas, cx: Float, cy: Float, r: Float) {
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2.2f
|
||||
}
|
||||
val innerEdge = r * 0.55f
|
||||
val outerEdge = r * 0.94f
|
||||
val grooveCount = 10
|
||||
val step = (outerEdge - innerEdge) / grooveCount
|
||||
|
||||
for (i in 0 until grooveCount) {
|
||||
val gr = innerEdge + i * step
|
||||
paint.color = if (i % 2 == 0) 0xFF323232.toInt() else 0xFF1A1A1A.toInt()
|
||||
canvas.drawCircle(cx, cy, gr, paint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Radial edge fade — blends disc into transparent background ────────────
|
||||
|
||||
private fun drawEdgeFade(canvas: Canvas, cx: Float, cy: Float, r: Float) {
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = RadialGradient(
|
||||
cx, cy, r,
|
||||
intArrayOf(
|
||||
Color.TRANSPARENT,
|
||||
Color.TRANSPARENT,
|
||||
0x73000000, // ~45% black at 92% radius
|
||||
0xD9000000.toInt() // ~85% black at rim
|
||||
),
|
||||
floatArrayOf(0f, 0.78f, 0.92f, 1.0f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
canvas.drawCircle(cx, cy, r, paint)
|
||||
}
|
||||
|
||||
// ── Highlight sheen ───────────────────────────────────────────────────────
|
||||
|
||||
private fun drawSheen(canvas: Canvas, cx: Float, cy: Float, r: Float) {
|
||||
val offsetX = cx - r * 0.35f
|
||||
val offsetY = cy - r * 0.35f
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = RadialGradient(
|
||||
offsetX, offsetY, r * 0.85f,
|
||||
intArrayOf(0x12FFFFFF, Color.TRANSPARENT),
|
||||
null,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
canvas.drawCircle(cx, cy, r, paint)
|
||||
}
|
||||
|
||||
// ── Centre label with album art ───────────────────────────────────────────
|
||||
|
||||
private fun drawLabel(
|
||||
canvas: Canvas,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
albumArtBytes: ByteArray?
|
||||
) {
|
||||
val labelR = r * 0.52f
|
||||
|
||||
// Dark label background
|
||||
canvas.drawCircle(cx, cy, labelR, Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFF1A1A1A.toInt()
|
||||
})
|
||||
|
||||
if (albumArtBytes != null) {
|
||||
// Decode and crop album art into a circle
|
||||
val raw = BitmapFactory.decodeByteArray(albumArtBytes, 0, albumArtBytes.size)
|
||||
if (raw != null) {
|
||||
val side = (labelR * 2).toInt()
|
||||
val scaled = Bitmap.createScaledBitmap(raw, side, side, true)
|
||||
val circleBmp = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888)
|
||||
val c2 = Canvas(circleBmp)
|
||||
val p = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
val rect = RectF(0f, 0f, side.toFloat(), side.toFloat())
|
||||
c2.drawOval(rect, p)
|
||||
p.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
|
||||
c2.drawBitmap(scaled, 0f, 0f, p)
|
||||
|
||||
canvas.drawBitmap(
|
||||
circleBmp,
|
||||
cx - labelR,
|
||||
cy - labelR,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Placeholder: simple music note text
|
||||
canvas.drawText(
|
||||
"♪",
|
||||
cx,
|
||||
cy + 14f,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0x66FFFFFF
|
||||
textSize = labelR * 0.7f
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Thin centre spindle hole
|
||||
canvas.drawCircle(cx, cy, r * 0.025f, Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFF333333.toInt()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- android/app/src/main/res/layout/vinyl_widget_layout.xml -->
|
||||
<!-- -->
|
||||
<!-- Layout for the 2x2 vinyl widget. Structure: -->
|
||||
<!-- FrameLayout (root, square) -->
|
||||
<!-- └─ ImageView: the vinyl disc bitmap drawn by VinylWidgetView -->
|
||||
<!-- (filled by VinylWidgetProvider at update time) -->
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widget_vinyl_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="Now playing vinyl" />
|
||||
|
||||
</FrameLayout>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- android/app/src/main/res/xml/notification_listener_service.xml -->
|
||||
<!-- Declares which notification categories the service can access. -->
|
||||
<!-- An empty <notification-listener-service> tag is sufficient; -->
|
||||
<!-- Android uses it to show the app in Notification Listener settings.-->
|
||||
<notification-listener-service
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- android/app/src/main/res/xml/vinyl_widget_animated_info.xml -->
|
||||
<appwidget-provider
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="146dp"
|
||||
android:minHeight="146dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="0"
|
||||
android:initialLayout="@layout/vinyl_widget_layout"
|
||||
android:widgetCategory="home_screen"
|
||||
android:resizeMode="none" />
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="146dp"
|
||||
android:minHeight="146dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="0"
|
||||
android:initialLayout="@layout/vinyl_widget_layout"
|
||||
android:widgetCategory="home_screen"
|
||||
android:resizeMode="none" />
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
Reference in New Issue
Block a user