ci(release): switch registry login to REGISTRY_TOKEN #3

Merged
bvandeusen merged 262 commits from dev into main 2026-04-18 19:33:09 -04:00
5 changed files with 143 additions and 0 deletions
Showing only changes of commit 2a28d22a2a - Show all commits
@@ -6,7 +6,11 @@ import androidx.work.Configuration
import coil3.ImageLoader import coil3.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.ResumeController
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -26,9 +30,20 @@ class MinstrelApplication :
*/ */
@Inject lateinit var okHttpClient: OkHttpClient @Inject lateinit var okHttpClient: OkHttpClient
/**
* Injecting ResumeController forces Hilt to construct the singleton
* so its init block (observe + persist on track change) wires up at
* app launch. We also fire `restore()` from onCreate so a torn-down
* player resumes the last queue without requiring UI interaction.
*/
@Inject lateinit var resumeController: ResumeController
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
appScope.launch { resumeController.restore() }
} }
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
@@ -5,6 +5,7 @@ import androidx.room.Room
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -46,5 +47,10 @@ object DatabaseModule {
@Singleton @Singleton
fun provideCachedTrackDao(db: AppDatabase): CachedTrackDao = db.cachedTrackDao() fun provideCachedTrackDao(db: AppDatabase): CachedTrackDao = db.cachedTrackDao()
@Provides
@Singleton
fun provideCachedResumeStateDao(db: AppDatabase): CachedResumeStateDao =
db.cachedResumeStateDao()
private const val DATABASE_NAME = "minstrel.db" private const val DATABASE_NAME = "minstrel.db"
} }
@@ -1,5 +1,7 @@
package com.fabledsword.minstrel.models package com.fabledsword.minstrel.models
import kotlinx.serialization.Serializable
/** /**
* Lightweight reference to one track. Mirrors * Lightweight reference to one track. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef`. * `flutter_client/lib/models/track.dart`'s `TrackRef`.
@@ -11,7 +13,13 @@ package com.fabledsword.minstrel.models
* Constructed from `TrackWire` (server JSON) via `LibraryMappers`, and * Constructed from `TrackWire` (server JSON) via `LibraryMappers`, and
* from `CachedTrackEntity` (Room cache) via the same mappers. Either * from `CachedTrackEntity` (Room cache) via the same mappers. Either
* source produces an equivalent TrackRef. * source produces an equivalent TrackRef.
*
* `@Serializable` because the ResumeController persists List<TrackRef>
* as JSON in `cached_resume_state.json`. (Mild leak of persistence
* concern into the domain type, but the alternative — a duplicate
* persisted DTO with manual mappers — wasn't worth the boilerplate.)
*/ */
@Serializable
data class TrackRef( data class TrackRef(
val id: String, val id: String,
val title: String, val title: String,
@@ -0,0 +1,91 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists the player's last queue + position to Room so a torn-down
* session can resume on next app launch. Mirrors
* `flutter_client/lib/cache/resume_controller.dart`.
*
* Subscribes to [PlayerController.uiState] in init; persists when the
* (track-id, queueIndex) changes — captures real session transitions
* (track started / skipped) without churning on every 1Hz position tick.
*
* Restore is one-shot at app start (called from `MinstrelApplication.
* onCreate`). Reads the row, decodes, calls
* [PlayerController.setQueue] — the player's transport buttons + any
* paired Wear/lock-screen surface now have something to act on even
* if the user never opened the in-app UI this run.
*/
@Singleton
class ResumeController @Inject constructor(
private val playerController: PlayerController,
private val dao: CachedResumeStateDao,
private val json: Json,
@ApplicationScope private val scope: CoroutineScope,
) {
init {
scope.launch { observeAndPersist() }
}
suspend fun restore() {
val row = dao.get() ?: return
val payload =
try {
json.decodeFromString<ResumePayload>(row.json)
} catch (e: SerializationException) {
// Schema drift or corruption — drop the row and start clean
// rather than crashing or leaving a poison pill.
Timber.w(e, "ResumeController: dropping unreadable resume state")
dao.clear()
return
}
if (payload.tracks.isEmpty()) return
playerController.setQueue(
tracks = payload.tracks,
initialIndex = payload.queueIndex.coerceAtLeast(0).coerceAtMost(payload.tracks.lastIndex),
source = payload.source,
)
// PositionMs not seeked yet — Player connection may still be
// establishing; the seek call would no-op. Acceptable to start
// from 0 on resume for now; #future-polish if it bothers anyone.
}
private suspend fun observeAndPersist() {
playerController.uiState
// Skip the initial "empty" snapshot before anything has been played.
.drop(1)
.map { Triple(it.currentTrack?.id, it.queueIndex, it.queue.size) }
.distinctUntilChanged()
.collect { persistCurrentSnapshot() }
}
private suspend fun persistCurrentSnapshot() {
val state = playerController.uiState.value
if (state.queue.isEmpty()) {
// Clear so an emptied queue doesn't auto-restore stale tracks.
dao.clear()
return
}
val payload = ResumePayload(
tracks = state.queue,
queueIndex = state.queueIndex.coerceAtLeast(0),
positionMs = state.positionMs,
)
dao.upsert(
CachedResumeStateEntity(json = json.encodeToString(payload)),
)
}
}
@@ -0,0 +1,23 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.serialization.Serializable
/**
* The JSON-serialized shape persisted in
* `cached_resume_state.json` by [ResumeController]. Versioned via the
* `schema` field so a future change can read old snapshots safely
* (decode → drop on mismatch rather than crash).
*/
@Serializable
data class ResumePayload(
val schema: Int = SCHEMA_VERSION,
val tracks: List<TrackRef>,
val queueIndex: Int,
val positionMs: Long,
val source: String? = null,
) {
companion object {
const val SCHEMA_VERSION: Int = 1
}
}