fix(android): keep the display awake while video plays
Android counts its display timeout from the last user input, and watching
something is exactly the case where there is none — so the screen dimmed and
slept mid-playback unless the user kept tapping it.
Nothing held it. FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither
renderer supplies a hold for free: ExoPlayer's setWakeMode is a CPU/wifi wake
lock that says nothing about the display, and it draws into the TextureView we
own (DR-192) rather than media3's PlayerView, which is the widget that would
otherwise set keepScreenOn itself; the webview <video> path is no better,
because the display wake lock Chrome takes for video lives in the browser layer
and not in an embedded WebView.
ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window
scoped, so it stops applying the moment the app is not visible and cannot
outlive a crash the way an acquired PowerManager.WakeLock can, and it needs no
permission. The two rendering paths are independent holders OR-ed in the pure
ScreenWakeState: the native path follows onIsPlayingChanged plus surface
teardown, so the hold tracks what ExoPlayer reports rather than what the UI
intends, and the webview path reuses the setHtml5VideoState report the frontend
already sends for PiP. Audio is deliberately not a holder — screen-off music is
the point of that path.
Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free,
so the decision is testable off-device with
./gradlew :app:testUniversalDebugUnitTest
(note the variant — plain testDebugUnitTest is ambiguous here). sync-android
-sources.sh mirrors src/test into the gen tree alongside the main sources.
TRACES: UR-003, UR-004 | DR-202 | UT-199
This commit is contained in:
@@ -375,6 +375,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
|
||||
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
|
||||
| DR-201 | A lockscreen skip means different things depending on what is playing, and the backend decides which. `onSkipToNext`/`onSkipToPrevious` forwarded a bare `"next"`/`"previous"` to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next *episode* instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `Advance` or `SeekTo`, and `is_background_audio_active()` is the whole test: the handoff exists only for video, and an episode played through it reports `MediaType::Audio`, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-`seek_absolute` path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do | Playback | UR-040, UR-006 | Done |
|
||||
| DR-202 | Video keeps the display awake. Android counts its display timeout from the last *user input*, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: `FLAG_KEEP_SCREEN_ON` appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's `setWakeMode` is a CPU/wifi wake lock that says nothing about the display, and it draws into the `TextureView` this app owns (DR-192) rather than media3's `PlayerView`, which is the widget that would otherwise set `keepScreenOn` itself; the WebView `<video>` path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. `ScreenWakeManager` toggles `FLAG_KEEP_SCREEN_ON` on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired `PowerManager.WakeLock` can, and it needs no permission (the manifest's `WAKE_LOCK` is the media service's). The two rendering paths are independent holders OR-ed in the pure `ScreenWakeState`: the native path follows `onIsPlayingChanged` plus surface teardown, so the hold tracks what ExoPlayer *reports* rather than what the UI intends, and the webview path reuses the `setHtml5VideoState` report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final `active = false`. Also the repo's first Kotlin JVM unit tests: `ScreenWakeState` is framework-free so the decision is testable off-device with `./gradlew :app:testUniversalDebugUnitTest` | Android | UR-003, UR-004 | Done (pending device verification) |
|
||||
| DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) |
|
||||
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
|
||||
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
|
||||
@@ -675,6 +676,7 @@ Internal architecture, components, and application logic.
|
||||
| UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done |
|
||||
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
|
||||
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
|
||||
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -270,9 +270,10 @@ describe("live requirements.md", () => {
|
||||
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
|
||||
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
|
||||
// merge, where it collided). Each branch bumped for its own — merged,
|
||||
// they sum. Resolve this by summing, never by taking one side.
|
||||
expect(defined.DR).toBe(192);
|
||||
// they sum. Resolve this by summing, never by taking one side. 193 adds
|
||||
// DR-202 (video keeps the display awake).
|
||||
expect(defined.DR).toBe(193);
|
||||
expect(defined.JA).toBe(36);
|
||||
expect(defined.total).toBe(335);
|
||||
expect(defined.total).toBe(336);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,19 @@ rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
|
||||
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
|
||||
cp -r "$SOURCE_DIR/security" "$TARGET_DIR/"
|
||||
|
||||
# JVM unit tests (src/test). Plain JUnit over the pure decision helpers — no
|
||||
# Android framework classes — run with `./gradlew :app:testDebugUnitTest` from
|
||||
# gen/android. Mirrored here so the canonical tree stays the only place tests
|
||||
# are edited.
|
||||
TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/test/java/com/dtourolle/jellytau"
|
||||
TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/test/java/com/dtourolle/jellytau"
|
||||
if [ -d "$TEST_SOURCE_DIR" ]; then
|
||||
rm -rf "$TEST_TARGET_DIR"
|
||||
mkdir -p "$TEST_TARGET_DIR"
|
||||
cp -r "$TEST_SOURCE_DIR"/. "$TEST_TARGET_DIR/"
|
||||
echo " Copied unit tests: src/test"
|
||||
fi
|
||||
|
||||
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
||||
for kt_file in "$SOURCE_DIR"/*.kt; do
|
||||
if [ -f "$kt_file" ]; then
|
||||
|
||||
@@ -85,6 +85,11 @@ class MainActivity : TauriActivity() {
|
||||
super.onWebViewCreate(webView)
|
||||
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
||||
mediaWebView = webView
|
||||
// A new WebView means a new page, which reports no video yet. Anything the
|
||||
// previous one left held would otherwise pin the screen on for the life of
|
||||
// the process, since a page that goes away never sends its final
|
||||
// setHtml5VideoState(false, …). (DR-202)
|
||||
ScreenWakeManager.releaseAll()
|
||||
installJavascriptBridges(webView)
|
||||
configureWebViewSettings(webView)
|
||||
}
|
||||
@@ -115,6 +120,11 @@ class MainActivity : TauriActivity() {
|
||||
// TRACES: UR-003, UR-041 | DR-151
|
||||
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
|
||||
|
||||
// The window whose FLAG_KEEP_SCREEN_ON is toggled while video plays. Set on
|
||||
// every onCreate so a recreated Activity (rotation) re-applies the current
|
||||
// hold to its new window. (UR-003, DR-202)
|
||||
ScreenWakeManager.setActivity(this)
|
||||
|
||||
// Configure WebView for media playback after Tauri initialization
|
||||
handler.postDelayed({
|
||||
configureWebViewForMedia()
|
||||
@@ -188,6 +198,7 @@ class MainActivity : TauriActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
ScreenWakeManager.clearActivity(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -311,6 +322,10 @@ class MainActivity : TauriActivity() {
|
||||
@JavascriptInterface
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
||||
// The same report is what keeps the display awake on the webview
|
||||
// rendering path — the WebView takes no display wake lock of its own
|
||||
// for `<video>`. (DR-202)
|
||||
ScreenWakeManager.onHtml5VideoState(active, playing)
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.WindowManager
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Which playback paths currently want the screen kept awake.
|
||||
*
|
||||
* Pure state, deliberately free of any Android type so it can be unit-tested —
|
||||
* see ScreenWakeStateTest. Two independent holders, because video can be
|
||||
* rendered by either renderer and only one of them is active at a time:
|
||||
*
|
||||
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
|
||||
* - **html5** — a `<video>` inside the WebView, reported by the frontend
|
||||
*
|
||||
* Audio is deliberately *not* a holder. Playing music with the screen off is the
|
||||
* point of the audio path; only video needs the display alive.
|
||||
*
|
||||
* TRACES: UR-003 | DR-202 | UT-199
|
||||
*/
|
||||
class ScreenWakeState {
|
||||
private var nativeVideoPlaying = false
|
||||
private var html5VideoPlaying = false
|
||||
|
||||
/** True while any video renderer is actively playing. */
|
||||
val keepScreenOn: Boolean
|
||||
get() = nativeVideoPlaying || html5VideoPlaying
|
||||
|
||||
/**
|
||||
* @param playing whether ExoPlayer is playing right now
|
||||
* @param isVideo whether what it is playing is video rather than audio
|
||||
*/
|
||||
fun updateNative(playing: Boolean, isVideo: Boolean) {
|
||||
nativeVideoPlaying = playing && isVideo
|
||||
}
|
||||
|
||||
/**
|
||||
* @param active whether a webview `<video>` is the current playback surface
|
||||
* @param playing whether that element is playing right now
|
||||
*/
|
||||
fun updateHtml5(active: Boolean, playing: Boolean) {
|
||||
html5VideoPlaying = active && playing
|
||||
}
|
||||
|
||||
/** Drop every hold (teardown, or a page that can no longer be trusted). */
|
||||
fun reset() {
|
||||
nativeVideoPlaying = false
|
||||
html5VideoPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the display awake while video is playing.
|
||||
*
|
||||
* TRACES: UR-003 | DR-202
|
||||
*
|
||||
* ## Why this is needed at all
|
||||
*
|
||||
* Android turns the screen off on its own display timeout, counted from the last
|
||||
* *user input*. Watching a film is precisely the case where there is none, so
|
||||
* without an explicit hold the screen dimmed and slept mid-playback and the user
|
||||
* had to keep tapping it. Nothing in the app held it: `FLAG_KEEP_SCREEN_ON`
|
||||
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
|
||||
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
|
||||
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
|
||||
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
|
||||
* `<video>` path does not either: the display wake lock Chrome takes for video
|
||||
* lives in the browser layer, not in an embedded WebView.
|
||||
*
|
||||
* ## Approach
|
||||
*
|
||||
* `FLAG_KEEP_SCREEN_ON` on the Activity window rather than a
|
||||
* `PowerManager.WakeLock`: the flag is scoped to the window, so it stops
|
||||
* applying the moment the app is not visible and cannot survive a crash or a
|
||||
* missed release the way an explicitly acquired wake lock can. It needs no
|
||||
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
|
||||
*
|
||||
* The two renderers report independently and are OR-ed together in
|
||||
* [ScreenWakeState]:
|
||||
*
|
||||
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
|
||||
* native path — ExoPlayer is the authoritative source of playback state, so
|
||||
* the hold follows what it reports rather than what the UI intends.
|
||||
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
|
||||
* the webview path. The frontend already reports that state on every
|
||||
* play/pause and on player teardown for PiP, so no new bridge is needed.
|
||||
*
|
||||
* The Activity reference is weak and re-set on every `onCreate`, so a
|
||||
* recreation (rotation) re-applies the current hold to the new window.
|
||||
*/
|
||||
object ScreenWakeManager {
|
||||
|
||||
private const val TAG = "ScreenWakeManager"
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val state = ScreenWakeState()
|
||||
private var activityRef: WeakReference<Activity>? = null
|
||||
|
||||
/**
|
||||
* Adopt the Activity whose window carries the flag, and re-apply the current
|
||||
* hold to it. Called from `MainActivity.onCreate`, so a rotation-recreated
|
||||
* Activity keeps the screen awake without waiting for the next state report.
|
||||
*/
|
||||
@Synchronized
|
||||
fun setActivity(activity: Activity) {
|
||||
activityRef = WeakReference(activity)
|
||||
apply()
|
||||
}
|
||||
|
||||
/** Drop the Activity on destroy, unless a newer one has already replaced it. */
|
||||
@Synchronized
|
||||
fun clearActivity(activity: Activity) {
|
||||
if (activityRef?.get() === activity) {
|
||||
activityRef = null
|
||||
}
|
||||
}
|
||||
|
||||
/** ExoPlayer's playback state changed. */
|
||||
@Synchronized
|
||||
fun onNativePlaybackChanged(playing: Boolean, isVideo: Boolean) {
|
||||
state.updateNative(playing, isVideo)
|
||||
apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The frontend reported the webview `<video>` state. Arrives on a WebView
|
||||
* binder thread, hence the synchronization and the post to the main thread.
|
||||
*/
|
||||
@Synchronized
|
||||
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
|
||||
state.updateHtml5(active, playing)
|
||||
apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every hold. Used when a new WebView/page load invalidates whatever the
|
||||
* previous page last reported — a page that goes away without a final
|
||||
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
|
||||
* for the life of the process.
|
||||
*/
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
state.reset()
|
||||
apply()
|
||||
}
|
||||
|
||||
private fun apply() {
|
||||
val desired = state.keepScreenOn
|
||||
val activity = activityRef?.get() ?: return
|
||||
mainHandler.post {
|
||||
try {
|
||||
if (activity.isFinishing || activity.isDestroyed) return@post
|
||||
if (desired) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
android.util.Log.d(TAG, "keepScreenOn = $desired")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to apply keep-screen-on flag", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
val state = if (isPlaying) "playing" else "paused"
|
||||
nativeOnStateChanged(state, currentMediaId)
|
||||
|
||||
// Hold the display awake for video, release it for a pause or for
|
||||
// audio: the display timeout counts from the last user input, and
|
||||
// watching something is exactly when there is none. (DR-202)
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(
|
||||
isPlaying,
|
||||
currentMediaType == MediaType.VIDEO
|
||||
)
|
||||
|
||||
if (isPlaying) {
|
||||
startPositionUpdates()
|
||||
} else {
|
||||
@@ -1027,6 +1035,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
fun release() {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||
coroutineScope.cancel()
|
||||
releaseAudioEffects()
|
||||
exoPlayer.release()
|
||||
@@ -1324,6 +1333,10 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* TRACES: UR-003, UR-041 | DR-184
|
||||
*/
|
||||
private fun clearVideoSurface() {
|
||||
// Whatever happens to the view, video is no longer what is on screen, so
|
||||
// the display hold goes with it. Outside the let: the hold must be
|
||||
// released even when no view was ever created. (DR-202)
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||
videoView?.let {
|
||||
exoPlayer.clearVideoSurface()
|
||||
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The screen-wake decision, isolated from the Activity window it is applied to.
|
||||
*
|
||||
* TRACES: UR-003 | DR-202 | UT-199
|
||||
*/
|
||||
class ScreenWakeStateTest {
|
||||
|
||||
@Test
|
||||
fun `starts released`() {
|
||||
assertFalse(ScreenWakeState().keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native video playing holds the screen on`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
assertTrue(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing native video releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateNative(playing = false, isVideo = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** Music with the screen off is the whole point of the audio path. */
|
||||
@Test
|
||||
fun `native audio playing does not hold the screen on`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = false)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview video playing holds the screen on`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
assertTrue(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview video paused releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = true, playing = false)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** The element going away must release even if it never reported a pause. */
|
||||
@Test
|
||||
fun `webview video going inactive while playing releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = false, playing = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** The two rendering paths are independent holders; either one is enough. */
|
||||
@Test
|
||||
fun `one path releasing does not release while the other still plays`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = false, playing = false)
|
||||
assertTrue(state.keepScreenOn)
|
||||
state.updateNative(playing = false, isVideo = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teardown releases both paths`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.reset()
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user