mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Refactor
This commit is contained in:
parent
bd75539cb6
commit
913334b43d
24 changed files with 41 additions and 117 deletions
|
|
@ -0,0 +1,56 @@
|
|||
package com.github.damontecres.dolphin.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import coil3.ImageLoader
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import com.github.damontecres.dolphin.data.ServerRepository
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@OptIn(ExperimentalTime::class, ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun CoilConfig(
|
||||
serverRepository: ServerRepository,
|
||||
okHttpClient: OkHttpClient,
|
||||
debugLogging: Boolean,
|
||||
) {
|
||||
setSingletonImageLoaderFactory { ctx ->
|
||||
ImageLoader
|
||||
.Builder(ctx)
|
||||
.diskCache(
|
||||
DiskCache
|
||||
.Builder()
|
||||
.directory(ctx.cacheDir.resolve("coil3_image_cache"))
|
||||
.maxSizeBytes(100L * 1024 * 1024)
|
||||
.build(),
|
||||
).crossfade(true)
|
||||
.logger(if (debugLogging) DebugLogger() else null)
|
||||
.components {
|
||||
add(
|
||||
OkHttpNetworkFetcherFactory(
|
||||
cacheStrategy = { CacheControlCacheStrategy() },
|
||||
callFactory = {
|
||||
Call.Factory { request ->
|
||||
// Ref: https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f
|
||||
val token = serverRepository.currentUser?.accessToken
|
||||
okHttpClient.newCall(
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "MediaBrowser Token=\"$token\"")
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.github.damontecres.dolphin.ui
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.foundation.MarqueeAnimationMode
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import timber.log.Timber
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun CharSequence?.isNotNullOrBlank(): Boolean {
|
||||
contract {
|
||||
returns(true) implies (this@isNotNullOrBlank != null)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to apply modifiers conditionally.
|
||||
*/
|
||||
fun Modifier.ifElse(
|
||||
condition: () -> Boolean,
|
||||
ifTrueModifier: Modifier,
|
||||
ifFalseModifier: Modifier = Modifier,
|
||||
): Modifier = then(if (condition()) ifTrueModifier else ifFalseModifier)
|
||||
|
||||
/**
|
||||
* Used to apply modifiers conditionally.
|
||||
*/
|
||||
fun Modifier.ifElse(
|
||||
condition: Boolean,
|
||||
ifTrueModifier: Modifier,
|
||||
ifFalseModifier: Modifier = Modifier,
|
||||
): Modifier = ifElse({ condition }, ifTrueModifier, ifFalseModifier)
|
||||
|
||||
/**
|
||||
* Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
|
||||
* accidentally move to another element.
|
||||
* */
|
||||
fun Modifier.handleDPadKeyEvents(
|
||||
onLeft: (() -> Unit)? = null,
|
||||
onRight: (() -> Unit)? = null,
|
||||
onCenter: (() -> Unit)? = null,
|
||||
triggerOnAction: Int = KeyEvent.ACTION_UP,
|
||||
) = onPreviewKeyEvent {
|
||||
fun onActionUp(block: () -> Unit) {
|
||||
if (it.nativeKeyEvent.action == triggerOnAction) block()
|
||||
}
|
||||
|
||||
when (it.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
|
||||
onLeft?.apply {
|
||||
onActionUp(::invoke)
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
|
||||
onRight?.apply {
|
||||
onActionUp(::invoke)
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
onCenter?.apply {
|
||||
onActionUp(::invoke)
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a [LaunchedEffect] exactly once even with multiple recompositions.
|
||||
*
|
||||
* If the composition is removed from the navigation back stack and "re-added", this will run again
|
||||
*/
|
||||
@Composable
|
||||
fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
|
||||
var hasRun by rememberSaveable { mutableStateOf(false) }
|
||||
if (!hasRun) {
|
||||
LaunchedEffect(Unit) {
|
||||
hasRun = true
|
||||
runOnceBlock.invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.enableMarquee(focused: Boolean) =
|
||||
if (focused) {
|
||||
basicMarquee(
|
||||
initialDelayMillis = 250,
|
||||
animationMode = MarqueeAnimationMode.Immediately,
|
||||
velocity = 40.dp,
|
||||
)
|
||||
} else {
|
||||
basicMarquee(animationMode = MarqueeAnimationMode.WhileFocused)
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package com.github.damontecres.dolphin.ui.cards
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.data.model.Library
|
||||
import com.github.damontecres.dolphin.data.model.Video
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
||||
@Composable
|
||||
fun DolphinCard(
|
||||
item: Any?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (item) {
|
||||
is Video ->
|
||||
VideoCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
is Library -> TODO()
|
||||
is BaseItemDto -> {
|
||||
Text(item.id.toString())
|
||||
}
|
||||
|
||||
null -> NullCard(modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
|
@ -41,8 +41,8 @@ import androidx.tv.material3.Text
|
|||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.dolphin.R
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.enableMarquee
|
||||
import com.github.damontecres.dolphin.ui.FontAwesome
|
||||
import com.github.damontecres.dolphin.ui.enableMarquee
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
package com.github.damontecres.dolphin.ui.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.dolphin.data.model.Video
|
||||
|
||||
@Composable
|
||||
fun VideoCard(
|
||||
item: Video,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 150.dp,
|
||||
cardHeight: Dp = 200.dp,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.width(cardWidth),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.imageUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(cardHeight),
|
||||
)
|
||||
Text(
|
||||
text = item.name ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,16 +42,16 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.R
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.ifElse
|
||||
import com.github.damontecres.dolphin.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.ui.AppColors
|
||||
import com.github.damontecres.dolphin.ui.FontAwesome
|
||||
import com.github.damontecres.dolphin.ui.cards.ItemCard
|
||||
import com.github.damontecres.dolphin.ui.ifElse
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.dolphin.ui.playback.isBackwardButton
|
||||
import com.github.damontecres.dolphin.ui.playback.isForwardButton
|
||||
import com.github.damontecres.dolphin.ui.playback.isPlayKeyUp
|
||||
import com.github.damontecres.dolphin.util.DolphinPager
|
||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.util.ItemPager
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.math.max
|
||||
|
|
@ -60,7 +60,7 @@ private const val DEBUG = false
|
|||
|
||||
@Composable
|
||||
fun CardGrid(
|
||||
pager: DolphinPager,
|
||||
pager: ItemPager,
|
||||
itemOnClick: (BaseItem) -> Unit,
|
||||
longClicker: (BaseItem) -> Unit,
|
||||
letterPosition: suspend (Char) -> Int,
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.data.model.Library
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.dolphin.util.DolphinPager
|
||||
import com.github.damontecres.dolphin.util.ItemPager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -37,7 +37,7 @@ class CollectionFolderViewModel
|
|||
constructor(
|
||||
api: ApiClient,
|
||||
) : ItemViewModel<Library>(api) {
|
||||
val pager = MutableLiveData<DolphinPager?>()
|
||||
val pager = MutableLiveData<ItemPager?>()
|
||||
|
||||
override fun init(
|
||||
itemId: UUID,
|
||||
|
|
@ -63,7 +63,7 @@ class CollectionFolderViewModel
|
|||
sortOrder = listOf(SortOrder.ASCENDING),
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO),
|
||||
)
|
||||
val newPager = DolphinPager(api, request, viewModelScope)
|
||||
val newPager = ItemPager(api, request, viewModelScope)
|
||||
newPager.init()
|
||||
pager.value = newPager
|
||||
}
|
||||
|
|
@ -124,7 +124,7 @@ fun TVShowCollectionDetails(
|
|||
navigationManager: NavigationManager,
|
||||
library: Library,
|
||||
item: BaseItem,
|
||||
pager: DolphinPager,
|
||||
pager: ItemPager,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
|
|||
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.dolphin.util.DolphinPager
|
||||
import com.github.damontecres.dolphin.util.ItemPager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -54,7 +54,8 @@ class SeasonViewModel
|
|||
sortOrder = listOf(SortOrder.ASCENDING),
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.CHILD_COUNT),
|
||||
)
|
||||
val pager = DolphinPager(api, request, viewModelScope, itemCount = item.data.childCount)
|
||||
val pager =
|
||||
ItemPager(api, request, viewModelScope, itemCount = item.data.childCount)
|
||||
pager.init()
|
||||
episodes.value = pager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
|
|||
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.dolphin.util.DolphinPager
|
||||
import com.github.damontecres.dolphin.util.ItemPager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -59,7 +59,8 @@ class SeriesViewModel
|
|||
ItemFields.SEASON_USER_DATA,
|
||||
),
|
||||
)
|
||||
val pager = DolphinPager(api, request, viewModelScope, itemCount = item.data.childCount)
|
||||
val pager =
|
||||
ItemPager(api, request, viewModelScope, itemCount = item.data.childCount)
|
||||
pager.init()
|
||||
seasons.value = pager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.isNotNullOrBlank
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.consumeAsFlow
|
|||
import kotlinx.coroutines.flow.debounce
|
||||
|
||||
class ControllerViewState internal constructor(
|
||||
@IntRange(from = 0)
|
||||
@param:IntRange(from = 0)
|
||||
private val hideMilliseconds: Int,
|
||||
val controlsEnabled: Boolean,
|
||||
) {
|
||||
|
|
@ -31,7 +31,6 @@ class ControllerViewState internal constructor(
|
|||
}
|
||||
|
||||
fun pulseControls(milliseconds: Int = hideMilliseconds) {
|
||||
// Log.i("PlaybackPageContent", "pulseControls=$milliseconds")
|
||||
channel.trySend(milliseconds)
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +40,6 @@ class ControllerViewState internal constructor(
|
|||
.consumeAsFlow()
|
||||
.debounce { it.toLong() }
|
||||
.collect {
|
||||
// Log.i("PlaybackPageContent", "collect")
|
||||
_controlsVisible = false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ import androidx.media3.ui.compose.state.rememberPresentationState
|
|||
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
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
|
||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.stashapp.ui.components.playback.SkipIndicator
|
||||
import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
|
|
|
|||
|
|
@ -57,8 +57,9 @@ import androidx.tv.material3.ListItem
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.R
|
||||
import com.github.damontecres.dolphin.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.ui.AppColors
|
||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.util.TrackSupport
|
||||
import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.media3.common.Player
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.util.TrackSupport
|
||||
|
||||
@Composable
|
||||
fun PlaybackOverlay(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.media3.common.C
|
|||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.dolphin.handleDPadKeyEvents
|
||||
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
|
||||
import com.github.damontecres.dolphin.ui.playback.ControllerViewState
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.R
|
||||
import com.github.damontecres.dolphin.ifElse
|
||||
import com.github.damontecres.dolphin.ui.ifElse
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
package com.github.damontecres.dolphin.ui.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||
import timber.log.Timber
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Listens to playback and periodically saves playback activity to the server
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class TrackActivityPlaybackListener(
|
||||
private val api: ApiClient,
|
||||
private val itemId: UUID,
|
||||
private val player: Player,
|
||||
) : Player.Listener {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main)
|
||||
private val task: TimerTask
|
||||
|
||||
private var totalPlayDurationMilliseconds = AtomicLong(0)
|
||||
private var currentDurationMilliseconds = AtomicLong(0)
|
||||
private var isPlaying = AtomicBoolean(false)
|
||||
private var incrementedPlayCount = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
val saveActivityInterval = 10.seconds.inWholeMilliseconds
|
||||
val delay = 1.seconds.inWholeMilliseconds
|
||||
// Every x seconds, check if the video is playing
|
||||
task =
|
||||
object : TimerTask() {
|
||||
private var timestamp = System.currentTimeMillis()
|
||||
|
||||
override fun run() {
|
||||
try {
|
||||
val now = System.currentTimeMillis()
|
||||
if (isPlaying.get()) {
|
||||
val diffTime = now - timestamp
|
||||
// If it is playing, add the interval to currently tracked duration
|
||||
val current = currentDurationMilliseconds.addAndGet(diffTime)
|
||||
// TODO currentDuration.getAndUpdate would be better, but requires API 24+
|
||||
if (current >= saveActivityInterval) {
|
||||
// If the accumulated currently tracked duration > threshold, reset it and save activity
|
||||
totalPlayDurationMilliseconds.addAndGet(current)
|
||||
saveActivity(-1L)
|
||||
}
|
||||
}
|
||||
timestamp = now
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception during track activity timer")
|
||||
}
|
||||
}
|
||||
}
|
||||
TIMER.schedule(task, delay, delay)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
task.cancel()
|
||||
TIMER.purge()
|
||||
coroutineScope.launch {
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = player.currentPosition.milliseconds.inWholeTicks,
|
||||
failed = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
this.isPlaying.set(isPlaying)
|
||||
if (!isPlaying) {
|
||||
val diff = currentDurationMilliseconds.getAndSet(0)
|
||||
if (diff > 0) {
|
||||
totalPlayDurationMilliseconds.addAndGet(diff)
|
||||
saveActivity(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
Timber.v("onPlaybackStateChanged STATE_ENDED")
|
||||
// TODO mark as watched
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveActivity(position: Long) {
|
||||
coroutineScope.launch {
|
||||
val totalDuration = totalPlayDurationMilliseconds.get()
|
||||
val calcPosition =
|
||||
(if (position >= 0) position else player.currentPosition).milliseconds
|
||||
Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition")
|
||||
// TODO parameters
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = calcPosition.inWholeTicks,
|
||||
canSeek = true,
|
||||
isPaused = !player.isPlaying,
|
||||
isMuted = false,
|
||||
playMethod = PlayMethod.DIRECT_PLAY,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TrackActivityPlaybackListener"
|
||||
|
||||
private val TIMER by lazy { Timer("$TAG-timer", true) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
package com.github.damontecres.dolphin.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
|
||||
data class TrackSupport(
|
||||
val id: String?,
|
||||
val type: TrackType,
|
||||
val supported: TrackSupportReason,
|
||||
val selected: Boolean,
|
||||
val labels: List<String>,
|
||||
val codecs: String?,
|
||||
val format: Format,
|
||||
) {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun displayString(context: Context): String =
|
||||
if (labels.isNotEmpty()) {
|
||||
labels.joinToString(", ")
|
||||
} else {
|
||||
val type =
|
||||
when (codecs) {
|
||||
MimeTypes.TEXT_VTT -> "vtt"
|
||||
MimeTypes.APPLICATION_VOBSUB -> "vobsub"
|
||||
MimeTypes.APPLICATION_SUBRIP -> "srt"
|
||||
MimeTypes.TEXT_SSA -> "ssa"
|
||||
MimeTypes.APPLICATION_PGS -> "pgs"
|
||||
MimeTypes.APPLICATION_DVBSUBS -> "dvd"
|
||||
MimeTypes.APPLICATION_TTML -> "ttml"
|
||||
MimeTypes.TEXT_UNKNOWN -> "unknown"
|
||||
null -> "unknown"
|
||||
else -> {
|
||||
val split = codecs.split("/")
|
||||
if (split.size > 1) split[1] else codecs
|
||||
}
|
||||
}
|
||||
val language = languageName(context, format.language)
|
||||
"$language ($type)"
|
||||
}
|
||||
}
|
||||
|
||||
enum class TrackSupportReason {
|
||||
HANDLED,
|
||||
EXCEEDS_CAPABILITIES,
|
||||
UNSUPPORTED_DRM,
|
||||
UNSUPPORTED_SUBTYPE,
|
||||
UNSUPPORTED_TYPE,
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
companion object {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun fromInt(
|
||||
@C.FormatSupport value: Int,
|
||||
): TrackSupportReason =
|
||||
when (value) {
|
||||
C.FORMAT_HANDLED -> HANDLED
|
||||
C.FORMAT_EXCEEDS_CAPABILITIES -> EXCEEDS_CAPABILITIES
|
||||
C.FORMAT_UNSUPPORTED_DRM -> UNSUPPORTED_DRM
|
||||
C.FORMAT_UNSUPPORTED_SUBTYPE -> UNSUPPORTED_SUBTYPE
|
||||
C.FORMAT_UNSUPPORTED_TYPE -> UNSUPPORTED_TYPE
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class TrackType {
|
||||
UNKNOWN,
|
||||
DEFAULT,
|
||||
AUDIO,
|
||||
VIDEO,
|
||||
TEXT,
|
||||
IMAGE,
|
||||
METADATA,
|
||||
CAMERA_MOTION,
|
||||
NONE,
|
||||
;
|
||||
|
||||
companion object {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun fromInt(value: Int): TrackType =
|
||||
when (value) {
|
||||
C.TRACK_TYPE_UNKNOWN -> UNKNOWN
|
||||
C.TRACK_TYPE_DEFAULT -> DEFAULT
|
||||
C.TRACK_TYPE_AUDIO -> AUDIO
|
||||
C.TRACK_TYPE_VIDEO -> VIDEO
|
||||
C.TRACK_TYPE_TEXT -> TEXT
|
||||
C.TRACK_TYPE_IMAGE -> IMAGE
|
||||
C.TRACK_TYPE_METADATA -> METADATA
|
||||
C.TRACK_TYPE_CAMERA_MOTION -> CAMERA_MOTION
|
||||
C.TRACK_TYPE_NONE -> NONE
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun checkForSupport(tracks: Tracks): List<TrackSupport> =
|
||||
tracks.groups.flatMap {
|
||||
buildList {
|
||||
val type = TrackType.fromInt(it.type)
|
||||
for (i in 0..<it.length) {
|
||||
val format = it.getTrackFormat(i)
|
||||
val labels =
|
||||
format.labels
|
||||
.map {
|
||||
if (it.language != null) {
|
||||
"${it.value} (${it.language})"
|
||||
} else {
|
||||
it.value
|
||||
}
|
||||
}
|
||||
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
|
||||
add(
|
||||
TrackSupport(
|
||||
format.id,
|
||||
type,
|
||||
reason,
|
||||
it.isSelected,
|
||||
labels,
|
||||
format.codecs,
|
||||
format,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun languageName(
|
||||
context: Context,
|
||||
code: String?,
|
||||
) = if (code != null && code != "00") {
|
||||
try {
|
||||
Locale(code).displayLanguage
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error in locale for '$code'")
|
||||
code.uppercase()
|
||||
}
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue