mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-06 02:40:58 +01:00
Add a lot of code documentation (#1145)
## Description A brain dump documenting many classes and functions throughout the app. Definitely does not document everything, but covers most of the major components. This should be useful for new contributors. There is a small amount of code clean up too. There are no user facing changes. ### Related issues N/A ### Testing N/A ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
2485d2c644
commit
66f060dccb
92 changed files with 719 additions and 222 deletions
|
|
@ -35,6 +35,11 @@ import androidx.tv.material3.ProvideTextStyle
|
|||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/**
|
||||
* This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc
|
||||
*
|
||||
* This allows for creating smaller and fully circular Buttons.
|
||||
*/
|
||||
@Composable
|
||||
fun Button(
|
||||
onClick: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ sealed interface DialogItemEntry
|
|||
|
||||
data object DialogItemDivider : DialogItemEntry
|
||||
|
||||
/**
|
||||
* An item within a [DialogPopup]
|
||||
*/
|
||||
data class DialogItem(
|
||||
val headlineContent: @Composable () -> Unit,
|
||||
val onClick: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
|
|
@ -49,8 +48,10 @@ import androidx.tv.material3.MaterialTheme
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.google.protobuf.value
|
||||
|
||||
/**
|
||||
* An input field for text customized for TV entry
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditTextBox(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Button for filtering data.
|
||||
*
|
||||
* Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked.
|
||||
*
|
||||
* @see GetItemsFilter
|
||||
* @see ItemFilterBy
|
||||
*/
|
||||
@Composable
|
||||
fun FilterByButton(
|
||||
filterOptions: List<ItemFilterBy<*>>,
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ private val genreCache by lazy {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mapping from genre IDs to image URLs using random items within each genre
|
||||
*/
|
||||
suspend fun getGenreImageMap(
|
||||
api: ApiClient,
|
||||
userId: UUID?,
|
||||
|
|
@ -230,6 +233,9 @@ data class Genre(
|
|||
override val sortName: String get() = name
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an optimized grid of genres for a library
|
||||
*/
|
||||
@Composable
|
||||
fun GenreCardGrid(
|
||||
itemId: UUID,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import androidx.tv.material3.Text
|
|||
|
||||
private const val MAX_TO_SHOW = 4
|
||||
|
||||
/**
|
||||
* Display a comma separated list of genres
|
||||
*
|
||||
* Only the first few will be shown
|
||||
*/
|
||||
@Composable
|
||||
fun GenreText(
|
||||
genres: List<String>,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ class ItemGridViewModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a grid of a list of arbitrary item IDs such as for [com.github.damontecres.wholphin.data.ExtrasItem]
|
||||
*/
|
||||
@Composable
|
||||
fun ItemGrid(
|
||||
destination: Destination.ItemGrid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import androidx.compose.ui.Modifier
|
|||
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
|
||||
/**
|
||||
* Displays dependencies' license information to comply with attribution
|
||||
*/
|
||||
@Composable
|
||||
fun LicenseInfo(modifier: Modifier = Modifier) {
|
||||
val libraries by produceLibraries()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import androidx.tv.material3.Text
|
|||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||
|
||||
/**
|
||||
* Show the overview text for an item. Uses a fixed size and allows for clicking.
|
||||
*/
|
||||
@Composable
|
||||
fun OverviewText(
|
||||
overview: String,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.rememberPosition
|
|||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -50,8 +51,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Abstract [ViewModel] for the "Recommended" tab for a library
|
||||
*/
|
||||
abstract class RecommendedViewModel(
|
||||
val context: Context,
|
||||
@param:ApplicationContext val context: Context,
|
||||
val navigationManager: NavigationManager,
|
||||
val favoriteWatchManager: FavoriteWatchManager,
|
||||
val mediaReportService: MediaReportService,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
|
||||
/**
|
||||
* Shows a dot indicator
|
||||
*
|
||||
* Intended for using within the `leadingContent` of a [androidx.tv.material3.ListItem]
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.SelectedLeadingContent(
|
||||
selected: Boolean,
|
||||
|
|
|
|||
|
|
@ -126,16 +126,19 @@ data class SliderColors(
|
|||
@Composable
|
||||
fun default() =
|
||||
SliderColors(
|
||||
activeFocused = SliderActiveColor(true),
|
||||
activeUnfocused = SliderActiveColor(false),
|
||||
inactiveFocused = SliderInactiveColor(true),
|
||||
inactiveUnfocused = SliderInactiveColor(false),
|
||||
activeFocused = sliderActiveColor(true),
|
||||
activeUnfocused = sliderActiveColor(false),
|
||||
inactiveFocused = sliderInactiveColor(true),
|
||||
inactiveUnfocused = sliderInactiveColor(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the active color for the slider. This is the "filled" left side of the slider
|
||||
*/
|
||||
@Composable
|
||||
fun SliderActiveColor(focused: Boolean): Color {
|
||||
fun sliderActiveColor(focused: Boolean): Color {
|
||||
val theme = LocalTheme.current
|
||||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
|
|
@ -165,8 +168,11 @@ fun SliderActiveColor(focused: Boolean): Color {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the inactive color for the slider. This is background of "unfilled" right side of the slider.
|
||||
*/
|
||||
@Composable
|
||||
fun SliderInactiveColor(focused: Boolean): Color {
|
||||
fun sliderInactiveColor(focused: Boolean): Color {
|
||||
val theme = LocalTheme.current
|
||||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
|
||||
/**
|
||||
* Displays the [LocalClock] in the upper right corner of the parent [androidx.compose.foundation.layout.Box]
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||
val timeString by LocalClock.current.timeString
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
/**
|
||||
* A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions].
|
||||
*
|
||||
* It displays the [AppPreference] objects from [ViewOptions.OPTIONS]
|
||||
*/
|
||||
@Composable
|
||||
fun ViewOptionsDialog(
|
||||
viewOptions: ViewOptions,
|
||||
|
|
@ -105,6 +110,11 @@ fun ViewOptionsDialog(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the customizable view changes from the user
|
||||
*
|
||||
* This is used to determine how the UI looks such as card size and shape
|
||||
*/
|
||||
@Serializable
|
||||
data class ViewOptions(
|
||||
val columns: Int = 6,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import timber.log.Timber
|
|||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A supplementary [ViewModel] for adding items to a server playlist
|
||||
* @see com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AddPlaylistViewModel
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ data class ItemDetailsDialogInfo(
|
|||
val files: List<MediaSourceInfo>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Dialog showing metadata about an item
|
||||
*/
|
||||
@Composable
|
||||
fun ItemDetailsDialog(
|
||||
info: ItemDetailsDialogInfo,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ interface CardGridItem {
|
|||
val sortName: String
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a vertical grid of [CardGridItem]s
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun <T : CardGridItem> CardGrid(
|
||||
|
|
|
|||
|
|
@ -49,9 +49,8 @@ enum class ClearChosenStreams {
|
|||
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
|
||||
*
|
||||
* @param item the media item to build for, typically an Episode or Movie
|
||||
* @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
|
||||
* @param seriesId the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
|
||||
* @param sourceId the item's media source UUID
|
||||
* @param navigateTo a function to trigger a navigation
|
||||
* @param onChooseVersion callback to pick a version of the item
|
||||
* @param onChooseTracks callback to pick a track for the given type of the item
|
||||
* @param onShowOverview callback to show overview dialog with media information
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ class PlaylistViewModel
|
|||
/**
|
||||
* This method tries to determine the [MediaType] of a playlist
|
||||
*
|
||||
* In theory, the server will set the type, but sometime it doesn't
|
||||
* In theory, the server will set the type, but sometimes it doesn't
|
||||
*/
|
||||
private suspend fun determineMediaType() {
|
||||
// Use the type the server says
|
||||
|
|
|
|||
|
|
@ -172,6 +172,9 @@ class LiveTvViewModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a list of [LocalDateTime] for each hour from now
|
||||
*/
|
||||
private fun buildGuideTimes() =
|
||||
buildList {
|
||||
val start = LocalDateTime.now().roundDownToHalfHour()
|
||||
|
|
@ -194,15 +197,22 @@ class LiveTvViewModel
|
|||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get live TV programs for a subset of channels
|
||||
*
|
||||
* @param guideStart The start timestamp to fetch from
|
||||
* @param channels The full list of channels
|
||||
* @param channelIndices The indices of which channels to fetch programs for
|
||||
*/
|
||||
private suspend fun fetchPrograms(
|
||||
guideStart: LocalDateTime,
|
||||
channels: List<TvChannel>,
|
||||
range: IntRange,
|
||||
channelIndices: IntRange,
|
||||
) = mutex.withLock {
|
||||
val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1)
|
||||
val minEndDate = guideStart.plusMinutes(1L)
|
||||
val channelsToFetch = channels.subList(range.first, range.last + 1)
|
||||
Timber.v("Fetching programs for $range channels ${channelsToFetch.size}")
|
||||
val channelsToFetch = channels.subList(channelIndices.first, channelIndices.last + 1)
|
||||
Timber.v("Fetching programs for $channelIndices channels ${channelsToFetch.size}")
|
||||
val request =
|
||||
GetProgramsDto(
|
||||
maxStartDate = maxStartDate,
|
||||
|
|
@ -390,7 +400,7 @@ class LiveTvViewModel
|
|||
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
|
||||
withContext(Dispatchers.Main) {
|
||||
this@LiveTvViewModel.programs.value =
|
||||
FetchedPrograms(range, finalProgramList, programsByChannel)
|
||||
FetchedPrograms(channelIndices, finalProgramList, programsByChannel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,6 +482,11 @@ class LiveTvViewModel
|
|||
|
||||
private var focusLoadingJob: Job? = null
|
||||
|
||||
/**
|
||||
* Callback when focusing on the EPG grid
|
||||
*
|
||||
* This determines if more programs/channels should be fetched based on the current position
|
||||
*/
|
||||
fun onFocusChannel(position: RowColumn) {
|
||||
channels.value?.let { channels ->
|
||||
val fetchedRange = programs.value!!.range
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
|||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Shows the current backdrop images provided by [com.github.damontecres.wholphin.services.BackdropService]
|
||||
*/
|
||||
@Composable
|
||||
fun Backdrop(
|
||||
drawerIsOpen: Boolean,
|
||||
|
|
@ -59,6 +62,9 @@ fun Backdrop(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the current backdrop images provided by the [BackdropResult]
|
||||
*/
|
||||
@Composable
|
||||
fun Backdrop(
|
||||
backdrop: BackdropResult,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ class NavDrawerViewModel
|
|||
|
||||
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
||||
|
||||
/**
|
||||
* Determine which nav drawer item should be highlighted as currently selected
|
||||
*/
|
||||
fun updateSelectedIndex() {
|
||||
viewModelScope.launchDefault {
|
||||
val asDestinations =
|
||||
|
|
@ -215,6 +218,11 @@ class NavDrawerViewModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An item that can be shown in the nav drawer
|
||||
*
|
||||
* Some are built-in such as Favorites. Others are created dynamically for libraries.
|
||||
*/
|
||||
sealed interface NavDrawerItem {
|
||||
val id: String
|
||||
|
||||
|
|
@ -242,6 +250,9 @@ sealed interface NavDrawerItem {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server provided nav drawer item, typically a library
|
||||
*/
|
||||
data class ServerNavDrawerItem(
|
||||
val itemId: UUID,
|
||||
val name: String,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ import androidx.tv.material3.NavigationDrawerItemDefaults
|
|||
import androidx.tv.material3.NavigationDrawerScope
|
||||
import androidx.tv.material3.rememberDrawerState
|
||||
|
||||
/**
|
||||
* This is a re-implementation of [androidx.tv.material3.ModalNavigationDrawer].
|
||||
*
|
||||
* It changes padding, sizing, and animations that couldn't be changed in the Androidx implementation.
|
||||
*/
|
||||
@Composable
|
||||
fun ModalNavigationDrawer(
|
||||
drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
|||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
/**
|
||||
* Metadata about the currently playing media
|
||||
*
|
||||
* @see CurrentPlayback
|
||||
*/
|
||||
data class CurrentMediaInfo(
|
||||
val sourceId: String?,
|
||||
val videoStream: SimpleVideoStream?,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.PlayMethod
|
|||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Information about how the current media is being played such transcoding and decoder info
|
||||
*
|
||||
* @see CurrentMediaInfo
|
||||
*/
|
||||
data class CurrentPlayback(
|
||||
val item: BaseItem,
|
||||
val tracks: List<TrackSupport>,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
|||
|
||||
@Composable
|
||||
fun DownloadSubtitlesContent(
|
||||
state: SubtitleSearch,
|
||||
state: SubtitleSearchStatus,
|
||||
language: String,
|
||||
onSearch: (String) -> Unit,
|
||||
onClickDownload: (RemoteSubtitleInfo) -> Unit,
|
||||
|
|
@ -59,7 +59,7 @@ fun DownloadSubtitlesContent(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val s = state) {
|
||||
SubtitleSearch.Searching -> {
|
||||
SubtitleSearchStatus.Searching -> {
|
||||
Wrapper {
|
||||
Text(
|
||||
text = stringResource(R.string.searching),
|
||||
|
|
@ -69,7 +69,7 @@ fun DownloadSubtitlesContent(
|
|||
}
|
||||
}
|
||||
|
||||
SubtitleSearch.Downloading -> {
|
||||
SubtitleSearchStatus.Downloading -> {
|
||||
Wrapper {
|
||||
Text(
|
||||
text = stringResource(R.string.downloading),
|
||||
|
|
@ -79,11 +79,11 @@ fun DownloadSubtitlesContent(
|
|||
}
|
||||
}
|
||||
|
||||
is SubtitleSearch.Error -> {
|
||||
is SubtitleSearchStatus.Error -> {
|
||||
Wrapper { ErrorMessage(null, s.ex, modifier) }
|
||||
}
|
||||
|
||||
is SubtitleSearch.Success -> {
|
||||
is SubtitleSearchStatus.Success -> {
|
||||
val dialogItems = convertRemoteSubtitles(s.options, onClickDownload)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ import org.jellyfin.sdk.model.extensions.ticks
|
|||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Possible actions the user can take during playback
|
||||
*/
|
||||
sealed interface PlaybackAction {
|
||||
data object ShowDebug : PlaybackAction
|
||||
|
||||
|
|
@ -114,6 +117,9 @@ sealed interface PlaybackAction {
|
|||
data object Next : PlaybackAction
|
||||
}
|
||||
|
||||
/**
|
||||
* UI for actual playback controls such as seek bar, play/pause and seek buttons
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackControls(
|
||||
|
|
@ -515,6 +521,11 @@ fun PlaybackFaButton(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a dialog aligned to the bottom of the screen instead of centered.
|
||||
*
|
||||
* @param gravity The [Gravity] to align the dialog to left or right
|
||||
*/
|
||||
@Composable
|
||||
fun <T> BottomDialog(
|
||||
choices: List<BottomDialogItem<T>>,
|
||||
|
|
@ -594,10 +605,6 @@ fun <T> BottomDialog(
|
|||
}
|
||||
}
|
||||
|
||||
data class MoreButtonOptions(
|
||||
val options: Map<String, PlaybackAction>,
|
||||
)
|
||||
|
||||
data class BottomDialogItem<T>(
|
||||
val data: T,
|
||||
val headline: String,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,14 @@ data class PlaybackSettings(
|
|||
val playbackSpeedEnabled: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Centralized UI component for displaying dialogs during playback
|
||||
*
|
||||
* Typically, the user will click something generating a [PlaybackAction] which translates into the
|
||||
* [PlaybackDialogType] determining which dialog is shown by this component.
|
||||
*
|
||||
* @see PlaybackAction
|
||||
*/
|
||||
@Composable
|
||||
fun PlaybackDialog(
|
||||
enableSubtitleDelay: Boolean,
|
||||
|
|
|
|||
|
|
@ -575,6 +575,8 @@ enum class OverlayViewState {
|
|||
|
||||
/**
|
||||
* A wrapper for the playback controls to show title and other information, plus the actual controls
|
||||
*
|
||||
* @see PlaybackControls
|
||||
*/
|
||||
@Composable
|
||||
fun Controller(
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ fun PlaybackPageContent(
|
|||
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||
|
||||
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
||||
val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null)
|
||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
||||
|
||||
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ class PlaybackViewModel
|
|||
private var isPlaylist = false
|
||||
|
||||
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
||||
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
|
||||
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null)
|
||||
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
||||
|
||||
val currentUserDto = serverRepository.currentUserDto
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.listen
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun rememberPlayerLoadingState(player: Player): PlayerLoadingState {
|
||||
val state = remember(player) { PlayerLoadingState(player) }
|
||||
LaunchedEffect(player) {
|
||||
state.observe()
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class PlayerLoadingState(
|
||||
private val player: Player,
|
||||
) : State<Boolean> {
|
||||
override var value by mutableStateOf(player.isLoading)
|
||||
private set
|
||||
|
||||
suspend fun observe() {
|
||||
value = player.isLoading
|
||||
player.listen {
|
||||
if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) {
|
||||
value = player.isLoading
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,11 @@ import androidx.tv.material3.MaterialTheme
|
|||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* This is a seek bar which seeks by a percentage of the duration instead of a fixed amount of time
|
||||
*
|
||||
* For example, if [intervals] is 10, then each seek will be 10% of total media duration
|
||||
*/
|
||||
@Composable
|
||||
fun SteppedSeekBarImpl(
|
||||
progress: Float,
|
||||
|
|
@ -97,6 +102,9 @@ fun SteppedSeekBarImpl(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
fun IntervalSeekBarImpl(
|
||||
|
|
@ -147,8 +155,14 @@ fun IntervalSeekBarImpl(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually renders the seek bar. It has callbacks for when the user moves to the left or right
|
||||
*
|
||||
* @see IntervalSeekBarImpl
|
||||
* @see SteppedSeekBarImpl
|
||||
*/
|
||||
@Composable
|
||||
fun SeekBarDisplay(
|
||||
private fun SeekBarDisplay(
|
||||
progress: Float,
|
||||
bufferedProgress: Float,
|
||||
durationMs: Long,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
|
|
@ -52,20 +53,21 @@ fun Modifier.offsetByPercent(
|
|||
*
|
||||
* @param xPercentage percent offset between 0 inclusive and 1 inclusive
|
||||
*/
|
||||
fun Modifier.offsetByPercent(xPercentage: Float) =
|
||||
this.then(
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
placeable.placeRelative(
|
||||
x =
|
||||
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
|
||||
.coerceIn(0, constraints.maxWidth - placeable.width),
|
||||
y = 0,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
fun Modifier.offsetByPercent(
|
||||
@FloatRange(0.0, 1.0) xPercentage: Float,
|
||||
) = this.then(
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
placeable.placeRelative(
|
||||
x =
|
||||
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
|
||||
.coerceIn(0, constraints.maxWidth - placeable.width),
|
||||
y = 0,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Show trickplay preview image. This composable assumes the provided URL is for the correct index.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
|
||||
/**
|
||||
* A slimmer [MediaStream] with minimal data for UI purposes
|
||||
*
|
||||
* @see SimpleVideoStream
|
||||
*/
|
||||
data class SimpleMediaStream(
|
||||
val index: Int,
|
||||
val streamTitle: String?,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Shows a rotating indicator when seeking. Displays the amount of seconds in the [durationMs].
|
||||
*/
|
||||
@Composable
|
||||
fun SkipIndicator(
|
||||
durationMs: Long,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import kotlin.time.Duration.Companion.seconds
|
|||
|
||||
private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
|
||||
|
||||
/**
|
||||
* Shows buttons for adding to or subtracting from the given [Duration], ie subtitle delay
|
||||
*/
|
||||
@Composable
|
||||
fun SubtitleDelay(
|
||||
delay: Duration,
|
||||
|
|
|
|||
|
|
@ -20,23 +20,26 @@ import org.jellyfin.sdk.model.api.MediaStreamType
|
|||
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
||||
import timber.log.Timber
|
||||
|
||||
sealed interface SubtitleSearch {
|
||||
data object Searching : SubtitleSearch
|
||||
sealed interface SubtitleSearchStatus {
|
||||
data object Searching : SubtitleSearchStatus
|
||||
|
||||
data object Downloading : SubtitleSearch
|
||||
data object Downloading : SubtitleSearchStatus
|
||||
|
||||
data class Success(
|
||||
val options: List<RemoteSubtitleInfo>,
|
||||
) : SubtitleSearch
|
||||
) : SubtitleSearchStatus
|
||||
|
||||
data class Error(
|
||||
val message: String?,
|
||||
val ex: Exception?,
|
||||
) : SubtitleSearch
|
||||
) : SubtitleSearchStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a search for subtitles in the given language for the currently playing media
|
||||
*/
|
||||
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
|
||||
subtitleSearch.value = SubtitleSearch.Searching
|
||||
subtitleSearchStatus.value = SubtitleSearchStatus.Searching
|
||||
subtitleSearchLanguage.value = language
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
|
|
@ -52,23 +55,26 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu
|
|||
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
|
||||
.thenByDescending { it.downloadCount },
|
||||
)
|
||||
subtitleSearch.setValueOnMain(SubtitleSearch.Success(results))
|
||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results))
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception while searching for subtitles")
|
||||
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
|
||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the remote subtitles and attempt to activate them once complete
|
||||
*/
|
||||
fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||
subtitleId: String?,
|
||||
wasPlaying: Boolean,
|
||||
) {
|
||||
if (subtitleId == null) {
|
||||
subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null)
|
||||
subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null)
|
||||
} else {
|
||||
subtitleSearch.value = SubtitleSearch.Downloading
|
||||
subtitleSearchStatus.value = SubtitleSearchStatus.Downloading
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
currentItemPlayback.value?.let {
|
||||
|
|
@ -161,7 +167,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
)
|
||||
}
|
||||
}
|
||||
subtitleSearch.setValueOnMain(null)
|
||||
subtitleSearchStatus.setValueOnMain(null)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
|
|
@ -170,12 +176,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
|
||||
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
|
||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PlaybackViewModel.cancelSubtitleSearch() {
|
||||
subtitleSearch.value = null
|
||||
subtitleSearchStatus.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
|||
import timber.log.Timber
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player]
|
||||
*/
|
||||
object TrackSelectionUtils {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun createTrackSelections(
|
||||
|
|
@ -258,6 +261,9 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of [TrackSelectionUtils.createTrackSelections]
|
||||
*/
|
||||
data class TrackSelectionResult(
|
||||
val trackSelectionParameters: TrackSelectionParameters,
|
||||
val audioSelected: Boolean,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue