Compare commits

..

2 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
df3a6dcbd2 feat(remote-control): implement remote playback functionality and UI components 2026-08-28 18:28:24 +06:00
Kingkor Roy Tirtho
1aeee1db79 fix(sidebar): add spacer to AppSidebar for improved layout 2026-08-28 13:18:04 +06:00
28 changed files with 1578 additions and 94 deletions

View File

@ -18,6 +18,7 @@
package dev.krtirtho.spotube package dev.krtirtho.spotube
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.deeplink.ExternalUriHandler
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.media.PlaybackService
import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.init import io.github.vinceglb.filekit.dialogs.init
@ -33,7 +35,16 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
FileKit.init(this) 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) intent?.dataString?.let(ExternalUriHandler::onNewUri)
setContent { setContent {
App() App()

View File

@ -18,10 +18,8 @@
package dev.krtirtho.spotube package dev.krtirtho.spotube
import android.app.Application import android.app.Application
import android.content.Intent
import android.os.Build
import dev.krtirtho.spotube.core.di.initKoin 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Paths.init(this)
initKoin { initKoin {
androidContext(this@MyApplication) 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() { override fun onTerminate() {

View File

@ -29,7 +29,7 @@ import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscove
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModules = module { actual val platformModules = module {
single { Paths(get()) } single { Paths() }
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) } single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) } single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
single<ShareService> { AndroidShareService(get()) } single<ShareService> { AndroidShareService(get()) }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View File

@ -20,9 +20,10 @@ package dev.krtirtho.spotube.core.paths
import android.content.Context import android.content.Context
import android.os.Environment import android.os.Environment
actual class Paths( actual class Paths {
val context: Context private val context: Context
) { get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" }
actual fun getApplicationCacheDirPath(): String { actual fun getApplicationCacheDirPath(): String {
return context.cacheDir.absolutePath return context.cacheDir.absolutePath
} }
@ -38,4 +39,13 @@ actual class Paths(
actual fun getMusicCacheDirPath(): String { actual fun getMusicCacheDirPath(): String {
return context.cacheDir.absolutePath + "/music_cache" return context.cacheDir.absolutePath + "/music_cache"
} }
companion object {
@Volatile
private var appContext: Context? = null
fun init(context: Context) {
appContext = context.applicationContext
}
}
} }

View File

@ -28,8 +28,10 @@ import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discord.DiscordRpcService import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.jam.JamSessionService
import dev.krtirtho.spotube.core.navigation.navigationModule 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.RemoteControlHandler
import dev.krtirtho.spotube.core.remote.RemoteControlService 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.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.server.CacheManager 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.BlacklistRepository
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
import dev.krtirtho.spotube.modules.devices.DevicesViewModel 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.jam.JamViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
@ -140,6 +143,7 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(),
) )
} }
@ -175,7 +179,8 @@ val sharedModules = module {
// Blacklist // Blacklist
singleOf(::BlacklistRepository) singleOf(::BlacklistRepository)
viewModelOf(::BlacklistViewModel) viewModelOf(::BlacklistViewModel)
viewModelOf(::DevicesViewModel) viewModel { DevicesViewModel(get()) }
viewModelOf(::RemoteControlViewModel)
viewModelOf(::JamViewModel) viewModelOf(::JamViewModel)
// Album // Album
@ -215,10 +220,12 @@ val sharedModules = module {
createdAtStart() createdAtStart()
} }
single { RemoteControlHandler(get(), get(), get()) } single { RemoteControlHandler(get(), get(), get()) }
single { RemoteControlClient() }
singleOf(::DeviceDiscoveryService) singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions { single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart() createdAtStart()
} }
single { RemotePlaybackController() }
single { JamSessionService(get(), get()) } single { JamSessionService(get(), get()) }
singleOf(::JamDeepLinkService) singleOf(::JamDeepLinkService)
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() } singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }

View File

