mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Basic sync lyric support
This commit is contained in:
parent
bd7fba14d1
commit
49adae9365
7 changed files with 330 additions and 70 deletions
|
|
@ -87,6 +87,8 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -321,7 +323,13 @@ class MainActivity : AppCompatActivity() {
|
||||||
server = current.server,
|
server = current.server,
|
||||||
startDestination =
|
startDestination =
|
||||||
requestedDestination
|
requestedDestination
|
||||||
?: Destination.Home(),
|
// ?: Destination.Home(),
|
||||||
|
// TODO undo
|
||||||
|
?: Destination.MediaItem(
|
||||||
|
itemId = "7e64e319-657a-9516-ec78-490da03edccb".toUUID(),
|
||||||
|
type = BaseItemKind.COLLECTION_FOLDER,
|
||||||
|
collectionType = CollectionType.MUSIC,
|
||||||
|
),
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ data class AudioItem(
|
||||||
val artistNames: String?,
|
val artistNames: String?,
|
||||||
val runtime: Duration?,
|
val runtime: Duration?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
|
val hasLyrics: Boolean,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(
|
fun from(
|
||||||
|
|
@ -28,6 +29,7 @@ data class AudioItem(
|
||||||
artistNames = item.data.albumArtist,
|
artistNames = item.data.albumArtist,
|
||||||
runtime = item.data.runTimeTicks?.ticks,
|
runtime = item.data.runTimeTicks?.ticks,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
|
hasLyrics = item.data.hasLyrics == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import org.jellyfin.sdk.model.api.LyricDto
|
||||||
|
import org.jellyfin.sdk.model.api.LyricLine
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LyricsContent(
|
||||||
|
lyrics: LyricDto?,
|
||||||
|
currentLyricPosition: Int?,
|
||||||
|
onClick: (LyricLine) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState(currentLyricPosition ?: 0)
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
LaunchedEffect(currentLyricPosition) {
|
||||||
|
if (currentLyricPosition != null) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
if (lyrics?.lyrics?.isNotEmpty() == true) {
|
||||||
|
itemsIndexed(lyrics.lyrics) { index, lyric ->
|
||||||
|
val color by animateColorAsState(
|
||||||
|
if (index == currentLyricPosition || currentLyricPosition == null) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
|
||||||
|
},
|
||||||
|
animationSpec = tween(durationMillis = 500, easing = LinearEasing),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = lyric.text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = color,
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
index == currentLyricPosition,
|
||||||
|
Modifier.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,8 +24,8 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
import com.github.damontecres.wholphin.services.MusicServiceState
|
|
||||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
|
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
|
||||||
import com.github.damontecres.wholphin.ui.playback.SeekBar
|
import com.github.damontecres.wholphin.ui.playback.SeekBar
|
||||||
import com.github.damontecres.wholphin.ui.roundSeconds
|
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||||
|
|
@ -36,7 +36,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NowPlayingOverlay(
|
fun NowPlayingOverlay(
|
||||||
state: MusicServiceState,
|
state: NowPlayingState,
|
||||||
player: Player,
|
player: Player,
|
||||||
current: AudioItem?,
|
current: AudioItem?,
|
||||||
queue: List<AudioItem>,
|
queue: List<AudioItem>,
|
||||||
|
|
@ -79,7 +79,23 @@ fun NowPlayingOverlay(
|
||||||
player = player,
|
player = player,
|
||||||
initialFocusRequester = focusRequester,
|
initialFocusRequester = focusRequester,
|
||||||
onControllerInteraction = { controllerViewState.pulseControls() },
|
onControllerInteraction = { controllerViewState.pulseControls() },
|
||||||
onPlaybackActionClick = {},
|
onPlaybackActionClick = {
|
||||||
|
when (it) {
|
||||||
|
PlaybackAction.Next -> {
|
||||||
|
nextState.onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.Previous -> {
|
||||||
|
previousState.onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.ToggleCaptions -> {
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
showPlay = playPauseState.showPlay,
|
showPlay = playPauseState.showPlay,
|
||||||
previousEnabled = previousState.isEnabled,
|
previousEnabled = previousState.isEnabled,
|
||||||
nextEnabled = nextState.isEnabled,
|
nextEnabled = nextState.isEnabled,
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,40 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.music
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
import android.content.Context
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
|
||||||
import com.github.damontecres.wholphin.services.MusicService
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
|
||||||
import com.github.damontecres.wholphin.services.rememberQueue
|
import com.github.damontecres.wholphin.services.rememberQueue
|
||||||
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
|
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import dagger.assisted.AssistedFactory
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
|
|
||||||
class NowPlayingViewModel
|
|
||||||
@AssistedInject
|
|
||||||
constructor(
|
|
||||||
private val api: ApiClient,
|
|
||||||
@param:ApplicationContext private val context: Context,
|
|
||||||
private val navigationManager: NavigationManager,
|
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
|
||||||
private val backdropService: BackdropService,
|
|
||||||
private val imageUrlService: ImageUrlService,
|
|
||||||
private val musicService: MusicService,
|
|
||||||
val userPreferencesService: UserPreferencesService,
|
|
||||||
) : ViewModel() {
|
|
||||||
@AssistedFactory
|
|
||||||
interface Factory {
|
|
||||||
fun create(): NowPlayingViewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
val controllerViewState =
|
|
||||||
ControllerViewState(
|
|
||||||
AppPreference.ControllerTimeout.defaultValue,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
val state get() = musicService.state
|
|
||||||
val player get() = musicService.player
|
|
||||||
|
|
||||||
init {
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reportInteraction() {
|
|
||||||
controllerViewState.pulseControls()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NowPlayingPage(
|
fun NowPlayingPage(
|
||||||
|
|
@ -88,8 +46,8 @@ fun NowPlayingPage(
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val player = viewModel.player
|
val player = viewModel.player
|
||||||
val queue = rememberQueue(player, state.queueSize)
|
val queue = rememberQueue(player, state.musicServiceState.queueSize)
|
||||||
val current = queue.getOrNull(state.currentIndex)
|
val current = queue.getOrNull(state.musicServiceState.currentIndex)
|
||||||
|
|
||||||
val controllerViewState = viewModel.controllerViewState
|
val controllerViewState = viewModel.controllerViewState
|
||||||
val preferences =
|
val preferences =
|
||||||
|
|
@ -124,23 +82,51 @@ fun NowPlayingPage(
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
Box(modifier) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
|
||||||
modifier
|
|
||||||
.onPreviewKeyEvent(keyHandler::onKeyEvent)
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.focusable(),
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
contentDescription = null,
|
|
||||||
model = current?.imageUrl,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(80.dp)
|
.fillMaxSize()
|
||||||
.fillMaxSize(),
|
.onPreviewKeyEvent(keyHandler::onKeyEvent)
|
||||||
)
|
.focusRequester(focusRequester)
|
||||||
|
.focusable(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.lyrics != null && state.lyrics?.lyrics?.isNotEmpty() == true,
|
||||||
|
enter = expandHorizontally(expandFrom = Alignment.Start),
|
||||||
|
exit = shrinkHorizontally(shrinkTowards = Alignment.Start),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LyricsContent(
|
||||||
|
lyrics = state.lyrics,
|
||||||
|
currentLyricPosition = state.currentLyricIndex,
|
||||||
|
onClick = {},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(vertical = 120.dp, horizontal = 32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(320.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
contentDescription = null,
|
||||||
|
model = current?.imageUrl,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(80.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(controllerViewState.controlsVisible) {
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
}
|
||||||
AnimatedVisibility(controllerViewState.controlsVisible) {
|
AnimatedVisibility(controllerViewState.controlsVisible) {
|
||||||
NowPlayingOverlay(
|
NowPlayingOverlay(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.github.damontecres.wholphin.services.MusicServiceState
|
||||||
|
import org.jellyfin.sdk.model.api.LyricDto
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class NowPlayingState(
|
||||||
|
val musicServiceState: MusicServiceState,
|
||||||
|
val lyrics: LyricDto? = null,
|
||||||
|
val currentLyricIndex: Int? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
|
||||||
|
import com.mayakapps.kache.InMemoryKache
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.lyricsApi
|
||||||
|
import org.jellyfin.sdk.model.api.LyricDto
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
|
||||||
|
class NowPlayingViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val musicService: MusicService,
|
||||||
|
val userPreferencesService: UserPreferencesService,
|
||||||
|
) : ViewModel(),
|
||||||
|
Player.Listener {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(): NowPlayingViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
val controllerViewState =
|
||||||
|
ControllerViewState(
|
||||||
|
AppPreference.Companion.ControllerTimeout.defaultValue,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = MutableStateFlow(NowPlayingState(musicService.state.value))
|
||||||
|
val player get() = musicService.player
|
||||||
|
|
||||||
|
private val lyricCache =
|
||||||
|
InMemoryKache<UUID, LyricDto>(20) {
|
||||||
|
creationScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
player.addListener(this)
|
||||||
|
addCloseable { player.removeListener(this) }
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
musicService.state.collectLatest { musicServiceState ->
|
||||||
|
state.update { it.copy(musicServiceState = musicServiceState) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playbackLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportInteraction() {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrent(): AudioItem? {
|
||||||
|
val mediaItem =
|
||||||
|
onMain { player.currentMediaItemIndex.let { player.getMediaItemAt(it) } }
|
||||||
|
return mediaItem.localConfiguration?.tag as? AudioItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playbackLoop() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
while (isActive) {
|
||||||
|
val position = onMain { player.currentPosition }.milliseconds
|
||||||
|
// Timber.v("playbackLoop: %s", position)
|
||||||
|
getCurrent()?.let { audio ->
|
||||||
|
// Timber.v("Got current %s", audio.id)
|
||||||
|
if (audio.hasLyrics) {
|
||||||
|
val lyrics =
|
||||||
|
lyricCache.getOrPut(audio.id) {
|
||||||
|
// TODO remote lyrics?
|
||||||
|
api.lyricsApi.getLyrics(audio.id).content
|
||||||
|
}
|
||||||
|
val lyricIndex =
|
||||||
|
if (lyrics != null) {
|
||||||
|
val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO
|
||||||
|
val lyricPosition = offset + position
|
||||||
|
lyrics.lyrics
|
||||||
|
.indexOfLast {
|
||||||
|
it.start?.ticks?.let { lyricPosition >= it } == true
|
||||||
|
}.takeIf { it >= 0 }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
Timber.v("lyricIndex=$lyricIndex")
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
lyrics = lyrics,
|
||||||
|
currentLyricIndex = lyricIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaItemTransition(
|
||||||
|
mediaItem: MediaItem?,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val audio = mediaItem?.localConfiguration?.tag as? AudioItem
|
||||||
|
Timber.v("onMediaItemTransition to %s", audio?.id)
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
lyrics = null,
|
||||||
|
currentLyricIndex = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
audio?.let { audio ->
|
||||||
|
if (audio.hasLyrics) {
|
||||||
|
val lyrics =
|
||||||
|
lyricCache.getOrPut(audio.id) {
|
||||||
|
// TODO remote lyrics?
|
||||||
|
api.lyricsApi.getLyrics(audio.id).content
|
||||||
|
}
|
||||||
|
Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null)
|
||||||
|
state.update {
|
||||||
|
it.copy(
|
||||||
|
lyrics = lyrics,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue