feat: add server update and scan functionality
This commit is contained in:
@@ -231,6 +231,12 @@ class MpdClient(
|
||||
MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
|
||||
}
|
||||
|
||||
/** Trigger a database scan for new/changed/removed files (whole library). */
|
||||
suspend fun updateDatabase() = run(MpdCommands.update())
|
||||
|
||||
/** Trigger a full re-read of the database, including files with an unchanged mtime. */
|
||||
suspend fun rescanDatabase() = run(MpdCommands.rescan())
|
||||
|
||||
/** Replace the queue with an album and start playing it. */
|
||||
suspend fun playAlbum(
|
||||
album: String,
|
||||
@@ -533,8 +539,13 @@ class MpdClient(
|
||||
private const val RECONNECT_BACKOFF_MS = 1_000L
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 5
|
||||
|
||||
/** Idle subsystems that change something [status]/[currentSong] reflects. */
|
||||
private val REFRESHING_SUBSYSTEMS = setOf("player", "mixer", "options", "playlist")
|
||||
/**
|
||||
* Idle subsystems that change something [status]/[currentSong] reflects. `update` fires when
|
||||
* a database scan starts and finishes (toggling `status.updating`); `database` fires when the
|
||||
* scan actually changed the library.
|
||||
*/
|
||||
private val REFRESHING_SUBSYSTEMS =
|
||||
setOf("player", "mixer", "options", "playlist", "update", "database")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,21 @@ object MpdCommands {
|
||||
|
||||
// --- Database / library -------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scan the music database for new, modified, and removed files (whole library when [path] is
|
||||
* null). Respects file modification times, so unchanged files are skipped. Returns immediately
|
||||
* with `updating_db: <jobid>`; the scan runs in the background.
|
||||
*/
|
||||
fun update(path: String? = null) =
|
||||
if (path == null) MpdProtocol.command("update") else MpdProtocol.command("update", path)
|
||||
|
||||
/**
|
||||
* Like [update], but also re-reads files whose modification time is unchanged — the "hard"
|
||||
* refresh, for when tags changed without touching mtime. Heavier on the server.
|
||||
*/
|
||||
fun rescan(path: String? = null) =
|
||||
if (path == null) MpdProtocol.command("rescan") else MpdProtocol.command("rescan", path)
|
||||
|
||||
/** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */
|
||||
fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist")
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ data class MpdStatus(
|
||||
val bitrate: Int?, // instantaneous kbps
|
||||
val audio: String?, // e.g. "44100:16:2"
|
||||
val error: String?, // last player error, if any
|
||||
val updating: Boolean = false, // true while the server is scanning its database (`updating_db`)
|
||||
) {
|
||||
companion object {
|
||||
fun from(values: Map<String, String>): MpdStatus =
|
||||
@@ -64,6 +65,8 @@ data class MpdStatus(
|
||||
bitrate = values["bitrate"]?.toIntOrNull(),
|
||||
audio = values["audio"],
|
||||
error = values["error"],
|
||||
// Present (a job id) only while a database update/rescan is running.
|
||||
updating = values["updating_db"] != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,12 @@ class MpdConnectionManager(context: Context) {
|
||||
suspend fun loadAlbums(): List<MpdAlbum> =
|
||||
runCatching { client.albums() }.getOrDefault(emptyList())
|
||||
|
||||
/** Ask the server to scan for new/changed/removed files. */
|
||||
fun updateDatabase() = fire { updateDatabase() }
|
||||
|
||||
/** Ask the server to fully re-read the database, including unchanged files. */
|
||||
fun rescanDatabase() = fire { rescanDatabase() }
|
||||
|
||||
/** One-shot play-queue fetch; returns empty on any failure. */
|
||||
suspend fun loadQueue(): List<MpdSong> =
|
||||
runCatching { client.queue() }.getOrDefault(emptyList())
|
||||
|
||||
@@ -117,6 +117,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
suspend fun loadAlbums() = manager.loadAlbums()
|
||||
|
||||
fun updateDatabase() = manager.updateDatabase()
|
||||
|
||||
fun rescanDatabase() = manager.rescanDatabase()
|
||||
|
||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
||||
|
||||
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,10 +13,13 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -24,6 +28,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -35,6 +41,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -44,6 +51,7 @@ import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Settings: shows the current server and offers to switch servers or reset all saved settings.
|
||||
@@ -58,6 +66,15 @@ fun SettingsScreen(
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val status by vm.status.collectAsStateWithLifecycle()
|
||||
var confirmReset by remember { mutableStateOf(false) }
|
||||
var refreshMenuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun flash(message: String) {
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
scope.launch { snackbarHostState.showSnackbar(message) }
|
||||
}
|
||||
|
||||
// Server statistics (`stats`), fetched once when the screen opens. `null`
|
||||
// means either still loading or unavailable — [statsLoaded] disambiguates.
|
||||
@@ -78,7 +95,8 @@ fun SettingsScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier =
|
||||
@@ -158,6 +176,49 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
// Ask the server to rescan its music sources. Tap runs a normal (mtime-based)
|
||||
// update; the overflow offers a full rescan that also re-reads unchanged files.
|
||||
// While a scan runs, `status.updating` drives the "Refreshing…" state.
|
||||
val refreshing = status?.updating == true
|
||||
ListItem(
|
||||
modifier =
|
||||
Modifier.clickable(enabled = !refreshing) {
|
||||
vm.updateDatabase()
|
||||
flash("Library refresh started")
|
||||
},
|
||||
headlineContent = { Text("Refresh library") },
|
||||
supportingContent = {
|
||||
Text(if (refreshing) "Refreshing…" else "Scan for new and changed files")
|
||||
},
|
||||
trailingContent = {
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Box {
|
||||
IconButton(onClick = { refreshMenuOpen = true }) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = "More refresh options",
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = refreshMenuOpen,
|
||||
onDismissRequest = { refreshMenuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Full rescan") },
|
||||
onClick = {
|
||||
refreshMenuOpen = false
|
||||
vm.rescanDatabase()
|
||||
flash("Full rescan started")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
"Server statistics",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
|
||||
@@ -37,6 +37,7 @@ class MpdModelTest {
|
||||
assertEquals(3, s.song)
|
||||
assertEquals(42.5, s.elapsed!!, 0.0001)
|
||||
assertEquals(215.3, s.duration!!, 0.0001)
|
||||
assertFalse(s.updating) // no updating_db field → not scanning
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,6 +48,12 @@ class MpdModelTest {
|
||||
assertEquals(0, s.playlistLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun status_updatingDbFlagsScanInProgress() {
|
||||
val s = MpdStatus.from(mapOf("state" to "stop", "updating_db" to "3"))
|
||||
assertTrue(s.updating)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun song_parsesTagsAndDuration() {
|
||||
val song =
|
||||
|
||||
Reference in New Issue
Block a user