Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## New
- Player appearance: choose whether the thin progress bar sits on the video, just below it, or stays off while the controls are hidden (#1550)

## Fixed
- Scrobbling: signing in to Libre.fm works (#1658)
- Auto quality and live streams can go above 1080p again on phones with a higher resolution screen (#1617)
- A paused video that comes back black after leaving the app resumes from where it stopped, and changing quality keeps your place (#1527)
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ data class PlayerOverlayPreferences(
defaultPaddingDp = DEFAULT_PORTRAIT_SEEKBAR_PADDING_DP,
maxPaddingDp = MAX_PORTRAIT_SEEKBAR_PADDING_DP,
),
val idleProgressBar: IdleProgressBar = IdleProgressBar.ON_VIDEO,
val scrubPreviewStyle: ScrubPreviewStyle = ScrubPreviewStyle.STRIP,
/** Off by default: two more buttons in the transport row is a cost every video pays. */
val frameStepButtonsEnabled: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ class PlayerPreferences(
// Fullscreen Player
val PORTRAIT_SEEKBAR_PADDING_MODE = device(stringPreferencesKey("portrait_seekbar_padding_mode"))
val PORTRAIT_SEEKBAR_CUSTOM_PADDING_DP = device(intPreferencesKey("portrait_seekbar_custom_padding_dp"))
val PORTRAIT_IDLE_PROGRESS_BAR = device(stringPreferencesKey("portrait_idle_progress_bar"))
val FULLSCREEN_SEEKBAR_PADDING_MODE = device(stringPreferencesKey("fullscreen_seekbar_padding_mode"))
val FULLSCREEN_SEEKBAR_CUSTOM_PADDING_DP = device(intPreferencesKey("fullscreen_seekbar_custom_padding_dp"))
val FRAME_STEP_BUTTONS_ENABLED = device(booleanPreferencesKey("frame_step_buttons_enabled"))
Expand Down Expand Up @@ -782,6 +783,7 @@ class PlayerPreferences(
defaultPaddingDp = DEFAULT_PORTRAIT_SEEKBAR_PADDING_DP,
maxPaddingDp = MAX_PORTRAIT_SEEKBAR_PADDING_DP,
),
idleProgressBar = resolveIdleProgressBar(device[DeviceKeys.PORTRAIT_IDLE_PROGRESS_BAR]),
scrubPreviewStyle = resolveScrubPreviewStyle(profile[Keys.SCRUB_PREVIEW_STYLE]),
frameStepButtonsEnabled =
device[DeviceKeys.FRAME_STEP_BUTTONS_ENABLED] ?: overlayDefaults.frameStepButtonsEnabled,
Expand Down Expand Up @@ -2075,6 +2077,16 @@ class PlayerPreferences(
}
}

val idleProgressBar: Flow<IdleProgressBar> =
context.devicePreferencesDataStore.data
.map { preferences -> resolveIdleProgressBar(preferences[DeviceKeys.PORTRAIT_IDLE_PROGRESS_BAR]) }

suspend fun setIdleProgressBar(position: IdleProgressBar) {
context.devicePreferencesDataStore.edit { preferences ->
preferences[DeviceKeys.PORTRAIT_IDLE_PROGRESS_BAR] = position.name
}
}

val fullscreenSeekbarPaddingMode: Flow<SeekbarPaddingMode> =
context.devicePreferencesDataStore.data
.map { preferences ->
Expand Down Expand Up @@ -4296,6 +4308,16 @@ enum class SeekbarPaddingMode {
CUSTOM,
}

/** Where the portrait player draws its thin progress bar while the controls are hidden (#1550). */
enum class IdleProgressBar {
ON_VIDEO,
BELOW_VIDEO,
OFF,
}

internal fun resolveIdleProgressBar(stored: String?): IdleProgressBar =
stored?.let { value -> IdleProgressBar.entries.firstOrNull { it.name == value } } ?: IdleProgressBar.ON_VIDEO

/** What a scrub shows above the bar: a filmstrip around the target, or the single frame under it. */
enum class ScrubPreviewStyle {
STRIP,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.security.MessageDigest
import java.util.Locale

/** An app's identity on a Last.fm-compatible service. */
data class AudioscrobblerKeys(
Expand All @@ -24,13 +25,23 @@ data class AudioscrobblerKeys(
val isUsable: Boolean get() = apiKey.isNotBlank() && secret.isNotBlank()
}

/** How a service takes the credentials for auth.getMobileSession. */
enum class MobileSessionAuth {
/** Last.fm: the password itself, inside the signed request. */
PASSWORD,

/** Libre.fm (GNU FM): only an auth token derived from the user name and password. */
AUTH_TOKEN,
}

/**
* The Audioscrobbler 2.0 API that Last.fm and Libre.fm both speak: a mobile session from a user
* name and password, now playing, and scrobbles in batches of up to [MAX_BATCH].
*/
class AudioscrobblerClient(
private val baseUrl: String,
private val http: () -> OkHttpClient,
private val sessionAuth: MobileSessionAuth = MobileSessionAuth.PASSWORD,
) {
suspend fun signIn(
userName: String,
Expand All @@ -39,7 +50,12 @@ class AudioscrobblerClient(
): Result<ScrobbleAccount> =
withContext(Dispatchers.IO) {
runCatching {
val body = post(mapOf("method" to "auth.getMobileSession", "username" to userName, "password" to password), keys)
val credential =
when (sessionAuth) {
MobileSessionAuth.PASSWORD -> "password" to password
MobileSessionAuth.AUTH_TOKEN -> "authToken" to audioscrobblerAuthToken(userName, password)
}
val body = post(mapOf("method" to "auth.getMobileSession", "username" to userName, credential), keys)
body.errorCode()?.let { throw IOException(body.errorMessage() ?: "Error $it") }
val session = body["session"]?.jsonObject ?: throw IOException("No session")
ScrobbleAccount(
Expand Down Expand Up @@ -243,6 +259,12 @@ class AudioscrobblerClient(
}
}

/** GNU FM's token: MD5 of the lower-cased user name followed by the hex MD5 of the password. */
internal fun audioscrobblerAuthToken(
userName: String,
password: String,
): String = md5Hex(userName.lowercase(Locale.ROOT) + md5Hex(password))

/** The documented api_sig: every parameter but format and callback, sorted, joined, then the secret, MD5'd. */
internal fun audioscrobblerSignature(
params: Map<String, String>,
Expand All @@ -254,11 +276,14 @@ internal fun audioscrobblerSignature(
.toSortedMap()
.entries
.joinToString("") { it.key + it.value } + secret
return MessageDigest
return md5Hex(base)
}

private fun md5Hex(text: String): String =
MessageDigest
.getInstance("MD5")
.digest(base.toByteArray(Charsets.UTF_8))
.digest(text.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}

/** Session and key errors sign the account out; outages and rate limits retry; anything else drops the batch. */
internal fun outcomeOf(errorCode: Int?): SendOutcome =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class Scrobbler
suspend fun awaitWrites() = work.children.toList().joinAll()

private val lastFm = AudioscrobblerClient(AudioscrobblerClient.LASTFM_URL, http::get)
private val libreFm = AudioscrobblerClient(AudioscrobblerClient.LIBREFM_URL, http::get)
private val libreFm = AudioscrobblerClient(AudioscrobblerClient.LIBREFM_URL, http::get, MobileSessionAuth.AUTH_TOKEN)
private val listenBrainz = ListenBrainzClient(http::get)

/** The key Last.fm calls are signed with: the viewer's own when they turned it on, else the build's. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,7 @@ class EnhancedPlayerManager private constructor() {
onStreamExpired = { onStreamsDenied() },
onPlaybackAbandoned = { if (!skipAbandonedVideo()) scope.launch { _playbackAbandonedEvent.emit(Unit) } },
onDecoderGaveUp = { scope.launch { _decoderFailureEvent.emit(Unit) } },
rememberPlaybackForRecovery = { player?.let { captureClearedMediaRecovery(it, restoresOnForeground = false) } },
onGatedCodecFallback = { position -> qualityManager?.fallbackToAlternateCodec(position) ?: false },
getFailedStreamUrls = {
qualityManager?.let { qm ->
Expand Down Expand Up @@ -2410,8 +2411,8 @@ class EnhancedPlayerManager private constructor() {
restoreVideoOutput()
}
val p = player ?: return
if (reloadClearedMediaIfNeeded(playWhenReadyOverride = true)) return
if (p.playbackState == Player.STATE_IDLE) {
if (reloadClearedMediaIfNeeded(playWhenReadyOverride = true)) return
if (p.mediaItemCount > 0) {
Log.d(TAG, "play(): player IDLE with media — calling prepare()")
p.prepare()
Expand All @@ -2420,12 +2421,20 @@ class EnhancedPlayerManager private constructor() {
p.play()
}

fun recoverClearedMediaAfterForeground(): Boolean = reloadClearedMediaIfNeeded()
fun recoverClearedMediaAfterForeground(): Boolean = reloadClearedMediaIfNeeded(automatic = true)

private fun reloadClearedMediaIfNeeded(playWhenReadyOverride: Boolean? = null): Boolean {
/** Reloads an emptied player at its saved place, without extracting the video again. */
fun retryClearedMedia(): Boolean = reloadClearedMediaIfNeeded(playWhenReadyOverride = true)

private fun reloadClearedMediaIfNeeded(
playWhenReadyOverride: Boolean? = null,
automatic: Boolean = false,
): Boolean {
val p = player ?: return false
if (p.playbackState != Player.STATE_IDLE || p.mediaItemCount > 0) return false
// Empty is the signal, not IDLE: a prepare() on an emptied player moves it to ENDED.
if (p.mediaItemCount > 0) return false
val recovery = clearedMediaRecoveryState.pendingFor(currentVideoId) ?: return false
if (automatic && !recovery.restoresOnForeground) return false
val resumePosition = recovery.positionMs.takeIf { it > 0L }
val shouldPlay = playWhenReadyOverride ?: recovery.playWhenReady
Log.w(
Expand Down Expand Up @@ -2787,7 +2796,7 @@ class EnhancedPlayerManager private constructor() {
if (currentIsLiveStream) {
switchLiveQuality(height)
} else {
qualityManager?.switchQualityByHeight(height, player?.currentPosition ?: 0L)
qualityManager?.switchQualityByHeight(height, qualitySwitchPositionMs())
}

fun switchQuality(height: Int) = switchQualityByHeight(height)
Expand All @@ -2797,7 +2806,7 @@ class EnhancedPlayerManager private constructor() {
return switchSabrQuality(option)
}
if (currentIsLiveStream) return switchLiveQuality(option.height)
return qualityManager?.switchQuality(option, player?.currentPosition ?: 0L)
return qualityManager?.switchQuality(option, qualitySwitchPositionMs())
}

private fun updateLiveQualityOptions(tracks: Tracks) {
Expand Down Expand Up @@ -3151,19 +3160,35 @@ class EnhancedPlayerManager private constructor() {
if (shouldKeepPlaying) {
switchToAudioOnly()
} else {
if (p.mediaItemCount > 0) {
clearedMediaRecoveryState.capture(
videoId = currentVideoId,
positionMs = p.currentPosition,
playWhenReady = p.playWhenReady,
localFilePath = currentLocalFilePath,
)
}
captureClearedMediaRecovery(p)
p.stop()
p.clearMediaItems()
}
}

private fun captureClearedMediaRecovery(
p: ExoPlayer,
restoresOnForeground: Boolean = true,
) {
if (p.mediaItemCount == 0) return
clearedMediaRecoveryState.capture(
videoId = currentVideoId,
positionMs = p.currentPosition,
playWhenReady = p.playWhenReady,
localFilePath = currentLocalFilePath,
restoresOnForeground = restoresOnForeground,
)
}

private fun qualitySwitchPositionMs(): Long {
val p = player ?: return 0L
return clearedMediaRecoveryState.takeReloadPosition(
videoId = currentVideoId,
playerEmpty = p.mediaItemCount == 0,
currentPositionMs = p.currentPosition,
)
}

fun switchToAudioOnly() {
val p = player ?: return
autoNextLog("switchToAudioOnly")
Expand Down Expand Up @@ -3488,9 +3513,11 @@ class EnhancedPlayerManager private constructor() {
fun handleRefocusStuck(videoId: String?) {
val p = player ?: return
if (isRecoveringStreams(videoId) || hasAbandonedPlayback()) return
if (reloadClearedMediaIfNeeded()) {
if (reloadClearedMediaIfNeeded(automatic = true)) {
return
}
// Emptied by a decoder failure: the error screen's Retry owns the reload, not a resume.
if (p.mediaItemCount == 0 && clearedMediaRecoveryState.pendingFor(videoId) != null) return
errorHandler?.handleRefocusStuck(p, videoId)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,31 @@ import android.content.Context
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector

/** The largest frame the track selector may pick: never above 4K, lower on a small app heap. */
/**
* The largest frame the track selector may pick: never above 4K, and 1080p on a low-RAM device. The
* screen viewport, applied beside it, is what keeps automatic quality near the display's own size.
*/
data class VideoSizeCap(
val maxWidth: Int,
val maxHeight: Int,
) {
companion object {
val UHD = VideoSizeCap(PlayerConfig.MAX_VIDEO_WIDTH, PlayerConfig.MAX_VIDEO_HEIGHT)

fun forHeap(
memoryClassMb: Int,
isLowRamDevice: Boolean,
): VideoSizeCap =
when {
isLowRamDevice || memoryClassMb <= 256 -> VideoSizeCap(1920, 1080)
memoryClassMb <= 384 -> VideoSizeCap(2560, 1440)
else -> UHD
}
// Not keyed to memoryClass: most phones report 256 MB, and frame size does not decide playback
// memory, the buffer byte targets do. A 256 MB bucket held Auto at 1080p everywhere (#1617).
fun forRam(isLowRamDevice: Boolean): VideoSizeCap = if (isLowRamDevice) VideoSizeCap(1920, 1080) else UHD

fun forDevice(context: Context): VideoSizeCap {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
return forHeap(
memoryClassMb = activityManager?.memoryClass ?: 256,
isLowRamDevice = activityManager?.isLowRamDevice == true,
)
return forRam(isLowRamDevice = activityManager?.isLowRamDevice == true)
}
}
}

/**
* Drops every per-quality size constraint and puts the device limits back. Clearing alone would also
* lift the heap cap, and a local file clears the screen viewport, so an automatic pick could climb to
* lift the low-RAM cap, and a local file clears the screen viewport, so an automatic pick could climb to
* a size the device can neither decode comfortably nor show.
*/
@UnstableApi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class PlayerErrorHandler(
private val onStreamExpired: () -> Unit,
private val onPlaybackAbandoned: () -> Unit,
private val onDecoderGaveUp: () -> Unit,
private val rememberPlaybackForRecovery: () -> Unit,
private val onGatedCodecFallback: (Long) -> Boolean,
private val getFailedStreamUrls: () -> Set<String>,
private val markStreamFailed: (String) -> Unit,
Expand Down Expand Up @@ -421,6 +422,8 @@ class PlayerErrorHandler(
}

Log.e(TAG, "Decoder error — no alternatives available, stopping playback")
// A device fault, not a broken stream: keep the place so Play, Retry and a quality change resume it.
rememberPlaybackForRecovery()
onPlaybackShutdown()
stateFlow.value =
stateFlow.value.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ internal data class ClearedMediaRecoverySnapshot(
val videoId: String,
val positionMs: Long,
val playWhenReady: Boolean,
val localFilePath: String?
val localFilePath: String?,
/** False when only an explicit action (Play, Retry) may reload it, as after a decoder failure. */
val restoresOnForeground: Boolean = true,
)

internal class ClearedMediaRecoveryState {
Expand All @@ -14,26 +16,43 @@ internal class ClearedMediaRecoveryState {
videoId: String?,
positionMs: Long,
playWhenReady: Boolean,
localFilePath: String?
localFilePath: String?,
restoresOnForeground: Boolean = true,
) {
if (videoId == null) return
pendingSnapshot = ClearedMediaRecoverySnapshot(
videoId = videoId,
positionMs = positionMs.coerceAtLeast(0L),
playWhenReady = playWhenReady,
localFilePath = localFilePath
)
pendingSnapshot =
ClearedMediaRecoverySnapshot(
videoId = videoId,
positionMs = positionMs.coerceAtLeast(0L),
playWhenReady = playWhenReady,
localFilePath = localFilePath,
restoresOnForeground = restoresOnForeground,
)
}

fun pendingFor(videoId: String?): ClearedMediaRecoverySnapshot? =
pendingSnapshot?.takeIf { it.videoId == videoId }
fun pendingFor(videoId: String?): ClearedMediaRecoverySnapshot? = pendingSnapshot?.takeIf { it.videoId == videoId }

fun complete(snapshot: ClearedMediaRecoverySnapshot) {
if (pendingSnapshot === snapshot) {
pendingSnapshot = null
}
}

/**
* Where a reload should start: the saved position while the player is still empty for [videoId],
* consuming it, otherwise [currentPositionMs]. An emptied player reports 0.
*/
fun takeReloadPosition(
videoId: String?,
playerEmpty: Boolean,
currentPositionMs: Long,
): Long {
val snapshot = if (playerEmpty) pendingFor(videoId) else null
snapshot ?: return currentPositionMs
complete(snapshot)
return snapshot.positionMs
}

fun clear() {
pendingSnapshot = null
}
Expand Down
Loading
Loading