diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index 97feb54e4..29cb3b531 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -501,6 +501,12 @@ fun SettingScreen( } } } + var showLastFmDialog by rememberSaveable { mutableStateOf(false) } + val lastFmUsername by viewModel.lastFmUsername.collectAsStateWithLifecycle() + val lastFmApiKey by viewModel.lastFmApiKey.collectAsStateWithLifecycle() + val lastFmSecret by viewModel.lastFmSecret.collectAsStateWithLifecycle() + val lastFmSessionKey by viewModel.lastFmSessionKey.collectAsStateWithLifecycle() + val lastFmEnabled by viewModel.lastFmEnabled.collectAsStateWithLifecycle() var showYouTubeAccountDialog by rememberSaveable { mutableStateOf(false) } @@ -635,6 +641,13 @@ fun SettingScreen( showYouTubeAccountDialog = true }, ) + val isLastFmAuth by viewModel.isLastFmAuthenticating.collectAsStateWithLifecycle() + SettingItem( + title = "Last.fm Scrobbling", + subtitle = if (lastFmUsername.isNotBlank()) "Connected as $lastFmUsername" else if (isLastFmAuth) "Waiting for browser approval..." else "Tap to log in with your Last.fm account", + switch = if (lastFmUsername.isNotBlank()) (lastFmEnabled to { viewModel.setLastFmEnabled(it) }) else null, + onClick = { showLastFmDialog = true }, + ) SettingItem( title = stringResource(Res.string.language), subtitle = SUPPORTED_LANGUAGE.getLanguageFromCode(language ?: "en-US"), @@ -2339,6 +2352,89 @@ fun SettingScreen( }, ) } + if (showLastFmDialog) { + var inputUser by rememberSaveable { mutableStateOf(lastFmUsername) } + var inputKey by rememberSaveable { mutableStateOf(lastFmApiKey) } + var inputSecret by rememberSaveable { mutableStateOf(lastFmSecret) } + var inputSessionKey by rememberSaveable { mutableStateOf(lastFmSessionKey) } + val isLastFmAuth by viewModel.isLastFmAuthenticating.collectAsStateWithLifecycle() + val uriHandler = LocalUriHandler.current + + AlertDialog( + onDismissRequest = { showLastFmDialog = false }, + title = { Text(text = "Last.fm Account Integration", style = typo().titleMedium) }, + text = { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text( + text = "Enter your Last.fm Username, API Key, and Secret from last.fm/api/account/create:", + style = typo().bodySmall, + modifier = Modifier.padding(bottom = 12.dp) + ) + androidx.compose.material3.OutlinedTextField( + value = inputUser, + onValueChange = { inputUser = it }, + label = { Text("Last.fm Username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + androidx.compose.material3.OutlinedTextField( + value = inputKey, + onValueChange = { inputKey = it }, + label = { Text("Last.fm API Key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + androidx.compose.material3.OutlinedTextField( + value = inputSecret, + onValueChange = { inputSecret = it }, + label = { Text("Last.fm Shared Secret") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + + if (lastFmSessionKey.isNotBlank()) { + Text( + text = "Authenticated Session Key Active ✅", + style = typo().bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + viewModel.loginLastFmCustom(inputUser, inputKey, inputSecret) { url -> + try { + uriHandler.openUri(url) + } catch (e: Exception) { + try { + val os = System.getProperty("os.name").lowercase() + if (os.contains("win")) { + Runtime.getRuntime().exec(arrayOf("cmd.exe", "/c", "start", url)) + } else if (os.contains("mac")) { + Runtime.getRuntime().exec(arrayOf("open", url)) + } else { + Runtime.getRuntime().exec(arrayOf("xdg-open", url)) + } + } catch (_: Exception) {} + } + } + }, + enabled = !isLastFmAuth + ) { + Text(if (isLastFmAuth) "Waiting for browser approval..." else "Save & Authenticate") + } + }, + dismissButton = { + TextButton(onClick = { showLastFmDialog = false }) { + Text("Cancel") + } + } + ) + } + if (showYouTubeAccountDialog) { BasicAlertDialog( onDismissRequest = { }, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt index b382a58aa..ec7f90472 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt @@ -180,6 +180,21 @@ class SettingsViewModel( private val _localTrackingEnabled = MutableStateFlow(false) val localTrackingEnabled: StateFlow = _localTrackingEnabled + private val _lastFmEnabled = MutableStateFlow(false) + val lastFmEnabled: StateFlow = _lastFmEnabled + + private val _lastFmUsername = MutableStateFlow("") + val lastFmUsername: StateFlow = _lastFmUsername + + private val _lastFmApiKey = MutableStateFlow("") + val lastFmApiKey: StateFlow = _lastFmApiKey + + private val _lastFmSecret = MutableStateFlow("") + val lastFmSecret: StateFlow = _lastFmSecret + + private val _lastFmSessionKey = MutableStateFlow("") + val lastFmSessionKey: StateFlow = _lastFmSessionKey + private val _blogNotificationEnabled = MutableStateFlow(true) val blogNotificationEnabled: StateFlow = _blogNotificationEnabled @@ -279,6 +294,8 @@ class SettingsViewModel( getDownloadQuality() getVideoDownloadQuality() getLocalTrackingEnabled() + getLastFmEnabled() + getLastFmAccount() getBlogNotificationEnabled() getAutoBackupEnabled() getAutoBackupFrequency() @@ -305,6 +322,117 @@ class SettingsViewModel( viewModelScope.launch { dataStoreManager.setLocalTrackingEnabled(enabled) getLocalTrackingEnabled() + getLastFmEnabled() + getLastFmAccount() + } + } + + private fun getLastFmEnabled() { + viewModelScope.launch { + dataStoreManager.lastFmEnabled.collect { enabled -> + _lastFmEnabled.value = enabled == DataStoreManager.TRUE + } + } + } + + fun setLastFmEnabled(enabled: Boolean) { + viewModelScope.launch { + dataStoreManager.setLastFmEnabled(enabled) + getLastFmEnabled() + getLastFmAccount() + } + } + + private fun getLastFmAccount() { + viewModelScope.launch { + dataStoreManager.lastFmUsername.collect { _lastFmUsername.value = it } + } + viewModelScope.launch { + dataStoreManager.lastFmApiKey.collect { _lastFmApiKey.value = it } + } + viewModelScope.launch { + dataStoreManager.lastFmSecret.collect { _lastFmSecret.value = it } + } + viewModelScope.launch { + dataStoreManager.lastFmSessionKey.collect { _lastFmSessionKey.value = it } + } + } + + private val _isLastFmAuthenticating = MutableStateFlow(false) + val isLastFmAuthenticating: StateFlow = _isLastFmAuthenticating + + fun loginLastFm(openUrl: (String) -> Unit) { + viewModelScope.launch { + _isLastFmAuthenticating.value = true + val token = com.maxrave.data.scrobbler.LastFmScrobbler.fetchAuthToken() + if (token != null) { + openUrl("https://www.last.fm/api/auth/?api_key=${com.maxrave.data.scrobbler.LastFmScrobbler.DEFAULT_API_KEY}&token=$token") + for (i in 1..30) { + kotlinx.coroutines.delay(2000) + val result = com.maxrave.data.scrobbler.LastFmScrobbler.fetchSession(token) + if (result != null) { + val (username, sessionKey) = result + dataStoreManager.setLastFmUsername(username) + dataStoreManager.setLastFmSessionKey(sessionKey) + dataStoreManager.setLastFmApiKey(com.maxrave.data.scrobbler.LastFmScrobbler.DEFAULT_API_KEY) + dataStoreManager.setLastFmSecret(com.maxrave.data.scrobbler.LastFmScrobbler.DEFAULT_API_SECRET) + dataStoreManager.setLastFmEnabled(true) + getLastFmAccount() + getLastFmEnabled() + break + } + } + } + _isLastFmAuthenticating.value = false + } + } + + fun loginLastFmCustom(username: String, apiKey: String, secret: String, openUrl: (String) -> Unit) { + viewModelScope.launch { + _isLastFmAuthenticating.value = true + val effectiveKey = apiKey.ifBlank { com.maxrave.data.scrobbler.LastFmScrobbler.DEFAULT_API_KEY } + val effectiveSecret = secret.ifBlank { com.maxrave.data.scrobbler.LastFmScrobbler.DEFAULT_API_SECRET } + val token = com.maxrave.data.scrobbler.LastFmScrobbler.fetchAuthToken(effectiveKey) + if (token != null) { + openUrl("https://www.last.fm/api/auth/?api_key=${effectiveKey}&token=$token") + for (i in 1..30) { + kotlinx.coroutines.delay(2000) + val result = com.maxrave.data.scrobbler.LastFmScrobbler.fetchSession(token, effectiveKey, effectiveSecret) + if (result != null) { + val (fetchedUser, sessionKey) = result + val finalUser = fetchedUser.ifBlank { username } + dataStoreManager.setLastFmUsername(finalUser) + dataStoreManager.setLastFmSessionKey(sessionKey) + dataStoreManager.setLastFmApiKey(effectiveKey) + dataStoreManager.setLastFmSecret(effectiveSecret) + dataStoreManager.setLastFmEnabled(true) + getLastFmAccount() + getLastFmEnabled() + break + } + } + } + _isLastFmAuthenticating.value = false + } + } + + fun logoutLastFm() { + viewModelScope.launch { + dataStoreManager.setLastFmUsername("") + dataStoreManager.setLastFmSessionKey("") + dataStoreManager.setLastFmEnabled(false) + getLastFmAccount() + getLastFmEnabled() + } + } + + fun saveLastFmCredentials(username: String, apiKey: String, secret: String, sessionKey: String) { + viewModelScope.launch { + dataStoreManager.setLastFmUsername(username) + dataStoreManager.setLastFmApiKey(apiKey) + dataStoreManager.setLastFmSecret(secret) + dataStoreManager.setLastFmSessionKey(sessionKey) + getLastFmAccount() } } diff --git a/core b/core index ccaa2d11c..99708a96a 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit ccaa2d11c358d817bc99634153f189213bed3496 +Subproject commit 99708a96a36f2dc97d88292d647630a07ef75a00