mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
Compare commits
2 Commits
f21442b1c2
...
df3a6dcbd2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df3a6dcbd2 | ||
|
|
1aeee1db79 |
@ -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()
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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()) }
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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>() }
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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,53 +106,131 @@ fun DevicesScreen(
|
|||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val shellBottomInset = LocalAppShellBottomInset.current
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
|
|
||||||
if (devices.isEmpty()) {
|
Column(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxSize()
|
||||||
.fillMaxSize()
|
.padding(innerPadding),
|
||||||
.padding(innerPadding)
|
) {
|
||||||
.padding(bottom = shellBottomInset),
|
// Error banner
|
||||||
contentAlignment = Alignment.Center,
|
error?.let { errorMessage ->
|
||||||
) {
|
Box(
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
modifier = Modifier
|
||||||
Text(
|
.fillMaxWidth()
|
||||||
text = if (isDiscovering) {
|
.padding(16.dp),
|
||||||
"Searching for devices on the network..."
|
) {
|
||||||
} else {
|
Column {
|
||||||
"No devices found"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (!isDiscovering) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "Make sure the other device has \"Allow remote control\" enabled in settings.",
|
text = errorMessage,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
color = MaterialTheme.colorScheme.error,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp),
|
|
||||||
)
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.clearError() },
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LazyColumn(
|
// Connection status
|
||||||
modifier = Modifier
|
when (val state = connectionState) {
|
||||||
.fillMaxSize()
|
is ConnectionState.Connected -> {
|
||||||
.padding(innerPadding),
|
Box(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
modifier = Modifier
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
.fillMaxWidth()
|
||||||
horizontal = 16.dp,
|
.padding(16.dp),
|
||||||
vertical = 8.dp,
|
) {
|
||||||
),
|
Column {
|
||||||
) {
|
Text(
|
||||||
items(devices.values.toList(), key = { it.key }) { device ->
|
text = "Connected to ${state.host}:${state.port}",
|
||||||
DeviceRow(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
device = device,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
onClick = { viewModel.connectToDevice(device) },
|
)
|
||||||
)
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.disconnect() },
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item {
|
is ConnectionState.Connecting -> {
|
||||||
Box(modifier = Modifier.padding(bottom = shellBottomInset))
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Connecting...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device list
|
||||||
|
if (devices.isEmpty() && connectingToDevice == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = shellBottomInset),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = if (isDiscovering) {
|
||||||
|
"Searching for devices on the network..."
|
||||||
|
} else {
|
||||||
|
"No devices found"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (!isDiscovering) {
|
||||||
|
Text(
|
||||||
|
text = "Make sure the other device has \"Allow remote control\" enabled in settings.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||||
|
horizontal = 16.dp,
|
||||||
|
vertical = 8.dp,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
items(devices.values.toList(), key = { it.key }) { device ->
|
||||||
|
DeviceRow(
|
||||||
|
device = device,
|
||||||
|
isConnecting = connectingToDevice?.key == device.key,
|
||||||
|
onClick = { viewModel.connectToDevice(device) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.padding(bottom = shellBottomInset))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,35 +240,56 @@ fun DevicesScreen(
|
|||||||
@Composable
|
@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),
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isConnecting) {
|
||||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
CircularProgressIndicator(
|
||||||
contentDescription = null,
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||||
|
contentDescription = null,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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,30 +53,109 @@ 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 {
|
||||||
discoveryService.discover().collect { event ->
|
try {
|
||||||
when (event) {
|
discoveryService.discover().collect { event ->
|
||||||
is DiscoveryState.Discovered -> {
|
logger.d { "Discovery event: $event" }
|
||||||
event.resolve()
|
when (event) {
|
||||||
_devices.update { it + (event.device.key to event.device.copy()) }
|
is DiscoveryState.Discovered -> {
|
||||||
}
|
event.resolve()
|
||||||
is DiscoveryState.Resolved -> {
|
if (!isSelf(event.device)) {
|
||||||
_devices.update { it + (event.device.key to event.device) }
|
_devices.update { it + (event.device.key to event.device.copy()) }
|
||||||
}
|
}
|
||||||
is DiscoveryState.Removed -> {
|
}
|
||||||
_devices.update { it - event.device.key }
|
is DiscoveryState.Resolved -> {
|
||||||
|
if (isSelf(event.device)) {
|
||||||
|
// Resolved now carries our deviceId in TXT; drop self.
|
||||||
|
_devices.update { it - event.device.key }
|
||||||
|
} else {
|
||||||
|
_devices.update { it + (event.device.key to event.device) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DiscoveryState.Removed -> {
|
||||||
|
_devices.update { it - event.device.key }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.e(e) { "Discovery failed" }
|
||||||
|
_error.value = "Discovery failed: ${e.message}"
|
||||||
|
_isDiscovering.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [device] is this device's own advertisement. On the initial
|
||||||
|
* `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is
|
||||||
|
* empty), so we match by the name we advertise; once resolved we also have
|
||||||
|
* the authoritative deviceId.
|
||||||
|
*/
|
||||||
|
private fun isSelf(device: DiscoveredDevice): Boolean {
|
||||||
|
val localId = remoteControlService.localDeviceId.value.ifBlank {
|
||||||
|
settingsProvider.settingsState.value?.remoteControlDeviceId ?: ""
|
||||||
|
}
|
||||||
|
if (localId.isNotBlank() && device.deviceId == localId) return true
|
||||||
|
// Match by the deterministic advertised name as a fallback for the
|
||||||
|
// pre-resolution event where deviceId isn't available yet.
|
||||||
|
return device.name.isNotBlank() && device.name == remoteControlService.advertisedName()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeSelf() {
|
||||||
|
val localId = remoteControlService.localDeviceId.value
|
||||||
|
if (localId.isBlank()) return
|
||||||
|
_devices.update { map ->
|
||||||
|
map.filterNot { (_, device) -> device.deviceId == localId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun stopDiscovery() {
|
fun stopDiscovery() {
|
||||||
discoveryJob?.cancel()
|
discoveryJob?.cancel()
|
||||||
discoveryJob = null
|
discoveryJob = null
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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')}"
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,15 +227,35 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playPlaylist() {
|
fun playPlaylist() {
|
||||||
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
|
remotePlaybackController.wrapPlaybackAction {
|
||||||
|
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addPlaylistToQueue() {
|
fun addPlaylistToQueue() {
|
||||||
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
|
if (remotePlaybackController.isRemoteConnected()) {
|
||||||
|
remotePlaybackController.addToQueueOnRemote(playlistId)
|
||||||
|
} else {
|
||||||
|
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playPlaylistFromTrack(track: MetadataTrack) {
|
fun playPlaylistFromTrack(track: MetadataTrack) {
|
||||||
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
|
remotePlaybackController.wrapPlaybackAction {
|
||||||
|
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playLocally() {
|
||||||
|
remotePlaybackController.playLocally()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playOnRemote() {
|
||||||
|
remotePlaybackController.playOnRemote(playlistId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissPlayPicker() {
|
||||||
|
remotePlaybackController.dismissPicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -194,6 +194,8 @@ fun AppSidebar(
|
|||||||
selected = false,
|
selected = false,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(120.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 = {}
|
||||||
@ -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 = {}
|
||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user