mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02: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
|
|
@ -32,6 +32,9 @@ import java.io.File
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Handles any changes needed when the app in upgraded on the device such as setting new preferences
|
||||
*/
|
||||
@Singleton
|
||||
class AppUpgradeHandler
|
||||
@Inject
|
||||
|
|
@ -61,6 +64,7 @@ class AppUpgradeHandler
|
|||
Timber.i(
|
||||
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||
)
|
||||
// Store the previous and new version info
|
||||
prefs.edit(true) {
|
||||
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
|
||||
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
|
||||
|
|
@ -83,6 +87,9 @@ class AppUpgradeHandler
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the font file used by MPV subtitles to the app's files directory
|
||||
*/
|
||||
fun copySubfont(overwrite: Boolean) {
|
||||
try {
|
||||
val fontFileName = "subfont.ttf"
|
||||
|
|
@ -111,6 +118,9 @@ class AppUpgradeHandler
|
|||
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform any needed upgrades
|
||||
*/
|
||||
suspend fun upgradeApp(
|
||||
previous: Version,
|
||||
current: Version,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Stores state for the backdrop of the app shown on non-full screen pages
|
||||
*
|
||||
* This is usually the backdrop of the currently focused media
|
||||
*/
|
||||
@Singleton
|
||||
@OptIn(FlowPreview::class)
|
||||
class BackdropService
|
||||
|
|
@ -46,6 +51,9 @@ class BackdropService
|
|||
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
|
||||
val backdropFlow = _backdropFlow
|
||||
|
||||
/**
|
||||
* Update the backdrop to use the specified item
|
||||
*/
|
||||
suspend fun submit(item: BaseItem) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val imageUrl =
|
||||
|
|
@ -57,8 +65,14 @@ class BackdropService
|
|||
submit(item.id.toString(), imageUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the backdrop to use the specified discovered item
|
||||
*/
|
||||
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
||||
|
||||
/**
|
||||
* Update the backdrop to use the specified URL
|
||||
*/
|
||||
suspend fun submit(
|
||||
itemId: String,
|
||||
imageUrl: String?,
|
||||
|
|
@ -74,6 +88,9 @@ class BackdropService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the backdrop, such as when switching pages
|
||||
*/
|
||||
suspend fun clearBackdrop() {
|
||||
_backdropFlow.update {
|
||||
BackdropResult.NONE
|
||||
|
|
@ -224,6 +241,9 @@ class BackdropService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The result from determining the backdrop URL and extracted colors for the dynamic backdrop
|
||||
*/
|
||||
data class BackdropResult(
|
||||
val itemId: String?,
|
||||
val imageUrl: String?,
|
||||
|
|
@ -245,6 +265,9 @@ data class BackdropResult(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The colors extracted from an image
|
||||
*/
|
||||
data class ExtractedColors(
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ import java.util.concurrent.TimeUnit
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
|
||||
*/
|
||||
@Singleton
|
||||
class DatePlayedService
|
||||
@Inject
|
||||
|
|
@ -80,6 +83,12 @@ class DatePlayedService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logical timestamp when the item was last played.
|
||||
*
|
||||
* This is calculated using lastest of the actual last played timestamp of the item,
|
||||
* the previous episode's last played timestamp, and the episode's premiere date
|
||||
*/
|
||||
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val seriesId = item.data.seriesId
|
||||
|
|
@ -93,6 +102,11 @@ class DatePlayedService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the cached last date played for the given item's series
|
||||
*
|
||||
* Used for when a user plays this item
|
||||
*/
|
||||
fun invalidate(item: BaseItem) {
|
||||
item.data.seriesId?.let { seriesId ->
|
||||
Timber.d("Invalidating %s", seriesId)
|
||||
|
|
@ -100,6 +114,9 @@ class DatePlayedService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the cached last data played for the given item or its series
|
||||
*/
|
||||
suspend fun invalidate(itemId: UUID) {
|
||||
val seriesId =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
|
|
@ -121,6 +138,9 @@ class DatePlayedService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the date played cached when the current user changes
|
||||
*/
|
||||
@ActivityScoped
|
||||
class DatePlayedInvalidationService
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.DeviceProfile
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceProfileService
|
||||
@Inject
|
||||
|
|
@ -21,7 +24,7 @@ class DeviceProfileService
|
|||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
val mediaCodecCapabilitiesTest by lazy {
|
||||
// Created lazily below on the IO thread since it cn take time
|
||||
// Created lazily below on another thread since it cn take time
|
||||
MediaCodecCapabilitiesTest(context)
|
||||
}
|
||||
private val mutex = Mutex()
|
||||
|
|
@ -33,7 +36,7 @@ class DeviceProfileService
|
|||
prefs: PlaybackPreferences,
|
||||
serverVersion: ServerVersion?,
|
||||
): DeviceProfile =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val newConfig =
|
||||
DeviceProfileConfiguration(
|
||||
|
|
|
|||
|
|
@ -9,12 +9,20 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Get extras for media
|
||||
*
|
||||
* @see [ExtrasItem]
|
||||
*/
|
||||
@Singleton
|
||||
class ExtrasService
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
/**
|
||||
* Get the [ExtrasItem]s for the given item
|
||||
*/
|
||||
suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
|
||||
val extrasMap =
|
||||
api.userLibraryApi
|
||||
|
|
@ -42,6 +50,9 @@ class ExtrasService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The order which extras should be shown
|
||||
*/
|
||||
private val ExtraType.sortOrder: Int
|
||||
get() =
|
||||
when (this) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Handles toggling media as favorited or watched
|
||||
*/
|
||||
@Singleton
|
||||
class FavoriteWatchManager
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ import java.io.File
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Handles getting home page settings and data
|
||||
*/
|
||||
@Singleton
|
||||
class HomeSettingsService
|
||||
@Inject
|
||||
|
|
@ -84,6 +87,9 @@ class HomeSettingsService
|
|||
allowTrailingComma = true
|
||||
}
|
||||
|
||||
/**
|
||||
* The current home page settings
|
||||
*/
|
||||
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
|
||||
|
||||
/**
|
||||
|
|
@ -245,6 +251,9 @@ class HomeSettingsService
|
|||
currentSettings.update { resolvedSettings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the settings and set them to be the current settings
|
||||
*/
|
||||
suspend fun updateCurrent(settings: HomePageSettings) {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
|
|
@ -317,6 +326,9 @@ class HomeSettingsService
|
|||
return HomePageResolvedSettings(rowConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create home page settings from the user's web UI home page settings
|
||||
*/
|
||||
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
|
||||
val customPrefs =
|
||||
api.displayPreferencesApi
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -16,12 +14,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import timber.log.Timber
|
||||
|
|
@ -31,6 +24,9 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Get continue watching and next up items for users
|
||||
*/
|
||||
@Singleton
|
||||
class LatestNextUpService
|
||||
@Inject
|
||||
|
|
@ -39,6 +35,9 @@ class LatestNextUpService
|
|||
private val api: ApiClient,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
) {
|
||||
/**
|
||||
* Get resume (continue watching) items for a user
|
||||
*/
|
||||
suspend fun getResume(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
|
|
@ -60,13 +59,6 @@ class LatestNextUpService
|
|||
remove(BaseItemKind.EPISODE)
|
||||
}
|
||||
},
|
||||
// enableImageTypes =
|
||||
// listOf(
|
||||
// ImageType.PRIMARY,
|
||||
// ImageType.THUMB,
|
||||
// ImageType.BACKDROP,
|
||||
// ImageType.LOGO,
|
||||
// ),
|
||||
)
|
||||
val items =
|
||||
api.itemsApi
|
||||
|
|
@ -77,6 +69,9 @@ class LatestNextUpService
|
|||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next up items for a user
|
||||
*/
|
||||
suspend fun getNextUp(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
|
|
@ -98,13 +93,6 @@ class LatestNextUpService
|
|||
enableUserData = true,
|
||||
enableRewatching = enableRewatching,
|
||||
nextUpDateCutoff = nextUpDateCutoff,
|
||||
// enableImageTypes =
|
||||
// listOf(
|
||||
// ImageType.PRIMARY,
|
||||
// ImageType.THUMB,
|
||||
// ImageType.BACKDROP,
|
||||
// ImageType.LOGO,
|
||||
// ),
|
||||
)
|
||||
val nextUp =
|
||||
api.tvShowsApi
|
||||
|
|
@ -115,65 +103,11 @@ class LatestNextUpService
|
|||
return nextUp
|
||||
}
|
||||
|
||||
suspend fun getLatest(
|
||||
user: UserDto,
|
||||
limit: Int,
|
||||
includedIds: List<UUID>,
|
||||
): List<LatestData> {
|
||||
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
||||
val views by api.userViewsApi.getUserViews()
|
||||
val latestData =
|
||||
views.items
|
||||
.filter {
|
||||
it.id in includedIds && it.id !in excluded &&
|
||||
it.collectionType in supportedLatestCollectionTypes
|
||||
}.map { view ->
|
||||
val title =
|
||||
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
||||
?: context.getString(R.string.recently_added)
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = SlimItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = view.id,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
isPlayed = null, // Server will handle user's preference
|
||||
)
|
||||
LatestData(title, request)
|
||||
}
|
||||
|
||||
return latestData
|
||||
}
|
||||
|
||||
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
|
||||
val rows =
|
||||
latestData.mapNotNull { (title, request) ->
|
||||
try {
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
if (latest.isNotEmpty()) {
|
||||
HomeRowLoadingState.Success(
|
||||
title = title,
|
||||
items = latest,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception fetching %s", title)
|
||||
HomeRowLoadingState.Error(
|
||||
title = title,
|
||||
exception = ex,
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the combined Continue Watching & Next Up items
|
||||
*
|
||||
* @see [DatePlayedService]
|
||||
*/
|
||||
suspend fun buildCombined(
|
||||
resume: List<BaseItem>,
|
||||
nextUp: List<BaseItem>,
|
||||
|
|
@ -207,17 +141,3 @@ class LatestNextUpService
|
|||
return@withContext result
|
||||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
// Exclude Live TV because a recording folder view will be used instead
|
||||
null, // Recordings & mixed collection types
|
||||
)
|
||||
|
||||
data class LatestData(
|
||||
val title: String,
|
||||
val request: GetLatestMediaRequest,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Service to manage media such as deletions
|
||||
*/
|
||||
@Singleton
|
||||
class MediaManagementService
|
||||
@Inject
|
||||
|
|
@ -45,6 +48,9 @@ class MediaManagementService
|
|||
return canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the item can be deleted. This means the app setting is enabled and the user has permission.
|
||||
*/
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
|
|
@ -62,10 +68,14 @@ class MediaManagementService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the item.
|
||||
*
|
||||
* This item will be sent through [deletedItemFlow] for other services or view models to react.
|
||||
*/
|
||||
suspend fun deleteItem(item: BaseItem): DeleteResult {
|
||||
try {
|
||||
Timber.i("Deleting %s", item.id)
|
||||
// TODO enable
|
||||
api.libraryApi.deleteItem(item.id)
|
||||
_deletedItemFlow.emit(DeletedItem(item))
|
||||
return DeleteResult.Success
|
||||
|
|
@ -88,6 +98,9 @@ sealed interface DeleteResult {
|
|||
) : DeleteResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to delete an item and show a Toast based on success or error
|
||||
*/
|
||||
fun ViewModel.deleteItem(
|
||||
context: Context,
|
||||
mediaManagementService: MediaManagementService,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Send media info to the server
|
||||
*/
|
||||
@Singleton
|
||||
class MediaReportService
|
||||
@Inject
|
||||
|
|
@ -39,6 +42,9 @@ class MediaReportService
|
|||
encodeDefaults = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the media info and send it to the server
|
||||
*/
|
||||
fun sendReportFor(itemId: UUID) {
|
||||
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
||||
|
|
@ -46,6 +52,9 @@ class MediaReportService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the media report for the given item
|
||||
*/
|
||||
suspend fun sendReportFor(item: BaseItemDto) {
|
||||
val sources =
|
||||
item.mediaSources ?: api.userLibraryApi
|
||||
|
|
|
|||
|
|
@ -63,6 +63,11 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Manage the global state for playing music
|
||||
*
|
||||
* Has functions for modifying the queue
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class MusicService
|
||||
|
|
@ -104,6 +109,11 @@ class MusicService
|
|||
private var activityTracker: TrackActivityPlaybackListener? = null
|
||||
private var websocketJob: Job? = null
|
||||
|
||||
/**
|
||||
* Start music playback
|
||||
*
|
||||
* Sets up the media session, activity tracking, and actual playback
|
||||
*/
|
||||
suspend fun start() {
|
||||
if (mediaSession == null) {
|
||||
mutex.withLock {
|
||||
|
|
@ -130,6 +140,9 @@ class MusicService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop music playback
|
||||
*/
|
||||
suspend fun stop() {
|
||||
mutex.withLock {
|
||||
Timber.i("Stopping music")
|
||||
|
|
@ -191,6 +204,9 @@ class MusicService
|
|||
addAllToQueue(items, startIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the queue with the given items
|
||||
*/
|
||||
suspend fun setQueue(
|
||||
items: List<BaseItem>,
|
||||
shuffled: Boolean,
|
||||
|
|
@ -208,6 +224,9 @@ class MusicService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the specified index of the queue. If no index specified, it will be added to the end.
|
||||
*/
|
||||
suspend fun addToQueue(
|
||||
item: BaseItem,
|
||||
index: Int? = null,
|
||||
|
|
@ -229,6 +248,11 @@ class MusicService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all the items in teh list to end of the queue
|
||||
*
|
||||
* @param startIndex The index to start from within the source list
|
||||
*/
|
||||
suspend fun addAllToQueue(
|
||||
list: BlockingList<BaseItem?>,
|
||||
startIndex: Int,
|
||||
|
|
@ -255,6 +279,9 @@ class MusicService
|
|||
updateQueueSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
|
||||
*/
|
||||
private fun convert(audio: BaseItem): MediaItem {
|
||||
val url =
|
||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||
|
|
@ -282,6 +309,9 @@ class MusicService
|
|||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state for when the queue changes
|
||||
*/
|
||||
private suspend fun updateQueueSize() {
|
||||
// val ids =
|
||||
// withContext(Dispatchers.Default) {
|
||||
|
|
@ -306,6 +336,9 @@ class MusicService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item within the queue
|
||||
*/
|
||||
suspend fun moveQueue(
|
||||
index: Int,
|
||||
direction: MoveDirection,
|
||||
|
|
@ -314,6 +347,9 @@ class MusicService
|
|||
updateQueueSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item within the queue
|
||||
*/
|
||||
suspend fun moveQueue(
|
||||
index: Int,
|
||||
newIndex: Int,
|
||||
|
|
@ -322,6 +358,9 @@ class MusicService
|
|||
updateQueueSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback at the given index of the queue
|
||||
*/
|
||||
suspend fun playIndex(index: Int) {
|
||||
onMain {
|
||||
player.seekTo(index, 0L)
|
||||
|
|
@ -330,6 +369,9 @@ class MusicService
|
|||
// MusicPlayerListener will update state
|
||||
}
|
||||
|
||||
/**
|
||||
* Play this item next after the current, ie add the item as the next index in the queue
|
||||
*/
|
||||
suspend fun playNext(song: BaseItem) {
|
||||
val mediaItem = convert(song)
|
||||
onMain {
|
||||
|
|
@ -341,6 +383,9 @@ class MusicService
|
|||
updateQueueSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* From the item at the given index from the queue
|
||||
*/
|
||||
suspend fun removeFromQueue(index: Int) {
|
||||
onMain { player.removeMediaItem(index) }
|
||||
updateQueueSize()
|
||||
|
|
@ -353,7 +398,10 @@ class MusicService
|
|||
return result
|
||||
}
|
||||
|
||||
fun subscribe(): Job =
|
||||
/**
|
||||
* Subscribes to the server websocket to receive playback commands
|
||||
*/
|
||||
private fun subscribe(): Job =
|
||||
api.webSocket
|
||||
.subscribe<PlaystateMessage>()
|
||||
.onEach { message ->
|
||||
|
|
@ -503,6 +551,11 @@ private class MusicPlayerListener(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the queue currently playing
|
||||
*
|
||||
* @see MusicServiceState
|
||||
*/
|
||||
@Composable
|
||||
fun rememberQueue(
|
||||
player: Player,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/**
|
||||
* Gets the items to show in the nav drawer
|
||||
*/
|
||||
@Singleton
|
||||
class NavDrawerService
|
||||
@Inject
|
||||
|
|
@ -54,6 +57,7 @@ class NavDrawerService
|
|||
val state: StateFlow<NavDrawerItemState> = _state
|
||||
|
||||
init {
|
||||
// Handle updating the nav drawer when the user changes
|
||||
serverRepository.currentUser
|
||||
.asFlow()
|
||||
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
||||
|
|
@ -74,10 +78,13 @@ class NavDrawerService
|
|||
showToast(context, "Error fetching user's views")
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
// Handle when the user has logged into a Seerr server
|
||||
seerrServerRepository.active
|
||||
.onEach { discoverActive ->
|
||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
// Handle when music is actively playing or not
|
||||
coroutineScope.launchDefault {
|
||||
musicService.state.collectLatest { music ->
|
||||
Timber.v("MusicService updated")
|
||||
|
|
@ -114,6 +121,9 @@ class NavDrawerService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the libraries the user has access to
|
||||
*/
|
||||
suspend fun getAllUserLibraries(
|
||||
userId: UUID,
|
||||
tvAccess: Boolean,
|
||||
|
|
@ -147,6 +157,9 @@ class NavDrawerService
|
|||
return libraries
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the libraries that the user has not "pinned". These will show in the More section.
|
||||
*/
|
||||
suspend fun getFilteredUserLibraries(
|
||||
user: JellyfinUser,
|
||||
tvAccess: Boolean,
|
||||
|
|
@ -161,6 +174,9 @@ class NavDrawerService
|
|||
return libraries
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current state of the nav drawer items
|
||||
*/
|
||||
suspend fun updateNavDrawer(
|
||||
user: JellyfinUser,
|
||||
userDto: UserDto,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Gets people in media, specifically to check if they are favorited or not
|
||||
*/
|
||||
@Singleton
|
||||
class PeopleFavorites
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Create [Playlist]s (not Jellyfin server playlist) for playback
|
||||
*
|
||||
* Used to create a queue of episodes or from Play All button
|
||||
*/
|
||||
@Singleton
|
||||
class PlaylistCreator
|
||||
@Inject
|
||||
|
|
@ -74,6 +79,9 @@ class PlaylistCreator
|
|||
return Playlist(episodes, startIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from a server playlist ID
|
||||
*/
|
||||
suspend fun createFromPlaylistId(
|
||||
playlistId: UUID,
|
||||
startIndex: Int?,
|
||||
|
|
@ -138,6 +146,11 @@ class PlaylistCreator
|
|||
return Playlist(items, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a [Playlist] contextually based on the given item.
|
||||
*
|
||||
* For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed
|
||||
*/
|
||||
suspend fun createFrom(
|
||||
item: BaseItemDto,
|
||||
startIndex: Int = 0,
|
||||
|
|
@ -275,6 +288,9 @@ class PlaylistCreator
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the playlists on the server for a given media type
|
||||
*/
|
||||
suspend fun getServerPlaylists(
|
||||
mediaType: MediaType?,
|
||||
scope: CoroutineScope,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import javax.inject.Singleton
|
|||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media
|
||||
*/
|
||||
@Singleton
|
||||
class RefreshRateService
|
||||
@Inject
|
||||
|
|
@ -119,6 +122,9 @@ class RefreshRateService
|
|||
MainActivity.instance.changeDisplayMode(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for the display to change so we known the refresh rate or resolution is updated
|
||||
*/
|
||||
private class Listener(
|
||||
val displayId: Int,
|
||||
) : DisplayManager.DisplayListener {
|
||||
|
|
@ -213,6 +219,9 @@ class RefreshRateService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for a [Display.Mode]
|
||||
*/
|
||||
data class DisplayMode(
|
||||
val modeId: Int,
|
||||
val physicalWidth: Int,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Handles the queue of items to show on the screensaver, both in-app or OS
|
||||
*/
|
||||
@Singleton
|
||||
class ScreensaverService
|
||||
@Inject
|
||||
|
|
@ -67,6 +70,9 @@ class ScreensaverService
|
|||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timer before showing the in-app screensaver
|
||||
*/
|
||||
fun pulse() {
|
||||
waitJob?.cancel()
|
||||
if (_state.value.enabled) {
|
||||
|
|
@ -95,6 +101,9 @@ class ScreensaverService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately start the in-app screensaver
|
||||
*/
|
||||
fun start() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
|
@ -104,6 +113,9 @@ class ScreensaverService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop the in-app screensaver
|
||||
*/
|
||||
fun stop(cancelJob: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
|
@ -114,6 +126,9 @@ class ScreensaverService
|
|||
if (cancelJob) waitJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active
|
||||
*/
|
||||
fun keepScreenOn(keep: Boolean) {
|
||||
scope.launchDefault {
|
||||
val screensaverEnabled = _state.value.enabled
|
||||
|
|
@ -136,6 +151,9 @@ class ScreensaverService
|
|||
keepScreenOn.update { keep }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flow of items to show on the screensaver
|
||||
*/
|
||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
||||
flow {
|
||||
val prefs =
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ import org.jellyfin.sdk.model.api.MediaType
|
|||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Listens for basic messages from the server such as messages
|
||||
*/
|
||||
@ActivityScoped
|
||||
class ServerEventListener
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manage the track choices for media
|
||||
*/
|
||||
@Singleton
|
||||
class StreamChoiceService
|
||||
@Inject
|
||||
|
|
@ -94,6 +97,9 @@ class StreamChoiceService
|
|||
result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the audio stream that should play
|
||||
*/
|
||||
suspend fun chooseAudioStream(
|
||||
source: MediaSourceInfo,
|
||||
seriesId: UUID?,
|
||||
|
|
@ -108,6 +114,9 @@ class StreamChoiceService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the audio stream that should play
|
||||
*/
|
||||
fun chooseAudioStream(
|
||||
candidates: List<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
|
|
@ -135,6 +144,9 @@ class StreamChoiceService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subtitle stream that should play
|
||||
*/
|
||||
suspend fun chooseSubtitleStream(
|
||||
source: MediaSourceInfo,
|
||||
audioStream: MediaStream?,
|
||||
|
|
@ -196,6 +208,9 @@ class StreamChoiceService
|
|||
)?.index
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subtitle stream that should play
|
||||
*/
|
||||
fun chooseSubtitleStream(
|
||||
audioStreamLang: String?,
|
||||
candidates: List<MediaStream>,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Gets trailers for media
|
||||
*/
|
||||
@Singleton
|
||||
class TrailerService
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ import javax.inject.Singleton
|
|||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Checks if an app update is available
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateChecker
|
||||
@Inject
|
||||
|
|
@ -65,6 +68,11 @@ class UpdateChecker
|
|||
val ACTIVE = true
|
||||
}
|
||||
|
||||
/**
|
||||
* If the app hasn't recently checked, check for any updates and if there is one, show a toast message
|
||||
*
|
||||
* This is safe to call many times because it will only show the toast at most once every 12 hours
|
||||
*/
|
||||
suspend fun maybeShowUpdateToast(
|
||||
updateUrl: String,
|
||||
showNegativeToast: Boolean = false,
|
||||
|
|
@ -112,11 +120,17 @@ class UpdateChecker
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently installed version
|
||||
*/
|
||||
fun getInstalledVersion(): Version {
|
||||
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
return Version.Companion.fromString(pkgInfo.versionName!!)
|
||||
return Version.fromString(pkgInfo.versionName!!)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest released version
|
||||
*/
|
||||
suspend fun getLatestRelease(updateUrl: String): Release? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
|
|
@ -161,6 +175,9 @@ class UpdateChecker
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install an update
|
||||
*/
|
||||
suspend fun installRelease(
|
||||
release: Release,
|
||||
callback: DownloadCallback,
|
||||
|
|
@ -263,6 +280,9 @@ class UpdateChecker
|
|||
return targetFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app has permission to write the download
|
||||
*/
|
||||
fun hasPermissions(): Boolean =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Get the current user's [UserPreferences]
|
||||
*/
|
||||
@Singleton
|
||||
class UserPreferencesService
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -34,26 +34,48 @@ import org.jellyfin.sdk.model.DeviceInfo
|
|||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* An [OkHttpClient] that includes the user's access token when making requests
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class AuthOkHttpClient
|
||||
|
||||
/**
|
||||
* A basic [OkHttpClient] that does not include auth
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class StandardOkHttpClient
|
||||
|
||||
/**
|
||||
* A [CoroutineScope] with [Dispatchers.IO]
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoCoroutineScope
|
||||
|
||||
/**
|
||||
* A [CoroutineScope] with [Dispatchers.Default]
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultCoroutineScope
|
||||
|
||||
/**
|
||||
* [Dispatchers.IO]
|
||||
*
|
||||
* @see IoCoroutineScope
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
/**
|
||||
* [Dispatchers.Default]
|
||||
*
|
||||
* @see DefaultCoroutineScope
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultDispatcher
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ import kotlin.time.Duration.Companion.minutes
|
|||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
/**
|
||||
* Schedules the [TvProviderWorker] to update the OS resume watching row
|
||||
*/
|
||||
@ActivityScoped
|
||||
class TvProviderSchedulerService
|
||||
@Inject
|
||||
|
|
@ -44,6 +47,7 @@ class TvProviderSchedulerService
|
|||
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||
if (supportsTvProvider) {
|
||||
if (user != null) {
|
||||
// Schedule a new worker whenever the user changes
|
||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
||||
workManager
|
||||
|
|
@ -70,6 +74,9 @@ class TvProviderSchedulerService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the [TvProviderWorker] as a one-off instead of scheduled
|
||||
*/
|
||||
fun launchOneTimeRefresh() {
|
||||
if (supportsTvProvider) {
|
||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ import java.util.Date
|
|||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Updates the Android OS resume watching row for a given Jellyfin user
|
||||
*
|
||||
* @see TvProviderSchedulerService
|
||||
*/
|
||||
@HiltWorker
|
||||
class TvProviderWorker
|
||||
@AssistedInject
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue