Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6191044057 | ||
|
|
d0365ced7e | ||
|
|
9c8143368f | ||
|
|
266d5738a6 | ||
|
|
e34442a432 | ||
|
|
12e4c96304 | ||
|
|
857ad84771 | ||
|
|
7dfd3b9179 | ||
|
|
63de8d16d2 | ||
|
|
b771bb4c27 | ||
|
|
2b01ac9ffc | ||
|
|
b857fb4f1c | ||
|
|
19707eb9c8 | ||
|
|
c60d210e87 | ||
|
|
cb046c26c5 | ||
|
|
3d97b5abb0 | ||
|
|
da8c7c2837 | ||
|
|
d9c11d86ad | ||
|
|
b9172845d8 | ||
|
|
3d3fa76771 | ||
|
|
521183d427 |
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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +180,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
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 +197,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,6 +23,13 @@ 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(),
|
||||
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
|
||||
@@ -31,7 +39,8 @@ 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) {
|
||||
@@ -59,12 +68,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 +82,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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -300,6 +300,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 +310,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 +327,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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.4"
|
||||
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