Migrate to Jellyfin Kotlin SDK (1.0.0-beta.3) (#333)

This commit is contained in:
Niels van Velzen
2021-04-27 20:57:43 +00:00
committed by GitHub
parent 84ae834c01
commit a29565c887
19 changed files with 547 additions and 555 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ dependencies {
kapt(Dependencies.Room.compiler)
// Network
implementation(Dependencies.Network.apiclient)
implementation(Dependencies.Network.jellyfinSdk)
implementation(Dependencies.Network.okHttp)
implementation(Dependencies.Network.coil)
implementation(Dependencies.Network.exoPlayerHLS)
@@ -3,17 +3,12 @@ package org.jellyfin.mobile
import coil.ImageLoader
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
import org.jellyfin.apiclient.AppInfo
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.android
import org.jellyfin.apiclient.interaction.AndroidDevice
import org.jellyfin.mobile.api.TimberLogger
import org.jellyfin.mobile.controller.ServerController
import org.jellyfin.mobile.controller.ApiController
import org.jellyfin.mobile.fragment.ConnectFragment
import org.jellyfin.mobile.fragment.WebViewFragment
import org.jellyfin.mobile.media.car.LibraryBrowser
import org.jellyfin.mobile.player.PlayerEvent
import org.jellyfin.mobile.player.PlayerFragment
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.viewmodel.MainViewModel
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
@@ -30,29 +25,22 @@ val applicationModule = module {
single { AppPreferences(androidApplication()) }
single { OkHttpClient() }
single { ImageLoader(androidApplication()) }
single {
Jellyfin {
appInfo = AppInfo(Constants.APP_INFO_NAME, Constants.APP_INFO_VERSION)
logger = TimberLogger()
android(androidApplication())
}
}
single {
get<Jellyfin>().createApi(device = AndroidDevice.fromContext(androidApplication()))
}
single { PermissionRequestHelper() }
single { WebappFunctionChannel() }
single { RemoteVolumeProvider(get()) }
single(named(PLAYER_EVENT_CHANNEL)) { Channel<PlayerEvent>() }
// Controllers
single { ServerController(get(), get(), get(), get()) }
single { ApiController(get(), get(), get(), get(), get()) }
// ViewModels
viewModel { MainViewModel(get(), get(), get()) }
viewModel { MainViewModel(get(), get()) }
// Fragments
fragment { ConnectFragment() }
fragment { WebViewFragment() }
fragment { PlayerFragment() }
// Media components
single { LibraryBrowser(get(), get(), get(), get(), get(), get(), get(), get()) }
}
@@ -3,6 +3,7 @@ package org.jellyfin.mobile
import android.app.Application
import android.webkit.WebView
import com.melegy.redscreenofdeath.RedScreenOfDeath
import org.jellyfin.mobile.api.apiModule
import org.jellyfin.mobile.model.databaseModule
import org.jellyfin.mobile.utils.JellyTree
import org.koin.android.ext.koin.androidContext
@@ -10,6 +11,7 @@ import org.koin.androidx.fragment.koin.fragmentFactory
import org.koin.core.context.startKoin
import timber.log.Timber
@Suppress("unused")
class JellyfinApplication : Application() {
override fun onCreate() {
super.onCreate()
@@ -28,7 +30,12 @@ class JellyfinApplication : Application() {
startKoin {
androidContext(this@JellyfinApplication)
fragmentFactory()
modules(applicationModule, databaseModule)
modules(
applicationModule,
apiModule,
databaseModule,
)
}
}
}
@@ -0,0 +1,40 @@
package org.jellyfin.mobile.api
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.KtorClient
import org.jellyfin.sdk.api.operations.*
import org.jellyfin.sdk.discovery.AndroidBroadcastAddressesProvider
import org.jellyfin.sdk.interaction.androidDevice
import org.jellyfin.sdk.model.ClientInfo
import org.koin.android.ext.koin.androidApplication
import org.koin.dsl.binds
import org.koin.dsl.module
val apiModule = module {
// Device info template and client info
single { androidDevice(androidApplication()) }
single { ClientInfo(name = Constants.APP_INFO_NAME, version = Constants.APP_INFO_VERSION) }
// Jellyfin API builder and API client instance
single {
Jellyfin {
discoveryBroadcastAddressesProvider = AndroidBroadcastAddressesProvider(androidApplication())
clientInfo = get()
deviceInfo = get()
}
}
single { get<Jellyfin>().createApi() } binds arrayOf(KtorClient::class, ApiClient::class)
// Add API modules
single { SystemApi(get()) }
single { ImageApi(get()) }
single { PlayStateApi(get()) }
single { ItemsApi(get()) }
single { UserViewsApi(get()) }
single { ArtistsApi(get()) }
single { GenresApi(get()) }
single { PlaylistsApi(get()) }
single { UniversalAudioApi(get()) }
}
@@ -1,22 +0,0 @@
package org.jellyfin.mobile.api
import org.jellyfin.apiclient.logging.ILogger
import timber.log.Timber
class TimberLogger : ILogger {
override fun debug(formatString: String?, vararg paramList: Any?) {
Timber.d(formatString, *paramList)
}
override fun info(formatString: String?, vararg paramList: Any?) {
Timber.i(formatString, *paramList)
}
override fun error(formatString: String?, vararg paramList: Any?) {
Timber.e(formatString, *paramList)
}
override fun error(formatString: String?, exception: Exception?, vararg paramList: Any?) {
Timber.e(exception, formatString, *paramList)
}
}
@@ -9,13 +9,10 @@ import android.net.Uri
import android.webkit.JavascriptInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.jellyfin.apiclient.interaction.AndroidDevice
import org.jellyfin.mobile.R
import org.jellyfin.mobile.fragment.WebViewFragment
import org.jellyfin.mobile.settings.SettingsFragment
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.APP_INFO_NAME
import org.jellyfin.mobile.utils.Constants.APP_INFO_VERSION
import org.jellyfin.mobile.utils.*
import org.jellyfin.mobile.utils.Constants.EXTRA_ALBUM
import org.jellyfin.mobile.utils.Constants.EXTRA_ARTIST
import org.jellyfin.mobile.utils.Constants.EXTRA_CAN_SEEK
@@ -27,19 +24,16 @@ import org.jellyfin.mobile.utils.Constants.EXTRA_ITEM_ID
import org.jellyfin.mobile.utils.Constants.EXTRA_PLAYER_ACTION
import org.jellyfin.mobile.utils.Constants.EXTRA_POSITION
import org.jellyfin.mobile.utils.Constants.EXTRA_TITLE
import org.jellyfin.mobile.utils.addFragment
import org.jellyfin.mobile.utils.disableFullscreen
import org.jellyfin.mobile.utils.enableFullscreen
import org.jellyfin.mobile.utils.requestDownload
import org.jellyfin.mobile.utils.requireMainActivity
import org.jellyfin.mobile.utils.runOnUiThread
import org.jellyfin.mobile.webapp.RemotePlayerService
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.koin.core.KoinComponent
import org.koin.core.get
import org.koin.core.inject
import timber.log.Timber
@@ -51,19 +45,21 @@ class NativeInterface(private val fragment: WebViewFragment) : KoinComponent {
@SuppressLint("HardwareIds")
@JavascriptInterface
fun getDeviceInformation(): String? = try {
val device = AndroidDevice.fromContext(context)
val deviceInfo = get<DeviceInfo>()
val clientInfo = get<ClientInfo>()
JSONObject().apply {
put("deviceId", device.deviceId)
put("deviceId", deviceInfo.id)
// normalize the name by removing special characters
// and making sure it's at least 1 character long
// otherwise the webui will fail to send it to the server
val name = device.deviceName
val name = deviceInfo.name
.replace("[^\\x20-\\x7E]".toRegex(), "")
.trim()
.padStart(1)
put("deviceName", name)
put("appName", APP_INFO_NAME)
put("appVersion", APP_INFO_VERSION)
put("appName", clientInfo.name)
put("appVersion", clientInfo.version)
}.toString()
} catch (e: JSONException) {
null
@@ -0,0 +1,97 @@
package org.jellyfin.mobile.controller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.mobile.AppPreferences
import org.jellyfin.mobile.model.sql.dao.ServerDao
import org.jellyfin.mobile.model.sql.dao.UserDao
import org.jellyfin.mobile.model.sql.entity.ServerEntity
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.KtorClient
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.*
class ApiController(
private val appPreferences: AppPreferences,
private val baseDeviceInfo: DeviceInfo,
private val apiClient: ApiClient,
private val serverDao: ServerDao,
private val userDao: UserDao,
) {
var currentUser: UUID? = null
private set
var currentDeviceId: String = baseDeviceInfo.id
private set
/**
* Migrate from preferences if necessary
*/
@Suppress("DEPRECATION")
suspend fun migrateFromPreferences() {
appPreferences.instanceUrl?.let { url ->
setupServer(url)
appPreferences.instanceUrl = null
}
}
suspend fun setupServer(hostname: String) {
appPreferences.currentServerId = withContext(Dispatchers.IO) {
serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname)
}
apiClient.baseUrl = hostname
}
suspend fun setupUser(serverId: Long, userId: String, accessToken: String) {
appPreferences.currentUserId = withContext(Dispatchers.IO) {
userDao.upsert(serverId, userId, accessToken)
}
configureApiClientUser(userId, accessToken)
}
suspend fun loadSavedServer(): ServerEntity? {
val server = withContext(Dispatchers.IO) {
val serverId = appPreferences.currentServerId ?: return@withContext null
serverDao.getServer(serverId)
}
configureApiClientServer(server)
return server
}
suspend fun loadSavedServerUser() {
val serverUser = withContext(Dispatchers.IO) {
val serverId = appPreferences.currentServerId ?: return@withContext null
val userId = appPreferences.currentUserId ?: return@withContext null
userDao.getServerUser(serverId, userId)
}
configureApiClientServer(serverUser?.server)
if (serverUser?.user?.accessToken != null) {
configureApiClientUser(serverUser.user.userId, serverUser.user.accessToken)
} else {
resetApiClientUser()
}
}
private fun configureApiClientServer(server: ServerEntity?) {
apiClient.baseUrl = server?.hostname
}
private fun configureApiClientUser(userId: String, accessToken: String) {
currentUser = userId.toUUID()
// Append user id to device id to ensure uniqueness across sessions
currentDeviceId = baseDeviceInfo.id + currentUser.toString()
apiClient.deviceInfo = baseDeviceInfo.copy(id = currentDeviceId)
apiClient.accessToken = accessToken
}
private fun resetApiClientUser() {
currentUser = null
currentDeviceId = baseDeviceInfo.id
apiClient.deviceInfo = baseDeviceInfo
apiClient.accessToken = null
}
}
@@ -1,52 +0,0 @@
package org.jellyfin.mobile.controller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.mobile.AppPreferences
import org.jellyfin.mobile.model.sql.dao.ServerDao
import org.jellyfin.mobile.model.sql.dao.UserDao
import org.jellyfin.mobile.model.sql.entity.ServerEntity
import org.jellyfin.mobile.model.sql.entity.ServerUser
class ServerController(
private val appPreferences: AppPreferences,
private val apiClient: ApiClient,
private val serverDao: ServerDao,
private val userDao: UserDao,
) {
/**
* Migrate from preferences if necessary
*/
@Suppress("DEPRECATION")
suspend fun migrateFromPreferences() {
appPreferences.instanceUrl?.let { url ->
setupServer(url)
appPreferences.instanceUrl = null
}
}
suspend fun setupServer(hostname: String) {
appPreferences.currentServerId = withContext(Dispatchers.IO) {
serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname)
}
}
suspend fun setupUser(serverId: Long, userId: String, accessToken: String) {
appPreferences.currentUserId = withContext(Dispatchers.IO) {
userDao.upsert(serverId, userId, accessToken)
}
apiClient.SetAuthenticationInfo(accessToken, userId)
}
suspend fun loadCurrentServer(): ServerEntity? = withContext(Dispatchers.IO) {
val serverId = appPreferences.currentServerId ?: return@withContext null
serverDao.getServer(serverId)
}
suspend fun loadCurrentServerUser(): ServerUser? = withContext(Dispatchers.IO) {
val serverId = appPreferences.currentServerId ?: return@withContext null
val userId = appPreferences.currentUserId ?: return@withContext null
userDao.getServerUser(serverId, userId)
}
}
@@ -23,21 +23,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.discovery.DiscoveryServerInfo
import org.jellyfin.apiclient.discovery.ServerDiscovery
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.model.system.PublicSystemInfo
import org.jellyfin.mobile.R
import org.jellyfin.mobile.controller.ServerController
import org.jellyfin.mobile.controller.ApiController
import org.jellyfin.mobile.databinding.FragmentConnectBinding
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PRODUCT_NAME_SUPPORTED_SINCE
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
import org.jellyfin.mobile.utils.getPublicSystemInfo
import org.jellyfin.mobile.viewmodel.MainViewModel
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.discovery.LocalServerDiscovery
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.koin.android.ext.android.inject
import org.koin.android.viewmodel.ext.android.sharedViewModel
import timber.log.Timber
@@ -45,8 +40,7 @@ import timber.log.Timber
class ConnectFragment : Fragment() {
private val mainViewModel: MainViewModel by sharedViewModel()
private val jellyfin: Jellyfin by inject()
private val apiClient: ApiClient by inject()
private val serverController: ServerController by inject()
private val apiController: ApiController by inject()
// UI
private var _connectServerBinding: FragmentConnectBinding? = null
@@ -57,9 +51,13 @@ class ConnectFragment : Fragment() {
private val connectButton: Button get() = connectServerBinding.connectButton
private val chooseServerButton: Button get() = connectServerBinding.chooseServerButton
private val serverList = ArrayList<DiscoveryServerInfo>(ServerDiscovery.DISCOVERY_MAX_SERVERS)
private val serverList = ArrayList<ServerDiscoveryInfo>(LocalServerDiscovery.DISCOVERY_MAX_SERVERS)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_connectServerBinding = FragmentConnectBinding.inflate(inflater, container, false)
return serverSetupLayout.apply { applyWindowInsetsAsMargins() }
}
@@ -117,7 +115,7 @@ class ConnectFragment : Fragment() {
val httpUrl = checkServerUrlAndConnection(enteredUrl)
if (httpUrl != null) {
clearServerList()
serverController.setupServer(httpUrl.toString())
apiController.setupServer(httpUrl)
mainViewModel.refreshServer()
}
hostInput.isEnabled = true
@@ -127,10 +125,13 @@ class ConnectFragment : Fragment() {
private fun discoverServers() {
lifecycleScope.launch {
jellyfin.discovery.discover().flowOn(Dispatchers.IO).collect { serverInfo ->
serverList.add(serverInfo)
chooseServerButton.isVisible = true
}
jellyfin.discovery
.discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS)
.flowOn(Dispatchers.IO)
.collect { serverInfo ->
serverList.add(serverInfo)
chooseServerButton.isVisible = true
}
}
}
@@ -138,7 +139,7 @@ class ConnectFragment : Fragment() {
AlertDialog.Builder(activity).apply {
setTitle(R.string.available_servers_title)
setItems(serverList.map { "${it.name}\n${it.address}" }.toTypedArray()) { _, index ->
connect(serverList[index].address)
connect(serverList[index].address!!)
}
}.show()
}
@@ -157,61 +158,52 @@ class ConnectFragment : Fragment() {
connectionErrorText.isVisible = false
}
private suspend fun checkServerUrlAndConnection(enteredUrl: String): HttpUrl? {
private suspend fun checkServerUrlAndConnection(enteredUrl: String): String? {
Timber.i("checkServerUrlAndConnection $enteredUrl")
val normalizedUrl = enteredUrl.run {
if (lastOrNull() == '/') this
else "$this/"
}
val urls = jellyfin.discovery.addressCandidates(normalizedUrl)
Timber.i("Address candidates are $urls")
val candidates = jellyfin.discovery.getAddressCandidates(enteredUrl)
Timber.i("Address candidates are $candidates")
var httpUrl: HttpUrl? = null
var serverInfo: PublicSystemInfo? = null
loop@ for (url in urls) {
httpUrl = url.toHttpUrlOrNull()
val recommendedServer = jellyfin.discovery.getRecommendedServer(candidates, false)
if (httpUrl == null) {
showConnectionError(R.string.connection_error_invalid_format)
return null // Format is invalid, don't try any other variants
}
// No server found that replied
if (recommendedServer == null) {
Timber.i("No recommended server found")
// Set API client address
apiClient.ChangeServerLocation(httpUrl.toString().trimEnd('/'))
serverInfo = apiClient.getPublicSystemInfo()
if (serverInfo != null)
break@loop
}
if (httpUrl == null || serverInfo == null) {
Timber.w("Failed to find server info, url was $httpUrl")
showConnectionError()
// TODO add candidates to error
showConnectionError(R.string.connection_error_cannot_connect)
return null
}
val version = serverInfo.version
.split('.')
.mapNotNull(String::toIntOrNull)
val systemInfo = recommendedServer.systemInfo
val isValidInstance = when {
version.size != 3 -> false
// Major version is invalid
version[0] != PRODUCT_NAME_SUPPORTED_SINCE.first -> false
// Minor version is too old
version[1] < PRODUCT_NAME_SUPPORTED_SINCE.second -> false
else -> true // FIXME: check ProductName once API client supports it
// System Info is missing, shouldn't be able to happen but check just in case
if (systemInfo == null) {
Timber.w("Recommended server did not contain system information!")
showConnectionError(R.string.connection_error_invalid_version)
return null
}
Timber.i("Server at $httpUrl with version ${serverInfo.version} valid: $isValidInstance")
val version = systemInfo.version?.let { ServerVersion.fromString(it) }
val isValidInstance = when {
// Incorrect format
version == null -> false
// Version too old
version < Jellyfin.recommendedVersion -> false
// Incorrect product name
systemInfo.productName != "Jellyfin Server" -> false
else -> true
}
Timber.i("Recommended server at ${recommendedServer.address} with version $version valid: $isValidInstance")
if (!isValidInstance) {
showConnectionError(R.string.connection_error_invalid_version)
return null
}
return httpUrl
return recommendedServer.address
}
}
@@ -23,14 +23,13 @@ import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.launch
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.bridge.ExternalPlayer
import org.jellyfin.mobile.bridge.NativeInterface
import org.jellyfin.mobile.bridge.NativePlayer
import org.jellyfin.mobile.bridge.NativePlayerHost
import org.jellyfin.mobile.controller.ServerController
import org.jellyfin.mobile.controller.ApiController
import org.jellyfin.mobile.databinding.FragmentWebviewBinding
import org.jellyfin.mobile.model.sql.entity.ServerEntity
import org.jellyfin.mobile.player.PlayerFragment
@@ -48,8 +47,7 @@ import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class WebViewFragment : Fragment(), NativePlayerHost {
val apiClient: ApiClient by inject()
private val serverController: ServerController by inject()
private val apiController: ApiController by inject()
private val webappFunctionChannel: WebappFunctionChannel by inject()
private lateinit var externalPlayer: ExternalPlayer
@@ -195,8 +193,8 @@ class WebViewFragment : Fragment(), NativePlayerHost {
val storedServer = credentials.getJSONArray("Servers").getJSONObject(0)
val user = storedServer.getString("UserId")
val token = storedServer.getString("AccessToken")
serverController.setupUser(server.id, user, token)
initLocale()
apiController.setupUser(server.id, user, token)
initLocale(user)
}
null
}
@@ -27,14 +27,12 @@ import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.*
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.mobile.R
import org.jellyfin.mobile.cast.CastPlayerProvider
import org.jellyfin.mobile.cast.ICastPlayerProvider
import org.jellyfin.mobile.controller.ServerController
import org.jellyfin.mobile.controller.ApiController
import org.jellyfin.mobile.media.car.LibraryBrowser
import org.jellyfin.mobile.media.car.LibraryPage
import org.jellyfin.mobile.model.sql.entity.ServerUser
import org.jellyfin.mobile.utils.toast
import org.koin.android.ext.android.inject
import timber.log.Timber
@@ -42,16 +40,13 @@ import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem
class MediaService : MediaBrowserServiceCompat() {
private val apiClient: ApiClient by inject()
private val serverController: ServerController by inject()
private val apiController: ApiController by inject()
private val libraryBrowser: LibraryBrowser by inject()
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
private val serviceScope = MainScope()
private var isForegroundService = false
private lateinit var loadingJob: Job
private var serverUser: ServerUser? = null
private val libraryBrowser = LibraryBrowser(this, apiClient)
// The current player will either be an ExoPlayer (for local playback) or a CastPlayer (for
// remote playback through a Cast device).
@@ -91,11 +86,7 @@ class MediaService : MediaBrowserServiceCompat() {
super.onCreate()
loadingJob = serviceScope.launch {
serverUser = serverController.loadCurrentServerUser()
serverUser?.let { serverUser ->
apiClient.ChangeServerLocation(serverUser.server.hostname.trimEnd('/'))
apiClient.SetAuthenticationInfo(serverUser.user.accessToken, serverUser.user.userId)
}
apiController.loadSavedServerUser()
}
val sessionActivityPendingIntent = packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
@@ -148,7 +139,7 @@ class MediaService : MediaBrowserServiceCompat() {
}
// Cancel coroutines when the service is going away
serviceJob.cancel()
serviceScope.cancel()
// Free ExoPlayer resources
exoPlayer.removeListener(playerListener)
@@ -168,10 +159,16 @@ class MediaService : MediaBrowserServiceCompat() {
result.detach()
serviceScope.launch(Dispatchers.IO) {
// Ensure credentials were loaded already
// Ensure that server and credentials are available
loadingJob.join()
val library = if (serverUser != null) libraryBrowser.loadLibrary(parentId) else null
result.sendResult(library ?: emptyList())
val items = try {
if (apiController.currentUser != null) libraryBrowser.loadLibrary(parentId) else null
} catch (t: Throwable) {
Timber.e(t)
null
}
result.sendResult(items ?: emptyList())
}
}
@@ -269,7 +266,12 @@ class MediaService : MediaBrowserServiceCompat() {
override fun onPrepare(playWhenReady: Boolean) {
serviceScope.launch {
val recents = libraryBrowser.getDefaultRecents()
val recents = try {
libraryBrowser.getDefaultRecents()
} catch (t: Throwable) {
Timber.e(t)
null
}
if (recents != null) {
preparePlaylist(recents, 0, playWhenReady)
} else setPlaybackError()
@@ -278,7 +280,7 @@ class MediaService : MediaBrowserServiceCompat() {
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
if (mediaId == LibraryPage.RESUME) {
// Recents requested
// Requested recents
onPrepare(playWhenReady)
} else serviceScope.launch {
val result = libraryBrowser.buildPlayQueue(mediaId)
@@ -294,7 +296,12 @@ class MediaService : MediaBrowserServiceCompat() {
// No search provided, fallback to recents
onPrepare(playWhenReady)
} else serviceScope.launch {
val results = libraryBrowser.getSearchResults(query, extras)
val results = try {
libraryBrowser.getSearchResults(query, extras)
} catch (t: Throwable) {
Timber.e(t)
null
}
if (results != null) {
preparePlaylist(results, 0, playWhenReady)
} else setPlaybackError()
@@ -11,21 +11,8 @@ import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.model.dto.BaseItemDto
import org.jellyfin.apiclient.model.dto.BaseItemType
import org.jellyfin.apiclient.model.dto.ImageOptions
import org.jellyfin.apiclient.model.entities.CollectionType
import org.jellyfin.apiclient.model.entities.ImageType
import org.jellyfin.apiclient.model.entities.SortOrder
import org.jellyfin.apiclient.model.playlists.PlaylistItemQuery
import org.jellyfin.apiclient.model.querying.ArtistsQuery
import org.jellyfin.apiclient.model.querying.ItemFilter
import org.jellyfin.apiclient.model.querying.ItemQuery
import org.jellyfin.apiclient.model.querying.ItemSortBy
import org.jellyfin.apiclient.model.querying.ItemsByNameQuery
import org.jellyfin.apiclient.model.querying.ItemsResult
import org.jellyfin.mobile.R
import org.jellyfin.mobile.controller.ApiController
import org.jellyfin.mobile.media.MediaService
import org.jellyfin.mobile.media.mediaId
import org.jellyfin.mobile.media.setAlbum
@@ -37,18 +24,31 @@ import org.jellyfin.mobile.media.setMediaId
import org.jellyfin.mobile.media.setMediaUri
import org.jellyfin.mobile.media.setTitle
import org.jellyfin.mobile.media.setTrackNumber
import org.jellyfin.mobile.utils.getArtists
import org.jellyfin.mobile.utils.getGenres
import org.jellyfin.mobile.utils.getItems
import org.jellyfin.mobile.utils.getPlaylistItems
import org.jellyfin.mobile.utils.getUserViews
import org.jellyfin.sdk.api.operations.GenresApi
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.PlaylistsApi
import org.jellyfin.sdk.api.operations.UniversalAudioApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.net.URLEncoder
import java.util.*
class LibraryBrowser(
private val context: Context,
private val apiClient: ApiClient
private val apiController: ApiController,
private val itemsApi: ItemsApi,
private val userViewsApi: UserViewsApi,
private val genresApi: GenresApi,
private val playlistsApi: PlaylistsApi,
private val imageApi: ImageApi,
private val universalAudioApi: UniversalAudioApi,
) {
fun getRoot(hints: Bundle?): MediaBrowserServiceCompat.BrowserRoot {
/**
@@ -76,8 +76,8 @@ class LibraryBrowser(
return null
val type = split[0]
val libraryId = split.getOrNull(1)
val itemId = split.getOrNull(2)
val libraryId = split.getOrNull(1)?.toUUIDOrNull()
val itemId = split.getOrNull(2)?.toUUIDOrNull()
return when {
libraryId != null -> {
@@ -112,13 +112,18 @@ class LibraryBrowser(
if (split.size != 3)
return null
val (type, collectionId, _) = split
val type = split[0]
val collectionId = split[1].toUUID()
val playQueue = when (type) {
LibraryPage.RECENTS -> getRecents(collectionId)
LibraryPage.ALBUM -> getAlbum(collectionId)
LibraryPage.PLAYLIST -> getPlaylist(collectionId)
else -> return null
val playQueue = try {
when (type) {
LibraryPage.RECENTS -> getRecents(collectionId)
LibraryPage.ALBUM -> getAlbum(collectionId)
LibraryPage.PLAYLIST -> getPlaylist(collectionId)
else -> null
}
} catch (t: Throwable) {
null
} ?: return null
val playIndex = playQueue.indexOfFirst { item ->
@@ -134,7 +139,7 @@ class LibraryBrowser(
// Search for specific album
extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.let { albumQuery ->
Timber.d("Searching for album $albumQuery")
searchItems(albumQuery, BaseItemType.MusicAlbum)
searchItems(albumQuery, "MusicAlbum")
}?.let { albumId ->
getAlbum(albumId)
}?.let { albumContent ->
@@ -146,83 +151,86 @@ class LibraryBrowser(
// Search for specific artist
extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.let { artistQuery ->
Timber.d("Searching for artist $artistQuery")
searchItems(artistQuery, BaseItemType.MusicArtist)
searchItems(artistQuery, "MusicArtist")
}?.let { artistId ->
val query = ItemQuery().apply {
userId = apiClient.currentUserId
artistIds = arrayOf(artistId)
includeItemTypes = arrayOf(BaseItemType.Audio.name)
sortBy = arrayOf(ItemSortBy.Random)
recursive = true
imageTypeLimit = 1
enableImageTypes = arrayOf(ImageType.Primary)
enableTotalRecordCount = false
limit = 100
}
apiClient.getItems(query)?.extractItems()
itemsApi.getItems(
userId = apiController.currentUser,
artistIds = listOf(artistId),
includeItemTypes = listOf("Audio"),
sortBy = listOf("Random"),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 100,
).content.extractItems()
}?.let { artistTracks ->
Timber.d("Got result, starting playback")
return artistTracks
}
}
}
// Fallback to generic search
Timber.d("Searching for '$searchQuery'")
val query = ItemQuery().apply {
userId = apiClient.currentUserId
searchTerm = searchQuery
includeItemTypes = arrayOf(BaseItemType.Audio.name)
recursive = true
imageTypeLimit = 1
enableImageTypes = arrayOf(ImageType.Primary)
enableTotalRecordCount = false
limit = 100
}
return apiClient.getItems(query)?.extractItems()
val result by itemsApi.getItems(
userId = apiController.currentUser,
searchTerm = searchQuery,
includeItemTypes = listOf("Audio"),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 100,
)
return result.extractItems()
}
/**
* Find a single specific item for the given [searchQuery] with a specific [type]
*/
private suspend fun searchItems(searchQuery: String, type: BaseItemType): String? {
val query = ItemQuery().apply {
userId = apiClient.currentUserId
searchTerm = searchQuery
includeItemTypes = arrayOf(type.name)
recursive = true
enableImages = false
enableTotalRecordCount = false
limit = 1
}
val searchResults = apiClient.getItems(query) ?: return null
return searchResults.items.firstOrNull()?.id
private suspend fun searchItems(searchQuery: String, type: String): UUID? {
val result by itemsApi.getItems(
userId = apiController.currentUser,
searchTerm = searchQuery,
includeItemTypes = listOf(type),
recursive = true,
enableImages = false,
enableTotalRecordCount = false,
limit = 1,
)
return result.items?.firstOrNull()?.id
}
suspend fun getDefaultRecents(): List<MediaMetadataCompat>? =
getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary -> getRecents(defaultLibrary) }
getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary -> getRecents(defaultLibrary.toUUID()) }
private suspend fun getLibraries(): List<MediaBrowserCompat.MediaItem> {
return apiClient.getUserViews(apiClient.currentUserId)?.run {
items.asSequence()
.filter { item -> item.collectionType == CollectionType.Music }
.map { item ->
val itemImageUrl = apiClient.GetImageUrl(item, ImageOptions().apply {
imageType = ImageType.Primary
maxWidth = 1080
quality = 90
})
val description = MediaDescriptionCompat.Builder().apply {
setMediaId(LibraryPage.LIBRARY + "|" + item.id)
setTitle(item.name)
setIconUri(Uri.parse(itemImageUrl))
}.build()
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
}
.toList()
} ?: emptyList()
val userViews by userViewsApi.getUserViews(
userId = apiController.currentUser ?: return emptyList()
)
return userViews.items.orEmpty()
.filter { item -> item.collectionType.equals("music", ignoreCase = true) }
.map { item ->
val itemImageUrl = imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.PRIMARY
)
val description = MediaDescriptionCompat.Builder().apply {
setMediaId(LibraryPage.LIBRARY + "|" + item.id)
setTitle(item.name)
setIconUri(Uri.parse(itemImageUrl))
}.build()
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
}
.toList()
}
private fun getLibraryViews(context: Context, libraryId: String): List<MediaBrowserCompat.MediaItem> {
private fun getLibraryViews(context: Context, libraryId: UUID): List<MediaBrowserCompat.MediaItem> {
val libraryViews = arrayOf(
LibraryPage.RECENTS to R.string.media_service_car_section_recents,
LibraryPage.ALBUMS to R.string.media_service_car_section_albums,
@@ -246,109 +254,110 @@ class LibraryBrowser(
}
}
private suspend fun getRecents(libraryId: String): List<MediaMetadataCompat>? {
val query = ItemQuery().apply {
userId = apiClient.currentUserId
parentId = libraryId
includeItemTypes = arrayOf(BaseItemType.Audio.name)
filters = arrayOf(ItemFilter.IsPlayed)
sortBy = arrayOf(ItemSortBy.DatePlayed)
sortOrder = SortOrder.Descending
recursive = true
imageTypeLimit = 1
enableImageTypes = arrayOf(ImageType.Primary)
enableTotalRecordCount = false
limit = 100
}
return apiClient.getItems(query)?.extractItems("${LibraryPage.RECENTS}|$libraryId")
private suspend fun getRecents(libraryId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItems(
userId = apiController.currentUser,
parentId = libraryId,
includeItemTypes = listOf("Audio"),
filters = listOf(ItemFilter.IS_PLAYED),
sortBy = listOf("DatePlayed"),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 100,
)
return result.extractItems("${LibraryPage.RECENTS}|$libraryId")
}
private suspend fun getAlbums(
libraryId: String,
filterArtist: String? = null,
filterGenre: String? = null
libraryId: UUID,
filterArtist: UUID? = null,
filterGenre: UUID? = null
): List<MediaBrowserCompat.MediaItem>? {
val query = ItemQuery().apply {
userId = apiClient.currentUserId
parentId = libraryId
when {
filterArtist != null -> artistIds = arrayOf(filterArtist)
filterGenre != null -> genreIds = arrayOf(filterGenre)
}
includeItemTypes = arrayOf(BaseItemType.MusicAlbum.name)
sortBy = arrayOf(ItemSortBy.DatePlayed)
sortOrder = SortOrder.Descending
recursive = true
imageTypeLimit = 1
enableImageTypes = arrayOf(ImageType.Primary)
limit = 100
}
return apiClient.getItems(query)?.extractItems()?.browsable()
val result by itemsApi.getItems(
userId = apiController.currentUser,
parentId = libraryId,
artistIds = filterArtist?.let(::listOf),
genreIds = filterGenre?.let(::listOf),
includeItemTypes = listOf("MusicAlbum"),
sortBy = listOf("DatePlayed"),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 100,
)
return result.extractItems()?.browsable()
}
private suspend fun getArtists(libraryId: String): List<MediaBrowserCompat.MediaItem>? {
val query = ArtistsQuery().apply {
userId = apiClient.currentUserId
parentId = libraryId
sortBy = arrayOf(ItemSortBy.SortName)
sortOrder = SortOrder.Ascending
recursive = true
imageTypeLimit = 1
enableImageTypes = arrayOf(ImageType.Primary)
limit = 100
}
return apiClient.getArtists(query)?.extractItems(libraryId)?.browsable()
private suspend fun getArtists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
userId = apiController.currentUser,
parentId = libraryId,
includeItemTypes = listOf("MusicArtist"),
sortBy = listOf("SortName"),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 100,
)
return result.extractItems(libraryId.toString())?.browsable()
}
private suspend fun getGenres(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by genresApi.getGenres(
userId = apiController.currentUser,
parentId = libraryId,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 100,
)
private suspend fun getGenres(libraryId: String): List<MediaBrowserCompat.MediaItem>? {
val query = ItemsByNameQuery().apply {
userId = apiClient.currentUserId
parentId = libraryId
sortBy = arrayOf(ItemSortBy.SortName)
sortOrder = SortOrder.Ascending
recursive = true
imageTypeLimit = 1
enableImageTypes = arrayOf(ImageType.Primary)
limit = 100
}
return apiClient.getGenres(query)?.extractItems(libraryId)?.browsable()
return result.extractItems(libraryId.toString())?.browsable()
}
private suspend fun getPlaylists(libraryId: String): List<MediaBrowserCompat.MediaItem>? {
val query = ItemQuery().apply {
userId = apiClient.currentUserId
parentId = libraryId
includeItemTypes = arrayOf(BaseItemType.Playlist.name)
sortBy = arrayOf(ItemSortBy.DatePlayed)
sortOrder = SortOrder.Descending
recursive = true
imageTypeLimit = 1
enableImageTypes = arrayOf(ImageType.Primary)
limit = 100
}
return apiClient.getItems(query)?.extractItems()?.browsable()
private suspend fun getPlaylists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
userId = apiController.currentUser,
parentId = libraryId,
includeItemTypes = listOf("Playlist"),
sortBy = listOf("DatePlayed"),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 100,
)
return result.extractItems()?.browsable()
}
private suspend fun getAlbum(albumId: String): List<MediaMetadataCompat>? {
val query = ItemQuery().apply {
parentId = albumId
userId = apiClient.currentUserId
sortBy = arrayOf(ItemSortBy.SortName)
}
return apiClient.getItems(query)?.extractItems("${LibraryPage.ALBUM}|$albumId")
private suspend fun getAlbum(albumId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItems(
userId = apiController.currentUser,
parentId = albumId,
sortBy = listOf("SortName"),
)
return result.extractItems("${LibraryPage.ALBUM}|$albumId")
}
private suspend fun getPlaylist(playlistId: String): List<MediaMetadataCompat>? {
val query = PlaylistItemQuery().apply {
userId = apiClient.currentUserId
id = playlistId
}
return apiClient.getPlaylistItems(query)?.extractItems("${LibraryPage.PLAYLIST}|$playlistId")
private suspend fun getPlaylist(playlistId: UUID): List<MediaMetadataCompat>? {
val result by playlistsApi.getPlaylistItems(
playlistId = playlistId,
userId = apiController.currentUser ?: return null
)
return result.extractItems("${LibraryPage.PLAYLIST}|$playlistId")
}
private fun ItemsResult.extractItems(libraryId: String? = null): List<MediaMetadataCompat> =
items.map { item -> buildMediaMetadata(item, libraryId) }.toList()
private fun BaseItemDtoQueryResult.extractItems(libraryId: String? = null): List<MediaMetadataCompat>? =
items?.map { item -> buildMediaMetadata(item, libraryId) }?.toList()
private fun buildMediaMetadata(item: BaseItemDto, libraryId: String?): MediaMetadataCompat {
val builder = MediaMetadataCompat.Builder()
@@ -356,33 +365,47 @@ class LibraryBrowser(
builder.setTitle(item.name ?: context.getString(R.string.media_service_car_item_no_title))
val isAlbum = item.albumId != null
val imageOptions = ImageOptions().apply {
imageType = ImageType.Primary
maxWidth = 1080
quality = 90
tag = if (isAlbum) item.albumPrimaryImageTag else item.imageTags[ImageType.Primary]
}
val primaryImageUrl = when {
item.hasPrimaryImage -> apiClient.GetImageUrl(item, imageOptions)
isAlbum -> apiClient.GetImageUrl(item.albumId, imageOptions)
val itemId = when {
item.imageTags.containsKey(ImageType.PRIMARY) -> item.id
isAlbum -> item.albumId
else -> null
}
val primaryImageUrl = itemId?.let {
imageApi.getItemImageUrl(
itemId = itemId,
imageType = ImageType.PRIMARY,
tag = if (isAlbum) item.albumPrimaryImageTag else item.imageTags[ImageType.PRIMARY],
)
}
if (item.type.equals("audio", ignoreCase = true)) {
val uri = universalAudioApi.getUniversalAudioStreamUrl(
itemId = item.id,
userId = apiController.currentUser,
deviceId = apiController.currentDeviceId,
maxStreamingBitrate = 140000000,
container = listOf(
"opus",
"mp3|mp3",
"aac",
"m4a",
"m4b|aac",
"flac",
"webma",
"webm",
"wav",
"ogg"
),
transcodingProtocol = "hls",
transcodingContainer = "ts",
audioCodec = "aac",
enableRemoteMedia = true,
includeCredentials = true,
)
if (item.baseItemType == BaseItemType.Audio) {
val uri = "${apiClient.serverAddress}/Audio/${item.id}/universal?" +
"UserId=${apiClient.currentUserId}&" +
"DeviceId=${URLEncoder.encode(apiClient.deviceId, Charsets.UTF_8.name())}&" +
"MaxStreamingBitrate=140000000&" +
"Container=opus,mp3|mp3,aac,m4a,m4b|aac,flac,webma,webm,wav,ogg&" +
"TranscodingContainer=ts&" +
"TranscodingProtocol=hls&" +
"AudioCodec=aac&" +
"api_key=${apiClient.accessToken}&" +
"PlaySessionId=${UUID.randomUUID()}&" +
"EnableRemoteMedia=true"
builder.setMediaUri(uri)
item.album?.let(builder::setAlbum)
builder.setArtist(item.artists.joinToString())
item.artists?.let { builder.setArtist(it.joinToString()) }
item.albumArtist?.let(builder::setAlbumArtist)
primaryImageUrl?.let(builder::setAlbumArtUri)
item.indexNumber?.toLong()?.let(builder::setTrackNumber)
@@ -393,13 +416,13 @@ class LibraryBrowser(
return builder.build()
}
private fun buildMediaId(item: BaseItemDto, extra: String?) = when (item.baseItemType) {
BaseItemType.MusicArtist -> "${LibraryPage.ARTIST_ALBUMS}|$extra|${item.id}"
BaseItemType.MusicGenre -> "${LibraryPage.GENRE_ALBUMS}|$extra|${item.id}"
BaseItemType.MusicAlbum -> "${LibraryPage.ALBUM}|${item.id}"
BaseItemType.Playlist -> "${LibraryPage.PLAYLIST}|${item.id}"
BaseItemType.Audio -> "$extra|${item.id}"
else -> throw IllegalArgumentException("Unhandled item type ${item.baseItemType.name}")
private fun buildMediaId(item: BaseItemDto, extra: String?) = when (item.type) {
"MusicArtist" -> "${LibraryPage.ARTIST_ALBUMS}|$extra|${item.id}"
"MusicGenre" -> "${LibraryPage.GENRE_ALBUMS}|$extra|${item.id}"
"MusicAlbum" -> "${LibraryPage.ALBUM}|${item.id}"
"Playlist" -> "${LibraryPage.PLAYLIST}|${item.id}"
"Audio" -> "$extra|${item.id}"
else -> throw IllegalArgumentException("Unhandled item type ${item.type}")
}
private fun List<MediaMetadataCompat>.browsable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
@@ -20,8 +20,6 @@ import com.google.android.exoplayer2.Player
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.apiclient.model.dto.ImageOptions
import org.jellyfin.apiclient.model.entities.ImageType.Primary
import org.jellyfin.mobile.AppPreferences
import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.MainActivity
@@ -29,14 +27,19 @@ import org.jellyfin.mobile.R
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID
import org.jellyfin.mobile.utils.createMediaNotificationChannel
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUID
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinComponent {
private val context: Context = viewModel.getApplication<Application>()
private val appPreferences: AppPreferences by inject()
private val notificationManager: NotificationManager? by lazy { context.getSystemService() }
private val imageApi: ImageApi by inject()
private val imageLoader: ImageLoader by inject()
private val receiverRegistered = AtomicBoolean(false)
@@ -57,12 +60,14 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
viewModel.viewModelScope.launch {
val mediaIcon: Bitmap? = withContext(Dispatchers.IO) {
val imageUrl = viewModel.apiClient.GetImageUrl(mediaSource.id, ImageOptions().apply {
imageType = Primary
val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
maxWidth = size
maxHeight = size
})
val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
val imageUrl = imageApi.getItemImageUrl(
itemId = mediaSource.id.toUUID(),
imageType = ImageType.PRIMARY,
maxWidth = size,
maxHeight = size,
)
imageLoader.execute(ImageRequest.Builder(context).data(imageUrl).build()).drawable?.toBitmap()
}
@@ -14,29 +14,30 @@ import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.analytics.AnalyticsCollector
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.util.Clock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.model.session.PlaybackProgressInfo
import org.jellyfin.apiclient.model.session.PlaybackStopInfo
import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.PLAYER_EVENT_CHANNEL
import org.jellyfin.mobile.controller.ApiController
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.player.source.MediaSourceManager
import org.jellyfin.mobile.utils.*
import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.serializer.toUUID
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koin.core.qualifier.named
import org.jellyfin.apiclient.model.session.RepeatMode as ApiRepeatMode
import java.util.*
class PlayerViewModel(application: Application) : AndroidViewModel(application), KoinComponent, Player.EventListener {
val apiClient: ApiClient by inject()
private val apiController by inject<ApiController>()
private val playStateApi by inject<PlayStateApi>()
val mediaSourceManager = MediaSourceManager(this)
private val audioManager: AudioManager by lazy { getApplication<Application>().getSystemService()!! }
val notificationHelper: PlayerNotificationHelper by lazy { PlayerNotificationHelper(this) }
@@ -128,16 +129,20 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
// Report playback stop via API
withTimeoutOrNull(200) {
val (playbackState, currentPosition) = playerState
apiClient.reportPlaybackStopped(PlaybackStopInfo().apply {
itemId = mediaSource.id
positionTicks = when (playbackState) {
Player.STATE_ENDED -> mediaSource.mediaDurationTicks
else -> currentPosition * Constants.TICKS_PER_MILLISECOND
}
})
playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = mediaSource.id.toUUID(),
positionTicks = when (playbackState) {
Player.STATE_ENDED -> mediaSource.mediaDurationTicks
else -> currentPosition * Constants.TICKS_PER_MILLISECOND
},
failed = false,
)
)
if (playbackState == Player.STATE_ENDED) {
val userId = requireNotNull(apiController.currentUser) { "Current user is null!" }
// Mark video as watched
apiClient.markPlayed(mediaSource.id, apiClient.currentUserId)
playStateApi.markPlayedItem(userId = userId, itemId = mediaSource.id.toUUID())
}
}
}
@@ -173,18 +178,27 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
val playbackPositionMillis = player.currentPosition
if (player.playbackState != Player.STATE_ENDED) {
webappFunctionChannel.exoPlayerUpdateProgress(playbackPositionMillis)
apiClient.reportPlaybackProgress(PlaybackProgressInfo().apply {
itemId = mediaSource.id
canSeek = true
isPaused = !player.isPlaying
isMuted = false
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND
val stream = AudioManager.STREAM_MUSIC
val volumeRange = audioManager.getVolumeRange(stream)
val currentVolume = audioManager.getStreamVolume(stream)
volumeLevel = (currentVolume - volumeRange.first) * 100 / volumeRange.width
repeatMode = ApiRepeatMode.RepeatNone
})
val stream = AudioManager.STREAM_MUSIC
val volumeRange = audioManager.getVolumeRange(stream)
val currentVolume = audioManager.getStreamVolume(stream)
playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = mediaSource.id.toUUID(),
canSeek = true,
isPaused = !player.isPlaying,
isMuted = false,
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND,
volumeLevel = (currentVolume - volumeRange.first) * 100 / volumeRange.width,
repeatMode = RepeatMode.REPEAT_NONE,
playMethod = when (mediaSource.playMethod) {
"DirectPlay" -> PlayMethod.DIRECT_PLAY
"DirectStream" -> PlayMethod.DIRECT_STREAM
"Transcode" -> PlayMethod.TRANSCODE
else -> throw IllegalArgumentException()
},
)
)
}
}
@@ -1,91 +0,0 @@
package org.jellyfin.mobile.utils
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.interaction.EmptyResponse
import org.jellyfin.apiclient.interaction.Response
import org.jellyfin.apiclient.model.configuration.ServerConfiguration
import org.jellyfin.apiclient.model.dto.BaseItemDto
import org.jellyfin.apiclient.model.dto.UserItemDataDto
import org.jellyfin.apiclient.model.playlists.PlaylistItemQuery
import org.jellyfin.apiclient.model.querying.ArtistsQuery
import org.jellyfin.apiclient.model.querying.ItemQuery
import org.jellyfin.apiclient.model.querying.ItemsByNameQuery
import org.jellyfin.apiclient.model.querying.ItemsResult
import org.jellyfin.apiclient.model.querying.LatestItemsQuery
import org.jellyfin.apiclient.model.session.PlaybackProgressInfo
import org.jellyfin.apiclient.model.session.PlaybackStopInfo
import org.jellyfin.apiclient.model.system.PublicSystemInfo
import timber.log.Timber
import java.util.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
val PRODUCT_NAME_SUPPORTED_SINCE: Pair<Int, Int> = 10 to 7
// Can be removed/replaced once the api client supports coroutines natively
suspend fun ApiClient.getPublicSystemInfo(): PublicSystemInfo? = suspendCoroutine { continuation ->
GetPublicSystemInfoAsync(ContinuationResponse(continuation))
}
suspend fun ApiClient.getServerConfiguration(): ServerConfiguration? = suspendCoroutine { continuation ->
GetServerConfigurationAsync(ContinuationResponse(continuation))
}
suspend fun ApiClient.reportPlaybackProgress(progressInfo: PlaybackProgressInfo) = suspendCoroutine<Boolean> { continuation ->
ReportPlaybackProgressAsync(progressInfo, ContinuationStatusResponse(continuation))
}
suspend fun ApiClient.reportPlaybackStopped(stopInfo: PlaybackStopInfo) = suspendCoroutine<Boolean> { continuation ->
ReportPlaybackStoppedAsync(stopInfo, ContinuationStatusResponse(continuation))
}
suspend fun ApiClient.markPlayed(itemId: String, userId: String): UserItemDataDto? = suspendCoroutine { continuation ->
MarkPlayedAsync(itemId, userId, Date(), ContinuationResponse(continuation))
}
suspend fun ApiClient.getUserViews(userId: String): ItemsResult? = suspendCoroutine { continuation ->
GetUserViews(userId, ContinuationResponse(continuation))
}
suspend fun ApiClient.getItems(query: ItemQuery): ItemsResult? = suspendCoroutine { continuation ->
GetItemsAsync(query, ContinuationResponse(continuation))
}
suspend fun ApiClient.getPlaylistItems(query: PlaylistItemQuery): ItemsResult? = suspendCoroutine { continuation ->
GetPlaylistItems(query, ContinuationResponse(continuation))
}
suspend fun ApiClient.getLatestItems(query: LatestItemsQuery): Array<BaseItemDto>? = suspendCoroutine { continuation ->
GetLatestItems(query, ContinuationResponse(continuation))
}
suspend fun ApiClient.getArtists(query: ArtistsQuery): ItemsResult? = suspendCoroutine { continuation ->
GetArtistsAsync(query, ContinuationResponse(continuation))
}
suspend fun ApiClient.getGenres(query: ItemsByNameQuery): ItemsResult? = suspendCoroutine { continuation ->
GetGenresAsync(query, ContinuationResponse(continuation))
}
class ContinuationResponse<T>(private val continuation: Continuation<T?>) : Response<T>() {
override fun onResponse(response: T?) {
continuation.resume(response)
}
override fun onError(exception: Exception) {
Timber.e(exception)
continuation.resume(null)
}
}
class ContinuationStatusResponse(private val continuation: Continuation<Boolean>) : EmptyResponse() {
override fun onResponse() {
continuation.resume(true)
}
override fun onError(exception: Exception) {
Timber.e(exception)
continuation.resume(false)
}
}
@@ -9,10 +9,10 @@ import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun WebViewFragment.initLocale() {
suspend fun WebViewFragment.initLocale(userId: String) {
// Try to set locale via user settings
val userSettings = suspendCoroutine<String> { continuation ->
webView.evaluateJavascript("window.localStorage.getItem('${apiClient.currentUserId}-language')") { result ->
webView.evaluateJavascript("window.localStorage.getItem('$userId-language')") { result ->
continuation.resume(result)
}
}
@@ -5,37 +5,26 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.mobile.controller.ServerController
import org.jellyfin.mobile.controller.ApiController
import org.jellyfin.mobile.model.sql.entity.ServerEntity
class MainViewModel(
app: Application,
private val apiClient: ApiClient,
private val serverController: ServerController,
private val apiController: ApiController,
) : AndroidViewModel(app) {
private val _serverState: MutableStateFlow<ServerState> = MutableStateFlow(ServerState.Pending)
val serverState: StateFlow<ServerState> get() = _serverState
init {
viewModelScope.launch {
serverState.collect { state ->
val serverAddress = state.server?.hostname?.trimEnd('/')
if (apiClient.serverAddress != serverAddress)
apiClient.ChangeServerLocation(serverAddress)
}
}
viewModelScope.launch {
serverController.migrateFromPreferences()
apiController.migrateFromPreferences()
refreshServer()
}
}
suspend fun refreshServer() {
val server = serverController.loadCurrentServer()
val server = apiController.loadSavedServer()
_serverState.value = server?.let { ServerState.Available(it) } ?: ServerState.Unset
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ object Dependencies {
const val room = "2.2.6"
// Network
const val apiclient = "0.7.9"
const val jellyfinSdk = "1.0.0-beta.3"
const val okHttp = "4.9.1"
const val coil = "1.1.1"
@@ -87,7 +87,7 @@ object Dependencies {
}
object Network {
const val apiclient = "org.jellyfin.apiclient:android:${Versions.apiclient}"
const val jellyfinSdk = "org.jellyfin.sdk:jellyfin-platform-android:${Versions.jellyfinSdk}"
const val okHttp = "com.squareup.okhttp3:okhttp:${Versions.okHttp}"
const val coil = "io.coil-kt:coil-base:${Versions.coil}"
val exoPlayerHLS = exoPlayer("hls")
+11 -10
View File
@@ -1,4 +1,4 @@
import java.util.Properties
import java.util.*
include(":app")
@@ -14,20 +14,21 @@ pluginManagement {
// Load properties from local.properties
val properties = Properties().apply {
val location = File("local.properties")
if (location.exists())
load(location.inputStream())
val propFile = File("local.properties")
if (propFile.exists()) {
load(propFile.inputStream())
}
}
// Get value for dependency substitution
// Check if dependency substitution is enabled
val enableDependencySubstitution = properties.getProperty("enable.dependency.substitution", "true").equals("true", true)
// Replace apiclient dependency with local version
val apiclientLocation = "../jellyfin-apiclient-java"
if (File(apiclientLocation).exists() && enableDependencySubstitution) {
includeBuild(apiclientLocation) {
// Replace SDK dependency with local version
val sdkLocation = "../jellyfin-sdk-kotlin"
if (File(sdkLocation).exists() && enableDependencySubstitution) {
includeBuild(sdkLocation) {
dependencySubstitution {
substitute(module("org.jellyfin.apiclient:android")).with(project(":android"))
substitute(module("org.jellyfin.sdk:jellyfin-platform-android")).with(project(":jellyfin-platform-android"))
}
}
}