Skip to main content

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}