jellytau_lib/player/background_policy.rs
1//! What playback should do when the app stops being visible.
2//!
3//! TRACES: UR-040 | DR-224 | UT-211
4//!
5//! # The defect this exists for
6//!
7//! The per-player background-audio toggle (UR-040) was built for the WebView
8//! `<video>` path, where backgrounding the app kills the decode: the toggle
9//! decided whether to *hand off* to a native audio stream or let playback die.
10//!
11//! Native video then became the default renderer (DR-188). On that path playback
12//! runs through ExoPlayer inside a `MediaSessionService` — a foreground media
13//! service whose entire purpose is to keep playing when the app is not visible.
14//! Nothing stops it, and nothing in the codebase paused playback on background.
15//!
16//! So locking the screen kept the audio playing **whether or not the toggle was
17//! on**. The toggle governed a handoff that no longer had anything to hand off
18//! *from*: there was no gap in playback to bridge. A user who had never touched
19//! it got background audio anyway, which is the bug as reported.
20//!
21//! # Why the decision lives in Rust
22//!
23//! It depends on what the item *is* (a video keeps its picture; music has none
24//! to lose) and on a user setting — domain questions, not presentation ones, and
25//! the answer must be identical for both renderers. The frontend and the Android
26//! activity carry it out; neither decides it. Putting the rule in either would
27//! have reproduced exactly the split that caused this: two renderers, two
28//! behaviours, one toggle that only reached one of them.
29
30use crate::player::media::MediaType;
31
32/// What the player should do when the app is backgrounded.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
34#[serde(rename_all = "camelCase")]
35pub enum BackgroundAction {
36 /// Carry on. Music, and video the user explicitly asked to keep hearing
37 /// while it is in a picture-in-picture window.
38 KeepPlaying,
39 /// Swap the video stream for an audio-only one and keep playing.
40 HandOffToAudio,
41 /// Stop making sound. The user did not ask for background playback.
42 Pause,
43}
44
45/// Decide what backgrounding should do.
46///
47/// * `is_video` — whether the current item has a picture to lose. Music is
48/// never paused by backgrounding; that is what a music player is for.
49/// * `background_audio_armed` — the per-player toggle (UR-040).
50/// * `in_picture_in_picture` — the app is not "gone", it is in a floating
51/// window and still visible. Pausing here would break PiP (UR-041).
52///
53/// TRACES: UR-040, UR-041 | DR-224 | UT-211
54pub fn background_action(
55 is_video: bool,
56 background_audio_armed: bool,
57 in_picture_in_picture: bool,
58) -> BackgroundAction {
59 // PiP first: the window is still on screen, so this is not backgrounding in
60 // any sense the user would recognise.
61 if in_picture_in_picture {
62 return BackgroundAction::KeepPlaying;
63 }
64
65 // Music has no picture to lose; a music player that stopped when the screen
66 // locked would be broken in an obvious way.
67 if !is_video {
68 return BackgroundAction::KeepPlaying;
69 }
70
71 if background_audio_armed {
72 BackgroundAction::HandOffToAudio
73 } else {
74 BackgroundAction::Pause
75 }
76}
77
78/// Whether a media type has a picture that backgrounding would throw away.
79///
80/// TRACES: UR-040 | DR-224
81pub fn is_video_media(media_type: MediaType) -> bool {
82 matches!(media_type, MediaType::Video)
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn video_without_the_toggle_pauses() {
91 // THE REPORTED BUG. Native video runs in a foreground media service that
92 // keeps playing when the app is hidden, and nothing paused it -- so
93 // locking the screen gave background audio to a user who never asked
94 // for it.
95 assert_eq!(
96 background_action(true, false, false),
97 BackgroundAction::Pause
98 );
99 }
100
101 #[test]
102 fn video_with_the_toggle_hands_off_to_audio() {
103 assert_eq!(
104 background_action(true, true, false),
105 BackgroundAction::HandOffToAudio
106 );
107 }
108
109 #[test]
110 fn music_always_keeps_playing() {
111 // Backgrounding a music player and having it stop would be absurd. The
112 // toggle is irrelevant here: there is no picture to give up.
113 assert_eq!(
114 background_action(false, false, false),
115 BackgroundAction::KeepPlaying
116 );
117 assert_eq!(
118 background_action(false, true, false),
119 BackgroundAction::KeepPlaying
120 );
121 }
122
123 #[test]
124 fn picture_in_picture_is_not_backgrounding() {
125 // The video is in a floating window and still on screen. Pausing would
126 // break PiP (UR-041), which is a separate feature reached through
127 // onUserLeaveHint rather than onStop.
128 assert_eq!(
129 background_action(true, false, true),
130 BackgroundAction::KeepPlaying
131 );
132 assert_eq!(
133 background_action(true, true, true),
134 BackgroundAction::KeepPlaying
135 );
136 }
137
138 #[test]
139 fn the_rule_does_not_depend_on_the_renderer() {
140 // There is deliberately no renderer parameter. The WebView path and the
141 // native path must answer identically -- the split between them is what
142 // produced the defect, because the toggle only ever reached one.
143 for armed in [true, false] {
144 let once = background_action(true, armed, false);
145 let again = background_action(true, armed, false);
146 assert_eq!(once, again);
147 }
148 }
149
150 #[test]
151 fn only_video_counts_as_having_a_picture() {
152 assert!(is_video_media(MediaType::Video));
153 assert!(!is_video_media(MediaType::Audio));
154 }
155}