Compare commits

...

32 Commits

Author SHA1 Message Date
Niels van Velzen
834bebb03f Add DVB subtitle support in ExoPlayer
(cherry picked from commit 93979e1d38)
2023-04-23 15:40:58 +02:00
Niels van Velzen
a001846da7 Enable PGS subtitles in ExoPlayer
(cherry picked from commit d63ac02525)
2023-04-23 10:58:46 +02:00
Niels van Velzen
5c7bf941ea Fix switching ExoPlayer subtitle track
(cherry picked from commit e903d8c347)
2023-04-23 10:58:44 +02:00
Niels van Velzen
53b92184a4 Fix offsetStreamIndex when selecting last track
(cherry picked from commit f862a17223)
2023-04-23 10:58:43 +02:00
renovate[bot]
1e369480fd Update dependency com.google.android.exoplayer:exoplayer to v2.18.6
(cherry picked from commit 4699454d5d)
2023-04-23 10:58:26 +02:00
Niels van Velzen
e00f6d3f40 Fix app crash when playback stop is requested without an active player
(cherry picked from commit f99451d22e)
2023-04-23 10:55:33 +02:00
Niels van Velzen
5e85a876bd Add F-Droid badge to README
(cherry picked from commit 1b44358f10)
2023-04-03 21:04:43 +02:00
Niels van Velzen
5249209f3a Fix "null" on Live TV guide "load channels" button 2023-04-03 19:32:56 +02:00
Niels van Velzen
276e138d7a Set screenOrientation for PreferencesActivity
(cherry picked from commit fb651b173c)
2023-04-03 19:31:41 +02:00
Niels van Velzen
00cc8f14ec Fix app closing when music queue ends
(cherry picked from commit 25e8738818)
2023-04-03 19:31:36 +02:00
Niels van Velzen
f14a8fa6b8 Store accounts in Account Manager by address
(cherry picked from commit 6806f69c62)
2023-04-03 19:31:23 +02:00
Niels van Velzen
6191044057 Always check server id when retrieving user data from AccountManagerStore
(cherry picked from commit 6712eeb781)
2023-03-12 10:34:04 +01:00
Niels van Velzen
d0365ced7e Fix SearchFragment not restoring properly
(cherry picked from commit 0a30b50904)
2023-03-12 10:34:03 +01:00
Niels van Velzen
9c8143368f Add workaround for non reproducible baseline profiles in AGP
(cherry picked from commit eada9ea21c)
2023-03-12 10:34:03 +01:00
omm
266d5738a6 Add IncludeType param when sending number query
(cherry picked from commit 68f6b3a3ef)
2023-03-12 10:34:03 +01:00
renovate[bot]
e34442a432 Update dependency com.google.android.exoplayer:exoplayer to v2.18.4
(cherry picked from commit 7a5ee0b229)
2023-03-12 10:34:02 +01:00
David Fairbrother
12e4c96304 Accept KEYCODE_ENTER as alt to KEYCODE_CENTER
Adds KEYCODE_ENTER wherever we accept KEYCODE_DPAD_CENTER. This was
reported in #2526 where third-party remotes will use the former instead
of the latter value.

I've added this to all places we accept CENTER, since our most of our
Kotlin code already accepts this as an alternative and I can't forsee a
case where we would want these to accept different things.

This was tested in the emulator to ensure we've not broken the existing
mapping, but short of using the enter key (which works) I can't verify
this fixes the original issue.

