Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
28 changes: 28 additions & 0 deletions design/phosphor-icons.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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<PlaybackCastSubtitle>,
/** 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}, <redacted urls>)"
}

/** 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, <redacted url>)"
}

/**
* 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, String>): 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<String> =
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",
)
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ public interface PlaybackRepository {
*/
public suspend fun findNeighbours(target: PlaybackTarget): PlaybackRepositoryResult<PlaybackNeighbours> =
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<PlaybackCastMedia> =
PlaybackRepositoryResult.Failure(PlaybackFailure.MediaUnsupported(UncastableSourceException()))
}

/** What sits around a playing file in its folder; null where there is nothing, or nothing known. */
Expand Down Expand Up @@ -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<PlaybackCastMedia> =
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
Expand Down Expand Up @@ -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,
Expand Down
Loading