@ -88,6 +88,7 @@ class DeviceDiscoveryService {
name: String, name: String,
port: Int, port: Int,
deviceId: String, deviceId: String,
registerTimeoutMs: Long = 5_000,
): NetService { ): NetService {
val service = createNetService( val service = createNetService(
type = SERVICE_TYPE, type = SERVICE_TYPE,
@ -95,7 +96,7 @@ class DeviceDiscoveryService {
port = port, port = port,
txt = mapOf(TXT_DEVICE_ID to deviceId), txt = mapOf(TXT_DEVICE_ID to deviceId),
) )
service.register() service.register(timeoutInMs = registerTimeoutMs)
return service return service
} }
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@ -22,6 +22,7 @@ import dev.krtirtho.spotube.modules.album.AlbumScreen
import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.artist.ArtistScreen
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
import dev.krtirtho.spotube.modules.devices.DevicesScreen 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.jam.JamScreen
import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.home.HomeScreen
import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.library.LibraryScreen
@ -78,6 +79,9 @@ sealed interface Routes : NavKey {
@Serializable @Serializable
data object Blacklist : Routes data object Blacklist : Routes
@Serializable
data object RemoteControl : Routes
@Serializable @Serializable
data object Devices : Routes data object Devices : Routes
@ -159,6 +163,11 @@ val navigationModule = module {
navigation<Routes.Devices> { navigation<Routes.Devices> {
DevicesScreen(navigationCommands = get()) DevicesScreen(navigationCommands = get())
} }
navigation<Routes.RemoteControl> {
RemoteControlScreen(
onDisconnect = { get<NavigationCommands>().pop() }
)
}
navigation<Routes.Jam> { navigation<Routes.Jam> {
JamScreen(navigationCommands = get()) JamScreen(navigationCommands = get())
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _stateUpdates = MutableSharedFlow<RemoteControlEvent>(extraBufferCapacity = 32)
val stateUpdates: SharedFlow<RemoteControlEvent> = _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
}

View File

@ -70,6 +70,12 @@ class RemoteControlHandler(
val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices
if (!isAllowed) { 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( val request = ConnectionRequest(
deviceId = deviceId ?: "unknown", deviceId = deviceId ?: "unknown",
deviceName = deviceName, 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)" } logger.i { "Remote control connection established from $deviceName ($deviceId)" }
try { try {

View File

@ -69,6 +69,14 @@ sealed class RemoteControlCommand {
@Serializable @Serializable
sealed class RemoteControlEvent { sealed class RemoteControlEvent {
@Serializable
@SerialName("connected")
data object Connected : RemoteControlEvent()
@Serializable
@SerialName("waitingForPermission")
data class WaitingForPermission(val message: String) : RemoteControlEvent()
@Serializable @Serializable
@SerialName("playerState") @SerialName("playerState")
data class PlayerState( data class PlayerState(

View File

@ -24,10 +24,18 @@ import dev.krtirtho.spotube.core.server.LocalServer
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob 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.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.random.Random import kotlin.random.Random
@ -36,6 +44,10 @@ import kotlin.random.Random
* instances can discover and control it. Advertises only while the * instances can discover and control it. Advertises only while the
* "Allow remote control" setting is enabled and the local playback server is * "Allow remote control" setting is enabled and the local playback server is
* listening on the LAN (0.0.0.0). * 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( class RemoteControlService(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
@ -45,9 +57,18 @@ class RemoteControlService(
private val log = Logger.withTag("RemoteControlService") private val log = Logger.withTag("RemoteControlService")
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _localDeviceId = MutableStateFlow("")
val localDeviceId: StateFlow<String> = _localDeviceId.asStateFlow()
private var advertisedService: NetService? = null private var advertisedService: NetService? = null
private var registerJob: Job? = null
init { 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 { scope.launch {
combine( combine(
settingsRepository.userSettings, settingsRepository.userSettings,
@ -56,32 +77,74 @@ class RemoteControlService(
.distinctUntilChanged() .distinctUntilChanged()
.collect { (settings, port) -> .collect { (settings, port) ->
if (settings.allowRemoteControl && port != null) { if (settings.allowRemoteControl && port != null) {
ensureAdvertised(settings.remoteControlDeviceName, port) if (registerJob?.isActive != true) {
registerJob = scope.launch {
registerLoop(settings.remoteControlDeviceName, port)
}
}
} else { } else {
registerJob?.cancel()
registerJob = null
stopAdvertising() 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() val deviceId = resolveDeviceId()
_localDeviceId.value = deviceId
val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } 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 { try {
advertisedService = discoveryService.advertise( advertisedService = discoveryService.advertise(
name = serviceName, name = serviceName,
port = port, port = port,
deviceId = deviceId, 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) { } 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() { private suspend fun stopAdvertising() {
registerJob?.cancel()
registerJob = null
if (advertisedService != null) { if (advertisedService != null) {
runCatching { advertisedService?.unregister() } runCatching { advertisedService?.unregister() }
advertisedService = null advertisedService = null
@ -101,4 +164,14 @@ class RemoteControlService(
settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated))
return 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
}
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean> = _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" }
}
}
}
}

View File

@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.core.remote.RemoteControlHandler
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
@ -63,7 +64,14 @@ class LocalServer(
) : KoinComponent { ) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
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 scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val serverMutex = Mutex() private val serverMutex = Mutex()

View File

@ -28,9 +28,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -42,7 +44,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.discovery.DiscoveredDevice 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.navigation.NavigationCommands
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -57,10 +61,19 @@ fun DevicesScreen(
val viewModel = koinViewModel<DevicesViewModel>() val viewModel = koinViewModel<DevicesViewModel>()
val devices by viewModel.devices.collectAsStateWithLifecycle() val devices by viewModel.devices.collectAsStateWithLifecycle()
val isDiscovering by viewModel.isDiscovering.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) { DisposableEffect(Unit) {
// Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works.
requestLocalNetworkPermission()
viewModel.startDiscovery() viewModel.startDiscovery()
onDispose { viewModel.stopDiscovery() } onDispose {
viewModel.stopDiscovery()
viewModel.disconnect()
}
} }
Scaffold( Scaffold(
@ -69,7 +82,7 @@ fun DevicesScreen(
backButton = true, backButton = true,
title = { Text("Devices") }, title = { Text("Devices") },
actions = { actions = {
if (isDiscovering) { if (isDiscovering && connectingToDevice == null) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
@ -82,7 +95,9 @@ fun DevicesScreen(
contentDescription = "Refresh", contentDescription = "Refresh",
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
.clickable { viewModel.startDiscovery() }, .clickable(enabled = connectingToDevice == null) {
viewModel.startDiscovery()
},
) )
} }
}, },
@ -91,11 +106,87 @@ fun DevicesScreen(
) { innerPadding -> ) { innerPadding ->
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
if (devices.isEmpty()) { Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// Error banner
error?.let { errorMessage ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Column {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(
onClick = { viewModel.clearError() },
modifier = Modifier.padding(top = 8.dp),
) {
Text("Dismiss")
}
}
}
}
// 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")
}
}
}
}
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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding)
.padding(bottom = shellBottomInset), .padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -123,7 +214,7 @@ fun DevicesScreen(
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues( contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp, horizontal = 16.dp,
@ -133,6 +224,7 @@ fun DevicesScreen(
items(devices.values.toList(), key = { it.key }) { device -> items(devices.values.toList(), key = { it.key }) { device ->
DeviceRow( DeviceRow(
device = device, device = device,
isConnecting = connectingToDevice?.key == device.key,
onClick = { viewModel.connectToDevice(device) }, onClick = { viewModel.connectToDevice(device) },
) )
} }
@ -142,40 +234,62 @@ fun DevicesScreen(
} }
} }
} }
}
} }
@Composable @Composable
private fun DeviceRow( private fun DeviceRow(
device: DiscoveredDevice, device: DiscoveredDevice,
isConnecting: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick, enabled = !isConnecting)
.padding(vertical = 12.dp, horizontal = 8.dp), .padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon( Icon(
imageVector = Iconsax.IconsaxMirroringScreen, imageVector = Iconsax.IconsaxMirroringScreen,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
}
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = device.name, text = device.name.ifBlank { "Unknown Device" },
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = "${device.host}:${device.port}", text = if (device.host.isNotBlank() && device.port > 0) {
"${device.host}:${device.port}"
} else {
"Resolving..."
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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,
)
}
} }
} }
} }

View File

@ -20,10 +20,15 @@ package dev.krtirtho.spotube.modules.devices
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.appstractive.dnssd.NetService
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discovery.DiscoveredDevice import dev.krtirtho.spotube.core.discovery.DiscoveredDevice
import dev.krtirtho.spotube.core.discovery.DiscoveryState 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.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -33,9 +38,14 @@ import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject 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 logger = Logger.withTag("DevicesViewModel")
private val discoveryService: DeviceDiscoveryService by inject() 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<Map<String, DiscoveredDevice>>(emptyMap()) private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap())
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow() val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
@ -43,27 +53,106 @@ class DevicesViewModel : ViewModel(), KoinComponent {
private val _isDiscovering = MutableStateFlow(false) private val _isDiscovering = MutableStateFlow(false)
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow() val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
val connectingToDevice: StateFlow<DiscoveredDevice?> = _connectingToDevice.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private var discoveryJob: Job? = null 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() { fun startDiscovery() {
if (discoveryJob?.isActive == true) return if (discoveryJob?.isActive == true) return
_isDiscovering.value = true _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 { discoveryJob = viewModelScope.launch {
try {
discoveryService.discover().collect { event -> discoveryService.discover().collect { event ->
logger.d { "Discovery event: $event" }
when (event) { when (event) {
is DiscoveryState.Discovered -> { is DiscoveryState.Discovered -> {
event.resolve() event.resolve()
if (!isSelf(event.device)) {
_devices.update { it + (event.device.key to event.device.copy()) } _devices.update { it + (event.device.key to event.device.copy()) }
} }
}
is DiscoveryState.Resolved -> { 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) } _devices.update { it + (event.device.key to event.device) }
} }
}
is DiscoveryState.Removed -> { is DiscoveryState.Removed -> {
_devices.update { it - event.device.key } _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 }
} }
} }
@ -74,6 +163,51 @@ class DevicesViewModel : ViewModel(), KoinComponent {
} }
fun connectToDevice(device: DiscoveredDevice) { 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}" } 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
} }
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RemoteControlClient>()
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)
}
},
)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RemoteControlViewModel>()
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')}"
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RemotePlayerState> = _playerState.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _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()
}
}
}

View File

@ -38,6 +38,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView 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.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
@ -59,6 +60,7 @@ fun PlaylistScreen(
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle()
var showEditPlaylist by remember { mutableStateOf(false) } var showEditPlaylist by remember { mutableStateOf(false) }
var showAddTracksDialog by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) }
@ -169,6 +171,13 @@ fun PlaylistScreen(
viewModel.refresh() viewModel.refresh()
}, },
) )
PlayDestinationPicker(
visible = showPlayDestinationPicker,
onDismiss = viewModel::dismissPlayPicker,
onPlayLocally = viewModel::playLocally,
onPlayOnRemote = viewModel::playOnRemote,
)
}, },
) )
} }

