diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt index 4734a6f8..55ab3202 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -25,6 +26,7 @@ import androidx.activity.enableEdgeToEdge import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.paths.Paths +import dev.krtirtho.spotube.media.PlaybackService import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init @@ -33,7 +35,16 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) FileKit.init(this) - NewPipeDownloader.init(Paths(this)) + NewPipeDownloader.init(Paths()) + + // Start PlaybackService from Activity context (allowed on Android 12+) + val serviceIntent = Intent(this, PlaybackService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + intent?.dataString?.let(ExternalUriHandler::onNewUri) setContent { App() diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt index 306e4433..d93242aa 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt @@ -18,10 +18,8 @@ package dev.krtirtho.spotube import android.app.Application -import android.content.Intent -import android.os.Build import dev.krtirtho.spotube.core.di.initKoin -import dev.krtirtho.spotube.media.PlaybackService +import dev.krtirtho.spotube.core.paths.Paths import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent { override fun onCreate() { super.onCreate() + Paths.init(this) initKoin { androidContext(this@MyApplication) } - val intent = Intent(this, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(intent) - } else { - startService(intent) - } } override fun onTerminate() { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt index 1ee43348..be29dba7 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt @@ -29,7 +29,7 @@ import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscove import org.koin.dsl.module actual val platformModules = module { - single { Paths(get()) } + single { Paths() } single { AudioPlayer(get()) } single { AndroidLocalMediaDiscoveryService(get()) } single { AndroidShareService(get()) } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt new file mode 100644 index 00000000..961a6b7c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.discovery + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +/** + * On Android 13+ (and especially 16+ where it became a runtime permission), + * mDNS/NSD discovery requires `NEARBY_WIFI_DEVICES`. Requests it when the + * returned lambda is invoked; the caller decides when (e.g. first visit to the + * Devices screen). + */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* result is picked up by discovery/advertising retry loops */ } + + return { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt index e8f172ce..20c187a0 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt @@ -20,9 +20,10 @@ package dev.krtirtho.spotube.core.paths import android.content.Context import android.os.Environment -actual class Paths( - val context: Context -) { +actual class Paths { + private val context: Context + get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" } + actual fun getApplicationCacheDirPath(): String { return context.cacheDir.absolutePath } @@ -38,4 +39,13 @@ actual class Paths( actual fun getMusicCacheDirPath(): String { return context.cacheDir.absolutePath + "/music_cache" } + + companion object { + @Volatile + private var appContext: Context? = null + + fun init(context: Context) { + appContext = context.applicationContext + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index e898e202..a2b6fe98 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -28,8 +28,10 @@ import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.navigation.navigationModule +import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.core.remote.RemoteControlService +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager @@ -44,6 +46,7 @@ import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel import dev.krtirtho.spotube.modules.devices.DevicesViewModel +import dev.krtirtho.spotube.modules.devices.RemoteControlViewModel import dev.krtirtho.spotube.modules.jam.JamViewModel import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel @@ -140,6 +143,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -175,7 +179,8 @@ val sharedModules = module { // Blacklist singleOf(::BlacklistRepository) viewModelOf(::BlacklistViewModel) - viewModelOf(::DevicesViewModel) + viewModel { DevicesViewModel(get()) } + viewModelOf(::RemoteControlViewModel) viewModelOf(::JamViewModel) // Album @@ -215,10 +220,12 @@ val sharedModules = module { createdAtStart() } single { RemoteControlHandler(get(), get(), get()) } + single { RemoteControlClient() } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } + single { RemotePlaybackController() } single { JamSessionService(get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt index 1c6b0a22..04221d6a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -88,6 +88,7 @@ class DeviceDiscoveryService { name: String, port: Int, deviceId: String, + registerTimeoutMs: Long = 5_000, ): NetService { val service = createNetService( type = SERVICE_TYPE, @@ -95,7 +96,7 @@ class DeviceDiscoveryService { port = port, txt = mapOf(TXT_DEVICE_ID to deviceId), ) - service.register() + service.register(timeoutInMs = registerTimeoutMs) return service } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt new file mode 100644 index 00000000..404c2b0d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** + * Returns a lambda that requests the OS permission needed for local network + * discovery (mDNS/NSD). No-op on platforms where such a permission doesn't + * exist or is granted implicitly. + */ +@Composable +expect fun rememberLocalNetworkPermissionRequester(): () -> Unit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index 79f26337..bfea9743 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.modules.album.AlbumScreen import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen import dev.krtirtho.spotube.modules.devices.DevicesScreen +import dev.krtirtho.spotube.modules.devices.RemoteControlScreen import dev.krtirtho.spotube.modules.jam.JamScreen import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.library.LibraryScreen @@ -78,6 +79,9 @@ sealed interface Routes : NavKey { @Serializable data object Blacklist : Routes + @Serializable + data object RemoteControl : Routes + @Serializable data object Devices : Routes @@ -159,6 +163,11 @@ val navigationModule = module { navigation { DevicesScreen(navigationCommands = get()) } + navigation { + RemoteControlScreen( + onDisconnect = { get().pop() } + ) + } navigation { JamScreen(navigationCommands = get()) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt new file mode 100644 index 00000000..e6d268f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.remote + +import co.touchlab.kermit.Logger +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** + * WebSocket client for controlling a remote Spotube instance. + * Connects to the remote device's `/control` endpoint and sends commands. + */ +class RemoteControlClient { + private val logger = Logger.withTag("RemoteControlClient") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val httpClient = HttpClient { + install(WebSockets) + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 30_000 + } + } + + private var session: WebSocketSession? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _stateUpdates = MutableSharedFlow(extraBufferCapacity = 32) + val stateUpdates: SharedFlow = _stateUpdates.asSharedFlow() + + suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { + if (_connectionState.value is ConnectionState.Connected) { + logger.w { "Already connected" } + return + } + + _connectionState.value = ConnectionState.Connecting + try { + session = httpClient.webSocketSession { + url("ws://$host:$port/control") + header("X-Device-Id", deviceId) + header("X-Device-Name", deviceName) + } + + logger.i { "WebSocket connected to $host:$port, waiting for authorization..." } + + // Start receiving messages in a separate coroutine + scope.launch { + receiveLoop(host, port) + } + } catch (e: Exception) { + logger.e(e) { "Failed to connect to $host:$port" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection failed") + disconnect() + } + } + + private suspend fun receiveLoop(host: String, port: Int) { + val currentSession = session ?: return + try { + for (frame in currentSession.incoming) { + when (frame) { + is Frame.Text -> { + val text = frame.readText() + try { + val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + when (event) { + is RemoteControlEvent.Connected -> { + logger.i { "Connection authorized by server" } + _connectionState.value = ConnectionState.Connected(host, port) + } + is RemoteControlEvent.WaitingForPermission -> { + logger.i { "Waiting for permission: ${event.message}" } + // Keep showing connecting state + } + else -> { + // Only emit state updates after connection is established + if (_connectionState.value is ConnectionState.Connected) { + _stateUpdates.emit(event) + } + } + } + } catch (e: Exception) { + logger.w(e) { "Failed to parse message: $text" } + } + } + is Frame.Close -> { + logger.i { "WebSocket closed by server" } + _connectionState.value = ConnectionState.Disconnected + break + } + else -> {} + } + } + } catch (e: Exception) { + logger.e(e) { "Error in receive loop" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection lost") + } + } + + suspend fun sendCommand(command: RemoteControlCommand) { + val currentSession = session ?: run { + logger.w { "Not connected" } + return + } + + val envelope = CommandEnvelope( + commandId = randomShortId(), + command = command, + ) + + try { + val text = json.encodeToString(CommandEnvelope.serializer(), envelope) + currentSession.send(Frame.Text(text)) + logger.d { "Sent command: $command" } + } catch (e: Exception) { + logger.e(e) { "Failed to send command" } + _connectionState.value = ConnectionState.Error(e.message ?: "Send failed") + } + } + + suspend fun disconnect() { + session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) + session = null + _connectionState.value = ConnectionState.Disconnected + logger.i { "Disconnected" } + } + + private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } + } +} + +sealed interface ConnectionState { + data object Disconnected : ConnectionState + data object Connecting : ConnectionState + data class Connected(val host: String, val port: Int) : ConnectionState + data class Error(val message: String) : ConnectionState +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 15f24636..f192855a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -70,6 +70,12 @@ class RemoteControlHandler( val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices if (!isAllowed) { + // Send waiting for permission message + val waitingMessage = RemoteControlEvent.WaitingForPermission( + "Waiting for permission from $deviceName..." + ) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage))) + val request = ConnectionRequest( deviceId = deviceId ?: "unknown", deviceName = deviceName, @@ -92,6 +98,8 @@ class RemoteControlHandler( } } + // Send connected message + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected))) logger.i { "Remote control connection established from $deviceName ($deviceId)" } try { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index b9cacd5c..5e63d305 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -69,6 +69,14 @@ sealed class RemoteControlCommand { @Serializable sealed class RemoteControlEvent { + @Serializable + @SerialName("connected") + data object Connected : RemoteControlEvent() + + @Serializable + @SerialName("waitingForPermission") + data class WaitingForPermission(val message: String) : RemoteControlEvent() + @Serializable @SerialName("playerState") data class PlayerState( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt index 0e342da4..207e2f8b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -24,10 +24,18 @@ import dev.krtirtho.spotube.core.server.LocalServer import dev.krtirtho.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.random.Random @@ -36,6 +44,10 @@ import kotlin.random.Random * instances can discover and control it. Advertises only while the * "Allow remote control" setting is enabled and the local playback server is * listening on the LAN (0.0.0.0). + * + * Registration is retried with backoff: NsdManager is flaky right after a cold + * start, and a single registration attempt is bounded by a short timeout so a + * stalled platform callback can't wedge a dispatcher thread for long. */ class RemoteControlService( private val settingsRepository: SettingsRepository, @@ -45,9 +57,18 @@ class RemoteControlService( private val log = Logger.withTag("RemoteControlService") private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val _localDeviceId = MutableStateFlow("") + val localDeviceId: StateFlow = _localDeviceId.asStateFlow() + private var advertisedService: NetService? = null + private var registerJob: Job? = null init { + // Ensure a stable device id exists and is persisted up front, so discovery + // can reliably filter out this device's own advertisement. + scope.launch { + _localDeviceId.value = resolveDeviceId() + } scope.launch { combine( settingsRepository.userSettings, @@ -56,32 +77,74 @@ class RemoteControlService( .distinctUntilChanged() .collect { (settings, port) -> if (settings.allowRemoteControl && port != null) { - ensureAdvertised(settings.remoteControlDeviceName, port) + if (registerJob?.isActive != true) { + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } } else { + registerJob?.cancel() + registerJob = null stopAdvertising() } } } } - private suspend fun ensureAdvertised(name: String, port: Int) { + /** + * The service name this device advertises under, derived deterministically + * from settings so discovery can match it against the local advertisement. + */ + fun advertisedName(): String { + val deviceId = _localDeviceId.value.ifBlank { + settingsRepository.userSettings.value.remoteControlDeviceId + } + val configured = settingsRepository.userSettings.value.remoteControlDeviceName + return configured.ifBlank { "Spotube-${deviceId.take(6)}" } + } + + /** + * Kicks off (or restarts) the advertising loop. Used when the local-network + * permission is granted at runtime after earlier attempts failed. + */ + fun retryAdvertising() { + val settings = settingsRepository.userSettings.value + val port = localServer.port.value + if (!settings.allowRemoteControl || port == null) return + registerJob?.cancel() + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + + private suspend fun registerLoop(name: String, port: Int) { val deviceId = resolveDeviceId() + _localDeviceId.value = deviceId val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } - if (advertisedService == null) { + + var attempt = 0 + while (advertisedService == null && coroutineContext.isActive) { + attempt++ + // The user may have toggled the setting off during backoff. + if (!settingsRepository.userSettings.value.allowRemoteControl) return try { advertisedService = discoveryService.advertise( name = serviceName, port = port, deviceId = deviceId, + registerTimeoutMs = REGISTER_TIMEOUT_MS, ) - log.i { "Advertising remote control service '$serviceName' on port $port" } + log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } } catch (e: Exception) { - log.w(e) { "Failed to advertise remote control service" } + log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + delay(retryDelayMs(attempt)) } } } private suspend fun stopAdvertising() { + registerJob?.cancel() + registerJob = null if (advertisedService != null) { runCatching { advertisedService?.unregister() } advertisedService = null @@ -101,4 +164,14 @@ class RemoteControlService( settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) return generated } + + private fun retryDelayMs(attempt: Int): Long = when { + attempt >= 6 -> 5 * 60_000L + attempt >= 3 -> 30_000L + else -> 5_000L + } + + companion object { + private const val REGISTER_TIMEOUT_MS = 4_000L + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt new file mode 100644 index 00000000..39b4fdae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.remote + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Manages the play destination picker state and remote playback commands. + * Injected into ViewModels to handle playback actions when a remote device is connected. + */ +class RemotePlaybackController : KoinComponent { + private val logger = Logger.withTag("RemotePlaybackController") + private val remoteControlClient: RemoteControlClient by inject() + + private val _showPicker = MutableStateFlow(false) + val showPicker: StateFlow = _showPicker.asStateFlow() + + private var pendingAction: (() -> Unit)? = null + + /** + * Checks if a remote device is connected. + */ + fun isRemoteConnected(): Boolean { + return remoteControlClient.connectionState.value is ConnectionState.Connected + } + + /** + * Wraps a playback action. If a remote device is connected, shows the picker. + * Otherwise, executes the action immediately. + * + * @param action The action to execute if playing locally + */ + fun wrapPlaybackAction(action: () -> Unit) { + if (isRemoteConnected()) { + pendingAction = action + _showPicker.value = true + } else { + action() + } + } + + /** + * Called when the user chooses to play locally. + */ + fun playLocally() { + _showPicker.value = false + pendingAction?.invoke() + pendingAction = null + } + + /** + * Called when the user chooses to play on the remote device. + * Sends a play command to the remote device. + * + * @param source The source identifier (e.g., playlist ID, album ID, track ID) + */ + fun playOnRemote(source: String) { + _showPicker.value = false + pendingAction = null + + CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + remoteControlClient.sendCommand(RemoteControlCommand.Play(source)) + logger.i { "Sent play command for source: $source" } + } catch (e: Exception) { + logger.e(e) { "Failed to send play command" } + } + } + } + + /** + * Called when the user dismisses the picker. + */ + fun dismissPicker() { + _showPicker.value = false + pendingAction = null + } + + /** + * Sends an add-to-queue command to the remote device. + * + * @param source The source identifier (e.g., playlist ID, album ID, track ID) + */ + fun addToQueueOnRemote(source: String) { + CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source)) + logger.i { "Sent add-to-queue command for source: $source" } + } catch (e: Exception) { + logger.e(e) { "Failed to send add-to-queue command" } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt index c61f45c5..e23f9b02 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.modules.settings.SettingsViewModel import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application import io.ktor.server.application.install @@ -63,7 +64,14 @@ class LocalServer( ) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient() + private val httpClient = HttpClient { + // A stalled upstream connection must not wedge the CIO dispatcher thread + // forever. Only the connect phase is bounded — the proxy streams long + // audio bodies, so request/socket timeouts would cut playback short. + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + } + } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serverMutex = Mutex() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index d9b3bf28..b4b4ce9c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -28,9 +28,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,7 +44,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -57,10 +61,19 @@ fun DevicesScreen( val viewModel = koinViewModel() val devices by viewModel.devices.collectAsStateWithLifecycle() val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() + val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() DisposableEffect(Unit) { + // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. + requestLocalNetworkPermission() viewModel.startDiscovery() - onDispose { viewModel.stopDiscovery() } + onDispose { + viewModel.stopDiscovery() + viewModel.disconnect() + } } Scaffold( @@ -69,7 +82,7 @@ fun DevicesScreen( backButton = true, title = { Text("Devices") }, actions = { - if (isDiscovering) { + if (isDiscovering && connectingToDevice == null) { CircularProgressIndicator( modifier = Modifier .size(24.dp) @@ -82,7 +95,9 @@ fun DevicesScreen( contentDescription = "Refresh", modifier = Modifier .size(24.dp) - .clickable { viewModel.startDiscovery() }, + .clickable(enabled = connectingToDevice == null) { + viewModel.startDiscovery() + }, ) } }, @@ -91,53 +106,131 @@ fun DevicesScreen( ) { innerPadding -> val shellBottomInset = LocalAppShellBottomInset.current - if (devices.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = if (isDiscovering) { - "Searching for devices on the network..." - } else { - "No devices found" - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (!isDiscovering) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Error banner + error?.let { errorMessage -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { Text( - text = "Make sure the other device has \"Allow remote control\" enabled in settings.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, ) + OutlinedButton( + onClick = { viewModel.clearError() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Dismiss") + } } } } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 16.dp, - vertical = 8.dp, - ), - ) { - items(devices.values.toList(), key = { it.key }) { device -> - DeviceRow( - device = device, - onClick = { viewModel.connectToDevice(device) }, - ) + + // Connection status + when (val state = connectionState) { + is ConnectionState.Connected -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { + Text( + text = "Connected to ${state.host}:${state.port}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + OutlinedButton( + onClick = { viewModel.disconnect() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Disconnect") + } + } + } } - item { - Box(modifier = Modifier.padding(bottom = shellBottomInset)) + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Connecting...", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + else -> {} + } + + // Device list + if (devices.isEmpty() && connectingToDevice == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (isDiscovering) { + "Searching for devices on the network..." + } else { + "No devices found" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!isDiscovering) { + Text( + text = "Make sure the other device has \"Allow remote control\" enabled in settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + items(devices.values.toList(), key = { it.key }) { device -> + DeviceRow( + device = device, + isConnecting = connectingToDevice?.key == device.key, + onClick = { viewModel.connectToDevice(device) }, + ) + } + item { + Box(modifier = Modifier.padding(bottom = shellBottomInset)) + } } } } @@ -147,35 +240,56 @@ fun DevicesScreen( @Composable private fun DeviceRow( device: DiscoveredDevice, + isConnecting: Boolean, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clickable(onClick = onClick, enabled = !isConnecting) .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } Column(modifier = Modifier.weight(1f)) { Text( - text = device.name, + text = device.name.ifBlank { "Unknown Device" }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - text = "${device.host}:${device.port}", + text = if (device.host.isNotBlank() && device.port > 0) { + "${device.host}:${device.port}" + } else { + "Resolving..." + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (device.deviceId.isNotBlank()) { + Text( + text = "ID: ${device.deviceId.take(8)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt index 5c8809f6..435feaaf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -20,10 +20,15 @@ package dev.krtirtho.spotube.modules.devices import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger -import com.appstractive.dnssd.NetService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discovery.DiscoveredDevice import dev.krtirtho.spotube.core.discovery.DiscoveryState +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlService +import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,9 +38,14 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject -class DevicesViewModel : ViewModel(), KoinComponent { +class DevicesViewModel( + private val navigationCommands: NavigationCommands, +) : ViewModel(), KoinComponent { private val logger = Logger.withTag("DevicesViewModel") private val discoveryService: DeviceDiscoveryService by inject() + private val remoteControlClient: RemoteControlClient by inject() + private val remoteControlService: RemoteControlService by inject() + private val settingsProvider: SettingsProvider by inject() private val _devices = MutableStateFlow>(emptyMap()) val devices: StateFlow> = _devices.asStateFlow() @@ -43,30 +53,109 @@ class DevicesViewModel : ViewModel(), KoinComponent { private val _isDiscovering = MutableStateFlow(false) val isDiscovering: StateFlow = _isDiscovering.asStateFlow() + private val _connectingToDevice = MutableStateFlow(null) + val connectingToDevice: StateFlow = _connectingToDevice.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + private var discoveryJob: Job? = null - private var advertisedService: NetService? = null + + init { + // Observe connection state from the client + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + if (state is ConnectionState.Error) { + _error.value = state.message + _connectingToDevice.value = null + } else if (state is ConnectionState.Disconnected) { + _connectingToDevice.value = null + } else if (state is ConnectionState.Connected) { + // Navigate to remote control screen after successful connection + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + } + // Whenever the local device id is resolved, drop any of our own + // advertisements that may have been picked up before we knew our id. + viewModelScope.launch { + remoteControlService.localDeviceId.collect { id -> + if (id.isNotBlank()) { + removeSelf() + } + } + } + } fun startDiscovery() { if (discoveryJob?.isActive == true) return _isDiscovering.value = true + _error.value = null + logger.i { "Starting device discovery" } + // Advertising may have failed before the local-network permission was + // granted; give it another chance now that discovery is being used. + remoteControlService.retryAdvertising() discoveryJob = viewModelScope.launch { - discoveryService.discover().collect { event -> - when (event) { - is DiscoveryState.Discovered -> { - event.resolve() - _devices.update { it + (event.device.key to event.device.copy()) } - } - is DiscoveryState.Resolved -> { - _devices.update { it + (event.device.key to event.device) } - } - is DiscoveryState.Removed -> { - _devices.update { it - event.device.key } + try { + discoveryService.discover().collect { event -> + logger.d { "Discovery event: $event" } + when (event) { + is DiscoveryState.Discovered -> { + event.resolve() + if (!isSelf(event.device)) { + _devices.update { it + (event.device.key to event.device.copy()) } + } + } + is DiscoveryState.Resolved -> { + if (isSelf(event.device)) { + // Resolved now carries our deviceId in TXT; drop self. + _devices.update { it - event.device.key } + } else { + _devices.update { it + (event.device.key to event.device) } + } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } } } + } catch (e: Exception) { + logger.e(e) { "Discovery failed" } + _error.value = "Discovery failed: ${e.message}" + _isDiscovering.value = false } } } + /** + * True when [device] is this device's own advertisement. On the initial + * `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is + * empty), so we match by the name we advertise; once resolved we also have + * the authoritative deviceId. + */ + private fun isSelf(device: DiscoveredDevice): Boolean { + val localId = remoteControlService.localDeviceId.value.ifBlank { + settingsProvider.settingsState.value?.remoteControlDeviceId ?: "" + } + if (localId.isNotBlank() && device.deviceId == localId) return true + // Match by the deterministic advertised name as a fallback for the + // pre-resolution event where deviceId isn't available yet. + return device.name.isNotBlank() && device.name == remoteControlService.advertisedName() + } + + private fun removeSelf() { + val localId = remoteControlService.localDeviceId.value + if (localId.isBlank()) return + _devices.update { map -> + map.filterNot { (_, device) -> device.deviceId == localId } + } + } + fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null @@ -74,6 +163,51 @@ class DevicesViewModel : ViewModel(), KoinComponent { } fun connectToDevice(device: DiscoveredDevice) { + if (_connectingToDevice.value != null) { + logger.w { "Already connecting to a device" } + return + } + if (isSelf(device)) { + logger.w { "Refusing to connect to self: ${device.name}" } + return + } + + _connectingToDevice.value = device + _error.value = null logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + + viewModelScope.launch { + try { + val settings = settingsProvider.settingsState.value + val deviceId = settings?.remoteControlDeviceId ?: "" + val deviceName = settings?.remoteControlDeviceName?.ifBlank { "Spotube Controller" } + ?: "Spotube Controller" + + remoteControlClient.connect( + host = device.host, + port = device.port, + deviceId = deviceId, + deviceName = deviceName, + ) + + // Clear connecting state after connection attempt + // The connectionState flow will show the actual connection status + _connectingToDevice.value = null + } catch (e: Exception) { + logger.e(e) { "Failed to connect to device" } + _error.value = "Failed to connect: ${e.message}" + _connectingToDevice.value = null + } + } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } + + fun clearError() { + _error.value = null } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt new file mode 100644 index 00000000..3c412260 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import org.koin.compose.koinInject + +/** + * Dialog shown when a remote device is connected and the user tries to play/add to queue. + * Allows the user to choose between playing on the local device or the remote device. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onPlayOnRemote: () -> Unit, +) { + val remoteControlClient = koinInject() + val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + + if (!visible) return + + val remoteDeviceName = when (val state = connectionState) { + is ConnectionState.Connected -> "Remote Device (${state.host})" + else -> "Remote Device" + } + + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Play Where?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Choose where to play this content:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + TextButton(onClick = onPlayLocally) { + Text("This Device") + } + TextButton(onClick = onPlayOnRemote) { + Text(remoteDeviceName) + } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt new file mode 100644 index 00000000..e5f1e7f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.compose.foundation.background +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious +import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle +import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh +import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RemoteControlScreen( + onDisconnect: () -> Unit, +) { + val viewModel = koinViewModel() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + GhostIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + } + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect", + ) + } + } + ) + } + ) { padding -> + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(padding) + ) + } + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } + } + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } + } + } + } +} + +@Composable +private fun RemoteControlContent( + playerState: RemotePlayerState, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onSetVolume: (Float) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoopMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Album art + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + ) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = playerState.currentTrackTitle ?: "Unknown Track", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = playerState.currentTrackArtists ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (playerState.currentTrackAlbum != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playerState.currentTrackAlbum!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Seek bar + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Slider( + value = playerState.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(playerState.positionMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(playerState.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // Shuffle + IconButton( + onClick = onToggleShuffle, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Skip previous + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = "Previous", + modifier = Modifier.size(32.dp), + ) + } + + // Play/Pause + IconButton( + onClick = onTogglePlayPause, + modifier = Modifier + .size(72.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(40.dp), + ) + } + + // Skip next + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = "Next", + modifier = Modifier.size(32.dp), + ) + } + + // Loop mode + IconButton( + onClick = onCycleLoopMode, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Volume control + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxVolumeHigh, + contentDescription = "Volume", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + + Slider( + value = playerState.volume, + onValueChange = onSetVolume, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +private fun formatDuration(ms: Long): String { + val duration = ms.milliseconds + val minutes = duration.inWholeMinutes + val seconds = duration.inWholeSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt new file mode 100644 index 00000000..65983a86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlCommand +import dev.krtirtho.spotube.core.remote.RemoteControlEvent +import kotlinx.coroutines.Job +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 org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +data class RemotePlayerState( + val isPlaying: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val volume: Float = 1.0f, + val shuffleEnabled: Boolean = false, + val loopMode: String = "none", + val currentTrackId: String? = null, + val currentTrackTitle: String? = null, + val currentTrackArtists: String? = null, + val currentTrackAlbum: String? = null, + val currentTrackCoverUrl: String? = null, +) + +class RemoteControlViewModel : ViewModel(), KoinComponent { + private val logger = Logger.withTag("RemoteControlViewModel") + private val remoteControlClient: RemoteControlClient by inject() + + private val _playerState = MutableStateFlow(RemotePlayerState()) + val playerState: StateFlow = _playerState.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private var stateUpdateJob: Job? = null + + init { + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + } + } + + viewModelScope.launch { + remoteControlClient.stateUpdates.collect { event -> + handleStateUpdate(event) + } + } + } + + private fun handleStateUpdate(event: RemoteControlEvent) { + when (event) { + is RemoteControlEvent.Connected -> { + // Connection already handled in RemoteControlClient + logger.d { "Connection confirmed" } + } + is RemoteControlEvent.WaitingForPermission -> { + // Waiting for permission - no action needed + logger.d { "Waiting for permission: ${event.message}" } + } + is RemoteControlEvent.PlayerState -> { + _playerState.update { + it.copy( + isPlaying = event.isPlaying, + positionMs = event.positionMs, + durationMs = event.durationMs, + volume = event.volume, + shuffleEnabled = event.shuffleEnabled, + loopMode = event.loopMode, + currentTrackId = event.currentTrackId, + currentTrackTitle = event.currentTrackTitle, + currentTrackArtists = event.currentTrackArtists, + currentTrackAlbum = event.currentTrackAlbum, + currentTrackCoverUrl = event.currentTrackCoverUrl, + ) + } + } + is RemoteControlEvent.QueueUpdated -> { + // TODO: Handle queue updates if needed + logger.d { "Queue updated: ${event.entries.size} entries" } + } + is RemoteControlEvent.Ack -> { + logger.d { "Command acknowledged: ${event.commandId}" } + } + is RemoteControlEvent.Error -> { + logger.e { "Remote error: ${event.message}" } + } + } + } + + fun togglePlayPause() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) + } + } + + fun skipNext() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipNext) + } + } + + fun skipPrevious() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious) + } + } + + fun seek(positionMs: Long) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs)) + } + } + + fun setVolume(volume: Float) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + } + } + + fun toggleShuffle() { + viewModelScope.launch { + val newState = !_playerState.value.shuffleEnabled + remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState)) + } + } + + fun cycleLoopMode() { + viewModelScope.launch { + val currentMode = _playerState.value.loopMode + val newMode = when (currentMode) { + "none" -> "one" + "one" -> "all" + else -> "none" + } + remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode)) + } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..dfbc15e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -38,6 +38,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView +import dev.krtirtho.spotube.modules.devices.PlayDestinationPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet @@ -59,6 +60,7 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle() var showEditPlaylist by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) } @@ -169,6 +171,13 @@ fun PlaylistScreen( viewModel.refresh() }, ) + + PlayDestinationPicker( + visible = showPlayDestinationPicker, + onDismiss = viewModel::dismissPlayPicker, + onPlayLocally = viewModel::playLocally, + onPlayOnRemote = viewModel::playOnRemote, + ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 7cd51cbb..e0e3a054 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -98,6 +99,7 @@ class PlaylistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -115,6 +117,8 @@ class PlaylistViewModel( private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + val showPlayDestinationPicker = remotePlaybackController.showPicker + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) private val _showAddToPlaylistPicker = MutableStateFlow(false) val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() @@ -223,15 +227,35 @@ class PlaylistViewModel( } fun playPlaylist() { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + remotePlaybackController.wrapPlaybackAction { + viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + } } fun addPlaylistToQueue() { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + if (remotePlaybackController.isRemoteConnected()) { + remotePlaybackController.addToQueueOnRemote(playlistId) + } else { + viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + } } fun playPlaylistFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + remotePlaybackController.wrapPlaybackAction { + viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + } + } + + fun playLocally() { + remotePlaybackController.playLocally() + } + + fun playOnRemote() { + remotePlaybackController.playOnRemote(playlistId) + } + + fun dismissPlayPicker() { + remotePlaybackController.dismissPicker() } fun refresh() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index e420009a..7601f014 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* @@ -62,6 +63,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { platformType == PlatformType.MacOS val shellBottomInset = LocalAppShellBottomInset.current + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val contentPadding = remember(shellBottomInset) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } @@ -106,6 +108,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { settings = settingsState!!, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, ) if (settingsState != null) cacheSection( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 658e6119..52d0a190 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -50,6 +50,7 @@ internal fun LazyListScope.playbackSection( settings: UserSettings, settingsViewModel: SettingsViewModel, navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, ) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( @@ -151,6 +152,11 @@ internal fun LazyListScope.playbackSection( settingsViewModel.updateSettings { copy(allowRemoteControl = enabled) } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } } ) }, diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt new file mode 100644 index 00000000..4b02635f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on iOS. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt new file mode 100644 index 00000000..68522b82 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on the JVM. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file