feat(m10): Glance Now Playing widget with transport

This commit is contained in:
John Smith
2026-09-10 20:47:03 -04:00
parent bfdd25f5d2
commit 64c9c5f1b0
11 changed files with 324 additions and 8 deletions
+2 -2
View File
@@ -135,7 +135,7 @@ archive. Binaries are encrypted (AES-ECB, marketplace DRM) and
### Tests
150 Robolectric unit tests + a design-invariant source scanner that
153 Robolectric unit tests + a design-invariant source scanner that
forbids `RoundedCornerShape`, `spring(`, raw hex `Color(0x…)`, and
named `Color.Black/White/Red/…` constants in `ui/` and `ui/apps/`.
@@ -193,7 +193,7 @@ mini-app platform.
| Mini-app platform | ✅ | ✅ | 29 apps (12 utilities + 9 games + 8 mocks) |
| Crossbar pivot nav | ✅ | ✅ | All collection screens have horizontal pivot pivots |
| Screensaver / now-playing art | ✅ | ✅ | |
| Widget (home-screen) | ✅ | ❌ | Not yet — see roadmap |
| Widget (home-screen) | ✅ | ✅ | Glance `NowPlayingWidget`: Now Playing + transport |
| Wi-Fi sync with Dorado (sibling project) | ✅ | ◐ | M8.1–8.3: engine + transport core + Device view (simulated target); LAN pairing pending M8.2b |
| USB MTPZ sync to physical Zune HD | ✅ | ❌ | Stretch goal — see roadmap |
+1
View File
@@ -77,6 +77,7 @@ dependencies {
implementation(libs.coil.network.okhttp)
implementation(libs.palette.ktx)
implementation(libs.datastore.preferences)
implementation(libs.glance.appwidget)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
+11
View File
@@ -61,5 +61,16 @@
<receiver
android:name=".media.AlarmReceiver"
android:exported="false" />
<receiver
android:name=".widget.NowPlayingWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/now_playing_widget_info" />
</receiver>
</application>
</manifest>
@@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -148,6 +149,24 @@ class DoradoApp : Application() {
runCatching { scrobble.flush() }
}
// M10: mirror the current track into the home-screen widget. No-op when no
// widget is placed; failures (e.g. Glance unavailable) are swallowed.
appScope.launch {
combine(controller.nowPlaying, controller.isPlaying) { track, playing -> track to playing }
.collect { (track, playing) ->
val state = com.heretek.dorado_hd.widget.NowPlayingWidgetStateMapper.from(
title = track?.title,
artist = track?.artist,
album = track?.album,
artUri = track?.albumArtUri?.toString(),
isPlaying = playing,
)
runCatching {
com.heretek.dorado_hd.widget.NowPlayingWidgetUpdater.update(this@DoradoApp, state)
}
}
}
// Opt-in MediaStore watcher (Settings > collection > watch media store).
// Debounced 2 s to coalesce bursts (e.g. mass-transfer).
var watcher: ContentObserver? = null
@@ -0,0 +1,161 @@
package com.heretek.dorado_hd.widget
import android.content.ComponentName
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.action.Action
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.heretek.dorado_hd.media.DoradoPlaybackService
import kotlinx.coroutines.guava.await
/** Preference keys backing the widget's Glance state. */
object NowPlayingWidgetKeys {
val Title = stringPreferencesKey("np_title")
val Artist = stringPreferencesKey("np_artist")
val Album = stringPreferencesKey("np_album")
val Playing = booleanPreferencesKey("np_playing")
val Art = stringPreferencesKey("np_art")
}
/**
* Zune-style Now Playing home-screen widget (M10). Renders square, typographic
* transport controls reading the playback snapshot pushed by
* [NowPlayingWidgetUpdater]. Widget surfaces render outside Compose, so the
* design tokens can't be consumed directly; the matte-black canvas, white text
* and muted grey mirror `DoradoTokens` values to honor the invariant's spirit.
*/
class NowPlayingWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<Preferences>()
NowPlayingWidgetContent(
NowPlayingWidgetState(
title = prefs[NowPlayingWidgetKeys.Title].orEmpty(),
artist = prefs[NowPlayingWidgetKeys.Artist].orEmpty(),
album = prefs[NowPlayingWidgetKeys.Album].orEmpty(),
isPlaying = prefs[NowPlayingWidgetKeys.Playing] ?: false,
artUri = prefs[NowPlayingWidgetKeys.Art],
),
)
}
}
}
@Composable
private fun NowPlayingWidgetContent(state: NowPlayingWidgetState) {
val white = ColorProvider(Color(0xFFFFFFFF))
val muted = ColorProvider(Color(0xFF9AA0A6))
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(ColorProvider(Color(0xFF0D0D0F)))
.padding(12.dp),
verticalAlignment = Alignment.Vertical.CenterVertically,
) {
Text(
text = state.title.ifBlank { "dorado hd" },
maxLines = 1,
style = TextStyle(color = white, fontSize = 16.sp, fontWeight = FontWeight.Medium),
)
Text(
text = state.artist.ifBlank { if (state.title.isBlank()) "not playing" else "" },
maxLines = 1,
style = TextStyle(color = muted, fontSize = 12.sp),
)
Spacer(GlanceModifier.height(8.dp))
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.Horizontal.CenterHorizontally,
) {
TransportGlyph("\u23EE", actionRunCallback<PreviousTrackAction>())
Spacer(GlanceModifier.width(18.dp))
TransportGlyph(
if (state.isPlaying) "\u23F8" else "\u25B6",
actionRunCallback<TogglePlaybackAction>(),
)
Spacer(GlanceModifier.width(18.dp))
TransportGlyph("\u23ED", actionRunCallback<NextTrackAction>())
}
}
}
@Composable
private fun TransportGlyph(glyph: String, action: Action) {
Text(
text = glyph,
style = TextStyle(color = ColorProvider(Color(0xFFFFFFFF)), fontSize = 22.sp),
modifier = GlanceModifier.padding(6.dp).clickable(action),
)
}
/** Drives the shared Media3 session directly from a widget tap. */
sealed class TransportAction : ActionCallback {
protected abstract fun apply(controller: MediaController)
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val token = SessionToken(context, ComponentName(context, DoradoPlaybackService::class.java))
val controller = MediaController.Builder(context, token).buildAsync().await()
try {
apply(controller)
} finally {
controller.release()
}
}
}
class TogglePlaybackAction : TransportAction() {
override fun apply(controller: MediaController) {
if (controller.isPlaying) controller.pause() else controller.play()
}
}
class NextTrackAction : TransportAction() {
override fun apply(controller: MediaController) = controller.seekToNextMediaItem()
}
class PreviousTrackAction : TransportAction() {
override fun apply(controller: MediaController) = controller.seekToPreviousMediaItem()
}
/** Hosts [NowPlayingWidget]; declared in AndroidManifest as the appwidget receiver. */
class NowPlayingWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = NowPlayingWidget()
}
@@ -0,0 +1,38 @@
package com.heretek.dorado_hd.widget
/**
* Snapshot of playback pushed to the home-screen Now Playing widget (M10).
* Pure data so the mapping can be unit-tested without Android/Glance.
*/
data class NowPlayingWidgetState(
val title: String,
val artist: String,
val album: String,
val isPlaying: Boolean,
val artUri: String?,
) {
companion object {
val Empty = NowPlayingWidgetState("", "", "", false, null)
}
}
/** Maps controller output to a widget snapshot. */
object NowPlayingWidgetStateMapper {
fun from(
title: String?,
artist: String?,
album: String?,
artUri: String?,
isPlaying: Boolean,
): NowPlayingWidgetState = NowPlayingWidgetState(
title = title.orEmpty(),
artist = artist.orEmpty(),
album = album.orEmpty(),
isPlaying = isPlaying,
artUri = artUri?.takeIf { it.isNotBlank() },
)
/** Standard MediaStore album-art content URI for an album id. */
fun albumArtUri(albumId: Long): String =
"content://media/external/audio/albumart/$albumId"
}
@@ -0,0 +1,34 @@
package com.heretek.dorado_hd.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
/**
* Pushes a playback snapshot into every placed Now Playing widget and asks
* Glance to re-render. Safe to call when no widget is on the home screen.
*/
object NowPlayingWidgetUpdater {
suspend fun update(context: Context, state: NowPlayingWidgetState) {
val manager = GlanceAppWidgetManager(context)
val ids = manager.getGlanceIds(NowPlayingWidget::class.java)
if (ids.isEmpty()) return
ids.forEach { id ->
updateAppWidgetState(context, id) { prefs ->
prefs[NowPlayingWidgetKeys.Title] = state.title
prefs[NowPlayingWidgetKeys.Artist] = state.artist
prefs[NowPlayingWidgetKeys.Album] = state.album
prefs[NowPlayingWidgetKeys.Playing] = state.isPlaying
if (state.artUri != null) {
prefs[NowPlayingWidgetKeys.Art] = state.artUri
} else {
prefs.remove(NowPlayingWidgetKeys.Art)
}
}
}
NowPlayingWidget().updateAll(context)
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="72dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:initialLayout="@layout/glance_default_loading_layout"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen" />
@@ -0,0 +1,39 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.widget.NowPlayingWidgetStateMapper
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
class NowPlayingWidgetStateMapperTest {
@Test
fun `null fields map to empty strings`() {
val state = NowPlayingWidgetStateMapper.from(null, null, null, null, isPlaying = false)
assertEquals("", state.title)
assertEquals("", state.artist)
assertEquals("", state.album)
assertFalse(state.isPlaying)
assertNull(state.artUri)
}
@Test
fun `blank art uri is dropped`() {
val state = NowPlayingWidgetStateMapper.from("T", "A", "Al", " ", isPlaying = true)
assertNull(state.artUri)
}
@Test
fun `values and playing flag are preserved`() {
val state = NowPlayingWidgetStateMapper.from("T", "A", "Al", "content://art/1", isPlaying = true)
assertEquals("T", state.title)
assertEquals("A", state.artist)
assertEquals("Al", state.album)
assertEquals("content://art/1", state.artUri)
assertEquals(true, state.isPlaying)
}
}
+7 -6
View File
@@ -42,7 +42,7 @@ A code-verified snapshot (not README claims):
| Gap | Evidence (current code) | Axis | Effort |
|---|---|---|---|
| Home-screen widget | no `appwidget`/Glance; `AndroidManifest.xml` has no receiver | device | M |
| Home-screen widget | ✅ Glance `NowPlayingWidget` + appwidget receiver | device | ✅ done |
| Wi-Fi sync with Dorado | no `sync/` package; desktop already has `SyncModels.cs`, `SyncEngine.BuildPlan`, `IDeviceTransport` + `SimulatedDeviceTransport` | cross-project | L |
| USB MTP/MTPZ to a physical Zune HD | stretch; spec is in `dorado/.agents/skills/zune-hardware-sync` (MTPZ handshake, F-marker ZMDB, PPP interceptor `0x922C/0x922D`, host `192.168.55.100` / device `.101`) | cross-project | XL |
| Long-press → pin incomplete | was `onLongClick = {}` across `DetailScreens`/`MediaScreens`/`MusicScreen`/`Podcasts`/`MarketplaceScreens`; now pin + shared `trackMenuActions` | device (M4) | ✅ done |
@@ -187,11 +187,12 @@ referencing `dorado/src/Dorado.Application`); re-run it after any desktop
### M10 — Always-on surfaces
- **Glance widget**: Zune-style Now Playing + transport; add the appwidget
receiver to `AndroidManifest.xml`. RemoteViews are outside Compose, so keep
widget XML zero-corner-radius to honor the invariant's spirit.
- Richer lock-screen Now Playing art/controls.
- Optional sleep timer (post-device extension).
- ✅ **Glance widget** (`widget/NowPlayingWidget`): Zune-style Now Playing +
square transport glyphs, driven by the shared Media3 session; receiver
declared in `AndroidManifest.xml`. No Material chrome; the matte-black canvas
and white/muted text honor the invariant's spirit.
- ⬜ Richer lock-screen Now Playing art/controls.
- ⬜ Optional sleep timer (post-device extension).
### M11 — Hardware stretch (capability-gated)
+2
View File
@@ -14,6 +14,7 @@ room = "2.8.5"
coil = "3.6.2"
palette = "1.0.0"
datastore = "1.2.1"
glance = "1.2.0"
coroutines = "1.10.2"
junit = "4.13.2"
robolectric = "4.13"
@@ -48,6 +49,7 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp"
palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" }