View File

@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -98,6 +99,7 @@ class PlaylistViewModel(
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService, private val shareService: ShareService,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<PlaylistViewModel>() private val logger by injectLogger<PlaylistViewModel>()
@ -115,6 +117,8 @@ class PlaylistViewModel(
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
val showPlayDestinationPicker = remotePlaybackController.showPicker
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList()) private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false) private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow() val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
@ -223,16 +227,36 @@ class PlaylistViewModel(
} }
fun playPlaylist() { fun playPlaylist() {
remotePlaybackController.wrapPlaybackAction {
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
} }
}
fun addPlaylistToQueue() { fun addPlaylistToQueue() {
if (remotePlaybackController.isRemoteConnected()) {
remotePlaybackController.addToQueueOnRemote(playlistId)
} else {
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
} }
}
fun playPlaylistFromTrack(track: MetadataTrack) { fun playPlaylistFromTrack(track: MetadataTrack) {
remotePlaybackController.wrapPlaybackAction {
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
} }
}
fun playLocally() {
remotePlaybackController.playLocally()
}
fun playOnRemote() {
remotePlaybackController.playOnRemote(playlistId)
}
fun dismissPlayPicker() {
remotePlaybackController.dismissPicker()
}
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {

View File

@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
@ -62,6 +63,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
platformType == PlatformType.MacOS platformType == PlatformType.MacOS
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
val contentPadding = remember(shellBottomInset) { val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
} }
@ -106,6 +108,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
settings = settingsState!!, settings = settingsState!!,
settingsViewModel = settingsViewModel, settingsViewModel = settingsViewModel,
navigatorCommands = navigatorCommands, navigatorCommands = navigatorCommands,
requestLocalNetworkPermission = requestLocalNetworkPermission,
) )
if (settingsState != null) if (settingsState != null)
cacheSection( cacheSection(

View File

@ -50,6 +50,7 @@ internal fun LazyListScope.playbackSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
navigatorCommands: NavigationCommands, navigatorCommands: NavigationCommands,
requestLocalNetworkPermission: () -> Unit,
) { ) {
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
val streamingQualities = availableAudioQualities( val streamingQualities = availableAudioQualities(
@ -151,6 +152,11 @@ internal fun LazyListScope.playbackSection(
settingsViewModel.updateSettings { settingsViewModel.updateSettings {
copy(allowRemoteControl = enabled) 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()
}
} }
) )
}, },

