Compare commits
58 Commits
master
...
release-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddc193086d | ||
|
|
d9e99cbb0b | ||
|
|
3d3e2e5333 | ||
|
|
c160feea59 | ||
|
|
1a8d821622 | ||
|
|
bf4fa8ea5d | ||
|
|
df561f88f5 | ||
|
|
20f62243aa | ||
|
|
027d1b1010 | ||
|
|
d565617e0b | ||
|
|
208dc4feb9 | ||
|
|
3c4613c63f | ||
|
|
b6605958b1 | ||
|
|
57730b75e2 | ||
|
|
74fe6658cc | ||
|
|
a8d45cecd7 | ||
|
|
6994234bd0 | ||
|
|
1ccfa7eae6 | ||
|
|
7749e401ad | ||
|
|
b2b60bf78a | ||
|
|
e2ede5b7be | ||
|
|
985e4ef9a7 | ||
|
|
7d5895134b | ||
|
|
dc5bf6b47d | ||
|
|
147fc4f772 | ||
|
|
ecead3fad8 | ||
|
|
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
|
||||
@@ -69,12 +70,12 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
|
||||
Timber.d("Authenticating user %s", user)
|
||||
Timber.i("Authenticating user %s", user)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -116,7 +121,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
|
||||
val accessToken = result.accessToken ?:return@flow emit(RequireSignInState)
|
||||
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
|
||||
val userInfo = result.user ?: return@flow emit(RequireSignInState)
|
||||
val user = PrivateUser(
|
||||
id = userInfo.id,
|
||||
@@ -130,8 +135,12 @@ class AuthenticationRepositoryImpl(
|
||||
|
||||
authenticateFinish(server, userInfo, accessToken)
|
||||
val success = setActiveSession(user, server)
|
||||
if (success) emit(AuthenticatedState)
|
||||
else emit(RequireSignInState)
|
||||
if (success) {
|
||||
emit(AuthenticatedState)
|
||||
} else {
|
||||
Timber.w("Failed to set active session after authenticating")
|
||||
emit(RequireSignInState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateToken(server: Server, user: User) = flow {
|
||||
@@ -145,9 +154,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 +179,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 +207,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -83,12 +83,12 @@ class ServerRepositoryImpl(
|
||||
|
||||
// Mutating data
|
||||
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
|
||||
Timber.d("Adding server %s", address)
|
||||
Timber.i("Adding server %s", address)
|
||||
|
||||
emit(ConnectingState(address))
|
||||
|
||||
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
|
||||
Timber.d("Found ${addressCandidates.size} candidates")
|
||||
Timber.i("Found ${addressCandidates.size} candidates")
|
||||
|
||||
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val badRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -64,33 +64,37 @@ class SessionRepositoryImpl(
|
||||
override val state = _state.asStateFlow()
|
||||
|
||||
override suspend fun restoreSession(): Unit = currentSessionMutex.withLock {
|
||||
Timber.d("Restoring session")
|
||||
Timber.i("Restoring session")
|
||||
_state.value = SessionRepositoryState.RESTORING_SESSION
|
||||
|
||||
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
|
||||
if (currentSession.value?.userId == userId) {
|
||||
Timber.d("Current session user is the same as the requested user")
|
||||
return false
|
||||
}
|
||||
|
||||
_state.value = SessionRepositoryState.SWITCHING_SESSION
|
||||
Timber.d("Switching current session to user $userId")
|
||||
Timber.i("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")
|
||||
Timber.w("Could not switch to non-existing session for user $userId")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -100,7 +104,7 @@ class SessionRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun destroyCurrentSession() {
|
||||
Timber.d("Destroying current session")
|
||||
Timber.i("Destroying current session")
|
||||
|
||||
userRepository.updateCurrentUser(null)
|
||||
_currentSession.value = null
|
||||
@@ -115,6 +119,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
|
||||
@@ -125,7 +130,7 @@ class SessionRepositoryImpl(
|
||||
// Update session after binding the apiclient settings
|
||||
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
|
||||
val success = apiBinder.updateSession(session, deviceInfo)
|
||||
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||
|
||||
if (success) {
|
||||
userApiClient.applySession(session, deviceInfo)
|
||||
@@ -156,13 +161,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"
|
||||
|
||||
@@ -25,4 +25,5 @@ enum class QueryType {
|
||||
LatestItems,
|
||||
SeriesTimer,
|
||||
Premieres,
|
||||
Resume,
|
||||
}
|
||||
|
||||
@@ -141,9 +141,8 @@ class SocketHandler(
|
||||
PlaystateCommand.SEEK -> playbackController?.seek(
|
||||
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
|
||||
)
|
||||
// FIXME get rewind/forward amount from displayprefs
|
||||
PlaystateCommand.REWIND -> playbackController?.skip(REWIND_MS)
|
||||
PlaystateCommand.FAST_FORWARD -> playbackController?.skip(FORWARD_MS)
|
||||
PlaystateCommand.REWIND -> playbackController?.rewind()
|
||||
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +179,5 @@ class SocketHandler(
|
||||
|
||||
companion object {
|
||||
const val TICKS_TO_MS = 10000L
|
||||
const val REWIND_MS = -11000
|
||||
const val FORWARD_MS = 30000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ package org.jellyfin.androidtv.data.model
|
||||
data class AppNotification(
|
||||
val message: String,
|
||||
val dismiss: () -> Unit,
|
||||
val public: Boolean,
|
||||
)
|
||||
|
||||
@@ -34,8 +34,8 @@ class NotificationsRepositoryImpl(
|
||||
addBetaNotification()
|
||||
}
|
||||
|
||||
private fun addNotification(message: String, dismiss: () -> Unit = {}) {
|
||||
notifications.value = notifications.value + AppNotification(message, dismiss)
|
||||
private fun addNotification(message: String, public: Boolean = false, dismiss: () -> Unit = {}) {
|
||||
notifications.value = notifications.value + AppNotification(message, dismiss, public)
|
||||
}
|
||||
|
||||
private fun addUiModeNotification() {
|
||||
@@ -45,7 +45,7 @@ class NotificationsRepositoryImpl(
|
||||
val hasHdmiCec = context.packageManager.hasSystemFeature("android.hardware.hdmi.cec")
|
||||
|
||||
if (invalidUiMode && isTouch && !hasHdmiCec) {
|
||||
addNotification(context.getString(R.string.app_notification_uimode_invalid))
|
||||
addNotification(context.getString(R.string.app_notification_uimode_invalid), public = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -937,15 +915,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
|
||||
private void refreshCurrentItem() {
|
||||
if (mediaManager.getValue().getCurrentMediaPosition() >= 0) {
|
||||
mCurrentItem = mediaManager.getValue().getCurrentMediaItem();
|
||||
|
||||
if (mGridPresenter instanceof HorizontalGridPresenter)
|
||||
((HorizontalGridPresenter) mGridPresenter).setPosition(mediaManager.getValue().getCurrentMediaPosition());
|
||||
// Don't do anything for vertical grids as the presenter does not allow setting the position
|
||||
|
||||
mediaManager.getValue().setCurrentMediaPosition(-1); // re-set so it doesn't mess with parent views
|
||||
}
|
||||
if (mCurrentItem != null && mCurrentItem.getBaseItemType() != BaseItemKind.PHOTO && mCurrentItem.getBaseItemType() != BaseItemKind.PHOTO_ALBUM
|
||||
&& mCurrentItem.getBaseItemType() != BaseItemKind.MUSIC_ARTIST && mCurrentItem.getBaseItemType() != BaseItemKind.MUSIC_ALBUM) {
|
||||
Timber.d("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
|
||||
@@ -958,8 +927,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 @@ import org.jellyfin.apiclient.model.querying.PersonsQuery;
|
||||
import org.jellyfin.apiclient.model.querying.SeasonQuery;
|
||||
import org.jellyfin.apiclient.model.querying.SimilarItemsQuery;
|
||||
import org.jellyfin.apiclient.model.querying.UpcomingEpisodesQuery;
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest;
|
||||
|
||||
public class BrowseRowDef {
|
||||
private String headerText;
|
||||
@@ -33,6 +34,7 @@ public class BrowseRowDef {
|
||||
|
||||
private ArtistsQuery artistsQuery;
|
||||
private SeasonQuery seasonQuery;
|
||||
private GetResumeItemsRequest resumeQuery;
|
||||
private QueryType queryType;
|
||||
|
||||
private int chunkSize = 0;
|
||||
@@ -174,6 +176,16 @@ public class BrowseRowDef {
|
||||
this.queryType = QueryType.Views;
|
||||
}
|
||||
|
||||
public BrowseRowDef(String header, GetResumeItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
|
||||
headerText = header;
|
||||
this.resumeQuery = query;
|
||||
this.chunkSize = chunkSize;
|
||||
this.queryType = QueryType.Resume;
|
||||
this.staticHeight = staticHeight;
|
||||
this.preferParentThumb = preferParentThumb;
|
||||
this.changeTriggers = changeTriggers;
|
||||
}
|
||||
|
||||
public int getChunkSize() {
|
||||
return chunkSize;
|
||||
}
|
||||
@@ -230,6 +242,8 @@ public class BrowseRowDef {
|
||||
|
||||
public SeriesTimerQuery getSeriesTimerQuery() { return seriesTimerQuery; }
|
||||
|
||||
public GetResumeItemsRequest getResumeQuery() { return resumeQuery; }
|
||||
|
||||
public ChangeTriggerType[] getChangeTriggers() {
|
||||
return changeTriggers;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -66,25 +66,31 @@ class HomeFragment : Fragment(R.layout.fragment_home) {
|
||||
}
|
||||
|
||||
private fun setUserImage(image: String?) {
|
||||
Glide.with(requireContext())
|
||||
Glide.with(this)
|
||||
.load(image)
|
||||
.placeholder(R.drawable.ic_switch_users)
|
||||
.centerInside()
|
||||
.circleCrop()
|
||||
.into(object : CustomViewTarget<ImageButton, Drawable>(binding.switchUsers) {
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
binding.switchUsers.imageTintMode = PorterDuff.Mode.SRC_IN
|
||||
binding.switchUsers.setImageDrawable(errorDrawable)
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
binding.switchUsers.imageTintMode = PorterDuff.Mode.SRC_IN
|
||||
binding.switchUsers.setImageDrawable(errorDrawable)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
binding.switchUsers.imageTintMode = null
|
||||
binding.switchUsers.setImageDrawable(resource)
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
binding.switchUsers.imageTintMode = null
|
||||
binding.switchUsers.setImageDrawable(resource)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResourceCleared(placeholder: Drawable?) {
|
||||
binding.switchUsers.imageTintMode = PorterDuff.Mode.SRC_IN
|
||||
binding.switchUsers.setImageDrawable(placeholder)
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
binding.switchUsers.imageTintMode = PorterDuff.Mode.SRC_IN
|
||||
binding.switchUsers.setImageDrawable(placeholder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class HomeFragmentBrowseRowDefRow(
|
||||
QueryType.LiveTvChannel -> ItemRowAdapter(context, browseRowDef.tvChannelQuery, 40, cardPresenter, rowsAdapter)
|
||||
QueryType.LiveTvProgram -> ItemRowAdapter(context, browseRowDef.programQuery, cardPresenter, rowsAdapter)
|
||||
QueryType.LiveTvRecording -> ItemRowAdapter(context, browseRowDef.recordingQuery, browseRowDef.chunkSize, cardPresenter, rowsAdapter)
|
||||
QueryType.Resume -> ItemRowAdapter(context, browseRowDef.resumeQuery, browseRowDef.chunkSize, browseRowDef.preferParentThumb, browseRowDef.isStaticHeight, cardPresenter, rowsAdapter)
|
||||
else -> ItemRowAdapter(context, browseRowDef.query, browseRowDef.chunkSize, browseRowDef.preferParentThumb, browseRowDef.isStaticHeight, cardPresenter, rowsAdapter, browseRowDef.queryType)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,16 @@ import android.content.Context
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.constant.ChangeTriggerType
|
||||
import org.jellyfin.androidtv.data.querying.StdItemQuery
|
||||
import org.jellyfin.androidtv.data.querying.ViewQuery
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef
|
||||
import org.jellyfin.apiclient.model.entities.LocationType
|
||||
import org.jellyfin.apiclient.model.entities.SortOrder
|
||||
import org.jellyfin.apiclient.model.livetv.RecommendedProgramQuery
|
||||
import org.jellyfin.apiclient.model.livetv.RecordingQuery
|
||||
import org.jellyfin.apiclient.model.querying.ItemFields
|
||||
import org.jellyfin.apiclient.model.querying.ItemFilter
|
||||
import org.jellyfin.apiclient.model.querying.NextUpQuery
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.constant.MediaType
|
||||
import org.jellyfin.sdk.model.api.ItemFields as SdkItemFields
|
||||
|
||||
class HomeFragmentHelper(
|
||||
private val context: Context,
|
||||
@@ -31,29 +28,31 @@ class HomeFragmentHelper(
|
||||
return HomeFragmentBrowseRowDefRow(BrowseRowDef(context.getString(R.string.lbl_my_media), ViewQuery))
|
||||
}
|
||||
|
||||
fun loadResume(title: String, includeMediaTypes: Array<String>): HomeFragmentRow {
|
||||
val query = StdItemQuery().apply {
|
||||
mediaTypes = includeMediaTypes
|
||||
recursive = true
|
||||
imageTypeLimit = 1
|
||||
enableTotalRecordCount = false
|
||||
collapseBoxSetItems = false
|
||||
excludeLocationTypes = arrayOf(LocationType.Virtual)
|
||||
limit = ITEM_LIMIT_RESUME
|
||||
filters = arrayOf(ItemFilter.IsResumable)
|
||||
sortBy = arrayOf(ItemSortBy.DatePlayed)
|
||||
sortOrder = SortOrder.Descending
|
||||
}
|
||||
fun loadResume(title: String, includeMediaTypes: List<String>): HomeFragmentRow {
|
||||
val query = GetResumeItemsRequest(
|
||||
userId = userRepository.currentUser.value!!.id,
|
||||
limit = ITEM_LIMIT_RESUME,
|
||||
fields = listOf(
|
||||
SdkItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
SdkItemFields.OVERVIEW,
|
||||
SdkItemFields.ITEM_COUNTS,
|
||||
SdkItemFields.DISPLAY_PREFERENCES_ID,
|
||||
SdkItemFields.CHILD_COUNT,
|
||||
),
|
||||
imageTypeLimit = 1,
|
||||
enableTotalRecordCount = false,
|
||||
mediaTypes = includeMediaTypes,
|
||||
)
|
||||
|
||||
return HomeFragmentBrowseRowDefRow(BrowseRowDef(title, query, 0, false, true, arrayOf(ChangeTriggerType.VideoQueueChange, ChangeTriggerType.TvPlayback, ChangeTriggerType.MoviePlayback)))
|
||||
}
|
||||
|
||||
fun loadResumeVideo(): HomeFragmentRow {
|
||||
return loadResume(context.getString(R.string.lbl_continue_watching), arrayOf(MediaType.Video))
|
||||
return loadResume(context.getString(R.string.lbl_continue_watching), listOf(MediaType.Video))
|
||||
}
|
||||
|
||||
fun loadResumeAudio(): HomeFragmentRow {
|
||||
return loadResume(context.getString(R.string.lbl_continue_watching), arrayOf(MediaType.Audio))
|
||||
return loadResume(context.getString(R.string.lbl_continue_watching), listOf(MediaType.Audio))
|
||||
}
|
||||
|
||||
fun loadLatestLiveTvRecordings(): HomeFragmentRow {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
private int BUTTON_SIZE;
|
||||
|
||||
private TextUnderButton mResumeButton;
|
||||
private TextUnderButton mVersionsButton;
|
||||
private TextUnderButton mPrevButton;
|
||||
private TextUnderButton mRecordButton;
|
||||
private TextUnderButton mRecSeriesButton;
|
||||
@@ -156,8 +157,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
private BaseItemDto mBaseItem;
|
||||
|
||||
private ArrayList<MediaSourceInfo> versions;
|
||||
private int selectedVersionPopupIndex = 0;
|
||||
|
||||
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
|
||||
private Lazy<org.jellyfin.sdk.api.client.ApiClient> api = inject(org.jellyfin.sdk.api.client.ApiClient.class);
|
||||
private Lazy<UserPreferences> userPreferences = inject(UserPreferences.class);
|
||||
@@ -175,7 +174,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);
|
||||
@@ -1020,7 +1018,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
}
|
||||
//Video versions button
|
||||
if (mBaseItem.getMediaSources() != null && mBaseItem.getMediaSources().size() > 1) {
|
||||
TextUnderButton versionsButton = TextUnderButton.create(requireContext(), R.drawable.ic_guide, buttonSize, 0, getString(R.string.select_version), new View.OnClickListener() {
|
||||
mVersionsButton = TextUnderButton.create(requireContext(), R.drawable.ic_guide, buttonSize, 0, getString(R.string.select_version), new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (versions != null ) {
|
||||
@@ -1031,7 +1029,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
}
|
||||
}
|
||||
});
|
||||
mDetailsOverviewRow.addAction(versionsButton);
|
||||
mDetailsOverviewRow.addAction(mVersionsButton);
|
||||
}
|
||||
|
||||
if (TrailerUtils.hasPlayableTrailers(requireContext(), ModelCompat.asSdk(mBaseItem))) {
|
||||
@@ -1390,20 +1388,24 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
|
||||
for (int i = 0; i< versions.size(); i++) {
|
||||
MenuItem item = menu.getMenu().add(Menu.NONE, i, Menu.NONE, versions.get(i).getName());
|
||||
item.setChecked(i == selectedVersionPopupIndex);
|
||||
item.setChecked(i == mDetailsOverviewRow.getSelectedMediaSourceIndex());
|
||||
}
|
||||
|
||||
menu.getMenu().setGroupCheckable(0,true,false);
|
||||
menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
|
||||
@Override
|
||||
public boolean onMenuItemClick(MenuItem menuItem) {
|
||||
selectedVersionPopupIndex = menuItem.getItemId();
|
||||
apiClient.getValue().GetItemAsync(versions.get(selectedVersionPopupIndex).getId(), KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new LifecycleAwareResponse<BaseItemDto>(getLifecycle()) {
|
||||
mDetailsOverviewRow.setSelectedMediaSourceIndex(menuItem.getItemId());
|
||||
apiClient.getValue().GetItemAsync(versions.get(mDetailsOverviewRow.getSelectedMediaSourceIndex()).getId(), KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new LifecycleAwareResponse<BaseItemDto>(getLifecycle()) {
|
||||
@Override
|
||||
public void onResponse(BaseItemDto response) {
|
||||
if (!getActive()) return;
|
||||
|
||||
mBaseItem = response;
|
||||
mDorPresenter.getViewHolder().setItem(mDetailsOverviewRow);
|
||||
if (mVersionsButton != null) {
|
||||
mVersionsButton.requestFocus();
|
||||
}
|
||||
}
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -158,7 +158,6 @@ public class ItemListFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
});
|
||||
|
||||
backgroundService.getValue().attach(requireActivity());
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class MyDetailsOverviewRow @JvmOverloads constructor(
|
||||
var infoItem1: InfoItem? = null,
|
||||
var infoItem2: InfoItem? = null,
|
||||
var infoItem3: InfoItem? = null,
|
||||
var selectedMediaSourceIndex: Int = 0,
|
||||
) : Row() {
|
||||
private val _actions = mutableListOf<TextUnderButton>()
|
||||
val actions get() = _actions.toList()
|
||||
|
||||
@@ -72,7 +72,16 @@ open class BaseRowItem protected constructor(
|
||||
baseItem = item.asSdk(),
|
||||
)
|
||||
|
||||
constructor(item: org.jellyfin.sdk.model.api.BaseItemDto) : this(
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
item: org.jellyfin.sdk.model.api.BaseItemDto,
|
||||
index: Int = 0,
|
||||
preferParentThumb: Boolean = false,
|
||||
staticHeight: Boolean = false,
|
||||
) : this(
|
||||
index = index,
|
||||
preferParentThumb = preferParentThumb,
|
||||
staticHeight = staticHeight,
|
||||
baseRowType = when (item.type) {
|
||||
BaseItemKind.PROGRAM -> BaseRowType.LiveTvProgram
|
||||
BaseItemKind.RECORDING -> BaseRowType.LiveTvRecording
|
||||
|
||||
@@ -133,7 +133,7 @@ public class ItemLauncher {
|
||||
KoinJavaComponent.<MediaManager>get(MediaManager.class).setCurrentMediaPosition(pos);
|
||||
|
||||
navigationRepository.navigate(Destinations.INSTANCE.pictureViewer(
|
||||
mediaManager.getCurrentMediaItem().getBaseItem().getId(),
|
||||
baseItem.getId(),
|
||||
false,
|
||||
mediaManager.getCurrentMediaAdapter().getSortBy(),
|
||||
mediaManager.getCurrentMediaAdapter().getSortOrder()
|
||||
|
||||
@@ -62,6 +62,7 @@ import org.jellyfin.apiclient.model.search.SearchQuery;
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson;
|
||||
import org.jellyfin.sdk.model.api.SortOrder;
|
||||
import org.jellyfin.sdk.model.api.UserDto;
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest;
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
@@ -90,6 +91,7 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
private ArtistsQuery mArtistsQuery;
|
||||
private LatestItemsQuery mLatestQuery;
|
||||
private SeriesTimerQuery mSeriesTimerQuery;
|
||||
private GetResumeItemsRequest resumeQuery;
|
||||
private QueryType queryType;
|
||||
|
||||
private String mSortBy;
|
||||
@@ -120,6 +122,7 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
private boolean staticHeight = false;
|
||||
|
||||
private final Lazy<ApiClient> apiClient = inject(ApiClient.class);
|
||||
private final Lazy<org.jellyfin.sdk.api.client.ApiClient> api = inject(org.jellyfin.sdk.api.client.ApiClient.class);
|
||||
private final Lazy<UserViewsRepository> userViewsRepository = inject(UserViewsRepository.class);
|
||||
private Context context;
|
||||
|
||||
@@ -388,6 +391,17 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
staticHeight = true;
|
||||
}
|
||||
|
||||
public ItemRowAdapter(Context context, GetResumeItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, Presenter presenter, MutableObjectAdapter<Row> parent) {
|
||||
super(presenter);
|
||||
this.context = context;
|
||||
mParent = parent;
|
||||
resumeQuery = query;
|
||||
this.chunkSize = chunkSize;
|
||||
this.preferParentThumb = preferParentThumb;
|
||||
this.staticHeight = staticHeight;
|
||||
this.queryType = QueryType.Resume;
|
||||
}
|
||||
|
||||
public void setItemsLoaded(int itemsLoaded) {
|
||||
this.itemsLoaded = itemsLoaded;
|
||||
this.fullyLoaded = chunkSize == 0 || itemsLoaded >= totalItems;
|
||||
@@ -446,6 +460,7 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
default:
|
||||
mQuery.setFilters(mFilters != null ? mFilters.getFilters() : null);
|
||||
}
|
||||
removeRow();
|
||||
}
|
||||
|
||||
public void setPosition(int pos) {
|
||||
@@ -706,6 +721,9 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
case SeriesTimer:
|
||||
retrieve(mSeriesTimerQuery);
|
||||
break;
|
||||
case Resume:
|
||||
ItemRowAdapterHelperKt.retrieveResumeItems(this, api.getValue(), resumeQuery);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package org.jellyfin.androidtv.ui.itemhandling
|
||||
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import timber.log.Timber
|
||||
|
||||
fun <T : Any> ItemRowAdapter.setItems(
|
||||
@@ -29,3 +35,16 @@ fun <T : Any> ItemRowAdapter.setItems(
|
||||
replaceAll(allItems)
|
||||
itemsLoaded = allItems.size
|
||||
}
|
||||
|
||||
fun ItemRowAdapter.retrieveResumeItems(api: ApiClient, query: GetResumeItemsRequest) {
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch {
|
||||
val response by api.itemsApi.getResumeItems(query)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty().toTypedArray(),
|
||||
transform = { item, i -> BaseRowItem(item, i, preferParentThumb, isStaticHeight) }
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class PictureViewerFragment : Fragment(), View.OnKeyListener {
|
||||
@@ -41,7 +41,7 @@ class PictureViewerFragment : Fragment(), View.OnKeyListener {
|
||||
private val AUTO_HIDE_ACTIONS_DURATION = 4.seconds
|
||||
}
|
||||
|
||||
private val pictureViewerViewModel by sharedViewModel<PictureViewerViewModel>()
|
||||
private val pictureViewerViewModel by viewModel<PictureViewerViewModel>()
|
||||
private val api by inject<ApiClient>()
|
||||
private lateinit var binding: FragmentPictureViewerBinding
|
||||
|
||||
|
||||
@@ -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()));
|
||||
@@ -1521,11 +1524,13 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
// Encode whitespace as html entities
|
||||
final String htmlText = text
|
||||
// Encode whitespace as html entities
|
||||
.replaceAll("\\r\\n", "<br>")
|
||||
.replaceAll("\\n", "<br>")
|
||||
.replaceAll("\\\\h", " ");
|
||||
.replaceAll("\\\\h", " ")
|
||||
// Remove SSA tags
|
||||
.replaceAll("\\{\\\\.*?\\}", "");
|
||||
|
||||
final SpannableString span = new SpannableString(TextUtilsKt.toHtmlSpanned(htmlText));
|
||||
if (subtitlesBackgroundEnabled) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.text.InputType;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory;
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
@@ -288,7 +289,13 @@ public class MediaManager {
|
||||
if (DeviceUtils.is60()) {
|
||||
Timber.i("creating audio player using: exoplayer");
|
||||
nativeMode = true;
|
||||
mExoPlayer = new ExoPlayer.Builder(context).build();
|
||||
|
||||
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
|
||||
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
|
||||
defaultRendererFactory.setEnableDecoderFallback(true);
|
||||
defaultRendererFactory.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
|
||||
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
|
||||
mExoPlayer = exoPlayerBuilder.build();
|
||||
mExoPlayer.addListener(new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
|
||||
|
||||
@@ -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();
|
||||
@@ -1293,7 +1301,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
currentSkipPos = 0;
|
||||
};
|
||||
|
||||
public void skip(int msec) {
|
||||
private void skip(int msec) {
|
||||
if (hasInitializedVideoManager() && (isPlaying() || isPaused()) && spinnerOff && mVideoManager.getCurrentPosition() > 0) { //guard against skipping before playback has truly begun
|
||||
mHandler.removeCallbacks(skipRunnable);
|
||||
refreshCurrentPosition();
|
||||
|
||||
@@ -25,6 +25,7 @@ class GarbagePlaybackLauncher(
|
||||
BaseItemKind.EPISODE,
|
||||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.SERIES,
|
||||
BaseItemKind.SEASON,
|
||||
BaseItemKind.RECORDING,
|
||||
-> userPreferences[UserPreferences.videoPlayer] === PreferredVideoPlayer.EXTERNAL
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
|
||||
@@ -74,7 +74,7 @@ public class PlaybackManager {
|
||||
request.setAudioStreamIndex(audioIdx);
|
||||
}
|
||||
Integer subIdx = options.getSubtitleStreamIndex();
|
||||
if (subIdx != null && subIdx >= 0) {
|
||||
if (subIdx != null) {
|
||||
request.setSubtitleStreamIndex(subIdx);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -72,8 +72,8 @@ class PlaybackOverlayActivity : FragmentActivity(R.layout.fragment_content_view)
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> playbackController?.play(0)
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> playbackController?.pause()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> playbackController?.playPause()
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_BUTTON_R2 -> playbackController?.skip(30000)
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_L2 -> playbackController?.skip(-11000)
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_BUTTON_R2 -> playbackController?.fastForward()
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_L2 -> playbackController?.rewind()
|
||||
|
||||
// Use parent handler
|
||||
else -> return super.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ class PlaybackPreferencesScreen : OptionsFragment() {
|
||||
setTitle(R.string.lbl_bitstream_ac3)
|
||||
setContent(R.string.desc_bitstream_ac3)
|
||||
bind(userPreferences, UserPreferences.ac3Enabled)
|
||||
depends { userPreferences[UserPreferences.videoPlayer] != PreferredVideoPlayer.EXTERNAL && !DeviceUtils.is60() }
|
||||
depends { userPreferences[UserPreferences.videoPlayer] != PreferredVideoPlayer.EXTERNAL }
|
||||
}
|
||||
|
||||
checkbox {
|
||||
|
||||
@@ -139,17 +139,19 @@ public class CardPresenter extends Presenter {
|
||||
case EPISODE:
|
||||
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_land_tv);
|
||||
aspect = ImageUtils.ASPECT_RATIO_16_9;
|
||||
switch (itemDto.getLocationType()) {
|
||||
case FILE_SYSTEM:
|
||||
break;
|
||||
case REMOTE:
|
||||
break;
|
||||
case VIRTUAL:
|
||||
mCardView.setBanner(itemDto.getPremiereDate() == null || itemDto.getPremiereDate().isAfter(LocalDateTime.now()) ? R.drawable.banner_edge_future : R.drawable.banner_edge_missing);
|
||||
break;
|
||||
case OFFLINE:
|
||||
mCardView.setBanner(R.drawable.banner_edge_offline);
|
||||
break;
|
||||
if (itemDto.getLocationType() != null) {
|
||||
switch (itemDto.getLocationType()) {
|
||||
case FILE_SYSTEM:
|
||||
break;
|
||||
case REMOTE:
|
||||
break;
|
||||
case VIRTUAL:
|
||||
mCardView.setBanner(itemDto.getPremiereDate() == null || itemDto.getPremiereDate().isAfter(LocalDateTime.now()) ? R.drawable.banner_edge_future : R.drawable.banner_edge_missing);
|
||||
break;
|
||||
case OFFLINE:
|
||||
mCardView.setBanner(R.drawable.banner_edge_offline);
|
||||
break;
|
||||
}
|
||||
}
|
||||
showProgress = true;
|
||||
//Always show info for episodes
|
||||
@@ -254,16 +256,18 @@ public class CardPresenter extends Presenter {
|
||||
if (cardWidth < 5) {
|
||||
cardWidth = 115; //Guard against zero size images causing picasso to barf
|
||||
}
|
||||
switch (program.getLocationType()) {
|
||||
case FILE_SYSTEM:
|
||||
case REMOTE:
|
||||
case OFFLINE:
|
||||
break;
|
||||
case VIRTUAL:
|
||||
if (program.getStartDate() != null && program.getStartDate().isAfter(LocalDateTime.now())) {
|
||||
mCardView.setBanner(R.drawable.banner_edge_future);
|
||||
}
|
||||
break;
|
||||
if (program.getLocationType() != null) {
|
||||
switch (program.getLocationType()) {
|
||||
case FILE_SYSTEM:
|
||||
case REMOTE:
|
||||
case OFFLINE:
|
||||
break;
|
||||
case VIRTUAL:
|
||||
if (program.getStartDate() != null && program.getStartDate().isAfter(LocalDateTime.now())) {
|
||||
mCardView.setBanner(R.drawable.banner_edge_future);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
mCardView.setMainImageDimensions(cardWidth, cardHeight);
|
||||
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_land_tv);
|
||||
|
||||
@@ -24,7 +24,7 @@ class MyDetailsOverviewRowPresenter(
|
||||
fun setItem(row: MyDetailsOverviewRow) {
|
||||
setTitle(row.item.name)
|
||||
|
||||
InfoLayoutHelper.addInfoRow(view.context, row.item, binding.fdMainInfoRow, false, false)
|
||||
InfoLayoutHelper.addInfoRow(view.context, row.item, row.selectedMediaSourceIndex, binding.fdMainInfoRow, false, false)
|
||||
binding.fdGenreRow.text = row.item.genres?.joinToString(" / ")
|
||||
|
||||
binding.infoTitle1.text = row.infoItem1?.label
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,6 +5,17 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -23,6 +34,7 @@ import org.jellyfin.androidtv.auth.model.ConnectingState
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.model.ServerAdditionState
|
||||
import org.jellyfin.androidtv.auth.model.UnableToConnectState
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.jellyfin.androidtv.databinding.FragmentSelectServerBinding
|
||||
import org.jellyfin.androidtv.ui.ServerButtonView
|
||||
import org.jellyfin.androidtv.ui.SpacingItemDecoration
|
||||
@@ -30,6 +42,7 @@ import org.jellyfin.androidtv.ui.startup.StartupViewModel
|
||||
import org.jellyfin.androidtv.util.ListAdapter
|
||||
import org.jellyfin.androidtv.util.MenuBuilder
|
||||
import org.jellyfin.androidtv.util.getSummary
|
||||
import org.koin.androidx.compose.get
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class SelectServerFragment : Fragment() {
|
||||
@@ -146,6 +159,32 @@ class SelectServerFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications
|
||||
binding.notifications.setContent {
|
||||
val notificationsRepository = get<NotificationsRepository>()
|
||||
val notifications by notificationsRepository.notifications.collectAsState()
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
for (notification in notifications) {
|
||||
if (!notification.public) continue
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
backgroundColor = colorResource(id = R.color.lb_basic_card_info_bg_color),
|
||||
contentColor = colorResource(id = R.color.white),
|
||||
) {
|
||||
Text(
|
||||
text = notification.message,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual
|
||||
binding.enterServerAddress.setOnClickListener {
|
||||
parentFragmentManager.commit {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,16 +46,20 @@ public class InfoLayoutHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static void addInfoRow(Context context, BaseItemDto item, LinearLayout layout, boolean includeRuntime, boolean includeEndTime) {
|
||||
public static void addInfoRow(Context context, BaseItemDto item, int mediaSourceIndex, LinearLayout layout, boolean includeRuntime, boolean includeEndTime) {
|
||||
layout.removeAllViews();
|
||||
if (item.getId() != null) {
|
||||
addInfoRow(context, item, layout, includeRuntime, includeEndTime, StreamHelper.getFirstAudioStream(item));
|
||||
addInfoRow(context, item, mediaSourceIndex, layout, includeRuntime, includeEndTime, StreamHelper.getFirstAudioStream(item));
|
||||
}else{
|
||||
addProgramChannel(context, item, layout);
|
||||
}
|
||||
}
|
||||
|
||||
public static void addInfoRow(Context context, BaseItemDto item, LinearLayout layout, boolean includeRuntime, boolean includeEndTime, MediaStream audioStream) {
|
||||
public static void addInfoRow(Context context, BaseItemDto item, LinearLayout layout, boolean includeRuntime, boolean includeEndTime) {
|
||||
addInfoRow(context, item, 0, layout, includeRuntime, includeEndTime);
|
||||
}
|
||||
|
||||
public static void addInfoRow(Context context, BaseItemDto item, int mediaSourceIndex, LinearLayout layout, boolean includeRuntime, boolean includeEndTime, MediaStream audioStream) {
|
||||
RatingType ratingType = KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getDefaultRatingType());
|
||||
if (ratingType != RatingType.RATING_HIDDEN) {
|
||||
addCriticInfo(context, item, layout);
|
||||
@@ -100,7 +104,7 @@ public class InfoLayoutHelper {
|
||||
}
|
||||
if (includeRuntime) addRuntime(context, item, layout, includeEndTime);
|
||||
addSeriesStatus(context, item, layout);
|
||||
addRatingAndRes(context, item, layout);
|
||||
addRatingAndRes(context, item, mediaSourceIndex, layout);
|
||||
addMediaDetails(context, audioStream, layout);
|
||||
}
|
||||
|
||||
@@ -325,14 +329,17 @@ public class InfoLayoutHelper {
|
||||
|
||||
}
|
||||
|
||||
private static void addRatingAndRes(Context context, BaseItemDto item, LinearLayout layout) {
|
||||
private static void addRatingAndRes(Context context, BaseItemDto item, int mediaSourceIndex, LinearLayout layout) {
|
||||
if (item.getOfficialRating() != null && !item.getOfficialRating().equals("0")) {
|
||||
addBlockText(context, layout, item.getOfficialRating());
|
||||
addSpacer(context, layout, " ");
|
||||
}
|
||||
if (item.getMediaStreams() != null && item.getMediaStreams().size() > 0 && item.getMediaStreams().get(0).getWidth() != null && item.getMediaStreams().get(0).getHeight() != null) {
|
||||
int width = item.getMediaStreams().get(0).getWidth();
|
||||
int height = item.getMediaStreams().get(0).getHeight();
|
||||
|
||||
MediaStream videoStream = StreamHelper.getFirstVideoStream(item, mediaSourceIndex);
|
||||
|
||||
if (videoStream != null && videoStream.getWidth() != null && videoStream.getHeight() != null) {
|
||||
int width = videoStream.getWidth();
|
||||
int height = videoStream.getHeight();
|
||||
if (width <= 960 && height <= 576) {
|
||||
addBlockText(context, layout, context.getString(R.string.lbl_sd));
|
||||
} else if (width <= 1280 && height <= 962) {
|
||||
@@ -347,8 +354,7 @@ public class InfoLayoutHelper {
|
||||
|
||||
addSpacer(context, layout, " ");
|
||||
|
||||
addVideoCodecDetails(context, layout, item.getMediaStreams().get(0));
|
||||
|
||||
addVideoCodecDetails(context, layout, videoStream);
|
||||
}
|
||||
if (Utils.isTrue(item.getHasSubtitles())) {
|
||||
addBlockText(context, layout, "CC");
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.jellyfin.apiclient.interaction.EmptyResponse;
|
||||
import org.jellyfin.apiclient.model.session.PlaybackProgressInfo;
|
||||
import org.jellyfin.apiclient.model.session.PlaybackStartInfo;
|
||||
import org.jellyfin.apiclient.model.session.PlaybackStopInfo;
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
import timber.log.Timber;
|
||||
@@ -39,7 +40,7 @@ public class ReportingHelper {
|
||||
public static void reportStart(org.jellyfin.sdk.model.api.BaseItemDto item, long pos) {
|
||||
PlaybackStartInfo startInfo = new PlaybackStartInfo();
|
||||
startInfo.setItemId(item.getId().toString());
|
||||
startInfo.setPositionTicks(pos);
|
||||
if (item.getType() != BaseItemKind.TV_CHANNEL) startInfo.setPositionTicks(pos);
|
||||
KoinJavaComponent.<PlaybackManager>get(PlaybackManager.class).reportPlaybackStart(startInfo, KoinJavaComponent.<ApiClient>get(ApiClient.class), new EmptyResponse());
|
||||
Timber.i("Playback of %s started.", item.getName());
|
||||
}
|
||||
@@ -48,9 +49,11 @@ public class ReportingHelper {
|
||||
if (item != null && currentStreamInfo != null) {
|
||||
PlaybackProgressInfo info = new PlaybackProgressInfo();
|
||||
info.setItemId(item.getId().toString());
|
||||
info.setPositionTicks(position);
|
||||
if (item.getType() != BaseItemKind.TV_CHANNEL) {
|
||||
info.setPositionTicks(position);
|
||||
info.setCanSeek(currentStreamInfo.getRunTimeTicks() != null && currentStreamInfo.getRunTimeTicks() > 0);
|
||||
}
|
||||
info.setIsPaused(isPaused);
|
||||
info.setCanSeek(currentStreamInfo.getRunTimeTicks() != null && currentStreamInfo.getRunTimeTicks() > 0);
|
||||
info.setPlayMethod(currentStreamInfo.getPlayMethod());
|
||||
if (playbackController != null && playbackController.isPlaying()) {
|
||||
info.setAudioStreamIndex(playbackController.getAudioStreamIndex());
|
||||
|
||||
@@ -26,9 +26,33 @@ public class StreamHelper {
|
||||
return getStreams(mediaSource, MediaStreamType.AUDIO);
|
||||
}
|
||||
|
||||
public static List<MediaStream> getVideoStreams(MediaSourceInfo mediaSource) {
|
||||
return getStreams(mediaSource, MediaStreamType.VIDEO);
|
||||
}
|
||||
|
||||
public static MediaStream getFirstAudioStream(BaseItemDto item) {
|
||||
if (item.getMediaSources() == null || item.getMediaSources().size() < 1) return null;
|
||||
List<MediaStream> streams = getAudioStreams(item.getMediaSources().get(0));
|
||||
return getFirstAudioStream(item, 0);
|
||||
}
|
||||
|
||||
public static MediaStream getFirstAudioStream(BaseItemDto item, int mediaSourceIndex) {
|
||||
return getFirstStreamOfType(item, MediaStreamType.AUDIO, mediaSourceIndex);
|
||||
}
|
||||
|
||||
public static MediaStream getFirstVideoStream(BaseItemDto item) {
|
||||
return getFirstVideoStream(item, 0);
|
||||
}
|
||||
|
||||
public static MediaStream getFirstVideoStream(BaseItemDto item, int mediaSourceIndex) {
|
||||
return getFirstStreamOfType(item, MediaStreamType.VIDEO, mediaSourceIndex);
|
||||
}
|
||||
|
||||
public static MediaStream getFirstStreamOfType(BaseItemDto item, MediaStreamType streamType) {
|
||||
return getFirstStreamOfType(item, streamType, 0);
|
||||
}
|
||||
|
||||
public static MediaStream getFirstStreamOfType(BaseItemDto item, MediaStreamType streamType, int mediaSourceIndex) {
|
||||
if (item.getMediaSources() == null || mediaSourceIndex > item.getMediaSources().size() - 1) return null;
|
||||
List<MediaStream> streams = getStreams(item.getMediaSources().get(mediaSourceIndex), streamType);
|
||||
if (streams == null || streams.size() < 1) return null;
|
||||
return streams.get(0);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -199,12 +203,12 @@ class ExoPlayerProfile(
|
||||
|
||||
subtitleProfiles = arrayOf(
|
||||
subtitleProfile(Codec.Subtitle.SRT, SubtitleDeliveryMethod.External),
|
||||
subtitleProfile(Codec.Subtitle.SRT, SubtitleDeliveryMethod.Embed),
|
||||
subtitleProfile(Codec.Subtitle.SUBRIP, SubtitleDeliveryMethod.Embed),
|
||||
subtitleProfile(Codec.Subtitle.SUBRIP, SubtitleDeliveryMethod.External),
|
||||
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),
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/notifications"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
android:id="@+id/content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:duplicateParentState="true"
|
||||
android:textSize="16sp"
|
||||
tools:text="This is the expanded text!" />
|
||||
|
||||
@@ -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.19.1"
|
||||
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-exoplayer-ffmpegextension = "2.19.1+1"
|
||||
jellyfin-sdk = "1.4.2"
|
||||
junit = "4.13.2"
|
||||
kenburnsview = "1.0.7"
|
||||
koin = "3.3.0"
|
||||
|
||||
Reference in New Issue
Block a user