release v2026.05.11.3: caching, perf, playlist polish #41
+70
@@ -0,0 +1,70 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the device's "is the internet usable
|
||||||
|
* right now" signal — wraps [ConnectivityManager] and exposes a hot
|
||||||
|
* cold-startable Flow that emits `false` while the active network
|
||||||
|
* lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier,
|
||||||
|
* captive portal, etc.) and `true` once a usable network appears.
|
||||||
|
*
|
||||||
|
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||||
|
* repositories can also collect this to gate retry loops.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityObserver @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) {
|
||||||
|
private val cm = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
|
||||||
|
val online: Flow<Boolean> = callbackFlow {
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
trySend(hasUsableInternet())
|
||||||
|
}
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
trySend(hasUsableInternet())
|
||||||
|
}
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
capabilities: NetworkCapabilities,
|
||||||
|
) {
|
||||||
|
trySend(
|
||||||
|
capabilities.hasCapability(
|
||||||
|
NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||||
|
) &&
|
||||||
|
capabilities.hasCapability(
|
||||||
|
NetworkCapabilities.NET_CAPABILITY_VALIDATED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cm.registerNetworkCallback(request, callback)
|
||||||
|
// Seed the initial value so the banner doesn't flash before the
|
||||||
|
// first capability callback fires.
|
||||||
|
trySend(hasUsableInternet())
|
||||||
|
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
private fun hasUsableInternet(): Boolean {
|
||||||
|
val active = cm.activeNetwork ?: return false
|
||||||
|
val caps = cm.getNetworkCapabilities(active) ?: return false
|
||||||
|
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity.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.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.composables.icons.lucide.CloudOff
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny VM that just lifts the [ConnectivityObserver] singleton's
|
||||||
|
* Flow into a StateFlow with the standard sharing strategy. Keeps
|
||||||
|
* the banner composable pure-presentation.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ConnectivityBannerViewModel @Inject constructor(
|
||||||
|
observer: ConnectivityObserver,
|
||||||
|
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
val online: StateFlow<Boolean> = observer.online.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner shown at the top of the shell when the device has no usable
|
||||||
|
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error
|
||||||
|
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile
|
||||||
|
* data" copy. Auto-hides via slide+fade when connectivity returns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConnectionErrorBanner(
|
||||||
|
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val online by viewModel.online.collectAsStateWithLifecycle()
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !online,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.CloudOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "No connection — check Wi-Fi or mobile data.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
||||||
@@ -52,9 +53,10 @@ fun ShellScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
// Banners (VersionTooOld, UpdateBanner) live here once those
|
// Banner slot. ConnectionErrorBanner auto-shows when the device
|
||||||
// mechanisms exist; for now the slot is empty so the layout
|
// loses usable internet. VersionTooOld / UpdateBanner will join
|
||||||
// matches the Flutter structure 1:1.
|
// this slot once those mechanisms exist.
|
||||||
|
ConnectionErrorBanner()
|
||||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user