Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834bebb03f | ||
|
|
a001846da7 | ||
|
|
5c7bf941ea | ||
|
|
53b92184a4 | ||
|
|
1e369480fd | ||
|
|
e00f6d3f40 | ||
|
|
5e85a876bd | ||
|
|
5249209f3a | ||
|
|
276e138d7a | ||
|
|
00cc8f14ec | ||
|
|
f14a8fa6b8 | ||
|
|
6191044057 | ||
|
|
d0365ced7e | ||
|
|
9c8143368f | ||
|
|
266d5738a6 | ||
|
|
e34442a432 | ||
|
|
12e4c96304 | ||
|
|
857ad84771 | ||
|
|
7dfd3b9179 | ||
|
|
63de8d16d2 | ||
|
|
b771bb4c27 | ||
|
|
2b01ac9ffc | ||
|
|
b857fb4f1c | ||
|
|
19707eb9c8 | ||
|
|
c60d210e87 | ||
|
|
cb046c26c5 | ||
|
|
3d97b5abb0 | ||
|
|
da8c7c2837 | ||
|
|
d9c11d86ad | ||
|
|
b9172845d8 | ||
|
|
3d3fa76771 | ||
|
|
521183d427 |
@@ -35,6 +35,9 @@
|
||||
<a href="https://www.amazon.com/gp/aw/d/B07TX7Z725">
|
||||
<img width="153" alt="Jellyfin on Amazon Appstore" src="https://jellyfin.org/images/store-icons/amazon.png"/>
|
||||
</a>
|
||||
<a href="https://f-droid.org/en/packages/org.jellyfin.androidtv/">
|
||||
<img width="153" alt="Jellyfin on F-Droid" src="https://jellyfin.org/images/store-icons/fdroid.png"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
|
||||
</p>
|
||||
|
||||
39
app/baselineWorkaround.gradle
Normal file
39
app/baselineWorkaround.gradle
Normal file
@@ -0,0 +1,39 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath libs.android.gradle
|
||||
}
|
||||
}
|
||||
|
||||
import com.android.tools.profgen.ArtProfileKt
|
||||
import com.android.tools.profgen.ArtProfileSerializer
|
||||
import com.android.tools.profgen.DexFile
|
||||
|
||||
/**
|
||||
* This is a temporary workaround for https://issuetracker.google.com/issues/231837768
|
||||
* which will be fixed in AGP 8.1.0, at which point the workaround may be deleted.
|
||||
*/
|
||||
project.afterEvaluate {
|
||||
tasks.compileReleaseArtProfile.doLast {
|
||||
outputs.files.each { file ->
|
||||
if (file.toString().endsWith(".profm")) {
|
||||
println("Sorting ${file}")
|
||||
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||
def profile = ArtProfileKt.ArtProfile(file)
|
||||
def keys = new ArrayList(profile.profileData.keySet())
|
||||
def sortedData = new LinkedHashMap()
|
||||
Collections.sort keys, new DexFile.Companion()
|
||||
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||
new FileOutputStream(file).with {
|
||||
write(version.magicBytes$profgen)
|
||||
write(version.versionBytes$profgen)
|
||||
version.write$profgen(it, sortedData, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ plugins {
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
}
|
||||
|
||||
// Apply workaround
|
||||
apply("baselineWorkaround.gradle")
|
||||
|
||||
android {
|
||||
namespace = "org.jellyfin.androidtv"
|
||||
compileSdk = 33
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.preference.PreferencesActivity"
|
||||
android:screenOrientation="behind"
|
||||
android:theme="@style/Theme.Jellyfin.Preferences" />
|
||||
|
||||
<!-- Playback related activities -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.UUID
|
||||
|
||||
data class AccountManagerAccount(
|
||||
val id: UUID,
|
||||
val address: String,
|
||||
val server: UUID,
|
||||
val name: String,
|
||||
val accessToken: String? = null,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
|
||||
sealed class LoginState
|
||||
object AuthenticatingState : LoginState()
|
||||
object RequireSignInState : LoginState()
|
||||
object ServerUnavailableState : LoginState()
|
||||
data class ServerVersionNotSupported(val server: Server) : LoginState()
|
||||
data class ApiClientErrorLoginState(val error: ApiClientException) : LoginState()
|
||||
object AuthenticatedState : LoginState()
|
||||
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
@@ -74,7 +75,7 @@ class AuthenticationRepositoryImpl(
|
||||
// Automatic logic is disabled when the always authenticate preference is enabled
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
|
||||
|
||||
val account = accountManagerStore.getAccount(user.id)
|
||||
val account = accountManagerStore.getAccount(server.id, user.id)
|
||||
// Try login with access token
|
||||
return if (account?.accessToken != null) authenticateToken(server, user.withToken(account.accessToken))
|
||||
// Try login without password
|
||||
@@ -94,7 +95,7 @@ class AuthenticationRepositoryImpl(
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in as $username")
|
||||
emit(RequireSignInState)
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
return@flow
|
||||
}
|
||||
|
||||
@@ -106,9 +107,13 @@ class AuthenticationRepositoryImpl(
|
||||
val result = try {
|
||||
val response = api.userApi.authenticateWithQuickConnect(secret)
|
||||
response.content
|
||||
} catch (err: TimeoutException) {
|
||||
Timber.e(err, "Failed to connect to server")
|
||||
emit(ServerUnavailableState)
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in with Quick Connect secret")
|
||||
emit(RequireSignInState)
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
return@flow
|
||||
}
|
||||
|
||||
@@ -145,9 +150,13 @@ class AuthenticationRepositoryImpl(
|
||||
val userInfo by userApiClient.userApi.getCurrentUser()
|
||||
authenticateFinish(server, userInfo, user.accessToken.orEmpty())
|
||||
emit(AuthenticatedState)
|
||||
} catch (err: TimeoutException) {
|
||||
Timber.e(err, "Failed to connect to server")
|
||||
emit(ServerUnavailableState)
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to get current user data")
|
||||
emit(RequireSignInState)
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,12 +175,18 @@ class AuthenticationRepositoryImpl(
|
||||
)
|
||||
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
|
||||
|
||||
val accountManagerAccount = AccountManagerAccount(userInfo.id, server.id, updatedUser.name, accessToken)
|
||||
val accountManagerAccount = AccountManagerAccount(
|
||||
id = userInfo.id,
|
||||
address = "${updatedUser.name}@${server.address}",
|
||||
server = server.id,
|
||||
name = updatedUser.name,
|
||||
accessToken = accessToken,
|
||||
)
|
||||
accountManagerStore.putAccount(accountManagerAccount)
|
||||
}
|
||||
|
||||
private suspend fun setActiveSession(user: User, server: Server): Boolean {
|
||||
val authenticated = sessionRepository.switchCurrentSession(user.id)
|
||||
val authenticated = sessionRepository.switchCurrentSession(server.id, user.id)
|
||||
|
||||
if (authenticated) {
|
||||
// Update last use in store
|
||||
@@ -188,7 +203,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun logout(user: User): Boolean {
|
||||
val authInfo = accountManagerStore.getAccount(user.id) ?: return false
|
||||
val authInfo = accountManagerStore.getAccount(user.serverId, user.id) ?: return false
|
||||
return accountManagerStore.removeAccount(authInfo)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class ServerUserRepositoryImpl(
|
||||
) : ServerUserRepository {
|
||||
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
|
||||
?.mapNotNull { (userId, userInfo) ->
|
||||
val authInfo = accountManagerStore.getAccount(userId)
|
||||
val authInfo = accountManagerStore.getAccount(server.id, userId)
|
||||
PrivateUser(
|
||||
id = authInfo?.id ?: userId,
|
||||
serverId = authInfo?.server ?: server.id,
|
||||
@@ -64,7 +64,7 @@ class ServerUserRepositoryImpl(
|
||||
authenticationStore.removeUser(user.serverId, user.id)
|
||||
|
||||
// Remove authentication info from system account manager
|
||||
accountManagerStore.getAccount(user.id)?.let { accountManagerAccount ->
|
||||
accountManagerStore.getAccount(user.serverId, user.id)?.let { accountManagerAccount ->
|
||||
accountManagerStore.removeAccount(accountManagerAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ interface SessionRepository {
|
||||
val state: StateFlow<SessionRepositoryState>
|
||||
|
||||
suspend fun restoreSession()
|
||||
suspend fun switchCurrentSession(userId: UUID): Boolean
|
||||
suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean
|
||||
fun destroyCurrentSession()
|
||||
}
|
||||
|
||||
@@ -69,26 +69,27 @@ class SessionRepositoryImpl(
|
||||
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
|
||||
|
||||
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||
|
||||
when (behavior) {
|
||||
when (authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]) {
|
||||
DISABLED -> destroyCurrentSession()
|
||||
LAST_USER -> setCurrentSession(createLastUserSession())
|
||||
SPECIFIC_USER -> setCurrentSession(createUserSession(userId))
|
||||
SPECIFIC_USER -> {
|
||||
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
|
||||
}
|
||||
}
|
||||
|
||||
_state.value = SessionRepositoryState.READY
|
||||
}
|
||||
|
||||
override suspend fun switchCurrentSession(userId: UUID): Boolean {
|
||||
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
|
||||
// No change in user - don't switch
|
||||
if (currentSession.value?.userId == userId) return false
|
||||
|
||||
_state.value = SessionRepositoryState.SWITCHING_SESSION
|
||||
Timber.d("Switching current session to user $userId")
|
||||
|
||||
val session = createUserSession(userId)
|
||||
val session = createUserSession(serverId, userId)
|
||||
if (session == null) {
|
||||
Timber.d("Could not switch to non-existing session for user $userId")
|
||||
return false
|
||||
@@ -115,6 +116,7 @@ class SessionRepositoryImpl(
|
||||
if (currentSession.value?.userId == session.userId) return true
|
||||
|
||||
// Update last active user
|
||||
authenticationPreferences[AuthenticationPreferences.lastServerId] = session.serverId.toString()
|
||||
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
|
||||
|
||||
// Check if server version is supported
|
||||
@@ -156,13 +158,14 @@ class SessionRepositoryImpl(
|
||||
|
||||
private fun createLastUserSession(): Session? {
|
||||
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
|
||||
return createUserSession(lastUserId)
|
||||
val lastServerId = authenticationPreferences[AuthenticationPreferences.lastServerId].toUUIDOrNull()
|
||||
|
||||
return if (lastUserId != null && lastServerId != null) createUserSession(lastServerId, lastUserId)
|
||||
else null
|
||||
}
|
||||
|
||||
private fun createUserSession(userId: UUID?): Session? {
|
||||
if (userId == null) return null
|
||||
|
||||
val account = accountManagerStore.getAccount(userId)
|
||||
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
|
||||
val account = accountManagerStore.getAccount(serverId, userId)
|
||||
if (account?.accessToken == null) return null
|
||||
|
||||
return Session(
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.auth.store
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
|
||||
@@ -22,8 +23,16 @@ class AccountManagerStore(
|
||||
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
|
||||
}
|
||||
|
||||
private fun Array<Account>.filterServerAccount(server: UUID, account: UUID? = null) = filter {
|
||||
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
|
||||
val validUserId = account == null || accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == account
|
||||
|
||||
validServerId && validUserId
|
||||
}
|
||||
|
||||
private fun getAccountData(account: Account): AccountManagerAccount = AccountManagerAccount(
|
||||
id = accountManager.getUserData(account, ACCOUNT_DATA_ID).toUUID(),
|
||||
address = account.name,
|
||||
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
|
||||
name = accountManager.getUserData(account, ACCOUNT_DATA_NAME),
|
||||
accessToken = accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE)
|
||||
@@ -31,11 +40,12 @@ class AccountManagerStore(
|
||||
|
||||
suspend fun putAccount(accountManagerAccount: AccountManagerAccount) {
|
||||
var androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
|
||||
.filterServerAccount(accountManagerAccount.server, accountManagerAccount.id)
|
||||
.firstOrNull()
|
||||
|
||||
// Update credentials
|
||||
if (androidAccount == null) {
|
||||
androidAccount = Account(accountManagerAccount.name, ACCOUNT_TYPE)
|
||||
androidAccount = Account(accountManagerAccount.address, ACCOUNT_TYPE)
|
||||
|
||||
accountManager.addAccountExplicitly(
|
||||
androidAccount,
|
||||
@@ -46,9 +56,14 @@ class AccountManagerStore(
|
||||
}
|
||||
|
||||
// Update name
|
||||
if (androidAccount.name != accountManagerAccount.name) {
|
||||
if (androidAccount.name != accountManagerAccount.address) {
|
||||
androidAccount = suspendCoroutine { continuation ->
|
||||
accountManager.renameAccount(androidAccount, accountManagerAccount.name, { continuation.resume(it.result) }, null)
|
||||
accountManager.renameAccount(
|
||||
androidAccount,
|
||||
accountManagerAccount.address,
|
||||
{ continuation.resume(it.result) },
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +74,13 @@ class AccountManagerStore(
|
||||
|
||||
fun removeAccount(accountManagerAccount: AccountManagerAccount): Boolean {
|
||||
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
|
||||
.filterServerAccount(accountManagerAccount.server, accountManagerAccount.id)
|
||||
.firstOrNull()
|
||||
?: return false
|
||||
|
||||
// Remove current account info
|
||||
@Suppress("DEPRECATION")
|
||||
return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
accountManager.removeAccount(androidAccount, null, null)
|
||||
true
|
||||
} else accountManager.removeAccountExplicitly(androidAccount)
|
||||
@@ -72,11 +88,12 @@ class AccountManagerStore(
|
||||
|
||||
fun getAccounts() = accountManager.getAccountsByType(ACCOUNT_TYPE).map(::getAccountData)
|
||||
|
||||
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).filter { account ->
|
||||
accountManager.getUserData(account, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
|
||||
}.map(::getAccountData)
|
||||
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.filterServerAccount(server)
|
||||
.map(::getAccountData)
|
||||
|
||||
fun getAccount(id: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull { account ->
|
||||
accountManager.getUserData(account, ACCOUNT_DATA_ID)?.toUUIDOrNull() == id
|
||||
}?.let(::getAccountData)
|
||||
fun getAccount(server: UUID, account: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.filterServerAccount(server, account)
|
||||
.firstOrNull()
|
||||
?.let(::getAccountData)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,30 @@ class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
|
||||
companion object {
|
||||
// Preferences
|
||||
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
|
||||
val autoLoginServerId = stringPreference("auto_login_server_id", "")
|
||||
val autoLoginUserId = stringPreference("auto_login_user_id", "")
|
||||
|
||||
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
|
||||
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
|
||||
|
||||
// Persistent state
|
||||
val lastServerId = stringPreference("last_server_id", "")
|
||||
val lastUserId = stringPreference("last_user_id", "")
|
||||
}
|
||||
|
||||
init {
|
||||
runMigrations {
|
||||
// v0.15.4 to v0.15.5
|
||||
migration(toVersion = 2) {
|
||||
// Unfortunately we cannot migrate the "specific user" login option
|
||||
// so we'll reset the preference to disabled if it was used
|
||||
if (it.getString("auto_login_user_behavior", null) === UserSelectBehavior.SPECIFIC_USER.name) {
|
||||
putString("auto_login_user_id", "")
|
||||
putString("auto_login_user_behavior", UserSelectBehavior.DISABLED.name)
|
||||
}
|
||||
|
||||
putString("last_user_id", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ object Codec {
|
||||
|
||||
object Subtitle {
|
||||
const val ASS = "ass"
|
||||
const val DVBSUB = "dvbsub"
|
||||
const val DVDSUB = "dvdsub"
|
||||
const val IDX = "idx"
|
||||
const val PGS = "pgs"
|
||||
|
||||
@@ -32,6 +32,7 @@ object TelemetryService {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
sharedPreferencesName = TelemetryPreferences.SHARED_PREFERENCES_NAME
|
||||
pluginLoader = AcraPluginLoader(AcraReportSenderFactory::class.java)
|
||||
applicationLogFileLines = 250
|
||||
|
||||
toast {
|
||||
text = context.getString(R.string.crash_report_toast)
|
||||
|
||||
@@ -10,11 +10,14 @@ import androidx.core.view.doOnAttach
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.LazyHeaders
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import kotlin.math.round
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -69,15 +72,23 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
// Start loading image or placeholder
|
||||
Glide.with(this@AsyncImageView)
|
||||
.load(url ?: placeholder).apply {
|
||||
if (url == null) {
|
||||
Glide.with(this@AsyncImageView).load(placeholder).apply {
|
||||
if (circleCrop) circleCrop()
|
||||
}.into(this@AsyncImageView)
|
||||
} else {
|
||||
val glideUrl = GlideUrl(url, LazyHeaders.Builder().apply {
|
||||
setHeader("Accept", ApiClient.HEADER_ACCEPT)
|
||||
}.build())
|
||||
|
||||
Glide.with(this@AsyncImageView).load(glideUrl).apply {
|
||||
placeholder(placeholderOrBlurHash)
|
||||
error(placeholder)
|
||||
if (circleCrop) circleCrop()
|
||||
// FIXME: Glide is unable to scale the image when transitions are enabled
|
||||
//transition(DrawableTransitionOptions.withCrossFade(crossFadeDuration.inWholeMilliseconds.toInt()))
|
||||
}
|
||||
.into(this@AsyncImageView)
|
||||
}.into(this@AsyncImageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.jellyfin.androidtv.ui;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
@@ -23,14 +22,14 @@ public class GuidePagingButton extends RelativeLayout {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public GuidePagingButton(final Activity activity, final LiveTvGuide guide, int start, String label) {
|
||||
super(activity);
|
||||
public GuidePagingButton(Context context, final LiveTvGuide guide, int start, String label) {
|
||||
super(context);
|
||||
|
||||
LayoutInflater inflater = LayoutInflater.from(activity);
|
||||
LayoutInflater inflater = LayoutInflater.from(context);
|
||||
ProgramGridCellBinding binding = ProgramGridCellBinding.inflate(inflater, this, true);
|
||||
binding.programName.setText(label);
|
||||
|
||||
setBackgroundColor(Utils.getThemeColor(activity, R.attr.buttonDefaultNormalBackground));
|
||||
setBackgroundColor(Utils.getThemeColor(context, R.attr.buttonDefaultNormalBackground));
|
||||
setFocusable(true);
|
||||
setOnClickListener(v -> guide.displayChannels(start, LiveTvGuideFragment.PAGE_SIZE));
|
||||
}
|
||||
|
||||
@@ -52,9 +52,6 @@ abstract class BrowseFolderFragment : BrowseSupportFragment(), RowLoader {
|
||||
folder = Json.decodeFromString<BaseItemDto>(arguments?.getString(Extras.Folder)!!)
|
||||
includeType = arguments?.getString(Extras.IncludeType)
|
||||
|
||||
// Attach background service
|
||||
backgroundService.attach(requireActivity())
|
||||
|
||||
// Set BrowseSupportFragment properties
|
||||
title = folder?.name
|
||||
headersState = HEADERS_DISABLED
|
||||
|
||||
@@ -157,7 +157,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
|
||||
mActivity = getActivity();
|
||||
backgroundService.getValue().attach(requireActivity());
|
||||
|
||||
mFolder = Json.Default.decodeFromString(BaseItemDto.Companion.serializer(), getArguments().getString(Extras.Folder));
|
||||
mParentId = mFolder.getId();
|
||||
@@ -188,27 +187,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
// Hide the description because we don't have room for it
|
||||
binding.npBug.showDescription(false);
|
||||
|
||||
// NOTE: we only get the 100% correct grid size if we render it once, so hook into it here
|
||||
binding.rowsFragment.post(() -> {
|
||||
if (binding.rowsFragment.getHeight() > 0 && binding.rowsFragment.getWidth() > 0) {
|
||||
if (mGridView == null) {
|
||||
return;
|
||||
}
|
||||
// prevent adaption on minor size delta's
|
||||
if (Math.abs(mGridHeight - binding.rowsFragment.getHeight()) > MIN_GRIDSIZE_CHANGE_DELTA || Math.abs(mGridWidth - binding.rowsFragment.getWidth()) > MIN_GRIDSIZE_CHANGE_DELTA) {
|
||||
mGridHeight = Math.round(binding.rowsFragment.getHeight() / getResources().getDisplayMetrics().density);
|
||||
mGridWidth = Math.round(binding.rowsFragment.getWidth() / getResources().getDisplayMetrics().density);
|
||||
Timber.d("Auto-Adapting grid size to height <%s> width <%s>", binding.rowsFragment.getHeight(), binding.rowsFragment.getWidth());
|
||||
mDirty = true;
|
||||
determiningPosterSize = true;
|
||||
setAutoCardGridValues();
|
||||
createGrid();
|
||||
loadGrid();
|
||||
determiningPosterSize = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@@ -223,7 +201,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
|
||||
@Override
|
||||
public boolean onKey(View v, int keyCode, KeyEvent event) {
|
||||
if (event.getAction() != KeyEvent.ACTION_DOWN) return false;
|
||||
if (event.getAction() != KeyEvent.ACTION_UP) return false;
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) {
|
||||
mediaManager.getValue().setCurrentMediaAdapter(mAdapter);
|
||||
@@ -958,8 +936,8 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
//Now - if filtered make sure we still pass
|
||||
if (mAdapter.getFilters() != null) {
|
||||
if ((mAdapter.getFilters().isFavoriteOnly() && !mCurrentItem.isFavorite()) || (mAdapter.getFilters().isUnwatchedOnly() && mCurrentItem.isPlayed())) {
|
||||
//if we are about to remove last item, throw focus to toolbar so framework doesn't crash
|
||||
if (mAdapter.size() == 1) binding.toolBar.requestFocus();
|
||||
// if we are about to remove the current item, throw focus to toolbar so framework doesn't crash
|
||||
binding.toolBar.requestFocus();
|
||||
mAdapter.remove(mCurrentItem);
|
||||
mAdapter.setTotalItems(mAdapter.getTotalItems() - 1);
|
||||
updateCounter(mCurrentItem.getIndex());
|
||||
|
||||
@@ -15,6 +15,7 @@ class ByLetterFragment : BrowseFolderFragment() {
|
||||
val numbersQuery = StdItemQuery().apply {
|
||||
parentId = folder?.id?.toString()
|
||||
sortBy = arrayOf(ItemSortBy.SortName)
|
||||
includeType?.let { includeItemTypes = arrayOf(it) }
|
||||
nameLessThan = letters.substring(0, 1)
|
||||
recursive = true
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ public class EnhancedBrowseFragment extends Fragment implements RowLoader, View.
|
||||
favSongsRowItem = new BaseRowItem(FakeBaseItem.INSTANCE.getFAV_SONGS());
|
||||
|
||||
mRowsAdapter = new MutableObjectAdapter<Row>(new PositionableListRowPresenter());
|
||||
backgroundService.getValue().attach(requireActivity());
|
||||
|
||||
setupViews();
|
||||
setupQueries(this);
|
||||
@@ -339,7 +338,7 @@ public class EnhancedBrowseFragment extends Fragment implements RowLoader, View.
|
||||
|
||||
@Override
|
||||
public boolean onKey(View v, int keyCode, KeyEvent event) {
|
||||
if (event.getAction() != KeyEvent.ACTION_DOWN) return false;
|
||||
if (event.getAction() != KeyEvent.ACTION_UP) return false;
|
||||
return KeyProcessor.HandleKey(keyCode, mCurrentItem, requireActivity());
|
||||
}
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@ class MainActivity : FragmentActivity(R.layout.fragment_content_view) {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!validateAuthentication()) return
|
||||
|
||||
applyTheme()
|
||||
|
||||
backgroundService.attach(this)
|
||||
onBackPressedDispatcher.addCallback(this, backPressedCallback)
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = MutableObjectAdapter<Row>(PositionableListRowPresenter())
|
||||
backgroundService.attach(requireActivity())
|
||||
|
||||
val currentUser = userRepository.currentUser.value
|
||||
if (currentUser == null) {
|
||||
@@ -162,7 +161,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
}
|
||||
|
||||
override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (event?.action != KeyEvent.ACTION_DOWN) return false
|
||||
if (event?.action != KeyEvent.ACTION_UP) return false
|
||||
return KeyProcessor.HandleKey(keyCode, currentItem, activity)
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
FragmentFullDetailsBinding binding = FragmentFullDetailsBinding.inflate(getLayoutInflater(), container, false);
|
||||
|
||||
BUTTON_SIZE = Utils.convertDpToPixel(requireContext(), 40);
|
||||
backgroundService.getValue().attach(requireActivity());
|
||||
|
||||
mMetrics = new DisplayMetrics();
|
||||
requireActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
|
||||
|
||||
@@ -158,7 +158,6 @@ public class ItemListFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
});
|
||||
|
||||
backgroundService.getValue().attach(requireActivity());
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
|
||||
@@ -446,6 +446,7 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
default:
|
||||
mQuery.setFilters(mFilters != null ? mFilters.getFilters() : null);
|
||||
}
|
||||
removeRow();
|
||||
}
|
||||
|
||||
public void setPosition(int pos) {
|
||||
|
||||
@@ -46,6 +46,7 @@ import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
|
||||
import org.jellyfin.androidtv.util.CoroutineUtils;
|
||||
import org.jellyfin.androidtv.util.ImageUtils;
|
||||
import org.jellyfin.androidtv.util.InfoLayoutHelper;
|
||||
import org.jellyfin.androidtv.util.TextUtilsKt;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
|
||||
@@ -300,6 +301,7 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
|
||||
private boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
switch (keyCode){
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
event.startTracking();
|
||||
return true;
|
||||
@@ -309,6 +311,7 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
|
||||
private boolean onKeyLongPress(int keyCode) {
|
||||
switch (keyCode){
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
if (mSelectedProgramView instanceof ProgramGridCell)
|
||||
showProgramOptions();
|
||||
@@ -325,6 +328,7 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
// bring up filter selection
|
||||
showFilterOptions();
|
||||
break;
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
if ((event.getFlags() & KeyEvent.FLAG_CANCELED_LONG_PRESS) == 0) {
|
||||
Date curUTC = TimeUtils.convertToUtcDate(new Date());
|
||||
@@ -538,7 +542,8 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
mChannels.addView(placeHolder);
|
||||
displayedChannels = 0;
|
||||
|
||||
mProgramRows.addView(new GuidePagingButton(requireActivity(), LiveTvGuideFragment.this, pageUpStart, getString(R.string.lbl_load_channels)+mAllChannels.get(pageUpStart).getNumber() + " - "+mAllChannels.get(mCurrentDisplayChannelStartNdx-1).getNumber()));
|
||||
String label = TextUtilsKt.getLoadChannelsLabel(requireContext(), mAllChannels.get(pageUpStart).getNumber(), mAllChannels.get(mCurrentDisplayChannelStartNdx - 1).getNumber());
|
||||
mProgramRows.addView(new GuidePagingButton(requireActivity(), LiveTvGuideFragment.this, pageUpStart, label));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +611,8 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
placeHolder.setHeight(guideRowHeightPx);
|
||||
mChannels.addView(placeHolder);
|
||||
|
||||
mProgramRows.addView(new GuidePagingButton(requireActivity(), LiveTvGuideFragment.this, mCurrentDisplayChannelEndNdx + 1, getString(R.string.lbl_load_channels)+mAllChannels.get(mCurrentDisplayChannelEndNdx+1).getNumber() + " - "+mAllChannels.get(pageDnEnd).getNumber()));
|
||||
String label = TextUtilsKt.getLoadChannelsLabel(requireContext(), mAllChannels.get(mCurrentDisplayChannelEndNdx + 1).getNumber(), mAllChannels.get(pageDnEnd).getNumber());
|
||||
mProgramRows.addView(new GuidePagingButton(requireActivity(), LiveTvGuideFragment.this, mCurrentDisplayChannelEndNdx + 1, label));
|
||||
}
|
||||
|
||||
mChannelStatus.setText(displayedChannels+" of "+mAllChannels.size()+" channels");
|
||||
|
||||
@@ -40,6 +40,13 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
|
||||
)
|
||||
album = albumResponse.items.orEmpty()
|
||||
albumIndex = album.indexOfFirst { it.id == id }
|
||||
|
||||
// In some rare cases the album of the image might be empty when the
|
||||
// files are considered invalid by the server
|
||||
if (album.isEmpty()) {
|
||||
album = listOf(itemResponse)
|
||||
albumIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Album actions
|
||||
|
||||
@@ -256,7 +256,6 @@ public class AudioNowPlayingFragment extends Fragment implements View.OnKeyListe
|
||||
mCurrentPos = binding.currentPos;
|
||||
mRemainingTime = binding.remainingTime;
|
||||
|
||||
backgroundService.getValue().attach(requireActivity());
|
||||
mMetrics = new DisplayMetrics();
|
||||
requireActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
|
||||
|
||||
@@ -393,7 +392,8 @@ public class AudioNowPlayingFragment extends Fragment implements View.OnKeyListe
|
||||
updateButtons(mediaManager.getValue().isPlayingAudio());
|
||||
}
|
||||
} else {
|
||||
requireActivity().finish(); // entire queue removed nothing to do here
|
||||
if (navigationRepository.getValue().getCanGoBack()) navigationRepository.getValue().goBack();
|
||||
else navigationRepository.getValue().reset(Destinations.INSTANCE.getHome());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -594,7 +594,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && mPlaybackController.canSeek()) {
|
||||
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER)
|
||||
&& mPlaybackController.canSeek()) {
|
||||
// if the player is playing and the overlay is hidden, this will pause
|
||||
// if the player is paused and then 'back' is pressed to hide the overlay, this will play
|
||||
mPlaybackController.playPause();
|
||||
@@ -844,7 +845,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
tvGuideBinding.channels.addView(placeHolder);
|
||||
displayedChannels = 0;
|
||||
|
||||
tvGuideBinding.programRows.addView(new GuidePagingButton(requireActivity(), guide, pageUpStart, getString(R.string.lbl_load_channels) + mAllChannels.get(pageUpStart).getNumber() + " - " + mAllChannels.get(mCurrentDisplayChannelStartNdx - 1).getNumber()));
|
||||
String label = TextUtilsKt.getLoadChannelsLabel(requireContext(), mAllChannels.get(pageUpStart).getNumber(), mAllChannels.get(mCurrentDisplayChannelStartNdx - 1).getNumber());
|
||||
tvGuideBinding.programRows.addView(new GuidePagingButton(requireActivity(), guide, pageUpStart, label));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,7 +907,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
placeHolder.setHeight(Utils.convertDpToPixel(getContext(), LiveTvGuideFragment.GUIDE_ROW_HEIGHT_DP));
|
||||
tvGuideBinding.channels.addView(placeHolder);
|
||||
|
||||
tvGuideBinding.programRows.addView(new GuidePagingButton(requireActivity(), guide, mCurrentDisplayChannelEndNdx + 1, getString(R.string.lbl_load_channels) + mAllChannels.get(mCurrentDisplayChannelEndNdx + 1).getNumber() + " - " + mAllChannels.get(pageDnEnd).getNumber()));
|
||||
String label = TextUtilsKt.getLoadChannelsLabel(requireContext(), mAllChannels.get(mCurrentDisplayChannelEndNdx + 1).getNumber(), mAllChannels.get(pageDnEnd).getNumber());
|
||||
tvGuideBinding.programRows.addView(new GuidePagingButton(requireActivity(), guide, mCurrentDisplayChannelEndNdx + 1, label));
|
||||
}
|
||||
|
||||
tvGuideBinding.channelsStatus.setText(getResources().getString(R.string.lbl_tv_channel_status, displayedChannels, mAllChannels.size()));
|
||||
|
||||
@@ -1032,9 +1032,17 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
mCurrentOptions.setSubtitleStreamIndex(index);
|
||||
mDefaultSubIndex = index;
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
if (!mVideoManager.setExoPlayerTrack(index, MediaStreamType.SUBTITLE, getCurrentlyPlayingItem().getMediaStreams())) {
|
||||
// error selecting internal subs
|
||||
if (mFragment != null)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
} else {
|
||||
mCurrentOptions.setSubtitleStreamIndex(index);
|
||||
mDefaultSubIndex = index;
|
||||
}
|
||||
}
|
||||
// not using vlc - fall through to external handling
|
||||
break;
|
||||
case External:
|
||||
if (mFragment != null) mFragment.showSubLoadingMsg(true);
|
||||
|
||||
@@ -1145,7 +1153,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
public void endPlayback(Boolean closeActivity) {
|
||||
if (closeActivity) mFragment.getActivity().finish();
|
||||
if (closeActivity && mFragment != null) mFragment.getActivity().finish();
|
||||
stop();
|
||||
if (mVideoManager != null)
|
||||
mVideoManager.destroy();
|
||||
|
||||
@@ -24,12 +24,12 @@ class PlaybackOverlayActivity : FragmentActivity(R.layout.fragment_content_view)
|
||||
var keyListener: View.OnKeyListener? = null
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!validateAuthentication()) return
|
||||
|
||||
applyTheme()
|
||||
|
||||
// Workaround for Sony Bravia devices that show a "grey" background on HDR videos
|
||||
// Note: Should NOT be applied to the decorView as this introduces artifacts
|
||||
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
@@ -57,7 +57,7 @@ class PlaybackOverlayActivity : FragmentActivity(R.layout.fragment_content_view)
|
||||
if (keyListener?.onKey(currentFocus, keyCode, event) == true)
|
||||
return true
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
val frag = supportFragmentManager.fragments[0]
|
||||
if (frag is CustomPlaybackOverlayFragment) {
|
||||
frag.onKeyUp(keyCode, event)
|
||||
@@ -83,7 +83,7 @@ class PlaybackOverlayActivity : FragmentActivity(R.layout.fragment_content_view)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
event?.startTracking()
|
||||
return true
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class PlaybackOverlayActivity : FragmentActivity(R.layout.fragment_content_view)
|
||||
}
|
||||
|
||||
override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
val frag = supportFragmentManager.fragments[0]
|
||||
if (frag is CustomPlaybackOverlayFragment) {
|
||||
frag.onKeyLongPress(keyCode, event)
|
||||
|
||||
@@ -464,7 +464,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
}
|
||||
index += indexStartsAtOne ? (adjustByAdding ? -1 : 1) : 0;
|
||||
|
||||
return index < 0 || index >= allStreams.size() ? -1 : index;
|
||||
return index < 0 || index > allStreams.size() ? -1 : index;
|
||||
}
|
||||
|
||||
public boolean setSubtitleTrack(int index, @Nullable List<org.jellyfin.sdk.model.api.MediaStream> allStreams) {
|
||||
|
||||
@@ -27,12 +27,12 @@ class NextUpActivity : FragmentActivity(R.layout.fragment_content_view) {
|
||||
private val navigationRepository: NavigationRepository by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!validateAuthentication()) return
|
||||
|
||||
applyTheme()
|
||||
|
||||
val useExternalPlayer = intent.getBooleanExtra(EXTRA_USE_EXTERNAL_PLAYER, false)
|
||||
|
||||
// Observe state
|
||||
|
||||
@@ -33,6 +33,7 @@ class OptionsItemUserPicker(
|
||||
|
||||
private fun MutableList<RichListItem<UserSelection>>.add(
|
||||
behavior: UserSelectBehavior,
|
||||
serverId: UUID? = null,
|
||||
userId: UUID? = null,
|
||||
title: String,
|
||||
summary: String
|
||||
@@ -40,7 +41,8 @@ class OptionsItemUserPicker(
|
||||
RichListOption(
|
||||
UserSelection(
|
||||
behavior = behavior,
|
||||
userId = userId
|
||||
serverId = serverId,
|
||||
userId = userId,
|
||||
),
|
||||
title,
|
||||
summary
|
||||
@@ -70,6 +72,7 @@ class OptionsItemUserPicker(
|
||||
|
||||
for (user in users) add(
|
||||
behavior = UserSelectBehavior.SPECIFIC_USER,
|
||||
serverId = user.serverId,
|
||||
userId = user.id,
|
||||
title = user.name,
|
||||
summary = context.getString(
|
||||
@@ -111,7 +114,8 @@ class OptionsItemUserPicker(
|
||||
|
||||
data class UserSelection(
|
||||
val behavior: UserSelectBehavior,
|
||||
val userId: UUID?
|
||||
val serverId: UUID?,
|
||||
val userId: UUID?,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ class AuthPreferencesScreen : OptionsFragment() {
|
||||
from(
|
||||
authenticationPreferences,
|
||||
AuthenticationPreferences.autoLoginUserBehavior,
|
||||
AuthenticationPreferences.autoLoginUserId
|
||||
AuthenticationPreferences.autoLoginServerId,
|
||||
AuthenticationPreferences.autoLoginUserId,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,25 +118,28 @@ class AuthPreferencesScreen : OptionsFragment() {
|
||||
private fun OptionsBinder.Builder<OptionsItemUserPicker.UserSelection>.from(
|
||||
authenticationPreferences: AuthenticationPreferences,
|
||||
userBehaviorPreference: Preference<UserSelectBehavior>,
|
||||
serverIdPreference: Preference<String>,
|
||||
userIdPreference: Preference<String>,
|
||||
onSet: ((OptionsItemUserPicker.UserSelection) -> Unit)? = null,
|
||||
) {
|
||||
get {
|
||||
OptionsItemUserPicker.UserSelection(
|
||||
authenticationPreferences[userBehaviorPreference],
|
||||
authenticationPreferences[userIdPreference].toUUIDOrNull()
|
||||
authenticationPreferences[serverIdPreference].toUUIDOrNull(),
|
||||
authenticationPreferences[userIdPreference].toUUIDOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
set {
|
||||
authenticationPreferences[userBehaviorPreference] = it.behavior
|
||||
authenticationPreferences[serverIdPreference] = it.serverId?.toString().orEmpty()
|
||||
authenticationPreferences[userIdPreference] = it.userId?.toString().orEmpty()
|
||||
|
||||
onSet?.invoke(it)
|
||||
}
|
||||
|
||||
default {
|
||||
OptionsItemUserPicker.UserSelection(UserSelectBehavior.LAST_USER, null)
|
||||
OptionsItemUserPicker.UserSelection(UserSelectBehavior.LAST_USER, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ class LeanbackSearchFragment : SearchSupportFragment() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
backgroundService.value.attach(requireActivity())
|
||||
|
||||
// Create provider
|
||||
val searchProvider = SearchProvider(requireContext(), lifecycle)
|
||||
setSearchResultProvider(searchProvider)
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.jellyfin.androidtv.R
|
||||
@@ -15,8 +14,8 @@ class SearchFragment : Fragment(R.layout.fragment_content_view) {
|
||||
&& ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_DENIED
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Determine fragment to use
|
||||
val searchFragment = when {
|
||||
|
||||
@@ -30,8 +30,6 @@ class TextSearchFragment : Fragment(), TextWatcher, TextView.OnEditorActionListe
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
backgroundService.value.attach(requireActivity())
|
||||
|
||||
// Create provider
|
||||
searchProvider = SearchProvider(requireContext(), lifecycle)
|
||||
}
|
||||
|
||||
@@ -70,10 +70,10 @@ class StartupActivity : FragmentActivity(R.layout.fragment_content_view) {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
applyTheme()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
backgroundService.attach(this)
|
||||
|
||||
if (!intent.getBooleanExtra(EXTRA_HIDE_SPLASH, false)) showSplash()
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
import org.jellyfin.androidtv.auth.model.PrivateUser
|
||||
@@ -80,7 +81,8 @@ class ServerFragment : Fragment() {
|
||||
UserLoginFragment.ARG_USERNAME to user.name,
|
||||
))
|
||||
// Errors
|
||||
ServerUnavailableState -> Toast.makeText(context, R.string.server_connection_failed, Toast.LENGTH_LONG).show()
|
||||
ServerUnavailableState,
|
||||
is ApiClientErrorLoginState-> Toast.makeText(context, R.string.server_connection_failed, Toast.LENGTH_LONG).show()
|
||||
is ServerVersionNotSupported -> Toast.makeText(
|
||||
context,
|
||||
getString(R.string.server_unsupported, state.server.version, ServerRepository.minimumServerVersion.toString()),
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
import org.jellyfin.androidtv.auth.model.RequireSignInState
|
||||
@@ -72,7 +73,8 @@ class UserLoginCredentialsFragment : Fragment() {
|
||||
))
|
||||
AuthenticatingState -> binding.error.setText(R.string.login_authenticating)
|
||||
RequireSignInState -> binding.error.setText(R.string.login_invalid_credentials)
|
||||
ServerUnavailableState -> binding.error.setText(R.string.login_server_unavailable)
|
||||
ServerUnavailableState,
|
||||
is ApiClientErrorLoginState -> binding.error.setText(R.string.login_server_unavailable)
|
||||
// Do nothing because the activity will respond to the new session
|
||||
AuthenticatedState -> Unit
|
||||
// Not initialized
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
import org.jellyfin.androidtv.auth.model.ConnectedQuickConnectState
|
||||
@@ -66,7 +67,8 @@ class UserLoginQuickConnectFragment : Fragment() {
|
||||
))
|
||||
AuthenticatingState -> binding.error.setText(R.string.login_authenticating)
|
||||
RequireSignInState -> binding.error.setText(R.string.login_invalid_credentials)
|
||||
ServerUnavailableState -> binding.error.setText(R.string.login_server_unavailable)
|
||||
ServerUnavailableState,
|
||||
is ApiClientErrorLoginState -> binding.error.setText(R.string.login_server_unavailable)
|
||||
// Do nothing because the activity will respond to the new session
|
||||
AuthenticatedState -> Unit
|
||||
// Not initialized
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
package org.jellyfin.androidtv.util
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Spanned
|
||||
import androidx.core.text.HtmlCompat
|
||||
import org.jellyfin.androidtv.R
|
||||
|
||||
/**
|
||||
* Convert string with HTML to a [Spanned]. Uses the [HtmlCompat.FROM_HTML_MODE_COMPACT] flag.
|
||||
*/
|
||||
fun String.toHtmlSpanned(): Spanned = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
|
||||
/**
|
||||
* Utility to get the string for the "Load channels" button in the Live TV guide.
|
||||
*/
|
||||
fun getLoadChannelsLabel(context: Context, startNumber: String? = null, endNumber: String? = null) = buildString {
|
||||
append(context.getString(R.string.lbl_load_channels))
|
||||
|
||||
if (!startNumber.isNullOrBlank() && !endNumber.isNullOrBlank()) append("$startNumber - $endNumber")
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ class ExoPlayerProfile(
|
||||
add(Codec.Audio.PCM_MULAW)
|
||||
}.toTypedArray()
|
||||
|
||||
private val allSupportedAudioCodecsWithoutFFmpegExperimental = allSupportedAudioCodecs
|
||||
.filterNot { it == Codec.Audio.DCA || it == Codec.Audio.TRUEHD }
|
||||
.toTypedArray()
|
||||
|
||||
init {
|
||||
name = "AndroidTV-ExoPlayer"
|
||||
|
||||
@@ -68,7 +72,7 @@ class ExoPlayerProfile(
|
||||
}.joinToString(",")
|
||||
audioCodec = when {
|
||||
Utils.downMixAudio(context) -> downmixSupportedAudioCodecs
|
||||
else -> allSupportedAudioCodecs
|
||||
else -> allSupportedAudioCodecsWithoutFFmpegExperimental
|
||||
}.joinToString(",")
|
||||
protocol = "hls"
|
||||
copyTimestamps = false
|
||||
@@ -203,8 +207,9 @@ class ExoPlayerProfile(
|
||||
subtitleProfile(Codec.Subtitle.SUBRIP, SubtitleDeliveryMethod.Embed),
|
||||
subtitleProfile(Codec.Subtitle.ASS, SubtitleDeliveryMethod.Encode),
|
||||
subtitleProfile(Codec.Subtitle.SSA, SubtitleDeliveryMethod.Encode),
|
||||
subtitleProfile(Codec.Subtitle.PGS, SubtitleDeliveryMethod.Encode),
|
||||
subtitleProfile(Codec.Subtitle.PGSSUB, SubtitleDeliveryMethod.Encode),
|
||||
subtitleProfile(Codec.Subtitle.PGS, SubtitleDeliveryMethod.Embed),
|
||||
subtitleProfile(Codec.Subtitle.PGSSUB, SubtitleDeliveryMethod.Embed),
|
||||
subtitleProfile(Codec.Subtitle.DVBSUB, SubtitleDeliveryMethod.Embed),
|
||||
subtitleProfile(Codec.Subtitle.DVDSUB, SubtitleDeliveryMethod.Encode),
|
||||
subtitleProfile(Codec.Subtitle.VTT, SubtitleDeliveryMethod.Embed),
|
||||
subtitleProfile(Codec.Subtitle.SUB, SubtitleDeliveryMethod.Embed),
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
<string name="pref_theme_emerald">Classic Emerald</string>
|
||||
<string name="pref_video_player_auto">Automatically choose</string>
|
||||
<string name="pref_video_player_exoplayer">ExoPlayer</string>
|
||||
<string name="pref_video_player_vlc">LibVLC</string>
|
||||
<string name="pref_video_player_vlc">LibVLC (experimental)</string>
|
||||
<string name="pref_video_player_external">External app</string>
|
||||
<string name="pref_video_player_choose">Always ask</string>
|
||||
<string name="pref_about_title">About</string>
|
||||
|
||||
@@ -24,12 +24,12 @@ androidx-window = "1.0.0"
|
||||
androidx-work = "2.7.1"
|
||||
blurhash = "0.1.0"
|
||||
detekt = "1.21.0"
|
||||
exoplayer = "2.18.2"
|
||||
exoplayer = "2.18.6"
|
||||
glide = "4.14.2"
|
||||
gson = "2.8.9"
|
||||
jellyfin-apiclient = "v0.7.10"
|
||||
jellyfin-exoplayer-ffmpegextension = "2.18.2+1"
|
||||
jellyfin-sdk = "1.4.0"
|
||||
jellyfin-sdk = "1.4.1"
|
||||
junit = "4.13.2"
|
||||
kenburnsview = "1.0.7"
|
||||
koin = "3.3.0"
|
||||
|
||||
Reference in New Issue
Block a user