mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-12 02:21:19 +00:00
feat(m10): Glance Now Playing widget with transport
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user