From a88b3d481b624c189889f5b4c7e59f96cb13ded0 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 15:05:03 +0300 Subject: [PATCH 01/16] feat(mobile): cast from the video player and Now Playing The players send to Google's Default Media Receiver with the account's download token on every URL, hand playback over where it stood, and resume locally where the receiver stopped. The player error offers Open in VLC only. Closes #378 --- DESIGN.md | 21 +- README.md | 2 + design/phosphor-icons.lock.json | 28 ++ .../android/playback/PlaybackCastMedia.kt | 146 +++++++ .../android/playback/PlaybackRepository.kt | 41 ++ .../android/playback/PlaybackCastMediaTest.kt | 171 ++++++++ gradle/libs.versions.toml | 5 + mobile/build.gradle.kts | 4 + mobile/proguard-rules.pro | 4 +- mobile/src/main/AndroidManifest.xml | 4 + .../io/putdotio/android/MobileNavigation.kt | 21 + .../downloads/OfflinePlaybackRepository.kt | 8 + .../putdotio/android/playback/MobileCast.kt | 353 ++++++++++++++++ .../android/playback/MobileCastGms.kt | 399 ++++++++++++++++++ .../android/playback/MobileCastPlayback.kt | 245 +++++++++++ .../putdotio/android/playback/MobileCastUi.kt | 289 +++++++++++++ .../playback/MobileNowPlayingScreen.kt | 43 +- .../android/playback/MobilePlayerScreen.kt | 171 ++++++-- .../main/res/drawable/ic_ph_screencast.xml | 10 + .../main/res/drawable/ic_ph_speaker_hifi.xml | 10 + .../main/res/drawable/ic_ph_television.xml | 10 + .../res/drawable/ic_ph_television_simple.xml | 10 + .../src/main/res/layout/widget_transfers.xml | 8 +- .../res/layout/widget_transfers_preview.xml | 10 +- mobile/src/main/res/values/strings.xml | 8 + .../android/MobileCastControllerTest.kt | 299 +++++++++++++ 26 files changed, 2258 insertions(+), 62 deletions(-) create mode 100644 domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt create mode 100644 domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt create mode 100644 mobile/src/main/res/drawable/ic_ph_screencast.xml create mode 100644 mobile/src/main/res/drawable/ic_ph_speaker_hifi.xml create mode 100644 mobile/src/main/res/drawable/ic_ph_television.xml create mode 100644 mobile/src/main/res/drawable/ic_ph_television_simple.xml create mode 100644 mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt diff --git a/DESIGN.md b/DESIGN.md index 4c36c9ae1..6ab11e174 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -643,6 +643,15 @@ over a top and bottom scrim: Subtitles and Speed chips, and Next episode on the right when the account's next-episode setting is on. +**Cast** shows in the top bar only while a receiver is in reach, in yellow +while casting. It opens "Cast to", a bottom sheet at most 560dp wide: 64dp +rows with the kind's glyph (TV, screen, speaker), the name over its model or +"Audio only", a yellow check on the one playing, and Stop casting while +connected. Casting video swaps the picture for black with "Playing on" and the +device, over the cast glyph in `on-surface-variant`, above the same transport, +times and scrubber; only Speed stays, when the receiver takes a playback rate. +Now Playing keeps its layout and names the device in its top bar. + Audio, Subtitles and Speed open a 340dp side sheet from the right with the video dimmed behind: @@ -669,8 +678,8 @@ tile, the filename cut in the middle in `body-sm`, elapsed and total time in mono, pause and close, and a 2dp yellow progress line along its foot. Tapping it opens Now Playing: -- Top: back as a caret down, and More when the file has more than one audio - track. +- Top: back as a caret down, Cast, and More when the file has more than one + audio track. - A large type glyph on a square `surface-container` tile with 28dp corners, 32dp from the sides. - The filename in 20sp medium on a 28sp line, on up to two lines and cut in the @@ -749,8 +758,10 @@ the navigation bar: **Welcome!** as a 40sp medium title on a 48sp line, then - **Resume:** "Resume from 18:42?" over the dimmed player, with **Start over** and a filled **Resume**. - **Player error:** the empty-state voice over the dimmed player, with - **Open in VLC** as the action for a video the player can't play, or **Try - again** when a retry can fix it. + **Open in VLC** as the only action for a video the player can't play, or + **Try again** when a retry can fix it. It never offers Download, which keeps + the same HLS rendition, or a browser, whose history would keep the + tokened link. - **Drop zone:** dragging a link, magnet or .torrent over Transfers shows one dashed yellow area: "Drop to add a transfer". - **Trash:** days left in each row's meta, **Restore** on the row (it confirms, @@ -799,8 +810,6 @@ the navigation bar: **Welcome!** as a 40sp medium title on a 48sp line, then counts elsewhere use the UI face. - Transfer cards and Transfer actions show no peer count: the Kotlin SDK's `Transfer` has no peers field. -- The player has no cast button, and its error has no Download: downloads keep - the HLS rendition. - The viewers have no Download: Downloads hold video and audio only. ## Android TV diff --git a/README.md b/README.md index 81bb0f3a6..b820180bf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Mobile, on the emulator harness against the live API: - Offline downloads with offline playback - Transfer intake from magnet links, `.torrent` files and shared links - Launcher shortcuts, a Transfers widget and the system file picker +- Google Cast from the players, resuming locally where the receiver stopped; + proven only on the emulator with faked data TV: diff --git a/design/phosphor-icons.lock.json b/design/phosphor-icons.lock.json index 9fe9cd7b2..7fab8c5ad 100644 --- a/design/phosphor-icons.lock.json +++ b/design/phosphor-icons.lock.json @@ -835,6 +835,34 @@ "asset": "assets/regular/x-circle.svg", "sourceSha256": "cff9cacbd83ab11a01585dc63af07d289479bdb332708b0de2ad4c953f67d223", "outputSha256": "4cdaa2c5c11806b30172de56bcb5677197e2a2fbf28cd730e3d37a459e3ec840" + }, + { + "resDir": "mobile/src/main/res", + "resource": "ic_ph_screencast", + "asset": "assets/regular/screencast.svg", + "sourceSha256": "2245e0a4a14f671f0b5c0338702b4b7edd70deb7dad5b51fa43e7f47dcae4458", + "outputSha256": "552fd0501b356b5af7a49f1786ed5e8dc2e8e4f285072857a37a0db8d487da25" + }, + { + "resDir": "mobile/src/main/res", + "resource": "ic_ph_television", + "asset": "assets/regular/television.svg", + "sourceSha256": "db6c3b07832e504d5634abfacd740e8aa45358bfc29b884f3ffd6819ec3ed124", + "outputSha256": "fe9c191ea5235c231086ba503a12f9f0d88ffe7395601aa73c7f4f989dc67db1" + }, + { + "resDir": "mobile/src/main/res", + "resource": "ic_ph_television_simple", + "asset": "assets/regular/television-simple.svg", + "sourceSha256": "c443681533640fe60a3c37b0ef7786886845c47b4f3dc2086e9a02b94d0a168a", + "outputSha256": "93995ad0c78623f1d1596937d5ec5d2eb7af6a8aebf1d220cb0a2887db127807" + }, + { + "resDir": "mobile/src/main/res", + "resource": "ic_ph_speaker_hifi", + "asset": "assets/regular/speaker-hifi.svg", + "sourceSha256": "582ba467cd79b96d3c4baae867223ae906c64c46f71e2f5268f89a62bf934427", + "outputSha256": "14c3d14b051677eb30ef76a3ffc440301a65173296fa835697cf47d91b338f22" } ] } diff --git a/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt b/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt new file mode 100644 index 000000000..04cb2481a --- /dev/null +++ b/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt @@ -0,0 +1,146 @@ +package io.putdotio.android.playback + +import io.putdotio.sdk.files.PlaybackSource +import io.putdotio.sdk.files.PlaybackSourceKind +import io.putdotio.sdk.files.PlaybackSubtitles +import java.net.URI +import java.net.URISyntaxException +import java.net.URLEncoder + +/** + * What a Cast receiver plays: the source the app resolved, with every URL carrying the account's + * download token, never the session's access token. The receiver fetches each URL itself, without + * the app's headers, so the token rides in the query; only put.io's API host receives it. + */ +public data class PlaybackCastMedia( + public val fileId: Long, + public val title: String, + public val mediaType: PlaybackMediaType, + public val url: String, + public val contentType: String, + /** + * Whether the receiver may treat the stream as a file of known length. HLS is left to the + * receiver: put.io's playlists carry no stable duration, so it reads seekability from the + * stream, as iOS sends it. + */ + public val buffered: Boolean, + public val durationMillis: Long?, + public val posterUrl: String?, + public val subtitles: List, + /** The subtitle track to show from the start; null for none. */ + public val activeSubtitleId: Long?, +) { + override fun toString(): String = + "PlaybackCastMedia(fileId=$fileId, contentType=$contentType, subtitles=${subtitles.size}, )" +} + +/** A subtitle as a WebVTT text track; [id] is the receiver's track id. */ +public data class PlaybackCastSubtitle( + public val id: Long, + public val url: String, + public val name: String, + public val languageCode: String, +) { + override fun toString(): String = "PlaybackCastSubtitle(id=$id, languageCode=$languageCode, )" +} + +/** + * The Cast form of this source, or null when it cannot go to a receiver: a URL that is not put.io's + * HTTPS API, such as one only this device's cache can answer. Video goes as the source the account's + * playback setting chose, HLS or MP4; audio as the original. Sidecar subtitles, which MP4 and + * original sources list, go as WebVTT tracks; HLS carries its own in the playlist. + */ +public fun PlaybackSource.toCastMedia( + target: PlaybackTarget, + downloadToken: String, + activeSubtitleKey: String? = null, +): PlaybackCastMedia? { + require(downloadToken.isNotBlank()) { "Download token must not be blank" } + val mediaUrl = url.value.withDownloadToken(downloadToken) ?: return null + val sidecar = (subtitles as? PlaybackSubtitles.Sidecar)?.tracks.orEmpty() + val tracks = sidecar.mapNotNull { subtitle -> + subtitle.url.value.withDownloadToken(downloadToken, SUBTITLE_FORMAT_QUERY to WEBVTT)?.let { subtitle to it } + } + val castSubtitles = tracks.mapIndexed { index, (subtitle, trackUrl) -> + PlaybackCastSubtitle( + id = index + 1L, + url = trackUrl, + name = subtitle.language.ifBlank { subtitle.name }, + languageCode = subtitle.languageCode, + ) + } + val activeIndex = tracks.indexOfFirst { (subtitle, _) -> subtitle.key == activeSubtitleKey } + return PlaybackCastMedia( + fileId = fileId, + title = target.name, + mediaType = target.mediaType, + url = mediaUrl, + contentType = castContentType(target), + buffered = kind != PlaybackSourceKind.HLS, + durationMillis = target.durationSeconds?.toPlaybackMillis()?.takeIf { it > 0L }, + posterUrl = target.posterUrl?.takeIf { it.startsWith("https://") }, + subtitles = castSubtitles, + activeSubtitleId = castSubtitles.getOrNull(activeIndex)?.id, + ) +} + +private fun PlaybackSource.castContentType(target: PlaybackTarget): String = + when (kind) { + PlaybackSourceKind.HLS -> HLS_CONTENT_TYPE + PlaybackSourceKind.MP4 -> MP4_CONTENT_TYPE + PlaybackSourceKind.ORIGINAL -> when (target.mediaType) { + PlaybackMediaType.AUDIO -> AUDIO_CONTENT_TYPES[target.name.extension()] ?: DEFAULT_AUDIO_CONTENT_TYPE + PlaybackMediaType.VIDEO -> VIDEO_CONTENT_TYPES[target.name.extension()] ?: MP4_CONTENT_TYPE + } + } + +private fun String.extension(): String = substringAfterLast('.', missingDelimiterValue = "").lowercase() + +/** + * This API URL with the download token as its only credential, plus [extra] query values; null for + * any other host or scheme. Whatever credential the URL held, the session's access token included, + * is dropped. + */ +private fun String.withDownloadToken(token: String, vararg extra: Pair): String? { + val uri = apiUri() ?: return null + val replaced = setOf(OAUTH_TOKEN_QUERY) + extra.map { it.first } + val kept = uri.rawQuery.orEmpty().split('&') + .filter { it.isNotEmpty() && it.substringBefore('=') !in replaced } + val added = (extra.toList() + (OAUTH_TOKEN_QUERY to token)).map { (name, value) -> "$name=${value.encoded()}" } + return "https://$PUTIO_API_HOST${uri.rawPath}?" + (kept + added).joinToString("&") +} + +/** This URL when it is put.io's HTTPS API; null for any other host, scheme or a malformed URL. */ +@Suppress("SwallowedException") +private fun String.apiUri(): URI? = + try { + URI(this).takeIf { it.scheme == "https" && it.host == PUTIO_API_HOST && !it.rawPath.isNullOrEmpty() } + } catch (_: URISyntaxException) { + null + } + +private fun String.encoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name()) + +private const val PUTIO_API_HOST = "api.put.io" +private const val OAUTH_TOKEN_QUERY = "oauth_token" +private const val SUBTITLE_FORMAT_QUERY = "format" +private const val WEBVTT = "webvtt" +private const val HLS_CONTENT_TYPE = "application/x-mpegURL" +private const val MP4_CONTENT_TYPE = "video/mp4" +private const val DEFAULT_AUDIO_CONTENT_TYPE = "audio/mpeg" +private val AUDIO_CONTENT_TYPES = mapOf( + "mp3" to "audio/mpeg", + "m4a" to "audio/mp4", + "m4b" to "audio/mp4", + "aac" to "audio/aac", + "flac" to "audio/flac", + "ogg" to "audio/ogg", + "oga" to "audio/ogg", + "opus" to "audio/ogg", + "wav" to "audio/wav", +) +private val VIDEO_CONTENT_TYPES = mapOf( + "mp4" to MP4_CONTENT_TYPE, + "m4v" to MP4_CONTENT_TYPE, + "webm" to "video/webm", +) diff --git a/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt b/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt index 96497cb58..1504276a7 100644 --- a/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt +++ b/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackRepository.kt @@ -114,6 +114,19 @@ public interface PlaybackRepository { */ public suspend fun findNeighbours(target: PlaybackTarget): PlaybackRepositoryResult = PlaybackRepositoryResult.Success(PlaybackNeighbours()) + + /** + * [source] as a Cast receiver plays it ([toCastMedia]), with the account's download token on + * every URL, as Open in VLC hands it. No download token fails as + * [PlaybackFailure.MediaCredentialUnavailable]; a source no receiver can reach as + * [PlaybackFailure.MediaUnsupported]. + */ + public suspend fun castMedia( + target: PlaybackTarget, + source: PlaybackSource, + activeSubtitleKey: String? = null, + ): PlaybackRepositoryResult = + PlaybackRepositoryResult.Failure(PlaybackFailure.MediaUnsupported(UncastableSourceException())) } /** What sits around a playing file in its folder; null where there is nothing, or nothing known. */ @@ -201,6 +214,32 @@ internal class SdkPlaybackRepository internal constructor( PlaybackRepositoryResult.Failure(PlaybackFailure.Putio(PutioFailure.Unexpected(unexpected))) } + @Suppress("TooGenericExceptionCaught") + override suspend fun castMedia( + target: PlaybackTarget, + source: PlaybackSource, + activeSubtitleKey: String?, + ): PlaybackRepositoryResult = + try { + // Never the session's access token: the receiver keeps what it is sent. + val token = loadAccount().downloadToken + val media = token?.let { source.toCastMedia(target, it.value, activeSubtitleKey) } + when { + token == null -> PlaybackRepositoryResult.Failure( + PlaybackFailure.MediaCredentialUnavailable(MissingPlaybackCredentialException()), + ) + media == null -> + PlaybackRepositoryResult.Failure(PlaybackFailure.MediaUnsupported(UncastableSourceException())) + else -> PlaybackRepositoryResult.Success(media) + } + } catch (error: CancellationException) { + throw error + } catch (error: PutioException) { + PlaybackRepositoryResult.Failure(error.toPlaybackFailure()) + } catch (unexpected: Exception) { + PlaybackRepositoryResult.Failure(PlaybackFailure.Putio(PutioFailure.Unexpected(unexpected))) + } + // The near-end rule needs a video's duration, and so does Continue watching, even for a video never started. private fun PlaybackTarget.needsDuration(): Boolean = durationSeconds == null && mediaType == PlaybackMediaType.VIDEO @@ -402,6 +441,8 @@ private const val AUTOPLAY_PAGE_SIZE = 200 internal class MissingPlaybackCredentialException : IllegalStateException("Playback credential is unavailable") +internal class UncastableSourceException : IllegalStateException("This source cannot play on a Cast receiver") + private fun io.putdotio.sdk.files.PlaybackResolution.toAppResolution( useStartFrom: Boolean, subtitlesHidden: Boolean, diff --git a/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt b/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt new file mode 100644 index 000000000..b4c5b0743 --- /dev/null +++ b/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt @@ -0,0 +1,171 @@ +package io.putdotio.android.playback + +import io.putdotio.android.files.FilesItemId +import io.putdotio.sdk.account.AccountDisk +import io.putdotio.sdk.account.AccountDownloadToken +import io.putdotio.sdk.account.AccountInfo +import io.putdotio.sdk.account.AccountSettings +import io.putdotio.sdk.files.PlaybackSource +import io.putdotio.sdk.files.PlaybackSourceKind +import io.putdotio.sdk.files.PlaybackSubtitle +import io.putdotio.sdk.files.PlaybackSubtitles +import io.putdotio.sdk.files.PutioCredentialUrl +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaybackCastMediaTest { + @Test + fun hlsGoesWithTheDownloadTokenInPlaceOfTheSessionToken() { + val media = source( + PlaybackSourceKind.HLS, + "https://api.put.io/v2/files/42/hls/media.m3u8?subtitle_languages=eng&oauth_token=$ACCESS", + PlaybackSubtitles.Embedded, + ).castable(Video) + + assertEquals( + "https://api.put.io/v2/files/42/hls/media.m3u8?subtitle_languages=eng&oauth_token=$DOWNLOAD", + media.url, + ) + assertEquals("application/x-mpegURL", media.contentType) + // put.io's playlists carry no stable duration; the receiver reads the stream. + assertFalse(media.buffered) + // The playlist carries its own subtitle renditions. + assertTrue(media.subtitles.isEmpty()) + assertEquals("episode.mkv", media.title) + assertFalse(media.toString().contains(DOWNLOAD)) + } + + @Test + fun sidecarSubtitlesGoAsWebVttWithTheDownloadTokenOnly() { + // put.io lists sidecar subtitle URLs with the caller's own access token in the query. + val media = source( + PlaybackSourceKind.MP4, + "https://api.put.io/v2/files/42/mp4/download?oauth_token=$ACCESS", + PlaybackSubtitles.Sidecar( + listOf( + subtitle("first", "https://api.put.io/v2/files/42/subtitles/first?oauth_token=$ACCESS"), + subtitle("elsewhere", "https://subtitles.example.com/42.srt"), + subtitle("second", "https://api.put.io/v2/files/42/subtitles/second?format=srt"), + ), + ), + ).castable(Video.copy(durationSeconds = 90.5), activeSubtitleKey = "second") + + assertEquals("video/mp4", media.contentType) + assertTrue(media.buffered) + assertEquals(90_500L, media.durationMillis) + assertEquals( + listOf( + "https://api.put.io/v2/files/42/subtitles/first?format=webvtt&oauth_token=$DOWNLOAD", + "https://api.put.io/v2/files/42/subtitles/second?format=webvtt&oauth_token=$DOWNLOAD", + ), + media.subtitles.map { it.url }, + ) + assertEquals(listOf(1L, 2L), media.subtitles.map { it.id }) + assertEquals(2L, media.activeSubtitleId) + assertFalse((media.subtitles.map { it.url } + media.url).any { it.contains(ACCESS) }) + } + + @Test + fun aDownloadsTokenFreeApiUrlTakesTheDownloadToken() { + val media = source( + PlaybackSourceKind.HLS, + "https://api.put.io/v2/files/42/hls/media.m3u8", + PlaybackSubtitles.Embedded, + ).castable(Video) + + assertEquals("https://api.put.io/v2/files/42/hls/media.m3u8?oauth_token=$DOWNLOAD", media.url) + } + + @Test + fun onlyPutioApiUrlsReachAReceiver() { + listOf( + "http://api.put.io/v2/files/42/stream", + "https://cdn.example.com/v2/files/42/stream", + ).forEach { url -> + val source = source(PlaybackSourceKind.ORIGINAL, url, PlaybackSubtitles.None) + assertNull(url, source.toCastMedia(Video, DOWNLOAD)) + } + } + + @Test + fun audioGoesAsTheOriginalWithItsOwnContentType() { + val stream = "https://api.put.io/v2/files/42/stream" + val original = source(PlaybackSourceKind.ORIGINAL, stream, PlaybackSubtitles.None) + val flac = original.castable(PlaybackTarget(FilesItemId(42L), "Tree Rings.FLAC", PlaybackMediaType.AUDIO)) + val unknown = original.castable(PlaybackTarget(FilesItemId(42L), "episode", PlaybackMediaType.AUDIO)) + + assertEquals("audio/flac", flac.contentType) + assertEquals(PlaybackMediaType.AUDIO, flac.mediaType) + assertEquals("audio/mpeg", unknown.contentType) + } + + @Test + fun theRepositoryCastsWithTheAccountsDownloadToken() = runBlocking { + val hls = source( + PlaybackSourceKind.HLS, + "https://api.put.io/v2/files/42/hls/media.m3u8?oauth_token=$ACCESS", + PlaybackSubtitles.Embedded, + ) + fun repository(token: AccountDownloadToken?) = + SdkPlaybackRepository( + playbackPreference = { io.putdotio.sdk.files.PlaybackPreference.HLS }, + loadAccount = { account(token) }, + resolvePlayback = { error("Casting reads no playback") }, + ) + + val ready = repository(AccountDownloadToken(DOWNLOAD)).castMedia(Video, hls) + val missing = repository(null).castMedia(Video, hls) + val uncastable = repository(AccountDownloadToken(DOWNLOAD)) + .castMedia(Video, source(PlaybackSourceKind.HLS, "https://cdn.example.com/a.m3u8", PlaybackSubtitles.None)) + + assertTrue(((ready as PlaybackRepositoryResult.Success).value).url.endsWith("oauth_token=$DOWNLOAD")) + // Never the session's access token in its place. + assertTrue((missing as PlaybackRepositoryResult.Failure).failure is PlaybackFailure.MediaCredentialUnavailable) + assertTrue((uncastable as PlaybackRepositoryResult.Failure).failure is PlaybackFailure.MediaUnsupported) + } + + private fun PlaybackSource.castable(target: PlaybackTarget, activeSubtitleKey: String? = null) = + requireNotNull(toCastMedia(target, DOWNLOAD, activeSubtitleKey)) + + private fun source(kind: PlaybackSourceKind, url: String, subtitles: PlaybackSubtitles) = + PlaybackSource( + fileId = 42L, + kind = kind, + url = PutioCredentialUrl.of(url), + startFromSeconds = 0.0, + subtitles = subtitles, + ) + + private fun subtitle(key: String, url: String) = + PlaybackSubtitle( + format = "srt", + key = key, + language = "English", + languageCode = "eng", + name = "$key.srt", + source = "opensubtitles", + url = PutioCredentialUrl.of(url), + ) + + private fun account(token: AccountDownloadToken?) = + AccountInfo( + userId = 1L, + username = "test", + mail = "test@example.com", + avatarUrl = "https://example.com/avatar.png", + disk = AccountDisk(available = 1L, size = 2L, used = 1L), + settings = AccountSettings(sortBy = "NAME_ASC", useStartFrom = true), + accountStatus = "active", + downloadToken = token, + ) + + private companion object { + const val ACCESS = "session-access-token" + const val DOWNLOAD = "download-token" + val Video = PlaybackTarget(FilesItemId(42L), "episode.mkv") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 837afc749..a40d9368d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,8 +10,10 @@ coroutines = "1.11.0" lifecycle = "2.11.0" navigationCompose = "2.10.2" media3 = "1.11.1" +mediarouter = "1.8.1" okhttp = "5.5.1" playPublisher = "4.1.1" +playServicesCastFramework = "22.3.1" putioSdkKotlin = "2.0.0" sentry = "8.61.1" sentryGradlePlugin = "6.24.0" @@ -54,6 +56,7 @@ androidx-media3-session = { module = "androidx.media3:media3-session", version.r androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "media3" } androidx-media3-ui-compose-material3 = { module = "androidx.media3:media3-ui-compose-material3", version.ref = "media3" } +androidx-mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" } androidx-test-ext-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "androidxTestExtJunit" } @@ -67,6 +70,8 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } putio-sdk-kotlin = { module = "io.put:putio-sdk-kotlin", version.ref = "putioSdkKotlin" } +# Google Cast sends from the mobile players only; the TV app builds the same id and never sends. +play-services-cast-framework = { module = "com.google.android.gms:play-services-cast-framework", version.ref = "playServicesCastFramework" } # Core only: JVM crashes and ANRs. No NDK, replay or performance modules. sentry-android-core = { module = "io.sentry:sentry-android-core", version.ref = "sentry" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index a4827655f..e6aa55b59 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -203,6 +203,10 @@ dependencies { implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.ui.compose.material3) + // Google Cast: the device picker reads MediaRouter's routes; sessions and the receiver's media + // run through the Cast framework, which stays dormant without Google Play services. + implementation(libs.androidx.mediarouter) + implementation(libs.play.services.cast.framework) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) implementation(libs.kotlinx.coroutines.android) diff --git a/mobile/proguard-rules.pro b/mobile/proguard-rules.pro index aa5a9ce8d..f8991d42f 100644 --- a/mobile/proguard-rules.pro +++ b/mobile/proguard-rules.pro @@ -1 +1,3 @@ -# The minified SDK-consumer proof currently requires no app-specific keep rules. +# The Cast framework instantiates its options provider by the class name the manifest's +# meta-data gives, which R8 cannot see. +-keep class io.putdotio.android.playback.MobileCastOptionsProvider { (); } diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 948216c48..85cb3bc50 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -162,6 +162,10 @@ android:name="androidx.media3.exoplayer.scheduler.PlatformScheduler$PlatformSchedulerService" android:exported="true" android:permission="android.permission.BIND_JOB_SERVICE" /> + + 0.0 } val target = PlaybackTarget(FilesItemId(fileId), name, mediaType, durationSeconds) + val castContext = LocalContext.current + val cast = remember(castContext) { MobileCast.controller(castContext) } val playbackViewModel: MobilePlaybackViewModel = viewModel( viewModelStoreOwner = backStackEntry, @@ -395,6 +402,8 @@ internal fun MobileNavHost( onOpenInVlc = filesActions?.let { actions -> { target -> actions.openInVlc(target.fileId, target.name) } }, + cast = cast, + castMedia = playbackRepository::castMedia, ) // Over the player, which paints its own background. filesActions?.let { MobileFilesNoticeHost(it.notices, Modifier.align(Alignment.BottomCenter)) } @@ -445,6 +454,9 @@ private fun MobilePlaybackRoute( findNeighbours: (suspend (PlaybackTarget) -> PlaybackRepositoryResult)? = null, onPlay: (PlaybackTarget) -> Unit = {}, onOpenInVlc: ((PlaybackTarget) -> Unit)? = null, + cast: MobileCastController? = null, + castMedia: (suspend (PlaybackTarget, PlaybackSource, String?) -> PlaybackRepositoryResult)? = + null, ) { val state by controller.state.collectAsStateWithLifecycle() // With the account's next_episode on, a playing video asks autoplay's folder walk for the one after it, once. @@ -498,9 +510,18 @@ private fun MobilePlaybackRoute( neighbours = neighbours, onPlay = onPlay, onOpenInVlc = onOpenInVlc?.let { open -> { open(state.target) } }, + cast = cast, + castMedia = castMediaFor(castMedia, state.target), ) } +/** [load] for the file on screen, or no Cast without it. */ +private fun castMediaFor( + load: (suspend (PlaybackTarget, PlaybackSource, String?) -> PlaybackRepositoryResult)?, + target: PlaybackTarget, +): MobileCastMediaLoader? = + if (load == null) null else { source, subtitleKey -> load(target, source, subtitleKey) } + /** * Playing audio asks for its folder once: the files around it for the skip buttons and Up next, and * the folder's name. A failed read leaves them off; playback does not depend on it. diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/OfflinePlaybackRepository.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/OfflinePlaybackRepository.kt index 751ecefd3..d0a37f110 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/downloads/OfflinePlaybackRepository.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/OfflinePlaybackRepository.kt @@ -1,6 +1,7 @@ package io.putdotio.android.downloads import io.putdotio.android.files.FilesItemId +import io.putdotio.android.playback.PlaybackCastMedia import io.putdotio.android.playback.PlaybackNeighbours import io.putdotio.android.playback.PlaybackNextResult import io.putdotio.android.playback.PlaybackRepository @@ -91,6 +92,13 @@ internal class OfflinePlaybackRepository( override suspend fun startConversion(target: PlaybackTarget): PlaybackRepositoryResult = delegate.startConversion(target) + // A receiver streams a downloaded file from put.io: the token-free API URL gets the download token. + override suspend fun castMedia( + target: PlaybackTarget, + source: PlaybackSource, + activeSubtitleKey: String?, + ): PlaybackRepositoryResult = delegate.castMedia(target, source, activeSubtitleKey) + override suspend fun findNextVideo(target: PlaybackTarget): PlaybackNextResult = delegate.findNextVideo(target) override suspend fun findNeighbours(target: PlaybackTarget): PlaybackRepositoryResult = diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt new file mode 100644 index 000000000..271c65b2f --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -0,0 +1,353 @@ +package io.putdotio.android.playback + +import android.os.Handler +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.DeviceInfo +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import io.putdotio.sdk.files.PlaybackSource +import io.putdotio.sdk.files.PlaybackSubtitles +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** Where Cast stands on this device; the Cast button shows only once a receiver is in reach. */ +internal sealed interface CastAvailability { + /** No Google Play services, or Cast failed to start: no button. */ + data object Unsupported : CastAvailability + + data object NoDevices : CastAvailability + + data object Available : CastAvailability + + data class Connecting(val deviceName: String?) : CastAvailability + + data class Connected(val deviceName: String) : CastAvailability +} + +internal val CastAvailability.showsButton: Boolean + get() = this != CastAvailability.Unsupported && this != CastAvailability.NoDevices + +internal val CastAvailability.connectedDevice: String? + get() = (this as? CastAvailability.Connected)?.deviceName + +internal enum class CastRemoteState { Idle, Buffering, Playing, Paused, Ended, Failed } + +/** What the receiver last said about its media; [fileId] is null for media this app did not send. */ +internal data class CastRemoteStatus( + val fileId: Long?, + val state: CastRemoteState, + val positionMillis: Long, + val durationMillis: Long?, + val speed: Float = 1f, + val speedSupported: Boolean = false, + val seekable: Boolean = true, + /** The clock reading the position was taken at; a playing position moves on from it. */ + val sampledAtMillis: Long, +) { + val playing: Boolean get() = state == CastRemoteState.Playing || state == CastRemoteState.Buffering + + fun positionAt(nowMillis: Long): Long { + val moved = if (state == CastRemoteState.Playing) ((nowMillis - sampledAtMillis) * speed).toLong() else 0L + val position = (positionMillis + moved.coerceAtLeast(0L)).coerceAtLeast(0L) + return durationMillis?.let(position::coerceAtMost) ?: position + } +} + +internal enum class CastDeviceKind { Tv, Speaker, Other } + +/** A receiver in the picker: [model] names the device, [audioOnly] one that has no screen. */ +internal data class CastDevice( + val id: String, + val name: String, + val model: String?, + val audioOnly: Boolean, + val kind: CastDeviceKind, + val selected: Boolean, +) + +/** Local playback as it hands over: where, whether it plays, and at what speed. */ +internal data class CastHandover( + val positionMillis: Long, + val playing: Boolean, + val speed: Float = 1f, +) + +/** The Cast SDK, behind what the app needs of it. Calls and callbacks run on the main thread. */ +internal interface CastGateway : CastRoutes, CastRemote { + val availability: StateFlow + + /** Ends the session and stops the receiver's playback. */ + fun stopCasting() +} + +/** The receivers the picker lists. */ +internal interface CastRoutes { + /** The receivers in reach while [startDiscovery] runs. */ + val devices: StateFlow> + + fun startDiscovery() + + fun stopDiscovery() + + fun select(deviceId: String) +} + +/** The connected receiver's media: its status, and the commands that drive it. */ +internal interface CastRemote { + /** Null without a session; the last status before the session ends hands the file back. */ + val status: StateFlow + + fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) + + fun play() + + fun pause() + + fun seekTo(positionMillis: Long) + + fun setSpeed(speed: Float) +} + +/** + * Hands playback between this device and a Cast receiver. Process-wide, as the Cast session is: it + * keeps the receiver's last word on each file, so the local player resumes where the receiver + * stopped once the session ends, whichever screen is open then. + */ +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +internal class MobileCastController( + val gateway: CastGateway, + scope: CoroutineScope, + private val clock: () -> Long, +) { + val availability: StateFlow get() = gateway.availability + val status: StateFlow get() = gateway.status + + /** The receiver as a Media3 player, for the player screen's controls and position reporting. */ + val remotePlayer: CastRemotePlayer = CastRemotePlayer(gateway, scope, clock) { replay(it) } + + private val handovers = mutableMapOf() + private var lastStatus: CastRemoteStatus? = null + private var lastMedia: PlaybackCastMedia? = null + + init { + scope.launch { + gateway.status.collect { status -> + val previous = lastStatus + // The gateway clears the status only as the session ends: freeze where the receiver + // stood, for the file's next local start. A file that finished hands back stopped. + val fileId = previous?.fileId + if (status == null && fileId != null) handovers[fileId] = previous.handover() + lastStatus = status + } + } + } + + /** Whether the receiver holds [fileId] now, so a screen opened on it attaches instead of loading. */ + fun holds(fileId: Long): Boolean = + gateway.status.value?.let { it.fileId == fileId && it.state != CastRemoteState.Idle } == true + + /** + * Sends [media] to the receiver from where local playback stood, unless the receiver already + * plays it; [item] is the local item with its reporting lease, so the receiver's positions + * reach put.io. Does nothing without a session. + */ + fun cast(media: PlaybackCastMedia, item: MediaItem, local: CastHandover): Boolean { + if (gateway.availability.value !is CastAvailability.Connected) return false + remotePlayer.adopt(item) + val loads = !holds(media.fileId) + if (loads) { + handovers.remove(media.fileId) + lastMedia = media + gateway.load(media, local.positionMillis.coerceAtLeast(0L), local.playing, local.speed) + } + return loads + } + + /** Ties the receiver's controls to [item] without loading it: the receiver already plays it. */ + fun attach(item: MediaItem) = remotePlayer.adopt(item) + + /** Where the local player picks [fileId] up after a session ended; taken once, null while casting. */ + fun takeHandover(fileId: Long): CastHandover? { + if (gateway.availability.value is CastAvailability.Connected) return null + val stored = handovers.remove(fileId) + // The session ended a moment ago and the status collector has not run yet. + val pending = lastStatus?.takeIf { stored == null && gateway.status.value == null && it.fileId == fileId } + if (pending != null) lastStatus = null + return stored ?: pending?.handover() + } + + private fun CastRemoteStatus.handover() = CastHandover(positionAt(clock()), playing, speed) + + // A finished file has left the receiver; playing it again sends it once more. + private fun replay(startMillis: Long) { + val media = lastMedia ?: return + if (gateway.status.value?.fileId != media.fileId) return + gateway.load(media, startMillis, autoplay = true, speed = gateway.status.value?.speed ?: 1f) + } +} + +/** + * The subtitle the receiver shows first, by the same rules as the local player: none when the + * account hides subtitles or the viewer turned them off, the viewer's pick, else put.io's default + * (the list's first) when the account selects one. + */ +internal fun castSubtitleKey( + source: PlaybackSource, + selection: SubtitleSelection?, + policy: SubtitleStartupPolicy?, +): String? { + val keys = (source.subtitles as? PlaybackSubtitles.Sidecar)?.tracks.orEmpty().map { it.key } + if (keys.isEmpty() || policy?.showSubtitles == false) return null + return when (selection) { + SubtitleSelection.Off -> null + is SubtitleSelection.Track -> selection.identity.id?.let { id -> + // Media3 may prefix a sidecar track's id with its source's index. + keys.firstOrNull { key -> id == key || id.endsWith(":$key") } + } + SubtitleSelection.Automatic -> keys.first() + null -> keys.first().takeIf { policy?.autoSelectSubtitles != false } + } +} + +/** + * The receiver as a Media3 [Player]: the player screen's transport, scrubber and speed sheet drive + * it, and the position observer reports its positions. A command shows at once and settles with + * the receiver's next status. + */ +@UnstableApi +internal class CastRemotePlayer( + private val gateway: CastGateway, + scope: CoroutineScope, + private val clock: () -> Long, + private val replay: (Long) -> Unit, +) : SimpleBasePlayer(Looper.getMainLooper()) { + private var item: MediaItem? = null + private var status: CastRemoteStatus? = gateway.status.value + private val pending = mutableListOf>() + private val handler = Handler(Looper.getMainLooper()) + + /** A finished file sent again, until the receiver reports on it. */ + private var replaying = false + + init { + scope.launch { + gateway.status.collect { + status = it + replaying = false + settle() + invalidateState() + } + } + } + + fun adopt(item: MediaItem) { + this.item = item + invalidateState() + } + + override fun getState(): State { + val current = status + val adopted = item?.takeIf { current?.fileId != null && it.mediaId == current.fileId.toString() } + val builder = State.Builder() + .setDeviceInfo(DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build()) + .setAvailableCommands(commands(current?.takeIf { adopted != null })) + if (current == null || adopted == null) return builder.setPlaybackState(STATE_IDLE).build() + val duration = current.durationMillis?.takeIf { it > 0L } + return builder + .setPlaylist( + listOf( + MediaItemData.Builder(adopted.mediaId) + .setMediaItem(adopted) + .setDurationUs(duration?.let { it * MICROS_PER_MILLI } ?: C.TIME_UNSET) + .setIsSeekable(current.seekable) + .build(), + ), + ) + .setCurrentMediaItemIndex(0) + .setPlaybackState( + when (current.state) { + CastRemoteState.Buffering -> STATE_BUFFERING + CastRemoteState.Playing, CastRemoteState.Paused -> STATE_READY + CastRemoteState.Ended -> STATE_ENDED + CastRemoteState.Idle, CastRemoteState.Failed -> STATE_IDLE + }, + ) + .setPlayWhenReady(current.playing, PLAY_WHEN_READY_CHANGE_REASON_REMOTE) + .setPlaybackParameters(PlaybackParameters(current.speed)) + .setContentPositionMs( + if (current.state == CastRemoteState.Playing) { + PositionSupplier.getExtrapolating(current.positionAt(clock()), current.speed) + } else { + PositionSupplier.getConstant(current.positionAt(clock())) + }, + ) + .build() + } + + private fun commands(current: CastRemoteStatus?): Player.Commands { + val builder = Player.Commands.Builder() + if (current == null) return builder.build() + builder.addAll( + COMMAND_PLAY_PAUSE, + COMMAND_GET_CURRENT_MEDIA_ITEM, + COMMAND_GET_TIMELINE, + COMMAND_GET_METADATA, + ) + if (current.seekable) builder.addAll(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, COMMAND_SEEK_TO_DEFAULT_POSITION) + if (current.speedSupported) builder.add(COMMAND_SET_SPEED_AND_PITCH) + return builder.build() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + when { + // Play after a finish seeks to the start first, which already sent the file again. + playWhenReady && replaying -> Unit + playWhenReady && status?.state == CastRemoteState.Ended -> replayFrom(0L) + playWhenReady -> gateway.play() + else -> gateway.pause() + } + return awaitStatus() + } + + override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + val position = if (positionMs == C.TIME_UNSET) 0L else positionMs.coerceAtLeast(0L) + if (status?.state == CastRemoteState.Ended && !replaying) replayFrom(position) else gateway.seekTo(position) + return awaitStatus() + } + + private fun replayFrom(positionMillis: Long) { + replaying = true + replay(positionMillis) + } + + override fun handleSetPlaybackParameters(playbackParameters: PlaybackParameters): ListenableFuture<*> { + gateway.setSpeed(playbackParameters.speed) + return awaitStatus() + } + + // The placeholder state holds until the receiver answers, or for a while when it does not. + private fun awaitStatus(): ListenableFuture<*> { + val future = SettableFuture.create() + pending += future + handler.postDelayed({ if (pending.remove(future)) future.set(null) }, STATUS_WAIT_MILLIS) + return future + } + + private fun settle() { + val settled = pending.toList() + pending.clear() + settled.forEach { it.set(null) } + } + + private companion object { + const val MICROS_PER_MILLI = 1_000L + const val STATUS_WAIT_MILLIS = 5_000L + } +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt new file mode 100644 index 000000000..2e1acd32e --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -0,0 +1,399 @@ +package io.putdotio.android.playback + +import android.content.Context +import androidx.core.net.toUri +import android.os.SystemClock +import androidx.core.content.ContextCompat +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import com.google.android.gms.cast.CastDevice as GmsCastDevice +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaLoadRequestData +import com.google.android.gms.cast.MediaMetadata +import com.google.android.gms.cast.MediaSeekOptions +import com.google.android.gms.cast.MediaStatus +import com.google.android.gms.cast.MediaTrack +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.CastState +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.cast.framework.SessionProvider +import com.google.android.gms.cast.framework.media.CastMediaOptions +import com.google.android.gms.cast.framework.media.MediaIntentReceiver +import com.google.android.gms.cast.framework.media.NotificationOptions +import com.google.android.gms.cast.framework.media.RemoteMediaClient +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.images.WebImage +import io.putdotio.android.MainActivity +import io.putdotio.android.R +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONObject + +/** The process's Cast controller, started on first use; null where Google Play services is missing. */ +internal object MobileCast { + @Volatile + private var controller: MobileCastController? = null + + fun controller(context: Context): MobileCastController? { + val appContext = context.applicationContext + if (!playServicesAvailable(appContext)) return null + return controller ?: synchronized(this) { + controller ?: run { + val gateway = GmsCastGateway(appContext).also { it.start() } + MobileCastController(gateway, MainScope(), SystemClock::elapsedRealtime).also { cast -> + // The receiver's positions reach put.io as the local player's do, under the item's lease. + DefaultMobilePlayerFactory.observePositions(appContext, cast.remotePlayer) + controller = cast + } + } + } + } + + // A device without Play services, or one whose Play services cannot answer, has no Cast. + @Suppress("SwallowedException") + private fun playServicesAvailable(context: Context): Boolean = + try { + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + } catch (_: RuntimeException) { + false + } +} + +/** + * Google's Default Media Receiver, as putio-web and putio-ios use it: put.io has no receiver of its + * own. While casting, the Cast SDK's own notification and media session carry the remote controls; + * the local player has stopped, so there is one of each. + */ +class MobileCastOptionsProvider : OptionsProvider { + override fun getCastOptions(context: Context): CastOptions { + val notification = NotificationOptions.Builder() + .setActions( + listOf( + MediaIntentReceiver.ACTION_REWIND, + MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK, + MediaIntentReceiver.ACTION_FORWARD, + MediaIntentReceiver.ACTION_STOP_CASTING, + ), + // The compact notification keeps play and pause, and stop casting. + intArrayOf(TOGGLE_ACTION_INDEX, STOP_ACTION_INDEX), + ) + .setSkipStepMs(NOTIFICATION_SKIP_MILLIS) + .setSmallIconDrawableResId(R.drawable.ic_ph_screencast) + .setTargetActivityClassName(MainActivity::class.java.name) + .build() + return CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .setCastMediaOptions( + CastMediaOptions.Builder() + .setNotificationOptions(notification) + .setMediaSessionEnabled(true) + .build(), + ) + .setStopReceiverApplicationWhenEndingSession(true) + .build() + } + + override fun getAdditionalSessionProviders(context: Context): List? = null + + private companion object { + const val NOTIFICATION_SKIP_MILLIS = 10_000L + const val TOGGLE_ACTION_INDEX = 1 + const val STOP_ACTION_INDEX = 3 + } +} + +/** + * [CastGateway] over the Cast SDK and MediaRouter. Without Google Play services, or when Cast fails + * to start, it stays [CastAvailability.Unsupported] and touches nothing else. + */ +internal class GmsCastGateway private constructor( + private val context: Context, + private val routes: GmsCastRoutes, + private val remote: GmsCastRemote, +) : CastGateway, CastRoutes by routes, CastRemote by remote { + constructor(context: Context) : this(context, GmsCastRoutes(context), GmsCastRemote()) + + private val mutableAvailability = MutableStateFlow(CastAvailability.Unsupported) + override val availability: StateFlow = mutableAvailability + private var castContext: CastContext? = null + + // Play services runs Cast in its own process; a failure to start it means no Cast here. + @Suppress("SwallowedException") + fun start() { + try { + CastContext.getSharedInstance(context, ContextCompat.getMainExecutor(context)) + .addOnSuccessListener(::bind) + } catch (_: RuntimeException) { + mutableAvailability.value = CastAvailability.Unsupported + } + } + + private fun bind(cast: CastContext) { + castContext = cast + routes.selector = cast.mergedSelector + cast.addCastStateListener { updateAvailability() } + cast.sessionManager.addSessionManagerListener(sessions, CastSession::class.java) + cast.sessionManager.currentCastSession?.takeIf { it.isConnected }?.let(::attach) + updateAvailability() + } + + /** + * Connected spans exactly the time a media client is attached: a screen that sees it can load at + * once, and the receiver's last status is out before a screen sees the session gone. + */ + private fun updateAvailability() { + val cast = castContext ?: return + val device = cast.sessionManager.currentCastSession?.castDevice?.friendlyName + mutableAvailability.value = when { + remote.attached -> CastAvailability.Connected(device.orEmpty()) + cast.castState == CastState.NO_DEVICES_AVAILABLE -> CastAvailability.NoDevices + cast.castState == CastState.CONNECTING || cast.castState == CastState.CONNECTED -> + CastAvailability.Connecting(device) + else -> CastAvailability.Available + } + } + + private val sessions = object : SessionManagerListener { + override fun onSessionStarting(session: CastSession) = updateAvailability() + + override fun onSessionStarted(session: CastSession, sessionId: String) = attach(session) + + override fun onSessionStartFailed(session: CastSession, error: Int) = updateAvailability() + + // The receiver's last position, read while the session still answers. + override fun onSessionEnding(session: CastSession) = remote.publish() + + override fun onSessionEnded(session: CastSession, error: Int) = detach() + + override fun onSessionResuming(session: CastSession, sessionId: String) = updateAvailability() + + override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) = attach(session) + + override fun onSessionResumeFailed(session: CastSession, error: Int) = detach() + + override fun onSessionSuspended(session: CastSession, reason: Int) = remote.publish() + } + + private fun attach(session: CastSession) { + remote.attach(session.remoteMediaClient) + updateAvailability() + } + + private fun detach() { + remote.detach() + updateAvailability() + } + + override fun stopCasting() { + castContext?.sessionManager?.endCurrentSession(true) + } +} + +/** The receivers MediaRouter finds for the Cast framework's selector, while the picker asks. */ +internal class GmsCastRoutes(private val context: Context) : CastRoutes { + private val mutableDevices = MutableStateFlow>(emptyList()) + override val devices: StateFlow> = mutableDevices + var selector: MediaRouteSelector? = null + private var discovering = false + + private val callback = object : MediaRouter.Callback() { + override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + + override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + + override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + + override fun onRouteSelected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) = refresh() + + override fun onRouteUnselected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) = refresh() + } + + override fun startDiscovery() { + val selector = selector ?: return + if (discovering) return + discovering = true + MediaRouter.getInstance(context).addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) + refresh() + } + + override fun stopDiscovery() { + if (!discovering) return + discovering = false + MediaRouter.getInstance(context).removeCallback(callback) + } + + private fun refresh() { + val selector = selector ?: return + mutableDevices.value = MediaRouter.getInstance(context).routes + .filter { !it.isDefault && !it.isBluetooth && it.isEnabled && it.matchesSelector(selector) } + .map { route -> + val device = GmsCastDevice.getFromBundle(route.extras) + val audioOnly = device?.hasCapability(GmsCastDevice.CAPABILITY_VIDEO_OUT) == false + CastDevice( + id = route.id, + name = route.name, + model = device?.modelName?.takeIf { it.isNotBlank() }, + audioOnly = audioOnly, + kind = when { + audioOnly -> CastDeviceKind.Speaker + route.deviceType == MediaRouter.RouteInfo.DEVICE_TYPE_TV -> CastDeviceKind.Tv + else -> CastDeviceKind.Other + }, + selected = route.isSelected, + ) + } + } + + override fun select(deviceId: String) { + val router = MediaRouter.getInstance(context) + router.routes.firstOrNull { it.id == deviceId }?.let(router::selectRoute) + } +} + +/** The session's media client: what the receiver plays, and the commands that drive it. */ +internal class GmsCastRemote : CastRemote { + private val mutableStatus = MutableStateFlow(null) + override val status: StateFlow = mutableStatus + private var client: RemoteMediaClient? = null + private var lastFileId: Long? = null + + val attached: Boolean get() = client != null + + private val callback = object : RemoteMediaClient.Callback() { + override fun onStatusUpdated() = publish() + + override fun onMetadataUpdated() = publish() + } + + private val progress = RemoteMediaClient.ProgressListener { _, _ -> publish() } + + fun attach(remote: RemoteMediaClient?) { + release() + client = remote?.also { + it.registerCallback(callback) + it.addProgressListener(progress, PROGRESS_INTERVAL_MILLIS) + } + publish() + } + + /** The session ended: one last status, then none, so the app hands the file back. */ + fun detach() { + publish() + release() + lastFileId = null + mutableStatus.value = null + } + + private fun release() { + client?.let { + it.unregisterCallback(callback) + it.removeProgressListener(progress) + } + client = null + } + + fun publish() { + val remote = client ?: return + val media = remote.mediaStatus ?: return + // A finished file's status drops its media; it still belongs to the file it finished. + val fileId = remote.mediaInfo?.customData?.optLong(FILE_ID_KEY)?.takeIf { it > 0L } + ?: lastFileId.takeIf { media.playerState == MediaStatus.PLAYER_STATE_IDLE } + lastFileId = fileId + mutableStatus.value = CastRemoteStatus( + fileId = fileId, + state = media.castRemoteState(), + positionMillis = remote.approximateStreamPosition.coerceAtLeast(0L), + durationMillis = remote.streamDuration.takeIf { it > 0L }, + speed = media.playbackRate.toFloat().takeIf { it > 0f } ?: 1f, + speedSupported = media.isMediaCommandSupported(MediaStatus.COMMAND_PLAYBACK_RATE), + seekable = media.isMediaCommandSupported(MediaStatus.COMMAND_SEEK), + sampledAtMillis = SystemClock.elapsedRealtime(), + ) + } + + override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { + val remote = client ?: return + lastFileId = media.fileId + remote.load( + MediaLoadRequestData.Builder() + .setMediaInfo(media.toMediaInfo()) + .setAutoplay(autoplay) + .setCurrentTime(startMillis) + .setPlaybackRate(speed.toDouble()) + .setActiveTrackIds(media.activeSubtitleId?.let { longArrayOf(it) } ?: longArrayOf()) + .build(), + ) + } + + override fun play() { + client?.play() + } + + override fun pause() { + client?.pause() + } + + override fun seekTo(positionMillis: Long) { + client?.seek(MediaSeekOptions.Builder().setPosition(positionMillis).build()) + } + + override fun setSpeed(speed: Float) { + client?.setPlaybackRate(speed.toDouble()) + } +} + +private fun MediaStatus.castRemoteState(): CastRemoteState = + when (playerState) { + MediaStatus.PLAYER_STATE_PLAYING -> CastRemoteState.Playing + MediaStatus.PLAYER_STATE_PAUSED -> CastRemoteState.Paused + MediaStatus.PLAYER_STATE_BUFFERING, MediaStatus.PLAYER_STATE_LOADING -> CastRemoteState.Buffering + MediaStatus.PLAYER_STATE_IDLE -> when (idleReason) { + MediaStatus.IDLE_REASON_FINISHED -> CastRemoteState.Ended + MediaStatus.IDLE_REASON_ERROR -> CastRemoteState.Failed + else -> CastRemoteState.Idle + } + else -> CastRemoteState.Idle + } + +/** The Cast form of [PlaybackCastMedia], tagged with its file id. */ +private fun PlaybackCastMedia.toMediaInfo(): MediaInfo { + val kind = if (mediaType == PlaybackMediaType.AUDIO) { + MediaMetadata.MEDIA_TYPE_MUSIC_TRACK + } else { + MediaMetadata.MEDIA_TYPE_MOVIE + } + val metadata = MediaMetadata(kind).apply { + putString(MediaMetadata.KEY_TITLE, title) + posterUrl?.let { addImage(WebImage(it.toUri())) } + } + return MediaInfo.Builder(fileId.toString()) + .setContentUrl(url) + .setContentType(contentType) + .setStreamType(if (buffered) MediaInfo.STREAM_TYPE_BUFFERED else MediaInfo.STREAM_TYPE_NONE) + .setMetadata(metadata) + .setMediaTracks( + subtitles.map { subtitle -> + MediaTrack.Builder(subtitle.id, MediaTrack.TYPE_TEXT) + .setContentId(subtitle.url) + .setContentType(WEBVTT_CONTENT_TYPE) + .setSubtype(MediaTrack.SUBTYPE_SUBTITLES) + .setName(subtitle.name) + .setLanguage(subtitle.languageCode) + .build() + }, + ) + .setCustomData(JSONObject().put(FILE_ID_KEY, fileId)) + .apply { durationMillis?.let(::setStreamDuration) } + .build() +} + +/** Ties the receiver's media to the put.io file; another sender's media carries none. */ +private const val FILE_ID_KEY = "putioFileId" +private const val WEBVTT_CONTENT_TYPE = "text/vtt" +private const val PROGRESS_INTERVAL_MILLIS = 1_000L diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt new file mode 100644 index 000000000..0b505b1a6 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -0,0 +1,245 @@ +package io.putdotio.android.playback + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.zIndex +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi +import io.putdotio.android.R +import io.putdotio.sdk.files.PlaybackSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Hands a resolved source to the receiver: the source and the subtitle to show first. */ +internal typealias MobileCastMediaLoader = + suspend (PlaybackSource, String?) -> PlaybackRepositoryResult + +/** The player screen's Cast: its top-bar button, and the session it casts to while one runs. */ +internal class PlayerCast( + val controller: MobileCastController?, + val button: @Composable (Modifier) -> Unit, + val casting: CastingTarget?, +) + +internal class CastingTarget( + val controller: MobileCastController, + val media: MobileCastMediaLoader, + val deviceName: String, +) + +/** No Cast without a controller (no Google Play services) or a way to load media. */ +@Composable +internal fun rememberPlayerCast( + cast: MobileCastController?, + castMedia: MobileCastMediaLoader?, + onOpenPicker: () -> Unit, +): PlayerCast { + val availability by (cast?.availability ?: NoCast).collectAsState() + if (cast == null || castMedia == null) return NoPlayerCast + val device = availability.connectedDevice + return PlayerCast( + controller = cast, + button = { modifier -> MobileCastButton(availability, onOpenPicker, modifier) }, + casting = if (device == null) null else CastingTarget(cast, castMedia, device), + ) +} + +private val NoCast: StateFlow = MutableStateFlow(CastAvailability.Unsupported) +private val NoPlayerCast = PlayerCast(controller = null, button = {}, casting = null) + +/** + * Where the local player picks [fileId] up after a Cast session ended, taken once; the preferences + * keep it so a recreated player starts there too. + */ +@Composable +internal fun rememberCastHandover( + playerCast: PlayerCast, + fileId: Long, + preferences: RetainedPlayerPreferences, +): CastHandover? { + val handover = remember(fileId) { playerCast.controller?.takeHandover(fileId) } + LaunchedEffect(handover) { + if (handover != null) { + preferences.retainPlayback(RetainedPlayback(handover.positionMillis, handover.playing)) + preferences.playbackSpeed = handover.speed + } + } + return handover +} + +/** The device picker over the whole player, except in the picture-in-picture window. */ +@Composable +internal fun MobileCastPickerHost( + cast: MobileCastController?, + open: MutableState, + pictureInPicture: Boolean, + modifier: Modifier = Modifier, +) { + if (cast == null || !open.value || pictureInPicture) return + MobileCastPicker(cast.gateway, onDismiss = { open.value = false }, modifier = modifier) +} + +/** + * The player while a Cast session runs. Local playback has stopped: the video's own player left with + * the local screen, and session audio stops here. The file goes to the receiver from where local + * playback stood, unless the receiver already plays it; the transport, scrubber and speed drive the + * receiver. Ending the session brings the local player back where the receiver stopped. + */ +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +@Composable +internal fun MobileCastPlayback( + state: PlaybackState, + cast: MobileCastController, + castMedia: MobileCastMediaLoader, + deviceName: String, + preferences: RetainedPlayerPreferences, + playerFactory: MobilePlayerFactory, + subtitleStartupPolicy: SubtitleStartupPolicy?, + castButton: @Composable (Modifier) -> Unit, + onBack: () -> Unit, + onSourceRequired: (Long?) -> Unit, + neighbours: PlaybackNeighbours, + onPlay: (PlaybackTarget) -> Unit, +) { + val context = LocalContext.current + val target = state.target + val fileId = target.fileId.value + val source = (state.content as? PlaybackContent.Ready)?.source + var failed by remember(fileId) { mutableStateOf(false) } + val currentPolicy by rememberUpdatedState(subtitleStartupPolicy) + val currentOnSourceRequired by rememberUpdatedState(onSourceRequired) + LaunchedEffect(fileId, source) { + failed = false + // One player at a time: session audio stops once the receiver takes over. + if (target.mediaType == PlaybackMediaType.AUDIO) playerFactory.stopAudio(context) + val load: suspend (PlaybackSource) -> PlaybackRepositoryResult = { source -> + castMedia(source, castSubtitleKey(source, preferences.subtitleSelection, currentPolicy)) + } + failed = !cast.handOver(state, load, preferences, playerFactory, currentOnSourceRequired) + } + + val player = cast.remotePlayer + val status by cast.status.collectAsState() + val current = status?.takeIf { it.fileId == fileId } + val label = stringResource( + if (failed || current?.state == CastRemoteState.Failed) { + R.string.mobile_cast_failed + } else { + R.string.mobile_cast_playing_on + }, + deviceName, + ) + val seekEnabled = current?.seekable == true + val intervals = if (target.mediaType == PlaybackMediaType.AUDIO) AudioSeekIntervals else VideoSeekIntervals + val seek = { direction: SeekDirection -> player.seekBy(intervals, direction) } + + if (target.mediaType == PlaybackMediaType.AUDIO) { + MobileNowPlayingScreen( + player = player, + title = target.name, + neighbours = neighbours, + seekEnabled = seekEnabled, + onSeek = seek, + onScrub = {}, + onPlay = onPlay, + onAudioSelectionChanged = {}, + castButton = castButton, + castLabel = label, + modifier = Modifier.zIndex(2f), + ) + return + } + val options = rememberPlayerOptionsSnapshot(player) + var speedSheet by rememberSaveable(fileId) { mutableStateOf(false) } + Box(Modifier.fillMaxSize()) { + MobileCastFrameLabel(label) + MobilePlayerChrome( + player = player, + title = target.name, + visible = !speedSheet, + seekEnabled = seekEnabled, + onSeek = seek, + onScrub = {}, + onBack = onBack, + modifier = Modifier.zIndex(2f), + topActions = { castButton(Modifier) }, + settings = { + if (options.canChangeSpeed) MobileSpeedChip(options, { speedSheet = true }) + }, + ) + MobilePlayerSideSheet( + visible = speedSheet, + title = stringResource(R.string.mobile_playback_speed), + onClose = { speedSheet = false }, + modifier = Modifier.zIndex(2.5f), + ) { + MobileSpeedSheetContent(player, options) + } + } +} + +/** + * Sends the file to the receiver from where local playback stood, attaches to it when the receiver + * already plays it, or asks for a source first. False when the file cannot go to a receiver. + */ +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +private suspend fun MobileCastController.handOver( + state: PlaybackState, + castMedia: suspend (PlaybackSource) -> PlaybackRepositoryResult, + preferences: RetainedPlayerPreferences, + playerFactory: MobilePlayerFactory, + onSourceRequired: (Long?) -> Unit, +): Boolean { + val target = state.target + val fileId = target.fileId.value + val ready = state.content as? PlaybackContent.Ready + val source = ready?.source + val local = CastHandover( + positionMillis = preferences.positionMillis ?: state.resumePositionMillis + ?: source?.startFromSeconds?.toPlaybackMillis() ?: 0L, + playing = preferences.resumeAfterLifecyclePause, + speed = preferences.playbackSpeed, + ) + val item = ready?.let { + playerFactory.reportableItem(it.source.toMediaItem(target.name, target.mediaType), it.useStartFrom) + } ?: bareItem(fileId, target.name) + val media = when { + holds(fileId) -> null + source == null -> null.also { onSourceRequired(local.positionMillis) } + else -> castMedia(source) + } + when (media) { + null -> if (holds(fileId)) attach(item) + is PlaybackRepositoryResult.Success -> cast(media.value, item, local) + is PlaybackRepositoryResult.Failure -> Unit + } + return media !is PlaybackRepositoryResult.Failure +} + +/** [intervals]' step from where the receiver is, within the file. */ +private fun androidx.media3.common.Player.seekBy(intervals: SeekIntervals, direction: SeekDirection) { + val step = intervals.millis(direction) + val position = currentPosition + if (direction == SeekDirection.Backward) -step else step + seekTo(position.coerceIn(0L, duration.takeIf { it > 0L } ?: Long.MAX_VALUE)) +} + +/** A receiver already playing [fileId], reached without a source: controls only, no reporting lease. */ +private fun bareItem(fileId: Long, title: String): MediaItem = + MediaItem.Builder() + .setMediaId(fileId.toString()) + .setMediaMetadata(MediaMetadata.Builder().setTitle(title).build()) + .build() diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt new file mode 100644 index 000000000..5156b3a05 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt @@ -0,0 +1,289 @@ +package io.putdotio.android.playback + +import androidx.annotation.DrawableRes +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.paneTitle +import io.putdotio.android.design.PutioRadius +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.putdotio.android.MobileSheetHandle +import io.putdotio.android.R +import io.putdotio.android.design.PutioDesignTokens + +internal const val MOBILE_CAST_BUTTON_TAG = "mobile-cast-button" +internal const val MOBILE_CAST_PICKER_TAG = "mobile-cast-picker" +internal const val MOBILE_CAST_STOP_TAG = "mobile-cast-stop" +internal const val MOBILE_CAST_LABEL_TAG = "mobile-cast-label" + +/** Cast in a player's top bar, once a receiver is in reach; yellow while casting. */ +@Composable +internal fun MobileCastButton(availability: CastAvailability, onClick: () -> Unit, modifier: Modifier = Modifier) { + if (!availability.showsButton) return + val device = availability.connectedDevice + IconButton(onClick = onClick, modifier = modifier.testTag(MOBILE_CAST_BUTTON_TAG)) { + Icon( + painterResource(R.drawable.ic_ph_screencast), + contentDescription = device?.let { stringResource(R.string.mobile_cast_casting_to, it) } + ?: stringResource(R.string.mobile_cast), + tint = if (device != null) PutioDesignTokens.yellowSolid else LocalContentColor.current, + ) + } +} + +/** + * "Cast to": the receivers in reach, each with its kind's glyph, its name and its model or "Audio + * only". The one playing carries a check, and Stop casting ends the session. The sheet lives in the + * player's own window, as its side sheets do, so an immersive video keeps its system bars hidden; + * Back or a tap on the dimmed player closes it. + */ +@Composable +internal fun MobileCastPicker(gateway: CastGateway, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + DisposableEffect(gateway) { + gateway.startDiscovery() + onDispose { gateway.stopDiscovery() } + } + val devices by gateway.devices.collectAsState() + val availability by gateway.availability.collectAsState() + BackHandler(onBack = onDismiss) + val title = stringResource(R.string.mobile_cast_to) + Box(modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = CAST_PICKER_SCRIM_ALPHA)) + .clearAndSetSemantics {} + .pointerInput(onDismiss) { detectTapGestures { onDismiss() } }, + ) + Column( + Modifier + .widthIn(max = CAST_PICKER_MAX_WIDTH) + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + RoundedCornerShape(topStart = PutioRadius.extraLarge, topEnd = PutioRadius.extraLarge), + ) + // A tap inside the sheet never reaches the scrim behind it. + .pointerInput(Unit) { detectTapGestures {} } + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(bottom = 28.dp) + .semantics { + paneTitle = title + isTraversalGroup = true + } + .testTag(MOBILE_CAST_PICKER_TAG), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MobileSheetHandle() + MobileCastPickerRows(gateway, devices, availability, onDismiss) + } + } +} + +@Composable +private fun MobileCastPickerRows( + gateway: CastGateway, + devices: List, + availability: CastAvailability, + onDismiss: () -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.mobile_cast_to), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 4.dp) + .semantics { heading() }, + ) + if (devices.isEmpty()) MobileCastSearchingRow() + devices.forEach { device -> + MobileCastDeviceRow(device) { + if (!device.selected) gateway.select(device.id) + onDismiss() + } + } + if (availability is CastAvailability.Connected || availability is CastAvailability.Connecting) { + MobileCastActionRow( + icon = R.drawable.ic_ph_stop_circle, + title = stringResource(R.string.mobile_cast_stop), + modifier = Modifier.testTag(MOBILE_CAST_STOP_TAG), + ) { + gateway.stopCasting() + onDismiss() + } + } + } +} + +@Composable +private fun MobileCastDeviceRow(device: CastDevice, onClick: () -> Unit) { + MobileCastActionRow( + icon = when (device.kind) { + CastDeviceKind.Tv -> R.drawable.ic_ph_television + CastDeviceKind.Speaker -> R.drawable.ic_ph_speaker_hifi + CastDeviceKind.Other -> R.drawable.ic_ph_television_simple + }, + title = device.name, + supporting = if (device.audioOnly) stringResource(R.string.mobile_cast_audio_only) else device.model, + selected = device.selected, + onClick = onClick, + ) +} + +@Composable +private fun MobileCastSearchingRow() { + Row( + Modifier.fillMaxWidth().height(CAST_ROW_HEIGHT).padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } + Text( + stringResource(R.string.mobile_cast_searching), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** A 64dp row: the glyph in the secondary text colour, the name over its supporting line. */ +@Composable +private fun MobileCastActionRow( + @DrawableRes icon: Int, + title: String, + modifier: Modifier = Modifier, + supporting: String? = null, + selected: Boolean = false, + onClick: () -> Unit, +) { + Row( + modifier + .fillMaxWidth() + .height(CAST_ROW_HEIGHT) + .clickable(role = Role.Button, onClick = onClick) + .semantics { this.selected = selected } + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Column(Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + supporting?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (selected) { + Icon( + painterResource(R.drawable.ic_ph_check), + contentDescription = null, + tint = PutioDesignTokens.yellowSolid, + modifier = Modifier.size(24.dp), + ) + } + } +} + +/** + * Over the casting video's dark frame, just above the transport: the cast glyph and "Playing on + * Living room TV", or why the receiver can't play it. + */ +@Composable +internal fun MobileCastFrameLabel(text: String, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) { + Row( + Modifier + .offset(y = -CAST_LABEL_LIFT) + .padding(horizontal = 24.dp) + .testTag(MOBILE_CAST_LABEL_TAG), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(R.drawable.ic_ph_screencast), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Text( + text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private val CAST_PICKER_MAX_WIDTH = 560.dp +private const val CAST_PICKER_SCRIM_ALPHA = 0.55f +private val CAST_ROW_HEIGHT = 64.dp + +/** Half the transport's 80dp, then room for the line itself over it. */ +private val CAST_LABEL_LIFT = 72.dp diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileNowPlayingScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileNowPlayingScreen.kt index a2d0e88d0..028348e41 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileNowPlayingScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileNowPlayingScreen.kt @@ -104,6 +104,9 @@ internal fun MobileNowPlayingScreen( onAudioSelectionChanged: (AudioSelection) -> Unit, modifier: Modifier = Modifier, sleepTimer: MobileSleepTimer = MobileSleepTimer.Shared, + castButton: @Composable (Modifier) -> Unit = {}, + /** "Playing on Living room TV" while a receiver plays the file; the sleep timer then stays here. */ + castLabel: String? = null, ) { val options = rememberPlayerOptionsSnapshot(player) var sheet by rememberSaveable { mutableStateOf(null) } @@ -111,6 +114,8 @@ internal fun MobileNowPlayingScreen( NowPlayingTopBar( canChooseAudio = options.audioTracks.size > 1, onAudioTrack = { sheet = NowPlayingSheet.AudioTrack }, + castButton = castButton, + castLabel = castLabel, ) BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { val details: @Composable (Modifier) -> Unit = { detailsModifier -> @@ -123,8 +128,11 @@ internal fun MobileNowPlayingScreen( onScrub = onScrub, onPlay = onPlay, chips = { - MobileSpeedChip(options, { sheet = NowPlayingSheet.Speed }, onPage = true) - SleepTimerChip(sleepTimer) { sheet = NowPlayingSheet.SleepTimer } + // A receiver without speed control leaves the chip out. + if (castLabel == null || options.canChangeSpeed) { + MobileSpeedChip(options, { sheet = NowPlayingSheet.Speed }, onPage = true) + } + if (castLabel == null) SleepTimerChip(sleepTimer) { sheet = NowPlayingSheet.SleepTimer } UpNextChip(neighbours.next) { sheet = NowPlayingSheet.UpNext } }, modifier = detailsModifier, @@ -168,14 +176,36 @@ internal fun MobileNowPlayingScreen( ) } -/** Room for the top bar's back, which the player screen draws, then More when there is a choice in it. */ +/** + * Room for the top bar's back, which the player screen draws, then where a receiver plays the file, + * Cast, and More when there is a choice in it. + */ @Composable -private fun NowPlayingTopBar(canChooseAudio: Boolean, onAudioTrack: () -> Unit) { +private fun NowPlayingTopBar( + canChooseAudio: Boolean, + onAudioTrack: () -> Unit, + castButton: @Composable (Modifier) -> Unit, + castLabel: String?, +) { Row( Modifier.fillMaxWidth().height(TOP_BAR_HEIGHT).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + Spacer(Modifier.width(TOP_BAR_BACK_ROOM)) + if (castLabel == null) { + Spacer(Modifier.weight(1f)) + } else { + Text( + castLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).testTag(MOBILE_CAST_LABEL_TAG), + ) + } + castButton(Modifier) if (!canChooseAudio) return@Row var expanded by remember { mutableStateOf(false) } Box { @@ -597,6 +627,9 @@ private fun UpNextRow(next: PlaybackTarget, onClick: () -> Unit) { } private val TOP_BAR_HEIGHT = 64.dp + +/** The player screen's 48dp back sits at the bar's start. */ +private val TOP_BAR_BACK_ROOM = 48.dp private val WIDE_LAYOUT_WIDTH = 600.dp /** Beside the glyph, the details need the transport's 288dp and their 16dp gutters. */ diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index f9a0733fa..ed3977dff 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -127,6 +127,9 @@ internal fun MobilePlayerScreen( onPlay: (PlaybackTarget) -> Unit = {}, /** Hands the original to VLC when this player can't play it; null where VLC isn't offered. */ onOpenInVlc: (() -> Unit)? = null, + /** The Cast session; with [castMedia], the top bar offers Cast once a receiver is in reach. */ + cast: MobileCastController? = null, + castMedia: MobileCastMediaLoader? = null, ) { // Like iOS, the window turns only for a landscape video; the last shape holds across autoplay. var landscapeVideo by rememberSaveable { mutableStateOf(false) } @@ -137,7 +140,10 @@ internal fun MobilePlayerScreen( // A closed window stopped the video; the next visit finds it paused instead of playing on. PictureInPictureDismissalEffect { preferences.resumeAfterLifecyclePause = false } } - var keyboardNavigationActive by rememberSaveable(state.target.fileId.value) { mutableStateOf(false) } + val keyboardNavigation = rememberSaveable(state.target.fileId.value) { mutableStateOf(false) } + var keyboardNavigationActive by keyboardNavigation + val castPicker = rememberSaveable { mutableStateOf(false) } + val playerCast = rememberPlayerCast(cast, castMedia) { castPicker.value = true } Box( modifier = modifier .fillMaxSize() @@ -159,49 +165,26 @@ internal fun MobilePlayerScreen( onDismiss = onBack, ) - is PlaybackContent.Ready, PlaybackContent.Session -> - MobileSessionPlayerHost( - mediaType = state.target.mediaType, - playerFactory = playerFactory, - ) { sessionPlayer -> - MobileReadyPlayer( - source = (content as? PlaybackContent.Ready)?.source, - useStartFrom = (content as? PlaybackContent.Ready)?.useStartFrom == true, - fileId = state.target.fileId.value, - title = state.target.name, - mediaType = state.target.mediaType, - sessionPlayer = sessionPlayer, - preferences = preferences, - sessionHandled = preferences.sessionHandled, - onSessionHandled = { preferences.sessionHandled = true }, - startPositionMillis = preferences.positionMillis ?: state.resumePositionMillis, - resumeAfterLifecyclePause = preferences.resumeAfterLifecyclePause, - retainedSubtitleSelection = preferences.subtitleSelection, - subtitleStartupPolicy = subtitleStartupPolicy.orHiddenWhen( - (content as? PlaybackContent.Ready)?.subtitlesHidden == true, - ), - onPlaybackRetained = preferences::retainPlayback, - onPositionChanged = preferences::retainPosition, - onSubtitleSelectionChanged = { preferences.subtitleSelection = it }, - keyboardNavigationActive = keyboardNavigationActive, - onKeyboardNavigation = { keyboardNavigationActive = true }, - onPointerNavigation = { keyboardNavigationActive = false }, - onPlayerFailure = onPlayerFailure, - autoplayNextVideo = autoplayNextVideo, - onPlaybackEnded = onPlaybackEnded, - playerFactory = playerFactory, - seekClock = seekClock, - onSourceRequired = onSourceRequired, - onBack = onBack, - onVideoAspectRatio = { landscapeVideo = it > 1f }, - pictureInPicture = pictureInPicture, - sidecarSubtitles = (content as? PlaybackContent.Ready)?.source?.subtitles.sidecarTracks(), - nextEpisode = nextEpisode.takeIf { isVideo }, - onNextEpisode = onNextEpisode, - neighbours = neighbours, - onPlay = onPlay, - ) - } + is PlaybackContent.Ready, PlaybackContent.Session -> MobileReadyContent( + state = state, + playerCast = playerCast, + preferences = preferences, + playerFactory = playerFactory, + subtitleStartupPolicy = subtitleStartupPolicy, + keyboardNavigation = keyboardNavigation, + onPlayerFailure = onPlayerFailure, + autoplayNextVideo = autoplayNextVideo, + onPlaybackEnded = onPlaybackEnded, + seekClock = seekClock, + onSourceRequired = onSourceRequired, + onBack = onBack, + onVideoAspectRatio = { landscapeVideo = it > 1f }, + pictureInPicture = pictureInPicture, + nextEpisode = nextEpisode.takeIf { isVideo }, + onNextEpisode = onNextEpisode, + neighbours = neighbours, + onPlay = onPlay, + ) is PlaybackContent.FindingNext -> MobileLoadingState(stringResource(R.string.mobile_playback_finding_next)) @@ -238,6 +221,97 @@ internal fun MobilePlayerScreen( if (state.showsSeparateBack() && !pictureInPicture.value) { MobilePlayerSeparateBack(state, onBack, Modifier.align(Alignment.TopStart)) } + MobileCastPickerHost(cast, castPicker, pictureInPicture.value, Modifier.zIndex(4f)) + } +} + +/** + * The player once it has something to play: the receiver's controls while a Cast session runs, + * otherwise the local player, which picks a file up where a session that just ended left it. + */ +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +@Composable +private fun MobileReadyContent( + state: PlaybackState, + playerCast: PlayerCast, + preferences: RetainedPlayerPreferences, + playerFactory: MobilePlayerFactory, + subtitleStartupPolicy: SubtitleStartupPolicy?, + keyboardNavigation: MutableState, + onPlayerFailure: (PlaybackFailure, Long) -> Unit, + autoplayNextVideo: Boolean, + onPlaybackEnded: () -> Unit, + seekClock: () -> Long, + onSourceRequired: (Long?) -> Unit, + onBack: () -> Unit, + onVideoAspectRatio: (Float) -> Unit, + pictureInPicture: State, + nextEpisode: PlaybackTarget?, + onNextEpisode: () -> Unit, + neighbours: PlaybackNeighbours, + onPlay: (PlaybackTarget) -> Unit, +) { + val casting = playerCast.casting + if (casting != null) { + MobileCastPlayback( + state = state, + cast = casting.controller, + castMedia = casting.media, + deviceName = casting.deviceName, + preferences = preferences, + playerFactory = playerFactory, + subtitleStartupPolicy = subtitleStartupPolicy, + castButton = playerCast.button, + onBack = onBack, + onSourceRequired = onSourceRequired, + neighbours = neighbours, + onPlay = onPlay, + ) + return + } + val handover = rememberCastHandover(playerCast, state.target.fileId.value, preferences) + val ready = state.content as? PlaybackContent.Ready + MobileSessionPlayerHost( + mediaType = state.target.mediaType, + playerFactory = playerFactory, + ) { sessionPlayer -> + MobileReadyPlayer( + source = ready?.source, + useStartFrom = ready?.useStartFrom == true, + fileId = state.target.fileId.value, + title = state.target.name, + mediaType = state.target.mediaType, + sessionPlayer = sessionPlayer, + preferences = preferences, + sessionHandled = preferences.sessionHandled, + onSessionHandled = { preferences.sessionHandled = true }, + startPositionMillis = handover?.positionMillis ?: preferences.positionMillis + ?: state.resumePositionMillis, + resumeAfterLifecyclePause = handover?.playing ?: preferences.resumeAfterLifecyclePause, + retainedSubtitleSelection = preferences.subtitleSelection, + subtitleStartupPolicy = subtitleStartupPolicy.orHiddenWhen(ready?.subtitlesHidden == true), + onPlaybackRetained = preferences::retainPlayback, + onPositionChanged = preferences::retainPosition, + onSubtitleSelectionChanged = { preferences.subtitleSelection = it }, + keyboardNavigationActive = keyboardNavigation.value, + onKeyboardNavigation = { keyboardNavigation.value = true }, + onPointerNavigation = { keyboardNavigation.value = false }, + onPlayerFailure = onPlayerFailure, + autoplayNextVideo = autoplayNextVideo, + onPlaybackEnded = onPlaybackEnded, + playerFactory = playerFactory, + seekClock = seekClock, + onSourceRequired = onSourceRequired, + onBack = onBack, + onVideoAspectRatio = onVideoAspectRatio, + pictureInPicture = pictureInPicture, + sidecarSubtitles = ready?.source?.subtitles.sidecarTracks(), + nextEpisode = nextEpisode, + onNextEpisode = onNextEpisode, + neighbours = neighbours, + onPlay = onPlay, + castButton = playerCast.button, + ) } } @@ -327,6 +401,7 @@ private fun MobileReadyPlayer( onNextEpisode: () -> Unit, neighbours: PlaybackNeighbours, onPlay: (PlaybackTarget) -> Unit, + castButton: @Composable (Modifier) -> Unit, ) { val context = LocalContext.current val lifecycle = LocalLifecycleOwner.current.lifecycle @@ -499,6 +574,7 @@ private fun MobileReadyPlayer( onPlay = onPlay, enterPictureInPicture = enterPictureInPicture, pictureInPictureSeeks = pictureInPictureSeeks, + castButton = castButton, ) } @@ -1043,6 +1119,7 @@ private fun MobileReadyPlayerContent( onPlay: (PlaybackTarget) -> Unit, enterPictureInPicture: (() -> Unit)?, pictureInPictureSeeks: SeekRelay, + castButton: @Composable (Modifier) -> Unit, ) { val player = playerState.player fun seek(direction: SeekDirection) { @@ -1135,6 +1212,7 @@ private fun MobileReadyPlayerContent( onScrub = { pendingSeek.value = null }, onPlay = onPlay, onAudioSelectionChanged = { preferences.audioSelection = it }, + castButton = castButton, modifier = Modifier.zIndex(2f).observeControlNavigation(onPointerNavigation, onKeyboardNavigation), ) MobileSeekFeedbackLayer(pendingSeek.value) @@ -1149,7 +1227,10 @@ private fun MobileReadyPlayerContent( onScrub = { pendingSeek.value = null }, onBack = onBack, modifier = Modifier.zIndex(2f), - topActions = { MobilePictureInPictureButton(enterPictureInPicture, optionModifier) }, + topActions = { + castButton(optionModifier) + MobilePictureInPictureButton(enterPictureInPicture, optionModifier) + }, settings = { MobileVideoChips(options, subtitleStartupPolicy, { sheet = it }, optionModifier) }, trailingChip = { MobileNextEpisodeChip(nextEpisode, onNextEpisode, optionModifier) }, onFooterHeight = { footerHeight = it }, diff --git a/mobile/src/main/res/drawable/ic_ph_screencast.xml b/mobile/src/main/res/drawable/ic_ph_screencast.xml new file mode 100644 index 000000000..a7012ee5c --- /dev/null +++ b/mobile/src/main/res/drawable/ic_ph_screencast.xml @@ -0,0 +1,10 @@ + + + + diff --git a/mobile/src/main/res/drawable/ic_ph_speaker_hifi.xml b/mobile/src/main/res/drawable/ic_ph_speaker_hifi.xml new file mode 100644 index 000000000..2a532f245 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_ph_speaker_hifi.xml @@ -0,0 +1,10 @@ + + + + diff --git a/mobile/src/main/res/drawable/ic_ph_television.xml b/mobile/src/main/res/drawable/ic_ph_television.xml new file mode 100644 index 000000000..c02c21f9c --- /dev/null +++ b/mobile/src/main/res/drawable/ic_ph_television.xml @@ -0,0 +1,10 @@ + + + + diff --git a/mobile/src/main/res/drawable/ic_ph_television_simple.xml b/mobile/src/main/res/drawable/ic_ph_television_simple.xml new file mode 100644 index 000000000..35369c1aa --- /dev/null +++ b/mobile/src/main/res/drawable/ic_ph_television_simple.xml @@ -0,0 +1,10 @@ + + + + diff --git a/mobile/src/main/res/layout/widget_transfers.xml b/mobile/src/main/res/layout/widget_transfers.xml index c0f3732a4..0cc0ce6d3 100644 --- a/mobile/src/main/res/layout/widget_transfers.xml +++ b/mobile/src/main/res/layout/widget_transfers.xml @@ -2,8 +2,10 @@ + reach the optional brand fonts, so numbers set in the system monospace face. RemoteViews inflate + framework ImageViews, which read android:tint; AppCompat's app:tint never reaches them. --> + android:tint="@color/putio_yellow_solid" + tools:ignore="UseAppTint" /> diff --git a/mobile/src/main/res/layout/widget_transfers_preview.xml b/mobile/src/main/res/layout/widget_transfers_preview.xml index 6f1423f49..921295133 100644 --- a/mobile/src/main/res/layout/widget_transfers_preview.xml +++ b/mobile/src/main/res/layout/widget_transfers_preview.xml @@ -1,7 +1,9 @@ + signed-in layout. It is inflated as is, so it has no ids and nothing is set at run time. RemoteViews inflate + framework ImageViews, which read android:tint; AppCompat's app:tint never reaches them. --> + android:tint="@color/putio_yellow_solid" + tools:ignore="UseAppTint" /> + android:tint="@color/putio_text_secondary" + tools:ignore="UseAppTint" /> Font and colours follow Android’s caption settings. Can’t play this here. This format needs another player. + Cast + Casting to %1$s + Cast to + Audio only + Looking for devices… + Stop casting + Playing on %1$s + Can’t play this on %1$s. This video can’t play in its original format, so put.io started a conversion. It keeps going if you leave. This text is too long. Paste fewer links. Replace the current draft? diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt new file mode 100644 index 000000000..1ad35e2e8 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -0,0 +1,299 @@ +package io.putdotio.android + +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.Player as Media3Player +import androidx.media3.common.util.UnstableApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.playback.CastAvailability +import io.putdotio.android.playback.CastDevice +import io.putdotio.android.playback.CastGateway +import io.putdotio.android.playback.CastHandover +import io.putdotio.android.playback.CastRemoteState +import io.putdotio.android.playback.CastRemoteStatus +import io.putdotio.android.playback.MobileCastController +import io.putdotio.android.playback.PlaybackCastMedia +import io.putdotio.android.playback.PlaybackTarget +import io.putdotio.android.playback.SubtitleSelection +import io.putdotio.android.playback.SubtitleStartupPolicy +import io.putdotio.android.playback.SubtitleTrackIdentity +import io.putdotio.android.playback.castSubtitleKey +import io.putdotio.android.playback.toCastMedia +import io.putdotio.sdk.files.PlaybackSource +import io.putdotio.sdk.files.PlaybackSourceKind +import io.putdotio.sdk.files.PlaybackSubtitle +import io.putdotio.sdk.files.PlaybackSubtitles +import io.putdotio.sdk.files.PutioCredentialUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +@UnstableApi +class MobileCastControllerTest { + // MainScope's dispatcher, as the app's controller runs on: collectors run on the next looper turn. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var now = 10_000L + private val gateway = FakeCastGateway() + private val cast = MobileCastController(gateway, scope) { now } + + @After + fun tearDown() = scope.cancel() + + @Test + fun castingHandsTheReceiverWhereLocalPlaybackStood() { + gateway.connect() + + assertTrue(cast.cast(Media, Item, CastHandover(positionMillis = 754_000L, playing = true, speed = 1.5f))) + + assertEquals(listOf(Load(42L, 754_000L, autoplay = true, speed = 1.5f)), gateway.loads) + } + + @Test + fun aPausedVideoStaysPausedOnTheReceiver() { + gateway.connect() + + cast.cast(Media, Item, CastHandover(positionMillis = 3_000L, playing = false)) + + assertEquals(listOf(Load(42L, 3_000L, autoplay = false, speed = 1f)), gateway.loads) + } + + @Test + fun aReceiverAlreadyPlayingTheFileIsAttachedNotReloaded() { + gateway.connect() + gateway.report(CastRemoteState.Playing, positionMillis = 600_000L) + + assertFalse(cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true))) + + assertTrue(gateway.loads.isEmpty()) + assertEquals(Media3Player.STATE_READY, cast.remotePlayer.playbackState) + } + + @Test + fun withoutASessionNothingGoesToAReceiver() { + assertFalse(cast.cast(Media, Item, CastHandover(positionMillis = 1_000L, playing = true))) + assertTrue(gateway.loads.isEmpty()) + } + + @Test + fun endingTheSessionResumesLocallyWhereThePlayingReceiverGotTo() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Playing, positionMillis = 60_000L) + // No new status for 2.5 s of playback, then the session ends. + now += 2_500L + assertNull(cast.takeHandover(42L)) + + gateway.disconnect() + + assertEquals(CastHandover(positionMillis = 62_500L, playing = true), cast.takeHandover(42L)) + // Taken once: a later opening resumes from put.io's position instead. + assertNull(cast.takeHandover(42L)) + } + + @Test + fun aScreenThatSeesTheSessionGoneFirstStillGetsTheHandoverOnce() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Playing, positionMillis = 60_000L) + + // The session ends; the player recomposes before the status collector runs. + gateway.status.value = null + gateway.availability.value = CastAvailability.Available + + assertEquals(CastHandover(positionMillis = 60_000L, playing = true), cast.takeHandover(42L)) + idle() + assertNull(cast.takeHandover(42L)) + } + + @Test + fun aPausedReceiverHandsBackItsPositionPaused() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Paused, positionMillis = 125_000L, speed = 2f) + now += 60_000L + + gateway.disconnect() + + assertEquals(CastHandover(positionMillis = 125_000L, playing = false, speed = 2f), cast.takeHandover(42L)) + } + + @Test + fun theRemotePlayerShowsAndDrivesTheReceiver() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Playing, positionMillis = 30_000L, durationMillis = 600_000L) + val player = cast.remotePlayer + + assertTrue(player.isPlaying) + assertEquals("42", player.currentMediaItem?.mediaId) + assertEquals(600_000L, player.duration) + assertEquals(30_000L, player.currentPosition) + // The Default Media Receiver says whether it takes a playback rate. + assertFalse(player.isCommandAvailable(Media3Player.COMMAND_SET_SPEED_AND_PITCH)) + + player.pause() + player.seekTo(90_000L) + gateway.report(CastRemoteState.Paused, positionMillis = 90_000L, speedSupported = true) + player.setPlaybackSpeed(1.25f) + player.play() + + assertEquals(listOf("pause", "seek 90000", "speed 1.25", "play"), gateway.commands) + } + + @Test + fun aFinishedFilePlaysAgainBySendingItOnceMore() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Ended, positionMillis = 600_000L) + + cast.remotePlayer.seekTo(0L) + cast.remotePlayer.play() + + // Play's own seek to the start sends it; play does not send it twice. + assertEquals(2, gateway.loads.size) + assertEquals(Load(42L, 0L, autoplay = true, speed = 1f), gateway.loads.last()) + } + + @Test + fun theReceiverShowsTheSubtitleTheLocalPlayerWould() { + val sidecar = source(listOf("default", "other")) + + assertEquals("default", castSubtitleKey(sidecar, null, Shown)) + assertNull(castSubtitleKey(sidecar, null, Shown.copy(autoSelectSubtitles = false))) + assertNull(castSubtitleKey(sidecar, SubtitleSelection.Automatic, Shown.copy(showSubtitles = false))) + assertNull(castSubtitleKey(sidecar, SubtitleSelection.Off, Shown)) + assertEquals("other", castSubtitleKey(sidecar, SubtitleSelection.Track(identity("0:other")), Shown)) + assertNull(castSubtitleKey(source(emptyList()), SubtitleSelection.Automatic, Shown)) + } + + private fun source(keys: List) = + PlaybackSource( + fileId = 42L, + kind = PlaybackSourceKind.MP4, + url = PutioCredentialUrl.of("https://api.put.io/v2/files/42/mp4/download"), + startFromSeconds = 0.0, + subtitles = PlaybackSubtitles.Sidecar( + keys.map { + PlaybackSubtitle( + format = "srt", + key = it, + language = "English", + languageCode = "eng", + name = it, + source = "folder", + url = PutioCredentialUrl.of("https://api.put.io/v2/files/42/subtitles/$it"), + ) + }, + ), + ) + + private fun identity(id: String) = + SubtitleTrackIdentity( + id = id, + language = "eng", + label = null, + sampleMimeType = null, + roleFlags = 0, + selectionFlags = 0, + accessibilityChannel = 0, + ) + + private data class Load(val fileId: Long, val startMillis: Long, val autoplay: Boolean, val speed: Float) + + /** The Cast SDK's side: a session the test opens and closes, and the receiver's reports. */ + private inner class FakeCastGateway : CastGateway { + override val availability = MutableStateFlow(CastAvailability.Available) + override val status = MutableStateFlow(null) + override val devices = MutableStateFlow>(emptyList()) + val loads = mutableListOf() + val commands = mutableListOf() + + fun connect() { + availability.value = CastAvailability.Connected("Living room TV") + idle() + } + + fun disconnect() { + availability.value = CastAvailability.Available + status.value = null + idle() + } + + fun report( + state: CastRemoteState, + positionMillis: Long, + durationMillis: Long? = null, + speed: Float = 1f, + speedSupported: Boolean = false, + ) { + status.value = CastRemoteStatus( + fileId = 42L, + state = state, + positionMillis = positionMillis, + durationMillis = durationMillis, + speed = speed, + speedSupported = speedSupported, + sampledAtMillis = now, + ) + idle() + } + + override fun startDiscovery() = Unit + + override fun stopDiscovery() = Unit + + override fun select(deviceId: String) = Unit + + override fun stopCasting() = disconnect() + + override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { + loads += Load(media.fileId, startMillis, autoplay, speed) + } + + override fun play() { + commands += "play" + } + + override fun pause() { + commands += "pause" + } + + override fun seekTo(positionMillis: Long) { + commands += "seek $positionMillis" + } + + override fun setSpeed(speed: Float) { + commands += "speed $speed" + } + } + + private fun idle() = shadowOf(Looper.getMainLooper()).idle() + + private companion object { + val Shown = SubtitleStartupPolicy(showSubtitles = true, autoSelectSubtitles = true) + val Media = requireNotNull( + PlaybackSource( + fileId = 42L, + kind = PlaybackSourceKind.HLS, + url = PutioCredentialUrl.of("https://api.put.io/v2/files/42/hls/media.m3u8"), + startFromSeconds = 0.0, + subtitles = PlaybackSubtitles.Embedded, + ).toCastMedia(PlaybackTarget(FilesItemId(42L), "episode.mkv"), "download-token"), + ) + val Item: MediaItem = MediaItem.Builder().setMediaId("42").build() + } +} From 47a38e5dc7c120af573fab41d0a66f59563440ab Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 15:27:20 +0300 Subject: [PATCH 02/16] fix(mobile): harden the Cast handback, URLs and session end Keep the receiver's last status for the handback, decode credential names before replacing them, cap Cast speed at 2x, send failed media again, end Cast on sign-out, and open the receiver's file from the Cast notification. --- .../android/playback/PlaybackCastMedia.kt | 24 ++++- .../android/playback/PlaybackCastMediaTest.kt | 13 +++ mobile/src/main/AndroidManifest.xml | 7 ++ .../kotlin/io/putdotio/android/MobileShell.kt | 9 +- .../kotlin/io/putdotio/android/PutioApp.kt | 6 +- .../putdotio/android/playback/MobileCast.kt | 93 ++++++++++++++----- .../android/playback/MobileCastGms.kt | 47 +++++++++- .../android/playback/MobileCastPlayback.kt | 31 +++---- .../putdotio/android/playback/MobileCastUi.kt | 10 ++ .../android/MobileCastControllerTest.kt | 50 ++++++++-- 10 files changed, 239 insertions(+), 51 deletions(-) diff --git a/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt b/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt index 04cb2481a..eab7be1e0 100644 --- a/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt +++ b/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt @@ -5,6 +5,7 @@ import io.putdotio.sdk.files.PlaybackSourceKind import io.putdotio.sdk.files.PlaybackSubtitles import java.net.URI import java.net.URISyntaxException +import java.net.URLDecoder import java.net.URLEncoder /** @@ -78,7 +79,8 @@ public fun PlaybackSource.toCastMedia( contentType = castContentType(target), buffered = kind != PlaybackSourceKind.HLS, durationMillis = target.durationSeconds?.toPlaybackMillis()?.takeIf { it > 0L }, - posterUrl = target.posterUrl?.takeIf { it.startsWith("https://") }, + // A poster is a token-free still; one that carries a credential stays here. + posterUrl = target.posterUrl?.takeIf { it.startsWith("https://") && OAUTH_TOKEN_QUERY !in it.queryNames() }, subtitles = castSubtitles, activeSubtitleId = castSubtitles.getOrNull(activeIndex)?.id, ) @@ -105,7 +107,8 @@ private fun String.withDownloadToken(token: String, vararg extra: Pair = + try { + URI(this).rawQuery.orEmpty().split('&').map { it.substringBefore('=').decoded() }.toSet() + } catch (_: URISyntaxException) { + emptySet() + } + +@Suppress("SwallowedException") +private fun String.decoded(): String = + try { + URLDecoder.decode(this, Charsets.UTF_8.name()) + } catch (_: IllegalArgumentException) { + this + } + private fun String.encoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name()) private const val PUTIO_API_HOST = "api.put.io" diff --git a/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt b/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt index b4c5b0743..d7a027328 100644 --- a/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt +++ b/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt @@ -69,6 +69,19 @@ class PlaybackCastMediaTest { assertFalse((media.subtitles.map { it.url } + media.url).any { it.contains(ACCESS) }) } + @Test + fun anEscapedCredentialNameIsReplacedToo() { + val media = source( + PlaybackSourceKind.MP4, + "https://api.put.io/v2/files/42/mp4/download?oauth%5Ftoken=$ACCESS&%6Fauth_token=$ACCESS", + PlaybackSubtitles.None, + ).castable(Video.copy(posterUrl = "https://api.put.io/v2/files/42/screenshot?oauth_token=$ACCESS")) + + assertEquals("https://api.put.io/v2/files/42/mp4/download?oauth_token=$DOWNLOAD", media.url) + // A poster that carries a credential is left out rather than sent. + assertNull(media.posterUrl) + } + @Test fun aDownloadsTokenFreeApiUrlTakesTheDownloadToken() { val media = source( diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 85cb3bc50..cccf5d572 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -162,6 +162,13 @@ android:name="androidx.media3.exoplayer.scheduler.PlatformScheduler$PlatformSchedulerService" android:exported="true" android:permission="android.permission.BIND_JOB_SERVICE" /> + + if (!pending) return@collect + // The Cast notification asks too: the receiver's file, while local audio has stopped. val target = playbackPlayerFactory.activeAudio(appContext) + ?.let { PlaybackTarget(it.fileId, it.title, PlaybackMediaType.AUDIO) } + ?: MobileCast.castingTarget() // Acknowledged only once the lookup finished: a cancelled lookup leaves it pending. nowPlayingRequests.acknowledge() if (target == null || transferDraft.state.value.incomingRequestId != null) return@collect @@ -719,8 +724,8 @@ private fun NowPlayingNavigationEffect( // A different item's route, still loading or failed, gives no controls for the live audio. navController.navigateToPlayback( target.fileId, - target.title, - PlaybackMediaType.AUDIO, + target.name, + target.mediaType, replaceCurrentPlayback = onPlaybackRoute != null, ) } diff --git a/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt b/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt index d704f2850..91d48b596 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt @@ -25,6 +25,7 @@ import io.putdotio.android.auth.MobileOAuthRuntime import io.putdotio.android.auth.MobileSignedOutReason import io.putdotio.android.auth.OAuthBrowserLaunchResult import io.putdotio.android.auth.OAuthLaunchResult +import io.putdotio.android.playback.MobileCast import io.putdotio.android.design.PutioTheme import io.putdotio.android.downloads.MobileDownloadsViewModel import io.putdotio.android.downloads.mobileDownloadsViewModelFactory @@ -120,7 +121,10 @@ private fun MobileAuthRoot( } PlaybackSessionBoundaryEffect( sessionId = (authState as? MobileAuthState.SignedIn)?.sessionId, - onSessionLeft = { MobilePlaybackService.stop(context.applicationContext) }, + onSessionLeft = { + MobilePlaybackService.stop(context.applicationContext) + MobileCast.sessionLeft() + }, ) when (val state = authState) { MobileAuthState.Initializing, diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index 271c65b2f..9f9665771 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -5,12 +5,14 @@ import android.os.Looper import androidx.media3.common.C import androidx.media3.common.DeviceInfo import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.SimpleBasePlayer import androidx.media3.common.util.UnstableApi import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture +import io.putdotio.android.files.FilesItemId import io.putdotio.sdk.files.PlaybackSource import io.putdotio.sdk.files.PlaybackSubtitles import kotlinx.coroutines.CoroutineScope @@ -101,9 +103,15 @@ internal interface CastRoutes { /** The connected receiver's media: its status, and the commands that drive it. */ internal interface CastRemote { - /** Null without a session; the last status before the session ends hands the file back. */ + /** Null without a session. */ val status: StateFlow + /** + * The last status of the session that ended most recently, set before [status] clears. A + * collector may see only the clearing, so the handback reads this instead. + */ + val endedStatus: CastRemoteStatus? + fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) fun play() @@ -133,25 +141,29 @@ internal class MobileCastController( val remotePlayer: CastRemotePlayer = CastRemotePlayer(gateway, scope, clock) { replay(it) } private val handovers = mutableMapOf() - private var lastStatus: CastRemoteStatus? = null private var lastMedia: PlaybackCastMedia? = null + /** The ended session whose position has been handed back, so it is handed back once. */ + private var handedBack: CastRemoteStatus? = null + init { scope.launch { gateway.status.collect { status -> - val previous = lastStatus - // The gateway clears the status only as the session ends: freeze where the receiver - // stood, for the file's next local start. A file that finished hands back stopped. - val fileId = previous?.fileId - if (status == null && fileId != null) handovers[fileId] = previous.handover() - lastStatus = status + // The status clears only as the session ends: freeze where the receiver stood, for + // the file's next local start. A file that finished hands back stopped. + if (status == null) takeEnded()?.let { (fileId, handover) -> handovers[fileId] = handover } } } } - /** Whether the receiver holds [fileId] now, so a screen opened on it attaches instead of loading. */ + /** + * Whether the receiver holds [fileId] now, so a screen opened on it attaches instead of loading. + * Media that failed there is sent again. + */ fun holds(fileId: Long): Boolean = - gateway.status.value?.let { it.fileId == fileId && it.state != CastRemoteState.Idle } == true + gateway.status.value?.let { + it.fileId == fileId && it.state != CastRemoteState.Idle && it.state != CastRemoteState.Failed + } == true /** * Sends [media] to the receiver from where local playback stood, unless the receiver already @@ -170,22 +182,52 @@ internal class MobileCastController( return loads } - /** Ties the receiver's controls to [item] without loading it: the receiver already plays it. */ - fun attach(item: MediaItem) = remotePlayer.adopt(item) + /** + * Ties the receiver's controls to [item] without loading it: the receiver already plays it. + * [media], when known, is what Play sends again once the file has finished there. + */ + fun attach(item: MediaItem, media: PlaybackCastMedia? = null) { + remotePlayer.adopt(item) + if (media != null) lastMedia = media + } + + /** The file the receiver plays for this app, named as it was sent; null without one. */ + fun castingTarget(): PlaybackTarget? { + val item = remotePlayer.currentMediaItem + ?.takeIf { gateway.availability.value is CastAvailability.Connected } + val fileId = item?.mediaId?.toLongOrNull() ?: return null + val audio = item.mediaMetadata.mediaType == MediaMetadata.MEDIA_TYPE_MUSIC + return PlaybackTarget( + FilesItemId(fileId), + item.mediaMetadata.title?.toString().orEmpty(), + if (audio) PlaybackMediaType.AUDIO else PlaybackMediaType.VIDEO, + ) + } + + /** Signing out ends the session: the receiver holds the account's download token. */ + fun end() { + lastMedia = null + handovers.clear() + gateway.stopCasting() + } /** Where the local player picks [fileId] up after a session ended; taken once, null while casting. */ fun takeHandover(fileId: Long): CastHandover? { if (gateway.availability.value is CastAvailability.Connected) return null - val stored = handovers.remove(fileId) - // The session ended a moment ago and the status collector has not run yet. - val pending = lastStatus?.takeIf { stored == null && gateway.status.value == null && it.fileId == fileId } - if (pending != null) lastStatus = null - return stored ?: pending?.handover() + // The session may have ended a moment ago, before the status collector ran. + if (gateway.status.value == null && gateway.endedStatus?.fileId == fileId) { + takeEnded()?.let { (endedFileId, handover) -> handovers[endedFileId] = handover } + } + return handovers.remove(fileId) } - private fun CastRemoteStatus.handover() = CastHandover(positionAt(clock()), playing, speed) + private fun takeEnded(): Pair? { + val ended = gateway.endedStatus?.takeIf { it !== handedBack } ?: return null + handedBack = ended + return ended.fileId?.let { it to CastHandover(ended.positionAt(clock()), ended.playing, ended.speed) } + } - // A finished file has left the receiver; playing it again sends it once more. + // A file that finished or failed has left the receiver; playing it again sends it once more. private fun replay(startMillis: Long) { val media = lastMedia ?: return if (gateway.status.value?.fileId != media.fileId) return @@ -247,7 +289,10 @@ internal class CastRemotePlayer( } } + /** Keeps an item that reports positions over a bare one for the same file. */ fun adopt(item: MediaItem) { + val current = this.item + if (current?.mediaId == item.mediaId && current.reportsPositions && !item.reportsPositions) return this.item = item invalidateState() } @@ -309,7 +354,7 @@ internal class CastRemotePlayer( when { // Play after a finish seeks to the start first, which already sent the file again. playWhenReady && replaying -> Unit - playWhenReady && status?.state == CastRemoteState.Ended -> replayFrom(0L) + playWhenReady && status?.left == true -> replayFrom(0L) playWhenReady -> gateway.play() else -> gateway.pause() } @@ -318,7 +363,7 @@ internal class CastRemotePlayer( override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { val position = if (positionMs == C.TIME_UNSET) 0L else positionMs.coerceAtLeast(0L) - if (status?.state == CastRemoteState.Ended && !replaying) replayFrom(position) else gateway.seekTo(position) + if (status?.left == true && !replaying) replayFrom(position) else gateway.seekTo(position) return awaitStatus() } @@ -346,6 +391,12 @@ internal class CastRemotePlayer( settled.forEach { it.set(null) } } + private val CastRemoteStatus.left: Boolean + get() = state == CastRemoteState.Ended || state == CastRemoteState.Failed + + private val MediaItem.reportsPositions: Boolean + get() = mediaMetadata.extras?.containsKey(PLAYBACK_REPORTING_LEASE_KEY) == true + private companion object { const val MICROS_PER_MILLI = 1_000L const val STATUS_WAIT_MILLIS = 5_000L diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index 2e1acd32e..f0f98e01d 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -1,7 +1,12 @@ package io.putdotio.android.playback +import android.app.Activity import android.content.Context +import android.content.Intent import androidx.core.net.toUri +import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.SystemClock import androidx.core.content.ContextCompat import androidx.mediarouter.media.MediaRouteSelector @@ -29,6 +34,7 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage import io.putdotio.android.MainActivity +import io.putdotio.android.MobilePlaybackService import io.putdotio.android.R import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.MutableStateFlow @@ -55,6 +61,18 @@ internal object MobileCast { } } + /** + * The signed-in session left: the receiver plays with the account's download token, so the Cast + * session ends with it. A process that never cast has nothing to end. + */ + fun sessionLeft() { + val cast = controller ?: return + Handler(Looper.getMainLooper()).post { cast.end() } + } + + /** The file a session plays now, for the Cast notification's tap; null without one. */ + fun castingTarget(): PlaybackTarget? = controller?.castingTarget() + // A device without Play services, or one whose Play services cannot answer, has no Cast. @Suppress("SwallowedException") private fun playServicesAvailable(context: Context): Boolean = @@ -85,7 +103,7 @@ class MobileCastOptionsProvider : OptionsProvider { ) .setSkipStepMs(NOTIFICATION_SKIP_MILLIS) .setSmallIconDrawableResId(R.drawable.ic_ph_screencast) - .setTargetActivityClassName(MainActivity::class.java.name) + .setTargetActivityClassName(MobileCastNotificationActivity::class.java.name) .build() return CastOptions.Builder() .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) @@ -108,6 +126,22 @@ class MobileCastOptionsProvider : OptionsProvider { } } +/** + * The Cast notification's tap. The Cast SDK opens its target with no action of its own, so this asks + * the app for the playing file as the audio notification does, which opens the receiver's controls. + */ +class MobileCastNotificationActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + startActivity( + Intent(this, MainActivity::class.java) + .setAction(MobilePlaybackService.ACTION_OPEN_NOW_PLAYING) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + finish() + } +} + /** * [CastGateway] over the Cast SDK and MediaRouter. Without Google Play services, or when Cast fails * to start, it stays [CastAvailability.Unsupported] and touches nothing else. @@ -260,6 +294,8 @@ internal class GmsCastRoutes(private val context: Context) : CastRoutes { internal class GmsCastRemote : CastRemote { private val mutableStatus = MutableStateFlow(null) override val status: StateFlow = mutableStatus + override var endedStatus: CastRemoteStatus? = null + private set private var client: RemoteMediaClient? = null private var lastFileId: Long? = null @@ -287,6 +323,7 @@ internal class GmsCastRemote : CastRemote { publish() release() lastFileId = null + endedStatus = mutableStatus.value mutableStatus.value = null } @@ -325,7 +362,7 @@ internal class GmsCastRemote : CastRemote { .setMediaInfo(media.toMediaInfo()) .setAutoplay(autoplay) .setCurrentTime(startMillis) - .setPlaybackRate(speed.toDouble()) + .setPlaybackRate(speed.castRate()) .setActiveTrackIds(media.activeSubtitleId?.let { longArrayOf(it) } ?: longArrayOf()) .build(), ) @@ -344,10 +381,14 @@ internal class GmsCastRemote : CastRemote { } override fun setSpeed(speed: Float) { - client?.setPlaybackRate(speed.toDouble()) + client?.setPlaybackRate(speed.castRate()) } } +/** Cast takes 0.5x to 2x; the player's faster presets play at 2x there. */ +private fun Float.castRate(): Double = + toDouble().coerceIn(MediaLoadRequestData.PLAYBACK_RATE_MIN, MediaLoadRequestData.PLAYBACK_RATE_MAX) + private fun MediaStatus.castRemoteState(): CastRemoteState = when (playerState) { MediaStatus.PLAYER_STATE_PLAYING -> CastRemoteState.Playing diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index 0b505b1a6..23f751e56 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -62,8 +62,9 @@ private val NoCast: StateFlow = MutableStateFlow(CastAvailabil private val NoPlayerCast = PlayerCast(controller = null, button = {}, casting = null) /** - * Where the local player picks [fileId] up after a Cast session ended, taken once; the preferences - * keep it so a recreated player starts there too. + * Where the local player picks [fileId] up after a Cast session ended, taken once. It moves into the + * preferences at once, which then answer as local playback moves on, so a player recreated later + * starts where local playback got to rather than where the receiver stopped. */ @Composable internal fun rememberCastHandover( @@ -71,11 +72,12 @@ internal fun rememberCastHandover( fileId: Long, preferences: RetainedPlayerPreferences, ): CastHandover? { - val handover = remember(fileId) { playerCast.controller?.takeHandover(fileId) } + var handover by remember(fileId) { mutableStateOf(playerCast.controller?.takeHandover(fileId)) } LaunchedEffect(handover) { - if (handover != null) { - preferences.retainPlayback(RetainedPlayback(handover.positionMillis, handover.playing)) - preferences.playbackSpeed = handover.speed + handover?.let { + preferences.retainPlayback(RetainedPlayback(it.positionMillis, it.playing)) + preferences.playbackSpeed = it.speed + handover = null } } return handover @@ -217,17 +219,14 @@ private suspend fun MobileCastController.handOver( val item = ready?.let { playerFactory.reportableItem(it.source.toMediaItem(target.name, target.mediaType), it.useStartFrom) } ?: bareItem(fileId, target.name) - val media = when { - holds(fileId) -> null - source == null -> null.also { onSourceRequired(local.positionMillis) } - else -> castMedia(source) + val media = source?.let { castMedia(it) } + when { + // The receiver plays it already: what Play sends again after it finishes, if it resolved. + holds(fileId) -> attach(item, (media as? PlaybackRepositoryResult.Success)?.value) + media == null -> onSourceRequired(local.positionMillis) + media is PlaybackRepositoryResult.Success -> cast(media.value, item, local) } - when (media) { - null -> if (holds(fileId)) attach(item) - is PlaybackRepositoryResult.Success -> cast(media.value, item, local) - is PlaybackRepositoryResult.Failure -> Unit - } - return media !is PlaybackRepositoryResult.Failure + return media !is PlaybackRepositoryResult.Failure || holds(fileId) } /** [intervals]' step from where the receiver is, within the file. */ diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt index 5156b3a05..9501cbc31 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.isTraversalGroup @@ -17,6 +19,7 @@ import androidx.compose.ui.semantics.paneTitle import io.putdotio.android.design.PutioRadius import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,6 +40,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -91,6 +96,9 @@ internal fun MobileCastPicker(gateway: CastGateway, onDismiss: () -> Unit, modif val devices by gateway.devices.collectAsState() val availability by gateway.availability.collectAsState() BackHandler(onBack = onDismiss) + // Keys move through the picker, not the player behind it. + val focus = remember { FocusRequester() } + LaunchedEffect(focus) { focus.requestFocus() } val title = stringResource(R.string.mobile_cast_to) Box(modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { Box( @@ -117,6 +125,8 @@ internal fun MobileCastPicker(gateway: CastGateway, onDismiss: () -> Unit, modif paneTitle = title isTraversalGroup = true } + .focusRequester(focus) + .focusGroup() .testTag(MOBILE_CAST_PICKER_TAG), horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt index 1ad35e2e8..96265ab0d 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -111,14 +111,36 @@ class MobileCastControllerTest { gateway.report(CastRemoteState.Playing, positionMillis = 60_000L) // The session ends; the player recomposes before the status collector runs. - gateway.status.value = null - gateway.availability.value = CastAvailability.Available + gateway.end() assertEquals(CastHandover(positionMillis = 60_000L, playing = true), cast.takeHandover(42L)) idle() assertNull(cast.takeHandover(42L)) } + @Test + fun theHandbackUsesTheReceiversLastWordEvenWhenNoCollectorSawIt() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Playing, positionMillis = 60_000L) + // The viewer paused on the TV; that status and the session's end arrive in one looper turn. + now += 10_000L + gateway.status.value = gateway.remote(CastRemoteState.Paused, positionMillis = 70_000L) + gateway.disconnect() + + assertEquals(CastHandover(positionMillis = 70_000L, playing = false), cast.takeHandover(42L)) + } + + @Test + fun aFileThatFailedOnTheReceiverIsSentAgain() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Failed, positionMillis = 0L) + + assertTrue(cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true))) + assertEquals(2, gateway.loads.size) + } + @Test fun aPausedReceiverHandsBackItsPositionPaused() { gateway.connect() @@ -218,6 +240,7 @@ class MobileCastControllerTest { private inner class FakeCastGateway : CastGateway { override val availability = MutableStateFlow(CastAvailability.Available) override val status = MutableStateFlow(null) + override var endedStatus: CastRemoteStatus? = null override val devices = MutableStateFlow>(emptyList()) val loads = mutableListOf() val commands = mutableListOf() @@ -228,9 +251,15 @@ class MobileCastControllerTest { } fun disconnect() { + end() + idle() + } + + /** As the SDK ends a session: the last status kept, then cleared, all before any collector runs. */ + fun end() { + endedStatus = status.value availability.value = CastAvailability.Available status.value = null - idle() } fun report( @@ -240,7 +269,18 @@ class MobileCastControllerTest { speed: Float = 1f, speedSupported: Boolean = false, ) { - status.value = CastRemoteStatus( + status.value = remote(state, positionMillis, durationMillis, speed, speedSupported) + idle() + } + + fun remote( + state: CastRemoteState, + positionMillis: Long, + durationMillis: Long? = null, + speed: Float = 1f, + speedSupported: Boolean = false, + ) = + CastRemoteStatus( fileId = 42L, state = state, positionMillis = positionMillis, @@ -249,8 +289,6 @@ class MobileCastControllerTest { speedSupported = speedSupported, sampledAtMillis = now, ) - idle() - } override fun startDiscovery() = Unit From 74ad6668566323e42d2df774afd722eff006e523 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 15:42:50 +0300 Subject: [PATCH 03/16] fix(mobile): settle Cast state before acting on it Wait for a resumed receiver's first status, report refused loads, keep a paused load paused on handback, resolve session audio's source when attaching, end Cast at the process's session boundary, reopen the receiver's file from a fresh process, and make the picker modal. --- .../kotlin/io/putdotio/android/MobileShell.kt | 2 +- .../kotlin/io/putdotio/android/PutioApp.kt | 6 +- .../android/auth/MobileOAuthRuntime.kt | 30 +++-- .../putdotio/android/playback/MobileCast.kt | 24 ++-- .../android/playback/MobileCastGms.kt | 86 +++++++++++- .../android/playback/MobileCastPlayback.kt | 8 +- .../putdotio/android/playback/MobileCastUi.kt | 3 + .../android/playback/MobilePlayerScreen.kt | 126 ++++++++++-------- .../android/MobileCastControllerTest.kt | 12 ++ 9 files changed, 202 insertions(+), 95 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt index c53e474d7..10ee1b6e1 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt @@ -715,7 +715,7 @@ private fun NowPlayingNavigationEffect( // The Cast notification asks too: the receiver's file, while local audio has stopped. val target = playbackPlayerFactory.activeAudio(appContext) ?.let { PlaybackTarget(it.fileId, it.title, PlaybackMediaType.AUDIO) } - ?: MobileCast.castingTarget() + ?: MobileCast.castingTarget(appContext) // Acknowledged only once the lookup finished: a cancelled lookup leaves it pending. nowPlayingRequests.acknowledge() if (target == null || transferDraft.state.value.incomingRequestId != null) return@collect diff --git a/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt b/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt index 91d48b596..d704f2850 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/PutioApp.kt @@ -25,7 +25,6 @@ import io.putdotio.android.auth.MobileOAuthRuntime import io.putdotio.android.auth.MobileSignedOutReason import io.putdotio.android.auth.OAuthBrowserLaunchResult import io.putdotio.android.auth.OAuthLaunchResult -import io.putdotio.android.playback.MobileCast import io.putdotio.android.design.PutioTheme import io.putdotio.android.downloads.MobileDownloadsViewModel import io.putdotio.android.downloads.mobileDownloadsViewModelFactory @@ -121,10 +120,7 @@ private fun MobileAuthRoot( } PlaybackSessionBoundaryEffect( sessionId = (authState as? MobileAuthState.SignedIn)?.sessionId, - onSessionLeft = { - MobilePlaybackService.stop(context.applicationContext) - MobileCast.sessionLeft() - }, + onSessionLeft = { MobilePlaybackService.stop(context.applicationContext) }, ) when (val state = authState) { MobileAuthState.Initializing, diff --git a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt index 0b28bedb3..23e7dda65 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt @@ -7,6 +7,7 @@ import android.net.Network import android.net.NetworkCapabilities import android.util.Log import io.putdotio.android.BuildConfig +import io.putdotio.android.playback.MobileCast import io.putdotio.android.playback.SdkPlaybackPositionRepository import io.putdotio.android.documents.MobileDocumentsProvider import io.putdotio.android.downloads.MobileDownloadCache @@ -210,18 +211,7 @@ class MobileOAuthRuntime internal constructor( putioClient = putioClient, authController = authController, applicationScope = applicationScope, - onSessionLeft = { session -> - MobileFileShareService.endSession(context, session) - MobilePdfDocument.clearCache(context) - MobileFileDrags.endSession(session) - MobileDocumentsProvider.sessionLeft(context) - // The collector can lag the next sign-in; that account's outcomes stay. - MobileDownloadNotifications.cancelOtherAccounts( - context, - (authController.state.value as? MobileAuthState.SignedIn)?.account?.userId, - ) - MobileWidgets.sessionLeft(context) - }, + onSessionLeft = { session -> sessionLeft(context, session, authController) }, onSessionStarted = { MobileDocumentsProvider.sessionStarted(context) MobileWidgets.sessionStarted(context) @@ -231,6 +221,22 @@ class MobileOAuthRuntime internal constructor( } } + /** What a session leaves behind on this device, ended or removed once it is gone. */ + private fun sessionLeft(context: Context, session: MobileAuthSessionId?, authController: MobileAuthController) { + MobileFileShareService.endSession(context, session) + MobilePdfDocument.clearCache(context) + MobileFileDrags.endSession(session) + MobileDocumentsProvider.sessionLeft(context) + // The collector can lag the next sign-in; that account's outcomes stay. + MobileDownloadNotifications.cancelOtherAccounts( + context, + (authController.state.value as? MobileAuthState.SignedIn)?.account?.userId, + ) + MobileWidgets.sessionLeft(context) + // The receiver plays with the account's download token. + MobileCast.sessionLeft() + } + /** What sign-out removes from this device for every account: move targets and Continue watching. */ private fun clearAccountLocalState(context: Context) { MobileMoveTargetStore.clearAll(context) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index 9f9665771..e5203a2e2 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -5,7 +5,6 @@ import android.os.Looper import androidx.media3.common.C import androidx.media3.common.DeviceInfo import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.SimpleBasePlayer @@ -52,8 +51,14 @@ internal data class CastRemoteStatus( val seekable: Boolean = true, /** The clock reading the position was taken at; a playing position moves on from it. */ val sampledAtMillis: Long, + /** Whether play was last asked for; a loading or buffering receiver plays only if so. */ + val playIntent: Boolean? = null, + /** The title and kind the media was sent with, so the app can reopen it without a screen's help. */ + val title: String? = null, + val audio: Boolean = false, ) { - val playing: Boolean get() = state == CastRemoteState.Playing || state == CastRemoteState.Buffering + val playing: Boolean + get() = state == CastRemoteState.Playing || (state == CastRemoteState.Buffering && playIntent != false) fun positionAt(nowMillis: Long): Long { val moved = if (state == CastRemoteState.Playing) ((nowMillis - sampledAtMillis) * speed).toLong() else 0L @@ -191,16 +196,17 @@ internal class MobileCastController( if (media != null) lastMedia = media } - /** The file the receiver plays for this app, named as it was sent; null without one. */ + /** + * The file the receiver plays for this app, named as it was sent; null without one. Read from the + * receiver itself, so it answers in a process that never opened a player. + */ fun castingTarget(): PlaybackTarget? { - val item = remotePlayer.currentMediaItem - ?.takeIf { gateway.availability.value is CastAvailability.Connected } - val fileId = item?.mediaId?.toLongOrNull() ?: return null - val audio = item.mediaMetadata.mediaType == MediaMetadata.MEDIA_TYPE_MUSIC + val status = gateway.status.value?.takeIf { gateway.availability.value is CastAvailability.Connected } + val fileId = status?.fileId ?: return null return PlaybackTarget( FilesItemId(fileId), - item.mediaMetadata.title?.toString().orEmpty(), - if (audio) PlaybackMediaType.AUDIO else PlaybackMediaType.VIDEO, + status.title.orEmpty(), + if (status.audio) PlaybackMediaType.AUDIO else PlaybackMediaType.VIDEO, ) } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index f0f98e01d..1278a25eb 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -39,6 +39,10 @@ import io.putdotio.android.R import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject /** The process's Cast controller, started on first use; null where Google Play services is missing. */ @@ -70,8 +74,18 @@ internal object MobileCast { Handler(Looper.getMainLooper()).post { cast.end() } } - /** The file a session plays now, for the Cast notification's tap; null without one. */ - fun castingTarget(): PlaybackTarget? = controller?.castingTarget() + /** + * The file a session plays now, for the Cast notification's tap; null without one. A process the + * tap just started resumes the session first, so this waits a moment for the receiver's word. + */ + suspend fun castingTarget(context: Context): PlaybackTarget? { + val cast = controller(context) ?: return null + return withTimeoutOrNull(SESSION_RESUME_WAIT_MILLIS) { + combine(cast.availability, cast.status) { _, _ -> cast.castingTarget() }.filterNotNull().first() + } + } + + private const val SESSION_RESUME_WAIT_MILLIS = 3_000L // A device without Play services, or one whose Play services cannot answer, has no Cast. @Suppress("SwallowedException") @@ -153,6 +167,10 @@ internal class GmsCastGateway private constructor( ) : CastGateway, CastRoutes by routes, CastRemote by remote { constructor(context: Context) : this(context, GmsCastRoutes(context), GmsCastRemote()) + init { + remote.onAttachedChanged = { updateAvailability() } + } + private val mutableAvailability = MutableStateFlow(CastAvailability.Unsupported) override val availability: StateFlow = mutableAvailability private var castContext: CastContext? = null @@ -299,10 +317,33 @@ internal class GmsCastRemote : CastRemote { private var client: RemoteMediaClient? = null private var lastFileId: Long? = null - val attached: Boolean get() = client != null + /** Whether play was last asked for, which a loading receiver's status does not say. */ + private var playIntent: Boolean? = null + + /** + * Whether the receiver has said what it plays. A resumed session can attach before its first + * status, and a screen must not take "nothing yet" for "nothing", or it would load over the + * receiver's own position. + */ + private var statusKnown = false + + /** Runs when [attached] changes, so availability follows it. */ + var onAttachedChanged: () -> Unit = {} + + val attached: Boolean get() = client != null && statusKnown + + private val markStatusKnown = { + if (client != null && !statusKnown) { + statusKnown = true + onAttachedChanged() + } + } private val callback = object : RemoteMediaClient.Callback() { - override fun onStatusUpdated() = publish() + override fun onStatusUpdated() { + markStatusKnown() + publish() + } override fun onMetadataUpdated() = publish() } @@ -314,6 +355,11 @@ internal class GmsCastRemote : CastRemote { client = remote?.also { it.registerCallback(callback) it.addProgressListener(progress, PROGRESS_INTERVAL_MILLIS) + // Answered or not, the first status request settles whether the receiver plays anything. + it.requestStatus().setResultCallback { + publish() + markStatusKnown() + } } publish() } @@ -323,6 +369,7 @@ internal class GmsCastRemote : CastRemote { publish() release() lastFileId = null + playIntent = null endedStatus = mutableStatus.value mutableStatus.value = null } @@ -333,30 +380,40 @@ internal class GmsCastRemote : CastRemote { it.removeProgressListener(progress) } client = null + statusKnown = false } fun publish() { val remote = client ?: return val media = remote.mediaStatus ?: return // A finished file's status drops its media; it still belongs to the file it finished. - val fileId = remote.mediaInfo?.customData?.optLong(FILE_ID_KEY)?.takeIf { it > 0L } + val info = remote.mediaInfo + val fileId = info?.customData?.optLong(FILE_ID_KEY)?.takeIf { it > 0L } ?: lastFileId.takeIf { media.playerState == MediaStatus.PLAYER_STATE_IDLE } lastFileId = fileId + val state = media.castRemoteState() + if (state == CastRemoteState.Playing || state == CastRemoteState.Paused) { + playIntent = state == CastRemoteState.Playing + } mutableStatus.value = CastRemoteStatus( fileId = fileId, - state = media.castRemoteState(), + state = state, positionMillis = remote.approximateStreamPosition.coerceAtLeast(0L), durationMillis = remote.streamDuration.takeIf { it > 0L }, speed = media.playbackRate.toFloat().takeIf { it > 0f } ?: 1f, speedSupported = media.isMediaCommandSupported(MediaStatus.COMMAND_PLAYBACK_RATE), seekable = media.isMediaCommandSupported(MediaStatus.COMMAND_SEEK), sampledAtMillis = SystemClock.elapsedRealtime(), + playIntent = playIntent, + title = info?.metadata?.getString(MediaMetadata.KEY_TITLE), + audio = info?.metadata?.mediaType == MediaMetadata.MEDIA_TYPE_MUSIC_TRACK, ) } override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { val remote = client ?: return lastFileId = media.fileId + playIntent = autoplay remote.load( MediaLoadRequestData.Builder() .setMediaInfo(media.toMediaInfo()) @@ -365,14 +422,29 @@ internal class GmsCastRemote : CastRemote { .setPlaybackRate(speed.castRate()) .setActiveTrackIds(media.activeSubtitleId?.let { longArrayOf(it) } ?: longArrayOf()) .build(), - ) + ).setResultCallback { result -> + // A refused or timed-out load says so for its file, unless another load followed it. + if (!result.status.isSuccess && client === remote && lastFileId == media.fileId) { + mutableStatus.value = CastRemoteStatus( + fileId = media.fileId, + state = CastRemoteState.Failed, + positionMillis = startMillis, + durationMillis = media.durationMillis, + sampledAtMillis = SystemClock.elapsedRealtime(), + title = media.title, + audio = media.mediaType == PlaybackMediaType.AUDIO, + ) + } + } } override fun play() { + playIntent = true client?.play() } override fun pause() { + playIntent = false client?.pause() } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index 23f751e56..a5fa1024a 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -221,8 +221,12 @@ private suspend fun MobileCastController.handOver( } ?: bareItem(fileId, target.name) val media = source?.let { castMedia(it) } when { - // The receiver plays it already: what Play sends again after it finishes, if it resolved. - holds(fileId) -> attach(item, (media as? PlaybackRepositoryResult.Success)?.value) + // The receiver plays it already: what Play sends again after it finishes, if it resolved. Session + // audio opens without a source; resolving one brings the reporting lease and that media. + holds(fileId) -> { + attach(item, (media as? PlaybackRepositoryResult.Success)?.value) + if (source == null) onSourceRequired(local.positionMillis) + } media == null -> onSourceRequired(local.positionMillis) media is PlaybackRepositoryResult.Success -> cast(media.value, item, local) } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt index 9501cbc31..dc2d2cdc2 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.clearAndSetSemantics @@ -126,6 +127,8 @@ internal fun MobileCastPicker(gateway: CastGateway, onDismiss: () -> Unit, modif isTraversalGroup = true } .focusRequester(focus) + // Focus stays in the picker until it closes. + .focusProperties { onExit = { cancelFocusChange() } } .focusGroup() .testTag(MOBILE_CAST_PICKER_TAG), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index ed3977dff..bf1bbe2ef 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.platform.LocalAccessibilityManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -153,78 +154,85 @@ internal fun MobilePlayerScreen( onKeyboardNavigation = { keyboardNavigationActive = true }, ), ) { - val isVideo = state.target.mediaType == PlaybackMediaType.VIDEO - when (val content = state.content) { - is PlaybackContent.Loading -> - MobileLoadingState(stringResource(state.target.mediaType.loadingMessage())) - - is PlaybackContent.AwaitingResume -> MobileResumePlaybackDialog( - startFromSeconds = content.source.startFromSeconds, - onResume = onResume, - onRestart = onRestart, - onDismiss = onBack, - ) + // The open device picker is modal: TalkBack reaches only it. + Box(Modifier.fillMaxSize().hiddenFromAccessibility(castPicker.value)) { + val isVideo = state.target.mediaType == PlaybackMediaType.VIDEO + when (val content = state.content) { + is PlaybackContent.Loading -> + MobileLoadingState(stringResource(state.target.mediaType.loadingMessage())) + + is PlaybackContent.AwaitingResume -> MobileResumePlaybackDialog( + startFromSeconds = content.source.startFromSeconds, + onResume = onResume, + onRestart = onRestart, + onDismiss = onBack, + ) - is PlaybackContent.Ready, PlaybackContent.Session -> MobileReadyContent( - state = state, - playerCast = playerCast, - preferences = preferences, - playerFactory = playerFactory, - subtitleStartupPolicy = subtitleStartupPolicy, - keyboardNavigation = keyboardNavigation, - onPlayerFailure = onPlayerFailure, - autoplayNextVideo = autoplayNextVideo, - onPlaybackEnded = onPlaybackEnded, - seekClock = seekClock, - onSourceRequired = onSourceRequired, - onBack = onBack, - onVideoAspectRatio = { landscapeVideo = it > 1f }, - pictureInPicture = pictureInPicture, - nextEpisode = nextEpisode.takeIf { isVideo }, - onNextEpisode = onNextEpisode, - neighbours = neighbours, - onPlay = onPlay, - ) + is PlaybackContent.Ready, PlaybackContent.Session -> MobileReadyContent( + state = state, + playerCast = playerCast, + preferences = preferences, + playerFactory = playerFactory, + subtitleStartupPolicy = subtitleStartupPolicy, + keyboardNavigation = keyboardNavigation, + onPlayerFailure = onPlayerFailure, + autoplayNextVideo = autoplayNextVideo, + onPlaybackEnded = onPlaybackEnded, + seekClock = seekClock, + onSourceRequired = onSourceRequired, + onBack = onBack, + onVideoAspectRatio = { landscapeVideo = it > 1f }, + pictureInPicture = pictureInPicture, + nextEpisode = nextEpisode.takeIf { isVideo }, + onNextEpisode = onNextEpisode, + neighbours = neighbours, + onPlay = onPlay, + ) - is PlaybackContent.FindingNext -> - MobileLoadingState(stringResource(R.string.mobile_playback_finding_next)) + is PlaybackContent.FindingNext -> + MobileLoadingState(stringResource(R.string.mobile_playback_finding_next)) - is PlaybackContent.NextFailed -> - MobilePlaybackFailureState( - title = stringResource(R.string.mobile_playback_next_error_title), - failure = content.failure, - onRetry = onRetry, - video = isVideo, - ) + is PlaybackContent.NextFailed -> + MobilePlaybackFailureState( + title = stringResource(R.string.mobile_playback_next_error_title), + failure = content.failure, + onRetry = onRetry, + video = isVideo, + ) - PlaybackContent.Ended -> Unit + PlaybackContent.Ended -> Unit - is PlaybackContent.Conversion -> - MobileConversionState(content, onRefreshConversion, onStartConversion) + is PlaybackContent.Conversion -> + MobileConversionState(content, onRefreshConversion, onStartConversion) - is PlaybackContent.Unsupported -> - MobileEmptyState( - title = stringResource(R.string.mobile_playback_unsupported_title), - message = stringResource(R.string.mobile_playback_unsupported_message), - ) + is PlaybackContent.Unsupported -> + MobileEmptyState( + title = stringResource(R.string.mobile_playback_unsupported_title), + message = stringResource(R.string.mobile_playback_unsupported_message), + ) - is PlaybackContent.Failed -> - MobilePlaybackFailureState( - title = stringResource(state.target.mediaType.errorTitle()), - failure = content.failure, - onRetry = onRetry, - video = isVideo, - onOpenInVlc = onOpenInVlc, - ) - } + is PlaybackContent.Failed -> + MobilePlaybackFailureState( + title = stringResource(state.target.mediaType.errorTitle()), + failure = content.failure, + onRetry = onRetry, + video = isVideo, + onOpenInVlc = onOpenInVlc, + ) + } - if (state.showsSeparateBack() && !pictureInPicture.value) { - MobilePlayerSeparateBack(state, onBack, Modifier.align(Alignment.TopStart)) + if (state.showsSeparateBack() && !pictureInPicture.value) { + MobilePlayerSeparateBack(state, onBack, Modifier.align(Alignment.TopStart)) + } } MobileCastPickerHost(cast, castPicker, pictureInPicture.value, Modifier.zIndex(4f)) } } +/** Leaves this content out of accessibility while something modal covers it. */ +private fun Modifier.hiddenFromAccessibility(hidden: Boolean): Modifier = + if (hidden) clearAndSetSemantics {} else this + /** * The player once it has something to play: the receiver's controls while a Cast session runs, * otherwise the local player, which picks a file up where a session that just ended left it. diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt index 96265ab0d..93b376160 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -131,6 +131,18 @@ class MobileCastControllerTest { assertEquals(CastHandover(positionMillis = 70_000L, playing = false), cast.takeHandover(42L)) } + @Test + fun aPausedFileStillLoadingHandsBackPaused() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 5_000L, playing = false)) + // Loading reads as buffering; only the play intent says it waits paused. + gateway.status.value = + gateway.remote(CastRemoteState.Buffering, positionMillis = 5_000L).copy(playIntent = false) + gateway.disconnect() + + assertEquals(CastHandover(positionMillis = 5_000L, playing = false), cast.takeHandover(42L)) + } + @Test fun aFileThatFailedOnTheReceiverIsSentAgain() { gateway.connect() From 9e81be37796ddc363b1f41fd95e88bd541d3e370 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 15:57:04 +0300 Subject: [PATCH 04/16] fix(mobile): attach to cast audio without replacing the screen's state Resolve the receiver's media on its own when a screen reopens without a source, so a failed lookup keeps the remote controls, and leave an unknown resume position unknown when asking for a source. --- .../io/putdotio/android/MobileNavigation.kt | 7 +- .../android/playback/MobileCastPlayback.kt | 76 +++++++++++++------ 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt index 3fabe8e73..75efa401e 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt @@ -59,6 +59,7 @@ import io.putdotio.android.files.MobileMoveTargetStore import io.putdotio.android.playback.MobileCast import io.putdotio.android.playback.MobileCastController import io.putdotio.android.playback.MobileCastMediaLoader +import io.putdotio.android.playback.castMediaFor import io.putdotio.android.playback.MobilePlaybackViewModel import io.putdotio.android.playback.MobilePlayerFactory import io.putdotio.android.playback.MobilePlayerScreen @@ -403,7 +404,7 @@ internal fun MobileNavHost( { target -> actions.openInVlc(target.fileId, target.name) } }, cast = cast, - castMedia = playbackRepository::castMedia, + castMedia = playbackRepository::castMediaFor, ) // Over the player, which paints its own background. filesActions?.let { MobileFilesNoticeHost(it.notices, Modifier.align(Alignment.BottomCenter)) } @@ -455,7 +456,7 @@ private fun MobilePlaybackRoute( onPlay: (PlaybackTarget) -> Unit = {}, onOpenInVlc: ((PlaybackTarget) -> Unit)? = null, cast: MobileCastController? = null, - castMedia: (suspend (PlaybackTarget, PlaybackSource, String?) -> PlaybackRepositoryResult)? = + castMedia: (suspend (PlaybackTarget, PlaybackSource?, String?) -> PlaybackRepositoryResult)? = null, ) { val state by controller.state.collectAsStateWithLifecycle() @@ -517,7 +518,7 @@ private fun MobilePlaybackRoute( /** [load] for the file on screen, or no Cast without it. */ private fun castMediaFor( - load: (suspend (PlaybackTarget, PlaybackSource, String?) -> PlaybackRepositoryResult)?, + load: (suspend (PlaybackTarget, PlaybackSource?, String?) -> PlaybackRepositoryResult)?, target: PlaybackTarget, ): MobileCastMediaLoader? = if (load == null) null else { source, subtitleKey -> load(target, source, subtitleKey) } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index a5fa1024a..5a5890a02 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -24,9 +24,30 @@ import io.putdotio.sdk.files.PlaybackSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -/** Hands a resolved source to the receiver: the source and the subtitle to show first. */ +/** + * The receiver's form of the file on screen: from its resolved source, or, given none, from a fresh + * resolution that leaves the screen's own state alone; then the subtitle to show first. + */ internal typealias MobileCastMediaLoader = - suspend (PlaybackSource, String?) -> PlaybackRepositoryResult + suspend (PlaybackSource?, String?) -> PlaybackRepositoryResult + +/** [MobileCastMediaLoader] for [target], resolving it again when no source is at hand. */ +internal suspend fun PlaybackRepository.castMediaFor( + target: PlaybackTarget, + source: PlaybackSource?, + subtitleKey: String?, +): PlaybackRepositoryResult { + val resolved = source?.let { PlaybackRepositoryResult.Success(PlaybackResolution.Ready(it)) } ?: resolve(target) + val ready = (resolved as? PlaybackRepositoryResult.Success)?.value as? PlaybackResolution.Ready + return when { + ready != null -> castMedia(target, ready.source, subtitleKey) + resolved is PlaybackRepositoryResult.Failure -> resolved + else -> PlaybackRepositoryResult.Failure(PlaybackFailure.MediaUnsupported(UncastableResolution())) + } +} + +/** A file that resolved to a conversion or an unsupported type has nothing a receiver can play yet. */ +private class UncastableResolution : IllegalStateException("The file resolved to nothing a receiver plays") /** The player screen's Cast: its top-bar button, and the session it casts to while one runs. */ internal class PlayerCast( @@ -128,8 +149,8 @@ internal fun MobileCastPlayback( failed = false // One player at a time: session audio stops once the receiver takes over. if (target.mediaType == PlaybackMediaType.AUDIO) playerFactory.stopAudio(context) - val load: suspend (PlaybackSource) -> PlaybackRepositoryResult = { source -> - castMedia(source, castSubtitleKey(source, preferences.subtitleSelection, currentPolicy)) + val load: suspend (PlaybackSource?) -> PlaybackRepositoryResult = { source -> + castMedia(source, source?.let { castSubtitleKey(it, preferences.subtitleSelection, currentPolicy) }) } failed = !cast.handOver(state, load, preferences, playerFactory, currentOnSourceRequired) } @@ -201,7 +222,7 @@ internal fun MobileCastPlayback( @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) private suspend fun MobileCastController.handOver( state: PlaybackState, - castMedia: suspend (PlaybackSource) -> PlaybackRepositoryResult, + castMedia: suspend (PlaybackSource?) -> PlaybackRepositoryResult, preferences: RetainedPlayerPreferences, playerFactory: MobilePlayerFactory, onSourceRequired: (Long?) -> Unit, @@ -209,28 +230,35 @@ private suspend fun MobileCastController.handOver( val target = state.target val fileId = target.fileId.value val ready = state.content as? PlaybackContent.Ready - val source = ready?.source - val local = CastHandover( - positionMillis = preferences.positionMillis ?: state.resumePositionMillis - ?: source?.startFromSeconds?.toPlaybackMillis() ?: 0L, - playing = preferences.resumeAfterLifecyclePause, - speed = preferences.playbackSpeed, + // Unknown stays unknown, so resolving a source still finds put.io's saved position. + val retained = preferences.positionMillis ?: state.resumePositionMillis + // Session audio reopens without a source; its lease rests on the account's resume setting alone. + val item = playerFactory.reportableItem( + ready?.source?.toMediaItem(target.name, target.mediaType) ?: bareItem(fileId, target.name), + ready?.useStartFrom ?: true, ) - val item = ready?.let { - playerFactory.reportableItem(it.source.toMediaItem(target.name, target.mediaType), it.useStartFrom) - } ?: bareItem(fileId, target.name) - val media = source?.let { castMedia(it) } + val holds = holds(fileId) + if (!holds && ready == null) { + onSourceRequired(retained) + return true + } + // Attaching resolves the media too, for Play to send again once the file has finished there; a + // failed lookup leaves the receiver's controls up. + val media = castMedia(ready?.source) + val loaded = media as? PlaybackRepositoryResult.Success when { - // The receiver plays it already: what Play sends again after it finishes, if it resolved. Session - // audio opens without a source; resolving one brings the reporting lease and that media. - holds(fileId) -> { - attach(item, (media as? PlaybackRepositoryResult.Success)?.value) - if (source == null) onSourceRequired(local.positionMillis) - } - media == null -> onSourceRequired(local.positionMillis) - media is PlaybackRepositoryResult.Success -> cast(media.value, item, local) + holds -> attach(item, loaded?.value) + loaded != null -> cast( + loaded.value, + item, + CastHandover( + positionMillis = retained ?: ready?.source?.startFromSeconds?.toPlaybackMillis() ?: 0L, + playing = preferences.resumeAfterLifecyclePause, + speed = preferences.playbackSpeed, + ), + ) } - return media !is PlaybackRepositoryResult.Failure || holds(fileId) + return holds || loaded != null } /** [intervals]' step from where the receiver is, within the file. */ From 80c746045973f9b4049b41ce89d8953f27ea4ca4 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 16:10:02 +0300 Subject: [PATCH 05/16] fix(mobile): keep Cast load, readiness and sign-out state exact Track the pending load apart from the reported file, mark the receiver ready only on an answered status, attach controls before resolving media, send finished files again, keep a finished file's title and kind, and hand nothing back from a session that sign-out ended. --- .../putdotio/android/playback/MobileCast.kt | 23 +++++++++++---- .../android/playback/MobileCastGms.kt | 28 +++++++++++++------ .../android/playback/MobileCastPlayback.kt | 8 +++--- .../android/MobileCastControllerTest.kt | 22 +++++++++++++++ 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index e5203a2e2..213a1ef15 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -151,6 +151,9 @@ internal class MobileCastController( /** The ended session whose position has been handed back, so it is handed back once. */ private var handedBack: CastRemoteStatus? = null + /** Set when signing out ends the session, whose position then goes nowhere. */ + private var discardNextEnd = false + init { scope.launch { gateway.status.collect { status -> @@ -162,13 +165,11 @@ internal class MobileCastController( } /** - * Whether the receiver holds [fileId] now, so a screen opened on it attaches instead of loading. - * Media that failed there is sent again. + * Whether the receiver plays [fileId] now, so a screen opened on it attaches instead of loading. + * Media that finished or failed there is sent again. */ fun holds(fileId: Long): Boolean = - gateway.status.value?.let { - it.fileId == fileId && it.state != CastRemoteState.Idle && it.state != CastRemoteState.Failed - } == true + gateway.status.value?.let { it.fileId == fileId && it.state in LIVE_STATES } == true /** * Sends [media] to the receiver from where local playback stood, unless the receiver already @@ -214,6 +215,10 @@ internal class MobileCastController( fun end() { lastMedia = null handovers.clear() + // The session this ends hands nothing back: the next account must not resume the last one's. + discardNextEnd = gateway.availability.value.let { + it is CastAvailability.Connected || it is CastAvailability.Connecting + } gateway.stopCasting() } @@ -230,7 +235,10 @@ internal class MobileCastController( private fun takeEnded(): Pair? { val ended = gateway.endedStatus?.takeIf { it !== handedBack } ?: return null handedBack = ended - return ended.fileId?.let { it to CastHandover(ended.positionAt(clock()), ended.playing, ended.speed) } + val discarded = discardNextEnd + discardNextEnd = false + return ended.fileId?.takeUnless { discarded } + ?.let { it to CastHandover(ended.positionAt(clock()), ended.playing, ended.speed) } } // A file that finished or failed has left the receiver; playing it again sends it once more. @@ -241,6 +249,9 @@ internal class MobileCastController( } } +/** Receiver states with the file still playable there. */ +private val LIVE_STATES = setOf(CastRemoteState.Buffering, CastRemoteState.Playing, CastRemoteState.Paused) + /** * The subtitle the receiver shows first, by the same rules as the local player: none when the * account hides subtitles or the viewer turned them off, the viewer's pick, else put.io's default diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index 1278a25eb..20c4b42d3 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -317,6 +317,9 @@ internal class GmsCastRemote : CastRemote { private var client: RemoteMediaClient? = null private var lastFileId: Long? = null + /** The file a load was sent for until the receiver reports on it, so a refusal is told for it. */ + private var pendingLoad: Long? = null + /** Whether play was last asked for, which a loading receiver's status does not say. */ private var playIntent: Boolean? = null @@ -355,10 +358,13 @@ internal class GmsCastRemote : CastRemote { client = remote?.also { it.registerCallback(callback) it.addProgressListener(progress, PROGRESS_INTERVAL_MILLIS) - // Answered or not, the first status request settles whether the receiver plays anything. - it.requestStatus().setResultCallback { - publish() - markStatusKnown() + // An answered first status request settles whether the receiver plays anything; without an + // answer the next status update does. + it.requestStatus().setResultCallback { result -> + if (client === it && result.status.isSuccess) { + publish() + markStatusKnown() + } } } publish() @@ -369,6 +375,7 @@ internal class GmsCastRemote : CastRemote { publish() release() lastFileId = null + pendingLoad = null playIntent = null endedStatus = mutableStatus.value mutableStatus.value = null @@ -386,11 +393,14 @@ internal class GmsCastRemote : CastRemote { fun publish() { val remote = client ?: return val media = remote.mediaStatus ?: return - // A finished file's status drops its media; it still belongs to the file it finished. + // A finished file's status drops its media; it still belongs to the file it finished, with the + // title and kind it was sent with. val info = remote.mediaInfo val fileId = info?.customData?.optLong(FILE_ID_KEY)?.takeIf { it > 0L } ?: lastFileId.takeIf { media.playerState == MediaStatus.PLAYER_STATE_IDLE } + val previous = mutableStatus.value?.takeIf { info == null && it.fileId == fileId } lastFileId = fileId + if (fileId != null && fileId == pendingLoad) pendingLoad = null val state = media.castRemoteState() if (state == CastRemoteState.Playing || state == CastRemoteState.Paused) { playIntent = state == CastRemoteState.Playing @@ -405,14 +415,15 @@ internal class GmsCastRemote : CastRemote { seekable = media.isMediaCommandSupported(MediaStatus.COMMAND_SEEK), sampledAtMillis = SystemClock.elapsedRealtime(), playIntent = playIntent, - title = info?.metadata?.getString(MediaMetadata.KEY_TITLE), - audio = info?.metadata?.mediaType == MediaMetadata.MEDIA_TYPE_MUSIC_TRACK, + title = info?.metadata?.getString(MediaMetadata.KEY_TITLE) ?: previous?.title, + audio = previous?.audio ?: (info?.metadata?.mediaType == MediaMetadata.MEDIA_TYPE_MUSIC_TRACK), ) } override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { val remote = client ?: return lastFileId = media.fileId + pendingLoad = media.fileId playIntent = autoplay remote.load( MediaLoadRequestData.Builder() @@ -424,7 +435,8 @@ internal class GmsCastRemote : CastRemote { .build(), ).setResultCallback { result -> // A refused or timed-out load says so for its file, unless another load followed it. - if (!result.status.isSuccess && client === remote && lastFileId == media.fileId) { + if (!result.status.isSuccess && client === remote && pendingLoad == media.fileId) { + pendingLoad = null mutableStatus.value = CastRemoteStatus( fileId = media.fileId, state = CastRemoteState.Failed, diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index 5a5890a02..12c556c52 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -242,10 +242,10 @@ private suspend fun MobileCastController.handOver( onSourceRequired(retained) return true } - // Attaching resolves the media too, for Play to send again once the file has finished there; a - // failed lookup leaves the receiver's controls up. - val media = castMedia(ready?.source) - val loaded = media as? PlaybackRepositoryResult.Success + // The controls attach at once; the media resolves after, for Play to send again once the file has + // finished there, and a failed lookup leaves the controls up. + if (holds) attach(item) + val loaded = castMedia(ready?.source) as? PlaybackRepositoryResult.Success when { holds -> attach(item, loaded?.value) loaded != null -> cast( diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt index 93b376160..c7fdf6d39 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -143,6 +143,28 @@ class MobileCastControllerTest { assertEquals(CastHandover(positionMillis = 5_000L, playing = false), cast.takeHandover(42L)) } + @Test + fun signingOutHandsNothingBackToTheNextAccount() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Playing, positionMillis = 60_000L) + + cast.end() + idle() + + assertNull(cast.takeHandover(42L)) + } + + @Test + fun aFileThatFinishedOnTheReceiverIsSentAgainWhenReopened() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Ended, positionMillis = 600_000L) + + assertTrue(cast.cast(Media, Item, CastHandover(positionMillis = 120_000L, playing = true))) + assertEquals(Load(42L, 120_000L, autoplay = true, speed = 1f), gateway.loads.last()) + } + @Test fun aFileThatFailedOnTheReceiverIsSentAgain() { gateway.connect() From b0fa0958cb8f545bd9df8835856eebf52b544292 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 16:27:19 +0300 Subject: [PATCH 06/16] fix(mobile): attach before resume prompts and freeze the Cast handback Attach to a receiver that already plays the file instead of asking to resume, stop the handback position where the session ended, end a resumed session on sign-out, drop an unclaimed handback on sign-out, and give the picker focus before any receiver is listed. --- .../android/auth/MobileOAuthRuntime.kt | 2 +- .../putdotio/android/playback/MobileCast.kt | 9 +++- .../android/playback/MobileCastGms.kt | 16 ++++--- .../android/playback/MobileCastPlayback.kt | 5 +- .../putdotio/android/playback/MobileCastUi.kt | 3 ++ .../android/playback/MobilePlayerScreen.kt | 46 +++++++++++-------- .../android/MobileCastControllerTest.kt | 27 ++++++++++- 7 files changed, 77 insertions(+), 31 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt index 23e7dda65..55755b2d8 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt @@ -234,7 +234,7 @@ class MobileOAuthRuntime internal constructor( ) MobileWidgets.sessionLeft(context) // The receiver plays with the account's download token. - MobileCast.sessionLeft() + MobileCast.sessionLeft(context) } /** What sign-out removes from this device for every account: move targets and Continue watching. */ diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index 213a1ef15..bbc91b19b 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -56,12 +56,15 @@ internal data class CastRemoteStatus( /** The title and kind the media was sent with, so the app can reopen it without a screen's help. */ val title: String? = null, val audio: Boolean = false, + /** When the session ended, for a last status: the position moves on no further than this. */ + val endedAtMillis: Long? = null, ) { val playing: Boolean get() = state == CastRemoteState.Playing || (state == CastRemoteState.Buffering && playIntent != false) fun positionAt(nowMillis: Long): Long { - val moved = if (state == CastRemoteState.Playing) ((nowMillis - sampledAtMillis) * speed).toLong() else 0L + val until = endedAtMillis?.coerceAtMost(nowMillis) ?: nowMillis + val moved = if (state == CastRemoteState.Playing) ((until - sampledAtMillis) * speed).toLong() else 0L val position = (positionMillis + moved.coerceAtLeast(0L)).coerceAtLeast(0L) return durationMillis?.let(position::coerceAtMost) ?: position } @@ -215,7 +218,9 @@ internal class MobileCastController( fun end() { lastMedia = null handovers.clear() - // The session this ends hands nothing back: the next account must not resume the last one's. + // The session this ends hands nothing back: the next account must not resume the last one's, + // nor one that ended a moment ago and is not handed back yet. + handedBack = gateway.endedStatus discardNextEnd = gateway.availability.value.let { it is CastAvailability.Connected || it is CastAvailability.Connecting } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index 20c4b42d3..314b9ae50 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -67,11 +67,10 @@ internal object MobileCast { /** * The signed-in session left: the receiver plays with the account's download token, so the Cast - * session ends with it. A process that never cast has nothing to end. + * session ends with it, a session the Cast SDK resumes for a fresh process included. */ - fun sessionLeft() { - val cast = controller ?: return - Handler(Looper.getMainLooper()).post { cast.end() } + fun sessionLeft(context: Context) { + Handler(Looper.getMainLooper()).post { controller(context)?.end() } } /** @@ -188,6 +187,7 @@ internal class GmsCastGateway private constructor( private fun bind(cast: CastContext) { castContext = cast + if (stopOnBind) cast.sessionManager.endCurrentSession(true) routes.selector = cast.mergedSelector cast.addCastStateListener { updateAvailability() } cast.sessionManager.addSessionManagerListener(sessions, CastSession::class.java) @@ -242,9 +242,13 @@ internal class GmsCastGateway private constructor( updateAvailability() } + /** Ends the session; before Cast has started, it ends the session Cast resumes once it does. */ override fun stopCasting() { - castContext?.sessionManager?.endCurrentSession(true) + val cast = castContext + if (cast == null) stopOnBind = true else cast.sessionManager.endCurrentSession(true) } + + private var stopOnBind = false } /** The receivers MediaRouter finds for the Cast framework's selector, while the picker asks. */ @@ -377,7 +381,7 @@ internal class GmsCastRemote : CastRemote { lastFileId = null pendingLoad = null playIntent = null - endedStatus = mutableStatus.value + endedStatus = mutableStatus.value?.copy(endedAtMillis = SystemClock.elapsedRealtime()) mutableStatus.value = null } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index 12c556c52..e0ab6f303 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -54,7 +54,10 @@ internal class PlayerCast( val controller: MobileCastController?, val button: @Composable (Modifier) -> Unit, val casting: CastingTarget?, -) +) { + /** Whether a session's receiver plays [fileId], so the player attaches instead of asking to resume. */ + fun attaches(fileId: Long): Boolean = casting?.controller?.holds(fileId) == true +} internal class CastingTarget( val controller: MobileCastController, diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt index dc2d2cdc2..5c3ed0fcb 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt @@ -21,6 +21,7 @@ import io.putdotio.android.design.PutioRadius import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -129,6 +130,8 @@ internal fun MobileCastPicker(gateway: CastGateway, onDismiss: () -> Unit, modif .focusRequester(focus) // Focus stays in the picker until it closes. .focusProperties { onExit = { cancelFocusChange() } } + // Focusable itself, so it takes focus before any receiver row exists. + .focusable() .focusGroup() .testTag(MOBILE_CAST_PICKER_TAG), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index bf1bbe2ef..7c7f682d7 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -145,6 +145,28 @@ internal fun MobilePlayerScreen( var keyboardNavigationActive by keyboardNavigation val castPicker = rememberSaveable { mutableStateOf(false) } val playerCast = rememberPlayerCast(cast, castMedia) { castPicker.value = true } + val readyContent: @Composable () -> Unit = { + MobileReadyContent( + state = state, + playerCast = playerCast, + preferences = preferences, + playerFactory = playerFactory, + subtitleStartupPolicy = subtitleStartupPolicy, + keyboardNavigation = keyboardNavigation, + onPlayerFailure = onPlayerFailure, + autoplayNextVideo = autoplayNextVideo, + onPlaybackEnded = onPlaybackEnded, + seekClock = seekClock, + onSourceRequired = onSourceRequired, + onBack = onBack, + onVideoAspectRatio = { landscapeVideo = it > 1f }, + pictureInPicture = pictureInPicture, + nextEpisode = nextEpisode.takeIf { state.target.mediaType == PlaybackMediaType.VIDEO }, + onNextEpisode = onNextEpisode, + neighbours = neighbours, + onPlay = onPlay, + ) + } Box( modifier = modifier .fillMaxSize() @@ -161,6 +183,9 @@ internal fun MobilePlayerScreen( is PlaybackContent.Loading -> MobileLoadingState(stringResource(state.target.mediaType.loadingMessage())) + // A receiver already playing the file needs no local resume decision. + is PlaybackContent.AwaitingResume if playerCast.attaches(state.target.fileId.value) -> readyContent() + is PlaybackContent.AwaitingResume -> MobileResumePlaybackDialog( startFromSeconds = content.source.startFromSeconds, onResume = onResume, @@ -168,26 +193,7 @@ internal fun MobilePlayerScreen( onDismiss = onBack, ) - is PlaybackContent.Ready, PlaybackContent.Session -> MobileReadyContent( - state = state, - playerCast = playerCast, - preferences = preferences, - playerFactory = playerFactory, - subtitleStartupPolicy = subtitleStartupPolicy, - keyboardNavigation = keyboardNavigation, - onPlayerFailure = onPlayerFailure, - autoplayNextVideo = autoplayNextVideo, - onPlaybackEnded = onPlaybackEnded, - seekClock = seekClock, - onSourceRequired = onSourceRequired, - onBack = onBack, - onVideoAspectRatio = { landscapeVideo = it > 1f }, - pictureInPicture = pictureInPicture, - nextEpisode = nextEpisode.takeIf { isVideo }, - onNextEpisode = onNextEpisode, - neighbours = neighbours, - onPlay = onPlay, - ) + is PlaybackContent.Ready, PlaybackContent.Session -> readyContent() is PlaybackContent.FindingNext -> MobileLoadingState(stringResource(R.string.mobile_playback_finding_next)) diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt index c7fdf6d39..a20147486 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -143,6 +143,31 @@ class MobileCastControllerTest { assertEquals(CastHandover(positionMillis = 5_000L, playing = false), cast.takeHandover(42L)) } + @Test + fun theHandbackStopsWhereTheSessionEndedHoweverLateItIsTaken() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Playing, positionMillis = 60_000L) + gateway.end() + // The screen asks half a minute later; the receiver stopped at the end. + now += 30_000L + + assertEquals(CastHandover(positionMillis = 60_000L, playing = true), cast.takeHandover(42L)) + } + + @Test + fun signingOutRightAfterASessionEndedHandsNothingBack() { + gateway.connect() + cast.cast(Media, Item, CastHandover(positionMillis = 0L, playing = true)) + gateway.report(CastRemoteState.Playing, positionMillis = 60_000L) + gateway.end() + + cast.end() + idle() + + assertNull(cast.takeHandover(42L)) + } + @Test fun signingOutHandsNothingBackToTheNextAccount() { gateway.connect() @@ -291,7 +316,7 @@ class MobileCastControllerTest { /** As the SDK ends a session: the last status kept, then cleared, all before any collector runs. */ fun end() { - endedStatus = status.value + endedStatus = status.value?.copy(endedAtMillis = now) availability.value = CastAvailability.Available status.value = null } From 8915231d5353ab3e44de2018810df620885702d9 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 16:42:50 +0300 Subject: [PATCH 07/16] fix(mobile): keep the receiver in reach while its file reconnects or fails here Answer the resume prompt for a file the receiver plays, keep the receiver's controls through a session reconnect and a local failure, hold a refused load over older receiver reports, stop session audio for casting video too, and keep an empty session's sign-out from dropping the next handback. --- .../putdotio/android/playback/MobileCast.kt | 4 +-- .../android/playback/MobileCastGms.kt | 26 ++++++++++++++----- .../android/playback/MobileCastPlayback.kt | 8 ++++-- .../android/playback/MobilePlayerScreen.kt | 18 ++++++++++--- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index bbc91b19b..39c932cf9 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -221,9 +221,7 @@ internal class MobileCastController( // The session this ends hands nothing back: the next account must not resume the last one's, // nor one that ended a moment ago and is not handed back yet. handedBack = gateway.endedStatus - discardNextEnd = gateway.availability.value.let { - it is CastAvailability.Connected || it is CastAvailability.Connecting - } + discardNextEnd = gateway.status.value != null gateway.stopCasting() } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index 314b9ae50..d56bdfdac 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -324,6 +324,9 @@ internal class GmsCastRemote : CastRemote { /** The file a load was sent for until the receiver reports on it, so a refusal is told for it. */ private var pendingLoad: Long? = null + /** A refused load, which stands over the receiver's reports on older media until the next load. */ + private var failedLoad: CastRemoteStatus? = null + /** Whether play was last asked for, which a loading receiver's status does not say. */ private var playIntent: Boolean? = null @@ -380,6 +383,7 @@ internal class GmsCastRemote : CastRemote { release() lastFileId = null pendingLoad = null + failedLoad = null playIntent = null endedStatus = mutableStatus.value?.copy(endedAtMillis = SystemClock.elapsedRealtime()) mutableStatus.value = null @@ -403,13 +407,9 @@ internal class GmsCastRemote : CastRemote { val fileId = info?.customData?.optLong(FILE_ID_KEY)?.takeIf { it > 0L } ?: lastFileId.takeIf { media.playerState == MediaStatus.PLAYER_STATE_IDLE } val previous = mutableStatus.value?.takeIf { info == null && it.fileId == fileId } - lastFileId = fileId - if (fileId != null && fileId == pendingLoad) pendingLoad = null val state = media.castRemoteState() - if (state == CastRemoteState.Playing || state == CastRemoteState.Paused) { - playIntent = state == CastRemoteState.Playing - } - mutableStatus.value = CastRemoteStatus( + noteReport(fileId, state) + mutableStatus.value = failedLoad ?: CastRemoteStatus( fileId = fileId, state = state, positionMillis = remote.approximateStreamPosition.coerceAtLeast(0L), @@ -424,10 +424,21 @@ internal class GmsCastRemote : CastRemote { ) } + /** The receiver reported [state] for [fileId]: a pending or refused load for it is settled. */ + private fun noteReport(fileId: Long?, state: CastRemoteState) { + lastFileId = fileId + if (fileId != null && fileId == pendingLoad) pendingLoad = null + if (fileId != null && fileId == failedLoad?.fileId) failedLoad = null + if (state == CastRemoteState.Playing || state == CastRemoteState.Paused) { + playIntent = state == CastRemoteState.Playing + } + } + override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { val remote = client ?: return lastFileId = media.fileId pendingLoad = media.fileId + failedLoad = null playIntent = autoplay remote.load( MediaLoadRequestData.Builder() @@ -441,7 +452,7 @@ internal class GmsCastRemote : CastRemote { // A refused or timed-out load says so for its file, unless another load followed it. if (!result.status.isSuccess && client === remote && pendingLoad == media.fileId) { pendingLoad = null - mutableStatus.value = CastRemoteStatus( + failedLoad = CastRemoteStatus( fileId = media.fileId, state = CastRemoteState.Failed, positionMillis = startMillis, @@ -450,6 +461,7 @@ internal class GmsCastRemote : CastRemote { title = media.title, audio = media.mediaType == PlaybackMediaType.AUDIO, ) + mutableStatus.value = failedLoad } } } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index e0ab6f303..5d9263dc0 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -73,8 +73,11 @@ internal fun rememberPlayerCast( onOpenPicker: () -> Unit, ): PlayerCast { val availability by (cast?.availability ?: NoCast).collectAsState() + val status by (cast?.status ?: NoStatus).collectAsState() if (cast == null || castMedia == null) return NoPlayerCast + // A session reconnecting still owns playback while the receiver's last status stands. val device = availability.connectedDevice + ?: (availability as? CastAvailability.Connecting)?.takeIf { status != null }?.let { it.deviceName.orEmpty() } return PlayerCast( controller = cast, button = { modifier -> MobileCastButton(availability, onOpenPicker, modifier) }, @@ -83,6 +86,7 @@ internal fun rememberPlayerCast( } private val NoCast: StateFlow = MutableStateFlow(CastAvailability.Unsupported) +private val NoStatus: StateFlow = MutableStateFlow(null) private val NoPlayerCast = PlayerCast(controller = null, button = {}, casting = null) /** @@ -150,8 +154,8 @@ internal fun MobileCastPlayback( val currentOnSourceRequired by rememberUpdatedState(onSourceRequired) LaunchedEffect(fileId, source) { failed = false - // One player at a time: session audio stops once the receiver takes over. - if (target.mediaType == PlaybackMediaType.AUDIO) playerFactory.stopAudio(context) + // One player at a time: session audio stops once the receiver takes over, for a video too. + playerFactory.stopAudio(context) val load: suspend (PlaybackSource?) -> PlaybackRepositoryResult = { source -> castMedia(source, source?.let { castSubtitleKey(it, preferences.subtitleSelection, currentPolicy) }) } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index 7c7f682d7..c4723c9d6 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -179,13 +179,10 @@ internal fun MobilePlayerScreen( // The open device picker is modal: TalkBack reaches only it. Box(Modifier.fillMaxSize().hiddenFromAccessibility(castPicker.value)) { val isVideo = state.target.mediaType == PlaybackMediaType.VIDEO - when (val content = state.content) { + when (val content = state.shownContent(playerCast, onResume)) { is PlaybackContent.Loading -> MobileLoadingState(stringResource(state.target.mediaType.loadingMessage())) - // A receiver already playing the file needs no local resume decision. - is PlaybackContent.AwaitingResume if playerCast.attaches(state.target.fileId.value) -> readyContent() - is PlaybackContent.AwaitingResume -> MobileResumePlaybackDialog( startFromSeconds = content.source.startFromSeconds, onResume = onResume, @@ -235,6 +232,19 @@ internal fun MobilePlayerScreen( } } +/** + * What the screen shows: a receiver already playing the file takes the place of a resume prompt, + * which is answered with the saved position so a session that ends hands back as from any ready + * file, and of a local failure, which leaves that receiver in reach. + */ +@Composable +private fun PlaybackState.shownContent(playerCast: PlayerCast, onResume: () -> Unit): PlaybackContent { + val prompt = content as? PlaybackContent.AwaitingResume + val attached = playerCast.attaches(target.fileId.value) && (prompt != null || content is PlaybackContent.Failed) + if (attached && prompt != null) LaunchedEffect(prompt) { onResume() } + return if (attached) PlaybackContent.Session else content +} + /** Leaves this content out of accessibility while something modal covers it. */ private fun Modifier.hiddenFromAccessibility(hidden: Boolean): Modifier = if (hidden) clearAndSetSemantics {} else this From 6d414f0e183aec444d54f5e9cd464012cd4f9f36 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 16:57:31 +0300 Subject: [PATCH 08/16] fix(mobile): show the receiver before local resolution and keep player keys Show a receiver that plays the file while the phone still resolves it, converts it or failed it, route Space and the arrows to the receiver, and let a sign-out end hand nothing back however the SDK orders its reports. --- .../putdotio/android/playback/MobileCast.kt | 19 +++---- .../android/playback/MobileCastGms.kt | 18 +++++- .../android/playback/MobileCastPlayback.kt | 55 +++++++++++++++++-- .../android/playback/MobilePlayerScreen.kt | 7 ++- .../android/MobileCastControllerTest.kt | 9 +++ 5 files changed, 90 insertions(+), 18 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index 39c932cf9..3df7ac2cb 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -95,6 +95,12 @@ internal interface CastGateway : CastRoutes, CastRemote { /** Ends the session and stops the receiver's playback. */ fun stopCasting() + + /** + * Ends the session for signing out: it hands nothing back, whenever it ends and whatever the + * receiver reported by then, so the next account never resumes the last one's. + */ + fun endForSignOut() } /** The receivers the picker lists. */ @@ -154,8 +160,6 @@ internal class MobileCastController( /** The ended session whose position has been handed back, so it is handed back once. */ private var handedBack: CastRemoteStatus? = null - /** Set when signing out ends the session, whose position then goes nowhere. */ - private var discardNextEnd = false init { scope.launch { @@ -218,11 +222,9 @@ internal class MobileCastController( fun end() { lastMedia = null handovers.clear() - // The session this ends hands nothing back: the next account must not resume the last one's, - // nor one that ended a moment ago and is not handed back yet. + // Nor does one that ended a moment ago and is not handed back yet. handedBack = gateway.endedStatus - discardNextEnd = gateway.status.value != null - gateway.stopCasting() + gateway.endForSignOut() } /** Where the local player picks [fileId] up after a session ended; taken once, null while casting. */ @@ -238,10 +240,7 @@ internal class MobileCastController( private fun takeEnded(): Pair? { val ended = gateway.endedStatus?.takeIf { it !== handedBack } ?: return null handedBack = ended - val discarded = discardNextEnd - discardNextEnd = false - return ended.fileId?.takeUnless { discarded } - ?.let { it to CastHandover(ended.positionAt(clock()), ended.playing, ended.speed) } + return ended.fileId?.let { it to CastHandover(ended.positionAt(clock()), ended.playing, ended.speed) } } // A file that finished or failed has left the receiver; playing it again sends it once more. diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index d56bdfdac..86fa31d5a 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -214,7 +214,11 @@ internal class GmsCastGateway private constructor( private val sessions = object : SessionManagerListener { override fun onSessionStarting(session: CastSession) = updateAvailability() - override fun onSessionStarted(session: CastSession, sessionId: String) = attach(session) + override fun onSessionStarted(session: CastSession, sessionId: String) { + // A new session is not the one a sign-out ended. + remote.discardHandback = false + attach(session) + } override fun onSessionStartFailed(session: CastSession, error: Int) = updateAvailability() @@ -243,6 +247,11 @@ internal class GmsCastGateway private constructor( } /** Ends the session; before Cast has started, it ends the session Cast resumes once it does. */ + override fun endForSignOut() { + remote.discardHandback = true + stopCasting() + } + override fun stopCasting() { val cast = castContext if (cast == null) stopOnBind = true else cast.sessionManager.endCurrentSession(true) @@ -337,6 +346,9 @@ internal class GmsCastRemote : CastRemote { */ private var statusKnown = false + /** Set when signing out ends the session: its end hands nothing back. */ + var discardHandback = false + /** Runs when [attached] changes, so availability follows it. */ var onAttachedChanged: () -> Unit = {} @@ -385,7 +397,9 @@ internal class GmsCastRemote : CastRemote { pendingLoad = null failedLoad = null playIntent = null - endedStatus = mutableStatus.value?.copy(endedAtMillis = SystemClock.elapsedRealtime()) + endedStatus = mutableStatus.value?.takeUnless { discardHandback } + ?.copy(endedAtMillis = SystemClock.elapsedRealtime()) + discardHandback = false mutableStatus.value = null } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index 5d9263dc0..a1b7b2ce7 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -16,6 +16,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.zIndex +import androidx.compose.foundation.focusGroup +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.input.key.onKeyEvent +import androidx.media3.common.util.Util +import io.putdotio.android.MobileKeyCommand import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi @@ -177,6 +184,14 @@ internal fun MobileCastPlayback( val intervals = if (target.mediaType == PlaybackMediaType.AUDIO) AudioSeekIntervals else VideoSeekIntervals val seek = { direction: SeekDirection -> player.seekBy(intervals, direction) } + // Space and the arrows drive the receiver as they drive the local player. + val keyFocus = remember { FocusRequester() } + LaunchedEffect(keyFocus) { keyFocus.requestFocus() } + val keys = Modifier + .onKeyEvent { event -> playerKeyCommand(event.nativeKeyEvent) { player.runCastKey(it, seek, onBack) } } + .focusRequester(keyFocus) + .focusTarget() + .focusGroup() if (target.mediaType == PlaybackMediaType.AUDIO) { MobileNowPlayingScreen( player = player, @@ -189,17 +204,32 @@ internal fun MobileCastPlayback( onAudioSelectionChanged = {}, castButton = castButton, castLabel = label, - modifier = Modifier.zIndex(2f), + modifier = Modifier.zIndex(2f).then(keys), ) - return + } else { + MobileCastVideo(player, target.name, label, seekEnabled, seek, onBack, castButton, keys) } +} + +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +@Composable +private fun MobileCastVideo( + player: CastRemotePlayer, + title: String, + label: String, + seekEnabled: Boolean, + seek: (SeekDirection) -> Unit, + onBack: () -> Unit, + castButton: @Composable (Modifier) -> Unit, + modifier: Modifier, +) { val options = rememberPlayerOptionsSnapshot(player) - var speedSheet by rememberSaveable(fileId) { mutableStateOf(false) } - Box(Modifier.fillMaxSize()) { + var speedSheet by rememberSaveable { mutableStateOf(false) } + Box(modifier.fillMaxSize()) { MobileCastFrameLabel(label) MobilePlayerChrome( player = player, - title = target.name, + title = title, visible = !speedSheet, seekEnabled = seekEnabled, onSeek = seek, @@ -222,6 +252,21 @@ internal fun MobileCastPlayback( } } +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +private fun androidx.media3.common.Player.runCastKey( + command: MobileKeyCommand, + seek: (SeekDirection) -> Unit, + onBack: () -> Unit, +) { + when (command) { + MobileKeyCommand.PlayPause -> Util.handlePlayPauseButtonAction(this) + MobileKeyCommand.SeekBack -> seek(SeekDirection.Backward) + MobileKeyCommand.SeekForward -> seek(SeekDirection.Forward) + MobileKeyCommand.Back -> onBack() + else -> Unit + } +} + /** * Sends the file to the receiver from where local playback stood, attaches to it when the receiver * already plays it, or asks for a source first. False when the file cannot go to a receiver. diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index c4723c9d6..464015386 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -240,11 +240,16 @@ internal fun MobilePlayerScreen( @Composable private fun PlaybackState.shownContent(playerCast: PlayerCast, onResume: () -> Unit): PlaybackContent { val prompt = content as? PlaybackContent.AwaitingResume - val attached = playerCast.attaches(target.fileId.value) && (prompt != null || content is PlaybackContent.Failed) + val attached = playerCast.attaches(target.fileId.value) && content.attachable if (attached && prompt != null) LaunchedEffect(prompt) { onResume() } return if (attached) PlaybackContent.Session else content } +/** States a receiver that already plays the file stands in for: none needs the viewer first. */ +private val PlaybackContent.attachable: Boolean + get() = this is PlaybackContent.Loading || this is PlaybackContent.AwaitingResume || + this is PlaybackContent.Conversion || this is PlaybackContent.Failed + /** Leaves this content out of accessibility while something modal covers it. */ private fun Modifier.hiddenFromAccessibility(hidden: Boolean): Modifier = if (hidden) clearAndSetSemantics {} else this diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt index a20147486..97c67e0fd 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -357,6 +357,15 @@ class MobileCastControllerTest { override fun stopCasting() = disconnect() + override fun endForSignOut() { + // As the SDK may: the receiver's status lands before the session's end does. + report(CastRemoteState.Playing, positionMillis = 90_000L) + availability.value = CastAvailability.Available + endedStatus = null + status.value = null + idle() + } + override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { loads += Load(media.fileId, startMillis, autoplay, speed) } From 150b1dd5107f89996ddb3308af1b57398930d4e1 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 17:30:00 +0300 Subject: [PATCH 09/16] fix(mobile): retry a handover after reconnecting and honour Start over Hand over once a reconnecting session answers, drop the receiver's position when the viewer chose Start over, tie the remote controls to the file on screen before its lookup, and let keyboard Back close the casting Speed sheet first. --- .../android/playback/MobileCastPlayback.kt | 30 +++++++++++++++---- .../android/playback/MobilePlayerScreen.kt | 7 ++++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index a1b7b2ce7..e14b65921 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.input.key.onKeyEvent import androidx.media3.common.util.Util import io.putdotio.android.MobileKeyCommand +import io.putdotio.android.MobileShortcutScope +import io.putdotio.android.mobileKeyCommand +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi @@ -106,8 +109,12 @@ internal fun rememberCastHandover( playerCast: PlayerCast, fileId: Long, preferences: RetainedPlayerPreferences, + restarted: Boolean, ): CastHandover? { - var handover by remember(fileId) { mutableStateOf(playerCast.controller?.takeHandover(fileId)) } + // Start over is the viewer's own word: the receiver's position is dropped for it. + var handover by remember(fileId) { + mutableStateOf(playerCast.controller?.takeHandover(fileId)?.takeUnless { restarted }) + } LaunchedEffect(handover) { handover?.let { preferences.retainPlayback(RetainedPlayback(it.positionMillis, it.playing)) @@ -159,7 +166,10 @@ internal fun MobileCastPlayback( var failed by remember(fileId) { mutableStateOf(false) } val currentPolicy by rememberUpdatedState(subtitleStartupPolicy) val currentOnSourceRequired by rememberUpdatedState(onSourceRequired) - LaunchedEffect(fileId, source) { + // A session reconnecting holds the handover until it answers again. + val connected = cast.availability.collectAsState().value is CastAvailability.Connected + LaunchedEffect(fileId, source, connected) { + if (!connected) return@LaunchedEffect failed = false // One player at a time: session audio stops once the receiver takes over, for a video too. playerFactory.stopAudio(context) @@ -225,7 +235,15 @@ private fun MobileCastVideo( ) { val options = rememberPlayerOptionsSnapshot(player) var speedSheet by rememberSaveable { mutableStateOf(false) } - Box(modifier.fillMaxSize()) { + // Back from the keyboard closes the Speed sheet before it leaves the player, as on local video. + val closeSheet = Modifier.onPreviewKeyEvent { event -> + val key = event.nativeKeyEvent + val back = mobileKeyCommand(MobileShortcutScope.Player, key.keyCode, key.metaState) == MobileKeyCommand.Back + (speedSheet && back).also { closes -> + if (closes && key.action == android.view.KeyEvent.ACTION_DOWN) speedSheet = false + } + } + Box(closeSheet.then(modifier).fillMaxSize()) { MobileCastFrameLabel(label) MobilePlayerChrome( player = player, @@ -294,9 +312,9 @@ private suspend fun MobileCastController.handOver( onSourceRequired(retained) return true } - // The controls attach at once; the media resolves after, for Play to send again once the file has - // finished there, and a failed lookup leaves the controls up. - if (holds) attach(item) + // The controls follow this file at once, so a failed lookup never leaves them driving another file; + // the media resolves after, for Play to send again once the file has finished there. + attach(item) val loaded = castMedia(ready?.source) as? PlaybackRepositoryResult.Success when { holds -> attach(item, loaded?.value) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index 464015386..f5baea67f 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -298,7 +298,12 @@ private fun MobileReadyContent( ) return } - val handover = rememberCastHandover(playerCast, state.target.fileId.value, preferences) + val handover = rememberCastHandover( + playerCast, + state.target.fileId.value, + preferences, + restarted = state.resumePositionMillis == 0L, + ) val ready = state.content as? PlaybackContent.Ready MobileSessionPlayerHost( mediaType = state.target.mediaType, From ccfab58770a873d916af0b3fc3b9fc0c8923ac0c Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 17:37:11 +0300 Subject: [PATCH 10/16] fix(mobile): let a pending Cast handback answer the resume prompt A video reopened after its session ended resumes where the receiver stopped instead of asking, so an earlier Start over no longer drops the handback. --- .../io/putdotio/android/playback/MobileCast.kt | 7 +++++++ .../android/playback/MobileCastPlayback.kt | 9 ++++----- .../android/playback/MobilePlayerScreen.kt | 15 +++++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index 3df7ac2cb..8a3a5f4c3 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -227,6 +227,13 @@ internal class MobileCastController( gateway.endForSignOut() } + /** Whether [takeHandover] has a place for [fileId]; it takes nothing. */ + fun hasHandover(fileId: Long): Boolean { + val unclaimed = gateway.endedStatus?.takeIf { it !== handedBack && gateway.status.value == null } + return gateway.availability.value !is CastAvailability.Connected && + (fileId in handovers || unclaimed?.fileId == fileId) + } + /** Where the local player picks [fileId] up after a session ended; taken once, null while casting. */ fun takeHandover(fileId: Long): CastHandover? { if (gateway.availability.value is CastAvailability.Connected) return null diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index e14b65921..7d6acb617 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -67,6 +67,9 @@ internal class PlayerCast( ) { /** Whether a session's receiver plays [fileId], so the player attaches instead of asking to resume. */ fun attaches(fileId: Long): Boolean = casting?.controller?.holds(fileId) == true + + /** Whether a session that ended left [fileId] a place to resume, which answers the resume prompt. */ + fun handsBack(fileId: Long): Boolean = controller?.hasHandover(fileId) == true } internal class CastingTarget( @@ -109,12 +112,8 @@ internal fun rememberCastHandover( playerCast: PlayerCast, fileId: Long, preferences: RetainedPlayerPreferences, - restarted: Boolean, ): CastHandover? { - // Start over is the viewer's own word: the receiver's position is dropped for it. - var handover by remember(fileId) { - mutableStateOf(playerCast.controller?.takeHandover(fileId)?.takeUnless { restarted }) - } + var handover by remember(fileId) { mutableStateOf(playerCast.controller?.takeHandover(fileId)) } LaunchedEffect(handover) { handover?.let { preferences.retainPlayback(RetainedPlayback(it.positionMillis, it.playing)) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index f5baea67f..ba2d0ae8e 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -235,13 +235,17 @@ internal fun MobilePlayerScreen( /** * What the screen shows: a receiver already playing the file takes the place of a resume prompt, * which is answered with the saved position so a session that ends hands back as from any ready - * file, and of a local failure, which leaves that receiver in reach. + * file, and of a local failure, which leaves that receiver in reach. A session that already ended + * answers the prompt too: the file resumes where the receiver stopped. */ @Composable private fun PlaybackState.shownContent(playerCast: PlayerCast, onResume: () -> Unit): PlaybackContent { val prompt = content as? PlaybackContent.AwaitingResume val attached = playerCast.attaches(target.fileId.value) && content.attachable - if (attached && prompt != null) LaunchedEffect(prompt) { onResume() } + // Where a receiver stopped is the answer the prompt would ask for, so it is not asked. + if (prompt != null && (attached || playerCast.handsBack(target.fileId.value))) { + LaunchedEffect(prompt) { onResume() } + } return if (attached) PlaybackContent.Session else content } @@ -298,12 +302,7 @@ private fun MobileReadyContent( ) return } - val handover = rememberCastHandover( - playerCast, - state.target.fileId.value, - preferences, - restarted = state.resumePositionMillis == 0L, - ) + val handover = rememberCastHandover(playerCast, state.target.fileId.value, preferences) val ready = state.content as? PlaybackContent.Ready MobileSessionPlayerHost( mediaType = state.target.mediaType, From 284c09f23c62fc6308034bdc8033675d88aa0b2b Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 18:08:42 +0300 Subject: [PATCH 11/16] fix(mobile): settle a Cast load only by its own result A report on the same file sent again can describe the media before it, so only the load's result clears it, and a refusal stands until the next load. --- .../putdotio/android/playback/MobileCastGms.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index 86fa31d5a..2eff2173b 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -330,10 +330,10 @@ internal class GmsCastRemote : CastRemote { private var client: RemoteMediaClient? = null private var lastFileId: Long? = null - /** The file a load was sent for until the receiver reports on it, so a refusal is told for it. */ + /** The file a load was sent for until its result arrives, so a refusal is told for it. */ private var pendingLoad: Long? = null - /** A refused load, which stands over the receiver's reports on older media until the next load. */ + /** A refused load, which stands over the receiver's reports until the next load. */ private var failedLoad: CastRemoteStatus? = null /** Whether play was last asked for, which a loading receiver's status does not say. */ @@ -438,11 +438,12 @@ internal class GmsCastRemote : CastRemote { ) } - /** The receiver reported [state] for [fileId]: a pending or refused load for it is settled. */ + /** + * The receiver reported [state] for [fileId]. A report never settles a load: one for the same file + * sent again can be about the media before it, so only the load's own result does. + */ private fun noteReport(fileId: Long?, state: CastRemoteState) { lastFileId = fileId - if (fileId != null && fileId == pendingLoad) pendingLoad = null - if (fileId != null && fileId == failedLoad?.fileId) failedLoad = null if (state == CastRemoteState.Playing || state == CastRemoteState.Paused) { playIntent = state == CastRemoteState.Playing } @@ -464,8 +465,9 @@ internal class GmsCastRemote : CastRemote { .build(), ).setResultCallback { result -> // A refused or timed-out load says so for its file, unless another load followed it. - if (!result.status.isSuccess && client === remote && pendingLoad == media.fileId) { - pendingLoad = null + val current = client === remote && pendingLoad == media.fileId + if (current) pendingLoad = null + if (current && !result.status.isSuccess) { failedLoad = CastRemoteStatus( fileId = media.fileId, state = CastRemoteState.Failed, From d05922688bafacc71a1ea33aa54f08d8d3fd6772 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 18:24:16 +0300 Subject: [PATCH 12/16] fix(mobile): identify a Cast load by its request An earlier load of the same file can no longer settle or fail a later one. --- .../io/putdotio/android/playback/MobileCastGms.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index 2eff2173b..a15d7aa31 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -330,8 +330,8 @@ internal class GmsCastRemote : CastRemote { private var client: RemoteMediaClient? = null private var lastFileId: Long? = null - /** The file a load was sent for until its result arrives, so a refusal is told for it. */ - private var pendingLoad: Long? = null + /** The latest load request, so only its own result settles it; an older one for the same file does not. */ + private var loadRequest = 0L /** A refused load, which stands over the receiver's reports until the next load. */ private var failedLoad: CastRemoteStatus? = null @@ -394,7 +394,8 @@ internal class GmsCastRemote : CastRemote { publish() release() lastFileId = null - pendingLoad = null + // A load still in flight belongs to the session that ended. + loadRequest++ failedLoad = null playIntent = null endedStatus = mutableStatus.value?.takeUnless { discardHandback } @@ -452,7 +453,7 @@ internal class GmsCastRemote : CastRemote { override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { val remote = client ?: return lastFileId = media.fileId - pendingLoad = media.fileId + val request = ++loadRequest failedLoad = null playIntent = autoplay remote.load( @@ -465,9 +466,7 @@ internal class GmsCastRemote : CastRemote { .build(), ).setResultCallback { result -> // A refused or timed-out load says so for its file, unless another load followed it. - val current = client === remote && pendingLoad == media.fileId - if (current) pendingLoad = null - if (current && !result.status.isSuccess) { + if (client === remote && loadRequest == request && !result.status.isSuccess) { failedLoad = CastRemoteStatus( fileId = media.fileId, state = CastRemoteState.Failed, From 2e4987964a5fc2b15ed74989b9ea0346a7a2cc92 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 18:41:53 +0300 Subject: [PATCH 13/16] fix(mobile): keep load intent, sign-out teardown and the header exact Hold a load's play intent until its result, send nothing to a session that sign-out is ending, and draw one top bar over the receiver's controls. --- .../io/putdotio/android/playback/MobileCast.kt | 16 +++++++++++++++- .../putdotio/android/playback/MobileCastGms.kt | 6 +++++- .../android/playback/MobilePlayerScreen.kt | 10 ++++++---- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index 8a3a5f4c3..b65bd3374 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -157,6 +157,9 @@ internal class MobileCastController( private val handovers = mutableMapOf() private var lastMedia: PlaybackCastMedia? = null + /** Set while a session that sign-out ended winds down. */ + private var ending = false + /** The ended session whose position has been handed back, so it is handed back once. */ private var handedBack: CastRemoteStatus? = null @@ -169,6 +172,12 @@ internal class MobileCastController( if (status == null) takeEnded()?.let { (fileId, handover) -> handovers[fileId] = handover } } } + scope.launch { + // The departing session is gone once no session is connected or connecting. + gateway.availability.collect { + if (it !is CastAvailability.Connected && it !is CastAvailability.Connecting) ending = false + } + } } /** @@ -184,7 +193,7 @@ internal class MobileCastController( * reach put.io. Does nothing without a session. */ fun cast(media: PlaybackCastMedia, item: MediaItem, local: CastHandover): Boolean { - if (gateway.availability.value !is CastAvailability.Connected) return false + if (gateway.availability.value !is CastAvailability.Connected || ending) return false remotePlayer.adopt(item) val loads = !holds(media.fileId) if (loads) { @@ -200,6 +209,7 @@ internal class MobileCastController( * [media], when known, is what Play sends again once the file has finished there. */ fun attach(item: MediaItem, media: PlaybackCastMedia? = null) { + if (ending) return remotePlayer.adopt(item) if (media != null) lastMedia = media } @@ -220,6 +230,10 @@ internal class MobileCastController( /** Signing out ends the session: the receiver holds the account's download token. */ fun end() { + // Nothing goes to the departing session while it winds down. + ending = gateway.availability.value.let { + it is CastAvailability.Connected || it is CastAvailability.Connecting + } lastMedia = null handovers.clear() // Nor does one that ended a moment ago and is not handed back yet. diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index a15d7aa31..a7bdb2640 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -332,6 +332,7 @@ internal class GmsCastRemote : CastRemote { /** The latest load request, so only its own result settles it; an older one for the same file does not. */ private var loadRequest = 0L + private var loadSettled = true /** A refused load, which stands over the receiver's reports until the next load. */ private var failedLoad: CastRemoteStatus? = null @@ -445,7 +446,8 @@ internal class GmsCastRemote : CastRemote { */ private fun noteReport(fileId: Long?, state: CastRemoteState) { lastFileId = fileId - if (state == CastRemoteState.Playing || state == CastRemoteState.Paused) { + // A load's own intent stands until its result: reports on the media before it say nothing of it. + if (loadSettled && (state == CastRemoteState.Playing || state == CastRemoteState.Paused)) { playIntent = state == CastRemoteState.Playing } } @@ -454,6 +456,7 @@ internal class GmsCastRemote : CastRemote { val remote = client ?: return lastFileId = media.fileId val request = ++loadRequest + loadSettled = false failedLoad = null playIntent = autoplay remote.load( @@ -466,6 +469,7 @@ internal class GmsCastRemote : CastRemote { .build(), ).setResultCallback { result -> // A refused or timed-out load says so for its file, unless another load followed it. + if (loadRequest == request) loadSettled = true if (client === remote && loadRequest == request && !result.status.isSuccess) { failedLoad = CastRemoteStatus( fileId = media.fileId, diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index ba2d0ae8e..5ef4cc0f2 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -179,7 +179,8 @@ internal fun MobilePlayerScreen( // The open device picker is modal: TalkBack reaches only it. Box(Modifier.fillMaxSize().hiddenFromAccessibility(castPicker.value)) { val isVideo = state.target.mediaType == PlaybackMediaType.VIDEO - when (val content = state.shownContent(playerCast, onResume)) { + val shown = state.shownContent(playerCast, onResume) + when (val content = shown) { is PlaybackContent.Loading -> MobileLoadingState(stringResource(state.target.mediaType.loadingMessage())) @@ -224,7 +225,7 @@ internal fun MobilePlayerScreen( ) } - if (state.showsSeparateBack() && !pictureInPicture.value) { + if (state.showsSeparateBack(shown) && !pictureInPicture.value) { MobilePlayerSeparateBack(state, onBack, Modifier.align(Alignment.TopStart)) } } @@ -371,8 +372,9 @@ private fun MobilePlayerSeparateBack(state: PlaybackState, onBack: () -> Unit, m } } -private fun PlaybackState.showsSeparateBack(): Boolean = - target.mediaType == PlaybackMediaType.AUDIO || content !is PlaybackContent.Ready +// The receiver's controls draw the video's own top bar, as the ready player does. +private fun PlaybackState.showsSeparateBack(shown: PlaybackContent): Boolean = + target.mediaType == PlaybackMediaType.AUDIO || (shown !is PlaybackContent.Ready && shown != PlaybackContent.Session) /** * Back and the filename over a video that is not playing yet or cannot: the resume prompt, the From 6ec2509fd1155a65f840b895b415b214fbbaff8a Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 25 Oct 2026 19:01:55 +0300 Subject: [PATCH 14/16] fix(mobile): clear an empty receiver, untagged media and stopped items Treat a receiver that answers with no media as playing nothing, leave another sender's media unowned, send a stopped file again on Play, and let picker rows grow with large text. --- .../kotlin/io/putdotio/android/playback/MobileCast.kt | 2 +- .../io/putdotio/android/playback/MobileCastGms.kt | 11 +++++++++-- .../io/putdotio/android/playback/MobileCastUi.kt | 4 +++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index b65bd3374..e89f4ebcd 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -432,7 +432,7 @@ internal class CastRemotePlayer( } private val CastRemoteStatus.left: Boolean - get() = state == CastRemoteState.Ended || state == CastRemoteState.Failed + get() = state !in LIVE_STATES private val MediaItem.reportsPositions: Boolean get() = mediaMetadata.extras?.containsKey(PLAYBACK_REPORTING_LEASE_KEY) == true diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index a7bdb2640..3e56bc2c3 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -362,8 +362,14 @@ internal class GmsCastRemote : CastRemote { } } + /** A receiver that answered with no media plays nothing, whatever it played before. */ + private val clearIfEmpty = { + if (client?.mediaStatus == null) mutableStatus.value = null + } + private val callback = object : RemoteMediaClient.Callback() { override fun onStatusUpdated() { + clearIfEmpty() markStatusKnown() publish() } @@ -382,6 +388,7 @@ internal class GmsCastRemote : CastRemote { // answer the next status update does. it.requestStatus().setResultCallback { result -> if (client === it && result.status.isSuccess) { + clearIfEmpty() publish() markStatusKnown() } @@ -418,10 +425,10 @@ internal class GmsCastRemote : CastRemote { val remote = client ?: return val media = remote.mediaStatus ?: return // A finished file's status drops its media; it still belongs to the file it finished, with the - // title and kind it was sent with. + // title and kind it was sent with. Media another sender sent, untagged, belongs to no file. val info = remote.mediaInfo val fileId = info?.customData?.optLong(FILE_ID_KEY)?.takeIf { it > 0L } - ?: lastFileId.takeIf { media.playerState == MediaStatus.PLAYER_STATE_IDLE } + ?: lastFileId.takeIf { info == null && media.playerState == MediaStatus.PLAYER_STATE_IDLE } val previous = mutableStatus.value?.takeIf { info == null && it.fileId == fileId } val state = media.castRemoteState() noteReport(fileId, state) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt index 5c3ed0fcb..6c1e01243 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -222,7 +223,8 @@ private fun MobileCastActionRow( Row( modifier .fillMaxWidth() - .height(CAST_ROW_HEIGHT) + // Large text grows the row past its 64dp. + .heightIn(min = CAST_ROW_HEIGHT) .clickable(role = Role.Button, onClick = onClick) .semantics { this.selected = selected } .padding(horizontal = 24.dp), From 433a8001b1b22bd2dd65aa9b8450a736c3410d37 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 26 Oct 2026 21:25:18 +0300 Subject: [PATCH 15/16] fix(mobile): find Cast receivers on a real Android 17 phone Start Cast in MainActivity.onCreate so its discovery covers the resumed activity, and declare ACCESS_LOCAL_NETWORK, which Play services needs before it shows the app any receiver. Without it the Cast button shows and its tap asks. --- DESIGN.md | 3 +- mobile/src/main/AndroidManifest.xml | 3 + .../io/putdotio/android/MainActivity.kt | 3 + .../putdotio/android/playback/MobileCast.kt | 14 +++- .../android/playback/MobileCastGms.kt | 40 ++++++++-- .../android/playback/MobileCastPlayback.kt | 4 +- .../putdotio/android/playback/MobileCastUi.kt | 35 ++++++++- .../android/MobileCastControllerTest.kt | 2 + .../putdotio/android/MobileCastStartTest.kt | 78 +++++++++++++++++++ 9 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 mobile/src/test/kotlin/io/putdotio/android/MobileCastStartTest.kt diff --git a/DESIGN.md b/DESIGN.md index 6ab11e174..eec2f0853 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -644,7 +644,8 @@ over a top and bottom scrim: next-episode setting is on. **Cast** shows in the top bar only while a receiver is in reach, in yellow -while casting. It opens "Cast to", a bottom sheet at most 560dp wide: 64dp +while casting. On Android 17 it also shows until the user allows nearby +devices; its tap asks, then opens the picker. It opens "Cast to", a bottom sheet at most 560dp wide: 64dp rows with the kind's glyph (TV, screen, speaker), the name over its model or "Audio only", a yellow check on the one playing, and Stop casting while connected. Casting video swaps the picture for black with "Playing on" and the diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index cccf5d572..fa079f345 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + + diff --git a/mobile/src/main/kotlin/io/putdotio/android/MainActivity.kt b/mobile/src/main/kotlin/io/putdotio/android/MainActivity.kt index 2325f27f5..5e91d6410 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MainActivity.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.putdotio.android.auth.handleAuthTabActivityResult +import io.putdotio.android.playback.MobileCast import io.putdotio.android.share.MobileFileShareService import io.putdotio.android.share.MobileResumedActivity import io.putdotio.android.transfers.MobileTransferDraft @@ -48,6 +49,8 @@ class MainActivity : BasePutioActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) configureEdgeToEdge() + // Before this activity resumes, so Cast looks for receivers while it shows. + MobileCast.controller(this) if (savedInstanceState == null) MobileFileShareService.pruneStale(applicationContext) consumeShare(intent, savedInstanceState?.getBoolean(STATE_SHARE_CONSUMED) == true) consumeDeepLink(intent, savedInstanceState?.getBoolean(STATE_DEEP_LINK_CONSUMED) == true) diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt index e89f4ebcd..7575ba372 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -18,13 +18,22 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -/** Where Cast stands on this device; the Cast button shows only once a receiver is in reach. */ +/** + * Where Cast stands on this device; the Cast button shows once a receiver is in reach, or while + * Android needs the local network allowed to find one. + */ internal sealed interface CastAvailability { /** No Google Play services, or Cast failed to start: no button. */ data object Unsupported : CastAvailability data object NoDevices : CastAvailability + /** + * Android 17 hides the receivers until the user allows the local network: the button shows, and + * its tap asks. + */ + data object NeedsLocalNetwork : CastAvailability + data object Available : CastAvailability data class Connecting(val deviceName: String?) : CastAvailability @@ -101,6 +110,9 @@ internal interface CastGateway : CastRoutes, CastRemote { * receiver reported by then, so the next account never resumes the last one's. */ fun endForSignOut() + + /** The user answered the local network prompt; a refusal hides the button until the next launch. */ + fun localNetworkAnswered(granted: Boolean) } /** The receivers the picker lists. */ diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt index 3e56bc2c3..a49de9ce3 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -1,13 +1,17 @@ package io.putdotio.android.playback +import android.Manifest import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import androidx.core.net.toUri import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.SystemClock +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter @@ -45,14 +49,22 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject -/** The process's Cast controller, started on first use; null where Google Play services is missing. */ +/** + * The process's Cast controller, started on first use; null where Google Play services is missing. + * MainActivity starts it in onCreate: Cast discovers receivers only for activities that resume after + * it started. + */ internal object MobileCast { @Volatile private var controller: MobileCastController? = null + /** Google Play services, which runs Cast in its own process. */ + @VisibleForTesting + var playServices: (Context) -> Boolean = ::playServicesAvailable + fun controller(context: Context): MobileCastController? { val appContext = context.applicationContext - if (!playServicesAvailable(appContext)) return null + if (!playServices(appContext)) return null return controller ?: synchronized(this) { controller ?: run { val gateway = GmsCastGateway(appContext).also { it.start() } @@ -174,12 +186,15 @@ internal class GmsCastGateway private constructor( override val availability: StateFlow = mutableAvailability private var castContext: CastContext? = null - // Play services runs Cast in its own process; a failure to start it means no Cast here. + /** + * Starts Cast at once: it tracks the activities that resume from here on and discovers receivers + * only while one is resumed, so a start that lands after the activity resumed finds none. Play + * services runs Cast in its own process; a failure to start it means no Cast here. + */ @Suppress("SwallowedException") fun start() { try { - CastContext.getSharedInstance(context, ContextCompat.getMainExecutor(context)) - .addOnSuccessListener(::bind) + bind(CastContext.getSharedInstance(context)) } catch (_: RuntimeException) { mutableAvailability.value = CastAvailability.Unsupported } @@ -204,7 +219,8 @@ internal class GmsCastGateway private constructor( val device = cast.sessionManager.currentCastSession?.castDevice?.friendlyName mutableAvailability.value = when { remote.attached -> CastAvailability.Connected(device.orEmpty()) - cast.castState == CastState.NO_DEVICES_AVAILABLE -> CastAvailability.NoDevices + cast.castState == CastState.NO_DEVICES_AVAILABLE -> + if (localNetworkBlocked()) CastAvailability.NeedsLocalNetwork else CastAvailability.NoDevices cast.castState == CastState.CONNECTING || cast.castState == CastState.CONNECTED -> CastAvailability.Connecting(device) else -> CastAvailability.Available @@ -258,6 +274,18 @@ internal class GmsCastGateway private constructor( } private var stopOnBind = false + + override fun localNetworkAnswered(granted: Boolean) { + if (!granted) localNetworkRefused = true + updateAvailability() + } + + private var localNetworkRefused = false + + private fun localNetworkBlocked(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && !localNetworkRefused && + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) != + PackageManager.PERMISSION_GRANTED } /** The receivers MediaRouter finds for the Cast framework's selector, while the picker asks. */ diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt index 7d6acb617..51f878a22 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -93,7 +93,9 @@ internal fun rememberPlayerCast( ?: (availability as? CastAvailability.Connecting)?.takeIf { status != null }?.let { it.deviceName.orEmpty() } return PlayerCast( controller = cast, - button = { modifier -> MobileCastButton(availability, onOpenPicker, modifier) }, + button = { modifier -> + MobileCastButton(availability, onOpenPicker, cast.gateway::localNetworkAnswered, modifier) + }, casting = if (device == null) null else CastingTarget(cast, castMedia, device), ) } diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt index 6c1e01243..d37ac60ca 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt @@ -1,7 +1,11 @@ package io.putdotio.android.playback +import android.Manifest +import android.annotation.SuppressLint import androidx.annotation.DrawableRes import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -69,12 +73,33 @@ internal const val MOBILE_CAST_PICKER_TAG = "mobile-cast-picker" internal const val MOBILE_CAST_STOP_TAG = "mobile-cast-stop" internal const val MOBILE_CAST_LABEL_TAG = "mobile-cast-label" -/** Cast in a player's top bar, once a receiver is in reach; yellow while casting. */ +/** + * Cast in a player's top bar, once a receiver is in reach; yellow while casting. Where Android needs + * the local network allowed first, the tap asks for it and opens the picker once allowed. + */ @Composable -internal fun MobileCastButton(availability: CastAvailability, onClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun MobileCastButton( + availability: CastAvailability, + onClick: () -> Unit, + onLocalNetworkAnswer: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { if (!availability.showsButton) return val device = availability.connectedDevice - IconButton(onClick = onClick, modifier = modifier.testTag(MOBILE_CAST_BUTTON_TAG)) { + val askLocalNetwork = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + onLocalNetworkAnswer(granted) + if (granted) onClick() + } + IconButton( + onClick = { + if (availability == CastAvailability.NeedsLocalNetwork) { + askLocalNetwork.launch(LOCAL_NETWORK_PERMISSION) + } else { + onClick() + } + }, + modifier = modifier.testTag(MOBILE_CAST_BUTTON_TAG), + ) { Icon( painterResource(R.drawable.ic_ph_screencast), contentDescription = device?.let { stringResource(R.string.mobile_cast_casting_to, it) } @@ -299,6 +324,10 @@ internal fun MobileCastFrameLabel(text: String, modifier: Modifier = Modifier) { } } +// Only asked for on Android 17 and later, where the platform defines it. +@SuppressLint("InlinedApi") +private const val LOCAL_NETWORK_PERMISSION = Manifest.permission.ACCESS_LOCAL_NETWORK + private val CAST_PICKER_MAX_WIDTH = 560.dp private const val CAST_PICKER_SCRIM_ALPHA = 0.55f private val CAST_ROW_HEIGHT = 64.dp diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt index 97c67e0fd..161ddd4c8 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -366,6 +366,8 @@ class MobileCastControllerTest { idle() } + override fun localNetworkAnswered(granted: Boolean) = Unit + override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { loads += Load(media.fileId, startMillis, autoplay, speed) } diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileCastStartTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileCastStartTest.kt new file mode 100644 index 000000000..0f7b693d1 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastStartTest.kt @@ -0,0 +1,78 @@ +package io.putdotio.android + +import android.app.Activity +import android.app.Application +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.playback.MobileCast +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** What Cast needs to find receivers on a real device, which the fake gateways never exercise. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileCastStartTest { + private val app = ApplicationProvider.getApplicationContext() + private val playServices = MobileCast.playServices + + @After + fun restorePlayServices() { + MobileCast.playServices = playServices + } + + @Test + fun castStartsBeforeMainActivityResumes() { + // Cast discovers only for activities that resume after it started. + val events = mutableListOf() + val lifecycle = object : Application.ActivityLifecycleCallbacks { + override fun onActivityPreResumed(activity: Activity) { + events += "resumed" + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityResumed(activity: Activity) = Unit + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit + } + app.registerActivityLifecycleCallbacks(lifecycle) + // No Play services here, so Cast goes no further than asking for it. + MobileCast.playServices = { + events += "cast" + false + } + try { + ActivityScenario.launch(MainActivity::class.java).use { + assertEquals("cast", events.firstOrNull()) + assertTrue("resumed" in events) + } + } finally { + app.unregisterActivityLifecycleCallbacks(lifecycle) + } + } + + @Test + fun manifestAsksForTheLocalNetworkCastFindsReceiversOn() { + // Android 17 shows an app that targets it no Cast receivers without this permission. + val requested = app.packageManager + .getPackageInfo(app.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions + .orEmpty() + assertTrue("android.permission.ACCESS_LOCAL_NETWORK" in requested) + } +} From 521c9c46c37d23b760d84c1fa26239e69ec6d574 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 26 Oct 2026 22:28:45 +0300 Subject: [PATCH 16/16] docs: state Cast as proven on a real phone --- DESIGN.md | 18 +++++++++--------- README.md | 3 +-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index eec2f0853..2faa03ccf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -643,15 +643,15 @@ over a top and bottom scrim: Subtitles and Speed chips, and Next episode on the right when the account's next-episode setting is on. -**Cast** shows in the top bar only while a receiver is in reach, in yellow -while casting. On Android 17 it also shows until the user allows nearby -devices; its tap asks, then opens the picker. It opens "Cast to", a bottom sheet at most 560dp wide: 64dp -rows with the kind's glyph (TV, screen, speaker), the name over its model or -"Audio only", a yellow check on the one playing, and Stop casting while -connected. Casting video swaps the picture for black with "Playing on" and the -device, over the cast glyph in `on-surface-variant`, above the same transport, -times and scrubber; only Speed stays, when the receiver takes a playback rate. -Now Playing keeps its layout and names the device in its top bar. +**Cast** shows in the top bar only while a receiver is in reach, in yellow while +casting. On Android 17 it also shows until the user allows nearby devices; its +tap asks, then opens the picker. It opens "Cast to", a bottom sheet at most +560dp wide: 64dp rows with the kind's glyph (TV, screen, speaker), the name over +its model or "Audio only", a yellow check on the one playing, and Stop casting +while connected. Casting video swaps the picture for black with "Playing on" and +the device, over the cast glyph in `on-surface-variant`, above the same +transport, times and scrubber; only Speed stays, when the receiver takes a +playback rate. Now Playing keeps its layout and names the device in its top bar. Audio, Subtitles and Speed open a 340dp side sheet from the right with the video dimmed behind: diff --git a/README.md b/README.md index b820180bf..0f69281be 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ Mobile, on the emulator harness against the live API: - Offline downloads with offline playback - Transfer intake from magnet links, `.torrent` files and shared links - Launcher shortcuts, a Transfers widget and the system file picker -- Google Cast from the players, resuming locally where the receiver stopped; - proven only on the emulator with faked data +- Google Cast from the players, resuming locally where the receiver stopped TV: