Initial commit

This commit is contained in:
Rui Barbosa
2026-05-18 09:37:50 +01:00
commit 7685118e0b
120 changed files with 5990 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+55
View File
@@ -0,0 +1,55 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
}
val keyPropsFile = rootProject.file("key.properties")
val keyProps = Properties().apply {
if (keyPropsFile.exists()) load(FileInputStream(keyPropsFile))
}
android {
namespace = "pt.ruifpb.openvinyl"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "pt.ruifpb.openvinyl"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keyProps["keyAlias"] as String?
keyPassword = keyProps["keyPassword"] as String?
storeFile = if (keyProps["storeFile"] != null) file(keyProps["storeFile"] as String) else null
storePassword = keyProps["storePassword"] as String?
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
flutter {
source = "../.."
}
@@ -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>
+84
View File
@@ -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>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")