From 1b1487b47dfdc3785ff5cbf747f4d9e58e27759c Mon Sep 17 00:00:00 2001 From: ZepiGit <292425648+ZepiGit@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:48:46 +0200 Subject: [PATCH 1/4] Fix sign-in, playback sessions and browsing in the native app Transport: - Add ISRG Root X1 next to the platform anchors for Jellyfin REST, media and snapshot clients; Android 5.1 lacks it. Validation is unchanged. - Fail a media read after 30 s without data; calls stay unbounded. - Clear favorite and watched marks with DELETE on the item resource instead of POST .../Delete. - Localize user-facing transport errors. Sign-in: - Classify failures by exception type; rejected credentials are no longer reported as an expired session. - Suggest http:// after a TLS failure on an address without scheme and confirm every cleartext sign-in; permit cleartext on Android 9+. - Revoke the token on sign-out and keep address and user name. Playback: - Finish the session once on natural end, fatal error, source change and teardown; offer retry with a new session after an error. - Choose subtitles from the server list during direct play as well. - Keep the screen on only while playback is wanted. Browsing: - Page container children past 60 entries, driven by D-pad focus. - Show episode numbers and a Continue button for series. - Hide library types without a native player; load home rows concurrently; show the overview for recently added items. - Show current values in settings; localize library sort names. Tests: PageCursorTest, PlaybackSessionTrackerTest, LoginErrorsTest, transport TLS/timeout cases, client route and paging cases, and an API 22 instrumented sign-in/mark/page/sign-out flow against a fixture server. --- app/build.gradle.kts | 1 + .../java/zepigit/firefin/app/Api22FlowTest.kt | 94 +++++- app/src/main/AndroidManifest.xml | 4 +- app/src/main/java/zepigit/firefin/app/RIds.kt | 8 - .../zepigit/firefin/app/ServiceLocator.kt | 16 +- .../firefin/app/data/JellyfinClient.kt | 57 +++- .../java/zepigit/firefin/app/data/Models.kt | 4 + .../zepigit/firefin/app/data/PageCursor.kt | 37 +++ .../firefin/app/data/ServerTransport.kt | 41 ++- .../zepigit/firefin/app/data/SessionStore.kt | 3 +- .../zepigit/firefin/app/images/ImageLoader.kt | 7 + .../firefin/app/playback/PlaybackEngine.kt | 47 ++- .../java/zepigit/firefin/app/ui/Adapters.kt | 22 +- .../zepigit/firefin/app/ui/DetailActivity.kt | 176 +++++++---- .../zepigit/firefin/app/ui/HomeActivity.kt | 47 ++- .../zepigit/firefin/app/ui/LibraryActivity.kt | 28 +- .../zepigit/firefin/app/ui/LoginActivity.kt | 51 +++- .../zepigit/firefin/app/ui/LoginErrors.kt | 39 +++ .../zepigit/firefin/app/ui/PlayerActivity.kt | 283 +++++++++++------- .../zepigit/firefin/app/ui/SearchActivity.kt | 10 +- .../zepigit/firefin/app/ui/SeerrActivity.kt | 35 ++- .../firefin/app/ui/SettingsActivity.kt | 62 ++-- .../app/util/PlaybackSessionTracker.kt | 34 +++ app/src/main/res/layout/activity_player.xml | 2 +- app/src/main/res/values-de/strings.xml | 37 ++- app/src/main/res/values/strings.xml | 37 ++- .../zepigit/firefin/app/JellyfinClientTest.kt | 69 +++++ .../zepigit/firefin/app/LoginErrorsTest.kt | 44 +++ .../zepigit/firefin/app/PageCursorTest.kt | 62 ++++ .../firefin/app/PlaybackSessionTrackerTest.kt | 101 +++++++ .../firefin/app/TransportRegressionTest.kt | 62 ++++ 31 files changed, 1210 insertions(+), 310 deletions(-) delete mode 100644 app/src/main/java/zepigit/firefin/app/RIds.kt create mode 100644 app/src/main/java/zepigit/firefin/app/data/PageCursor.kt create mode 100644 app/src/main/java/zepigit/firefin/app/ui/LoginErrors.kt create mode 100644 app/src/main/java/zepigit/firefin/app/util/PlaybackSessionTracker.kt create mode 100644 app/src/test/java/zepigit/firefin/app/LoginErrorsTest.kt create mode 100644 app/src/test/java/zepigit/firefin/app/PageCursorTest.kt create mode 100644 app/src/test/java/zepigit/firefin/app/PlaybackSessionTrackerTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21ee7c93..08e2f501 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,4 +81,5 @@ dependencies { androidTestImplementation("androidx.test:runner:1.5.2") androidTestImplementation("androidx.test:rules:1.5.0") androidTestImplementation("junit:junit:4.13.2") + androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") } diff --git a/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt b/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt index 1c7a1e94..cbef36c3 100644 --- a/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt +++ b/app/src/androidTest/java/zepigit/firefin/app/Api22FlowTest.kt @@ -1,18 +1,35 @@ package zepigit.firefin.app -import android.content.Intent +import android.content.Context import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import zepigit.firefin.app.data.MediaItem +import zepigit.firefin.app.data.PageCursor import zepigit.firefin.app.ui.LoginActivity @RunWith(AndroidJUnit4::class) class Api22FlowTest { + @Before fun signedOut() { + InstrumentationRegistry.getInstrumentation().targetContext + .getSharedPreferences("firefin_session", Context.MODE_PRIVATE).edit().clear().commit() + } + @Test fun loginScreenStartsWithoutCrashAndExposesServerField() { - androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext - .getSharedPreferences("firefin_session", android.content.Context.MODE_PRIVATE).edit().clear().commit() ActivityScenario.launch(LoginActivity::class.java).use { scenario -> scenario.onActivity { activity -> assertTrue(activity.findViewById(R.id.serverUrl).isFocusable) @@ -20,4 +37,75 @@ class Api22FlowTest { } } } + + /** + * Runs the app's own client on the API 22 runtime against a local fixture + * server: cleartext sign-in, mark/unmark, paging past the first page and + * sign-out. This covers request and state logic, not decoders or hardware. + */ + @Test fun signInMarkPageAndSignOutAgainstLocalServer() = runBlocking { + val server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val url = request.requestUrl!! + return when (url.encodedPath) { + "/Users/AuthenticateByName" -> MockResponse().setBody("""{"AccessToken":"tok","ServerId":"srv","User":{"Id":"u1","Name":"tester"}}""") + "/Users/u1/Items" -> { + val start = url.queryParameter("StartIndex")!!.toInt() + val limit = url.queryParameter("Limit")!!.toInt() + val items = JSONArray() + for (index in start until minOf(start + limit, EPISODES)) { + items.put(JSONObject().put("Id", "e$index").put("Type", "Episode").put("IndexNumber", index + 1)) + } + MockResponse().setBody(JSONObject().put("Items", items).put("TotalRecordCount", EPISODES).toString()) + } + else -> MockResponse().setBody("{}") + } + } + } + server.start() + val address = server.url("/").toString().trimEnd('/') + try { + val client = ServiceLocator.client + client.login(address, "tester", "pw") + assertTrue(ServiceLocator.session.isLoggedIn) + client.setFavorite("i1", true) + client.setFavorite("i1", false) + + val season = MediaItem.from(JSONObject().put("Id", "s1").put("Type", "Season")) + val cursor = PageCursor() + val ids = mutableListOf() + while (true) { + val start = cursor.next() ?: break + val (page, total) = client.children(season, start) + cursor.received(page.size, total) + ids += page.map { it.id } + } + assertEquals((0 until EPISODES).map { "e$it" }, ids) + + client.logout() + ServiceLocator.session.clear() + val requests = generateSequence { server.takeRequest(5, TimeUnit.SECONDS) } + .map { "${it.method} ${it.requestUrl!!.encodedPath}" }.toList() + assertTrue(requests.contains("POST /Users/u1/FavoriteItems/i1")) + assertTrue(requests.contains("DELETE /Users/u1/FavoriteItems/i1")) + assertFalse(requests.any { it.endsWith("/Delete") }) + assertTrue(requests.contains("POST /Sessions/Logout")) + } finally { + server.shutdown() + } + + assertFalse(ServiceLocator.session.isLoggedIn) + ActivityScenario.launch(LoginActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals(address, activity.findViewById(R.id.serverUrl).text.toString()) + assertEquals("tester", activity.findViewById(R.id.username).text.toString()) + assertEquals(R.id.password, activity.currentFocus?.id) + } + } + } + + private companion object { + const val EPISODES = 125 + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 254b5057..71688bd3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ - + android:theme="@style/Theme.Firefin" + android:usesCleartextTraffic="true"> val url = chain.request().url diff --git a/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt b/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt index 97aa1828..59853864 100644 --- a/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt +++ b/app/src/main/java/zepigit/firefin/app/data/JellyfinClient.kt @@ -2,8 +2,13 @@ package zepigit.firefin.app.data import android.os.Build import zepigit.firefin.app.BuildConfig +import zepigit.firefin.app.R +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.tls.HandshakeCertificates import org.json.JSONArray import org.json.JSONObject import zepigit.firefin.app.playback.DeviceProfile @@ -12,7 +17,7 @@ import java.io.IOException import java.net.URLEncoder /** Jellyfin REST client using one origin-bound, cancellable ServerTransport. */ -class JellyfinClient(private val session: SessionStore, private val preferences: () -> zepigit.firefin.app.preferences.EffectiveDevicePreferences = { +class JellyfinClient(private val session: SessionStore, trust: HandshakeCertificates? = null, private val preferences: () -> zepigit.firefin.app.preferences.EffectiveDevicePreferences = { zepigit.firefin.app.preferences.deriveEffective(zepigit.firefin.app.preferences.StoredPreferences(), DeviceProfile.MAX_WIDTH, DeviceProfile.MAX_HEIGHT, DeviceProfile.MAX_BITRATE) }) { private val transport = ServerTransport( @@ -20,7 +25,9 @@ class JellyfinClient(private val session: SessionStore, private val preferences: session.deviceId, Build.MODEL?.takeIf { it.isNotBlank() } ?: "Fire TV", VERSION, + trust = trust, ) + private val background = CoroutineScope(SupervisorJob() + Dispatchers.IO) val okHttp: okhttp3.OkHttpClient get() = transport.mediaHttp val baseUrl: String get() = session.serverUrl @@ -34,8 +41,8 @@ class JellyfinClient(private val session: SessionStore, private val preferences: val body = JSONObject().put("Username", username).put("Pw", password) val json = JSONObject(transport.json("Users/AuthenticateByName", "POST", body.toString())) val token = json.optString("AccessToken").takeIf { it.isNotBlank() } - ?: throw IOException("Login response did not contain an access token.") - val user = json.optJSONObject("User") ?: throw IOException("Login response did not contain a user.") + ?: throw LocalizedIOException(R.string.error_login_response, "Login response did not contain an access token.") + val user = json.optJSONObject("User") ?: throw LocalizedIOException(R.string.error_login_response, "Login response did not contain a user.") session.serverUrl = server session.accessToken = token session.userId = user.optString("Id") @@ -68,7 +75,7 @@ class JellyfinClient(private val session: SessionStore, private val preferences: } suspend fun latest(parentId: String? = null, limit: Int = 12): List = withContext(Dispatchers.IO) { - var path = "Users/${session.userId}/Items/Latest?Limit=$limit&Fields=PrimaryImageAspectRatio,ProductionYear&EnableImageTypes=Primary,Backdrop,Thumb" + var path = "Users/${session.userId}/Items/Latest?Limit=$limit&Fields=PrimaryImageAspectRatio,ProductionYear,Overview&EnableImageTypes=Primary,Backdrop,Thumb" if (!parentId.isNullOrBlank()) path += "&ParentId=${urlEncode(parentId)}" parseArray(transportText(path)) } @@ -88,9 +95,14 @@ class JellyfinClient(private val session: SessionStore, private val preferences: suspend fun item(itemId: String): MediaItem = withContext(Dispatchers.IO) { MediaItem.from(getJson("Users/${session.userId}/Items/${urlEncode(itemId)}")) } - suspend fun children(container: MediaItem): List = withContext(Dispatchers.IO) { - if (container.isSeries) parseItems(getJson("Shows/${urlEncode(container.id)}/Seasons?UserId=${urlEncode(session.userId)}")) - else items(parentId = container.id, recursive = false, sortBy = "IndexNumber", limit = 60).first + /** One page of a container's children plus the total count; a series returns all of its seasons at once. */ + suspend fun children(container: MediaItem, startIndex: Int = 0, limit: Int = CHILD_PAGE_SIZE): Pair, Int> = withContext(Dispatchers.IO) { + if (container.isSeries) { + val seasons = parseItems(getJson("Shows/${urlEncode(container.id)}/Seasons?UserId=${urlEncode(session.userId)}")) + seasons to seasons.size + } else { + items(parentId = container.id, startIndex = startIndex, recursive = false, sortBy = "IndexNumber", limit = limit) + } } suspend fun playbackInfo(itemId: String, audioIndex: Int? = null, subtitleIndex: Int? = null, pinnedTransport: ServerTransport? = null, mediaSourceId: String? = null): PlaybackSource = withContext(Dispatchers.IO) { @@ -120,7 +132,7 @@ class JellyfinClient(private val session: SessionStore, private val preferences: val sources = json.optJSONArray("MediaSources") ?: JSONArray() val source = (0 until sources.length()).mapNotNull { sources.optJSONObject(it) }.filter { mediaSourceId == null || it.optString("Id") == mediaSourceId }.firstOrNull { source -> source.optBoolean("SupportsDirectPlay", false) || source.optBoolean("SupportsDirectStream", false) || source.optBoolean("SupportsTranscoding", false) || source.optString("TranscodingUrl").isNotBlank() - } ?: throw IOException("No playable media source.") + } ?: throw LocalizedIOException(R.string.error_no_playable_source, "No playable media source.") val id = source.optString("Id").takeIf { it.isNotBlank() } ?: itemId val transcode = source.optString("TranscodingUrl").takeIf { it.isNotBlank() } val directStream = source.optString("DirectStreamUrl").takeIf { it.isNotBlank() } @@ -128,8 +140,8 @@ class JellyfinClient(private val session: SessionStore, private val preferences: source.optBoolean("SupportsDirectPlay") && transcode == null -> Urls.directStreamUrl(credentials.serverUrl, itemId, id) transcode != null -> Urls.resolveRelative(credentials.serverUrl, transcode) directStream != null -> Urls.resolveRelative(credentials.serverUrl, directStream) - source.optBoolean("SupportsDirectStream") || source.optBoolean("SupportsTranscoding") -> throw IOException("Server did not supply a negotiated media URL.") - else -> throw IOException("Media source is not playable on this device.") + source.optBoolean("SupportsDirectStream") || source.optBoolean("SupportsTranscoding") -> throw LocalizedIOException(R.string.error_no_playable_source, "Server did not supply a negotiated media URL.") + else -> throw LocalizedIOException(R.string.error_no_playable_source, "Media source is not playable on this device.") } val streams = source.optJSONArray("MediaStreams") ?: JSONArray() val subtitle = (0 until streams.length()).mapNotNull { streams.optJSONObject(it) } @@ -169,8 +181,24 @@ class JellyfinClient(private val session: SessionStore, private val preferences: } suspend fun sendCommand(sessionId: String, command: String) = withContext(Dispatchers.IO) { transportText("Sessions/${urlEncode(sessionId)}/Playing/${urlEncode(command)}", "POST", "{}") } - suspend fun setFavorite(itemId: String, favorite: Boolean) = withContext(Dispatchers.IO) { transportText("Users/${session.userId}/FavoriteItems/${urlEncode(itemId)}${if (favorite) "" else "/Delete"}", "POST", "{}") } - suspend fun setPlayed(itemId: String, played: Boolean) = withContext(Dispatchers.IO) { transportText("Users/${session.userId}/PlayedItems/${urlEncode(itemId)}${if (played) "" else "/Delete"}", "POST", "{}") } + // Jellyfin marks with POST and unmarks with DELETE on the same resource path. + suspend fun setFavorite(itemId: String, favorite: Boolean) = withContext(Dispatchers.IO) { + transportText("Users/${session.userId}/FavoriteItems/${urlEncode(itemId)}", if (favorite) "POST" else "DELETE") + } + suspend fun setPlayed(itemId: String, played: Boolean) = withContext(Dispatchers.IO) { + transportText("Users/${session.userId}/PlayedItems/${urlEncode(itemId)}", if (played) "POST" else "DELETE") + } + + /** + * Revokes the current access token on the server. Best effort and detached: + * the local sign-out never waits for it, and a failure leaves only a stale + * server-side session that the administrator can remove. + */ + fun logout() { + if (!session.isLoggedIn) return + val bound = transportSnapshot() + background.launch { runCatching { bound.json("Sessions/Logout", "POST") } } + } private suspend fun getJson(path: String, method: String = "GET", body: String? = null): JSONObject = JSONObject(transportText(path, method, body)) private suspend fun postSessionEvent(path: String, itemId: String, playSessionId: String, extra: JSONObject.() -> Unit) = withContext(Dispatchers.IO) { @@ -183,5 +211,8 @@ class JellyfinClient(private val session: SessionStore, private val preferences: private fun syncTransport() { transport.update(ServerCredentials(session.serverUrl, session.userId, session.accessToken)) } private fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8") - companion object { const val VERSION = BuildConfig.VERSION_NAME } + companion object { + const val VERSION = BuildConfig.VERSION_NAME + const val CHILD_PAGE_SIZE = 60 + } } diff --git a/app/src/main/java/zepigit/firefin/app/data/Models.kt b/app/src/main/java/zepigit/firefin/app/data/Models.kt index 852eef37..5903cb96 100644 --- a/app/src/main/java/zepigit/firefin/app/data/Models.kt +++ b/app/src/main/java/zepigit/firefin/app/data/Models.kt @@ -25,6 +25,10 @@ data class MediaItem( val isSeries: Boolean get() = type == "Series" val isPlayable: Boolean get() = type == "Movie" || type == "Episode" || type == "Video" + /** `S03E04` for numbered episodes, null otherwise. */ + val episodeCode: String? get() = + if (isEpisode && indexNumber > 0) String.format(java.util.Locale.ROOT, "S%02dE%02d", parentIndexNumber, indexNumber) else null + companion object { fun from(json: JSONObject): MediaItem { val userData = json.optJSONObject("UserData") ?: JSONObject() diff --git a/app/src/main/java/zepigit/firefin/app/data/PageCursor.kt b/app/src/main/java/zepigit/firefin/app/data/PageCursor.kt new file mode 100644 index 00000000..4c860c72 --- /dev/null +++ b/app/src/main/java/zepigit/firefin/app/data/PageCursor.kt @@ -0,0 +1,37 @@ +package zepigit.firefin.app.data + +/** + * Paging state for one remote list (main thread only): the server total, the + * next start index and at most one page in flight. A failed page leaves the + * loaded pages untouched and can be requested again. + */ +class PageCursor(private val prefetch: Int = 8) { + var total = -1 + private set + var loaded = 0 + private set + private var inFlight = false + + val hasMore: Boolean get() = total < 0 || loaded < total + + /** Start index of the next page, or null while a page is in flight or nothing is left. */ + fun next(): Int? { + if (inFlight || !hasMore) return null + inFlight = true + return loaded + } + + /** Records a received page; an empty page ends the list even if the server total says otherwise. */ + fun received(count: Int, serverTotal: Int) { + inFlight = false + loaded += count + total = if (count == 0) loaded else maxOf(serverTotal, loaded) + } + + fun failed() { + inFlight = false + } + + /** True when [position] is close enough to the loaded end to fetch the next page. */ + fun nearEnd(position: Int): Boolean = hasMore && position >= loaded - prefetch +} diff --git a/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt b/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt index 439f7ab6..ea69f50e 100644 --- a/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt +++ b/app/src/main/java/zepigit/firefin/app/data/ServerTransport.kt @@ -13,23 +13,40 @@ import okhttp3.Request import okhttp3.Response import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.MediaType.Companion.toMediaType +import okhttp3.tls.HandshakeCertificates +import zepigit.firefin.app.R import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -class SessionExpiredException : IOException("Your session has expired. Please sign in again.") -class ServerResponseException(val status: Int) : IOException("Server request failed (HTTP $status).") +class SessionExpiredException : LocalizedIOException(R.string.error_session_expired, "Your session has expired. Please sign in again.") +class ServerResponseException(val status: Int) : LocalizedIOException(R.string.error_server_status, "Server request failed (HTTP $status).", listOf(status)) class ServerCredentials(val serverUrl: String, val userId: String, val token: String) -/** A single origin-bound, cancellable HTTP stack shared by REST, images and Media3. */ -class ServerTransport(initial: ServerCredentials, val deviceId: String, private val deviceName: String = "Fire TV", private val version: String = "0.1.0-firefin", private val shared: ServerTransport? = null) { +/** + * A single origin-bound, cancellable HTTP stack shared by REST, images and Media3. + * + * [trust] adds public trust anchors to the platform store (see ServiceLocator); + * chain and hostname validation stay with the platform verifier. Snapshots + * inherit the trust and media read timeout of the transport they were taken from. + */ +class ServerTransport( + initial: ServerCredentials, + val deviceId: String, + private val deviceName: String = "Fire TV", + private val version: String = "0.1.0-firefin", + private val shared: ServerTransport? = null, + private val trust: HandshakeCertificates? = shared?.trust, + private val mediaReadTimeoutMs: Long = shared?.mediaReadTimeoutMs ?: MEDIA_READ_TIMEOUT_MS, +) { @Volatile private var current = initial private val lock = Any() private fun baseBuilder() = OkHttpClient.Builder() .apply { shared?.let { dispatcher(it.http.dispatcher); connectionPool(it.http.connectionPool) } } + .apply { trust?.let { sslSocketFactory(it.sslSocketFactory(), it.trustManager) } } .connectTimeout(10, TimeUnit.SECONDS) .followRedirects(false) .followSslRedirects(false) @@ -48,10 +65,15 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private chain.proceed(request) }).build() - /** Media3 keeps long-running VOD bodies open while sharing origin/auth policy. */ + /** + * Media3 keeps long-running VOD bodies open while sharing origin/auth policy. + * The call itself is unbounded, but a single read that receives no bytes for + * [mediaReadTimeoutMs] fails, so a stalled connection reaches Media3's retry + * and error handling instead of buffering forever. + */ val mediaHttp: OkHttpClient = baseBuilder() .callTimeout(0, TimeUnit.MILLISECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(mediaReadTimeoutMs, TimeUnit.MILLISECONDS) .addInterceptor(Interceptor { chain -> var request = chain.request() var redirects = 0 @@ -147,7 +169,7 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private val text = response.use { val body = it.body ?: return@use JsonResponse(it.code, "") val limit = if (it.isSuccessful) MAX_JSON_BYTES else 64L * 1024 - if (it.isSuccessful && body.contentLength() > limit) throw IOException("Server response is too large.") + if (it.isSuccessful && body.contentLength() > limit) throw LocalizedIOException(R.string.error_response_too_large, "Server response is too large.") val buffer = java.io.ByteArrayOutputStream() body.byteStream().use { stream -> val chunk = ByteArray(16 * 1024) @@ -157,7 +179,7 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private if (read < 0) break total += read if (total > limit) { - if (it.isSuccessful) throw IOException("Server response is too large.") + if (it.isSuccessful) throw LocalizedIOException(R.string.error_response_too_large, "Server response is too large.") buffer.write(chunk, 0, (read - (total - limit)).toInt()) break } @@ -190,5 +212,8 @@ class ServerTransport(initial: ServerCredentials, val deviceId: String, private val JSON = "application/json; charset=utf-8".toMediaType() val REDIRECTS = setOf(301, 302, 303, 307, 308) const val MAX_JSON_BYTES = 8L * 1024 * 1024 + // Jellyfin can hold an HLS segment request until the transcoder has + // written it; 30 s stays well above that delay on slow servers. + const val MEDIA_READ_TIMEOUT_MS = 30_000L } } diff --git a/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt b/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt index caa1c6a8..36262c53 100644 --- a/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt +++ b/app/src/main/java/zepigit/firefin/app/data/SessionStore.kt @@ -40,11 +40,10 @@ class SessionStore( val isLoggedIn: Boolean get() = serverUrl.isNotEmpty() && accessToken.isNotEmpty() && userId.isNotEmpty() + /** Signs out: removes the account and token, keeps server address and user name to prefill the next sign-in. */ fun clear() { prefs.edit() - .remove(KEY_SERVER) .remove(KEY_USER_ID) - .remove(KEY_USER_NAME) .remove(KEY_SERVER_NAME) .remove(KEY_TOKEN) .apply() diff --git a/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt b/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt index 94140f8b..436ed09a 100644 --- a/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt +++ b/app/src/main/java/zepigit/firefin/app/images/ImageLoader.kt @@ -103,6 +103,8 @@ class ImageLoader(context: Context, http: okhttp3.OkHttpClient, cacheName: Strin val file = diskFile(load.key) if (!file.exists()) return null if (file.length() > MAX_IMAGE_BYTES) { file.delete(); return null } + // Touch on read so the size trim removes the least recently used files first. + file.setLastModified(System.currentTimeMillis()) runCatching { readBounded(file.inputStream()) }.getOrNull() } ?: return null val bitmap = runCatching { decode(bytes, load.width) }.getOrNull() @@ -142,6 +144,11 @@ class ImageLoader(context: Context, http: okhttp3.OkHttpClient, cacheName: Strin if (zepigit.firefin.app.BuildConfig.DEBUG && !load.cancelled) android.util.Log.w("FirefinArtwork", failure.javaClass.simpleName) if (!load.cancelled) failures.put(load.url, System.currentTimeMillis()) null + } catch (failure: OutOfMemoryError) { + // A single oversized artwork must not end the shared decode workers on a 1 GB device. + memCache.evictAll() + if (!load.cancelled) failures.put(load.url, System.currentTimeMillis()) + null } finally { load.call = null } private fun readBounded(input: java.io.InputStream): ByteArray? = input.use { stream -> diff --git a/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt b/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt index 909f4eeb..069b77b5 100644 --- a/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt +++ b/app/src/main/java/zepigit/firefin/app/playback/PlaybackEngine.kt @@ -6,6 +6,9 @@ import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -43,13 +46,32 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { .setPreferredAudioLanguage(prefs.stored.audioLanguage.takeIf { it.isNotBlank() }) .setPreferredTextLanguage(prefs.stored.subtitleLanguage.takeIf { it.isNotBlank() }) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, prefs.stored.subtitleLanguage.isBlank()).build() + addListener(object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) = selectExternalSubtitle(tracks) + }) } + /** True until the sideloaded subtitle of the current item has been selected explicitly. */ + private var externalSubtitlePending = false + var currentUrl: String? = null private set + /** + * Applies a negotiated subtitle choice: text on or off, preferring [language]. + * Call before [prepare]; the sideloaded file is then pinned once its track appears. + */ + fun applySubtitleChoice(enabled: Boolean, language: String?) { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !enabled) + .setPreferredTextLanguage(language?.takeIf { it.isNotBlank() }) + .build() + } + fun prepare(url: String, startMs: Long, subtitleUrl: String? = null, subtitleMime: String? = null) { currentUrl = url + externalSubtitlePending = subtitleUrl != null && subtitleMime != null val isHls = url.contains(".m3u8") || url.contains("/hls/") val mediaItem = MediaItem.Builder() .setUri(url) @@ -57,7 +79,7 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { .apply { if (subtitleUrl != null && subtitleMime != null) setSubtitleConfigurations(listOf( MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitleUrl)) - .setMimeType(subtitleMime).setSelectionFlags(C.SELECTION_FLAG_DEFAULT).build(), + .setId(EXTERNAL_SUBTITLE_ID).setMimeType(subtitleMime).setSelectionFlags(C.SELECTION_FLAG_DEFAULT).build(), )) }.build() player.setMediaItem(mediaItem, startMs) @@ -65,6 +87,25 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { player.playWhenReady = true } + /** + * A direct-played file can carry embedded subtitles in the same language; + * the user picked the server-delivered file, so select that track by its id. + */ + private fun selectExternalSubtitle(tracks: Tracks) { + if (!externalSubtitlePending) return + for (group in tracks.groups) { + if (group.type != C.TRACK_TYPE_TEXT) continue + for (index in 0 until group.length) { + if (group.getTrackFormat(index).id?.endsWith(EXTERNAL_SUBTITLE_ID) != true) continue + externalSubtitlePending = false + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, index)) + .build() + return + } + } + } + fun attach(surface: SurfaceView) { player.setVideoSurfaceView(surface) } @@ -72,4 +113,8 @@ class PlaybackEngine(context: Context, okHttpClient: okhttp3.OkHttpClient) { fun release() { player.release() } + + private companion object { + const val EXTERNAL_SUBTITLE_ID = "firefin-external-subtitle" + } } diff --git a/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt b/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt index f1b914bb..b91bc2ca 100644 --- a/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt +++ b/app/src/main/java/zepigit/firefin/app/ui/Adapters.kt @@ -17,6 +17,8 @@ import zepigit.firefin.app.util.Urls class MediaCardAdapter( private val posterStyle: Boolean = true, + /** Episodes inside a season list show only their episode number, not the series name. */ + private val inSeason: Boolean = false, private val onFocus: (MediaItem) -> Unit = {}, private val artwork: ((MediaItem, ImageView) -> Unit)? = null, private val onClick: (MediaItem) -> Unit, @@ -32,6 +34,7 @@ class MediaCardAdapter( val unique = newItems.filter { next -> items.none { it.id == next.id } } val start = items.size; items.addAll(unique); notifyItemRangeInserted(start, unique.size) } + fun positionOf(item: MediaItem) = items.indexOfFirst { it.id == item.id } override fun getItemCount() = items.size override fun getItemId(position: Int) = items[position].id.hashCode().toLong() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = Holder( @@ -39,7 +42,12 @@ class MediaCardAdapter( ) override fun onBindViewHolder(holder: Holder, position: Int) { val item = items[position] - holder.name.text = if ((item.isEpisode || item.type == "Season") && item.seriesName.isNotEmpty()) "${item.seriesName} · ${item.name}" else item.name + holder.name.text = when { + item.isEpisode && inSeason -> listOfNotNull(item.indexNumber.takeIf { it > 0 }?.let { String.format(java.util.Locale.ROOT, "E%02d", it) }, item.name).joinToString(" · ") + item.isEpisode -> listOfNotNull(item.seriesName.ifBlank { null }, item.episodeCode, item.name).joinToString(" · ") + item.type == "Season" && item.seriesName.isNotEmpty() -> "${item.seriesName} · ${item.name}" + else -> item.name + } holder.subtitle.text = buildString { if (item.year > 0) append(item.year) if (item.played) append(" ✓ ").append(holder.itemView.context.getString(R.string.mark_watched)) @@ -59,23 +67,17 @@ class MediaCardAdapter( ServiceLocator.images.load(url, ArtworkPolicy.decodeBucket(width), holder.image) } holder.card.setOnClickListener { onClick(item) } - holder.card.setOnFocusChangeListener { view, focused -> - val expand = ServiceLocator.preferences.effective().cardFocusExpansion - val scale = if (expand && focused) 1.05f else 1f - view.animate().scaleX(scale).scaleY(scale).setDuration(if (expand) 150 else 0).start() + // Card focus expansion is locked off on this target (EffectiveDevicePreferences); + // focus only starts the title marquee and notifies the screen. + holder.card.setOnFocusChangeListener { _, focused -> holder.name.isSelected = focused if (focused) onFocus(item) } - val expand = ServiceLocator.preferences.effective().cardFocusExpansion - holder.card.scaleX = if (expand && holder.card.hasFocus()) 1.05f else 1f - holder.card.scaleY = holder.card.scaleX holder.name.isSelected = holder.card.hasFocus() } override fun onViewRecycled(holder: Holder) { ServiceLocator.images.cancel(holder.image) - holder.card.animate().cancel() holder.card.setOnFocusChangeListener(null) - holder.card.scaleX = 1f; holder.card.scaleY = 1f super.onViewRecycled(holder) } class Holder(view: View) : RecyclerView.ViewHolder(view) { diff --git a/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt b/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt index 47a52067..9a1304c9 100644 --- a/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt +++ b/app/src/main/java/zepigit/firefin/app/ui/DetailActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.view.View import android.widget.Button import android.widget.ImageView +import android.widget.ProgressBar import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity @@ -20,11 +21,12 @@ import kotlinx.coroutines.launch import zepigit.firefin.app.R import zepigit.firefin.app.ServiceLocator import zepigit.firefin.app.data.MediaItem +import zepigit.firefin.app.data.PageCursor import zepigit.firefin.app.images.ArtworkPolicy import zepigit.firefin.app.util.Ticks import zepigit.firefin.app.util.Urls -/** Item details with backdrop, play/resume, favorite/watched and episode children. */ +/** Item details with backdrop, play/resume, favorite/watched and paged children. */ class DetailActivity : AppCompatActivity() { private val scope = CoroutineScope(Dispatchers.Main + Job()) @@ -33,21 +35,37 @@ class DetailActivity : AppCompatActivity() { private var firstResume = true private var loadJob: Job? = null private var startingPlayback = false + private var childCursor = PageCursor() + private var childAdapter: MediaCardAdapter? = null + + private lateinit var backdrop: ImageView + private lateinit var name: TextView + private lateinit var meta: TextView + private lateinit var overview: TextView + private lateinit var play: Button + private lateinit var restart: Button + private lateinit var favorite: Button + private lateinit var watched: Button + private lateinit var retry: Button + private lateinit var progress: ProgressBar + private lateinit var children: RecyclerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_detail) itemId = intent.getStringExtra(EXTRA_ID) ?: return finish() - val backdrop = findViewById(R.id.backdrop) - val name = findViewById(R.id.name) - val meta = findViewById(R.id.meta) - val overview = findViewById(R.id.overview) - val play = findViewById