mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Minimal playback
This commit is contained in:
parent
80070f4174
commit
3e7f11c588
14 changed files with 405 additions and 12 deletions
|
|
@ -90,6 +90,7 @@ dependencies {
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
implementation(libs.androidx.media3.exoplayer.hls)
|
implementation(libs.androidx.media3.exoplayer.hls)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
|
implementation(libs.androidx.media3.ui.compose)
|
||||||
|
|
||||||
implementation(libs.coil.core)
|
implementation(libs.coil.core)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.github.damontecres.dolphin
|
package com.github.damontecres.dolphin
|
||||||
|
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import timber.log.Timber
|
||||||
import kotlin.contracts.ExperimentalContracts
|
import kotlin.contracts.ExperimentalContracts
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
|
|
||||||
|
|
@ -10,3 +12,15 @@ fun CharSequence?.isNotNullOrBlank(): Boolean {
|
||||||
}
|
}
|
||||||
return !this.isNullOrBlank()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import java.util.UUID
|
||||||
sealed interface DolphinModel {
|
sealed interface DolphinModel {
|
||||||
val id: UUID
|
val id: UUID
|
||||||
val name: String?
|
val name: String?
|
||||||
|
val type: BaseItemKind
|
||||||
val imageUrl: String?
|
val imageUrl: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.data.model
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
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.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -10,8 +11,9 @@ import java.util.UUID
|
||||||
data class Library(
|
data class Library(
|
||||||
override val id: UUID,
|
override val id: UUID,
|
||||||
override val name: String?,
|
override val name: String?,
|
||||||
|
override val type: BaseItemKind,
|
||||||
override val imageUrl: String?,
|
override val imageUrl: String?,
|
||||||
val type: CollectionType,
|
val collectionType: CollectionType,
|
||||||
) : DolphinModel {
|
) : DolphinModel {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDto(
|
fun fromDto(
|
||||||
|
|
@ -21,8 +23,9 @@ data class Library(
|
||||||
Library(
|
Library(
|
||||||
id = dto.id,
|
id = dto.id,
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
|
type = dto.type,
|
||||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||||
type = dto.collectionType ?: CollectionType.UNKNOWN,
|
collectionType = dto.collectionType ?: CollectionType.UNKNOWN,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package com.github.damontecres.dolphin.data.model
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class Video(
|
data class Video(
|
||||||
override val id: UUID,
|
override val id: UUID,
|
||||||
override val name: String?,
|
override val name: String?,
|
||||||
|
override val type: BaseItemKind,
|
||||||
override val imageUrl: String?,
|
override val imageUrl: String?,
|
||||||
) : DolphinModel {
|
) : DolphinModel {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -19,6 +21,7 @@ data class Video(
|
||||||
Video(
|
Video(
|
||||||
id = dto.id,
|
id = dto.id,
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
|
type = dto.type,
|
||||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,9 +32,11 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
|
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.userApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -87,8 +89,6 @@ class MainViewModel
|
||||||
.getLatestMedia(request)
|
.getLatestMedia(request)
|
||||||
.content
|
.content
|
||||||
.map { convertModel(it, api) }
|
.map { convertModel(it, api) }
|
||||||
Timber.v("latest: %s", latest)
|
|
||||||
// api.itemsApi.getItems(request)
|
|
||||||
HomeRow(
|
HomeRow(
|
||||||
section = section,
|
section = section,
|
||||||
items = latest,
|
items = latest,
|
||||||
|
|
@ -100,7 +100,26 @@ class MainViewModel
|
||||||
HomeSection.LIBRARY_TILES_SMALL -> null
|
HomeSection.LIBRARY_TILES_SMALL -> null
|
||||||
HomeSection.RESUME -> null
|
HomeSection.RESUME -> null
|
||||||
HomeSection.ACTIVE_RECORDINGS -> 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
|
HomeSection.LIVE_TV -> null
|
||||||
|
|
||||||
// TODO Not supported?
|
// TODO Not supported?
|
||||||
|
|
@ -132,7 +151,14 @@ fun MainPage(
|
||||||
item {
|
item {
|
||||||
HomePageRow(
|
HomePageRow(
|
||||||
row = row,
|
row = row,
|
||||||
onClickItem = { navigationManager.navigateTo(Destination.MediaItem(it.id)) },
|
onClickItem = {
|
||||||
|
navigationManager.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
it.id,
|
||||||
|
it.type,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package com.github.damontecres.dolphin.ui.nav
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.github.damontecres.dolphin.util.UuidSerializer
|
import com.github.damontecres.dolphin.util.UuidSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -24,11 +26,14 @@ sealed class Destination(
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaItem(
|
data class MediaItem(
|
||||||
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
||||||
|
val type: BaseItemKind,
|
||||||
|
@Transient val item: BaseItemDto? = null,
|
||||||
) : Destination()
|
) : Destination()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Playback(
|
data class Playback(
|
||||||
val itemId: String,
|
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
|
@Transient val item: BaseItemDto? = null,
|
||||||
) : Destination(true)
|
) : Destination(true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ package com.github.damontecres.dolphin.ui.nav
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
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.main.MainPage
|
||||||
|
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DestinationContent(
|
fun DestinationContent(
|
||||||
|
|
@ -22,9 +23,22 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Destination.MediaItem -> {
|
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.Search -> TODO()
|
||||||
Destination.Settings -> TODO()
|
Destination.Settings -> TODO()
|
||||||
Destination.Setup -> TODO()
|
Destination.Setup -> TODO()
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,14 @@ fun NavDrawer(
|
||||||
items(libraries) {
|
items(libraries) {
|
||||||
LibraryNavItem(
|
LibraryNavItem(
|
||||||
library = it,
|
library = it,
|
||||||
onClick = { navigationManager.navigateTo(Destination.MediaItem(it.id)) },
|
onClick = {
|
||||||
|
navigationManager.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
it.id,
|
||||||
|
it.type,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -192,7 +199,7 @@ fun NavigationDrawerScope.LibraryNavItem(
|
||||||
) {
|
) {
|
||||||
// TODO
|
// TODO
|
||||||
val icon =
|
val icon =
|
||||||
when (library.type) {
|
when (library.collectionType) {
|
||||||
CollectionType.MOVIES -> Icons.Default.Email
|
CollectionType.MOVIES -> Icons.Default.Email
|
||||||
CollectionType.TVSHOWS -> Icons.Default.DateRange
|
CollectionType.TVSHOWS -> Icons.Default.DateRange
|
||||||
CollectionType.HOMEVIDEOS -> Icons.Default.ShoppingCart
|
CollectionType.HOMEVIDEOS -> Icons.Default.ShoppingCart
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,7 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi
|
||||||
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
|
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
|
||||||
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
|
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
|
||||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||||
|
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||||
|
|
||||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue