diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 6716588e..e15ea87d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -747,7 +747,7 @@ class MinstrelForwardingPlayer( * the library's duration, so the scrubber has an end to run to. */ private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long = - if (single != null && info.trackDurationMs <= 0L) singleTrack.knownDurationMs() else info.trackDurationMs + if (single != null && info.trackDurationMs <= 0L) single.knownDurationMs else info.trackDurationMs /** * Ask the watchdog what to make of this poll, and act on its answer. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 65598476..d0d217d7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -679,11 +679,11 @@ class OutputPickerController @Inject constructor( localQueue: List, localIndex: Int, ): Boolean { - val (transport, rendering, route, queueMode) = target + val route = target.route val plan = probeAdoptable(target, localQueue, localIndex) ?: return false - if (queueMode == QueueMode.SINGLE_TRACK) { + if (target.queueMode == QueueMode.SINGLE_TRACK) { // The renderer holds only this track; arm the one after it. - singleTrack.adopt(transport, localQueue, plan.localIndex, plan.trackUri) + singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri) } Timber.w( "UPnP adopt: attaching to live session on %s at track %d (%dms)", @@ -704,9 +704,9 @@ class OutputPickerController @Inject constructor( ActiveUpnp( routeId = route.id, routeName = route.name, - avTransport = transport, - rendering = rendering, - queueMode = queueMode, + avTransport = target.transport, + rendering = target.rendering, + queueMode = target.queueMode, ), ) return true diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt index 32eddab1..cae5430b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt @@ -70,8 +70,10 @@ class SingleTrackLoader @Inject constructor( * renderer is playing (0 when nothing is loaded). [advanced] is true when * this poll started the next track itself, so the reading it was given is * already stale and the caller should not judge a stall from it. + * [knownDurationMs] is the library's duration for that track, for a + * renderer that reports none. */ - data class PollOutcome(val trackNumber: Int, val advanced: Boolean) + data class PollOutcome(val trackNumber: Int, val advanced: Boolean, val knownDurationMs: Long = 0L) /** Take [queue] as the one to play, and start [index] on the renderer. */ suspend fun load(transport: AVTransportClient, queue: List, index: Int) = @@ -137,9 +139,8 @@ class SingleTrackLoader @Inject constructor( if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false) val moved = followRenderer(trackUri) armNext(transport) - if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false) - val known = queue[currentIndex].durationSec * MILLIS_PER_SECOND - endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, known) + if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex)) + endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex)) val next = currentIndex + 1 val advance = endedOnLastPoll && playIntent && next in queue.indices if (advance) { @@ -147,7 +148,7 @@ class SingleTrackLoader @Inject constructor( runCatching { start(transport, next) } .onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) } } - PollOutcome(currentIndex + 1, advanced = advance) + PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex)) } /** @@ -162,10 +163,6 @@ class SingleTrackLoader @Inject constructor( RemoteStallWatchdog.QueueState.UNKNOWN } - /** The library's duration for the current track, for a renderer that reports none. */ - fun knownDurationMs(): Long = - queue.getOrNull(currentIndex)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L - private suspend fun start(transport: AVTransportClient, index: Int): Boolean { if (index !in queue.indices) return false val token = mint(index) @@ -240,11 +237,14 @@ class SingleTrackLoader @Inject constructor( ) private companion object { - const val MILLIS_PER_SECOND = 1_000L const val ARM_RETRY_POLLS = 10 } } +/** The library's duration of [queue]'s track at [index], or 0 when there is none. */ +private fun knownDurationMs(queue: List, index: Int): Long = + queue.getOrNull(index)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L + /** * Index in [queue] of the track with [id], taking the occurrence nearest * [preferNear] so a track queued twice does not send the cursor across the @@ -263,3 +263,4 @@ internal fun streamUriTrackId(uri: String): String? { } private const val TRACKS_PATH_MARKER = "/api/tracks/" +private const val MILLIS_PER_SECOND = 1_000L diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt deleted file mode 100644 index b9539bd8..00000000 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.fabledsword.minstrel.settings.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.fabledsword.minstrel.BuildConfig -import com.fabledsword.minstrel.api.ErrorCopy -import com.fabledsword.minstrel.models.UpdateInfo -import com.fabledsword.minstrel.update.data.ApkInstaller -import com.fabledsword.minstrel.update.data.InstallStage -import com.fabledsword.minstrel.update.data.UpdateRepository -import com.fabledsword.minstrel.update.data.isBusy -import com.fabledsword.minstrel.update.data.isUpdateAvailable -import com.fabledsword.minstrel.update.data.message -import com.fabledsword.minstrel.update.data.stage -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.io.File -import javax.inject.Inject - -/** - * One of three terminal states the Check-for-updates button surfaces. - * `Idle` is the pre-check state; `Latest` means the installed build - * matches or exceeds the server's bundled APK; `UpdateAvailable` - * surfaces an "Install vX.Y.Z" button that downloads the APK and - * installs it via [ApkInstaller]. - */ -sealed interface UpdateCheckResult { - data object Idle : UpdateCheckResult - data object Latest : UpdateCheckResult - data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult - data class Error(val message: String) : UpdateCheckResult -} - -data class AboutUiState( - val installedVersion: String = BuildConfig.VERSION_NAME, - // The value the platform installs by, and therefore the one the update - // check must decide on. Held in state rather than read inline so a test - // can drive the comparison without a BuildConfig. - val installedCode: Long = BuildConfig.VERSION_CODE.toLong(), - // A debug build is signed with this machine's debug key, so the server's - // release-signed APK can never install over it. It updates from Android - // Studio instead. - val selfUpdates: Boolean = !BuildConfig.DEBUG, - val isChecking: Boolean = false, - val installStage: InstallStage = InstallStage.IDLE, - val installMessage: String? = null, - val result: UpdateCheckResult = UpdateCheckResult.Idle, -) - -/** - * Backs the About card's update controls. "Check for updates" calls - * [UpdateRepository.getLatest], compares versus this build via - * [isUpdateAvailable] — on the ordering key where the server reports one, - * on the name otherwise — and reports the terminal state. - * When an update is available, [install] downloads the APK via - * [ApkInstaller] and installs it — routing the user to the "install - * unknown apps" settings page first when that permission hasn't been - * granted. - */ -@HiltViewModel -class AboutCardViewModel @Inject constructor( - private val repository: UpdateRepository, - private val installer: ApkInstaller, -) : ViewModel() { - - private val internal = MutableStateFlow(AboutUiState()) - val state: StateFlow = internal.asStateFlow() - - fun checkForUpdates() { - if (internal.value.isChecking || !internal.value.selfUpdates) return - viewModelScope.launch { - internal.update { it.copy(isChecking = true, installMessage = null) } - val installed = internal.value.installedVersion - val installedCode = internal.value.installedCode - val result = runCatching { repository.getLatest() } - .map { latest -> - if ( - isUpdateAvailable( - serverCode = latest.code, - serverName = latest.version, - installedCode = installedCode, - installedName = installed, - ) - ) { - UpdateCheckResult.UpdateAvailable(latest) - } else { - UpdateCheckResult.Latest - } - } - .getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) } - internal.update { it.copy(isChecking = false, result = result) } - } - } - - fun install(info: UpdateInfo) { - if (internal.value.installStage.isBusy()) return - if (!installer.canInstall()) { - installer.requestInstallPermission() - internal.update { - it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.") - } - return - } - viewModelScope.launch { - internal.update { - it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null) - } - val apk = download(info.apkUrl) - if (apk != null) { - // The install half now suspends on the platform's verdict, so it - // gets its own stage — reporting "Downloading…" through it would - // be a lie once a confirm dialog is on screen. - internal.update { it.copy(installStage = InstallStage.INSTALLING) } - val outcome = installer.install(apk) - internal.update { - it.copy(installStage = outcome.stage(), installMessage = outcome.message()) - } - } - } - } - - private suspend fun download(apkUrl: String): File? = - runCatching { installer.downloadApk(apkUrl) } - .onFailure { e -> - internal.update { - it.copy( - installStage = InstallStage.ERROR, - installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}", - ) - } - } - .getOrNull() -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt deleted file mode 100644 index c81f6b83..00000000 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.fabledsword.minstrel.update.data - -import com.fabledsword.minstrel.BuildConfig -import com.fabledsword.minstrel.di.ApplicationScope -import com.fabledsword.minstrel.models.UpdateInfo -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton - -private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L - -/** - * Drives the shell's soft "update available" banner. Polls - * `/api/client/version` at launch + every 24h and, when the bundled - * APK outranks this build — by ordering key where the server reports one, - * by name otherwise — exposes its [UpdateInfo] so - * [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an - * install. - * - * Dismissals are kept in memory and keyed by version, so dismissing - * one release's banner still lets a later release re-surface — and a - * restart re-shows it, which is acceptable nudging. Server 404 / - * network errors stay silent. Constructed at - * launch via the construct-the-singleton trick in `MinstrelApplication`. - * - * A debug build never polls: it is signed with a local debug key, so the - * server's release-signed APK could never install over it. - */ -@Singleton -class UpdateBannerController @Inject constructor( - @ApplicationScope private val scope: CoroutineScope, - private val repository: UpdateRepository, -) { - private val latest = MutableStateFlow(null) - private val dismissed = MutableStateFlow>(emptySet()) - - /** The available update, or null when none / dismissed / unreachable. */ - val available: StateFlow = - combine(latest, dismissed) { info, seen -> - info?.takeIf { it.version !in seen } - }.stateIn(scope, SharingStarted.Eagerly, null) - - init { - if (!BuildConfig.DEBUG) startPolling() - } - - private fun startPolling() { - scope.launch { - while (true) { - runOnce() - delay(POLL_INTERVAL_MS) - } - } - } - - fun dismiss(version: String) { - dismissed.value = dismissed.value + version - } - - private suspend fun runOnce() { - val info = runCatching { repository.getLatest() }.getOrNull() ?: return - latest.value = info.takeIf { - isUpdateAvailable( - serverCode = it.code, - serverName = it.version, - installedCode = BuildConfig.VERSION_CODE.toLong(), - installedName = BuildConfig.VERSION_NAME, - ) - } - } -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt deleted file mode 100644 index 906a5287..00000000 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.fabledsword.minstrel.update.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.composables.icons.lucide.Download -import com.composables.icons.lucide.Lucide -import com.composables.icons.lucide.X -import com.fabledsword.minstrel.models.UpdateInfo -import com.fabledsword.minstrel.update.data.InstallStage -import com.fabledsword.minstrel.update.data.isBusy - -/** - * Shell-level soft banner that nudges an available update. Renders - * nothing until [UpdateBannerController] surfaces a newer bundled APK - * (and the user hasn't dismissed that version): a download glyph, - * "Update Minstrel · {version} available", an Install button - * (download → system installer), and a dismiss X. Uses an understated - * surface tone, not the alarming error color reserved for - * `VersionTooOldBanner`. - * - * [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller] - * exposes no byte progress, so the downloading state is an - * indeterminate bar. - */ -@Composable -fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val info = state.info - AnimatedVisibility( - visible = info != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - // info can flip to null during the exit animation; hold the last - // non-null so the collapsing banner keeps its content. - val shown = info ?: return@AnimatedVisibility - BannerBody( - info = shown, - stage = state.stage, - message = state.message, - onInstall = { viewModel.install(shown) }, - onDismiss = { viewModel.dismiss(shown.version) }, - ) - } -} - -@Composable -private fun BannerBody( - info: UpdateInfo, - stage: InstallStage, - message: String?, - onInstall: () -> Unit, - onDismiss: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), - ) { - BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss) - if (stage.isBusy()) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), - color = MaterialTheme.colorScheme.primary, - ) - } - if (message != null) { - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } - } -} - -@Composable -private fun BannerRow( - info: UpdateInfo, - stage: InstallStage, - onInstall: () -> Unit, - onDismiss: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Lucide.Download, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = if (stage == InstallStage.ERROR) { - "Update failed" - } else { - "Update Minstrel · ${info.version} available" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onInstall, enabled = !stage.isBusy()) { - // Downloading and installing are separate words because they're now - // separate waits — the install half suspends on the platform, which - // may be sitting on a confirm dialog. - Text( - when (stage) { - InstallStage.DOWNLOADING -> "Downloading…" - InstallStage.INSTALLING -> "Installing…" - else -> "Install" - }, - ) - } - IconButton(onClick = onDismiss) { - Icon( - imageVector = Lucide.X, - contentDescription = "Dismiss", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt deleted file mode 100644 index 553c108c..00000000 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.fabledsword.minstrel.update.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.fabledsword.minstrel.api.ErrorCopy -import com.fabledsword.minstrel.models.UpdateInfo -import com.fabledsword.minstrel.update.data.ApkInstaller -import com.fabledsword.minstrel.update.data.InstallStage -import com.fabledsword.minstrel.update.data.UpdateBannerController -import com.fabledsword.minstrel.update.data.isBusy -import com.fabledsword.minstrel.update.data.message -import com.fabledsword.minstrel.update.data.stage -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import java.io.File -import javax.inject.Inject - -private const val SHARE_STOP_TIMEOUT_MS = 5_000L - -data class UpdateBannerUiState( - val info: UpdateInfo? = null, - val stage: InstallStage = InstallStage.IDLE, - val message: String? = null, -) - -/** - * Thin VM over [UpdateBannerController]. Surfaces the available update - * and runs the download → install handoff via [ApkInstaller], mirroring - * the About card's flow (route to "install unknown apps" settings first - * when the permission is missing). - */ -@HiltViewModel -class UpdateBannerViewModel @Inject constructor( - private val controller: UpdateBannerController, - private val installer: ApkInstaller, -) : ViewModel() { - - private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null)) - - val uiState: StateFlow = - combine(controller.available, installState) { info, install -> - UpdateBannerUiState(info = info, stage = install.stage, message = install.message) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), - initialValue = UpdateBannerUiState(), - ) - - fun dismiss(version: String) = controller.dismiss(version) - - fun install(info: UpdateInfo) { - if (installState.value.stage.isBusy()) return - if (!installer.canInstall()) { - installer.requestInstallPermission() - installState.value = InstallSnapshot( - InstallStage.IDLE, - "Allow installs for Minstrel, then tap Install again.", - ) - return - } - viewModelScope.launch { - installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null) - val apk = download(info.apkUrl) - if (apk != null) { - // Await the platform's verdict rather than firing an intent and - // assuming it worked. On a silent install this suspends until - // the process is replaced, so the line below is only reached - // when the install did NOT simply succeed. - installState.value = InstallSnapshot(InstallStage.INSTALLING, null) - val outcome = installer.install(apk) - installState.value = InstallSnapshot(outcome.stage(), outcome.message()) - } - } - } - - private suspend fun download(apkUrl: String): File? = - runCatching { installer.downloadApk(apkUrl) } - .onFailure { e -> - installState.value = InstallSnapshot( - InstallStage.ERROR, - "Couldn't download update: ${ErrorCopy.fromThrowable(e)}", - ) - } - .getOrNull() -} - -private data class InstallSnapshot(val stage: InstallStage, val message: String?)