(cherry picked from commit b3bc4b0d8c)
2023-03-12 10:33:34 +01:00
Niels van Velzen
857ad84771 Add missing mediaManager injection to EnhancedBrowseFragment 2023-02-26 17:10:48 +01:00
Niels van Velzen
7dfd3b9179 Mark LibVLC as experimental
(cherry picked from commit 74a7898fcd)
2023-02-26 17:08:16 +01:00
Niels van Velzen
63de8d16d2 Fix theme not applied before creating views
(cherry picked from commit 328072a36c)
2023-02-26 16:59:46 +01:00
Niels van Velzen
b771bb4c27 Only attach background service in activities
(cherry picked from commit bbc7f3eea4)
2023-02-26 16:59:42 +01:00
Niels van Velzen
2b01ac9ffc Don't show "invalid credentials" message when authentication fails with an SDK exception
(cherry picked from commit 84dc7fc48d)
2023-02-26 16:59:34 +01:00
renovate[bot]
b857fb4f1c Update dependency com.google.android.exoplayer:exoplayer to v2.18.3
(cherry picked from commit f10cbc05d7)
2023-02-26 16:59:24 +01:00
Timmeh
19707eb9c8 Fix old items are shown when new filter is applied. (#2472)
* clear rows after applying new filter.

* Always set current item to toolbar if current item is filtered out.

(cherry picked from commit 215b0e9f2c)
2023-02-26 16:58:34 +01:00
Niels van Velzen
c60d210e87 Fix rare picture viewer crash when album is empty
(cherry picked from commit e3d5262b5b)
2023-02-26 16:58:16 +01:00
Niels van Velzen
cb046c26c5 Change opening menu to ACTION_UP instead of ACTION_DOWN
(cherry picked from commit 22b4ecf0ec)
2023-01-24 21:23:20 +01:00
Niels van Velzen
3d97b5abb0 Use HEADER_ACCEPT from SDK as Glide Accept header value
(cherry picked from commit a7b06d39e1)
2023-01-24 21:23:18 +01:00
Niels van Velzen
da8c7c2837 Set Accept header in Glide requests
(cherry picked from commit 3839d1c5d1)
2023-01-24 21:23:17 +01:00
renovate[bot]
d9c11d86ad Update dependency org.jellyfin.sdk:jellyfin-core to v1.4.1
(cherry picked from commit 80a2dd5ac1)
2023-01-24 21:23:15 +01:00
Niels van Velzen
b9172845d8 Increase amount of logs added to crash reports
Some stacktraces with Kotlin coroutines can get big. Right now we have 100 lines and this often is not enough to see what happened before the exception. Increasing the count to 250 should hopefully fix that.

(cherry picked from commit 5b83a6de9a)
2023-01-24 21:23:15 +01:00
Niels van Velzen
3d3fa76771 Remove experimental audio codecs from transcoding profile
(cherry picked from commit 3c70b8abf5)
2023-01-10 13:45:37 +01:00
Niels van Velzen
521183d427 Fix BrowseGridFragment focus not restoring when going back
(cherry picked from commit b5b87fb625)
2023-01-10 13:45:36 +01:00
45 changed files with 257 additions and 120 deletions

View File

@@ -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>

View 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, "")
}
}
}
}
}

View File

@@ -6,6 +6,9 @@ plugins {
alias(libs.plugins.aboutlibraries)
}
// Apply workaround
apply("baselineWorkaround.gradle")
android {
namespace = "org.jellyfin.androidtv"
compileSdk = 33

View File

@@ -138,6 +138,7 @@
<activity
android:name=".ui.preference.PreferencesActivity"
android:screenOrientation="behind"
android:theme="@style/Theme.Jellyfin.Preferences" />
<!-- Playback related activities -->

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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", "")
}
}
}
}

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)
}
}
}
}

View File

@@ -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));
}

View File

@@ -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

View File

@@ -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());

View File

@@ -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
}

View File

@@ -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());
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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);

View File

@@ -158,7 +158,6 @@ public class ItemListFragment extends Fragment implements View.OnKeyListener {
}
});
backgroundService.getValue().attach(requireActivity());
return binding.getRoot();
}

View File

@@ -446,6 +446,7 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
default:
mQuery.setFilters(mFilters != null ? mFilters.getFilters() : null);
}
removeRow();
}
public void setPosition(int pos) {

View File

@@ -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");

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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()));

View File

@@ -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();

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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

View File

@@ -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?,
)
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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()),

View File

@@ -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

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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),

View File

@@ -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>

View File

@@ -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"