Minimal playback

This commit is contained in:
Damontecres 2025-09-22 17:02:32 -04:00
parent 80070f4174
commit 3e7f11c588
No known key found for this signature in database
14 changed files with 405 additions and 12 deletions

View file

@ -1,5 +1,7 @@
package com.github.damontecres.dolphin
import androidx.compose.ui.focus.FocusRequester
import timber.log.Timber
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@ -10,3 +12,15 @@ fun CharSequence?.isNotNullOrBlank(): Boolean {
}
return !this.isNullOrBlank()
}
/**
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
*/
fun FocusRequester.tryRequestFocus(): Boolean =
try {
requestFocus()
true
} catch (ex: IllegalStateException) {
Timber.w(ex, "Failed to request focus")
false
}

View file

@ -8,6 +8,7 @@ import java.util.UUID
sealed interface DolphinModel {
val id: UUID
val name: String?
val type: BaseItemKind
val imageUrl: String?
}

View file

@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
@ -10,8 +11,9 @@ import java.util.UUID
data class Library(
override val id: UUID,
override val name: String?,
override val type: BaseItemKind,
override val imageUrl: String?,
val type: CollectionType,
val collectionType: CollectionType,
) : DolphinModel {
companion object {
fun fromDto(
@ -21,8 +23,9 @@ data class Library(
Library(
id = dto.id,
name = dto.name,
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
type = dto.collectionType ?: CollectionType.UNKNOWN,
collectionType = dto.collectionType ?: CollectionType.UNKNOWN,
)
}
}

View file

@ -3,12 +3,14 @@ package com.github.damontecres.dolphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
data class Video(
override val id: UUID,
override val name: String?,
override val type: BaseItemKind,
override val imageUrl: String?,
) : DolphinModel {
companion object {
@ -19,6 +21,7 @@ data class Video(
Video(
id = dto.id,
name = dto.name,
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
)
}

View file

@ -0,0 +1,89 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class EpisodeViewModel
@Inject
constructor(
val api: ApiClient,
) : ViewModel() {
val item = MutableLiveData<BaseItemDto?>(null)
fun init(
itemId: UUID,
potential: BaseItemDto?,
) {
if (item.value == null && potential?.id == itemId) {
item.value = potential
return
}
if (item.value?.id == itemId) {
return
}
viewModelScope.launch {
try {
item.value = api.userLibraryApi.getItem(itemId).content
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
item.value = null
}
}
}
}
@Composable
fun EpisodeDetails(
preferences: UserPreferences,
navigationManager: NavigationManager,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: EpisodeViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
LazyColumn(modifier = modifier) {
item {
Text(text = item.name ?: "Unknown")
}
item {
Button(
onClick = {
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
},
) {
Text(text = "Play")
}
}
}
}
}
}

View file

@ -0,0 +1,28 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun MediaItemContent(
preferences: UserPreferences,
navigationManager: NavigationManager,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
) {
when (destination.type) {
BaseItemKind.SERIES -> TODO()
BaseItemKind.SEASON -> TODO()
BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier)
BaseItemKind.MOVIE -> TODO()
BaseItemKind.VIDEO -> TODO()
else -> {
Text("Unsupported item type: ${destination.type}")
}
}
}

View file

@ -32,9 +32,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import timber.log.Timber
import javax.inject.Inject
@ -87,8 +89,6 @@ class MainViewModel
.getLatestMedia(request)
.content
.map { convertModel(it, api) }
Timber.v("latest: %s", latest)
// api.itemsApi.getItems(request)
HomeRow(
section = section,
items = latest,
@ -100,7 +100,26 @@ class MainViewModel
HomeSection.LIBRARY_TILES_SMALL -> null
HomeSection.RESUME -> null
HomeSection.ACTIVE_RECORDINGS -> null
HomeSection.NEXT_UP -> null
HomeSection.NEXT_UP -> {
val request =
GetNextUpRequest(
fields = listOf(),
imageTypeLimit = 1,
parentId = null,
limit = 25,
enableResumable = false,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { convertModel(it, api) }
HomeRow(
section = section,
items = nextUp,
)
}
HomeSection.LIVE_TV -> null
// TODO Not supported?
@ -132,7 +151,14 @@ fun MainPage(
item {
HomePageRow(
row = row,
onClickItem = { navigationManager.navigateTo(Destination.MediaItem(it.id)) },
onClickItem = {
navigationManager.navigateTo(
Destination.MediaItem(
it.id,
it.type,
),
)
},
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -3,6 +3,8 @@ package com.github.damontecres.dolphin.ui.nav
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.dolphin.util.UuidSerializer
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
@Serializable
@ -24,11 +26,14 @@ sealed class Destination(
@Serializable
data class MediaItem(
@Serializable(with = UuidSerializer::class) val itemId: UUID,
val type: BaseItemKind,
@Transient val item: BaseItemDto? = null,
) : Destination()
@Serializable
data class Playback(
val itemId: String,
@Serializable(with = UuidSerializer::class) val itemId: UUID,
val positionMs: Long,
@Transient val item: BaseItemDto? = null,
) : Destination(true)
}

View file

@ -2,9 +2,10 @@ package com.github.damontecres.dolphin.ui.nav
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.detail.MediaItemContent
import com.github.damontecres.dolphin.ui.main.MainPage
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
@Composable
fun DestinationContent(
@ -22,9 +23,22 @@ fun DestinationContent(
)
}
is Destination.MediaItem -> {
Text("MediaItem: ${destination.itemId}")
MediaItemContent(
preferences = preferences,
navigationManager = navigationManager,
destination = destination,
modifier = modifier,
)
}
is Destination.Playback -> {
PlaybackContent(
preferences = preferences,
navigationManager = navigationManager,
destination = destination,
modifier = modifier,
)
}
is Destination.Playback -> TODO()
Destination.Search -> TODO()
Destination.Settings -> TODO()
Destination.Setup -> TODO()

View file

@ -133,7 +133,14 @@ fun NavDrawer(
items(libraries) {
LibraryNavItem(
library = it,
onClick = { navigationManager.navigateTo(Destination.MediaItem(it.id)) },
onClick = {
navigationManager.navigateTo(
Destination.MediaItem(
it.id,
it.type,
),
)
},
)
}
item {
@ -192,7 +199,7 @@ fun NavigationDrawerScope.LibraryNavItem(
) {
// TODO
val icon =
when (library.type) {
when (library.collectionType) {
CollectionType.MOVIES -> Icons.Default.Email
CollectionType.TVSHOWS -> Icons.Default.DateRange
CollectionType.HOMEVIDEOS -> Icons.Default.ShoppingCart

View file

@ -0,0 +1,85 @@
package com.github.damontecres.dolphin.ui.playback
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
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.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberPresentationState
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.tryRequestFocus
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
@OptIn(UnstableApi::class)
@Composable
fun PlaybackContent(
preferences: UserPreferences,
navigationManager: NavigationManager,
destination: Destination.Playback,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(),
) {
LaunchedEffect(destination.itemId) {
viewModel.init(destination.itemId)
}
val player = viewModel.player
val stream by viewModel.stream.observeAsState(null)
if (stream == null) {
// TODO loading
} else {
stream?.let {
var contentScale by remember { mutableStateOf(ContentScale.Fit) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val presentationState = rememberPresentationState(player)
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
Box(
modifier
.background(Color.Black)
.onKeyEvent {
// TODO handle key events for playback controls
false
}.focusRequester(focusRequester)
.focusable(),
) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
)
}
}
}
}
}

View file

@ -0,0 +1,116 @@
package com.github.damontecres.dolphin.ui.playback
import android.content.Context
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import java.util.UUID
import javax.inject.Inject
enum class TranscodeType {
DIRECT_PLAY,
DIRECT_STREAM,
TRANSCODE,
}
data class StreamDecision(
val itemId: UUID,
val url: String,
val type: TranscodeType,
) {
val mediaItem: MediaItem
get() =
MediaItem
.Builder()
.setMediaId(itemId.toString())
.setUri(url.toUri())
.build()
}
@HiltViewModel
class PlaybackViewModel
@Inject
constructor(
@ApplicationContext context: Context,
val api: ApiClient,
) : ViewModel() {
val player: ExoPlayer =
ExoPlayer
.Builder(context)
.build()
.apply {
playWhenReady = true
}
val stream = MutableLiveData<StreamDecision?>(null)
init {
addCloseable { player.release() }
}
fun init(itemId: UUID) {
viewModelScope.launch(Dispatchers.IO) {
val response =
api.mediaInfoApi
.getPostedPlaybackInfo(
itemId,
// TODO device profile, etc
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile = null,
enableDirectStream = true,
enableDirectPlay = true,
maxAudioChannels = null,
audioStreamIndex = null,
subtitleStreamIndex = null,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
autoOpenLiveStream = true,
mediaSourceId = null,
),
).content
if (response.errorCode != null) {
// TODO handle error
throw Exception("Playback error: ${response.errorCode}")
} else {
val source = response.mediaSources.firstOrNull()
source?.let {
val mediaUrl =
api.videosApi.getVideoStreamUrl(
itemId = itemId,
mediaSourceId = it.id,
static = true,
tag = it.eTag,
allowAudioStreamCopy = true,
allowVideoStreamCopy = true,
)
val transcodeType =
when {
it.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
it.supportsDirectStream -> TranscodeType.DIRECT_STREAM
it.supportsTranscoding -> TranscodeType.TRANSCODE
else -> throw Exception("No supported playback method")
}
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
withContext(Dispatchers.Main) {
stream.value = decision
player.setMediaItem(decision.mediaItem)
player.prepare()
}
}
}
}
}
}