Compare commits

..

No commits in common. "df3a6dcbd275299888ba1e591f7f5d92fe5ea461" and "f21442b1c27b5cad96e5206f02a18dd401125280" have entirely different histories.

28 changed files with 95 additions and 1579 deletions

View File

@ -18,7 +18,6 @@
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
@ -26,7 +25,6 @@ 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
@ -35,16 +33,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
FileKit.init(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)
}
NewPipeDownloader.init(Paths(this))
intent?.dataString?.let(ExternalUriHandler::onNewUri)
setContent {
App()

View File

@ -18,8 +18,10 @@
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.core.paths.Paths
import dev.krtirtho.spotube.media.PlaybackService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -32,10 +34,15 @@ 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() {

View File

@ -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() }
single { Paths(get()) }
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
single<ShareService> { AndroidShareService(get()) }

View File

@ -1,50 +0,0 @@
/*
* 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,10 +20,9 @@ package dev.krtirtho.spotube.core.paths
import android.content.Context
import android.os.Environment
actual class Paths {
private val context: Context
get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" }
actual class Paths(
val context: Context
) {
actual fun getApplicationCacheDirPath(): String {
return context.cacheDir.absolutePath
}
@ -39,13 +38,4 @@ 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
}
}
}

View File

@ -28,10 +28,8 @@ 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
@ -46,7 +44,6 @@ 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
@ -143,7 +140,6 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
@ -179,8 +175,7 @@ val sharedModules = module {
// Blacklist
singleOf(::BlacklistRepository)
viewModelOf(::BlacklistViewModel)
viewModel { DevicesViewModel(get()) }
viewModelOf(::RemoteControlViewModel)
viewModelOf(::DevicesViewModel)
viewModelOf(::JamViewModel)
// Album
@ -220,12 +215,10 @@ 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<QueueStateRepository>() }

View File

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

View File

@ -1,28 +0,0 @@
/*
* 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,7 +22,6 @@ 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
@ -79,9 +78,6 @@ sealed interface Routes : NavKey {
@Serializable
data object Blacklist : Routes
@Serializable
data object RemoteControl : Routes
@Serializable
data object Devices : Routes
@ -163,11 +159,6 @@ val navigationModule = module {
navigation<Routes.Devices> {
DevicesScreen(navigationCommands = get())
}
navigation<Routes.RemoteControl> {
RemoteControlScreen(
onDisconnect = { get<NavigationCommands>().pop() }
)
}
navigation<Routes.Jam> {
JamScreen(navigationCommands = get())
}

View File

@ -1,188 +0,0 @@
/*
* 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,12 +70,6 @@ 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,
@ -98,8 +92,6 @@ 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 {

View File

@ -69,14 +69,6 @@ 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(

View File

@ -24,18 +24,10 @@ 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
@ -44,10 +36,6 @@ 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,
@ -57,18 +45,9 @@ class RemoteControlService(
private val log = Logger.withTag("RemoteControlService")
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _localDeviceId = MutableStateFlow("")
val localDeviceId: StateFlow<String> = _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,
@ -77,74 +56,32 @@ class RemoteControlService(
.distinctUntilChanged()
.collect { (settings, port) ->
if (settings.allowRemoteControl && port != null) {
if (registerJob?.isActive != true) {
registerJob = scope.launch {
registerLoop(settings.remoteControlDeviceName, port)
}
}
ensureAdvertised(settings.remoteControlDeviceName, port)
} else {
registerJob?.cancel()
registerJob = null
stopAdvertising()
}
}
}
}
/**
* 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) {
private suspend fun ensureAdvertised(name: String, port: Int) {
val deviceId = resolveDeviceId()
_localDeviceId.value = deviceId
val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" }
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
if (advertisedService == null) {
try {
advertisedService = discoveryService.advertise(
name = serviceName,
port = port,
deviceId = deviceId,
registerTimeoutMs = REGISTER_TIMEOUT_MS,
)
log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" }
log.i { "Advertising remote control service '$serviceName' on port $port" }
} catch (e: Exception) {
log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" }
delay(retryDelayMs(attempt))
log.w(e) { "Failed to advertise remote control service" }
}
}
}
private suspend fun stopAdvertising() {
registerJob?.cancel()
registerJob = null
if (advertisedService != null) {
runCatching { advertisedService?.unregister() }
advertisedService = null
@ -164,14 +101,4 @@ 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
}
}

View File

@ -1,117 +0,0 @@
/*
* 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,7 +22,6 @@ 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
@ -64,14 +63,7 @@ class LocalServer(
) : KoinComponent {
val logger by injectLogger<LocalServer>()
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 httpClient = HttpClient()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val serverMutex = Mutex()

View File

@ -28,11 +28,9 @@ 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
@ -44,9 +42,7 @@ 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
@ -61,19 +57,10 @@ fun DevicesScreen(
val viewModel = koinViewModel<DevicesViewModel>()
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()
viewModel.disconnect()
}
onDispose { viewModel.stopDiscovery() }
}
Scaffold(
@ -82,7 +69,7 @@ fun DevicesScreen(
backButton = true,
title = { Text("Devices") },
actions = {
if (isDiscovering && connectingToDevice == null) {
if (isDiscovering) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
@ -95,9 +82,7 @@ fun DevicesScreen(
contentDescription = "Refresh",
modifier = Modifier
.size(24.dp)
.clickable(enabled = connectingToDevice == null) {
viewModel.startDiscovery()
},
.clickable { viewModel.startDiscovery() },
)
}
},
@ -106,87 +91,11 @@ fun DevicesScreen(
) { innerPadding ->
val shellBottomInset = LocalAppShellBottomInset.current
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) {
if (devices.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center,
) {
@ -214,7 +123,7 @@ fun DevicesScreen(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f),
.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp,
@ -224,7 +133,6 @@ fun DevicesScreen(
items(devices.values.toList(), key = { it.key }) { device ->
DeviceRow(
device = device,
isConnecting = connectingToDevice?.key == device.key,
onClick = { viewModel.connectToDevice(device) },
)
}
@ -234,62 +142,40 @@ fun DevicesScreen(
}
}
}
}
}
@Composable
private fun DeviceRow(
device: DiscoveredDevice,
isConnecting: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick, enabled = !isConnecting)
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
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.ifBlank { "Unknown Device" },
text = device.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (device.host.isNotBlank() && device.port > 0) {
"${device.host}:${device.port}"
} else {
"Resolving..."
},
text = "${device.host}:${device.port}",
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,
)
}
}
}
}

View File

@ -20,15 +20,10 @@ 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
@ -38,14 +33,9 @@ import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class DevicesViewModel(
private val navigationCommands: NavigationCommands,
) : ViewModel(), KoinComponent {
class DevicesViewModel : 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<Map<String, DiscoveredDevice>>(emptyMap())
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
@ -53,106 +43,27 @@ class DevicesViewModel(
private val _isDiscovering = MutableStateFlow(false)
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
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()
}
}
}
}
private var advertisedService: NetService? = null
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 {
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 }
}
}
@ -163,51 +74,6 @@ class DevicesViewModel(
}
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
}
}

View File

@ -1,88 +0,0 @@
/*
* 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

@ -1,375 +0,0 @@
/*
* 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

@ -1,170 +0,0 @@
/*
* 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,7 +38,6 @@ 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
@ -60,7 +59,6 @@ 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) }
@ -171,13 +169,6 @@ fun PlaylistScreen(
viewModel.refresh()
},
)
PlayDestinationPicker(
visible = showPlayDestinationPicker,
onDismiss = viewModel::dismissPlayPicker,
onPlayLocally = viewModel::playLocally,
onPlayOnRemote = viewModel::playOnRemote,
)
},
)
}

View File

@ -26,7 +26,6 @@ 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
@ -99,7 +98,6 @@ 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<PlaylistViewModel>()
@ -117,8 +115,6 @@ class PlaylistViewModel(
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
val showPlayDestinationPicker = remotePlaybackController.showPicker
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
@ -227,36 +223,16 @@ class PlaylistViewModel(
}
fun playPlaylist() {
remotePlaybackController.wrapPlaybackAction {
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
}
}
fun addPlaylistToQueue() {
if (remotePlaybackController.isRemoteConnected()) {
remotePlaybackController.addToQueueOnRemote(playlistId)
} else {
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
}
}
fun playPlaylistFromTrack(track: MetadataTrack) {
remotePlaybackController.wrapPlaybackAction {
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
}
}
fun playLocally() {
remotePlaybackController.playLocally()
}
fun playOnRemote() {
remotePlaybackController.playOnRemote(playlistId)
}
fun dismissPlayPicker() {
remotePlaybackController.dismissPicker()
}
fun refresh() {
viewModelScope.launch {

View File

@ -35,7 +35,6 @@ 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.*
@ -63,7 +62,6 @@ 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)
}
@ -108,7 +106,6 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
settings = settingsState!!,
settingsViewModel = settingsViewModel,
navigatorCommands = navigatorCommands,
requestLocalNetworkPermission = requestLocalNetworkPermission,
)
if (settingsState != null)
cacheSection(

View File

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

View File

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

View File

@ -1,24 +0,0 @@
/*
* 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

@ -1,24 +0,0 @@
/*
* 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-core = "1.19.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-lifecycle = "2.11.0"
androidx-testExt = "1.3.0"
appdirs = "1.5.0"
cache4k = "0.14.0"
@ -62,8 +62,8 @@ compose-webview = "1.0.1"
composeNativeTray = "2.0.3"
koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-alpha05"
compose-multiplatform-lifecycle = "2.10.0"
compose-multiplatform-adaptive = "1.3.0-beta02"
compose-multiplatform-lifecycle = "2.11.0"
feather-icons = "1.1.1"
material3-window-size = "1.9.0"
kotlinx-datetime = "0.8.0"