mirror of
https://github.com/Assasinnys/RemoteAudioControl.git
synced 2025-06-07 19:03:18 +03:00
Merge pull request #1 from Assasinnys/compose_refactor
Compose refactor
This commit is contained in:
commit
3f49e3c4be
@ -68,4 +68,6 @@ dependencies {
|
|||||||
|
|
||||||
implementation "com.squareup.moshi:moshi:1.14.0"
|
implementation "com.squareup.moshi:moshi:1.14.0"
|
||||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.14.0"
|
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.14.0"
|
||||||
|
|
||||||
|
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1"
|
||||||
}
|
}
|
@ -23,6 +23,7 @@ class SocketRepositoryImpl @Inject constructor(
|
|||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e( javaClass.simpleName, "Socket connection error; address: $address, port: $port", e)
|
Log.e( javaClass.simpleName, "Socket connection error; address: $address, port: $port", e)
|
||||||
|
socketSource.close()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
package com.dmitryzenevich.remoteaudiocontrol.data
|
package com.dmitryzenevich.remoteaudiocontrol.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@ -9,6 +13,7 @@ import javax.inject.Singleton
|
|||||||
class SocketSource @Inject constructor() : Closeable {
|
class SocketSource @Inject constructor() : Closeable {
|
||||||
|
|
||||||
private var socket: Socket? = null
|
private var socket: Socket? = null
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
val writer
|
val writer
|
||||||
get() = socket().getOutputStream().writer()
|
get() = socket().getOutputStream().writer()
|
||||||
@ -18,12 +23,16 @@ class SocketSource @Inject constructor() : Closeable {
|
|||||||
|
|
||||||
private fun socket(): Socket = socket ?: throw IllegalStateException("Socket is not created")
|
private fun socket(): Socket = socket ?: throw IllegalStateException("Socket is not created")
|
||||||
|
|
||||||
fun openSocketConnection(address: String, port: Int) {
|
suspend fun openSocketConnection(address: String, port: Int) {
|
||||||
socket = Socket(address, port)
|
mutex.withLock {
|
||||||
|
close()
|
||||||
|
socket = Socket().apply { connect(InetSocketAddress(address, port), 5000) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
|
Log.i(javaClass.simpleName, "Socket $socket is now closing")
|
||||||
socket?.close()
|
socket?.close()
|
||||||
|
socket = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,22 @@
|
|||||||
package com.dmitryzenevich.remoteaudiocontrol.di
|
package com.dmitryzenevich.remoteaudiocontrol.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.SharedPreferences
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class AppModule {
|
class AppModule {
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun getSharedPreferences(@ApplicationContext appContext: Context): SharedPreferences {
|
||||||
|
return appContext.getSharedPreferences("AppPrefs", MODE_PRIVATE)
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,5 +0,0 @@
|
|||||||
package com.dmitryzenevich.remoteaudiocontrol.presentation
|
|
||||||
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
fun Float.classicRound(): Float = times(100).roundToInt().div(100f)
|
|
@ -1,45 +1,57 @@
|
|||||||
package com.dmitryzenevich.remoteaudiocontrol.presentation
|
package com.dmitryzenevich.remoteaudiocontrol.presentation
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.lifecycleScope
|
import com.dmitryzenevich.remoteaudiocontrol.presentation.theme.Dimens.TabHeight
|
||||||
import com.dmitryzenevich.remoteaudiocontrol.presentation.theme.RemoteAudioControlTheme
|
import com.dmitryzenevich.remoteaudiocontrol.presentation.theme.RemoteAudioControlTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import kotlinx.coroutines.flow.onEach
|
import com.dmitryzenevich.remoteaudiocontrol.R
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
private val viewModel by viewModels<MainViewModel>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
|
RemoteAudioControlApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RemoteAudioControlApp(viewModel: MainViewModel = viewModel()) {
|
||||||
|
val uiState = viewModel.uiState.collectAsState()
|
||||||
|
val showDialog = viewModel.showAddressDialog.collectAsState()
|
||||||
|
val isLoading = viewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
RemoteAudioControlTheme {
|
RemoteAudioControlTheme {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
Scaffold(
|
||||||
LazyRow {
|
topBar = { TopBar(viewModel::onAddressClick) }
|
||||||
items(uiState.volumes) { itemState ->
|
) { padding ->
|
||||||
VolumeItem(itemState, viewModel)
|
Box(Modifier.padding(padding)) {
|
||||||
|
MainScreen(
|
||||||
|
uiState = uiState.value,
|
||||||
|
onVolumeChanged = viewModel::onVolumeChanged,
|
||||||
|
onMuteClick = viewModel::onMuteClick
|
||||||
|
)
|
||||||
|
if (showDialog.value) {
|
||||||
|
IpAddressDialog(initIp = viewModel.getCurrentIp(), onConfirm = viewModel::onConfirmAddress)
|
||||||
}
|
}
|
||||||
|
if (isLoading.value) {
|
||||||
|
ConnectionProgressBar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,59 +59,60 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VolumeItem(volumeItemState: VolumeItemState, viewModel: MainViewModel) {
|
fun ConnectionProgressBar() {
|
||||||
var volume by remember { mutableStateOf(volumeItemState.volume) }
|
Box(
|
||||||
volume = volumeItemState.volume
|
modifier = Modifier.fillMaxSize(),
|
||||||
val onValueChanged = { newValue: Float ->
|
contentAlignment = Alignment.Center
|
||||||
Log.i("debug", "new value: $newValue")
|
|
||||||
viewModel.onVolumeChanged(volumeItemState, newValue.toInt())
|
|
||||||
volume = newValue.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.wrapContentSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Text(
|
CircularProgressIndicator()
|
||||||
text = "${volumeItemState.name}\nVolume: ${volumeItemState.volume}"
|
|
||||||
)
|
|
||||||
VerticalSlider(
|
|
||||||
value = /*volumeItemState.volume.toFloat()*/volume.toFloat(),
|
|
||||||
onValueChanged = onValueChanged
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VerticalSlider(
|
fun TopBar(onAddressClick: () -> Unit) {
|
||||||
value: Float,
|
Surface(
|
||||||
onValueChanged: (Float) -> Unit
|
|
||||||
) {
|
|
||||||
Slider(
|
|
||||||
value = value,
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
steps = 99,
|
|
||||||
onValueChange = onValueChanged,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.graphicsLayer {
|
.height(TabHeight)
|
||||||
rotationZ = 270f
|
.fillMaxWidth(),
|
||||||
transformOrigin = TransformOrigin(0f, 0f)
|
color = MaterialTheme.colorScheme.primary
|
||||||
}
|
) {
|
||||||
.layout { measurable, constraints ->
|
Row(
|
||||||
val placeable = measurable.measure(
|
modifier = Modifier
|
||||||
Constraints(
|
.fillMaxSize()
|
||||||
minWidth = constraints.minHeight,
|
.padding(horizontal = 8.dp),
|
||||||
maxWidth = constraints.maxHeight,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
minHeight = constraints.minWidth,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
maxHeight = constraints.maxHeight,
|
) {
|
||||||
|
Text(text = stringResource(R.string.app_name))
|
||||||
|
IconButton(onClick = onAddressClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Build,
|
||||||
|
contentDescription = stringResource(R.string.accessibility_ip_address_button)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
layout(placeable.height, placeable.width) {
|
|
||||||
placeable.place(-placeable.width, 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 24.dp)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun IpAddressDialog(initIp: String, onConfirm: (String) -> Unit) {
|
||||||
|
var ipText by remember { mutableStateOf(initIp) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = {
|
||||||
|
Text(text = stringResource(R.string.ip_dialog_title))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
TextField(value = ipText, onValueChange = { ipText = it })
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { onConfirm(ipText) }
|
||||||
|
) {
|
||||||
|
Text(text = "Ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
package com.dmitryzenevich.remoteaudiocontrol.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
uiState: MainScreenUiState,
|
||||||
|
onVolumeChanged: (VolumeItemState, Int) -> Unit,
|
||||||
|
onMuteClick: (VolumeItemState) -> Unit
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = uiState.volumes,
|
||||||
|
key = { item -> item.pid }
|
||||||
|
) { itemState ->
|
||||||
|
VolumeItem(
|
||||||
|
volumeItemState = itemState,
|
||||||
|
onValueChanged = { newValue: Float ->
|
||||||
|
onVolumeChanged(itemState, newValue.toInt())
|
||||||
|
},
|
||||||
|
onMuteClick = { onMuteClick(itemState) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package com.dmitryzenevich.remoteaudiocontrol.presentation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
|
||||||
|
data class MainScreenUiState(
|
||||||
|
val volumes: SnapshotStateList<VolumeItemState> = mutableStateListOf(),
|
||||||
|
val isError: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class VolumeItemState(
|
||||||
|
val pid: Long,
|
||||||
|
val name: String = "",
|
||||||
|
val volume: MutableState<Int> = mutableStateOf(0),
|
||||||
|
val isMuted: MutableState<Boolean> = mutableStateOf(false),
|
||||||
|
val isActive: Boolean = false
|
||||||
|
)
|
@ -5,57 +5,64 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.dmitryzenevich.remoteaudiocontrol.data.SocketRepositoryImpl
|
import com.dmitryzenevich.remoteaudiocontrol.data.SocketRepositoryImpl
|
||||||
import com.dmitryzenevich.remoteaudiocontrol.data.model.*
|
import com.dmitryzenevich.remoteaudiocontrol.data.model.*
|
||||||
|
import com.dmitryzenevich.remoteaudiocontrol.presentation.helper.PrefHelper
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
|
const val NOT_BLOCKED = -1L
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MainViewModel @Inject constructor(
|
class MainViewModel @Inject constructor(
|
||||||
private val socketRepositoryImpl: SocketRepositoryImpl
|
private val socketRepositoryImpl: SocketRepositoryImpl,
|
||||||
|
private val prefHelper: PrefHelper
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MainScreenUiState())
|
private val _uiState = MutableStateFlow(MainScreenUiState())
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var commandJob: Job? = null
|
private val _showAddressDialog = MutableStateFlow(false)
|
||||||
|
val showAddressDialog = _showAddressDialog.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var blockingPid: Long = NOT_BLOCKED
|
||||||
|
private var blockingJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
connectToSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectToSocket() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val isReady = socketRepositoryImpl.openSocketConnection("10.0.2.2", 54683)
|
_isLoading.value = true
|
||||||
|
val isReady = socketRepositoryImpl.openSocketConnection(prefHelper.getIpAddress(), 54683)
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
socketRepositoryImpl.bindSocketInput()
|
socketRepositoryImpl.bindSocketInput()
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.onEach {
|
.onEach {
|
||||||
Log.i(javaClass.simpleName, "received: $it")
|
Log.i(javaClass.simpleName, "Received: $it")
|
||||||
// TODO: test it
|
|
||||||
withContext(Dispatchers.Main) { proceedEvent(it) }
|
withContext(Dispatchers.Main) { proceedEvent(it) }
|
||||||
}
|
}
|
||||||
.catch { Log.e(javaClass.simpleName, "Fetch event error", it) }
|
.catch { Log.e(javaClass.simpleName, "Fetch event error", it) }
|
||||||
.launchIn(CoroutineScope(Dispatchers.IO))
|
.launchIn(CoroutineScope(Dispatchers.IO))
|
||||||
}
|
}
|
||||||
|
_isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proceedEvent(event: Event) {
|
private fun proceedEvent(event: Event) {
|
||||||
val volumes = _uiState.value.volumes.toMutableList()
|
val volumes = _uiState.value.volumes
|
||||||
|
|
||||||
when(event) {
|
when(event) {
|
||||||
is NewSessionEvent -> {
|
is NewSessionEvent -> addIfNotExist(event)
|
||||||
// if (_uiState.value.volumes.find { it.pid == event.PID } == null) {
|
|
||||||
// _uiState.value = MainScreenUiState(volumes = _uiState.value.volumes + event.toVolumeItemState())
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
is MuteStateChangedEvent -> {
|
is MuteStateChangedEvent -> {
|
||||||
volumes.find { event.PID == it.pid }?.let { item ->
|
volumes.find { event.PID == it.pid }?.let { item ->
|
||||||
val newItem = item.copy(isMuted = event.isMuted)
|
item.isMuted.value = event.isMuted
|
||||||
volumes.set(
|
|
||||||
index = volumes.indexOfFirst { it.pid == newItem.pid },
|
|
||||||
element = newItem
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
_uiState.value = _uiState.value.copy(volumes = volumes)
|
|
||||||
}
|
}
|
||||||
is SetNameEvent -> {
|
is SetNameEvent -> {
|
||||||
volumes.find { event.PID == it.pid }?.let { item ->
|
volumes.find { event.PID == it.pid }?.let { item ->
|
||||||
@ -65,7 +72,6 @@ class MainViewModel @Inject constructor(
|
|||||||
element = newItem
|
element = newItem
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = _uiState.value.copy(volumes = volumes)
|
|
||||||
}
|
}
|
||||||
is StateChangedEvent -> {
|
is StateChangedEvent -> {
|
||||||
volumes.find { event.PID == it.pid }?.let { item ->
|
volumes.find { event.PID == it.pid }?.let { item ->
|
||||||
@ -75,69 +81,70 @@ class MainViewModel @Inject constructor(
|
|||||||
element = newItem
|
element = newItem
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = _uiState.value.copy(volumes = volumes)
|
|
||||||
}
|
}
|
||||||
is VolumeChangedEvent -> {
|
is VolumeChangedEvent -> {
|
||||||
addIfNotExist(event)
|
if (blockingPid == event.PID) return
|
||||||
val v = _uiState.value.volumes.toMutableList()
|
|
||||||
|
|
||||||
v.find { event.PID == it.pid }?.let { item ->
|
volumes.find { event.PID == it.pid }?.let { item ->
|
||||||
val newItem = item.copy(volume = event.volume)
|
item.volume.value = event.volume
|
||||||
v.set(
|
blockVolumeEvent(event.PID)
|
||||||
index = v.indexOfFirst { it.pid == newItem.pid },
|
|
||||||
element = newItem
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
_uiState.value = _uiState.value.copy(volumes = v)
|
|
||||||
}
|
}
|
||||||
UnknownEvent -> _uiState.value = _uiState.value.copy(isError = true)
|
UnknownEvent -> _uiState.value.isError // TODO: implement error state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blockVolumeEvent(eventPid: Long) {
|
||||||
|
blockingJob?.cancel()
|
||||||
|
blockingJob = viewModelScope.launch {
|
||||||
|
blockingPid = eventPid
|
||||||
|
delay(200)
|
||||||
|
blockingPid = NOT_BLOCKED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onVolumeChanged(volumeItemState: VolumeItemState, newVolume: Int) {
|
fun onVolumeChanged(volumeItemState: VolumeItemState, newVolume: Int) {
|
||||||
Log.i(javaClass.simpleName, "old volume: ${volumeItemState.volume}, newVolume: $newVolume")
|
Log.i(javaClass.simpleName, "old volume: ${volumeItemState.volume}, newVolume: $newVolume")
|
||||||
val increment = newVolume.minus(volumeItemState.volume)
|
volumeItemState.volume.value = newVolume
|
||||||
Log.i(javaClass.simpleName, "increment: $increment")
|
|
||||||
|
|
||||||
// val volumes = _uiState.value.volumes.toMutableList()
|
|
||||||
// volumes.find { volumeItemState.pid == it.pid }?.let { item ->
|
|
||||||
// val newItem = item.copy(volume = newVolume)
|
|
||||||
// volumes.set(
|
|
||||||
// index = volumes.indexOfFirst { it.pid == newItem.pid },
|
|
||||||
// element = newItem
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// _uiState.value = _uiState.value.copy(volumes = volumes)
|
|
||||||
|
|
||||||
sendCommand(SetVolumeCommand(volumeItemState.pid, newVolume))
|
sendCommand(SetVolumeCommand(volumeItemState.pid, newVolume))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onMuteClick(volumeItemState: VolumeItemState) {
|
||||||
|
Log.i(javaClass.simpleName, "new checked: ${!volumeItemState.isMuted.value}")
|
||||||
|
volumeItemState.isMuted.value = !volumeItemState.isMuted.value
|
||||||
|
sendCommand(MuteToggleCommand(volumeItemState.pid))
|
||||||
|
}
|
||||||
|
|
||||||
private fun sendCommand(command: Command) {
|
private fun sendCommand(command: Command) {
|
||||||
commandJob?.cancel()
|
viewModelScope.launch { socketRepositoryImpl.sendCommand(command) }
|
||||||
commandJob = viewModelScope.launch { socketRepositoryImpl.sendCommand(command) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addIfNotExist(event: VolumeChangedEvent) {
|
private fun addIfNotExist(event: NewSessionEvent) {
|
||||||
if (_uiState.value.volumes.find { it.pid == event.PID } == null) {
|
if (_uiState.value.volumes.find { it.pid == event.PID } == null) {
|
||||||
_uiState.value = MainScreenUiState(volumes = _uiState.value.volumes + event.toVolumeItemState())
|
_uiState.value.volumes.add(event.toVolumeItemState())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAddressClick() {
|
||||||
|
_showAddressDialog.value = true
|
||||||
|
Log.i(javaClass.simpleName, "address clicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfirmAddress(ip: String) {
|
||||||
|
if (isValidIp(ip)) {
|
||||||
|
prefHelper.setIpAddress(ip)
|
||||||
|
_showAddressDialog.value = false
|
||||||
|
connectToSocket()
|
||||||
|
Log.i(javaClass.simpleName, "new ip: $ip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentIp() = prefHelper.getIpAddress().also { Log.i(javaClass.simpleName, "Current ip: $it") }
|
||||||
|
|
||||||
|
private fun isValidIp(ip: String): Boolean = ip.matches(ipCheckRegex.toRegex())
|
||||||
|
.also { Log.i(javaClass.simpleName, "Regex matches: $it") }
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MainScreenUiState(
|
const val ipCheckRegex = "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}\$"
|
||||||
val volumes: List<VolumeItemState> = emptyList(),
|
|
||||||
val isError: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
data class VolumeItemState(
|
|
||||||
val pid: Long,
|
|
||||||
val name: String = "",
|
|
||||||
val volume: Int = 0,
|
|
||||||
val isMuted: Boolean = false,
|
|
||||||
val isActive: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
fun NewSessionEvent.toVolumeItemState() = VolumeItemState(pid = PID)
|
fun NewSessionEvent.toVolumeItemState() = VolumeItemState(pid = PID)
|
||||||
|
|
||||||
fun VolumeChangedEvent.toVolumeItemState() = VolumeItemState(pid = PID, volume = volume)
|
|
@ -0,0 +1,84 @@
|
|||||||
|
package com.dmitryzenevich.remoteaudiocontrol.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.dmitryzenevich.remoteaudiocontrol.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VolumeItem(
|
||||||
|
volumeItemState: VolumeItemState,
|
||||||
|
onValueChanged: (Float) -> Unit,
|
||||||
|
onMuteClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Top
|
||||||
|
) {
|
||||||
|
Text(text = volumeItemState.name)
|
||||||
|
Text(text = volumeItemState.volume.value.toString())
|
||||||
|
VerticalSlider(
|
||||||
|
value = volumeItemState.volume.value.toFloat(),
|
||||||
|
onValueChanged = onValueChanged,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = onMuteClick) {
|
||||||
|
if (volumeItemState.isMuted.value)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_volume_off),
|
||||||
|
contentDescription = stringResource(R.string.accessibility_muted)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_volume_up),
|
||||||
|
contentDescription = stringResource(R.string.accessibility_resumed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerticalSlider(
|
||||||
|
value: Float,
|
||||||
|
onValueChanged: (Float) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
valueRange = 0f..100f,
|
||||||
|
onValueChange = onValueChanged,
|
||||||
|
modifier = modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
rotationZ = 270f
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
}
|
||||||
|
.layout { measurable, constraints ->
|
||||||
|
val placeable = measurable.measure(
|
||||||
|
Constraints(
|
||||||
|
minWidth = constraints.minHeight,
|
||||||
|
maxWidth = constraints.maxHeight,
|
||||||
|
minHeight = constraints.minWidth,
|
||||||
|
maxHeight = constraints.maxHeight,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
layout(placeable.height, placeable.width) {
|
||||||
|
placeable.place(-placeable.width, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package com.dmitryzenevich.remoteaudiocontrol.presentation.helper
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PrefHelper @Inject constructor(private val sPref: SharedPreferences) {
|
||||||
|
|
||||||
|
fun getIpAddress(): String {
|
||||||
|
return sPref.getString(KEY_IP, "192.168.100.9")!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIpAddress(ip: String) = sPref.edit { putString(KEY_IP, ip) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY_IP = "key_ip"
|
||||||
|
}
|
||||||
|
}
|
@ -2,10 +2,10 @@ package com.dmitryzenevich.remoteaudiocontrol.presentation.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
val Purple80 = Color(0xFF80CBC4)
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val PurpleGrey80 = Color(0xFF4DB6AC)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
val Pink80 = Color(0xFF81C784)
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
val Purple40 = Color(0xFF009688)
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val PurpleGrey40 = Color(0xFF00897B)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val Pink40 = Color(0xFF80CBC4)
|
@ -0,0 +1,7 @@
|
|||||||
|
package com.dmitryzenevich.remoteaudiocontrol.presentation.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object Dimens {
|
||||||
|
val TabHeight = 56.dp
|
||||||
|
}
|
5
app/src/main/res/drawable/ic_volume_off.xml
Normal file
5
app/src/main/res/drawable/ic_volume_off.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9L3,9v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3zM12,4L9.91,6.09 12,8.18L12,4z"/>
|
||||||
|
</vector>
|
5
app/src/main/res/drawable/ic_volume_up.xml
Normal file
5
app/src/main/res/drawable/ic_volume_up.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
|
||||||
|
</vector>
|
@ -1,3 +1,9 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Remote Audio Control</string>
|
<string name="app_name">Remote Audio Control</string>
|
||||||
|
<string name="ip_dialog_title">Enter a valid ip address</string>
|
||||||
|
|
||||||
|
|
||||||
|
<string name="accessibility_ip_address_button">Choose server ip address</string>
|
||||||
|
<string name="accessibility_muted">Muted</string>
|
||||||
|
<string name="accessibility_resumed">Resumed</string>
|
||||||
</resources>
|
</resources>
|
Loading…
x
Reference in New Issue
Block a user