View File

@ -194,6 +194,8 @@ fun AppSidebar(
selected = false, selected = false,
expanded = expanded, expanded = expanded,
) )
Spacer(modifier = Modifier.height(120.dp))
} }
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.discovery
import androidx.compose.runtime.Composable
/** No runtime local-network permission needed on iOS. */
@Composable
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 = {}

View File

@ -22,7 +22,7 @@ androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core = "1.19.0" androidx-core = "1.19.0"
androidx-espresso = "3.7.0" androidx-espresso = "3.7.0"
androidx-lifecycle = "2.11.0" androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
appdirs = "1.5.0" appdirs = "1.5.0"
cache4k = "0.14.0" cache4k = "0.14.0"
@ -62,8 +62,8 @@ compose-webview = "1.0.1"
composeNativeTray = "2.0.3" composeNativeTray = "2.0.3"
koin = "4.2.2" koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1" multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-beta02" compose-multiplatform-adaptive = "1.3.0-alpha05"
compose-multiplatform-lifecycle = "2.11.0" compose-multiplatform-lifecycle = "2.10.0"
feather-icons = "1.1.1" feather-icons = "1.1.1"
material3-window-size = "1.9.0" material3-window-size = "1.9.0"
kotlinx-datetime = "0.8.0" kotlinx-datetime = "0.8.0"