feat(ui): add PlayerBottomSheet and PlayerViewModel
This commit is contained in:
171
app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt
Normal file
171
app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package com.radiola.ui.player
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Pause
|
||||||
|
import com.composables.icons.lucide.Play
|
||||||
|
import com.radiola.domain.model.DeeplinkService
|
||||||
|
import com.radiola.domain.model.Station
|
||||||
|
import com.radiola.domain.model.Track
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlayerBottomSheet(
|
||||||
|
station: Station?,
|
||||||
|
track: Track?,
|
||||||
|
isPlaying: Boolean,
|
||||||
|
onPlayPause: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: PlayerViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(Color(0xFF1a1a2e), Color(0xFF121212))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
station?.let {
|
||||||
|
Text(
|
||||||
|
text = it.name,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF888888)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(240.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(
|
||||||
|
Brush.linearGradient(listOf(Color(0xFF667eea), Color(0xFF764ba2)))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = track?.coverUrl ?: station?.coverUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = track?.song ?: "",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = track?.artist ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF888888),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
items(enabledServices) { service ->
|
||||||
|
DeeplinkButton(
|
||||||
|
service = service,
|
||||||
|
onClick = {
|
||||||
|
track?.let { t ->
|
||||||
|
val url = viewModel.getDeeplinkUrl(t, service)
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(30.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
ControlButton(size = 56.dp, onClick = { })
|
||||||
|
ControlButton(
|
||||||
|
size = 72.dp,
|
||||||
|
isPlay = true,
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
onClick = onPlayPause
|
||||||
|
)
|
||||||
|
ControlButton(size = 56.dp, onClick = { })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeeplinkButton(
|
||||||
|
service: DeeplinkService,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFF2A2A2A))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = service.displayName.take(2),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color(0xFF888888)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlButton(
|
||||||
|
size: androidx.compose.ui.unit.Dp,
|
||||||
|
isPlay: Boolean = false,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isPlay) Color.White else Color(0xFF2A2A2A))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (isPlay) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
|
tint = Color.Black,
|
||||||
|
modifier = Modifier.size(size * 0.4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
Normal file
66
app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package com.radiola.ui.player
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.radiola.domain.model.DeeplinkService
|
||||||
|
import com.radiola.domain.model.Station
|
||||||
|
import com.radiola.domain.model.Track
|
||||||
|
import com.radiola.domain.repository.SettingsRepository
|
||||||
|
import com.radiola.domain.usecase.GetNowPlayingUseCase
|
||||||
|
import com.radiola.domain.usecase.SearchTrackInServiceUseCase
|
||||||
|
import com.radiola.service.PlayerController
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PlayerViewModel @Inject constructor(
|
||||||
|
private val playerController: PlayerController,
|
||||||
|
private val getNowPlayingUseCase: GetNowPlayingUseCase,
|
||||||
|
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
|
||||||
|
private val settingsRepository: SettingsRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
|
||||||
|
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
|
||||||
|
|
||||||
|
private val _currentTrack = MutableStateFlow<Track?>(null)
|
||||||
|
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow()
|
||||||
|
|
||||||
|
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
|
||||||
|
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepository.getEnabledDeeplinkServices().collect { ids ->
|
||||||
|
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(station: Station) {
|
||||||
|
playerController.play(station.streamUrl, station.prefix)
|
||||||
|
viewModelScope.launch {
|
||||||
|
getNowPlayingUseCase(station.prefix).collect { track ->
|
||||||
|
_currentTrack.value = track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
playerController.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume(station: Station) {
|
||||||
|
if (playerController.currentStationPrefix.value == station.prefix) {
|
||||||
|
playerController.exoPlayer.play()
|
||||||
|
} else {
|
||||||
|
play(station)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeeplinkUrl(track: Track, service: DeeplinkService): String {
|
||||||
|
return searchTrackInServiceUseCase(track, service)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user