2 Commits
Author SHA1 Message Date
austinried e396671ac7 feat: auto-sync the library on a timer while the app is open
While the app is in the foreground a timer checks the auto-sync setting and
syncs when due (default every 24h; the interval is selectable from 15 minutes
to 24 hours). It stops when the app is backgrounded. "Sync automatically"
is one setting: Off, Wi-Fi only (default) or Always.

A running sync is shown by a silent, dismissible notification and a small
spinner on the library tab bar's settings button. A manual pull-to-refresh
still holds the pull spinner; only an automatic sync does not. The library
and Home sync actions no-op while a sync is running, and SyncManager
coalesces a request that arrives mid-run into a single follow-up, so repeated
pull-to-refresh cannot pile up runs while adding a server still syncs it.

The time of the last sync attempt is recorded, so a failing server is retried
once per interval rather than every poll. The sync consumer and the auto-sync
loop both survive an unexpected failure instead of silently disabling syncing
for the rest of the process.

The poll sleeps for the interval (capped at 15 minutes) instead of a fixed
minute, the network mode is collected once rather than re-registering a
connectivity callback each poll, and the decision is logged at DEBUG (visible
with Verbose logging) rather than writing to the log file every poll.
2026-10-10 03:36:16 +00:00
austinried 8e7431cc2e fix: stop a queue drag from moving the wrong rows
A drop captured the dragged row's queue position once, at drag start, and
committed on a fire-and-forget coroutine. If the queue renumbered while the
drag was in flight (up-next consumed, a reconcile) or before a prior move had
reconciled, the drop resolved its target against stale coordinates and moved
the wrong entry - the "two middle rows swap on release" symptom.

