mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
323 lines
14 KiB
Kotlin
323 lines
14 KiB
Kotlin
package com.subtracks.ui
|
|
|
|
import android.net.Uri
|
|
import androidx.activity.compose.BackHandler
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.core.animate
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.slideInHorizontally
|
|
import androidx.compose.animation.slideInVertically
|
|
import androidx.compose.animation.slideOutHorizontally
|
|
import androidx.compose.animation.slideOutVertically
|
|
import androidx.compose.foundation.gestures.Orientation
|
|
import androidx.compose.foundation.gestures.draggable
|
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.navigationBars
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableFloatStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
import androidx.compose.ui.layout.positionInRoot
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.lifecycle.viewModelScope
|
|
import androidx.navigation.NavType
|
|
import androidx.navigation.compose.NavHost
|
|
import androidx.navigation.compose.composable
|
|
import androidx.navigation.compose.rememberNavController
|
|
import androidx.navigation.navArgument
|
|
import com.subtracks.data.repo.SourceRepository
|
|
import com.subtracks.playback.PlaybackController
|
|
import com.subtracks.ui.components.LoadingState
|
|
import com.subtracks.ui.library.AlbumDetailRoute
|
|
import com.subtracks.ui.library.ArtistDetailRoute
|
|
import com.subtracks.ui.library.LibraryRoute
|
|
import com.subtracks.ui.library.PlaylistDetailRoute
|
|
import com.subtracks.ui.playback.MiniPlayer
|
|
import com.subtracks.ui.playback.NowPlayingRoute
|
|
import com.subtracks.ui.playback.QueueRoute
|
|
import com.subtracks.ui.settings.AddSourceRoute
|
|
import com.subtracks.ui.settings.SettingsRoute
|
|
import com.subtracks.ui.theme.rememberArtworkColors
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.flow.SharingStarted
|
|
import kotlinx.coroutines.flow.map
|
|
import kotlinx.coroutines.flow.stateIn
|
|
import kotlinx.coroutines.launch
|
|
import org.koin.compose.koinInject
|
|
import org.koin.compose.viewmodel.koinViewModel
|
|
|
|
class RootViewModel(
|
|
sourceRepository: SourceRepository,
|
|
) : ViewModel() {
|
|
val hasSource =
|
|
sourceRepository
|
|
.activeSourceId()
|
|
.map { it != null }
|
|
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
|
}
|
|
|
|
private const val NAVIGATION_DURATION_MS = 260
|
|
private const val OVERLAY_DURATION_MS = 200
|
|
private const val EXPAND_FADE = 0.1f
|
|
private const val FLING_VELOCITY = 1000f
|
|
|
|
private object Routes {
|
|
const val LIBRARY = "library"
|
|
const val SETTINGS = "settings"
|
|
const val ADD_SERVER = "add-server"
|
|
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
|
|
const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}"
|
|
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
|
|
|
|
fun album(
|
|
id: String,
|
|
coverArt: String? = null,
|
|
) = "album/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}"
|
|
|
|
fun artist(
|
|
id: String,
|
|
coverArt: String? = null,
|
|
) = "artist/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}"
|
|
|
|
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
|
|
}
|
|
|
|
@Composable
|
|
fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
|
|
when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) {
|
|
null -> LoadingState()
|
|
false -> AddSourceRoute(onSaved = {}, onBack = null)
|
|
true -> MainNavigation()
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun MainNavigation() {
|
|
val navController = rememberNavController()
|
|
val playbackController = koinInject<PlaybackController>()
|
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
|
var showingQueue by rememberSaveable { mutableStateOf(false) }
|
|
var nowPlayingOpen by rememberSaveable { mutableStateOf(false) }
|
|
var nowPlayingProgress by remember { mutableFloatStateOf(0f) }
|
|
var miniPlayerTopPx by remember { mutableFloatStateOf(0f) }
|
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
|
val scope = rememberCoroutineScope()
|
|
|
|
fun settleNowPlaying(open: Boolean) {
|
|
if (open) nowPlayingOpen = true
|
|
settleJob?.cancel()
|
|
settleJob =
|
|
scope.launch {
|
|
animate(nowPlayingProgress, if (open) 1f else 0f, animationSpec = tween(OVERLAY_DURATION_MS)) { value, _ ->
|
|
nowPlayingProgress = value
|
|
}
|
|
if (!open) nowPlayingOpen = false
|
|
}
|
|
}
|
|
LaunchedEffect(Unit) { playbackController.connect() }
|
|
LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f }
|
|
|
|
val playerVisible = playback.item != null
|
|
LaunchedEffect(playerVisible) {
|
|
if (!playerVisible) {
|
|
settleJob?.cancel()
|
|
nowPlayingOpen = false
|
|
nowPlayingProgress = 0f
|
|
}
|
|
}
|
|
val density = LocalDensity.current
|
|
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
|
|
|
Box(Modifier.fillMaxSize()) {
|
|
Column(Modifier.fillMaxSize()) {
|
|
NavHost(
|
|
navController = navController,
|
|
modifier =
|
|
Modifier
|
|
.weight(1f)
|
|
.then(
|
|
if (playerVisible) {
|
|
Modifier.consumeWindowInsets(WindowInsets.navigationBars)
|
|
} else {
|
|
Modifier
|
|
},
|
|
),
|
|
startDestination = Routes.LIBRARY,
|
|
enterTransition = {
|
|
slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
|
fadeIn(tween(NAVIGATION_DURATION_MS))
|
|
},
|
|
exitTransition = {
|
|
slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
|
fadeOut(tween(NAVIGATION_DURATION_MS))
|
|
},
|
|
popEnterTransition = {
|
|
slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
|
fadeIn(tween(NAVIGATION_DURATION_MS))
|
|
},
|
|
popExitTransition = {
|
|
slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
|
fadeOut(tween(NAVIGATION_DURATION_MS))
|
|
},
|
|
) {
|
|
composable(Routes.LIBRARY) {
|
|
LibraryRoute(
|
|
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
|
|
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
|
|
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
|
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
|
bottomInset = if (playerVisible) 0.dp else navBarInset,
|
|
)
|
|
}
|
|
composable(Routes.SETTINGS) {
|
|
SettingsRoute(
|
|
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
composable(
|
|
route = Routes.ALBUM_DETAIL,
|
|
arguments =
|
|
listOf(
|
|
navArgument("albumId") { type = NavType.StringType },
|
|
navArgument("coverArt") {
|
|
type = NavType.StringType
|
|
defaultValue = ""
|
|
},
|
|
),
|
|
) { entry ->
|
|
AlbumDetailRoute(
|
|
albumId = entry.arguments?.getString("albumId").orEmpty(),
|
|
coverArtId =
|
|
entry.arguments
|
|
?.getString("coverArt")
|
|
.orEmpty()
|
|
.ifEmpty { null },
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
composable(
|
|
route = Routes.ARTIST_DETAIL,
|
|
arguments =
|
|
listOf(
|
|
navArgument("artistId") { type = NavType.StringType },
|
|
navArgument("coverArt") {
|
|
type = NavType.StringType
|
|
defaultValue = ""
|
|
},
|
|
),
|
|
) { entry ->
|
|
ArtistDetailRoute(
|
|
artistId = entry.arguments?.getString("artistId").orEmpty(),
|
|
coverArtId =
|
|
entry.arguments
|
|
?.getString("coverArt")
|
|
.orEmpty()
|
|
.ifEmpty { null },
|
|
onBack = { navController.popBackStack() },
|
|
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
|
|
)
|
|
}
|
|
composable(
|
|
route = Routes.PLAYLIST_DETAIL,
|
|
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
|
) { entry ->
|
|
PlaylistDetailRoute(
|
|
playlistId = entry.arguments?.getString("playlistId").orEmpty(),
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
composable(Routes.ADD_SERVER) {
|
|
AddSourceRoute(
|
|
onSaved = { navController.popBackStack() },
|
|
onBack = { navController.popBackStack() },
|
|
)
|
|
}
|
|
}
|
|
|
|
if (playerVisible) {
|
|
val miniArt = playbackController.coverArt(playback.item, thumbnail = true)
|
|
MiniPlayer(
|
|
state = playback,
|
|
coverArt = miniArt,
|
|
artwork = rememberArtworkColors(miniArt),
|
|
onExpand = { settleNowPlaying(true) },
|
|
onPlayPause = playbackController::togglePlayPause,
|
|
onNext = playbackController::next,
|
|
onExpandDrag = { dragUpPx ->
|
|
nowPlayingOpen = true
|
|
settleJob?.cancel()
|
|
if (miniPlayerTopPx > 0f) {
|
|
nowPlayingProgress = (dragUpPx / miniPlayerTopPx).coerceIn(0f, 1f)
|
|
}
|
|
},
|
|
onExpandRelease = { velocity -> settleNowPlaying(nowPlayingProgress > 0.05f || velocity < -FLING_VELOCITY) },
|
|
modifier = Modifier.onGloballyPositioned { miniPlayerTopPx = it.positionInRoot().y },
|
|
)
|
|
}
|
|
}
|
|
|
|
if (nowPlayingOpen) {
|
|
Box(
|
|
modifier =
|
|
Modifier
|
|
.fillMaxSize()
|
|
.graphicsLayer {
|
|
translationY = (1f - nowPlayingProgress) * miniPlayerTopPx
|
|
alpha = (nowPlayingProgress / EXPAND_FADE).coerceAtMost(1f)
|
|
}.draggable(
|
|
orientation = Orientation.Vertical,
|
|
state =
|
|
rememberDraggableState { delta ->
|
|
settleJob?.cancel()
|
|
if (miniPlayerTopPx > 0f) {
|
|
nowPlayingProgress = (nowPlayingProgress - delta / miniPlayerTopPx).coerceIn(0f, 1f)
|
|
}
|
|
},
|
|
onDragStopped = { velocity -> settleNowPlaying(nowPlayingProgress > 0.9f && velocity < FLING_VELOCITY) },
|
|
),
|
|
) {
|
|
NowPlayingRoute(
|
|
onBack = { settleNowPlaying(false) },
|
|
onQueue = { showingQueue = true },
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
}
|
|
|
|
AnimatedVisibility(
|
|
visible = showingQueue,
|
|
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(OVERLAY_DURATION_MS)),
|
|
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(OVERLAY_DURATION_MS)),
|
|
) {
|
|
QueueRoute(
|
|
onBack = { showingQueue = false },
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
}
|
|
|
|
BackHandler(enabled = showingQueue) { showingQueue = false }
|
|
BackHandler(enabled = nowPlayingOpen && !showingQueue) {
|
|
settleNowPlaying(false)
|
|
}
|
|
}
|