diff --git a/DESIGN.md b/DESIGN.md index 4c36c9ae..2faa03cc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -643,6 +643,16 @@ 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. + Audio, Subtitles and Speed open a 340dp side sheet from the right with the video dimmed behind: @@ -669,8 +679,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 +759,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 +811,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 81bb0f3a..0f69281b 100644 --- a/README.md +++ b/README.md @@ -23,6 +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 TV: diff --git a/design/phosphor-icons.lock.json b/design/phosphor-icons.lock.json index 9fe9cd7b..7fab8c5a 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 00000000..eab7be1e --- /dev/null +++ b/domain/playback/src/main/kotlin/io/putdotio/android/playback/PlaybackCastMedia.kt @@ -0,0 +1,166 @@ +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.URLDecoder +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 }, + // 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, + ) +} + +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('&') + // Names compare decoded, so an escaped `oauth%5Ftoken` cannot carry the session's token through. + .filter { it.isNotEmpty() && it.substringBefore('=').decoded() !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 + } + +/** This URL's query names, decoded; empty for a malformed URL. */ +@Suppress("SwallowedException") +private fun String.queryNames(): Set = + 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" +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 96497cb5..1504276a 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 00000000..d7a02732 --- /dev/null +++ b/domain/playback/src/test/kotlin/io/putdotio/android/playback/PlaybackCastMediaTest.kt @@ -0,0 +1,184 @@ +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 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( + 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 837afc74..a40d9368 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 a4827655..e6aa55b5 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 aa5a9ce8..f8991d42 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 948216c4..fa079f34 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + + @@ -162,6 +165,17 @@ 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 +403,8 @@ internal fun MobileNavHost( onOpenInVlc = filesActions?.let { actions -> { target -> actions.openInVlc(target.fileId, target.name) } }, + cast = cast, + castMedia = playbackRepository::castMediaFor, ) // Over the player, which paints its own background. filesActions?.let { MobileFilesNoticeHost(it.notices, Modifier.align(Alignment.BottomCenter)) } @@ -445,6 +455,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 +511,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/MobileShell.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt index 032c0942..10ee1b6e 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt @@ -97,10 +97,12 @@ import io.putdotio.android.history.HistoryEvent import io.putdotio.android.history.HistoryReducer import io.putdotio.android.history.HistoryState import io.putdotio.android.playback.DefaultMobilePlayerFactory +import io.putdotio.android.playback.MobileCast import io.putdotio.android.playback.MobileNowPlayingBar import io.putdotio.android.playback.MobilePlayerFactory import io.putdotio.android.playback.PlaybackMediaType import io.putdotio.android.playback.PlaybackRepository +import io.putdotio.android.playback.PlaybackTarget import io.putdotio.android.playback.rememberNowPlaying import io.putdotio.android.search.MobileHistoryOverflow import io.putdotio.android.search.MobileSearchBar @@ -710,7 +712,10 @@ private fun NowPlayingNavigationEffect( LaunchedEffect(nowPlayingRequests, playbackPlayerFactory, appContext, transferDraft) { nowPlayingRequests.pending.collect { pending -> 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(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 @@ -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/auth/MobileOAuthRuntime.kt b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt index 0b28bedb..55755b2d 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(context) + } + /** 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/downloads/OfflinePlaybackRepository.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/OfflinePlaybackRepository.kt index 751ecefd..d0a37f11 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 00000000..7575ba37 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCast.kt @@ -0,0 +1,456 @@ +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.android.files.FilesItemId +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 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 + + 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, + /** 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, + /** 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 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 + } +} + +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() + + /** + * 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 user answered the local network prompt; a refusal hides the button until the next launch. */ + fun localNetworkAnswered(granted: Boolean) +} + +/** 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. */ + 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() + + 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 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 + + + init { + scope.launch { + gateway.status.collect { 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 } + } + } + 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 + } + } + } + + /** + * 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 in LIVE_STATES } == 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 || ending) 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. + * [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 + } + + /** + * 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 status = gateway.status.value?.takeIf { gateway.availability.value is CastAvailability.Connected } + val fileId = status?.fileId ?: return null + return PlaybackTarget( + FilesItemId(fileId), + status.title.orEmpty(), + if (status.audio) PlaybackMediaType.AUDIO else PlaybackMediaType.VIDEO, + ) + } + + /** 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. + handedBack = gateway.endedStatus + 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 + // 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 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 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 + gateway.load(media, startMillis, autoplay = true, speed = gateway.status.value?.speed ?: 1f) + } +} + +/** 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 + * (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() + } + } + } + + /** 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() + } + + 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?.left == true -> 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?.left == true && !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 val CastRemoteStatus.left: Boolean + get() = state !in LIVE_STATES + + 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 new file mode 100644 index 00000000..a49de9ce --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastGms.kt @@ -0,0 +1,594 @@ +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 +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.MobilePlaybackService +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. + * 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 (!playServices(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 + } + } + } + } + + /** + * The signed-in session left: the receiver plays with the account's download token, so the Cast + * session ends with it, a session the Cast SDK resumes for a fresh process included. + */ + fun sessionLeft(context: Context) { + Handler(Looper.getMainLooper()).post { controller(context)?.end() } + } + + /** + * 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") + 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(MobileCastNotificationActivity::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 + } +} + +/** + * 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. + */ +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()) + + init { + remote.onAttachedChanged = { updateAvailability() } + } + + private val mutableAvailability = MutableStateFlow(CastAvailability.Unsupported) + override val availability: StateFlow = mutableAvailability + private var castContext: CastContext? = null + + /** + * 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 { + bind(CastContext.getSharedInstance(context)) + } catch (_: RuntimeException) { + mutableAvailability.value = CastAvailability.Unsupported + } + } + + 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) + 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 -> + if (localNetworkBlocked()) CastAvailability.NeedsLocalNetwork else 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) { + // A new session is not the one a sign-out ended. + remote.discardHandback = false + 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() + } + + /** 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) + } + + 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. */ +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 + override var endedStatus: CastRemoteStatus? = null + private set + private var client: RemoteMediaClient? = null + private var lastFileId: 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 + private var loadSettled = true + + /** 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. */ + 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 + + /** 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 = {} + + val attached: Boolean get() = client != null && statusKnown + + private val markStatusKnown = { + if (client != null && !statusKnown) { + statusKnown = true + onAttachedChanged() + } + } + + /** 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() + } + + 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) + // 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) { + clearIfEmpty() + publish() + markStatusKnown() + } + } + } + publish() + } + + /** The session ended: one last status, then none, so the app hands the file back. */ + fun detach() { + publish() + release() + lastFileId = null + // A load still in flight belongs to the session that ended. + loadRequest++ + failedLoad = null + playIntent = null + endedStatus = mutableStatus.value?.takeUnless { discardHandback } + ?.copy(endedAtMillis = SystemClock.elapsedRealtime()) + discardHandback = false + mutableStatus.value = null + } + + private fun release() { + client?.let { + it.unregisterCallback(callback) + 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, with the + // 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 { 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) + mutableStatus.value = failedLoad ?: CastRemoteStatus( + fileId = fileId, + 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) ?: previous?.title, + audio = previous?.audio ?: (info?.metadata?.mediaType == MediaMetadata.MEDIA_TYPE_MUSIC_TRACK), + ) + } + + /** + * 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 + // 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 + } + } + + override fun load(media: PlaybackCastMedia, startMillis: Long, autoplay: Boolean, speed: Float) { + val remote = client ?: return + lastFileId = media.fileId + val request = ++loadRequest + loadSettled = false + failedLoad = null + playIntent = autoplay + remote.load( + MediaLoadRequestData.Builder() + .setMediaInfo(media.toMediaInfo()) + .setAutoplay(autoplay) + .setCurrentTime(startMillis) + .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 (loadRequest == request) loadSettled = true + if (client === remote && loadRequest == request && !result.status.isSuccess) { + failedLoad = CastRemoteStatus( + fileId = media.fileId, + state = CastRemoteState.Failed, + positionMillis = startMillis, + durationMillis = media.durationMillis, + sampledAtMillis = SystemClock.elapsedRealtime(), + title = media.title, + audio = media.mediaType == PlaybackMediaType.AUDIO, + ) + mutableStatus.value = failedLoad + } + } + } + + override fun play() { + playIntent = true + client?.play() + } + + override fun pause() { + playIntent = false + client?.pause() + } + + override fun seekTo(positionMillis: Long) { + client?.seek(MediaSeekOptions.Builder().setPosition(positionMillis).build()) + } + + override fun setSpeed(speed: Float) { + 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 + 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 00000000..51f878a2 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastPlayback.kt @@ -0,0 +1,347 @@ +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.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 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 +import io.putdotio.android.R +import io.putdotio.sdk.files.PlaybackSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * 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 + +/** [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( + 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 + + /** 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( + 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() + 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, cast.gateway::localNetworkAnswered, modifier) + }, + casting = if (device == null) null else CastingTarget(cast, castMedia, device), + ) +} + +private val NoCast: StateFlow = MutableStateFlow(CastAvailability.Unsupported) +private val NoStatus: StateFlow = MutableStateFlow(null) +private val NoPlayerCast = PlayerCast(controller = null, button = {}, casting = null) + +/** + * 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( + playerCast: PlayerCast, + fileId: Long, + preferences: RetainedPlayerPreferences, +): CastHandover? { + var handover by remember(fileId) { mutableStateOf(playerCast.controller?.takeHandover(fileId)) } + LaunchedEffect(handover) { + handover?.let { + preferences.retainPlayback(RetainedPlayback(it.positionMillis, it.playing)) + preferences.playbackSpeed = it.speed + handover = null + } + } + 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) + // 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) + val load: suspend (PlaybackSource?) -> PlaybackRepositoryResult = { source -> + castMedia(source, source?.let { castSubtitleKey(it, 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) } + + // 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, + title = target.name, + neighbours = neighbours, + seekEnabled = seekEnabled, + onSeek = seek, + onScrub = {}, + onPlay = onPlay, + onAudioSelectionChanged = {}, + castButton = castButton, + castLabel = label, + modifier = Modifier.zIndex(2f).then(keys), + ) + } 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 { mutableStateOf(false) } + // 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, + title = title, + 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) + } + } +} + +@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. + */ +@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 + // 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 holds = holds(fileId) + if (!holds && ready == null) { + onSourceRequired(retained) + return true + } + // 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) + loaded != null -> cast( + loaded.value, + item, + CastHandover( + positionMillis = retained ?: ready?.source?.startFromSeconds?.toPlaybackMillis() ?: 0L, + playing = preferences.resumeAfterLifecyclePause, + speed = preferences.playbackSpeed, + ), + ) + } + return holds || loaded != null +} + +/** [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 00000000..d37ac60c --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobileCastUi.kt @@ -0,0 +1,336 @@ +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 +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.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 +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.focusGroup +import androidx.compose.foundation.focusable +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.heightIn +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.LaunchedEffect +import androidx.compose.runtime.remember +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. 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, + onLocalNetworkAnswer: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + if (!availability.showsButton) return + val device = availability.connectedDevice + 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) } + ?: 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) + // 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( + 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 + } + .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, + ) { + 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() + // 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), + 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, + ) + } + } +} + +// 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 + +/** 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 a2d0e88d..028348e4 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 f9a0733f..5ef4cc0f 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 @@ -127,6 +128,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 +141,32 @@ 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 } + 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() @@ -147,97 +176,176 @@ 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, - ) - - 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, + // The open device picker is modal: TalkBack reaches only it. + Box(Modifier.fillMaxSize().hiddenFromAccessibility(castPicker.value)) { + val isVideo = state.target.mediaType == PlaybackMediaType.VIDEO + val shown = state.shownContent(playerCast, onResume) + when (val content = shown) { + 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.FindingNext -> - MobileLoadingState(stringResource(R.string.mobile_playback_finding_next)) + is PlaybackContent.Ready, PlaybackContent.Session -> readyContent() - is PlaybackContent.NextFailed -> - MobilePlaybackFailureState( - title = stringResource(R.string.mobile_playback_next_error_title), - failure = content.failure, - onRetry = onRetry, - video = isVideo, - ) + 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, + ) - 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(shown) && !pictureInPicture.value) { + MobilePlayerSeparateBack(state, onBack, Modifier.align(Alignment.TopStart)) + } } + MobileCastPickerHost(cast, castPicker, pictureInPicture.value, Modifier.zIndex(4f)) + } +} + +/** + * 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. 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 + // 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 +} + +/** 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 + +/** + * 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, + ) } } @@ -264,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 @@ -327,6 +436,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 +609,7 @@ private fun MobileReadyPlayer( onPlay = onPlay, enterPictureInPicture = enterPictureInPicture, pictureInPictureSeeks = pictureInPictureSeeks, + castButton = castButton, ) } @@ -1043,6 +1154,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 +1247,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 +1262,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 00000000..a7012ee5 --- /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 00000000..2a532f24 --- /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 00000000..c02c21f9 --- /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 00000000..35369c1a --- /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 c0f3732a..0cc0ce6d 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 6f1423f4..92129513 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 00000000..161ddd4c --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileCastControllerTest.kt @@ -0,0 +1,407 @@ +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.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 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 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() + 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() + 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() + 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 var endedStatus: CastRemoteStatus? = null + override val devices = MutableStateFlow>(emptyList()) + val loads = mutableListOf() + val commands = mutableListOf() + + fun connect() { + availability.value = CastAvailability.Connected("Living room TV") + idle() + } + + 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?.copy(endedAtMillis = now) + availability.value = CastAvailability.Available + status.value = null + } + + fun report( + state: CastRemoteState, + positionMillis: Long, + durationMillis: Long? = null, + speed: Float = 1f, + speedSupported: Boolean = false, + ) { + 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, + durationMillis = durationMillis, + speed = speed, + speedSupported = speedSupported, + sampledAtMillis = now, + ) + + override fun startDiscovery() = Unit + + override fun stopDiscovery() = Unit + + override fun select(deviceId: String) = Unit + + 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 localNetworkAnswered(granted: Boolean) = Unit + + 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() + } +} 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 00000000..0f7b693d --- /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) + } +}