Resolve the move from the current rows at drop time, and have move() report
whether it was accepted so a refused reorder is not announced as done; a
further move is refused while one is still committing.
2026-10-09 19:01:22 +00:00
20 changed files with 721 additions and 59 deletions
+1
View File
@@ -48,6 +48,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default. - Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table. - Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected. - The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running.
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL. - Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests. - UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
- UI and playback preferences go in DataStore (`UserPreferences`), not Room. - UI and playback preferences go in DataStore (`UserPreferences`), not Room.
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.subtracks.data.sync.AutoSync
import com.subtracks.ui.NowPlayingLauncher import com.subtracks.ui.NowPlayingLauncher
import com.subtracks.ui.SubtracksRoot import com.subtracks.ui.SubtracksRoot
import com.subtracks.ui.theme.SubtracksTheme import com.subtracks.ui.theme.SubtracksTheme
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val launchViewModel: NowPlayingLaunchViewModel by viewModels() private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() } private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
private val requestNotifications = private val requestNotifications =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { } registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onStart() {
super.onStart()
autoSync.setForeground(true)
}
override fun onStop() {
super.onStop()
autoSync.setForeground(false)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermission() requestNotificationPermission()
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.subtracks.data.net.NetworkMode import com.subtracks.data.net.NetworkMode
@@ -48,6 +49,10 @@ data class StreamQuality(
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs") private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
enum class SyncMode { Off, WifiOnly, Always }
data class ArtworkSeedValue( data class ArtworkSeedValue(
val cacheKey: String, val cacheKey: String,
val primary: Int, val primary: Int,
@@ -105,6 +110,34 @@ class UserPreferences(
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency") private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
fun syncMode(): Flow<SyncMode> =
store.data.map { prefs ->
prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly
}
suspend fun setSyncMode(mode: SyncMode) {
store.edit { prefs -> prefs[syncModeKey] = mode.name }
}
private val syncModeKey = stringPreferencesKey("sync_mode")
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
suspend fun setLastSyncAt(value: Long) {
store.edit { prefs -> prefs[lastSyncAtKey] = value }
}
private val lastSyncAtKey = longPreferencesKey("last_sync_at")
fun syncIntervalMinutes(): Flow<Int> =
store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) }
suspend fun setSyncIntervalMinutes(value: Int) {
store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) }
}
private val syncIntervalKey = intPreferencesKey("sync_interval_minutes")
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true } fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
suspend fun setScrobbling(enabled: Boolean) { suspend fun setScrobbling(enabled: Boolean) {
@@ -0,0 +1,108 @@
package com.subtracks.data.sync
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000
internal fun shouldAutoSync(
mode: SyncMode,
network: NetworkMode,
offline: Boolean,
hasSource: Boolean,
lastSyncAt: Long,
now: Long,
intervalMs: Long,
): Boolean {
val allowed =
when (mode) {
SyncMode.Off -> false
SyncMode.WifiOnly -> network == NetworkMode.Wifi
SyncMode.Always -> true
}
return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs)
}
class AutoSync(
private val preferences: UserPreferences,
private val networkMode: Flow<NetworkMode>,
private val hasSource: suspend () -> Boolean,
private val requestSync: () -> Unit,
private val scope: CoroutineScope,
private val now: () -> Long = System::currentTimeMillis,
private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS,
) {
@Volatile
private var lastNetwork: NetworkMode? = null
private var loop: Job? = null
init {
scope.launch { networkMode.collect { lastNetwork = it } }
}
fun setForeground(active: Boolean) {
if (active) start() else stop()
}
private fun start() {
if (loop?.isActive == true) return
loop =
scope.launch {
while (isActive) {
val intervalMs =
try {
checkAndRequest()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
Log.w("sync", "auto-sync check failed", failure)
maxPollMs
}
delay(intervalMs.coerceAtMost(maxPollMs))
}
}
}
private fun stop() {
loop?.cancel()
loop = null
}
private suspend fun checkAndRequest(): Long {
val mode = preferences.syncMode().first()
val network = lastNetwork ?: networkMode.first()
val offline = preferences.offlineMode().first()
val hasSource = hasSource()
val lastSyncAt = preferences.lastSyncAt().first()
val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L
val time = now()
val due =
shouldAutoSync(
mode = mode,
network = network,
offline = offline,
hasSource = hasSource,
lastSyncAt = lastSyncAt,
now = time,
intervalMs = intervalMs,
)
Log.d(
"sync",
"auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " +
"sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)",
)
if (due && currentCoroutineContext().isActive) requestSync()
return intervalMs
}
}
@@ -3,6 +3,7 @@ package com.subtracks.data.sync
import com.subtracks.R import com.subtracks.R
import com.subtracks.UiMessage import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink import com.subtracks.data.source.ServerActionSink
@@ -13,14 +14,18 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed interface SyncStatus { sealed interface SyncStatus {
data object Idle : SyncStatus data object Idle : SyncStatus
data object Running : SyncStatus data class Running(
val silent: Boolean,
) : SyncStatus
data object Success : SyncStatus data object Success : SyncStatus
@@ -34,31 +39,47 @@ class SyncManager(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val serverActions: ServerActionSink, private val serverActions: ServerActionSink,
private val preferences: UserPreferences,
private val showMessage: (UiMessage) -> Unit = {}, private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) { ) {
private val requests = Channel<Unit>(Channel.CONFLATED) private val requests = Channel<Boolean>(Channel.CONFLATED)
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle) private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
val status: StateFlow<SyncStatus> = _status val status: StateFlow<SyncStatus> = _status
init { init {
scope.launch { scope.launch {
for (ignored in requests) runSync() for (silent in requests) {
try {
runSync(silent)
} catch (cancellation: CancellationException) {
if (!currentCoroutineContext().isActive) throw cancellation
_status.value = SyncStatus.Idle
Log.w("sync", "sync interrupted", cancellation)
} catch (failure: Throwable) {
_status.value = SyncStatus.Idle
Log.w("sync", "unexpected failure", failure)
}
}
} }
} }
fun requestSync() { fun requestSync(silent: Boolean = false) {
requests.trySend(Unit) requests.trySend(silent)
} }
private suspend fun runSync() { private suspend fun runSync(silent: Boolean) {
if (sourceRepository.offline.value) { if (sourceRepository.offline.value) {
if (!silent) {
val offline = UiMessage(R.string.sync_offline) val offline = UiMessage(R.string.sync_offline)
showMessage(offline) showMessage(offline)
_status.value = SyncStatus.Failed(offline) _status.value = SyncStatus.Failed(offline)
}
return return
} }
_status.value = SyncStatus.Running _status.value = SyncStatus.Running(silent)
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
val startedAt = System.nanoTime() val startedAt = System.nanoTime()
val result = val result =
try { try {
@@ -88,7 +109,7 @@ class SyncManager(
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: "")) ?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
} }
} }
showMessage(message) if (!silent) showMessage(message)
SyncStatus.Failed(message) SyncStatus.Failed(message)
} }
queueRepository.invalidateLibraryCache() queueRepository.invalidateLibraryCache()
@@ -0,0 +1,72 @@
package com.subtracks.data.sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.subtracks.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class SyncNotifier(
private val context: Context,
private val status: Flow<SyncStatus>,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private val manager = NotificationManagerCompat.from(context)
fun start() {
createChannel()
scope.launch {
status.collect { syncStatus ->
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
manager.notify(ID, build())
} else {
manager.cancel(ID)
}
}
}
}
private fun build(): Notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_name)
.setContentTitle(context.getString(R.string.sync_notification_title))
.setContentText(context.getString(R.string.sync_notification_text))
.setOnlyAlertOnce(true)
.setSilent(true)
.setProgress(0, 0, true)
.setContentIntent(launchIntent())
.build()
private fun launchIntent(): PendingIntent? =
context.packageManager
.getLaunchIntentForPackage(context.packageName)
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.sync_notification_channel),
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) },
)
}
companion object {
private const val ID = 2
private const val CHANNEL_ID = "sync"
}
}
@@ -27,7 +27,9 @@ import com.subtracks.data.repo.PendingServerActionSink
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.sync.AutoSync
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncNotifier
import com.subtracks.log.Log import com.subtracks.log.Log
import com.subtracks.playback.MediaSessionConnection import com.subtracks.playback.MediaSessionConnection
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
@@ -96,7 +98,16 @@ fun appModule(
} }
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) } single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) } single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) } single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
single {
AutoSync(
preferences = get(),
networkMode = networkMode(context.applicationContext),
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
requestSync = { get<SyncManager>().requestSync(silent = true) },
scope = get(),
)
}
single { QueueRepository(get()) } single { QueueRepository(get()) }
single { ArtworkStore(downloadsRoot(context)) } single { ArtworkStore(downloadsRoot(context)) }
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) } single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
@@ -115,6 +126,7 @@ fun appModule(
).also { it.start() } ).also { it.start() }
} }
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } } single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) } single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) } single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
single(createdAtStart = true) { single(createdAtStart = true) {
@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -53,11 +52,6 @@ class HomeViewModel(
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
) : ViewModel() { ) : ViewModel() {
val syncing: StateFlow<Boolean> =
syncManager.status
.map { it == SyncStatus.Running }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val offline: StateFlow<Boolean> = val offline: StateFlow<Boolean> =
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
@@ -141,7 +135,9 @@ class HomeViewModel(
viewModelScope.playDownloadedList(libraryRepository, playbackController, song) viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
} }
fun sync() = syncManager.requestSync() fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
}
private data class PlayRows( private data class PlayRows(
val recentAlbums: List<Album>, val recentAlbums: List<Album>,
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Remove import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
@@ -243,6 +244,7 @@ fun LibraryRoute(
var previousTabName by remember { mutableStateOf(selectedTab.name) } var previousTabName by remember { mutableStateOf(selectedTab.name) }
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
val syncing by viewModel.syncing.collectAsStateWithLifecycle() val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
val listTab = selectedTab.listTab() val listTab = selectedTab.listTab()
LaunchedEffect(selectedTab) { LaunchedEffect(selectedTab) {
if (previousTab != selectedTab) { if (previousTab != selectedTab) {
@@ -296,6 +298,7 @@ fun LibraryRoute(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) }, onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
syncing = syncing, syncing = syncing,
refreshing = refreshing,
bottomInset = bottomInset, bottomInset = bottomInset,
artwork = artwork, artwork = artwork,
albums = viewModel.albums, albums = viewModel.albums,
@@ -392,6 +395,7 @@ fun LibraryScreen(
onExitOffline: () -> Unit = {}, onExitOffline: () -> Unit = {},
onItemLongClick: (MenuTarget) -> Unit = {}, onItemLongClick: (MenuTarget) -> Unit = {},
syncing: Boolean = false, syncing: Boolean = false,
refreshing: Boolean = false,
bottomInset: Dp = 0.dp, bottomInset: Dp = 0.dp,
listQuery: ListQuery = ListQuery(""), listQuery: ListQuery = ListQuery(""),
resetKeys: Map<LibraryTab, Any?> = emptyMap(), resetKeys: Map<LibraryTab, Any?> = emptyMap(),
@@ -474,13 +478,13 @@ fun LibraryScreen(
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox( PullToRefreshBox(
isRefreshing = syncing, isRefreshing = refreshing,
onRefresh = onSync, onRefresh = onSync,
state = pullToRefreshState, state = pullToRefreshState,
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = syncing, isRefreshing = refreshing,
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(), modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
containerColor = headerColor, containerColor = headerColor,
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary, color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
@@ -582,6 +586,7 @@ fun LibraryScreen(
offline = offline, offline = offline,
onExitOffline = onExitOffline, onExitOffline = onExitOffline,
artwork = artwork, artwork = artwork,
syncing = syncing,
) )
} }
} }
@@ -848,6 +853,7 @@ internal fun LibraryTabs(
offline: Boolean = false, offline: Boolean = false,
onExitOffline: () -> Unit = {}, onExitOffline: () -> Unit = {},
artwork: ArtworkColors?, artwork: ArtworkColors?,
syncing: Boolean = false,
) { ) {
val indicatorStretch = 18.dp val indicatorStretch = 18.dp
val density = LocalDensity.current val density = LocalDensity.current
@@ -939,11 +945,20 @@ internal fun LibraryTabs(
} }
} }
IconButton(onClick = onOpenSettings) { IconButton(onClick = onOpenSettings) {
Box(contentAlignment = Alignment.Center) {
Icon( Icon(
imageVector = Icons.Rounded.Settings, imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.navigation_tabs_settings), contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
if (syncing) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
} }
} }
} }
@@ -117,7 +117,12 @@ class LibraryViewModel(
val syncing: StateFlow<Boolean> = val syncing: StateFlow<Boolean> =
syncManager.status syncManager.status
.map { it == SyncStatus.Running } .map { it is SyncStatus.Running }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val refreshing: StateFlow<Boolean> =
syncManager.status
.map { it is SyncStatus.Running && !it.silent }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull() private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -206,7 +211,9 @@ class LibraryViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun sync() = syncManager.requestSync() fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
}
} }
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
@@ -113,6 +113,8 @@ class QueueViewModel(
var generation by mutableIntStateOf(0) var generation by mutableIntStateOf(0)
private set private set
private var reorderPending = false
private var nextId = 0L private var nextId = 0L
private val mutex = Mutex() private val mutex = Mutex()
@@ -249,13 +251,20 @@ class QueueViewModel(
fun move( fun move(
from: Long, from: Long,
to: Long, to: Long,
) { ): Boolean {
if (from == to) return if (from == to) return false
if (reorderPending) return false
reorderPending = true
viewModelScope.launch { viewModelScope.launch {
try {
playbackController.move(from, to) playbackController.move(from, to)
mutex.withLock { reconcileLocked() } mutex.withLock { reconcileLocked() }
} finally {
reorderPending = false
} }
} }
return true
}
fun remove(position: Long) { fun remove(position: Long) {
viewModelScope.launch { viewModelScope.launch {
@@ -331,7 +340,7 @@ fun QueueScreen(
onPlay: (Long) -> Unit, onPlay: (Long) -> Unit,
onRemove: (Long) -> Unit, onRemove: (Long) -> Unit,
onReorder: (Int, Int) -> Unit, onReorder: (Int, Int) -> Unit,
onMove: (Long, Long) -> Unit, onMove: (Long, Long) -> Boolean,
onLoadOlder: () -> Unit, onLoadOlder: () -> Unit,
onLoadNewer: () -> Unit, onLoadNewer: () -> Unit,
onUndo: () -> Unit, onUndo: () -> Unit,
@@ -347,7 +356,6 @@ fun QueueScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dragId by remember { mutableStateOf<Long?>(null) } var dragId by remember { mutableStateOf<Long?>(null) }
var dragFrom by remember { mutableStateOf<Long?>(null) }
val undoLabel = stringResource(R.string.undo) val undoLabel = stringResource(R.string.undo)
val queueReordered = stringResource(R.string.queue_reordered) val queueReordered = stringResource(R.string.queue_reordered)
val queueRemoved = stringResource(R.string.queue_removed) val queueRemoved = stringResource(R.string.queue_removed)
@@ -470,28 +478,14 @@ fun QueueScreen(
null null
} else { } else {
Modifier.draggableHandle( Modifier.draggableHandle(
onDragStarted = { onDragStarted = { dragId = row.id },
dragId = row.id
dragFrom = row.position
},
onDragStopped = { onDragStopped = {
val id = dragId dragId?.let { id ->
val from = dragFrom rows.dropMoveFor(id)?.let { move ->
if (id != null && from != null) { if (onMove(move.from, move.to)) showUndo(queueReordered)
val draggedIndex = rows.indexOfFirst { it.id == id }
val to =
if (draggedIndex < 0) {
from
} else {
dropTarget(rows, draggedIndex, from)
}
if (to != from) {
onMove(from, to)
showUndo(queueReordered)
} }
} }
dragId = null dragId = null
dragFrom = null
}, },
) )
}, },
@@ -563,6 +557,19 @@ internal fun dropTarget(
return (before - if (before > from) 1 else 0) + 1 return (before - if (before > from) 1 else 0) + 1
} }
internal data class QueueMove(
val from: Long,
val to: Long,
)
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
val index = indexOfFirst { it.id == draggedId }
if (index < 0) return null
val from = this[index].position
val to = dropTarget(this, index, from)
return if (to == from) null else QueueMove(from, to)
}
@Suppress("ModifierParameter") @Suppress("ModifierParameter")
@Composable @Composable
private fun QueueRowItem( private fun QueueRowItem(
@@ -65,7 +65,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.BuildConfig import com.subtracks.BuildConfig
import com.subtracks.R import com.subtracks.R
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.log.Log import com.subtracks.log.Log
import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
@@ -82,7 +84,7 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks" private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
private const val SUPPORT_URL = "https://ko-fi.com/austinried" private const val SUPPORT_URL = "https://ko-fi.com/austinried"
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency } private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
@@ -99,6 +101,8 @@ fun SettingsRoute(
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle() val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle() val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle() val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
val syncMode by viewModel.syncMode.collectAsStateWithLifecycle()
val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle() val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle() val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle() val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
@@ -112,6 +116,8 @@ fun SettingsRoute(
wifiQuality = wifiQuality, wifiQuality = wifiQuality,
mobileQuality = mobileQuality, mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency, syncConcurrency = syncConcurrency,
syncMode = syncMode,
syncInterval = syncIntervalMinutes,
downloadQuality = downloadQuality, downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered, downloadOverMetered = downloadOverMetered,
scrobbling = scrobbling, scrobbling = scrobbling,
@@ -122,6 +128,8 @@ fun SettingsRoute(
onWifiQualityChange = viewModel::setWifiQuality, onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality, onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency, onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onSyncModeChange = viewModel::setSyncMode,
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
onDownloadQualityChange = viewModel::setDownloadQuality, onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered, onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onScrobblingChange = viewModel::setScrobbling, onScrobblingChange = viewModel::setScrobbling,
@@ -166,11 +174,15 @@ fun SettingsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
offline: Boolean = false, offline: Boolean = false,
verboseLogging: Boolean = false, verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
onSelectSource: (Long) -> Unit, onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit, onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit, onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit, onSyncConcurrencyChange: (Int) -> Unit,
onSyncModeChange: (SyncMode) -> Unit = {},
onSyncIntervalChange: (Int) -> Unit = {},
onDownloadQualityChange: (StreamQuality) -> Unit, onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit, onDownloadOverMeteredChange: (Boolean) -> Unit,
onScrobblingChange: (Boolean) -> Unit, onScrobblingChange: (Boolean) -> Unit,
@@ -288,6 +300,22 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_interval)) },
supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_music_name)) } item { SectionHeader(stringResource(R.string.settings_music_name)) }
item { item {
ListItem( ListItem(
@@ -475,6 +503,22 @@ fun SettingsScreen(
) )
} }
SettingsDialog.SyncInterval -> {
SyncIntervalDialog(
selected = syncInterval,
onSelect = onSyncIntervalChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.SyncMode -> {
SyncModeDialog(
selected = syncMode,
onSelect = onSyncModeChange,
onDismiss = { dialog = null },
)
}
null -> { null -> {
Unit Unit
} }
@@ -500,11 +544,56 @@ private fun bitrateLabel(
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16) private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
private fun concurrencyLabel( private fun concurrencyLabel(
resources: Resources, resources: Resources,
value: Int, value: Int,
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString() ): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
private fun syncIntervalLabel(
resources: Resources,
minutes: Int,
): String =
if (minutes >= 60 && minutes % 60 == 0) {
resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60)
} else {
resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes)
}
@Composable
private fun SyncIntervalDialog(
selected: Int,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableIntStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.sync_interval)) },
text = {
ChoiceGroup(
header = stringResource(R.string.sync_interval_prompt),
options = syncIntervalOptions.map { it to syncIntervalLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
},
confirmButton = {
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@Composable @Composable
private fun ConcurrencyDialog( private fun ConcurrencyDialog(
selected: Int, selected: Int,
@@ -538,6 +627,51 @@ private fun ConcurrencyDialog(
) )
} }
private val syncModeOptions = SyncMode.entries
private fun syncModeLabel(
resources: Resources,
mode: SyncMode,
): String =
when (mode) {
SyncMode.Off -> resources.getString(R.string.sync_mode_off)
SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi)
SyncMode.Always -> resources.getString(R.string.sync_mode_always)
}
@Composable
private fun SyncModeDialog(
selected: SyncMode,
onSelect: (SyncMode) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_sync_auto_title)) },
text = {
ChoiceGroup(
header = stringResource(R.string.sync_mode_prompt),
options = syncModeOptions.map { it to syncModeLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
},
confirmButton = {
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@Composable @Composable
private fun QualityDialog( private fun QualityDialog(
title: String, title: String,
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
@@ -48,6 +50,16 @@ class SettingsViewModel(
.syncConcurrency() .syncConcurrency()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
val syncMode: StateFlow<SyncMode> =
userPreferences
.syncMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncMode.WifiOnly)
val syncIntervalMinutes: StateFlow<Int> =
userPreferences
.syncIntervalMinutes()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_INTERVAL_MINUTES)
val scrobbling: StateFlow<Boolean> = val scrobbling: StateFlow<Boolean> =
userPreferences userPreferences
.scrobbling() .scrobbling()
@@ -92,6 +104,14 @@ class SettingsViewModel(
viewModelScope.launch { userPreferences.setSyncConcurrency(value) } viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
} }
fun setSyncMode(mode: SyncMode) {
viewModelScope.launch { userPreferences.setSyncMode(mode) }
}
fun setSyncIntervalMinutes(value: Int) {
viewModelScope.launch { userPreferences.setSyncIntervalMinutes(value) }
}
fun setScrobbling(enabled: Boolean) { fun setScrobbling(enabled: Boolean) {
viewModelScope.launch { userPreferences.setScrobbling(enabled) } viewModelScope.launch { userPreferences.setScrobbling(enabled) }
} }
+18
View File
@@ -2,6 +2,9 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">subtracks</string> <string name="app_name">subtracks</string>
<string name="download_channel_description">Progress while songs download</string> <string name="download_channel_description">Progress while songs download</string>
<string name="sync_notification_channel">Sync</string>
<string name="sync_notification_title">Syncing library</string>
<string name="sync_notification_text">Keeping your library up to date</string>
<string name="actions_cancel">Cancel</string> <string name="actions_cancel">Cancel</string>
<string name="actions_delete">Delete</string> <string name="actions_delete">Delete</string>
@@ -201,6 +204,21 @@
<string name="sync_concurrency">Sync concurrency</string> <string name="sync_concurrency">Sync concurrency</string>
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string> <string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
<string name="sync_concurrency_sequential">1 (sequential)</string> <string name="sync_concurrency_sequential">1 (sequential)</string>
<string name="settings_sync_auto_title">Sync automatically</string>
<string name="sync_mode_off">Off</string>
<string name="sync_mode_wifi">Wi-Fi only</string>
<string name="sync_mode_always">Always</string>
<string name="sync_mode_prompt">When to sync automatically</string>
<string name="sync_interval">Automatic sync interval</string>
<string name="sync_interval_prompt">How often to sync automatically</string>
<plurals name="sync_interval_minutes">
<item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<plurals name="sync_interval_hours">
<item quantity="one">%1$d hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<string name="download_quality">Download quality</string> <string name="download_quality">Download quality</string>
<string name="download_over_mobile">Download over mobile data</string> <string name="download_over_mobile">Download over mobile data</string>
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string> <string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string>
@@ -0,0 +1,146 @@
package com.subtracks.data.sync
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class AutoSyncTest {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@After
fun tearDown() {
cancelAndJoinBlocking(scope)
}
@Test
fun decidesWhetherASyncIsDue() {
assertTrue(shouldSync())
assertTrue(shouldSync(mode = SyncMode.WifiOnly))
assertTrue(shouldSync(mode = SyncMode.Always, network = NetworkMode.Mobile))
assertTrue(shouldSync(lastSyncAt = INTERVAL_MS * 2, now = INTERVAL_MS))
assertFalse(shouldSync(mode = SyncMode.Off))
assertFalse(shouldSync(mode = SyncMode.WifiOnly, network = NetworkMode.Mobile))
assertFalse(shouldSync(lastSyncAt = INTERVAL_MS - 1, now = INTERVAL_MS))
assertFalse(shouldSync(offline = true))
assertFalse(shouldSync(hasSource = false))
}
@Test
fun syncsWhenTheAppIsActiveAndDue() {
val preferences = fakeUserPreferences()
runBlocking {
preferences.setSyncMode(SyncMode.WifiOnly)
preferences.setLastSyncAt(0L)
}
val requested = AtomicBoolean(false)
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
autoSync.setForeground(true)
awaitUntil("auto sync requested") { requested.get() }
autoSync.setForeground(false)
}
@Test
fun doesNotSyncWhenOff() {
val preferences = fakeUserPreferences()
runBlocking {
preferences.setSyncMode(SyncMode.Off)
preferences.setLastSyncAt(0L)
}
val requested = AtomicBoolean(false)
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
autoSync.setForeground(true)
runBlocking { delay(300) }
autoSync.setForeground(false)
assertFalse(requested.get())
}
@Test
fun stopsSyncingWhenBackgrounded() {
val preferences = fakeUserPreferences()
runBlocking {
preferences.setSyncMode(SyncMode.Always)
preferences.setLastSyncAt(0L)
}
val count = AtomicInteger(0)
val autoSync =
AutoSync(
preferences = preferences,
networkMode = flowOf(NetworkMode.Wifi),
hasSource = { true },
requestSync = { count.incrementAndGet() },
scope = scope,
now = { INTERVAL_MS + 1 },
maxPollMs = 50L,
)
autoSync.setForeground(true)
awaitUntil("first sync") { count.get() >= 1 }
autoSync.setForeground(false)
runBlocking { delay(200) }
val afterStop = count.get()
runBlocking { delay(300) }
assertEquals(afterStop, count.get())
}
private fun autoSync(
preferences: UserPreferences,
requestSync: () -> Unit,
now: () -> Long = { INTERVAL_MS + 1 },
): AutoSync =
AutoSync(
preferences = preferences,
networkMode = flowOf(NetworkMode.Wifi),
hasSource = { true },
requestSync = requestSync,
scope = scope,
now = now,
maxPollMs = 10_000L,
)
private fun shouldSync(
mode: SyncMode = SyncMode.WifiOnly,
network: NetworkMode = NetworkMode.Wifi,
offline: Boolean = false,
hasSource: Boolean = true,
lastSyncAt: Long = 0L,
now: Long = INTERVAL_MS,
intervalMs: Long = INTERVAL_MS,
): Boolean =
shouldAutoSync(
mode = mode,
network = network,
offline = offline,
hasSource = hasSource,
lastSyncAt = lastSyncAt,
now = now,
intervalMs = intervalMs,
)
private companion object {
val INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MINUTES * 60_000L
}
}
@@ -10,6 +10,7 @@ import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.NetworkServerActionSink import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
@@ -44,6 +45,7 @@ class SyncManagerTest {
private lateinit var sourceRepository: SourceRepository private lateinit var sourceRepository: SourceRepository
private lateinit var queueRepository: QueueRepository private lateinit var queueRepository: QueueRepository
private lateinit var manager: SyncManager private lateinit var manager: SyncManager
private lateinit var preferences: UserPreferences
private lateinit var scope: CoroutineScope private lateinit var scope: CoroutineScope
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private lateinit var server: MockWebServer private lateinit var server: MockWebServer
@@ -62,12 +64,14 @@ class SyncManagerTest {
sourceRepository = sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope) SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
queueRepository = QueueRepository(db) queueRepository = QueueRepository(db)
preferences = fakeUserPreferences()
manager = manager =
SyncManager( SyncManager(
db, db,
sourceRepository, sourceRepository,
queueRepository, queueRepository,
NetworkServerActionSink(sourceRepository), NetworkServerActionSink(sourceRepository),
preferences = preferences,
showMessage = { messages += it.resolve(resources) }, showMessage = { messages += it.resolve(resources) },
scope = scope, scope = scope,
) )
@@ -89,6 +93,16 @@ class SyncManagerTest {
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } } withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(listOf("No server configured"), messages) assertEquals(listOf("No server configured"), messages)
assertTrue(preferences.lastSyncAt().first() > 0)
}
@Test
fun aSilentSyncDoesNotTellTheUser() =
runBlocking {
manager.requestSync(silent = true)
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertTrue(messages.isEmpty())
} }
@Test @Test
@@ -114,6 +128,7 @@ class SyncManagerTest {
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
assertEquals(listOf("al1"), db.libraryDao().albumIds(1)) assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
assertTrue(preferences.lastSyncAt().first() > 0)
} }
@Test @Test
@@ -506,7 +506,7 @@ class AppScreenshotTest {
onPlay = {}, onPlay = {},
onRemove = {}, onRemove = {},
onReorder = { _, _ -> }, onReorder = { _, _ -> },
onMove = { _, _ -> }, onMove = { _, _ -> true },
onLoadOlder = {}, onLoadOlder = {},
onLoadNewer = {}, onLoadNewer = {},
onUndo = {}, onUndo = {},
@@ -325,6 +325,42 @@ class QueueViewModelTest {
assertEquals((220L..399L).toList(), rowsSnapshot().map { it.position }) assertEquals((220L..399L).toList(), rowsSnapshot().map { it.position })
} }
@Test
fun ignoringASecondMoveWhileTheFirstIsStillCommitting() {
runBlocking { seedSongs(10) }
controller.playAlbum(1, "al1", 0)
await { controller.state.value.position == 0L }
viewModel.open()
await { viewModel.ready }
val before = rowsSnapshot().map { it.song.song.id }
val from = rowsSnapshot().first().position
runOnMain {
assertTrue(viewModel.move(from, from + 3))
assertFalse(viewModel.move(from, from + 1))
}
val expected = before.toMutableList().also { it.add(3, it.removeAt(0)) }
await { rowsSnapshot().map { it.song.song.id } == expected }
assertEquals(expected, rowsSnapshot().map { it.song.song.id })
}
@Test
fun dropMoveTakesTheDraggedRowsCurrentPosition() {
val rows =
listOf(
row(10).copy(id = 2L),
row(11).copy(id = 3L),
row(9).copy(id = 1L),
)
val move = rows.dropMoveFor(1L)!!
assertEquals(9L, move.from)
assertEquals(11L, move.to)
}
@Test @Test
fun removingATrackUnderTheBlockReloadsRows() { fun removingATrackUnderTheBlockReloadsRows() {
runBlocking { seedSongs(10) } runBlocking { seedSongs(10) }
@@ -156,7 +156,14 @@ class AddSourceViewModelTest {
private fun viewModel(sourceId: Long? = null): AddSourceViewModel { private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
val syncManager = val syncManager =
SyncManager(db, sourceRepository, QueueRepository(db), NetworkServerActionSink(sourceRepository), scope = repoScope) SyncManager(
db,
sourceRepository,
QueueRepository(db),
NetworkServerActionSink(sourceRepository),
preferences = fakeUserPreferences(),
scope = repoScope,
)
val resources = ApplicationProvider.getApplicationContext<Context>().resources val resources = ApplicationProvider.getApplicationContext<Context>().resources
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it } return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
} }
+6 -4
View File
@@ -52,7 +52,7 @@ app/src/main/kotlin/com/subtracks
repo/ SourceRepository, LibraryRepository repo/ SourceRepository, LibraryRepository
source/ MusicSource abstraction source/ MusicSource abstraction
subsonic/ SubsonicClient, SubsonicXml, SubsonicSource subsonic/ SubsonicClient, SubsonicXml, SubsonicSource
sync/ SyncService (stream + per-batch writes + prune), SyncManager (status) sync/ SyncService (stream + per-batch writes + prune), SyncManager (status), AutoSync (foreground timer), SyncNotifier
``` ```
## Layers ## Layers
@@ -65,12 +65,12 @@ Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic)
- The remote side sits behind `MusicSource` so additional backends can be added later. - The remote side sits behind `MusicSource` so additional backends can be added later.
- `SourceRepository` owns the configured servers and builds a `SubsonicSource` from the active one; it also serves cover-art URLs. `LibraryRepository` reads the local mirror. - `SourceRepository` owns the configured servers and builds a `SubsonicSource` from the active one; it also serves cover-art URLs. `LibraryRepository` reads the local mirror.
- The local side is Room; reads are `Flow`s, so the UI updates as sync writes. - The local side is Room; reads are `Flow`s, so the UI updates as sync writes.
- `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow<SyncStatus>`; `SyncService` does the actual work. - `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow<SyncStatus>`; `SyncService` does the actual work. `AutoSync` runs a foreground-only timer that asks `SyncManager` for a silent sync once the configured interval has elapsed.
- ViewModels are wired with Koin; screens are split into a stateful `*Route` (default `koinViewModel()`) and a stateless `*Screen` so the latter can be screenshot-tested directly. - ViewModels are wired with Koin; screens are split into a stateful `*Route` (default `koinViewModel()`) and a stateless `*Screen` so the latter can be screenshot-tested directly.
## UI shell ## UI shell
The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition. The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The library syncs on pull-to-refresh; the pull indicator holds only while a manual sync runs, and a spinner rings the Settings action whenever any sync is running (an automatic sync shows only the spinner, plus a notification). There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
The Albums tab is a three-column grid of covers only. Sorting and filtering hang off a FAB, and Settings holds the server list and the network, playback, download and about sections rather than list sort or sync. The Albums tab is a three-column grid of covers only. Sorting and filtering hang off a FAB, and Settings holds the server list and the network, playback, download and about sections rather than list sort or sync.
@@ -116,7 +116,9 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det
## Sync ## Sync
`SyncService.sync()` is serialized by `SyncManager`, which coalesces duplicate requests. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against. `SyncService.sync()` is serialized by `SyncManager`, which coalesces a request that arrives while one is already running into a single follow-up. The library and Home sync actions are no-ops while a sync runs (so repeated pull-to-refresh cannot pile up runs), while a programmatic request such as adding a server still gets its follow-up. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against.
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end. Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.