mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Dev: Move services into their own package (#214)
Moves service classes into their own files and package for easier organization and discovery No user facing changes in the PR
This commit is contained in:
parent
76b7a33328
commit
b7cb6efc4f
56 changed files with 259 additions and 233 deletions
|
|
@ -0,0 +1,104 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.edit
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppUpgradeHandler
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val appPreferences: DataStore<AppPreferences>,
|
||||
) {
|
||||
suspend fun run() {
|
||||
val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0)
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
||||
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
||||
|
||||
val newVersion = pkgInfo.versionName!!
|
||||
val newVersionCode =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
pkgInfo.longVersionCode
|
||||
} else {
|
||||
pkgInfo.versionCode.toLong()
|
||||
}
|
||||
if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
|
||||
Timber.i(
|
||||
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||
)
|
||||
prefs.edit(true) {
|
||||
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
|
||||
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
|
||||
putString(VERSION_NAME_CURRENT_KEY, newVersion)
|
||||
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
|
||||
}
|
||||
try {
|
||||
upgradeApp(
|
||||
context,
|
||||
Version.Companion.fromString(previousVersion ?: "0.0.0"),
|
||||
Version.Companion.fromString(newVersion),
|
||||
appPreferences,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception during app upgrade")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VERSION_NAME_PREVIOUS_KEY = "version.previous.name"
|
||||
const val VERSION_CODE_PREVIOUS_KEY = "version.previous.code"
|
||||
const val VERSION_NAME_CURRENT_KEY = "version.current.name"
|
||||
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun upgradeApp(
|
||||
context: Context,
|
||||
previous: Version,
|
||||
current: Version,
|
||||
appPreferences: DataStore<AppPreferences>,
|
||||
) {
|
||||
if (previous.isEqualOrBefore(Version.Companion.fromString("0.1.0-2-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updatePlaybackOverrides {
|
||||
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
||||
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
||||
directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.3-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.5-11-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
showClock = AppPreference.ShowClock.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.7-1-g0"))) {
|
||||
PreferencesViewModel.resetSubtitleSettings(appPreferences)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.UserItemDataDto
|
||||
import java.util.UUID
|
||||
|
||||
interface FavoriteWatchManager {
|
||||
suspend fun setWatched(
|
||||
itemId: UUID,
|
||||
played: Boolean,
|
||||
): UserItemDataDto
|
||||
|
||||
suspend fun setFavorite(
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
): UserItemDataDto
|
||||
}
|
||||
|
||||
class FavoriteWatchManagerImpl(
|
||||
private val api: ApiClient,
|
||||
) : FavoriteWatchManager {
|
||||
override suspend fun setWatched(
|
||||
itemId: UUID,
|
||||
played: Boolean,
|
||||
): UserItemDataDto =
|
||||
if (played) {
|
||||
api.playStateApi.markPlayedItem(itemId).content
|
||||
} else {
|
||||
api.playStateApi.markUnplayedItem(itemId).content
|
||||
}
|
||||
|
||||
override suspend fun setFavorite(
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
): UserItemDataDto =
|
||||
if (favorite) {
|
||||
api.userLibraryApi.markFavoriteItem(itemId).content
|
||||
} else {
|
||||
api.userLibraryApi.unmarkFavoriteItem(itemId).content
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import org.acra.ACRA
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manages navigating between pages and manages the app's back stack
|
||||
*/
|
||||
@Singleton
|
||||
class NavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
var backStack: MutableList<NavKey> = mutableListOf()
|
||||
|
||||
/**
|
||||
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
||||
*/
|
||||
fun navigateTo(destination: Destination) {
|
||||
backStack.add(destination)
|
||||
log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the specified [Destination], but reset the back stack to Home first
|
||||
*/
|
||||
fun navigateToFromDrawer(destination: Destination) {
|
||||
goToHome()
|
||||
if (destination == Destination.ServerList || destination is Destination.UserList) {
|
||||
backStack.add(destination)
|
||||
(0..<backStack.size - 1).forEach { _ -> backStack.removeAt(0) }
|
||||
} else {
|
||||
backStack.add(destination)
|
||||
}
|
||||
log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the previous page
|
||||
*/
|
||||
fun goBack() {
|
||||
backStack.removeLastOrNull()
|
||||
log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Go all the way back to the home page
|
||||
*/
|
||||
fun goToHome() {
|
||||
while (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
if (backStack[0] !is Destination.Home) {
|
||||
backStack[0] = Destination.Home()
|
||||
}
|
||||
log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Go all the way back to the home page, and reload it from scratch
|
||||
*/
|
||||
fun reloadHome() {
|
||||
goToHome()
|
||||
val id = (backStack[0] as Destination.Home).id + 1
|
||||
backStack[0] = Destination.Home(id)
|
||||
log()
|
||||
}
|
||||
|
||||
private fun log() {
|
||||
val dest = backStack.lastOrNull().toString()
|
||||
Timber.Forest.i("Current Destination: %s", dest)
|
||||
ACRA.errorReporter.putCustomData("destination", dest)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PeopleFavorites
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
suspend fun getPeopleFor(item: BaseItem): List<Person> =
|
||||
item.data.people.orEmpty().map { it.id }.chunked(50).let { chunks ->
|
||||
val favorites =
|
||||
chunks
|
||||
.map {
|
||||
api.itemsApi
|
||||
.getItems(ids = it)
|
||||
.content.items
|
||||
}.flatten()
|
||||
.associateBy({ it.id }, { it.userData?.isFavorite ?: false })
|
||||
val people =
|
||||
item.data.people
|
||||
?.letNotEmpty { people ->
|
||||
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
|
||||
}.orEmpty()
|
||||
people
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Observes the activity lifecycle in order to pause/resume/stop playback
|
||||
*/
|
||||
@ActivityRetainedScoped
|
||||
class PlaybackLifecycleObserver
|
||||
@Inject
|
||||
constructor(
|
||||
private val navigationManager: NavigationManager,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
) : DefaultLifecycleObserver {
|
||||
private var wasPlaying: Boolean? = null
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
wasPlaying = null
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
if (wasPlaying == true) {
|
||||
playerFactory.currentPlayer?.let {
|
||||
if (!it.isReleased) it.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
playerFactory.currentPlayer?.let {
|
||||
wasPlaying = it.isPlaying
|
||||
it.pause()
|
||||
}
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (navigationManager.backStack.lastOrNull() is Destination.Playback) {
|
||||
navigationManager.goBack()
|
||||
}
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
@file:OptIn(markerClass = [UnstableApi::class])
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Constructs a [Player] instance for video playback
|
||||
*/
|
||||
@Singleton
|
||||
class PlayerFactory
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val appPreferences: DataStore<AppPreferences>,
|
||||
) {
|
||||
@Volatile
|
||||
var currentPlayer: Player? = null
|
||||
private set
|
||||
|
||||
fun createVideoPlayer(): Player {
|
||||
if (currentPlayer?.isReleased == false) {
|
||||
Timber.w("Player was not released before trying to create a new one!")
|
||||
currentPlayer?.release()
|
||||
}
|
||||
|
||||
val extensions =
|
||||
runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled
|
||||
Timber.v("extensions=$extensions")
|
||||
val rendererMode =
|
||||
when (extensions) {
|
||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
}
|
||||
val newPlayer =
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.setRenderersFactory(
|
||||
DefaultRenderersFactory(context)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode),
|
||||
).build()
|
||||
.apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
currentPlayer = newPlayer
|
||||
return newPlayer
|
||||
}
|
||||
}
|
||||
|
||||
val Player.isReleased: Boolean
|
||||
get() {
|
||||
return when (this) {
|
||||
is ExoPlayer -> isReleased
|
||||
else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.TransformList
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CreatePlaylistDto
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PlaylistUserPermissions
|
||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PlaylistCreator
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
) {
|
||||
suspend fun createFromEpisode(
|
||||
seriesId: UUID,
|
||||
episodeId: UUID,
|
||||
): Playlist {
|
||||
val request =
|
||||
GetEpisodesRequest(
|
||||
seriesId = seriesId,
|
||||
fields = DefaultItemFields,
|
||||
startItemId = episodeId,
|
||||
limit = Playlist.Companion.MAX_SIZE,
|
||||
)
|
||||
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
|
||||
val startIndex =
|
||||
episodes.indexOfFirstOrNull { it.id == episodeId }
|
||||
?: throw IllegalStateException("Episode $episodeId was not returned")
|
||||
return Playlist(episodes.map { BaseItem.Companion.from(it, api) }, startIndex)
|
||||
}
|
||||
|
||||
suspend fun createFromPlaylistId(
|
||||
playlistId: UUID,
|
||||
startIndex: Int?,
|
||||
shuffled: Boolean,
|
||||
): Playlist {
|
||||
val request =
|
||||
GetPlaylistItemsRequest(
|
||||
playlistId = playlistId,
|
||||
fields = DefaultItemFields,
|
||||
startIndex = startIndex,
|
||||
limit = Playlist.Companion.MAX_SIZE,
|
||||
)
|
||||
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
|
||||
var baseItems = items.map { BaseItem.Companion.from(it, api) }
|
||||
if (shuffled) {
|
||||
baseItems = baseItems.shuffled()
|
||||
}
|
||||
return Playlist(baseItems, 0)
|
||||
}
|
||||
|
||||
suspend fun getPlaylists(
|
||||
mediaType: MediaType?,
|
||||
scope: CoroutineScope,
|
||||
): List<PlaylistInfo?> {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
|
||||
mediaTypes = mediaType?.let { listOf(mediaType) },
|
||||
recursive = true,
|
||||
)
|
||||
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||
return TransformList(pager) {
|
||||
it?.let {
|
||||
PlaylistInfo(
|
||||
id = it.id,
|
||||
name = it.name ?: context.getString(R.string.unknown),
|
||||
count = it.data.childCount ?: 0,
|
||||
mediaType = it.data.mediaType,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createPlaylist(
|
||||
name: String,
|
||||
initialItems: List<UUID>,
|
||||
): UUID? =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
api.playlistsApi
|
||||
.createPlaylist(
|
||||
CreatePlaylistDto(
|
||||
name = name,
|
||||
ids = initialItems,
|
||||
users = listOf(PlaylistUserPermissions(user.id, true)),
|
||||
isPublic = false,
|
||||
),
|
||||
).content.id
|
||||
.toUUIDOrNull()
|
||||
}
|
||||
|
||||
suspend fun addToPlaylist(
|
||||
playlistId: UUID,
|
||||
itemId: UUID,
|
||||
) {
|
||||
api.playlistsApi.addItemToPlaylist(playlistId, listOf(itemId))
|
||||
}
|
||||
|
||||
suspend fun removeFromPlaylist(
|
||||
playlistId: UUID,
|
||||
itemId: UUID,
|
||||
) {
|
||||
api.playlistsApi.removeItemFromPlaylist(
|
||||
playlistId.toServerString(),
|
||||
listOf(itemId.toServerString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||
import org.jellyfin.sdk.api.sockets.subscribe
|
||||
import org.jellyfin.sdk.model.api.GeneralCommandMessage
|
||||
import org.jellyfin.sdk.model.api.GeneralCommandType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@ActivityScoped
|
||||
class ServerEventListener
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
) : DefaultLifecycleObserver {
|
||||
private val activity = (context as AppCompatActivity)
|
||||
|
||||
private var listenJob: Job? = null
|
||||
|
||||
init {
|
||||
activity.lifecycle.addObserver(this)
|
||||
serverRepository.current.observe(activity) {
|
||||
Timber.d("New user/server: %s", it)
|
||||
listenJob?.cancel()
|
||||
if (it != null) {
|
||||
init(it.server, it.user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init(
|
||||
server: JellyfinServer?,
|
||||
user: JellyfinUser?,
|
||||
) {
|
||||
if (server != null && user != null && api.baseUrl != null && api.accessToken != null) {
|
||||
(context as AppCompatActivity).lifecycleScope.launchIO {
|
||||
api.sessionApi.postCapabilities(
|
||||
playableMediaTypes = listOf(MediaType.VIDEO),
|
||||
supportedCommands =
|
||||
listOf(
|
||||
GeneralCommandType.DISPLAY_MESSAGE,
|
||||
GeneralCommandType.SEND_STRING,
|
||||
),
|
||||
supportsMediaControl = true,
|
||||
)
|
||||
setupListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setupListeners() {
|
||||
serverRepository.currentUser
|
||||
Timber.v("Subscribing to WebSocket")
|
||||
listenJob?.cancel()
|
||||
listenJob =
|
||||
api.webSocket
|
||||
.subscribe<GeneralCommandMessage>()
|
||||
.onEach { message ->
|
||||
if (message.data?.name in
|
||||
setOf(
|
||||
GeneralCommandType.DISPLAY_MESSAGE,
|
||||
GeneralCommandType.SEND_STRING,
|
||||
)
|
||||
) {
|
||||
val header = message.data?.arguments["Header"]
|
||||
val text =
|
||||
message.data?.arguments["Text"] ?: message.data?.arguments["String"]
|
||||
val toast =
|
||||
listOfNotNull(header, text)
|
||||
.joinToString("\n")
|
||||
showToast(context, toast, Toast.LENGTH_LONG)
|
||||
}
|
||||
}.launchIn(activity.lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
serverRepository.current.value?.let { init(it.server, it.user) }
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
Timber.v("Cancelling WebSocket")
|
||||
listenJob?.cancel()
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
Timber.v("Cancelling WebSocket")
|
||||
listenJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Simple service to play theme song music
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class ThemeSongPlayer
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
private val player: Player by lazy {
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(
|
||||
OkHttpDataSource.Factory(authOkHttpClient),
|
||||
),
|
||||
).build()
|
||||
}
|
||||
|
||||
suspend fun playThemeFor(
|
||||
itemId: UUID,
|
||||
volume: ThemeSongVolume,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (volume == ThemeSongVolume.DISABLED || volume == ThemeSongVolume.UNRECOGNIZED) {
|
||||
return@withContext false
|
||||
}
|
||||
val themeSongs by api.libraryApi.getThemeSongs(itemId)
|
||||
return@withContext themeSongs.items.randomOrNull()?.let { theme ->
|
||||
val url =
|
||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||
theme.id,
|
||||
container =
|
||||
listOf(
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.MP3,
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.FLAC,
|
||||
),
|
||||
)
|
||||
Timber.v("Found theme song for $itemId")
|
||||
withContext(Dispatchers.Main) {
|
||||
play(volume, url)
|
||||
}
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
|
||||
private fun play(
|
||||
volumeLevel: ThemeSongVolume,
|
||||
url: String,
|
||||
) {
|
||||
val volumeLevel =
|
||||
when (volumeLevel) {
|
||||
ThemeSongVolume.UNRECOGNIZED,
|
||||
ThemeSongVolume.DISABLED,
|
||||
-> return
|
||||
|
||||
ThemeSongVolume.LOWEST -> .05f
|
||||
ThemeSongVolume.LOW -> .1f
|
||||
ThemeSongVolume.MEDIUM -> .25f
|
||||
ThemeSongVolume.HIGH -> .5f
|
||||
ThemeSongVolume.HIGHEST -> 75f
|
||||
}
|
||||
player.apply {
|
||||
stop()
|
||||
volume = volumeLevel
|
||||
setMediaItem(MediaItem.fromUri(url))
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Timber.v("Stopping theme song")
|
||||
player.stop()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TrailerService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
suspend fun getTrailers(item: BaseItem): List<Trailer> {
|
||||
val remoteTrailers =
|
||||
item.data.remoteTrailers
|
||||
?.mapNotNull { t ->
|
||||
t.url?.let { url ->
|
||||
val name =
|
||||
t.name
|
||||
// TODO would be nice to clean up the trailer name
|
||||
// ?.replace(item.name ?: "", "")
|
||||
// ?.removePrefix(" - ")
|
||||
?: context.getString(R.string.trailer)
|
||||
RemoteTrailer(name, url)
|
||||
}
|
||||
}.orEmpty()
|
||||
.sortedBy { it.name }
|
||||
val localTrailerCount = item.data.localTrailerCount ?: 0
|
||||
val localTrailers =
|
||||
if (localTrailerCount > 0) {
|
||||
api.userLibraryApi.getLocalTrailers(item.id).content.map {
|
||||
LocalTrailer(BaseItem.Companion.from(it, api))
|
||||
}
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
return localTrailers + remoteTrailers
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Note: This is explicitly <b>not</b> a member function because the injected Context is not an Activity.
|
||||
* We want to start the intent without a new task which requires the Activity context
|
||||
*
|
||||
* This can be provided by LocalContext.current from Compose
|
||||
*/
|
||||
fun onClick(
|
||||
context: Context,
|
||||
trailer: Trailer,
|
||||
navigateTo: (Destination) -> Unit,
|
||||
) {
|
||||
when (trailer) {
|
||||
is LocalTrailer ->
|
||||
navigateTo.invoke(
|
||||
Destination.Playback(
|
||||
itemId = trailer.baseItem.id,
|
||||
item = trailer.baseItem,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
|
||||
is RemoteTrailer -> {
|
||||
val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.headersContentLength
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Singleton
|
||||
class UpdateChecker
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
companion object {
|
||||
// TODO apk names
|
||||
private const val ASSET_NAME = "Wholphin.apk"
|
||||
private const val DEBUG_ASSET_NAME = "Wholphin-debug.apk"
|
||||
private const val RELEASE_ASSET_NAME = "Wholphin-release.apk"
|
||||
|
||||
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
||||
|
||||
private const val PERMISSION_REQUEST_CODE = 123456
|
||||
|
||||
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
||||
}
|
||||
|
||||
suspend fun maybeShowUpdateToast(
|
||||
updateUrl: String,
|
||||
showNegativeToast: Boolean = false,
|
||||
) {
|
||||
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val now = Date()
|
||||
val lastUpdateCheckThreshold =
|
||||
pref
|
||||
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
|
||||
.hours
|
||||
val lastUpdateCheck =
|
||||
pref.getLong(
|
||||
context.getString(R.string.pref_key_update_last_check),
|
||||
0,
|
||||
)
|
||||
val timeSince = (now.time - lastUpdateCheck).milliseconds
|
||||
Timber.v("Last successful update check was $timeSince ago")
|
||||
val installedVersion = getInstalledVersion()
|
||||
val latestRelease = getLatestRelease(updateUrl)
|
||||
if (latestRelease != null && latestRelease.version.isGreaterThan(installedVersion)) {
|
||||
Timber.v("Update available $installedVersion => ${latestRelease.version}")
|
||||
pref.edit {
|
||||
putLong(context.getString(R.string.pref_key_update_last_check), now.time)
|
||||
}
|
||||
if (lastUpdateCheckThreshold >= timeSince) {
|
||||
Timber.i(
|
||||
"Skipping update notification, threshold is $lastUpdateCheckThreshold",
|
||||
)
|
||||
} else {
|
||||
showToast(
|
||||
context,
|
||||
"Update available: $installedVersion => ${latestRelease.version}!",
|
||||
Toast.LENGTH_LONG,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No update available for $installedVersion")
|
||||
if (showNegativeToast) {
|
||||
showToast(
|
||||
context,
|
||||
"No updates available, $installedVersion is the latest!",
|
||||
Toast.LENGTH_LONG,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstalledVersion(): Version {
|
||||
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
return Version.Companion.fromString(pkgInfo.versionName!!)
|
||||
}
|
||||
|
||||
suspend fun getLatestRelease(updateUrl: String): Release? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val preferredAsset =
|
||||
if (PreferenceManager
|
||||
.getDefaultSharedPreferences(context)
|
||||
.getBoolean("updatePreferRelease", true)
|
||||
) {
|
||||
RELEASE_ASSET_NAME
|
||||
} else {
|
||||
DEBUG_ASSET_NAME
|
||||
}
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(updateUrl)
|
||||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use {
|
||||
if (it.isSuccessful && it.body != null) {
|
||||
val result = Json.parseToJsonElement(it.body!!.string())
|
||||
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
val version = Version.Companion.tryFromString(name)
|
||||
val publishedAt =
|
||||
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
|
||||
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
|
||||
val downloadUrl =
|
||||
result.jsonObject["assets"]
|
||||
?.jsonArray
|
||||
?.firstOrNull { asset ->
|
||||
val assetName =
|
||||
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
assetName == ASSET_NAME || assetName == preferredAsset
|
||||
}?.jsonObject
|
||||
?.get("browser_download_url")
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
if (version != null) {
|
||||
val notes =
|
||||
if (body.isNotNullOrBlank()) {
|
||||
NOTE_REGEX
|
||||
.findAll(body)
|
||||
.map { m ->
|
||||
m.groupValues[1]
|
||||
}.toList()
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
return@use Release(version, downloadUrl, publishedAt, body, notes)
|
||||
} else {
|
||||
Timber.w("Update version parsing failed. name=$name")
|
||||
}
|
||||
} else {
|
||||
Timber.w("Update check failed: ${it.message}")
|
||||
}
|
||||
return@use null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun installRelease(
|
||||
release: Release,
|
||||
callback: DownloadCallback,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
cleanup()
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(release.downloadUrl!!)
|
||||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use {
|
||||
if (it.isSuccessful && it.body != null) {
|
||||
Timber.v("Request successful for ${release.downloadUrl}")
|
||||
withContext(Dispatchers.Main) {
|
||||
callback.contentLength(it.headersContentLength())
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val contentValues =
|
||||
ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
|
||||
put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
)
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
val uri =
|
||||
resolver.insert(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
contentValues,
|
||||
)
|
||||
if (uri != null) {
|
||||
it.body!!.byteStream().use { input ->
|
||||
resolver.openOutputStream(uri).use { output ->
|
||||
copyTo(input, output!!, callback = callback)
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.data = uri
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
Timber.e("Resolver URI is null, trying fallback")
|
||||
// showToast(context, "Unable to download the apk")
|
||||
val targetFile = fallbackDownload(it, callback)
|
||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.data =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
context.packageName + ".provider",
|
||||
targetFile,
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
} else {
|
||||
val targetFile = fallbackDownload(it, callback)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.data =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
context.packageName + ".provider",
|
||||
targetFile,
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.setDataAndType(Uri.fromFile(targetFile), APK_MIME_TYPE)
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
|
||||
showToast(context, "Error downloading the apk: ${it.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fallbackDownload(
|
||||
response: Response,
|
||||
callback: DownloadCallback,
|
||||
): File {
|
||||
val downloadDir =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
downloadDir.mkdirs()
|
||||
val targetFile = File(downloadDir, ASSET_NAME)
|
||||
targetFile.outputStream().use { output ->
|
||||
response.body!!.byteStream().use { input ->
|
||||
copyTo(input, output, callback = callback)
|
||||
}
|
||||
}
|
||||
return targetFile
|
||||
}
|
||||
|
||||
fun hasPermissions(): Boolean =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Delete previously downloaded APKs
|
||||
*/
|
||||
fun cleanup() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
context.contentResolver
|
||||
.query(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(
|
||||
MediaStore.MediaColumns._ID,
|
||||
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
||||
),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
|
||||
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getString(0)
|
||||
val displayName = cursor.getString(1)
|
||||
Timber.v("id=$id, displayName=$displayName")
|
||||
}
|
||||
}
|
||||
val deletedRows =
|
||||
context.contentResolver.delete(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
|
||||
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
|
||||
)
|
||||
Timber.i("Deleted $deletedRows rows")
|
||||
} else {
|
||||
val downloadDir =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val targetFile = File(downloadDir, ASSET_NAME)
|
||||
if (targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception during cleanup")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Release(
|
||||
val version: Version,
|
||||
val downloadUrl: String?,
|
||||
val publishedAt: String?,
|
||||
val body: String?,
|
||||
val notes: List<String>,
|
||||
)
|
||||
|
||||
interface DownloadCallback {
|
||||
fun contentLength(contentLength: Long)
|
||||
|
||||
fun bytesDownloaded(bytes: Long)
|
||||
}
|
||||
|
||||
suspend fun copyTo(
|
||||
input: InputStream,
|
||||
out: OutputStream,
|
||||
bufferSize: Int = 16 * 1024,
|
||||
callback: DownloadCallback,
|
||||
): Long {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var bytes = input.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
withContext(Dispatchers.Main) {
|
||||
callback.bytesDownloaded(bytesCopied)
|
||||
}
|
||||
bytes = input.read(buffer)
|
||||
}
|
||||
return bytesCopied
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
package com.github.damontecres.wholphin.services.hilt
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManagerImpl
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class AuthOkHttpClient
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class StandardOkHttpClient
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoCoroutineScope
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun clientInfo(
|
||||
@ApplicationContext context: Context,
|
||||
): ClientInfo =
|
||||
ClientInfo(
|
||||
name = context.getString(R.string.app_name),
|
||||
version = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun deviceInfo(
|
||||
@ApplicationContext context: Context,
|
||||
): DeviceInfo =
|
||||
DeviceInfo(
|
||||
id = @SuppressLint("HardwareIds") Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
Settings.Secure.ANDROID_ID,
|
||||
),
|
||||
name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME),
|
||||
)
|
||||
|
||||
@StandardOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpClient() =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.apply {
|
||||
// TODO user agent, timeouts, logging, etc
|
||||
}.build()
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
fun authOkHttpClient(
|
||||
serverRepository: ServerRepository,
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
clientInfo: ClientInfo,
|
||||
deviceInfo: DeviceInfo,
|
||||
) = okHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor {
|
||||
val request = it.request()
|
||||
val newRequest =
|
||||
serverRepository.currentUser.value?.accessToken?.let { token ->
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader(
|
||||
"Authorization",
|
||||
AuthorizationHeaderBuilder.buildHeader(
|
||||
clientName = clientInfo.name,
|
||||
clientVersion = clientInfo.version,
|
||||
deviceId = deviceInfo.id,
|
||||
deviceName = deviceInfo.name,
|
||||
accessToken = token,
|
||||
),
|
||||
).build()
|
||||
}
|
||||
it.proceed(newRequest ?: request)
|
||||
}.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpFactory(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = OkHttpFactory(okHttpClient)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun jellyfin(
|
||||
okHttpFactory: OkHttpFactory,
|
||||
@ApplicationContext context: Context,
|
||||
clientInfo: ClientInfo,
|
||||
deviceInfo: DeviceInfo,
|
||||
): Jellyfin =
|
||||
createJellyfin {
|
||||
this.context = context
|
||||
this.clientInfo = clientInfo
|
||||
this.deviceInfo = deviceInfo
|
||||
apiClientFactory = okHttpFactory
|
||||
socketConnectionFactory = okHttpFactory
|
||||
minimumServerVersion = Jellyfin.minimumVersion
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
|
||||
|
||||
/**
|
||||
* Implementation of [RememberTabManager] which remembers by server, user, & item
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun rememberTabManager(
|
||||
serverRepository: ServerRepository,
|
||||
appPreference: DataStore<AppPreferences>,
|
||||
@IoCoroutineScope scope: CoroutineScope,
|
||||
) = object : RememberTabManager {
|
||||
fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId"
|
||||
|
||||
override fun getRememberedTab(
|
||||
preferences: UserPreferences,
|
||||
itemId: String,
|
||||
defaultTab: Int,
|
||||
): Int {
|
||||
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
|
||||
return preferences.appPreferences.interfacePreferences
|
||||
.getRememberedTabsOrDefault(key(itemId), defaultTab)
|
||||
} else {
|
||||
return defaultTab
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveRememberedTab(
|
||||
preferences: UserPreferences,
|
||||
itemId: String,
|
||||
tabIndex: Int,
|
||||
) {
|
||||
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
appPreference.updateData {
|
||||
preferences.appPreferences.updateInterfacePreferences {
|
||||
putRememberedTabs(key(itemId), tabIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun favoriteWatchManager(api: ApiClient): FavoriteWatchManager = FavoriteWatchManagerImpl(api)
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.github.damontecres.wholphin.services.hilt
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import androidx.room.Room
|
||||
import com.github.damontecres.wholphin.data.AppDatabase
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.Migrations
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun database(
|
||||
@ApplicationContext context: Context,
|
||||
): AppDatabase =
|
||||
Room
|
||||
.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"wholphin",
|
||||
).addMigrations(Migrations.Migrate2to3)
|
||||
// .setQueryCallback({ sqlQuery, args ->
|
||||
// Timber.v("sqlQuery=$sqlQuery, args=$args")
|
||||
// }, Dispatchers.IO.asExecutor())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
userPreferencesSerializer: AppPreferencesSerializer,
|
||||
): DataStore<AppPreferences> =
|
||||
DataStoreFactory.create(
|
||||
serializer = userPreferencesSerializer,
|
||||
produceFile = { context.dataStoreFile("app_preferences.pb") },
|
||||
corruptionHandler =
|
||||
ReplaceFileCorruptionHandler(
|
||||
produceNewData = { AppPreferences.getDefaultInstance() },
|
||||
),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue