From c055ceed626a5d6e0dd2bb8fba1c4895ad7cff54 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 27 Oct 2026 21:08:05 +0300 Subject: [PATCH 1/5] fix(mobile): let the player open local file:// media --- .../io/putdotio/android/playback/MobilePlayerFactory.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt index 987c9eca..561a3dc2 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt @@ -4,6 +4,7 @@ import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem import androidx.media3.common.Player as Media3Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaController @@ -109,9 +110,13 @@ internal object DefaultMobilePlayerFactory : MobilePlayerFactory { ): Media3Player { return ExoPlayer.Builder(context, playbackRenderersFactory(context)) // Completed downloads play from the cache; everything else streams through the same source. + // Local schemes such as file:// bypass it; network URLs still go through the cache. .setMediaSourceFactory( DefaultMediaSourceFactory( - MobileDownloadCache.get(context).let { it.playbackFactory(it.activeUserId ?: NO_DOWNLOAD_USER) }, + DefaultDataSource.Factory( + context, + MobileDownloadCache.get(context).let { it.playbackFactory(it.activeUserId ?: NO_DOWNLOAD_USER) }, + ), ), ) .setAudioAttributes(mediaType.audioAttributes(), true) From b832040ee83496600466863547fc962410d4c34f Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 27 Oct 2026 21:11:08 +0300 Subject: [PATCH 2/5] test(mobile): run the playback proofs on the production player --- .../android/MobileFullscreenVideoProofTest.kt | 23 +++++------- .../MobilePictureInPictureProofTest.kt | 36 ++++++++----------- .../android/MobilePlaybackOptionsProofTest.kt | 17 +++++++-- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileFullscreenVideoProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileFullscreenVideoProofTest.kt index 91f3a13e..659a36d4 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileFullscreenVideoProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileFullscreenVideoProofTest.kt @@ -32,7 +32,6 @@ import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.Lifecycle import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import io.putdotio.android.design.PutioTheme @@ -43,6 +42,7 @@ import io.putdotio.android.playback.MOBILE_PLAYER_SIDE_SHEET_TAG import io.putdotio.android.playback.PlaybackAudioTrack import io.putdotio.android.playback.PlaybackSubtitleTrack import io.putdotio.android.playback.languageName +import io.putdotio.android.playback.DefaultMobilePlayerFactory import io.putdotio.android.playback.MobilePlayerFactory import io.putdotio.android.playback.MobilePlayerScreen import io.putdotio.android.playback.PlaybackContent @@ -50,9 +50,7 @@ import io.putdotio.android.playback.PlaybackMediaType import io.putdotio.android.playback.PlaybackState import io.putdotio.android.playback.PlaybackTarget import io.putdotio.android.playback.SubtitleTrackIdentity -import io.putdotio.android.playback.audioAttributes import io.putdotio.android.playback.playbackAudioTracks -import io.putdotio.android.playback.playbackRenderersFactory import io.putdotio.android.playback.playbackState import io.putdotio.android.playback.playbackSubtitleTracks import io.putdotio.sdk.files.PlaybackSource @@ -359,19 +357,14 @@ private class FullscreenProofPlayerFactory : MobilePlayerFactory { var renderedFrame = false private set fun current(): Player = checkNotNull(player) - // The production player streams through the download cache's HTTP source, which cannot read - // the caller's local file; this one keeps its renderers and audio attributes. override fun create(context: Context, mediaType: PlaybackMediaType): Player = - ExoPlayer.Builder(context, playbackRenderersFactory(context)) - .setAudioAttributes(mediaType.audioAttributes(), true) - .build() - .also { - player = it - renderedFrame = false - it.addListener(object : Player.Listener { - override fun onRenderedFirstFrame() { renderedFrame = true } - }) - } + DefaultMobilePlayerFactory.create(context, mediaType).also { + player = it + renderedFrame = false + it.addListener(object : Player.Listener { + override fun onRenderedFirstFrame() { renderedFrame = true } + }) + } // This private-video proof must not stop an unrelated real audio session. override fun stopAudio(context: Context) = Unit } diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePictureInPictureProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePictureInPictureProofTest.kt index 60a9beda..3965b257 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePictureInPictureProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePictureInPictureProofTest.kt @@ -15,12 +15,12 @@ import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import io.putdotio.android.design.PutioTheme import io.putdotio.android.files.FilesItemId +import io.putdotio.android.playback.DefaultMobilePlayerFactory import io.putdotio.android.playback.MobilePlayerFactory import io.putdotio.android.playback.MobilePlayerScreen import io.putdotio.android.playback.PlaybackContent @@ -28,8 +28,6 @@ import io.putdotio.android.playback.PlaybackMediaType import io.putdotio.android.playback.PlaybackPositionObserver import io.putdotio.android.playback.PlaybackTarget import io.putdotio.android.playback.SubtitleStartupPolicy -import io.putdotio.android.playback.audioAttributes -import io.putdotio.android.playback.playbackRenderersFactory import io.putdotio.android.playback.playbackState import io.putdotio.android.playback.withReportingLease import io.putdotio.sdk.files.PlaybackSource @@ -357,10 +355,7 @@ class MobilePictureInPictureProofTest { private class MenuButtons(val action: Rect, val expand: Rect, val close: Rect) -/** - * Production renderers and audio attributes over a local file, which the production player's - * download-cache HTTP source cannot read; positions go through the real observer to a recorder. - */ +/** The production player over a local file; positions go through the real observer to a recorder. */ private class PictureInPictureProofPlayerFactory(private val lease: String) : MobilePlayerFactory { var player: Player? = null private set @@ -373,21 +368,18 @@ private class PictureInPictureProofPlayerFactory(private val lease: String) : Mo fun current(): Player = checkNotNull(player) override fun create(context: Context, mediaType: PlaybackMediaType): Player = - ExoPlayer.Builder(context, playbackRenderersFactory(context)) - .setAudioAttributes(mediaType.audioAttributes(), true) - .build() - .also { - created += 1 - player = it - renderedFrame = false - it.addListener( - object : Player.Listener { - override fun onRenderedFirstFrame() { - renderedFrame = true - } - }, - ) - } + DefaultMobilePlayerFactory.create(context, mediaType).also { + created += 1 + player = it + renderedFrame = false + it.addListener( + object : Player.Listener { + override fun onRenderedFirstFrame() { + renderedFrame = true + } + }, + ) + } override fun reportableItem(item: MediaItem, useStartFrom: Boolean): MediaItem = item.withReportingLease(lease) diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt index 857150af..80c902a9 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.click @@ -202,9 +203,15 @@ class MobilePlaybackOptionsProofTest { openSpeedOptions() val choice = compose.onNodeWithText("1.25×") val instrumentation = InstrumentationRegistry.getInstrumentation() - for (ignored in 0 until 8) { - if (choice.fetchSemanticsNode().config.getOrElse(SemanticsProperties.Focused) { false }) break - instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + // The quick speeds are a row under the speed track: down into the row, then across to 1.25×. + for (ignored in 0 until 12) { + if (focused("1.25×")) break + val key = when { + focused("1×") -> KeyEvent.KEYCODE_DPAD_RIGHT + focused("1.5×") || focused("2×") -> KeyEvent.KEYCODE_DPAD_LEFT + else -> KeyEvent.KEYCODE_DPAD_DOWN + } + instrumentation.sendKeyDownUpSync(key) compose.waitForIdle() } choice.assertIsFocused() @@ -214,6 +221,10 @@ class MobilePlaybackOptionsProofTest { } } + private fun focused(label: String) = compose.onAllNodesWithText(label).fetchSemanticsNodes().any { + it.config.getOrElse(SemanticsProperties.Focused) { false } + } + /** The audio choices: the video's Audio side sheet, or Now Playing's sheet under More. */ private fun showOptions() { showControls() From 4ef21286e020bc3d96239fcc07112b6dc75882d3 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 27 Oct 2026 21:18:01 +0300 Subject: [PATCH 3/5] test(mobile): tab past the speed track to the quick speeds --- .../putdotio/android/MobilePlaybackOptionsProofTest.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt index 80c902a9..7935742a 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt @@ -203,15 +203,10 @@ class MobilePlaybackOptionsProofTest { openSpeedOptions() val choice = compose.onNodeWithText("1.25×") val instrumentation = InstrumentationRegistry.getInstrumentation() - // The quick speeds are a row under the speed track: down into the row, then across to 1.25×. + // Focus opens on the speed track, which takes the arrow keys; Tab moves on to the quick speeds. for (ignored in 0 until 12) { if (focused("1.25×")) break - val key = when { - focused("1×") -> KeyEvent.KEYCODE_DPAD_RIGHT - focused("1.5×") || focused("2×") -> KeyEvent.KEYCODE_DPAD_LEFT - else -> KeyEvent.KEYCODE_DPAD_DOWN - } - instrumentation.sendKeyDownUpSync(key) + instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_TAB) compose.waitForIdle() } choice.assertIsFocused() From b422c70e0e02cd2f2097a213a5a51fd3dd794023 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 27 Oct 2026 21:19:35 +0300 Subject: [PATCH 4/5] test(mobile): tap again when the player controls are fading out --- .../io/putdotio/android/MobilePlaybackOptionsProofTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt index 7935742a..95d11df5 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobilePlaybackOptionsProofTest.kt @@ -274,8 +274,11 @@ class MobilePlaybackOptionsProofTest { } else { hasContentDescription("Playback speed ", substring = true) } - if (compose.onAllNodes(trigger).fetchSemanticsNodes().isEmpty()) { + // Controls that are fading out still exist but aren't displayed; a tap brings them back. + repeat(3) { + if (runCatching { compose.onNode(trigger).assertIsDisplayed() }.isSuccess) return compose.onNodeWithTag(MOBILE_PLAYER_GESTURE_TAG).performTouchInput { click() } + compose.waitForIdle() } compose.onNode(trigger).assertIsDisplayed() } From 5b8a1a06d521f668841f16c6f9788626aeef1c97 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 27 Oct 2026 21:26:21 +0300 Subject: [PATCH 5/5] style(mobile): wrap the playback data source line --- .../io/putdotio/android/playback/MobilePlayerFactory.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt index 561a3dc2..2fb49831 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerFactory.kt @@ -115,7 +115,9 @@ internal object DefaultMobilePlayerFactory : MobilePlayerFactory { DefaultMediaSourceFactory( DefaultDataSource.Factory( context, - MobileDownloadCache.get(context).let { it.playbackFactory(it.activeUserId ?: NO_DOWNLOAD_USER) }, + MobileDownloadCache.get(context).let { + it.playbackFactory(it.activeUserId ?: NO_DOWNLOAD_USER) + }, ), ), )