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}