feat: sync Playlists, wip: pair songs
This commit is contained in:
128
app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt
Normal file
128
app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt
Normal file
@@ -0,0 +1,128 @@
|
||||
package at.lockstep.player.ui
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import at.lockstep.jukebox.PlaylistSummary
|
||||
import at.lockstep.player.LockstepViewModel
|
||||
import at.lockstep.player.playback.PlaybackService
|
||||
import at.lockstep.player.ui.library.LibraryScreen
|
||||
import at.lockstep.player.ui.navigation.Routes
|
||||
import at.lockstep.player.ui.pairing.PairingScreen
|
||||
import at.lockstep.player.ui.settings.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun LockstepAppNavHost(
|
||||
activity: ComponentActivity,
|
||||
navController: NavHostController,
|
||||
viewModel: LockstepViewModel,
|
||||
) {
|
||||
var playback by remember { mutableStateOf<PlaybackService?>(null) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val connection =
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(
|
||||
name: ComponentName?,
|
||||
binder: IBinder?,
|
||||
) {
|
||||
playback = (binder as PlaybackService.LocalBinder).getService()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
playback = null
|
||||
}
|
||||
}
|
||||
activity.bindService(
|
||||
Intent(activity, PlaybackService::class.java),
|
||||
connection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
onDispose {
|
||||
activity.unbindService(connection)
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.Library,
|
||||
) {
|
||||
composable(Routes.Library) {
|
||||
LibraryScreen(
|
||||
viewModel = viewModel,
|
||||
onPlaylistSelected = { playlist: PlaylistSummary, hasPaired ->
|
||||
if (!hasPaired) {
|
||||
navController.navigate(Routes.pairing(playlist.id))
|
||||
} else {
|
||||
startPlaylistPlayback(activity, playlist.id)
|
||||
navController.navigate(Routes.nowPlaying(playlist.id))
|
||||
}
|
||||
},
|
||||
onOpenSettings = {
|
||||
navController.navigate(Routes.Settings)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.Settings) {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.Pairing,
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("playlistId") { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val playlistId = entry.arguments?.getString("playlistId").orEmpty()
|
||||
PairingScreen(
|
||||
playlistId = playlistId,
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.NowPlaying,
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("playlistId") { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val playlistId = entry.arguments?.getString("playlistId").orEmpty()
|
||||
NowPlayingRoute(
|
||||
playlistId = playlistId,
|
||||
playback = playback,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPlaylistPlayback(
|
||||
context: Context,
|
||||
playlistId: String,
|
||||
) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, PlaybackService::class.java).apply {
|
||||
action = PlaybackService.ACTION_START_PLAYLIST
|
||||
putExtra(PlaybackService.EXTRA_PLAYLIST_ID, playlistId)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -8,16 +8,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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
|
||||
@@ -25,13 +30,18 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.lockstep.player.playback.PlaybackService
|
||||
import at.lockstep.player.R
|
||||
|
||||
data class NowPlayingUiState(
|
||||
val title: String,
|
||||
val artist: String,
|
||||
/** Placeholder until live cadence / JNI wiring; MVP shows "--". */
|
||||
val stepFrequencyDisplay: String,
|
||||
/** 0f..1f until real timing is wired to Oboe / JNI. */
|
||||
val progress: Float,
|
||||
/** Total track length in seconds (placeholder for UI formatting). */
|
||||
@@ -51,11 +61,25 @@ fun NowPlayingScreen(
|
||||
val elapsed = (state.progress * state.durationSeconds).toInt().coerceIn(0, state.durationSeconds)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = LocalContext.current.getString(R.string.now_playing_step_frequency_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = state.stepFrequencyDisplay,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = state.title,
|
||||
@@ -124,6 +148,70 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NowPlayingRoute(
|
||||
playlistId: String,
|
||||
playback: PlaybackService?,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var ui by remember {
|
||||
mutableStateOf(
|
||||
NowPlayingUiState(
|
||||
title = context.getString(R.string.now_playing_idle_title),
|
||||
artist = context.getString(R.string.now_playing_idle_artist),
|
||||
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
|
||||
progress = 0f,
|
||||
durationSeconds = 180,
|
||||
isPlaying = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
service.uiState.collect { p ->
|
||||
ui =
|
||||
NowPlayingUiState(
|
||||
title = p.title,
|
||||
artist = p.artist,
|
||||
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
|
||||
progress = p.progress,
|
||||
durationSeconds = p.durationSeconds,
|
||||
isPlaying = p.isPlaying,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = context.getString(R.string.now_playing_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
NowPlayingScreen(
|
||||
state = ui,
|
||||
onProgressChange = { fraction ->
|
||||
playback?.requestSeek(fraction)
|
||||
ui = ui.copy(progress = fraction)
|
||||
},
|
||||
onPrevious = { playback?.requestSkipPrevious() },
|
||||
onTogglePlayPause = { playback?.requestTogglePause() },
|
||||
onNext = { playback?.requestSkipNext() },
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMmSs(totalSeconds: Int): String {
|
||||
val s = totalSeconds.coerceAtLeast(0)
|
||||
val m = s / 60
|
||||
@@ -138,13 +226,15 @@ private fun NowPlayingScreenPreview() {
|
||||
var progress by remember { mutableFloatStateOf(0.35f) }
|
||||
var playing by remember { mutableStateOf(true) }
|
||||
NowPlayingScreen(
|
||||
state = NowPlayingUiState(
|
||||
title = "Example Track Title",
|
||||
artist = "Example Artist",
|
||||
progress = progress,
|
||||
durationSeconds = 215,
|
||||
isPlaying = playing,
|
||||
),
|
||||
state =
|
||||
NowPlayingUiState(
|
||||
title = "Example Track Title",
|
||||
artist = "Example Artist",
|
||||
stepFrequencyDisplay = "--",
|
||||
progress = progress,
|
||||
durationSeconds = 215,
|
||||
isPlaying = playing,
|
||||
),
|
||||
onProgressChange = { progress = it },
|
||||
onPrevious = {},
|
||||
onTogglePlayPause = { playing = !playing },
|
||||
|
||||
109
app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt
Normal file
109
app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package at.lockstep.player.ui.library
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import at.lockstep.jukebox.PlaylistSummary
|
||||
import at.lockstep.player.LockstepViewModel
|
||||
import at.lockstep.player.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
viewModel: LockstepViewModel,
|
||||
onPlaylistSelected: (PlaylistSummary, Boolean) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val token by viewModel.spotifyAccessToken.collectAsStateWithLifecycle()
|
||||
val playlists by viewModel.observePlaylists().observeAsState(initial = emptyList())
|
||||
|
||||
LaunchedEffect(token) {
|
||||
if (token.isNullOrBlank()) return@LaunchedEffect
|
||||
val err = viewModel.syncJukeboxIfToken()
|
||||
if (err != null) {
|
||||
Toast.makeText(context, err, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = context.getString(R.string.library_title)) },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = context.getString(R.string.settings_title))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(playlists, key = { it.id }) { playlist ->
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val hasPaired =
|
||||
viewModel.hasPairedTracks(playlist.id)
|
||||
onPlaylistSelected(playlist, hasPaired)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(playlist.name) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = context.getString(R.string.library_open_playlist),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt
Normal file
13
app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package at.lockstep.player.ui.navigation
|
||||
|
||||
object Routes {
|
||||
const val Onboarding = "onboarding"
|
||||
const val Library = "library"
|
||||
const val Settings = "settings"
|
||||
const val Pairing = "pairing/{playlistId}"
|
||||
const val NowPlaying = "nowPlaying/{playlistId}"
|
||||
|
||||
fun pairing(playlistId: String) = "pairing/$playlistId"
|
||||
|
||||
fun nowPlaying(playlistId: String) = "nowPlaying/$playlistId"
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package at.lockstep.player.ui.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material3.Button
|
||||
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.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import at.lockstep.player.LockstepViewModel
|
||||
import at.lockstep.player.R
|
||||
import at.lockstep.player.auth.SpotifyLogin
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
viewModel: LockstepViewModel,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var step by remember { mutableIntStateOf(0) }
|
||||
val token by viewModel.spotifyAccessToken.collectAsStateWithLifecycle()
|
||||
|
||||
val notificationsLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) {
|
||||
step = 1
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.onboarding_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
when (step) {
|
||||
0 -> {
|
||||
Text(
|
||||
text = context.getString(R.string.onboarding_notifications_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
step = 1
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = context.getString(R.string.onboarding_notifications_cta))
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
Text(
|
||||
text = context.getString(R.string.onboarding_spotify_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (token != null) {
|
||||
Text(
|
||||
text = context.getString(R.string.onboarding_spotify_connected),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val tabs = CustomTabsIntent.Builder().build()
|
||||
SpotifyLogin.launch(context, tabs)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = context.getString(R.string.onboarding_spotify_open_browser))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.completeOnboarding()
|
||||
onFinished()
|
||||
},
|
||||
enabled = !token.isNullOrBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = context.getString(R.string.onboarding_continue_signed_in))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.completeOnboarding()
|
||||
onFinished()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = context.getString(R.string.onboarding_continue_without_spotify))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
217
app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt
Normal file
217
app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt
Normal file
@@ -0,0 +1,217 @@
|
||||
package at.lockstep.player.ui.pairing
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import at.lockstep.jukebox.db.TrackRow
|
||||
import at.lockstep.player.LockstepViewModel
|
||||
import at.lockstep.player.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PairingScreen(
|
||||
playlistId: String,
|
||||
viewModel: LockstepViewModel,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val tracks by viewModel.observeJukeboxTracks(playlistId).observeAsState(initial = emptyList())
|
||||
val pairings by viewModel.observePairings(playlistId).collectAsStateWithLifecycle(emptyList())
|
||||
val pairingByTrackId = pairings.associateBy { it.trackId }
|
||||
var pendingTrack by remember { mutableStateOf<TrackRow?>(null) }
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
viewModel.ensurePlaylistTracksLoaded(playlistId)
|
||||
}
|
||||
|
||||
val openDoc =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
onResult = { uri ->
|
||||
val track = pendingTrack
|
||||
pendingTrack = null
|
||||
if (track != null && uri != null) {
|
||||
viewModel.pairTrackFromUri(playlistId, track, uri) { ok, message ->
|
||||
if (!ok && message != null) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val openTree =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
onResult = { tree ->
|
||||
if (tree != null) {
|
||||
viewModel.pairPlaylistFromFolder(playlistId, tree) { r ->
|
||||
when {
|
||||
r.mp3Count == 0 ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.pairing_no_mp3_in_folder),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
|
||||
r.jukeboxRowCount == 0 ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.pairing_jukebox_empty),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
|
||||
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.pairing_all_missing_spotify_id),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
|
||||
r.paired == 0 && r.failed == 0 ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.pairing_no_mp3_in_folder),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
|
||||
r.failed > 0 ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
|
||||
else ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.pairing_folder_ok, r.paired),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = context.getString(R.string.pairing_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Button(
|
||||
onClick = { openTree.launch(null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(context.getString(R.string.pairing_choose_folder))
|
||||
}
|
||||
}
|
||||
items(tracks, key = { it.trackId ?: "removed-${it.position}" }) { track ->
|
||||
val p = track.trackId?.let { pairingByTrackId[it] }
|
||||
val paired = p?.localUri != null && p.pairingError == null
|
||||
val muted = !paired
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
pendingTrack = track
|
||||
openDoc.launch(arrayOf("audio/mpeg", "audio/*"))
|
||||
},
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (muted) {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = track.trackName ?: context.getString(R.string.pairing_unknown_track),
|
||||
color =
|
||||
if (muted) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
val sub =
|
||||
when {
|
||||
paired ->
|
||||
context.getString(
|
||||
R.string.pairing_status_paired,
|
||||
)
|
||||
p?.pairingError != null ->
|
||||
p.pairingError
|
||||
else ->
|
||||
context.getString(
|
||||
R.string.pairing_status_unpaired,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = sub,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package at.lockstep.player.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.lockstep.player.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = context.getString(R.string.settings_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.settings_stub_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/main/java/at/lockstep/player/ui/theme/Color.kt
Normal file
11
app/src/main/java/at/lockstep/player/ui/theme/Color.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package at.lockstep.player.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val LockstepOrange = Color(0xFFE65100)
|
||||
val LockstepOrangeLight = Color(0xFFFF833A)
|
||||
val LockstepBlueDark = Color(0xFF0D47A1)
|
||||
val LockstepBlue = Color(0xFF1976D2)
|
||||
val LockstepBlueDeep = Color(0xFF002171)
|
||||
val LockstepSurfaceDark = Color(0xFF0B1220)
|
||||
val LockstepSurfaceLight = Color(0xFFFFF8F2)
|
||||
57
app/src/main/java/at/lockstep/player/ui/theme/Theme.kt
Normal file
57
app/src/main/java/at/lockstep/player/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,57 @@
|
||||
package at.lockstep.player.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val LightScheme =
|
||||
lightColorScheme(
|
||||
primary = LockstepOrange,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = LockstepOrangeLight,
|
||||
onPrimaryContainer = Color.Black,
|
||||
secondary = LockstepBlue,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = LockstepBlue.copy(alpha = 0.35f),
|
||||
onSecondaryContainer = LockstepBlueDeep,
|
||||
background = LockstepSurfaceLight,
|
||||
onBackground = LockstepBlueDeep,
|
||||
surface = Color(0xFFFFFBF7),
|
||||
onSurface = LockstepBlueDeep,
|
||||
surfaceVariant = Color(0xFFE8EEF7),
|
||||
onSurfaceVariant = Color(0xFF30343B),
|
||||
outline = LockstepBlue.copy(alpha = 0.45f),
|
||||
)
|
||||
|
||||
private val DarkScheme =
|
||||
darkColorScheme(
|
||||
primary = LockstepOrangeLight,
|
||||
onPrimary = Color.Black,
|
||||
primaryContainer = LockstepOrange,
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = LockstepBlue,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = LockstepBlueDark,
|
||||
onSecondaryContainer = Color(0xFFD0E4FF),
|
||||
background = LockstepSurfaceDark,
|
||||
onBackground = Color(0xFFEEF2FA),
|
||||
surface = LockstepSurfaceDark,
|
||||
onSurface = Color(0xFFEEF2FA),
|
||||
surfaceVariant = Color(0xFF1E2836),
|
||||
onSurfaceVariant = Color(0xFFC3CAD6),
|
||||
outline = LockstepOrangeLight.copy(alpha = 0.45f),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LockstepTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkScheme else LightScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user