mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-09-02 21:04:10 +03:00
Migrate to Jellyfin Kotlin SDK (1.0.0-beta.3) (#333)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user