feat: M4c radio similarity-driven candidate pool + 80% queue refresh (closes M4) #28
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.api.endpoints
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -33,6 +34,23 @@ interface MeApi {
|
|||||||
*/
|
*/
|
||||||
@PUT("api/me/password")
|
@PUT("api/me/password")
|
||||||
suspend fun changePassword(@Body body: ChangePasswordRequest)
|
suspend fun changePassword(@Body body: ChangePasswordRequest)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller's ListenBrainz state — never returns the token itself,
|
||||||
|
* only whether one is stored, whether scrobbling is enabled, and
|
||||||
|
* the last successful scrobble timestamp (RFC3339 string or null).
|
||||||
|
*/
|
||||||
|
@GET("api/me/listenbrainz")
|
||||||
|
suspend fun getListenBrainz(): ListenBrainzStatusWire
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT against the same `/api/me/listenbrainz` endpoint with one
|
||||||
|
* of two shapes — `{token: ...}` stashes a new token,
|
||||||
|
* `{enabled: ...}` flips the scrobble switch. Server returns the
|
||||||
|
* post-write status so callers refresh without a separate GET.
|
||||||
|
*/
|
||||||
|
@PUT("api/me/listenbrainz")
|
||||||
|
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,3 +72,15 @@ data class ChangePasswordRequest(
|
|||||||
@SerialName("current_password") val currentPassword: String,
|
@SerialName("current_password") val currentPassword: String,
|
||||||
@SerialName("new_password") val newPassword: String,
|
@SerialName("new_password") val newPassword: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
|
||||||
|
* is set; the server treats other fields as untouched. Mirrors
|
||||||
|
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
|
||||||
|
* over a single endpoint shape.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ListenBrainzPutBody(
|
||||||
|
val token: String? = null,
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller's ListenBrainz integration state. Mirrors
|
||||||
|
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
|
||||||
|
* and the server's `listenBrainzResp`.
|
||||||
|
*
|
||||||
|
* The token itself is never read back from the server — `tokenSet`
|
||||||
|
* is the visible signal. Empty `lastScrobbledAt` means no scrobble
|
||||||
|
* has happened (either never enabled, or first play hasn't ended yet).
|
||||||
|
*/
|
||||||
|
data class ListenBrainzStatus(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val tokenSet: Boolean,
|
||||||
|
val lastScrobbledAt: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape for `GET /api/me/listenbrainz` and the responses from
|
||||||
|
* the two PUT variants. `token` is write-only — the server never
|
||||||
|
* sends it back, so the field doesn't appear here.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ListenBrainzStatusWire(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
@SerialName("token_set") val tokenSet: Boolean = false,
|
||||||
|
@SerialName("last_scrobbled_at") val lastScrobbledAt: String? = null,
|
||||||
|
)
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.fabledsword.minstrel.settings.data
|
package com.fabledsword.minstrel.settings.data
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.ChangePasswordRequest
|
import com.fabledsword.minstrel.api.endpoints.ChangePasswordRequest
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ListenBrainzPutBody
|
||||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.UpdateProfileRequest
|
import com.fabledsword.minstrel.api.endpoints.UpdateProfileRequest
|
||||||
|
import com.fabledsword.minstrel.models.ListenBrainzStatus
|
||||||
import com.fabledsword.minstrel.models.MyProfile
|
import com.fabledsword.minstrel.models.MyProfile
|
||||||
|
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -32,6 +35,15 @@ class MeRepository @Inject constructor(retrofit: Retrofit) {
|
|||||||
ChangePasswordRequest(currentPassword = current, newPassword = next),
|
ChangePasswordRequest(currentPassword = current, newPassword = next),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getListenBrainz(): ListenBrainzStatus =
|
||||||
|
api.getListenBrainz().toDomain()
|
||||||
|
|
||||||
|
suspend fun setListenBrainzToken(token: String): ListenBrainzStatus =
|
||||||
|
api.setListenBrainz(ListenBrainzPutBody(token = token)).toDomain()
|
||||||
|
|
||||||
|
suspend fun setListenBrainzEnabled(enabled: Boolean): ListenBrainzStatus =
|
||||||
|
api.setListenBrainz(ListenBrainzPutBody(enabled = enabled)).toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MyProfileWire.toDomain(): MyProfile = MyProfile(
|
private fun MyProfileWire.toDomain(): MyProfile = MyProfile(
|
||||||
@@ -41,3 +53,9 @@ private fun MyProfileWire.toDomain(): MyProfile = MyProfile(
|
|||||||
email = email,
|
email = email,
|
||||||
isAdmin = isAdmin,
|
isAdmin = isAdmin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun ListenBrainzStatusWire.toDomain(): ListenBrainzStatus = ListenBrainzStatus(
|
||||||
|
enabled = enabled,
|
||||||
|
tokenSet = tokenSet,
|
||||||
|
lastScrobbledAt = lastScrobbledAt,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
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.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.fabledsword.minstrel.models.ListenBrainzStatus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "ListenBrainz",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Send your plays to ListenBrainz so you can keep your " +
|
||||||
|
"scrobble history portable. Get a token at " +
|
||||||
|
"listenbrainz.org/profile — it's stored encrypted on " +
|
||||||
|
"the server and never read back.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (state.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
} else {
|
||||||
|
ListenBrainzForm(
|
||||||
|
status = state.status,
|
||||||
|
tokenInput = state.tokenInput,
|
||||||
|
isSaving = state.isSaving,
|
||||||
|
message = state.message,
|
||||||
|
onTokenChange = viewModel::setTokenInput,
|
||||||
|
onSaveToken = viewModel::saveToken,
|
||||||
|
onSetEnabled = viewModel::setEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListenBrainzForm(
|
||||||
|
status: ListenBrainzStatus?,
|
||||||
|
tokenInput: String,
|
||||||
|
isSaving: Boolean,
|
||||||
|
message: String?,
|
||||||
|
onTokenChange: (String) -> Unit,
|
||||||
|
onSaveToken: () -> Unit,
|
||||||
|
onSetEnabled: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tokenInput,
|
||||||
|
onValueChange = onTokenChange,
|
||||||
|
label = {
|
||||||
|
Text(if (status?.tokenSet == true) "Replace token" else "Token")
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(if (status?.tokenSet == true) "Token saved" else "")
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onSaveToken,
|
||||||
|
enabled = !isSaving && tokenInput.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
Text(if (isSaving) "Saving…" else "Save token")
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Send my plays to ListenBrainz",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
if (status?.lastScrobbledAt != null) {
|
||||||
|
Text(
|
||||||
|
text = "Last scrobble: ${status.lastScrobbledAt}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = status?.enabled == true,
|
||||||
|
onCheckedChange = onSetEnabled,
|
||||||
|
enabled = !isSaving && status?.tokenSet == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (message != null) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.models.ListenBrainzStatus
|
||||||
|
import com.fabledsword.minstrel.settings.data.MeRepository
|
||||||
|
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 javax.inject.Inject
|
||||||
|
|
||||||
|
data class ListenBrainzUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val status: ListenBrainzStatus? = null,
|
||||||
|
val tokenInput: String = "",
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs the ListenBrainz card in Settings. Loads the caller's state
|
||||||
|
* on init, then handles token-save and enabled-toggle. The token is
|
||||||
|
* never read back from the server — the UI shows "token saved" /
|
||||||
|
* "no token" based on [ListenBrainzStatus.tokenSet] without exposing
|
||||||
|
* the value. Submit clears [tokenInput] on success so the field
|
||||||
|
* acts as a write-only entry.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ListenBrainzViewModel @Inject constructor(
|
||||||
|
private val repository: MeRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow(ListenBrainzUiState())
|
||||||
|
val state: StateFlow<ListenBrainzUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTokenInput(value: String) {
|
||||||
|
internal.update { it.copy(tokenInput = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.update { it.copy(isLoading = true, message = null) }
|
||||||
|
try {
|
||||||
|
val s = repository.getListenBrainz()
|
||||||
|
internal.update { it.copy(isLoading = false, status = s) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Couldn't load: ${e.message ?: "unknown error"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveToken() {
|
||||||
|
val token = internal.value.tokenInput.trim()
|
||||||
|
if (token.isEmpty() || internal.value.isSaving) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.update { it.copy(isSaving = true, message = null) }
|
||||||
|
try {
|
||||||
|
val s = repository.setListenBrainzToken(token)
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
isSaving = false,
|
||||||
|
status = s,
|
||||||
|
tokenInput = "",
|
||||||
|
message = "Token saved.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
isSaving = false,
|
||||||
|
message = "Couldn't save: ${e.message ?: "unknown error"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(enabled: Boolean) {
|
||||||
|
if (internal.value.isSaving) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.update { it.copy(isSaving = true, message = null) }
|
||||||
|
try {
|
||||||
|
val s = repository.setListenBrainzEnabled(enabled)
|
||||||
|
internal.update { it.copy(isSaving = false, status = s) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
isSaving = false,
|
||||||
|
message = "Couldn't update: ${e.message ?: "unknown error"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
ProfileCard()
|
ProfileCard()
|
||||||
PasswordCard()
|
PasswordCard()
|
||||||
|
ListenBrainzCard()
|
||||||
AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode)
|
AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
|
|||||||
Reference in New Issue
Block a user