From d3837ce407fd5a9172c138a70e5d3ffa8c8ec425 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 11:20:58 +0200 Subject: [PATCH 01/29] build: replace ExoPlayer 2.18.7 with media3 1.4.1 dependencies --- app/build.gradle | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 421e7d3b9..fc0151f21 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -137,7 +137,7 @@ ext { androidxRoomVersion = '2.4.2' androidxWorkVersion = '2.10.2' - exoPlayerVersion = '2.18.7' + media3Version = '1.4.1' googleAutoServiceVersion = '1.0.1' groupieVersion = '2.10.0' markwonVersion = '4.6.2' @@ -271,8 +271,14 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:3.12.13" // Media player - implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}" - implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}" + implementation "androidx.media3:media3-exoplayer:${media3Version}" + implementation "androidx.media3:media3-exoplayer-dash:${media3Version}" + implementation "androidx.media3:media3-exoplayer-hls:${media3Version}" + implementation "androidx.media3:media3-exoplayer-smoothstreaming:${media3Version}" + implementation "androidx.media3:media3-datasource:${media3Version}" + implementation "androidx.media3:media3-ui:${media3Version}" + implementation "androidx.media3:media3-session:${media3Version}" + implementation "androidx.media3:media3-common:${media3Version}" // Metadata generator for service descriptors compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}" From 2957c0e027025749996db8b047aa52bb9648a22a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 11:20:58 +0200 Subject: [PATCH 02/29] feat(player): vendor exo_icon_/exo_media_action_ drawables for media3-ui --- .../exo_icon_fastforward.xml | 25 +++++++++++++++++ .../exo_icon_fullscreen_enter.xml | 24 ++++++++++++++++ .../exo_icon_fullscreen_exit.xml | 24 ++++++++++++++++ .../res/drawable-anydpi-v21/exo_icon_next.xml | 25 +++++++++++++++++ .../drawable-anydpi-v21/exo_icon_pause.xml | 25 +++++++++++++++++ .../res/drawable-anydpi-v21/exo_icon_play.xml | 25 +++++++++++++++++ .../drawable-anydpi-v21/exo_icon_previous.xml | 25 +++++++++++++++++ .../exo_icon_repeat_all.xml | 23 ++++++++++++++++ .../exo_icon_repeat_off.xml | 23 ++++++++++++++++ .../exo_icon_repeat_one.xml | 23 ++++++++++++++++ .../drawable-anydpi-v21/exo_icon_rewind.xml | 25 +++++++++++++++++ .../exo_icon_shuffle_off.xml | 26 ++++++++++++++++++ .../exo_icon_shuffle_on.xml | 26 ++++++++++++++++++ .../res/drawable-anydpi-v21/exo_icon_stop.xml | 13 +++++++++ .../exo_media_action_repeat_all.xml | 23 ++++++++++++++++ .../exo_media_action_repeat_off.xml | 23 ++++++++++++++++ .../exo_media_action_repeat_one.xml | 23 ++++++++++++++++ .../exo_icon_circular_play.png | Bin 0 -> 995 bytes .../drawable-hdpi-v4/exo_icon_fastforward.png | Bin 0 -> 283 bytes .../exo_icon_fullscreen_enter.png | Bin 0 -> 107 bytes .../exo_icon_fullscreen_exit.png | Bin 0 -> 105 bytes .../res/drawable-hdpi-v4/exo_icon_next.png | Bin 0 -> 264 bytes .../res/drawable-hdpi-v4/exo_icon_pause.png | Bin 0 -> 90 bytes .../res/drawable-hdpi-v4/exo_icon_play.png | Bin 0 -> 233 bytes .../drawable-hdpi-v4/exo_icon_previous.png | Bin 0 -> 255 bytes .../drawable-hdpi-v4/exo_icon_repeat_all.png | Bin 0 -> 203 bytes .../drawable-hdpi-v4/exo_icon_repeat_off.png | Bin 0 -> 223 bytes .../drawable-hdpi-v4/exo_icon_repeat_one.png | Bin 0 -> 223 bytes .../res/drawable-hdpi-v4/exo_icon_rewind.png | Bin 0 -> 332 bytes .../drawable-hdpi-v4/exo_icon_shuffle_off.png | Bin 0 -> 265 bytes .../drawable-hdpi-v4/exo_icon_shuffle_on.png | Bin 0 -> 268 bytes .../res/drawable-hdpi-v4/exo_icon_stop.png | Bin 0 -> 90 bytes .../main/res/drawable-hdpi-v4/exo_icon_vr.png | Bin 0 -> 291 bytes .../exo_media_action_repeat_all.png | Bin 0 -> 203 bytes .../exo_media_action_repeat_off.png | Bin 0 -> 223 bytes .../exo_media_action_repeat_one.png | Bin 0 -> 223 bytes .../exo_icon_circular_play.png | Bin 0 -> 457 bytes .../drawable-ldpi-v4/exo_icon_fastforward.png | Bin 0 -> 173 bytes .../exo_icon_fullscreen_enter.png | Bin 0 -> 139 bytes .../exo_icon_fullscreen_exit.png | Bin 0 -> 146 bytes .../res/drawable-ldpi-v4/exo_icon_next.png | Bin 0 -> 152 bytes .../res/drawable-ldpi-v4/exo_icon_pause.png | Bin 0 -> 88 bytes .../res/drawable-ldpi-v4/exo_icon_play.png | Bin 0 -> 167 bytes .../drawable-ldpi-v4/exo_icon_previous.png | Bin 0 -> 161 bytes .../drawable-ldpi-v4/exo_icon_repeat_all.png | Bin 0 -> 142 bytes .../drawable-ldpi-v4/exo_icon_repeat_off.png | Bin 0 -> 166 bytes .../drawable-ldpi-v4/exo_icon_repeat_one.png | Bin 0 -> 160 bytes .../res/drawable-ldpi-v4/exo_icon_rewind.png | Bin 0 -> 186 bytes .../drawable-ldpi-v4/exo_icon_shuffle_off.png | Bin 0 -> 182 bytes .../drawable-ldpi-v4/exo_icon_shuffle_on.png | Bin 0 -> 187 bytes .../res/drawable-ldpi-v4/exo_icon_stop.png | Bin 0 -> 88 bytes .../main/res/drawable-ldpi-v4/exo_icon_vr.png | Bin 0 -> 170 bytes .../exo_media_action_repeat_all.png | Bin 0 -> 142 bytes .../exo_media_action_repeat_off.png | Bin 0 -> 166 bytes .../exo_media_action_repeat_one.png | Bin 0 -> 160 bytes .../exo_icon_circular_play.png | Bin 0 -> 672 bytes .../drawable-mdpi-v4/exo_icon_fastforward.png | Bin 0 -> 217 bytes .../exo_icon_fullscreen_enter.png | Bin 0 -> 101 bytes .../exo_icon_fullscreen_exit.png | Bin 0 -> 101 bytes .../res/drawable-mdpi-v4/exo_icon_next.png | Bin 0 -> 209 bytes .../res/drawable-mdpi-v4/exo_icon_pause.png | Bin 0 -> 140 bytes .../res/drawable-mdpi-v4/exo_icon_play.png | Bin 0 -> 200 bytes .../drawable-mdpi-v4/exo_icon_previous.png | Bin 0 -> 180 bytes .../drawable-mdpi-v4/exo_icon_repeat_all.png | Bin 0 -> 210 bytes .../drawable-mdpi-v4/exo_icon_repeat_off.png | Bin 0 -> 227 bytes .../drawable-mdpi-v4/exo_icon_repeat_one.png | Bin 0 -> 232 bytes .../res/drawable-mdpi-v4/exo_icon_rewind.png | Bin 0 -> 217 bytes .../drawable-mdpi-v4/exo_icon_shuffle_off.png | Bin 0 -> 228 bytes .../drawable-mdpi-v4/exo_icon_shuffle_on.png | Bin 0 -> 230 bytes .../res/drawable-mdpi-v4/exo_icon_stop.png | Bin 0 -> 97 bytes .../main/res/drawable-mdpi-v4/exo_icon_vr.png | Bin 0 -> 207 bytes .../exo_media_action_repeat_all.png | Bin 0 -> 210 bytes .../exo_media_action_repeat_off.png | Bin 0 -> 227 bytes .../exo_media_action_repeat_one.png | Bin 0 -> 232 bytes .../exo_icon_circular_play.png | Bin 0 -> 1322 bytes .../exo_icon_fastforward.png | Bin 0 -> 386 bytes .../exo_icon_fullscreen_enter.png | Bin 0 -> 109 bytes .../exo_icon_fullscreen_exit.png | Bin 0 -> 106 bytes .../res/drawable-xhdpi-v4/exo_icon_next.png | Bin 0 -> 269 bytes .../res/drawable-xhdpi-v4/exo_icon_pause.png | Bin 0 -> 164 bytes .../res/drawable-xhdpi-v4/exo_icon_play.png | Bin 0 -> 270 bytes .../drawable-xhdpi-v4/exo_icon_previous.png | Bin 0 -> 328 bytes .../drawable-xhdpi-v4/exo_icon_repeat_all.png | Bin 0 -> 288 bytes .../drawable-xhdpi-v4/exo_icon_repeat_off.png | Bin 0 -> 322 bytes .../drawable-xhdpi-v4/exo_icon_repeat_one.png | Bin 0 -> 331 bytes .../res/drawable-xhdpi-v4/exo_icon_rewind.png | Bin 0 -> 391 bytes .../exo_icon_shuffle_off.png | Bin 0 -> 342 bytes .../drawable-xhdpi-v4/exo_icon_shuffle_on.png | Bin 0 -> 342 bytes .../res/drawable-xhdpi-v4/exo_icon_stop.png | Bin 0 -> 91 bytes .../res/drawable-xhdpi-v4/exo_icon_vr.png | Bin 0 -> 375 bytes .../exo_media_action_repeat_all.png | Bin 0 -> 288 bytes .../exo_media_action_repeat_off.png | Bin 0 -> 322 bytes .../exo_media_action_repeat_one.png | Bin 0 -> 331 bytes .../exo_icon_circular_play.png | Bin 0 -> 2020 bytes .../exo_icon_fastforward.png | Bin 0 -> 575 bytes .../exo_icon_fullscreen_enter.png | Bin 0 -> 123 bytes .../exo_icon_fullscreen_exit.png | Bin 0 -> 123 bytes .../res/drawable-xxhdpi-v4/exo_icon_next.png | Bin 0 -> 385 bytes .../res/drawable-xxhdpi-v4/exo_icon_pause.png | Bin 0 -> 111 bytes .../res/drawable-xxhdpi-v4/exo_icon_play.png | Bin 0 -> 378 bytes .../drawable-xxhdpi-v4/exo_icon_previous.png | Bin 0 -> 458 bytes .../exo_icon_repeat_all.png | Bin 0 -> 266 bytes .../exo_icon_repeat_off.png | Bin 0 -> 309 bytes .../exo_icon_repeat_one.png | Bin 0 -> 309 bytes .../drawable-xxhdpi-v4/exo_icon_rewind.png | Bin 0 -> 561 bytes .../exo_icon_shuffle_off.png | Bin 0 -> 438 bytes .../exo_icon_shuffle_on.png | Bin 0 -> 436 bytes .../res/drawable-xxhdpi-v4/exo_icon_stop.png | Bin 0 -> 95 bytes .../res/drawable-xxhdpi-v4/exo_icon_vr.png | Bin 0 -> 602 bytes .../exo_media_action_repeat_all.png | Bin 0 -> 266 bytes .../exo_media_action_repeat_off.png | Bin 0 -> 309 bytes .../exo_media_action_repeat_one.png | Bin 0 -> 309 bytes .../exo_icon_circular_play.png | Bin 0 -> 2683 bytes .../exo_icon_fastforward.png | Bin 0 -> 854 bytes .../exo_icon_fullscreen_enter.png | Bin 0 -> 1004 bytes .../exo_icon_fullscreen_exit.png | Bin 0 -> 1042 bytes .../res/drawable-xxxhdpi-v4/exo_icon_next.png | Bin 0 -> 799 bytes .../drawable-xxxhdpi-v4/exo_icon_pause.png | Bin 0 -> 557 bytes .../res/drawable-xxxhdpi-v4/exo_icon_play.png | Bin 0 -> 667 bytes .../drawable-xxxhdpi-v4/exo_icon_previous.png | Bin 0 -> 1018 bytes .../exo_icon_repeat_all.png | Bin 0 -> 922 bytes .../exo_icon_repeat_off.png | Bin 0 -> 840 bytes .../exo_icon_repeat_one.png | Bin 0 -> 1026 bytes .../drawable-xxxhdpi-v4/exo_icon_rewind.png | Bin 0 -> 830 bytes .../exo_icon_shuffle_off.png | Bin 0 -> 1176 bytes .../exo_icon_shuffle_on.png | Bin 0 -> 1188 bytes .../res/drawable-xxxhdpi-v4/exo_icon_stop.png | Bin 0 -> 274 bytes .../exo_media_action_repeat_all.png | Bin 0 -> 922 bytes .../exo_media_action_repeat_off.png | Bin 0 -> 840 bytes .../exo_media_action_repeat_one.png | Bin 0 -> 1026 bytes 130 files changed, 401 insertions(+) create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_fastforward.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_enter.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_exit.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_next.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_pause.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_play.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_previous.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_all.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_off.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_one.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_rewind.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_off.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_on.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_icon_stop.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_all.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_off.xml create mode 100644 app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_one.xml create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_circular_play.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_fastforward.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_fullscreen_enter.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_fullscreen_exit.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_next.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_pause.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_play.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_previous.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_repeat_all.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_repeat_off.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_repeat_one.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_rewind.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_shuffle_off.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_shuffle_on.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_stop.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_icon_vr.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_media_action_repeat_all.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_media_action_repeat_off.png create mode 100644 app/src/main/res/drawable-hdpi-v4/exo_media_action_repeat_one.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_circular_play.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_fastforward.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_fullscreen_enter.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_fullscreen_exit.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_next.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_pause.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_play.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_previous.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_repeat_all.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_repeat_off.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_repeat_one.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_rewind.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_shuffle_off.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_shuffle_on.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_stop.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_icon_vr.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_media_action_repeat_all.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_media_action_repeat_off.png create mode 100644 app/src/main/res/drawable-ldpi-v4/exo_media_action_repeat_one.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_circular_play.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_fastforward.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_fullscreen_enter.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_fullscreen_exit.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_next.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_pause.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_play.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_previous.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_repeat_all.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_repeat_off.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_repeat_one.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_rewind.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_shuffle_off.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_shuffle_on.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_stop.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_icon_vr.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_all.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_off.png create mode 100644 app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_one.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_circular_play.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_fastforward.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_fullscreen_enter.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_fullscreen_exit.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_next.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_pause.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_play.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_previous.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_repeat_all.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_repeat_off.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_repeat_one.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_rewind.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_shuffle_off.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_shuffle_on.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_stop.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_icon_vr.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_media_action_repeat_all.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_media_action_repeat_off.png create mode 100644 app/src/main/res/drawable-xhdpi-v4/exo_media_action_repeat_one.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_circular_play.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_fastforward.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_fullscreen_enter.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_fullscreen_exit.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_next.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_pause.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_play.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_previous.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_all.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_off.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_one.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_rewind.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_shuffle_off.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_shuffle_on.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_stop.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_icon_vr.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_media_action_repeat_all.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_media_action_repeat_off.png create mode 100644 app/src/main/res/drawable-xxhdpi-v4/exo_media_action_repeat_one.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_circular_play.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_fastforward.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_fullscreen_enter.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_fullscreen_exit.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_next.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_pause.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_play.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_previous.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_repeat_all.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_repeat_off.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_repeat_one.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_rewind.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_shuffle_off.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_shuffle_on.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_icon_stop.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_all.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_off.png create mode 100644 app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_one.png diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_fastforward.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_fastforward.xml new file mode 100644 index 000000000..4b86e109e --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_fastforward.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_enter.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_enter.xml new file mode 100644 index 000000000..db11f299b --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_enter.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_exit.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_exit.xml new file mode 100644 index 000000000..782d9ff34 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_fullscreen_exit.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_next.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_next.xml new file mode 100644 index 000000000..6305bcbc9 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_next.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_pause.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_pause.xml new file mode 100644 index 000000000..45cd68bed --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_pause.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_play.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_play.xml new file mode 100644 index 000000000..c8c4cdb12 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_play.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_previous.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_previous.xml new file mode 100644 index 000000000..9564a2a35 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_previous.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_all.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_all.xml new file mode 100644 index 000000000..dad37fa1f --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_all.xml @@ -0,0 +1,23 @@ + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_off.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_off.xml new file mode 100644 index 000000000..132eae0d7 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_off.xml @@ -0,0 +1,23 @@ + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_one.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_one.xml new file mode 100644 index 000000000..d51010566 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_repeat_one.xml @@ -0,0 +1,23 @@ + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_rewind.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_rewind.xml new file mode 100644 index 000000000..976b70617 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_rewind.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_off.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_off.xml new file mode 100644 index 000000000..283ce30c3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_off.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_on.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_on.xml new file mode 100644 index 000000000..123c06c43 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_shuffle_on.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_icon_stop.xml b/app/src/main/res/drawable-anydpi-v21/exo_icon_stop.xml new file mode 100644 index 000000000..2e1e40cbb --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_icon_stop.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_all.xml b/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_all.xml new file mode 100644 index 000000000..dad37fa1f --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_all.xml @@ -0,0 +1,23 @@ + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_off.xml b/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_off.xml new file mode 100644 index 000000000..132eae0d7 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_off.xml @@ -0,0 +1,23 @@ + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_one.xml b/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_one.xml new file mode 100644 index 000000000..d51010566 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_one.xml @@ -0,0 +1,23 @@ + + + + diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_circular_play.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_circular_play.png new file mode 100644 index 0000000000000000000000000000000000000000..ecf3df3cb17d34f1737fd54fc4fbeba558018d36 GIT binary patch literal 995 zcmV<9104K`P)_=93O49-$`l z5>_|dT;VFjGQ=FAIaeS_SY6&6yH60yBy(B77f7oMIR(ZOL(W{?FY*UUSlwlax`Qqv zxy4++NEpapT@zJAy@^Q1AnKR?KmpZ#gQ%m36xBCMHRM&y1I}c`{p721~SJvyT z?BFcw5B-b!V2piF_}FK^G4>|h7(ehG9beIy#8oNZBI-yYxfxMc(HKWnj>GjvBI!1R ze{sS8g_asI`&&VPla2R{YYBkJSC(x2UkI+R#URenaAcVC$X z{EDb6$Pei24%^xf(&`d15eCd3gmv<#HTMGVi7fGJQ&4!XEd6R<1!%SwxAf zIDsXIx{oVx^0|rhD=lO}D}UHKC2j_o!4%#nJxzRMe=SqN4-X(VQtDrNA0x(lNXTo5 z@mESI){ZSpc#*`k^Dkmt#D#=#2}=;;U$hZGf!T<0H!o5k2}NFE4`Q52jzB6JNo6I& z$>X8GC{`nNamu8iz<)@c>v)sPXo{Jr%Xv#D)s~ebCGEV5^l6oE7|iXQPMMCHWlraI z2J;r@Q!xD>&d( zc!KflL}J{*I3B0WL8ObD7{!N7WieHD)~vFaseHglZeag77zQaA1*2dT008y_=SL%k Rgw+55002ovPDHLkV1k*n&J+Lu literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_fastforward.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_fastforward.png new file mode 100644 index 0000000000000000000000000000000000000000..5699614c6b57ccdb83eef3e147759a6d2712e144 GIT binary patch literal 283 zcmV+$0p$LPP)@E!Q3-SxXAgBC-{DLsZF9?Uha5xACgTd3y;d}3;!T0j- z?vd~Q-zY9FE^dh9LSh(wK*3xv3qJrY2gUdSfG1hdyvY0j)NBN^^#cG`Cc1F~@FfqL zlH3nK$B9HXMgZW=OgByd8g@bRW9J6|9;88YC-nnRu~Lj5XxJ;p4ZN8J&8Ig`pyN!U z87EMZ3ufs)(<_spx%I{g)NCE223}->nR>^rn(+b`62XkUFSBALnDxjT h4^qPndU0`aaev93S;ei1XQluE002ovPDHLkV1hR$a_Im7 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_fullscreen_enter.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_fullscreen_enter.png new file mode 100644 index 0000000000000000000000000000000000000000..9b8131124d7cb5a540f50e963b1940737574d5cd GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^9w5vJBp7O^^}Pa8OeH~n!3+##lh0ZJc`BYRjv*C{ z$r2$AKmW_K{r=B>BDt19=+BlEUV#Pe_cgeFA7o*e-4OJ%*1*LDWQwP&pUXO@geCxg CH6INC literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_fullscreen_exit.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..159bea7fd8a47a129f63ce2e1208003beb7e49a9 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^9w5vJBp7O^^}Pa8OeH~n!3+##lh0ZJc}kuxjv*C{ z$r33K_?vBeBhUPAT=Sp(L;^3zf-`4JI4&P#Ww^WCBX{DIiBcdlJYD@<);T3K0RT_o BA0_|* literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_next.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_next.png new file mode 100644 index 0000000000000000000000000000000000000000..303e896187f42c67f2c74aa4bf290ca13160a98f GIT binary patch literal 264 zcmV+j0r&oiP)(qMR63y zLG{1r0tg_000Ic0WdQ_GN;#`;st-^`YXSJLS1TsK`=}!q;N8@M3Gm)(#|3y7HDdz2 zr`j+9-dCMe#{_r}wPXUkLG8H!@2ciZfcH{cGGI^#D8OH65}^7?B>}2GH5kBW1qo0c zb~M1Bmov9~Dz O0000zopr0A|D{vfA zh@bF2S&r@Jp*c+o%MF$r#Lv+GvF|~$0E>K_wN<?57n5$vTzu@@Bh2_7xrUTv1;OXk;vd$@?2>_+$SFr#9 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_previous.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..2c3b3af982afd2bb1bb23da18c54c4d691addff3 GIT binary patch literal 255 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtW1cRKAr*{C1^OQtwUDmuGU$n15Ye(BPG`PJ-BufCFnTG&WcsNntuB xF_TTAxke`;Y=X@JjmFrVrAuKB5a$2Ecv$6xXWhk|Wk4S>c)I$ztaD0e0sxgKS)Kp@ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_repeat_all.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_repeat_all.png new file mode 100644 index 0000000000000000000000000000000000000000..2824e7847c490a8f3a79c10e95fc7b4425480f36 GIT binary patch literal 203 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtNuDl_Ar*{ouWaOP2oN}Y;r0)= z#3uo}mj2+-IV$|bbY}gPDDO>W4p%xZWHDyBoap4(+kB#Yt++!rdrT0+<9_C61^mZm zGxUfvN$9f}#Bn6-5js$z+`yHwmeX%hgNinbft_ay!!d0}sET{634h#I9-q$?lf}TH v{D3jZr*VOzdeVtbiFAcyk{v%?*KUS5PN+l&~*%+u6{1-oD!MOr+|pbped50J@B5bw4tnbyC=om# zcylV3qw)u*3mkg{OeCvY7YLR#DK7Y5$}GWfm*E)WXY1>%tvf4Z9{f^h{`QA$-I)m- zS`L#oIlw)4SD0@UU*J=HIknYaFbY%753t*g-%`{bk!>F;9PggiXAccv$DaX8uQTB*zuG9MaAm=l9y85}Sb4q9e E07KDJ@&Et; literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_rewind.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..d9e279231a831a95bc4f64d68142db1b7d6e7cf0 GIT binary patch literal 332 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(FbaFRIEGX(CKc#^kh~;up-;BS zMZh&tB=2^P+v>Y{$NLV(y-CRbmcZUD+kCh0D&Orhlg(?ci9a!X{3nj*`+>FX;Q$e%-vo&927OF6?+eCUEn9O}Z)Gk-y!AX_}jOx-etGSp88q4>mG^@)u zMC+7I_;N<=*Jer2sx;xhkNaF~CwJOexlfvR(7!P&sto3)7+{rzY|OH=gIq{2%7koSSbot2;Bl*Z~rsziFwQQA0p}?-;bZ zA$d>OouIrYwyU7LBR<=|u)HG-;CVvud^j-CJO!?Lkvsv6n0USp4mffJ0+6L|cR#qfu^z8=xV=jnM!Na5#kbz~|tV9=-_Q3o3bjQ2(1-tyWW` zm-++mJ!N(zKS}xk0s@mH0RjV~#0UsbLIVI05%~=OfN*&OaKh&|Jv2Fh6Fxu0=ZyeQ z_U> z$6!27$vYyRV56m=AfxF%$&wwCimfr_zwb~E-7#5fO S8wt(;0000fV^ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_icon_vr.png b/app/src/main/res/drawable-hdpi-v4/exo_icon_vr.png new file mode 100644 index 0000000000000000000000000000000000000000..90948eb44f23878a6cf8fcca64da973317231928 GIT binary patch literal 291 zcmV+;0o?wHP)#Jt5@A-Lr0l>mDBPro`~|EJMkgz+dpBVqRGlPk%weuv##X_zN;-L1hOOjC6F2 z_S%`t1uZEN5h<>GbtmpJC_SOBAZ2Y%wN9Qjtqbi!bm zHaVuwGQ`A~z5e8hf-6@FCV{|X*fqSC?u*8M*!hp^o}6&$hUE0}aqV6>H5m`ve~?kj p7mjOXUjA<<;38a^ilQirfFI#cK#(TP+w1@U002ovPDHLkV1gM{eZ2qx literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi-v4/exo_media_action_repeat_all.png b/app/src/main/res/drawable-hdpi-v4/exo_media_action_repeat_all.png new file mode 100644 index 0000000000000000000000000000000000000000..2824e7847c490a8f3a79c10e95fc7b4425480f36 GIT binary patch literal 203 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtNuDl_Ar*{ouWaOP2oN}Y;r0)= z#3uo}mj2+-IV$|bbY}gPDDO>W4p%xZWHDyBoap4(+kB#Yt++!rdrT0+<9_C61^mZm zGxUfvN$9f}#Bn6-5js$z+`yHwmeX%hgNinbft_ay!!d0}sET{634h#I9-q$?lf}TH v{D3jZr*VOzdeVtbiFAcyk{v%?*KUS5PN+l&~*%+u6{1-oD!MOr+|pbped50J@B5bw4tnbyC=om# zcylV3qw)u*3mkg{OeCvY7YLR#DK7Y5$}GWfm*E)WXY1>%tvf4Z9{f^h{`QA$-I)m- zS`L#oIlw)4SD0@UU*J=HIknYaFbY%753t*g-%`{bk!>F;9PggiXAccv$DaX8uQTB*zuG9MaAm=l9y85}Sb4q9e E07KDJ@&Et; literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_circular_play.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_circular_play.png new file mode 100644 index 0000000000000000000000000000000000000000..e5104fcd62cfd3892e6339e84466463bfbc6db40 GIT binary patch literal 457 zcmV;)0XF`LP)kdg0004yNklEXOoj`TbCRJWC%;snK zeYxwK-T3{ZGc)p!Ns%E-ZW~!Lq}Yp7Mno*w5EHXuLBx;~{zRG{u_wBOB=EpYf*J{ z;K!j<)D2U>8>+X3Z6#qQmZyO25nH)1|;Qxyg>^9eTMgc97xMx00000NkvXXu0mjft&PIl literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_fastforward.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_fastforward.png new file mode 100644 index 0000000000000000000000000000000000000000..e63921abe67f48258aa3275dcb881a57b546b3e9 GIT binary patch literal 173 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+i5>FS$kP61P7X*140z_ILDsVIz zoDhFqB|7Wp#`t#iL_xtacZQC8aWJ>b#o({G|k0wldT1B8K8kf)1dh{y5d1PNAWnLp`AbesJ7 zW(Q_8L|95mf9YS~_`jn>aG`y&j>98&2HuZ#^LAd-MOxn4W<3 k3DP#HW}>SX2O0n^u-xHw#h^cACeU;SPgg&ebxsLQ0QiY2&Hw-a literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_fullscreen_exit.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..db03f46435a18125e157c3bfeceda4c6645281e6 GIT binary patch literal 146 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8K8q^FBxh{y5d1PRu|Y=6>SmTv46 z$QF@!ATHU-rpT_6*!+R*cRkCG_Nxo(IapmPICdx16m2o$xO{yE=cE$`Qmji0k;AIRfd(Cn43;~*u3VOo6arep;OXk;vd$@?2>=%MELs2n literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_next.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_next.png new file mode 100644 index 0000000000000000000000000000000000000000..78f9bed7628afe6908b82b4f16136d61ed1782aa GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+icuyC{kP60R1<{_S|9AgK8XRe0 zICyx9&Hwmz5nhE4f9uUYN+l$GuIGGYqHy5#|9eNW92#E!uk^UQ;qQK)K&b_B=hbg{ zZaAPC=ls2%?@{yDkM-$6N_k)F84r0j8%VG+^mi>-EV6Iy6rfcMp00i_>zopr0Iu*m AlmGw# literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_pause.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..1818039e5161e9d588c1cc6581b38476bebf9663 GIT binary patch literal 88 zcmeAS@N?(olHy`uVBq!ia0vp^5+KY7Bp6QcFoXgrrjj7PU literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_previous.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..4d2eccfe9a4b53cfac15a6dae8f6a60d5f870718 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+i3{MxwkP61P7X*0@2S~JDWIuW^ z!D5c}nY+(s?oKx-mpEg*yz*7da;J?2Ui+uK`&?HyY4+{Ds^)E{PcCQPF`J|D;At<8 zJ3RtS{@jWMADd;aNPJXj-X6;L>ViPXxqXH!Y|bdIi1{0x?k=6UV~K2xz^z9>I~hD( L{an^LB{Ts5)G|Ar*{ouQ0MX1ah!mEV~mF z^{Ga_MPVM}EY>^D8td#8av4My-d5Z*)hr-eVcOY3#us3;W6xx!m5dHd91i{33=?d& mFrH#z6AE+iJn%>VI(zlSX$1zyy_N$lVDNPHb6Mw<&;$Smi7d&^EJwwS%-c!vhAr z7X|Xy8>)84FrKStx^$T_k+~t@-Gj?Jn0k)4_BC!{ko_nhva8|S{x^3FN*XRc{JCW< P&|(HpS3j3^P67Fi*Ar*{ouWS@-FyLWvobHmY z)hHChrK;z=hK12fBL2k#^G(NIab4uia9z++5njUl*~}ns+A(|fPZhEU+M0Que@t)q z^RMv6e@+KUw=Ikuul_GExyPiiBAhWzeZqu`qG`et-k*NO@^JlW$q61;lz>(;c)I$z JtaD0e0swHLIa>e# literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_rewind.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..8cd1daa8109f9da55f9311f3b73ee8ee16372195 GIT binary patch literal 186 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+iMo$;VkP61P7X*2n3Iz^4RHzYX zNj&HHE_#Cg3EAd5iYKNAOV~W0(`4}DR@3cw*YcA#d&Ae;(i_X9g)Aj-#!{F)a=d#Wzp$PyJ_)vfV literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_shuffle_off.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_shuffle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..2353dd42894d3cd8c121320e4219557f28fb88d3 GIT binary patch literal 182 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+iT2B|pkP61TlNi|=97J54Ias_d z1Twiy6j=1Ge!_J9H(6Wa1%K gy{w(*XkOu&a%t_Iz(lrVI~F;eUBxBp z#}dj^lPK&c(r>L5WwvzhrpvY;SLX+1e`U;j>OZIN6ZgH%zmNL-vHWy%_7CsEgvZaO ls~x-?zbLiqaX`uxX0MyCcfUOJfFI}@22WQ%mvv4FO#oRXM=1aR literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_stop.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..836f4dbb55e53e7e0eacb7f5859a431a3fff832c GIT binary patch literal 88 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_*1c~2L|5R22v2@*FS*ejnj`d`R! kr>rMS;;O^aBj!LI9u`U;{{1(c094B0>FVdQ&MBb@0HW6!m;e9( literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi-v4/exo_icon_vr.png b/app/src/main/res/drawable-ldpi-v4/exo_icon_vr.png new file mode 100644 index 0000000000000000000000000000000000000000..6e21960ed6eac2fc430a0cdc3b0108cd36b562f4 GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+iLQfaRkP61P6B}6%7>Kw;TR9zH zt@J|Sq{2y+nF^k6RlN>VwO26gWdC0H=YRf8DIGIDy@LmrG52|ih0Nh&5t_Atd7jHb zGp?6U|CS#pOZV*mFF!&1X|Ar*{ouQ0MX1ah!mEV~mF z^{Ga_MPVM}EY>^D8td#8av4My-d5Z*)hr-eVcOY3#us3;W6xx!m5dHd91i{33=?d& mFrH#z6AE+iJn%>VI(zlSX$1zyy_N$lVDNPHb6Mw<&;$Smi7d&^EJwwS%-c!vhAr z7X|Xy8>)84FrKStx^$T_k+~t@-Gj?Jn0k)4_BC!{ko_nhva8|S{x^3FN*XRc{JCW< P&|(HpS3j3^P67Fi*Ar*{ouWS@-FyLWvobHmY z)hHChrK;z=hK12fBL2k#^G(NIab4uia9z++5njUl*~}ns+A(|fPZhEU+M0Que@t)q z^RMv6e@+KUw=Ikuul_GExyPiiBAhWzeZqu`qG`et-k*NO@^JlW$q61;lz>(;c)I$z JtaD0e0swHLIa>e# literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_circular_play.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_circular_play.png new file mode 100644 index 0000000000000000000000000000000000000000..7242e1f5c8acf21b4b2036e73597cfee887c4b9a GIT binary patch literal 672 zcmV;R0$=@!P)~F^jVFo}-QbYlqEs$$sN9&7NAX}b!o!i>`zVXe}%+F$M=>6ma>BV_W(Zgh}K&RsTJV>akK0Sru+zgYww zvBpZ2GpfQAj8yT$?x5KknX2N*fW+%^xFu5pY#tckdg0001>NklYyCj#Y zRQiuAt>+@1%=C`yf!Ii`+#7(2PPsP#3j-nHM0W;aAGX4G-T-_#EB6LscbcR>Xsm&F zVCBxhz(#n;Ut!`XymKCLq}HArm^lgWosaaUP5LW(ZeZa;$h(Le2jTl_wNmK^5Q&s= TKo_aB00000NkvXXu0mjf*0Eeg literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_fullscreen_enter.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_fullscreen_enter.png new file mode 100644 index 0000000000000000000000000000000000000000..4423c7ce990e5f02ba52de5f53659b75e907a8b0 GIT binary patch literal 101 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZA`BpB)|k7xlYrjj7PU|_|fb^sd^~`LtLbIA@2_R9U%Rlu6{1-oD!MdB9JMLh#Eox~+4A6}Xp00i_ I>zopr0341^lmGw# literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_pause.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..3e150b5a45ac6e321d1cd54b625e412a6d762e30 GIT binary patch literal 140 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJU{4pvkP6160{st+TcX*;7!DYS zxP)scFqcfone=zLhqy)8qEKk4U{hlt4Ar*{oFAEAV1q!e|Xl6g? zD8S-qmuMGh_j8W_j9@VImuO2AU^{qF-0uF=Sv&7PpMCRh`Rw?!0=Z%@=A12J$d6WK zK5$iu{labORE8bVPZ=zvo-@odvt-zMMB=D|!_ZlZ^;7h{Zy4K6YMrADbQ*)FtDnm{r-UW|DUM2I literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_previous.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..ea83907d8edc58358daeb5dbb93695f8e97c3e95 GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4U)t)YnAr*{oF9`}TIWn|9Y*nzZ z;c1@pxu)oxZ1Jh*3iB1_*POHDX*T%57e4LY%YSzAdn%V+&t_eA=}bZtqeYc4li9j| zEe(4moemuB7ci)D;IQ~9z_cxI0`ubKG8t#Y`zj1hH&!i_SfSq6FzcW#OV+u4f&rU9 eYwe9&%sSCUaZ>S;pWQ%bFnGH9xvXk4UGd*1#Ln;{WTv;f}6v*Rpkwb+w zhtq@0;Yi>yeZ|z8ovaEPt(W8kJ50|Mozp0DJ1;8O`JXFYTw)5h!^{Si2#=eblM+5; zpJEMT^*J`(?nn*u5<4Z^@`icV4(DYPIA!1M*>&9_Z^y?saZc@*-%SkFxWu4gz`(`x zz_{J%fAf`MN!B%L8x+-?%Q`Q(9LqDg*x>%8dP;bLGtkX;(TjdEt_l1NbR>hPtDnm{ Hr-UW|Cn-tw literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_repeat_off.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_repeat_off.png new file mode 100644 index 0000000000000000000000000000000000000000..6a0232170238bb285a24d6b34e43247005cc2169 GIT binary patch literal 227 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4Ut2|vCLn;`rTw!EmHWX=jI7MiM z>*5B7298|;Cpfuw{xAH;w6MQ#D*Ii@4Kw&>dsu|;sA}~2$khCZMa5C&WLTTZiJoTN zxCNRECe?q@d@k(qNPX5%ri5ow36H;ioqINh*`$pz`NBiT<(qvMe4ck8>cxN7g7oy~ zVkJ8w9OO51wmxCdV9!gM^qKX*8FttG>?*d`MIDM6+E%38lxR}eW4_+9!E?!Zjr9lG bL>L%2bOKr?dByevUCrR>>gTe~DWM4fJ|9<5 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_repeat_one.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_repeat_one.png new file mode 100644 index 0000000000000000000000000000000000000000..59bac337054e11eac123964a46de9c937d15b3b9 GIT binary patch literal 232 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4U>pfi@Ln;`rUKJEzHWX-m=&;eQ zP-~ul;@Wot%S+o6YPd>Q1>d>5(XZZnF3UULW!ihUi&V&`luasGQ_#o6+RyaB(LpH1 z$B)%BAfng3aan`c&6&Rg#2Y%jMZSh6tjlibeO{t>rj++WgX9^{cImn2zOh=ZXZ)f1 ze(=loKuWATqb^&I%hD4kH93`bRr)fV gZaHed!TBjKXZ&Rgrb(UmfKF%dboFyt=akR{0P5IV-v9sr literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_rewind.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..231bcee4cafaefc493711312a144d4e43097a846 GIT binary patch literal 217 zcmV;~04D#5P)kdg0001>Nkl9aBP zdzZmaY4)+|$;u6s==bcw(U%;`o-7rphnB9N{cJF*(3_HES%3Th#_ZW;ZjNWDA4NCM^^fn8Z97gYRaBlW}-6xYjb4Hd{ TSHd>;00000NkvXXu0mjffqz{g literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_shuffle_off.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_shuffle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..a55d716cced6f60f93bc403c1cafa785a99496f0 GIT binary patch literal 228 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4Ut36#DLn;`P78pse{J9Po?zX{o-TQ{ZXM3MDneg;gita;L_f`?^_1 zl0MUbyBlW$9RnGj!Vh4CdW#`zCpT cnglDurOSd!uTxH$0iDg@>FVdQ&MBb@03C8#m;e9( literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_icon_shuffle_on.png b/app/src/main/res/drawable-mdpi-v4/exo_icon_shuffle_on.png new file mode 100644 index 0000000000000000000000000000000000000000..0924b2cb69b3d5c8acd3efbfc833d36e1896a9d8 GIT binary patch literal 230 zcmVkdg00023Nklk4U8lEnWAs(H{2@Jw%1#3HFnGH9xvXk4U(>z@qLn;{G1~alTC<@pV?tQq< z{to}I_=e`+#~kB3n*Nz=Kct)F&0^wQwrJ(uvhA&{ng{rF3-V>Rf9PPcT~o8$J?{ed z`UbZgDVG;=%`RtruGO-c`$YRTT^pl!*3Y=IfBAvKTQ(Rl%Rl|oT@_XB`4Z?t22WQ%mvv4F FO#lNFPiFuC literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_all.png b/app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_all.png new file mode 100644 index 0000000000000000000000000000000000000000..97f7e1cc7590310a3b6aa322584e6e1ec8b6bab9 GIT binary patch literal 210 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UGd*1#Ln;{WTv;f}6v*Rpkwb+w zhtq@0;Yi>yeZ|z8ovaEPt(W8kJ50|Mozp0DJ1;8O`JXFYTw)5h!^{Si2#=eblM+5; zpJEMT^*J`(?nn*u5<4Z^@`icV4(DYPIA!1M*>&9_Z^y?saZc@*-%SkFxWu4gz`(`x zz_{J%fAf`MN!B%L8x+-?%Q`Q(9LqDg*x>%8dP;bLGtkX;(TjdEt_l1NbR>hPtDnm{ Hr-UW|Cn-tw literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_off.png b/app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_off.png new file mode 100644 index 0000000000000000000000000000000000000000..6a0232170238bb285a24d6b34e43247005cc2169 GIT binary patch literal 227 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4Ut2|vCLn;`rTw!EmHWX=jI7MiM z>*5B7298|;Cpfuw{xAH;w6MQ#D*Ii@4Kw&>dsu|;sA}~2$khCZMa5C&WLTTZiJoTN zxCNRECe?q@d@k(qNPX5%ri5ow36H;ioqINh*`$pz`NBiT<(qvMe4ck8>cxN7g7oy~ zVkJ8w9OO51wmxCdV9!gM^qKX*8FttG>?*d`MIDM6+E%38lxR}eW4_+9!E?!Zjr9lG bL>L%2bOKr?dByevUCrR>>gTe~DWM4fJ|9<5 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_one.png b/app/src/main/res/drawable-mdpi-v4/exo_media_action_repeat_one.png new file mode 100644 index 0000000000000000000000000000000000000000..59bac337054e11eac123964a46de9c937d15b3b9 GIT binary patch literal 232 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4U>pfi@Ln;`rUKJEzHWX-m=&;eQ zP-~ul;@Wot%S+o6YPd>Q1>d>5(XZZnF3UULW!ihUi&V&`luasGQ_#o6+RyaB(LpH1 z$B)%BAfng3aan`c&6&Rg#2Y%jMZSh6tjlibeO{t>rj++WgX9^{cImn2zOh=ZXZ)f1 ze(=loKuWATqb^&I%hD4kH93`bRr)fV gZaHed!TBjKXZ&Rgrb(UmfKF%dboFyt=akR{0P5IV-v9sr literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_circular_play.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_circular_play.png new file mode 100644 index 0000000000000000000000000000000000000000..dd31d608d4c73914c4b3ffcfaf60bc5e578b4160 GIT binary patch literal 1322 zcmV+_1=aeAP)NklE;LX9}f=Atg*Ak9#7?94;aDaDka5$gmNdKtjwhhLSN&_!@~I zU(kpQ7{fIrqFiAFk&3g*PsP=0eG9WMV5rhuHN6~H->Q+`_5}=8(wFqFaJ65Jbh0mC zs8Uz-@~+OQk=phJ3{|S9rq1B%wi=1s7cf*Qf&T1VRj84eeE~z2VrruNep_IaVS#&I zq1_%Tl+-KVKPU0(s7_*i8SRWe#_HHFqu;2JKA|tL+i)ZM5?8C#$SnM^RY#krrkCC8 zhq+Psvs1mo6#tLY$MMHj9qqLLUnXiID$Vi3QXQ>rkS{cFSPf0Y4@-5lsroBru%GpQ z!4FGyv_FFVyxCWcrTmo0P#vu`qS6CTlH5>(@8g-FI@(9-D6SF5qit&R3i)`Ys}ocq z`v19&c=k4;G6%18b%I)esJww^Ih3g3%T&V~dvyS6auxl*eI1C7c! ze97Yi`dainURbLG@DieOjwC+KL{yHDMGAt9!Ofn->M>6}OG;T%(lb5G(A>qM(9jaIq`R&m+>pL1$zqkn6*~lvgG#geJ#Uw zueTCpdT+KR1BregEt3 z>RGH`QqBcF*t2Kyfw~Cw#-h)DOsBJCS)bjq;_R7RE+k>}TIIsmWBcC(%(`aHF{S*i zEX$`u)|^Xj?zX?#&T{UwHRqf5@(p#H<+_h1CD^m1@XI-iuX_pf41=eupUXO@geCwx CaLT$m`Z~Df*BafCZDwc^3*(C978G? zlNlNV5B&dc&wTj*fBB4ajKP1J`;5;VNZ`w^TDCoqfnmn3`o>w)@0Ebe@pScbS?83{ F1OOWIB9;IE literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_fullscreen_exit.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..ef360fe40c758ab7e8d3e168e6c2ef013515646a GIT binary patch literal 106 zcmeAS@N?(olHy`uVBq!ia0vp^2_Vb}Bp6OT_L>T$m`Z~Df*BafCZDwc@{~PY978G? zlN%ZWKgf5mski^P@A&+mS=aI~*9HAMY}L0USsB(|s&9O`k8?Z75KmV>mvv4FO#pDq BA9(-( literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_next.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_next.png new file mode 100644 index 0000000000000000000000000000000000000000..f3552d7216d3811e777e649c137d13a243388bb6 GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7_7UE1oWnAr*{ouPJgIN)T~PJlvFc zFvDO?vWWz%bs5iVi8nVcTd`Gd+T3?ABfaqAjtvPG+oD}v@^&ugxBGFtI)74-m*&zh zno?cs_pc1x&-~!vCGLia&EgCfjJ`8HIC+jOAl*x!!740{VUI=N42D;3PgxHfxW^U{ zo)*XOB`1gBYCr|!Eit=>ZLRzZav!q1P_$`iTlt=|YTLQ_0gq2GO01aM{KD_4^wFqO zat_CD?d;Q^vai`?`AYT+n?A@cRd&A~xz6)FW5%Uw2CLmM48FhqGk}~k<(@Z3bbtA) RzH*Q^Jzf1=);T3K0RTb7Zax42 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_pause.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..1c868f1831dbd75b5b9375479ed5fc67a90254eb GIT binary patch literal 164 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|e(Ey(iSN8&+{}^EVDaKns1#Bfj ze!&d;=a=^myH<6wnL?Pgg&ebxsLQ05&!_aR2}S literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_play.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_play.png new file mode 100644 index 0000000000000000000000000000000000000000..381eabdccf795abbc00d86fbd2cb04449abb7356 GIT binary patch literal 270 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7_7UtDY{7Ar*{ouL=q{IZC)bOfcj) zq;N+4#M4AS{?8}X=Nl|HSgvqFou}D=L!L!_cV2qdztZ@6%RhE|lKxDOfo+=!`vg@#?g@k}rVdM8gc9F9s;AUDr6kZC*B14)6><@ JWt~$(695KMV)Bn`{ESg^Ze$jFU zRt*P6u7Cz6kp)2JfycpM*0Znw-t;CX%5yfD&r#rFkgj>m7RXR>(B46W!LG1XgCXZ5 zw*YH{>^UIOD&L^Nu;&p+A;XVDyo(q%9F+$tDgY|ddBbSL&?YmV+2F|!nFht++}b<#TTVLx{oC)z4*}Q$iB} D_1|@w literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_repeat_all.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_repeat_all.png new file mode 100644 index 0000000000000000000000000000000000000000..2baaedecbff58a5b4141266f7ed74804fe1d034b GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7_7U=bkQ(Ar*{oZx|LbI|{H~Y+HPR zLBztCf!UyilQmh9X9{x?0~hbYxf&atdt>)6x%H3XnQHmLO}A|e=Ur|%?8Ut062n>( z2W{R9K&DfJ;K6R&TcPeO{Qq}>q#CBO>g?bz__`xGzWMWhhO>W+V|t$^nuUwz#f|iXZvrTvq{=LSAXBVvZQPO`YXDabgVfxm|&nkH=lo~U6B6# zdFi`9vJGGD8*~;Xy6vdyDQlEmC~2;2lETRw>Et}^!UReRm#EJ(%Qe`=D7jfWz+g9{$_)k; z2}TtQ5QB-4d(sx^E*A&4pP$d=F>v}Fc*Sh6pMQhm`y2fe)-!n8w4K?_07r#!>(_)ngB)2D?Z)X{-ahQv$FPb-yZ%AR*Wh$7#I_s zr4aSDbU2^Y3#5Z==u%2ZJ&pAdYrFn_{&hMD_$mGhZh z?lE@k;TEVAUoiJM18-Gb{klshm>op(8Ko-OE@(c=Juvsm?FP?Zyazrr=goR_?A1xj zxcUNySxMlR4NrfDZ{qo+=kO%9;lyr+iQgGt z?fKHQpFd^V^V9qO@Eyov+QKPwfMYh-E>%^x6P)}43=KjC4Z;uvbmgZ1@0UMbxBflz X#E2BlHybV;1_hF*tDnm{r-UW|-I#^I literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_rewind.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..a798fee30ee704c190e03acb258790ae9ff5bfd6 GIT binary patch literal 391 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!LIQk3T-^(N{!<$?d%wI4G+(A9 z$S;^7-7r~X!+!_&=MOn1T`B%8voG`$-y&<4R+ZBl@0vZcfa;EUx;TbZFuuKVT=1}g zz|nx?@8_(&Q$K%S^*krWBZADXdOM?>76xdnG5;1_vElaf4L6z}Yx49zulLux?xJXW zGgZuVyunwembrY}PlymJiEb3%7nq`OA4&caOUb*Gt=9 zJpJhv{W}gB_J1$8c=nTBw6DtG3&-)j%R3bJt~}58cJE}FOS8WpU%1y_xc$?zo%Rx! zW}iK~aPRq&_Ak#WiZb4xeLLaa8hgICwFfL^UhK7e^>kl=hDcq?a|vIruje9wK4S26 L^>bP0l+XkKTix2T literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_shuffle_off.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_shuffle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..2b67cabf5afabec4433cb71905087d42cc094b89 GIT binary patch literal 342 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7`%AFv@zmIEGX(zP-`Nb|^uj^TTd7ZCALw$Y0r&=p1Lj{YUe@tcqam3F*7V$q%g~zq$ zeqNj>wf&6!pX1j{=FJoT_W#WMlIPD^eY0-b{9hz|?fXTsbN|)Wd}85v(sM?NtyIO*{-%sBUL`7Ldk h!^?}awnnY}#`f#r3*~;M_+_B*@^tlcS?83{1OVHAnMMEr literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_icon_shuffle_on.png b/app/src/main/res/drawable-xhdpi-v4/exo_icon_shuffle_on.png new file mode 100644 index 0000000000000000000000000000000000000000..ede80c93417756aa396efeb759fd415ec524b160 GIT binary patch literal 342 zcmV-c0jd6pP)4l>LI}_?I>7M1LkH*xJIan3AcQcy;RUEq4{rwFJAlpeGt#zQuh;AKs_6{R zKWY@miRNcLHNk5ZeczyYNm0V!`6vMZbdi@O@H>zpa6o{dD+iZ*kTIHAb<7eN7>P~=UZ08S|KCK!+-+;#XpZ$b&+{|_YZ=?G#af#-A6%=6*t z%=77WzVn?g@;YprpLEx>W&TMYU-S9ud13$0{7Dbh&kM6gHea7%g6F(A0OU)8^BjQk z1;Kd^Ao+seyaBO1xZ)*Y0LbTG%|MpS0a?c zg&?8k=|MG#EI>-*B|t-}0sI6Q=xB{P?ACPnUIX-0{E!(HRPT002ovPDHLkV1faAonrt1 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi-v4/exo_media_action_repeat_all.png b/app/src/main/res/drawable-xhdpi-v4/exo_media_action_repeat_all.png new file mode 100644 index 0000000000000000000000000000000000000000..2baaedecbff58a5b4141266f7ed74804fe1d034b GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7_7U=bkQ(Ar*{oZx|LbI|{H~Y+HPR zLBztCf!UyilQmh9X9{x?0~hbYxf&atdt>)6x%H3XnQHmLO}A|e=Ur|%?8Ut062n>( z2W{R9K&DfJ;K6R&TcPeO{Qq}>q#CBO>g?bz__`xGzWMWhhO>W+V|t$^nuUwz#f|iXZvrTvq{=LSAXBVvZQPO`YXDabgVfxm|&nkH=lo~U6B6# zdFi`9vJGGD8*~;Xy6vdyDQlEmC~2;2lETRw>Et}^!UReRm#EJ(%Qe`=D7jfWz+g9{$_)k; z2}TtQ5QB-4d(sx^E*A&4pP$d=F>v}Fc*Sh6pMQhm`y2fe)-!n8w4K?_07r#!>(_)ngB)2D?Z)X{-ahQv$FPb-yZ%AR*Wh$7#I_s zr4aSDbU2^Y3#5Z==u%2ZJ&pAdYrFn_{&hMD_$mGhZh z?lE@k;TEVAUoiJM18-Gb{klshm>op(8Ko-OE@(c=Juvsm?FP?Zyazrr=goR_?A1xj zxcUNySxMlR4NrfDZ{qo+=kO%9;lyr+iQgGt z?fKHQpFd^V^V9qO@Eyov+QKPwfMYh-E>%^x6P)}43=KjC4Z;uvbmgZ1@0UMbxBflz X#E2BlHybV;1_hF*tDnm{r-UW|-I#^I literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_circular_play.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_circular_play.png new file mode 100644 index 0000000000000000000000000000000000000000..56ed071b2f7aa4e69c2d69626c984ff827c11dd0 GIT binary patch literal 2020 zcmVL{N zxRpViLJU&H0B)fci}M1@5#uc5IpWkhFngl`R@eI@W8S#}$9wS6Aa~|=Lv-IE+T)LFr zoJD-(94`=(w|rfnSJR6QSm;Dws(t+hZxDi)eT`=pS77NX=K31Z3uM`oKFW2NSICAp zIO3ydkCH_<`q(v(r5V}Mf{8wM*Ck|0C(lFeX-dhqk2&Lc(C+~DiabvXcTz$aZe%x- zGHWQnn&(Bua#|3UR;>2C05SF+k}?x1B22|hMN;N140ZRoX)~NW!j@!|$CZ9pjKo>t zF`hV4knkAMQmeo78;^435fw?MAn5tRX05p@<*vDiC@D_4yRM)k&P_;pU~fUxv}O;Y zW)zOypYpV@gebZx<*DOcI222HpR1Ip`Yh#1V+?0rPI=80Ws?y#&*Dg2zlqFYnvt0S z6l1R?M-e5Ph~dNwh?-Z(l(3b{vG*3D=4qT*t}lm?IYFr69qcVYlq|-9TlICiK&yrF zN9?P1t-kij%>*_GQPK;74=5{D8-OV3M?j6$r^C+B!O?*1tJals>N;aX0{H|{(gU0g z$i8a*5G5ZF$U^lt;3bX+WLve%)mx2o2%xRHdz%W*2V`5d8S0^?rA5J>HW`p*)tzZ{u2xbWL35QDW6tGl8oGmD7nLUKsHso8&Pr#8TmF5YB9>)9d=6fH5B^+nN@9>vh{aK+uxN<{nvg#hE?mKZ2Qlo zZJ4sDZ|w(UShXLNEe|Gb^Oa3KXFnjrs=es$ntqeAshjKvWLPzA0eQWSMr{5{ObQD_5F)-vsywQPP`0lxkTSU{NrD zMpUUkn$(E^lxo@Nrv6q<1@-M_-F{w!3OKy<~J zhNyYUBLsF&(<_cMZq|6>}A`G{BzO!s4`Pica z`;nA6!)IjMSDf>FiCa6eq{PQ-)F#uCY-z_w^(&n(~?ES0bb@yvOiB?NEy8c_}X>4JrB4D$Kff!7Gh3!L-$>W02t=J-C>0N<~y zc$N@7N~P}?kIdjXk_46HMP{b)8l;B^Sqb|Q-#N_xxSIr-$m3pma3rm_vtC04bkI*7 zm7HJ{A9EEk{D^ZkpD>1#NLpD*J0hZxz8Ss;Y#BZHffu=zi)ltN2@(|3jElLI7x|H% zEYI-0Wj)9zQr@B*iAYZJDiPF`rAP#_h)zUQoG(LukzW~~5+l+&G8zdf!)Z^gaF>2u zGs`M&rxuCvESr!lWdl!93&nVv#rC^oo+L&sb~F8q-rieDUv8vMXvhc5p&D_NYG(01 z4X9&UQOa1#0-gen;eA?Ahb6g%?-<8!B#rE19N%#>d0apma|iG8X9hEe_3Y+2Ra8?= z7021ldS)}2Kl3iP(}(L8w#oJ1)#Zm3K1aV_EwKEtL;P`z*)sVY&Nd6z`!O&u zdU?7yhEy=Vo$lJl5-4(*Yxc~UFaL?loH2N!xwrTFv>1t(>c=FKdkmkIU-Bt;dg3Zs zpjFQDDNFa!i$?WVyzfjNdNl62%4Z>K`@v=Va%&ZaU(D}#*n=2)j!BlQ%@kp{JoCi| zh8g-hHpgTzZip&Q-#m>eVYl#H2Zkk*rEUdV7>;#X7cm?@ENj_N_^40rfJLD@UqZzW zWp)FbBQN>CFtoK>KK9{Hs92%Q1{68iaFUUSzc4J@vf&}8MODJSev7IH2F=d*HcK1` z_mDNcV9z$E@!!mORYwj+bui{gmR@oAD^no#=|-qNz(Ijp3;@#R9lX&nhw9h<#V!m>{qf&-|D`M0%JQOutw- TbRR8?0GaCP>gTe~DWM4f)Gs9S literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_fullscreen_exit.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..b7f4133fd978de01cb1e62b660d402ec92e3e4da GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^6F`^|NHCnYy)O!+m`Z~Df*BafCZDwc@=QEk978G? zlNYe`GzEV6&&bP0l+XkKgGeJs literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_next.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_next.png new file mode 100644 index 0000000000000000000000000000000000000000..131a531b3714b7616c211e59f1674b52a9659736 GIT binary patch literal 385 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy#_0(?ST-3xsFQwF}~PUr`kCRP&U z7tGMj`v1K4uiR}?@BC)+-8ZW`st~)<@y@bFUZ9E_o-U3d6^w7sZf#_76kv9En=xbB zXa27@YwapyIYoL5k4Yq-xh~c&|B(HUSginikEhIpogK{A)c8)=9btT~#V29DocF|E z0roY^Efm%(@>%2;9@t@#>+nl~Phzr#La`wG8ef?SwH?gALS+o{{G=!RR^*HDFLa1= zmMOURXu-T&3HA$k9z9#Y^NdTQ*=_+(7V{KGy#O{t##0B_J(`a?N}iDpV7uO6v_gFW z&o|~%3VD2|64o^syL;wH) literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_pause.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..ac8d4fcad56a7e2b7e0a2a379e5e28e98f7d6216 GIT binary patch literal 111 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeK3?y%aJ*@^(YymzYuI>ds|NsA=KV^|KP=v80 z$S;_|;n|HeAV=KO#WAFUF`1z`@WX$54u17>8tGGvxfqxZ+*iEm^>GVO34^DrpUXO@ GgeCwoL?BK8 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_play.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_play.png new file mode 100644 index 0000000000000000000000000000000000000000..365b3dfee5bcc47da1f04d84a67c3169dcc73e7f GIT binary patch literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy$=1AIbU-3xsFQvkerXGs8!5-kbx z3uc%s@c&7`df9jBN438;v);4_pXZP(caPB$WTmHzV@L(#+iUDxOo1Xt15Cf&`?f#q zWs9E2lD(T7xs4xw6mL8rk=$cAZMAFnS2ekYw|a63^&RXFD!SM$;@7=pxbyBKgUL}P z&y5j*I$2lqfAwx2N^tF{an^LB{Ts5v<|n9 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_previous.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..884cbdd4077318cd73bce790f3faa0260ba5630f GIT binary patch literal 458 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy#z0(?ST-3xsF(-#!%=eQ5FU9BX@ zFPPy!&wQKvEX}`FPN#nsS?~UwW3s{JVwwHH?-xJr|GxXW_hyZDf$-x&%T@q&JoI#N z45?szdu3}PGlK~8MSWxA_RRl+hXcho)y~apydL{E#!GYQl%PxLUzbbyie+qdWtyAk zuW~`&L&ZR*ljWFjC(AP9i3e&FCmtwL6^Z!n!ZdrW3)Aks9t#R5NE~>*kYRm0(;hjl z4^qnsO$SWVTV~`!L7gK=C7F&LM{Loj@6$ j4|BADj`q_0dxJZABX_X=iu@>`XBj+Q{an^LB{Ts55pVU6 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_all.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_all.png new file mode 100644 index 0000000000000000000000000000000000000000..d7207ebc0d296d8d33a9de048883837b982a7e7d GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeG3?%1&o4*=J@dWsUxB}__Fko@OdIgZrQWE4B z%#i=TU(UMI1;}6N>Eakt!Fc!NLP2H)0hf!(GaQ2$mM&P)o~6NbxN!l?tA$-Rx_{^y z)@-%*pSM=Pq{+{KV+%5M=I1&HSM8wE4VL2#Gi+HC1aq304Uj2k1^edj!GDD_%u$XM)e8a6A;w&@ZStI$t(%W3R^9Kj%M(5^>bP0 Hl+XkK`_Wv{ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_off.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_off.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6253ead693a726bdcd46b1421318d52bf8f325 GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeE3?v1%WpM*3p#Yx{S0Mc#71#x>wE~K=l?3?( zGbs6L^O-t)+`SVha>moeF{Fa=?b(BzEe--M7qfgE)RSMBvf65t-N3SQ)9Z;lHp{cI7`5{L(N5a5$8o9 dSO4aA`^e8F3nN!ATnF?QgQu&X%Q~loCIHE)abExc literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_one.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_repeat_one.png new file mode 100644 index 0000000000000000000000000000000000000000..d577f4ebcd6cf5eb5a30319b407bc9901e585e40 GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeE3?v1%WpM*3p#Yx{S0Mc#71#x>wE~K=l?3?( zGvxp8H&}n8-Sjk2Hj zW)=|^O%ARm5f)ZQuBKg#zpAWF+3rdQBwyHWVajqhIw1Lh?1g5ATkLY|Ilo;MG%^HQ z6f*>l{4-?ZZ8Bg%;15svm_Sk@e>7%jNT|99TY10ur^a-xCBw-?ah8B@hnkD9I duKvyK_K}}U7DldKxDMzq22WQ%mvv4FO#ooxa~J>s literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_rewind.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..4bab5457141f9f14771f1006db37c82a2b9ad2d1 GIT binary patch literal 561 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy$81AIbU-3xsF4-n8x+3yN;hGt2S zUoZpDe2e@4ZCILrs~kU_{5f4@z5CE@6!Y3U*JcEIO z(ZduFGiSc5cTBeVJl{O*XlSOlyyqkpAUavUG9>i?+C@I~0zK`neUiP8`?t#&&t!Xia?Jt5&#oG<7EZ866W$nAKH}LlC{#0`&&GyDEhN0_|UtCh`yU!B+Xww_+t?l z`ygSCz-hzDGas*(u`%p(H@A>dKT_bc{P_>Q_Qx$V`i`G8l=)y|z3u?x`pJ)#WqL3F f_5%lq=O@FlwLLh1E0au)z4*}Q$iB};MzC; literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_shuffle_off.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_shuffle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..22209d1f88712f3b615018fb1e7cfe572bdf256c GIT binary patch literal 438 zcmV;n0ZIOeP)A95OS2(`R-JKs2G;M#V*4;bHWx~}WGuIsw4 z>;9VVwAF*pWZGz)`kI&jrlF6^6K3X*1YvG|fG{;bK;Qtq`mz9l1N5vzkiY>E0ssUE zTI+xT0fO`Q8XOQJIG+Oo1m|-AC-8g@-~^tJ0f4~s_j=Nn0ssN$uaEEDng9TRfbx$s z0RRAj<(ptYfWY!iFd#ty`6eI$Agoxv2><{H8=h~1Pv;($!Pf3`RvnSWluE|5PJu+n=jp94&Y%-P0 z+t->RuQji1eYfM?ywc;PdLpk^UQS9}xS=4b*2MSRl5(@1)6?V59si$~8GF4Hg6GO_+)>tvRSrW5t|-tKt3gkhe-r+RY^rUVY9 zdI1&(L6?6D5e*7E{x}*iINs>~<0!!3XmRR~hnBtlJePmd zw*CF{%zXdPv-x$OXP^JK?Q{Q}$~{kO4hVkwIcviC>n$~Qr&;)-Pu0(e*B7>Mu&e*s z?>OV9z$x*TiTpEbfXoRe#am_~GaGA4Uv^u5`uQ_GZ1Q`~hc*5j&#MomeLns*pIhK( zos0v+wz>P+9w^V;&z5jLUF|+Io8pA`jLiZkzBf2>JgGjQP_t%cJ|jHT>^BNrb3E|o Sniw!P7(8A5T-G@yGywp@636lY literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_stop.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..52393366712cb37ce62aef45fd4542ce45cc6ac5 GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^2_Vb}Bp6OT_L>T$m`Z~Df*BafCZDwc@+3T6978G? plNF?Un*Jv^WS`SWpW>`1!OD<-Rqn&L4h?CLPES`qmvv4FO#td&7>@t| literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_icon_vr.png b/app/src/main/res/drawable-xxhdpi-v4/exo_icon_vr.png new file mode 100644 index 0000000000000000000000000000000000000000..7c0338842d0bd3efa47d0ad816ed71c0a6e5acd6 GIT binary patch literal 602 zcmV-g0;Tqr?$rj3;+NC0D!!+X9k=N17R4JjI1>)GLV#EU<`~|{>JnVbO5?C04rm# z{eprSU>F-Dm?p?bsqHTR>Dgh9tt*FKuOVK5=d{nYZNI)B9vymwmeQkxXY8xL%$CQd1`>B}HTfaa^ir9#NASJXRFr(g3W_ASfjS1up5@-cl0m+<_LZxJmTqX>7 zX9AK1DOV))RT9ReELKab=3NOacw|6C7hk1I#DGT@Xu_O#As|T^5aMaZBV<6T@6?=k zA~0u6g!gCUix@N4j5+T>pd_KY#C1t1HDk^@5Gd&5FL6G73e6but$^#dQ2QZn;@4>V zL#9WAvB4-|z;EX;Cy`q^%o#*b@}g2An)rV}Qt`mK$^#X$c>;oG5(1TkXGHS^Dn>+j zDiI?ps{$5k6+y-c!B3}pLWZzdt5_(ua<19aXZN@!w^nRfs1`qY;tXG(^*Q5-Pq)>g zdATbo7;*6H^Z_FZqEakt!Fc!NLP2H)0hf!(GaQ2$mM&P)o~6NbxN!l?tA$-Rx_{^y z)@-%*pSM=Pq{+{KV+%5M=I1&HSM8wE4VL2#Gi+HC1aq304Uj2k1^edj!GDD_%u$XM)e8a6A;w&@ZStI$t(%W3R^9Kj%M(5^>bP0 Hl+XkK`_Wv{ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_media_action_repeat_off.png b/app/src/main/res/drawable-xxhdpi-v4/exo_media_action_repeat_off.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6253ead693a726bdcd46b1421318d52bf8f325 GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeE3?v1%WpM*3p#Yx{S0Mc#71#x>wE~K=l?3?( zGbs6L^O-t)+`SVha>moeF{Fa=?b(BzEe--M7qfgE)RSMBvf65t-N3SQ)9Z;lHp{cI7`5{L(N5a5$8o9 dSO4aA`^e8F3nN!ATnF?QgQu&X%Q~loCIHE)abExc literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi-v4/exo_media_action_repeat_one.png b/app/src/main/res/drawable-xxhdpi-v4/exo_media_action_repeat_one.png new file mode 100644 index 0000000000000000000000000000000000000000..d577f4ebcd6cf5eb5a30319b407bc9901e585e40 GIT binary patch literal 309 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeE3?v1%WpM*3p#Yx{S0Mc#71#x>wE~K=l?3?( zGvxp8H&}n8-Sjk2Hj zW)=|^O%ARm5f)ZQuBKg#zpAWF+3rdQBwyHWVajqhIw1Lh?1g5ATkLY|Ilo;MG%^HQ z6f*>l{4-?ZZ8Bg%;15svm_Sk@e>7%jNT|99TY10ur^a-xCBw-?ah8B@hnkD9I duKvyK_K}}U7DldKxDMzq22WQ%mvv4FO#ooxa~J>s literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_circular_play.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_circular_play.png new file mode 100644 index 0000000000000000000000000000000000000000..eabf12f38a1de0da267044d2a713b8dec9a8327f GIT binary patch literal 2683 zcmV->3WW8EP)G0000U?Nkli^FoK8AW<}f*BW)5Gb zV40b@VP-5dH&_hA%@}0s+`$gd+10|?mG}9E>DS~~pZ>)}L=jy%o9mgt^UUF0*0F`% z)X+#HHSA^!>v)$rJjVpC<7~Q6M8dOHoW)pP;~N?gmuci{US%w2Qie;3)(qlNz95VE zkSw3`2m@(FLe-6Ntf3JJAPuZ$3|)vm6XlHO3nYYm&RCA%H;E#uS%HL{WelT;Ca)7y z*o7EnCzI$%lXVQQ(0~{x!;2h6lXEn)kO*Zuy=XGJ@D>ueWOYuN^W63^&YpTRm99N54)ZWvl&4t zek$V<=6E;+tYr`}G8V}(>lsBGg3^{TY{>aH=x$;m#S|o`EayDZ1TD=4tVD9k1R^iZ zqeu>!#c_n`MCKwnM_lG1B5JDZ zoy8bhV6BipyWX))%!i%N3Mz<-F04XaE<+qJ|_}qy&dj|mXjh> z*E!tx_b2WKJ3KD9k`T3KM|8W<=(lbwrG(}_1U+w&A|IrffuLtHA?ZScqf>4Nk}pd6 z9#K-@)eq&>X z_5@}MqUL-EVgM1TqJEY)trW8hQL+ev89-#JXgQ)}2ZaQp8c}mXPy>ie74=5c3?&dN z5GAu9@B!FY(R@V7QUXwcs5vf_0a#bjNr;-mEY1lohmZzfT}7)b&I5meC^;|G0a#Yi zMTnA5@L4xR$$HWu55TU9(riSORN|{~h>}qd`~d8$$m-u?je7jo#%KUGRn(qF^@OC4 zTC2;=2D1U!RM7(UC7Gr8W)Pxegy{e*s_1e=Nk4q^sQOfUsqFv^s;ISkjqHK&wxILC zdH@Dhv^cD-Xe;&3n49efU{FPOsILz%!53#CO3LjAU{FQ+vC-+_JpcG3*bl&@infGv zEbyAL%q#W-Fsh5y{E8>X1*y&BU|k^5%5(XK_3otzVG2k6_tXkMRYW>+~;KpzN#1OF>^9 z{6+co1k18|ben^}IkAg+Tgriv9>9~Px22Saabwm=1j(*?bXDquTP^Xy*UBe1b)KB9CWKKWc(YCgfSsvg}E<1Z>VI2;VNpH04(gxe^042WJceMHR*#4G zhmFKnT@fW4OkUVjjfFIuoIf2F)>c%wfB-1fW1}ykanm0QL@m8G$2aWQHYNsQ#}C1===YA+7Y0a5H)|sN0F%>fPWxr zo+VI6A!_RAf^Q;HJpkRwAZmILuo;M&RiyDrB&r9XfOilzZxXm(WDzwZ$v5K>HCeh5 zg4Yo>8IB>JoJa$r<|RT=;iwD0qm+EniuH(^dJZQfQxWt`Cq+I;GY3J>1VU5BHUvH6 z$OnH#(6faSLUa{^o-BiiyWxnVTt=u;tVGaLOBHd}hdKm3OGpv2N{1h9Cq0R)qu7O@ zr-maiFa~jyEmRUmU6fxs^P`?-tU(;*rx=c)3qK={vVsDP9Lip&--4JG(NT{7`ghX_ zLn9D}*-3BWq7S!cD?;{SAWjraOEY05#$5~4Yu-B1o zh~vzll&EUO9K><9(2j_xq84$S?>UYrI+68=xXgO`8CT~f#AWUyveG<_ zxXvL$+`NG1QUw5zqORH@TIQDZ!0Ws<@4}*n)V@TG|s+g*<`ymQBp&LGEAx zr_!A^lu=9p1r$?8Te@>91Gs|+nZstpw>(TsVy&7zNKCVvA>@HWS&hUnE9gX?XvS!2 zkr<_hyD1>ARI&nzNtSX1c__t|Y(*lTEnG&5yj98+GDt*I&jd=ydlkG&7KvE0yu{%& z89jL$iBR648%<6xULb>g?L12lnyd~?WCvoD9ZaAdO@>{3dZYqxlpG zAs;i6L$Mc0m5gEq4M+gVu$+-pkWiJw5hWyCD&cfS@iJfJ@SVu; zIWIAi(^mOzrolSMRRfyE$;4ecx~Hn7@1Ogx|hy%MCQ9qO$RDe#^HC zpstPnjm|0k%Yiok7TvL{;E|kv}SgW!Bkj0yF!V+S$K@TzxV4!K-+%(Hi;_KK&{D zBJ$%we*j1#@WJB!E+8NO_5P*{_X+D^8HN85TWGkqsvD*(Cx7{kF}?T&&z+`c;J@o9@~1Er~m8r+V01mVm*K5|73q??qg)N*|>IwQIk}T&|WhePO!y`__BKzVB17EV%yV-u0^U zrhBIcHpgAAj(=@?{Y#u1ZvkPqW*dGt`O1RT?>1(h5B)A- z`eIde&aL9Bwr;#bUKx&vYbgFzka*SB?Cs81a*NqwOZ$DRf#I;i5f~1a=dFHcm~|oa z`;9HnS5@~&l?;7lUuNC=9rb?avhPM&7qY9~XYYMJ>)z=Vj_Y6UUH^4nc9s5Ow!O=~ s@4HsL?&aPdsX?I_N3=nN5B&eZxNhR%O;Vp<$bdvVUHx3vIVCg!0EgrVrT_o{ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_fullscreen_exit.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_fullscreen_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..0db7b8f8b49546821cbc46c02fed6be066776c7d GIT binary patch literal 1042 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V7}?;;uum9_x7$~mxQAL%fa9O zm#fSpPRl-y~18Q=$WiIqO!uTe#eJd-{P{*XWu%# vg8$dxXD0tTIFu6(g-INkckzG^>;EyWn|OHBwewp`K*~H_{an^LB{Ts54KW+& literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_next.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_next.png new file mode 100644 index 0000000000000000000000000000000000000000..7cb2bd1b5b93a3a1e3e6749733b54a192aa83279 GIT binary patch literal 799 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV0z~1;uum9_x9G_+>k_(mPGE( zmX@9gGiESlG;nD&f=~<-rzUR>&g4S@8=ZXxV7i=d)cWP4(}Yr>TlQNeZCW4 zY_f>Kl7Gq1d(V3Jzw>ZmERs8M^Szn;->vS7ToXP!oY`)+{*UcyVb&9s3jIHBKHIxL zKNY0m3Fr6RxF5g6RX~dLeq4OEw*FWaNVmrW|JnYz^M0nUbrMl{-uR{<>+>!9d{&U2 zl;!ccc0bb3se$yY+j7O$5v1Zy!eyoJEP)Mk*ozjczT*VD=ERliCJl#k%$g=|=P7_x zTS?UU3xM1(muIhf2h$Uq3Fq4G2!mbAaQ8h}Q_6&O5MzvPc*;V|Noo27F=ytAHxP5w zZif^=4C3DL6Cxy5^cCU|ooA~csw1zggEAs&Ad1-wK0*}pH86#Iau7SVzCq4rg6l{3 z1bw*^Nk7atnDeJ7>NL51a1cA7RiVP4vdLGb`Pe51wMQ~G&Fn^u{4HmmH1d=Q^|zdT z+Q@UrdEx304r~uLewbRJFgNzj<8X-Aj#U5Q0(-wX7RI^|3S*tP3S~WUGk^b48yp7G zIa)WMn!~kjLs`~SVbZdGuQnWC4-P-3*zZ3?!NJ9P7se89djmDco#!r8bJ`EN{os(d zoV^XI)uggN9vD&I6}nZwfz5a>W2U?Ulf4=A?ag8c$nm)*01FY;K?(!)^fuu(yJ1<1$i4^X0q4<28ddf`mO?{an^LB{Ts5zB{l^ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_play.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_play.png new file mode 100644 index 0000000000000000000000000000000000000000..1f0f6818a81669d379e2e151b06eb0215e5cd3fa GIT binary patch literal 667 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU`q0IaSW-5dwcV2uCSxbv4w$K^LgdJ`X_%qng3+Zxy!;p zv*6%B+9VtQ?`i*v{gSFa+xvb`-o^ji z+sqn1#{!@1W6+wsvAj~J;Z(|-&}*Cv3?!?kd}UhUb8KG9Uk0wp8^v#`0i|+cxA6j{ zqNlxO21<#g?g!~oy=mWYC?&_Wj}vHM|FmR~fyz&R0Hr>1WU2$D?nK|>0!n?KWCqf; zcIjK7frZ|g_CN#gIQ;_Ib$!}CkX>7soCitqzBC6))wuiux$ynOLMEVr(Wz^I1{SXD zk#tBj(wkTaGAY}rj-mBrg(pivo}@j4JmdA0I}CpWceotmIlvR6mCX8}dqH&S9EKXv zn*J~H4fSlcN`KiBZmm_U1(B+?tUyxtFH6F$T*bHSKvFXoMC#@OW#6ji0%hOoz6Hw4 zDjo-l%W576ip%OA2a0S}`VAD>s`4ACW~;_+5L@RqPpZdz+v-{%HB8FvmC7;{}vW+W68#2q? p^nsG;rh7l0H+&0E4IqYEJ>!a_JAXN@tC0k8Jzf1=);T3K0RVhv8pZ$s literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_previous.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..50cf727a0261f0e1355c121b1c9033096ab61d81 GIT binary patch literal 1018 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU_R*S;uum9_xA3_UJ*x;mWRSZ ze0*|hW@d~N7z72FAc#+oQ-HxvPfbpY `!`%Gc@uGMqr)*4?fe*b?-Swi_U-(1gv zyV`fJiUQ5W1`co<-dC@EKXL2x%3Igm|9&p|5^N^t!+2}+o~+l^^%{S77vkDl!pxyn!`i^Zwk|)9nJ5uh(^F zHslL$F#EiDo89Z?J4@bcf&@Yv%-+A=R`1RLh%dJ(-$3j61fEafBRT^K=sS5rx^;z1Xw$yfB%sD@(aXS$J$~2?W6IF z+EYMT~p?d1#Mv2`b3TN!<*!F_lwK0=1=@^KU58^~JCOrdj%0Zke#z$vr z|L|BjgE(1?Ne6*4a~A`h9i#O&yw@0hLybYKv-04LA^CF_7;q`Z`2 zB1qyn$mM!^4l3_~_Dh0XeU!ChD#&GPK+asmbfgQU!sda+1p!lNN*Q zuLjz0xSC<(Ly-NTpxeOOAq&!9JO$_(kq3&G9C)G|JU{p!*a$Rl4O7X%dZvD7X2Ue` z1ff3?7IwTxEOs-*ZV)sv13EpVLGK2a+;p}i9lZl87Cch@Y)LUO4SE)Qa@}l6OGFZc zY7|p=q&g->HR%2Dy73R_jlB%9AC_LI%~Uw1#X5r}p8I|~Thbb!beVB6|n!^ZzY_H>}v4g;OT`JA)rEGRT~FqITOtZa${nI*A_AvUI9-h%5O zv)XhIsEEx`e_;x83oz{-o%x*e*FR8b3b4*Na^^D&B>k;mDlvR)<9I)~k1a`N9m8Y; zpsHISRU24eJemho6$`Y!@CDG8UvxOQ5?0 zkKdu074HM)w0v*5wPNCdPnTb0yGe=vsrw$1=`7m){dbkJMnx49&;$^8<8rD0S9Zn| zfdj7}evYe`^_|0Hp!DK}4ujH*6)X-ccXe12TrRXuV+IO3TnCaK%L^IYSWcE$GblHO zM2R;DIx4N@KhUAjyNxx$WrE9XkdlSJfo6Ct_|3fGr?Q32$C@8|O#juqFf4XoaJ-JG zz$BkVsbTLK$wrO@|Ie-pOgC=F2zD^+J8T0YnHf#Y&i_zjx^X$?@obI+{m;GmN)5JW zEcazbGyJ>tMfc6&@Pl6$HD3SqJN^0WR}-br*zU``%`9+W`Oo9#xz!9z7N;ccZ+Xke zp^#Z~c^mr+IfrXMmgVwaux!}#(f76dfn979fJWVA{&Uf=;m$|j?eY&|wli!xZr&@u z;FXBOzaPt9^KV$<&0u@XT-W}9sVvhA<9)g|2Ry16_8!~*iDA16`-S`(>*@y6Am$gA z`{v3etUba|^*?x{+}3qA0^$zUAliW;(6)yKewt?wC(^8Pg z7v2X2f2tob|5&zOn*ZqC{at;J-R&Ckf8F`MY{&2XOV58yx+`HYX@BS&)%G!To ztO?#(sPgXL>JrO+M{OJOSGn^Yxx3%v^yBC|Kr^bIINbRwUQ%1rUCnSmq+h0E_kZr* zE^PyZ16&aiK#oaKbl{o8u;Bl$mE zv0%uRzh5Nx;E4HEWp<-?*J~d~A7h^L5oFfS_t*bEzWs+k0A%TbJKtY#{P@;+`u~@g z-pJqIa%~Uei;o`-vl){9>^&piAW`pM%&Ktg{%sq^j*t6+>A>8dK}`vmI2dPi022yp zf>IMO(KK{&d^5;rSaw|5E}E5y)5|>!BYI6<$=d{cLxT|l6V#tU@}xzXfU}Y z#>l90=@$ow!qjW74h+CF%ftjszAP*k8rK1p#%5SDF}-k^E-2t&t_ozm&;b&!L>L)M imK~_q5`#w7zq#>q=6EHZS9o{}B;x7n=d#Wzp$PzYw5NUm literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_repeat_off.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_repeat_off.png new file mode 100644 index 0000000000000000000000000000000000000000..64324b3fdc92c905abec6b9a9fc50923427b6504 GIT binary patch literal 840 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVCMI9aSW-5dpp-KPufv{MbaW@ zNkl`kE8ic%|NqS&OHZ8k^6|Tm)3&$R+zoY5XOZ|)Vra>AwGEqU`futkD<13Q(oGdeDMm<((|3^h2!7ZzA z{*f6BKW~)$)!159A#z9K>gn6R{$7=Qv)}LS$H@no6%O3LB5yBg!@x9Wv0WQ!KtL7b_ZsSmv_2rH0y86i({rZ&+ zZ?3$!*&Xv=dXYU)`KI8->H0-~|0`}osQa&8SYLCJIc2%q`M4*)>IHrugB$Vx1jOul zdo#ZFZ~XX`ufRx5;n?x(RtH+a#=8Bl$f=pj95ZpQlx_9O`}T73@sAm_!Io_OeSG6b z<9Gi}R;^hKZAkbNxl-N1;NO`mLJAV~Wv@5{dj9t)G*rIgn7|@*#!r`Vl0(x> zpb8-m18q}Q4<^pECF>YGWu`lP4E*2`cg?F~

n| zbp#RcnA*+4@xX`UUx*Go c*biL)DE;W)lg~%kjT}LOp00i_>zopr0H=Ra_W%F@ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_repeat_one.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_repeat_one.png new file mode 100644 index 0000000000000000000000000000000000000000..bd4fc75657140bcc634bb8938c017b456a16234e GIT binary patch literal 1026 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU_R;T;uum9_xA3=LSbKlwnTRa z9=}87O&{i3ds;C2?JHL2bW)ts|93*m(~DVK7SG*UzU%(-Ed3DA=(~SE$@3ij{{d(e z7`$+~H2u@{2`5AjeEza$%Kko;K1K<_loBxp!ITgd2bP;>SQ1<&v_4}tP&(mo9Y}Wg zeP!t3P`dmUNG`gx3B^Aa@sA}QC@TT6KljZh9tTBTXGoHC7C;_Yc9WKopV~u z;mzaOuedc*xfFnATo*S;|IDzgyk_pa`$wz&ckRD_KXRW-WMBTQ_yeYQ`3n~PdI%BR zqkR5jZW^O$v`oUuhyL3t{{Q^5voHTBQ{KxmhOM&ymfZgB|7Y&;_vgxMtfLPIt>$0w zo3DP;ufMAG%Qwm0@8gchy2`lh{C|!88!oQ;em?M9z1B5v-R&_OUol*MUOzSdM#k0O zhKHddN)3NM2A$RoUt`t4kx;+qO3`W2)qDy}H|A%q^uPSM_~GY}x_w8#{CsbGzw+Oo zKv_nYWw-ygDm0vMVc6NpP$bOwM2+c$FUy44917-w4tYuqcYqQvg_$m}|I?5$e^Jcz zV}m`i>Hn-=f(Pn+5AV;}%OH5b?q$KBv*JMeW}Z^iK6JL;@j4Rkf8VR)+Y7iq_&l8;`taF*|Lw?f z{nKRcAJRQwJk>d@W%mB3Kg9AF=dG04q;&01mi4})Q3sqKFE7I+d_IIPQZTvh zZh1}j#s*`MqNG1ROaFh&{lh9^6Kt7s^v37xyC2_%PXB*-=|%bTTdwS2%=r7!FpJ^Q zkG(zO4Hk9&$*c-}_itM;c0A5+WSAta&LAxK#Vm|LP3cAE9tIzm3#Vr=&gfXsUdU{) zwqM~n+a literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_rewind.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..709611c56871c430ce3a024b9cc670a3a9956d81 GIT binary patch literal 830 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU}p7naSW-5dwbWmFC<;0C9%cD z$?4gI88cWbnAaTePGEha64yB0!RZxm1>-qS&XY0|BRuryx8$Vh9rpP@r~16+%i1NA zzo=Xl?MYj;{z|``=Oh)Zh?ikc%=fb2Rb5~HPdNwPBI_*ZzL)RvE~;?0NoXuc~eD?k?N$zej(<2cVqe{&l~1ePCwJYyUVBqyo9QNpL!Y-tE!`9G{rk3+^CiL+9{!A8>lC;C_nzFBa*MfCZ0cu+ z9?^gO@6NX3CP9$qt6C4Q?()B#=659ysP^_6^ZzAxZJWZh0S2Ae*;adivpW-258PXusO*K8iN~C0gp| zcvbupK0XCHr=F+p=Hqg0 zU_dBu_%F9V8yF7q&40iBa0a^NKgXNR$K^p`>%O3_-+l|wAN`JZKbxwo8e^>5k!^t|%KcmIQL-RJ$jbWeN0gTQ^Z z6O?x36fz%JSucZXE-MR!774hL@k-SI0?5)iMgO^fg>RR`S!P?|)9k|A>%u1{Ox; z2d0L>|0Wk#Dy=YQXsQ(azD2k0@E9X`v*RA+~RIXsADN+d82m!Kr%}%n}dOE)9x+TpZ>I2F!}s` z!zs_d67FoPn{O>-Z;;*E`>}*YA35_t4>R@o&4v7!>8Me7#h8Ad+JD$d-cC^Xz`sv#W4aQu3uJzpxo!}hp7d z0|!vg9*%dQPwu}puezINbU(m-8u&{>twVQxFLkLh|mEaLR2Hq3q z8~j)bnH`)z@#@Gso?*DKpoTe?Wq{E|e8`_=<|M-96apW3?f?uKQru%&I7guNS z_;=~k&H%ZZ@a-LVB_ZSGaQ&}8*`r)e`Hp8QRmvD_uuO6=VQMk?zLkr0Kg@~ b|DWuhdyU?y{&Ok@mN*Qau6{1-oD!ML&cDgwyTP~Y(MDaVq4fX`(yujlJowZH$c)pJSkmXK3smy|4I{)+u?ewC8i?j zT@tn~)@^!w|I|e*!Kzu3n3i@{2ED4@3emO7FSNo^OThWTC&B zBIIG$>5D529Fm2`!938$;=hS$v(Gr=1gmt(x;?8zQwVQ3127D;IlD=${*JqGdt1~@ z_|SEF!;&|CHLOai6*cCLdv_@`p8?n8o&)WNm{N*gbi=0D*UEtvjwroT;+p@GOO-+X z=Fv`>0WiuDN8reSt>xG~n>o-xVry}cK5Es4iw04aIjp^1Ws40G73Zp{! zs%zr@U722?jgY36&UvmNj`v5FXPl>d?!xCjBPmvQxgk=|KI1=9F*7~A$86UMXdLZI zq_Z!mP@=qDXN*EIr55mIOP`z-d(P_fp7RqQB$f0zYqy}P6N0`P#@gv3H@U?>K%H0U zz^+33dA-H=za%S4HI-o&Cp(E&F}(4myE*Qu;^+~?8GfyJ1rmv!2zvmu7SL z>?$dA)UIP2g_7|UP$DGb5F#$`bD;F0GGd5<2+vB!PXb;5|G1XU zTA&pV0A`Xy2L)R!e{);)~QSz9@tG?qLWY2~SJx&KGis>B4?+9Qv zuy&QwR$V$wkp1F^y<1!4!O&?J0K5#qva(Bk4APW}|J`e4LVnC#_iY~=_BJYqU bPP~iHq(^X7P;ox_`+(Hs^rV}6^Q-;^LliKs literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_stop.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_icon_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..06c99c9b59edd43ee52d982867bb46b124db64e9 GIT binary patch literal 274 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGoH$7b(Ln`9lp5MsHpulj%;d^}4 z@Ao?VMH3i{{3fY*BG8}R6?UE c;FVdQ&MBb@0LR;R#Q*>R literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_all.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_all.png new file mode 100644 index 0000000000000000000000000000000000000000..b847d068d2ab7eb3b965dd94a375a7a15def67c1 GIT binary patch literal 922 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU{3UOaSW-5dwcg_p>UvxOQ5?0 zkKdu074HM)w0v*5wPNCdPnTb0yGe=vsrw$1=`7m){dbkJMnx49&;$^8<8rD0S9Zn| zfdj7}evYe`^_|0Hp!DK}4ujH*6)X-ccXe12TrRXuV+IO3TnCaK%L^IYSWcE$GblHO zM2R;DIx4N@KhUAjyNxx$WrE9XkdlSJfo6Ct_|3fGr?Q32$C@8|O#juqFf4XoaJ-JG zz$BkVsbTLK$wrO@|Ie-pOgC=F2zD^+J8T0YnHf#Y&i_zjx^X$?@obI+{m;GmN)5JW zEcazbGyJ>tMfc6&@Pl6$HD3SqJN^0WR}-br*zU``%`9+W`Oo9#xz!9z7N;ccZ+Xke zp^#Z~c^mr+IfrXMmgVwaux!}#(f76dfn979fJWVA{&Uf=;m$|j?eY&|wli!xZr&@u z;FXBOzaPt9^KV$<&0u@XT-W}9sVvhA<9)g|2Ry16_8!~*iDA16`-S`(>*@y6Am$gA z`{v3etUba|^*?x{+}3qA0^$zUAliW;(6)yKewt?wC(^8Pg z7v2X2f2tob|5&zOn*ZqC{at;J-R&Ckf8F`MY{&2XOV58yx+`HYX@BS&)%G!To ztO?#(sPgXL>JrO+M{OJOSGn^Yxx3%v^yBC|Kr^bIINbRwUQ%1rUCnSmq+h0E_kZr* zE^PyZ16&aiK#oaKbl{o8u;Bl$mE zv0%uRzh5Nx;E4HEWp<-?*J~d~A7h^L5oFfS_t*bEzWs+k0A%TbJKtY#{P@;+`u~@g z-pJqIa%~Uei;o`-vl){9>^&piAW`pM%&Ktg{%sq^j*t6+>A>8dK}`vmI2dPi022yp zf>IMO(KK{&d^5;rSaw|5E}E5y)5|>!BYI6<$=d{cLxT|l6V#tU@}xzXfU}Y z#>l90=@$ow!qjW74h+CF%ftjszAP*k8rK1p#%5SDF}-k^E-2t&t_ozm&;b&!L>L)M imK~_q5`#w7zq#>q=6EHZS9o{}B;x7n=d#Wzp$PzYw5NUm literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_off.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_off.png new file mode 100644 index 0000000000000000000000000000000000000000..64324b3fdc92c905abec6b9a9fc50923427b6504 GIT binary patch literal 840 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVCMI9aSW-5dpp-KPufv{MbaW@ zNkl`kE8ic%|NqS&OHZ8k^6|Tm)3&$R+zoY5XOZ|)Vra>AwGEqU`futkD<13Q(oGdeDMm<((|3^h2!7ZzA z{*f6BKW~)$)!159A#z9K>gn6R{$7=Qv)}LS$H@no6%O3LB5yBg!@x9Wv0WQ!KtL7b_ZsSmv_2rH0y86i({rZ&+ zZ?3$!*&Xv=dXYU)`KI8->H0-~|0`}osQa&8SYLCJIc2%q`M4*)>IHrugB$Vx1jOul zdo#ZFZ~XX`ufRx5;n?x(RtH+a#=8Bl$f=pj95ZpQlx_9O`}T73@sAm_!Io_OeSG6b z<9Gi}R;^hKZAkbNxl-N1;NO`mLJAV~Wv@5{dj9t)G*rIgn7|@*#!r`Vl0(x> zpb8-m18q}Q4<^pECF>YGWu`lP4E*2`cg?F~

n| zbp#RcnA*+4@xX`UUx*Go c*biL)DE;W)lg~%kjT}LOp00i_>zopr0H=Ra_W%F@ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_one.png b/app/src/main/res/drawable-xxxhdpi-v4/exo_media_action_repeat_one.png new file mode 100644 index 0000000000000000000000000000000000000000..bd4fc75657140bcc634bb8938c017b456a16234e GIT binary patch literal 1026 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU_R;T;uum9_xA3=LSbKlwnTRa z9=}87O&{i3ds;C2?JHL2bW)ts|93*m(~DVK7SG*UzU%(-Ed3DA=(~SE$@3ij{{d(e z7`$+~H2u@{2`5AjeEza$%Kko;K1K<_loBxp!ITgd2bP;>SQ1<&v_4}tP&(mo9Y}Wg zeP!t3P`dmUNG`gx3B^Aa@sA}QC@TT6KljZh9tTBTXGoHC7C;_Yc9WKopV~u z;mzaOuedc*xfFnATo*S;|IDzgyk_pa`$wz&ckRD_KXRW-WMBTQ_yeYQ`3n~PdI%BR zqkR5jZW^O$v`oUuhyL3t{{Q^5voHTBQ{KxmhOM&ymfZgB|7Y&;_vgxMtfLPIt>$0w zo3DP;ufMAG%Qwm0@8gchy2`lh{C|!88!oQ;em?M9z1B5v-R&_OUol*MUOzSdM#k0O zhKHddN)3NM2A$RoUt`t4kx;+qO3`W2)qDy}H|A%q^uPSM_~GY}x_w8#{CsbGzw+Oo zKv_nYWw-ygDm0vMVc6NpP$bOwM2+c$FUy44917-w4tYuqcYqQvg_$m}|I?5$e^Jcz zV}m`i>Hn-=f(Pn+5AV;}%OH5b?q$KBv*JMeW}Z^iK6JL;@j4Rkf8VR)+Y7iq_&l8;`taF*|Lw?f z{nKRcAJRQwJk>d@W%mB3Kg9AF=dG04q;&01mi4})Q3sqKFE7I+d_IIPQZTvh zZh1}j#s*`MqNG1ROaFh&{lh9^6Kt7s^v37xyC2_%PXB*-=|%bTTdwS2%=r7!FpJ^Q zkG(zO4Hk9&$*c-}_itM;c0A5+WSAta&LAxK#Vm|LP3cAE9tIzm3#Vr=&gfXsUdU{) zwqM~n+a literal 0 HcmV?d00001 From 488da050ba8c3e340d0f916cf6d581b00ddcd539 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 11:21:24 +0200 Subject: [PATCH 03/29] feat(player): rewrite the media session glue for media3 --- .../newpipe/player/PlayerServiceForAuto.java | 49 ++- .../player/helper/MediaSessionManager.java | 313 +++++++++++++----- .../MediaBrowserPlaybackPreparer.kt | 13 +- .../mediasession/PlayQueueNavigator.java | 109 ------ .../player/mediasession/PlaybackPreparer.java | 22 ++ 5 files changed, 290 insertions(+), 216 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/PlaybackPreparer.java diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerServiceForAuto.java b/app/src/main/java/org/schabi/newpipe/player/PlayerServiceForAuto.java index b529c34ba..85b0ac81a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerServiceForAuto.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerServiceForAuto.java @@ -38,7 +38,8 @@ import androidx.core.content.ContextCompat; import androidx.media.MediaBrowserServiceCompat; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import android.net.Uri; +import android.support.v4.media.session.PlaybackStateCompat; import org.schabi.newpipe.App; import org.schabi.newpipe.databinding.PlayerBinding; import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl; @@ -71,7 +72,6 @@ public final class PlayerServiceForAuto extends MediaBrowserServiceCompat implem // these are instantiated in onCreate() as per // https://developer.android.com/training/cars/media#browser_workflow private MediaSessionCompat mediaSession; - private MediaSessionConnector sessionConnector; private Player player; private WindowManager windowManager; @@ -103,20 +103,44 @@ public void onCreate() { // see https://developer.android.com/training/cars/media#browser_workflow mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ"); setSessionToken(mediaSession.getSessionToken()); - sessionConnector = new MediaSessionConnector(mediaSession); - sessionConnector.setMetadataDeduplicationEnabled(true); + mediaSession.setActive(true); mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer( this, - sessionConnector::setCustomErrorMessage, - () -> sessionConnector.setCustomErrorMessage(null), + this::setSessionError, + () -> setSessionError(null, 0), (playWhenReady) -> { if (player != null) { player.onPrepare(); } } ); - sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer); + mediaSession.setCallback(new MediaSessionCompat.Callback() { + @Override + public void onPrepare() { + mediaBrowserPlaybackPreparer.onPrepare(false); + } + + @Override + public void onPlayFromMediaId(final String mediaId, final Bundle extras) { + mediaBrowserPlaybackPreparer.onPrepareFromMediaId(mediaId, true, extras); + } + + @Override + public void onPrepareFromMediaId(final String mediaId, final Bundle extras) { + mediaBrowserPlaybackPreparer.onPrepareFromMediaId(mediaId, false, extras); + } + + @Override + public void onPlayFromSearch(final String query, final Bundle extras) { + mediaBrowserPlaybackPreparer.onPrepareFromSearch(query, true, extras); + } + + @Override + public void onPlayFromUri(final Uri uri, final Bundle extras) { + mediaBrowserPlaybackPreparer.onPrepareFromUri(uri, true, extras); + } + }); } private void createView() { @@ -302,6 +326,17 @@ public Player getPlayer() { return player; } + private void setSessionError(@Nullable final String message, final int code) { + final PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder() + .setActions(PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID); + if (message != null) { + builder.setState(PlaybackStateCompat.STATE_ERROR, 0, 1).setErrorMessage(code, message); + } else { + builder.setState(PlaybackStateCompat.STATE_NONE, 0, 1); + } + mediaSession.setPlaybackState(builder.build()); + } + /** * @return the media session for Android Auto compatibility */ diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java index 72c1be036..fa231b28c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java @@ -2,6 +2,7 @@ import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -15,38 +16,54 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media.session.MediaButtonReceiver; - -import com.google.android.exoplayer2.ForwardingPlayer; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer; +import androidx.media3.common.Player; +import androidx.media3.common.util.Util; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.player.NotificationUtil; import org.schabi.newpipe.player.mediasession.MediaSessionCallback; -import org.schabi.newpipe.player.mediasession.PlayQueueNavigator; +import org.schabi.newpipe.player.mediasession.PlaybackPreparer; import org.schabi.newpipe.player.playback.PlayerMediaSession; import org.schabi.newpipe.util.StreamTypeUtil; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Optional; -import static org.schabi.newpipe.player.PlayerService.*; +import static org.schabi.newpipe.player.PlayerService.ACTION_CHANGE_PLAY_MODE; +import static org.schabi.newpipe.player.PlayerService.ACTION_CLOSE; public class MediaSessionManager { private static final String TAG = MediaSessionManager.class.getSimpleName(); public static final boolean DEBUG = MainActivity.DEBUG; + private static final int MAX_QUEUE_SIZE = 10; + + private static final long PLAYBACK_ACTIONS = PlaybackStateCompat.ACTION_SEEK_TO + | PlaybackStateCompat.ACTION_PLAY + | PlaybackStateCompat.ACTION_PAUSE + | PlaybackStateCompat.ACTION_PLAY_PAUSE + | PlaybackStateCompat.ACTION_SKIP_TO_NEXT + | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + | PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM + | PlaybackStateCompat.ACTION_SET_REPEAT_MODE + | PlaybackStateCompat.ACTION_STOP + | PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID; + @NonNull private final MediaSessionCompat mediaSession; @NonNull - private final MediaSessionConnector sessionConnector; + private final Player exoPlayer; + @NonNull + private final MediaSessionCallback callback; + @Nullable + private final PlaybackPreparer playbackPreparer; private final boolean isExternalSession; - private int lastTitleHashCode; - private int lastArtistHashCode; - private long lastDuration; - private int lastAlbumArtHashCode; + @Nullable + private PlaybackStateCompat.CustomAction errorAction; private org.schabi.newpipe.player.Player player; @@ -68,90 +85,205 @@ public MediaSessionManager(@NonNull final Context context, @NonNull final MediaSessionCallback callback, @Nullable final MediaSessionCompat existingSession, @Nullable final PlaybackPreparer playbackPreparer) { - if (DEBUG) { - Log.d(TAG, "MediaSessionManager called"); - } - mediaSession = existingSession != null ? existingSession : new MediaSessionCompat(context, TAG); + this.exoPlayer = player; + this.callback = callback; + this.playbackPreparer = playbackPreparer; + mediaSession = existingSession != null ? existingSession + : new MediaSessionCompat(context, TAG); isExternalSession = existingSession != null; mediaSession.setActive(true); - mediaSession.setPlaybackState(new PlaybackStateCompat.Builder() - .setState(PlaybackStateCompat.STATE_NONE, -1, 1) - .setActions(PlaybackStateCompat.ACTION_SEEK_TO - | PlaybackStateCompat.ACTION_PLAY - | PlaybackStateCompat.ACTION_PAUSE // was play and pause now play/pause - | PlaybackStateCompat.ACTION_SKIP_TO_NEXT - | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - | PlaybackStateCompat.ACTION_SET_REPEAT_MODE - | PlaybackStateCompat.ACTION_STOP - | PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID) - .build()); - - sessionConnector = new MediaSessionConnector(mediaSession); - sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback)); - sessionConnector.setPlayer(new ForwardingPlayer(player) { - @Override - public void play() { - callback.play(); + mediaSession.setCallback(sessionCallback); + + exoPlayer.addListener(playerListener); + updatePlaybackState(); + } + + private final Player.Listener playerListener = new Player.Listener() { + @Override + public void onEvents(@NonNull final Player p, @NonNull final Player.Events events) { + if (events.containsAny( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_IS_PLAYING_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY, + Player.EVENT_REPEAT_MODE_CHANGED, + Player.EVENT_PLAYBACK_PARAMETERS_CHANGED)) { + updatePlaybackState(); + } + if (events.containsAny( + Player.EVENT_MEDIA_ITEM_TRANSITION, + Player.EVENT_MEDIA_METADATA_CHANGED, + Player.EVENT_TIMELINE_CHANGED)) { + updateMetadata(); + publishQueue(); } + } + }; + + private final MediaSessionCompat.Callback sessionCallback = new MediaSessionCompat.Callback() { + @Override + public void onPlay() { + callback.play(); + } - @Override - public void pause() { - callback.pause(); + @Override + public void onPause() { + callback.pause(); + } + + @Override + public void onSkipToNext() { + callback.playNext(); + } + + @Override + public void onSkipToPrevious() { + callback.playPrevious(); + } + + @Override + public void onSkipToQueueItem(final long id) { + callback.playItemAtIndex((int) id); + } + + @Override + public void onSeekTo(final long pos) { + exoPlayer.seekTo(pos); + } + + @Override + public void onStop() { + callback.close(); + } + + @Override + public void onCustomAction(final String action, final Bundle extras) { + if (ACTION_CHANGE_PLAY_MODE.equals(action)) { + callback.changePlayMode(); + } else if (ACTION_CLOSE.equals(action)) { + callback.close(); } - }); - MediaSessionConnector.CustomActionProvider[] providers = new MediaSessionConnector.CustomActionProvider[2]; - providers[0] = new MediaSessionConnector.CustomActionProvider() { - @Override - public void onCustomAction(@NonNull Player player, @NonNull String action, @Nullable Bundle extras) { - if (action.equals(ACTION_CHANGE_PLAY_MODE)) { - callback.changePlayMode(); - } + } + + @Override + public void onPrepare() { + if (playbackPreparer != null) { + playbackPreparer.onPrepare(false); } + } - @Nullable - @Override - public PlaybackStateCompat.CustomAction getCustomAction(Player player) { - switch (((PlayerMediaSession)callback).mode){ - case 0: - default: - return new android.support.v4.media.session.PlaybackStateCompat.CustomAction.Builder( - ACTION_CHANGE_PLAY_MODE, "Shuffle", R.drawable.shuffle_disabled).build(); - case 1: - return new android.support.v4.media.session.PlaybackStateCompat.CustomAction.Builder( - ACTION_CHANGE_PLAY_MODE, "Repeat all", R.drawable.exo_controls_shuffle_on).build(); - case 2: - return new android.support.v4.media.session.PlaybackStateCompat.CustomAction.Builder( - ACTION_CHANGE_PLAY_MODE, "Repeat none", R.drawable.exo_controls_repeat_one).build(); - case 3: - return new android.support.v4.media.session.PlaybackStateCompat.CustomAction.Builder( - ACTION_CHANGE_PLAY_MODE, "Repeat one", R.drawable.exo_controls_repeat_all).build(); - } + @Override + public void onPlayFromMediaId(final String mediaId, final Bundle extras) { + if (playbackPreparer != null) { + playbackPreparer.onPrepareFromMediaId(mediaId, true, extras); } - }; - providers[1] = new MediaSessionConnector.CustomActionProvider() { - @Override - public void onCustomAction(@NonNull Player player, @NonNull String action, @Nullable Bundle extras) { - if (action.equals(ACTION_CLOSE)) { - callback.close(); - } + } + + @Override + public void onPrepareFromMediaId(final String mediaId, final Bundle extras) { + if (playbackPreparer != null) { + playbackPreparer.onPrepareFromMediaId(mediaId, false, extras); } + } - @Nullable - @Override - public PlaybackStateCompat.CustomAction getCustomAction(Player player) { - // Close - return new android.support.v4.media.session.PlaybackStateCompat.CustomAction.Builder( - ACTION_CLOSE, "Close", R.drawable.ic_close).build(); + @Override + public void onPlayFromSearch(final String query, final Bundle extras) { + if (playbackPreparer != null) { + playbackPreparer.onPrepareFromSearch(query, true, extras); } - }; - sessionConnector.setCustomActionProviders(providers); - sessionConnector.setMetadataDeduplicationEnabled(true); - sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata()); - - // Set PlaybackPreparer if provided (for Android Auto support) - if (playbackPreparer != null) { - sessionConnector.setPlaybackPreparer(playbackPreparer); } + + @Override + public void onPlayFromUri(final Uri uri, final Bundle extras) { + if (playbackPreparer != null) { + playbackPreparer.onPrepareFromUri(uri, true, extras); + } + } + }; + + private void updatePlaybackState() { + final PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder() + .setActions(PLAYBACK_ACTIONS + | (playbackPreparer != null + ? playbackPreparer.getSupportedPrepareActions() : 0)) + .setState(toPlaybackStateCompat(), exoPlayer.getCurrentPosition(), + exoPlayer.getPlaybackParameters().speed); + builder.addCustomAction(changePlayModeAction()); + builder.addCustomAction(new PlaybackStateCompat.CustomAction.Builder( + ACTION_CLOSE, "Close", R.drawable.ic_close).build()); + if (errorAction != null) { + builder.addCustomAction(errorAction); + } + mediaSession.setPlaybackState(builder.build()); + } + + @PlaybackStateCompat.State + private int toPlaybackStateCompat() { + switch (exoPlayer.getPlaybackState()) { + case Player.STATE_BUFFERING: + return PlaybackStateCompat.STATE_BUFFERING; + case Player.STATE_READY: + return exoPlayer.getPlayWhenReady() + ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED; + case Player.STATE_ENDED: + return PlaybackStateCompat.STATE_STOPPED; + case Player.STATE_IDLE: + default: + return PlaybackStateCompat.STATE_NONE; + } + } + + @NonNull + private PlaybackStateCompat.CustomAction changePlayModeAction() { + final int mode = callback instanceof PlayerMediaSession + ? ((PlayerMediaSession) callback).mode : 0; + switch (mode) { + case 1: + return new PlaybackStateCompat.CustomAction.Builder(ACTION_CHANGE_PLAY_MODE, + "Repeat all", R.drawable.exo_icon_shuffle_on).build(); + case 2: + return new PlaybackStateCompat.CustomAction.Builder(ACTION_CHANGE_PLAY_MODE, + "Repeat none", R.drawable.exo_icon_repeat_one).build(); + case 3: + return new PlaybackStateCompat.CustomAction.Builder(ACTION_CHANGE_PLAY_MODE, + "Repeat one", R.drawable.exo_icon_repeat_all).build(); + case 0: + default: + return new PlaybackStateCompat.CustomAction.Builder(ACTION_CHANGE_PLAY_MODE, + "Shuffle", R.drawable.shuffle_disabled).build(); + } + } + + private void publishQueue() { + final int windowCount = callback.getQueueSize(); + if (windowCount == 0) { + mediaSession.setQueue(Collections.emptyList()); + return; + } + final int currentWindowIndex = callback.getCurrentPlayingIndex(); + final int queueSize = Math.min(MAX_QUEUE_SIZE, windowCount); + final int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, + windowCount - queueSize); + + final List queue = new ArrayList<>(); + for (int i = startIndex; i < startIndex + queueSize; i++) { + queue.add(new MediaSessionCompat.QueueItem(callback.getQueueMetadata(i), i)); + } + mediaSession.setQueue(queue); + } + + /** Show an error on the session (used by the Android Auto media browser preparer). */ + public void setCustomErrorMessage(@Nullable final CharSequence message, final int code) { + errorAction = null; + final PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder() + .setActions(PLAYBACK_ACTIONS) + .setState(PlaybackStateCompat.STATE_ERROR, 0, 1) + .setErrorMessage(code, message == null ? "" : message); + mediaSession.setPlaybackState(builder.build()); + } + + public void clearCustomErrorMessage() { + updatePlaybackState(); } @Nullable @@ -164,8 +296,13 @@ public MediaSessionCompat.Token getSessionToken() { return mediaSession.getSessionToken(); } - public void setPlayer(org.schabi.newpipe.player.Player player) { + public void setPlayer(final org.schabi.newpipe.player.Player player) { this.player = player; + updateMetadata(); + } + + private void updateMetadata() { + mediaSession.setMetadata(buildMediaMetadata()); } private MediaMetadataCompat buildMediaMetadata() { @@ -173,7 +310,7 @@ private MediaMetadataCompat buildMediaMetadata() { Log.d(TAG, "buildMediaMetadata called"); } - if(player == null) { + if (player == null) { return new MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, "") .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "") @@ -219,8 +356,8 @@ private MediaMetadataCompat buildMediaMetadata() { * Should be called on player destruction to prevent leakage. */ public void dispose() { - sessionConnector.setPlayer(null); - sessionConnector.setQueueNavigator(null); + exoPlayer.removeListener(playerListener); + mediaSession.setCallback(null); if (!isExternalSession) { mediaSession.setActive(false); mediaSession.release(); diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt index 129f24887..044e7b4b4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt @@ -3,11 +3,9 @@ package org.schabi.newpipe.player.mediabrowser import android.content.Context import android.net.Uri import android.os.Bundle -import android.os.ResultReceiver import android.support.v4.media.session.PlaybackStateCompat import android.util.Log -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer +import org.schabi.newpipe.player.mediasession.PlaybackPreparer import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.Disposable @@ -94,15 +92,6 @@ class MediaBrowserPlaybackPreparer( override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { onUnsupportedError() } - - override fun onCommand( - player: Player, - command: String, - extras: Bundle?, - cb: ResultReceiver? - ): Boolean { - return false - } //endregion //region Errors diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java deleted file mode 100644 index 92cd425c5..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.schabi.newpipe.player.mediasession; - -import android.os.Bundle; -import android.os.ResultReceiver; -import android.support.v4.media.session.MediaSessionCompat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.util.Util; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM; - -public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator { - public static final int DEFAULT_MAX_QUEUE_SIZE = 10; - - private final MediaSessionCompat mediaSession; - private final MediaSessionCallback callback; - private final int maxQueueSize; - - private long activeQueueItemId; - - public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession, - @NonNull final MediaSessionCallback callback) { - this.mediaSession = mediaSession; - this.callback = callback; - this.maxQueueSize = DEFAULT_MAX_QUEUE_SIZE; - - this.activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; - } - - @Override - public long getSupportedQueueNavigatorActions(@Nullable final Player player) { - return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM; - } - - @Override - public void onTimelineChanged(@NonNull final Player player) { - publishFloatingQueueWindow(); - } - - @Override - public void onCurrentMediaItemIndexChanged(@NonNull final Player player) { - if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID - || player.getCurrentTimeline().getWindowCount() > maxQueueSize) { - publishFloatingQueueWindow(); - } else if (!player.getCurrentTimeline().isEmpty()) { - activeQueueItemId = player.getCurrentMediaItemIndex(); - } - } - - @Override - public long getActiveQueueItemId(@Nullable final Player player) { - return callback.getCurrentPlayingIndex(); - } - - @Override - public void onSkipToPrevious(@NonNull final Player player) { - callback.playPrevious(); - } - - @Override - public void onSkipToQueueItem(@NonNull final Player player, final long id) { - callback.playItemAtIndex((int) id); - } - - @Override - public void onSkipToNext(@NonNull final Player player) { - callback.playNext(); - } - - private void publishFloatingQueueWindow() { - if (callback.getQueueSize() == 0) { - mediaSession.setQueue(Collections.emptyList()); - activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; - return; - } - - // Yes this is almost a copypasta, got a problem with that? =\ - final int windowCount = callback.getQueueSize(); - final int currentWindowIndex = callback.getCurrentPlayingIndex(); - final int queueSize = Math.min(maxQueueSize, windowCount); - final int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, - windowCount - queueSize); - - final List queue = new ArrayList<>(); - for (int i = startIndex; i < startIndex + queueSize; i++) { - queue.add(new MediaSessionCompat.QueueItem(callback.getQueueMetadata(i), i)); - } - mediaSession.setQueue(queue); - activeQueueItemId = currentWindowIndex; - } - - @Override - public boolean onCommand(@NonNull final Player player, - @NonNull final String command, - @Nullable final Bundle extras, - @Nullable final ResultReceiver cb) { - return false; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlaybackPreparer.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlaybackPreparer.java new file mode 100644 index 000000000..8be402b78 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlaybackPreparer.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.player.mediasession; + +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +/** + * Replacement for ExoPlayer's removed {@code MediaSessionConnector.PlaybackPreparer}: lets the + * media browser (Android Auto) turn a media id / search / uri into actual playback. + */ +public interface PlaybackPreparer { + long getSupportedPrepareActions(); + + void onPrepare(boolean playWhenReady); + + void onPrepareFromMediaId(String mediaId, boolean playWhenReady, @Nullable Bundle extras); + + void onPrepareFromSearch(String query, boolean playWhenReady, @Nullable Bundle extras); + + void onPrepareFromUri(Uri uri, boolean playWhenReady, @Nullable Bundle extras); +} From 586f031eb4a1dded2c8b8bcf84a1f91607d2f0ae Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 11:21:24 +0200 Subject: [PATCH 04/29] feat(player): convert HttpDataSource subclasses to delegation for media3 --- .../datasource/YoutubeHttpDataSource.java | 42 +-- .../newpipe/player/helper/CacheFactory.java | 20 +- .../player/helper/NiconicoLiveDataSource.java | 28 +- .../helper/NiconicoLiveHttpDataSource.java | 157 +++--------- .../player/helper/PlayerDataSource.java | 38 +-- .../player/helper/PurifiedDataSource.java | 28 +- .../player/helper/PurifiedHttpDataSource.java | 239 +++++++----------- .../resolver/CustomDataSourceFactory.java | 6 +- 8 files changed, 211 insertions(+), 347 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java index 6cb23953d..5417ddd51 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java @@ -7,11 +7,11 @@ package org.schabi.newpipe.player.datasource; -import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; -import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; -import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Util.castNonNull; +import static androidx.media3.datasource.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; +import static androidx.media3.datasource.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; +import static androidx.media3.datasource.HttpUtil.buildRangeRequestHeader; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Util.castNonNull; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl; @@ -26,20 +26,20 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.metadata.icy.IcyHeaders; -import com.google.android.exoplayer2.upstream.BaseDataSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSourceException; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.HttpUtil; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.Util; +import androidx.media3.common.C; +import androidx.media3.common.PlaybackException; +import androidx.media3.extractor.metadata.icy.IcyHeaders; +import androidx.media3.datasource.BaseDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSourceException; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.DataSpec.HttpMethod; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.HttpUtil; +import androidx.media3.datasource.TransferListener; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.Util; import com.google.common.base.Predicate; import com.google.common.collect.ForwardingMap; import com.google.common.collect.ImmutableMap; @@ -66,7 +66,7 @@ /** * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}, based on - * {@link com.google.android.exoplayer2.upstream.DefaultHttpDataSource}, for YouTube streams. + * {@link androidx.media3.datasource.DefaultHttpDataSource}, for YouTube streams. * *

* It adds more headers to {@code videoplayback} URLs, such as {@code Origin}, {@code Referer} @@ -182,7 +182,7 @@ public Factory setAllowCrossProtocolRedirects( * *

* Note that it must be not enabled on streams which are using a {@link - * com.google.android.exoplayer2.source.ProgressiveMediaSource}, as it will break playback + * androidx.media3.exoplayer.source.ProgressiveMediaSource}, as it will break playback * for them (some exceptions may be thrown). *

* diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index 202ff8541..dc5e52097 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -6,16 +6,16 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.FileDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.upstream.cache.CacheDataSink; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import androidx.media3.database.StandaloneDatabaseProvider; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.FileDataSource; +import androidx.media3.datasource.TransferListener; +import androidx.media3.datasource.cache.CacheDataSink; +import androidx.media3.datasource.cache.CacheDataSource; +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor; +import androidx.media3.datasource.cache.SimpleCache; import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveDataSource.java index 608196fc4..124237678 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveDataSource.java @@ -20,19 +20,19 @@ import android.net.Uri; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.upstream.AssetDataSource; -import com.google.android.exoplayer2.upstream.ContentDataSource; -import com.google.android.exoplayer2.upstream.DataSchemeDataSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.FileDataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.RawResourceDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.upstream.UdpDataSource; -import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.Util; +import androidx.media3.datasource.AssetDataSource; +import androidx.media3.datasource.ContentDataSource; +import androidx.media3.datasource.DataSchemeDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.FileDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.RawResourceDataSource; +import androidx.media3.datasource.TransferListener; +import androidx.media3.datasource.UdpDataSource; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.Util; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -331,7 +331,7 @@ private DataSource getContentDataSource() { private DataSource getRtmpDataSource() { if (rtmpDataSource == null) { try { - Class clazz = Class.forName("com.google.android.exoplayer2.ext.rtmp.RtmpDataSource"); + Class clazz = Class.forName("androidx.media3.datasource.rtmp.RtmpDataSource"); rtmpDataSource = (DataSource) clazz.getConstructor().newInstance(); addListenersToDataSource(rtmpDataSource); } catch (ClassNotFoundException e) { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveHttpDataSource.java index 6f8a0c889..62c71d4b5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveHttpDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/NiconicoLiveHttpDataSource.java @@ -1,24 +1,14 @@ package org.schabi.newpipe.player.helper; -import android.net.Uri; - import androidx.annotation.Nullable; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.TransferListener; import com.google.common.base.Predicate; -import com.grack.nanojson.JsonParserException; - -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; -import java.io.IOException; -import java.util.Arrays; -import java.util.Date; import java.util.HashMap; -import java.util.List; import java.util.Map; public class NiconicoLiveHttpDataSource extends PurifiedHttpDataSource { @@ -29,156 +19,73 @@ public class NiconicoLiveHttpDataSource extends PurifiedHttpDataSource { private boolean isFetching = false; public static class Factory implements HttpDataSource.Factory { - - private final RequestProperties defaultRequestProperties; + private final DefaultHttpDataSource.Factory inner = new DefaultHttpDataSource.Factory(); private final String url; - @Nullable - private TransferListener transferListener; - @Nullable private Predicate contentTypePredicate; - @Nullable private String userAgent; - private int connectTimeoutMs; - private int readTimeoutMs; - private boolean allowCrossProtocolRedirects; - private boolean keepPostFor302Redirects; - /** Creates an instance. */ - public Factory(String url) { - if(url.equals("")){ - throw(new RuntimeException("Build NicoNico live source failed. This should never happen.")); + public Factory(final String url) { + if (url.equals("")) { + throw new RuntimeException( + "Build NicoNico live source failed. This should never happen."); } - defaultRequestProperties = new RequestProperties(); - connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; - readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; this.url = url; } @Override - public final NiconicoLiveHttpDataSource.Factory setDefaultRequestProperties(Map defaultRequestProperties) { - this.defaultRequestProperties.clearAndSet(defaultRequestProperties); + public final Factory setDefaultRequestProperties( + final Map defaultRequestProperties) { + inner.setDefaultRequestProperties(defaultRequestProperties); return this; } - /** - * Sets the user agent that will be used. - * - *

The default is {@code null}, which causes the default user agent of the underlying - * platform to be used. - * - * @param userAgent The user agent that will be used, or {@code null} to use the default user - * agent of the underlying platform. - * @return This factory. - */ - public NiconicoLiveHttpDataSource.Factory setUserAgent(@Nullable String userAgent) { - this.userAgent = userAgent; + public Factory setUserAgent(@Nullable final String userAgent) { + inner.setUserAgent(userAgent); return this; } - /** - * Sets the connect timeout, in milliseconds. - * - *

The default is {@link PurifiedHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. - * - * @param connectTimeoutMs The connect timeout, in milliseconds, that will be used. - * @return This factory. - */ - public NiconicoLiveHttpDataSource.Factory setConnectTimeoutMs(int connectTimeoutMs) { - this.connectTimeoutMs = connectTimeoutMs; + public Factory setConnectTimeoutMs(final int connectTimeoutMs) { + inner.setConnectTimeoutMs(connectTimeoutMs); return this; } - /** - * Sets the read timeout, in milliseconds. - * - *

The default is {@link PurifiedHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. - * - * @param readTimeoutMs The connect timeout, in milliseconds, that will be used. - * @return This factory. - */ - public NiconicoLiveHttpDataSource.Factory setReadTimeoutMs(int readTimeoutMs) { - this.readTimeoutMs = readTimeoutMs; + public Factory setReadTimeoutMs(final int readTimeoutMs) { + inner.setReadTimeoutMs(readTimeoutMs); return this; } - /** - * Sets whether to allow cross protocol redirects. - * - *

The default is {@code false}. - * - * @param allowCrossProtocolRedirects Whether to allow cross protocol redirects. - * @return This factory. - */ - public NiconicoLiveHttpDataSource.Factory setAllowCrossProtocolRedirects(boolean allowCrossProtocolRedirects) { - this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + public Factory setAllowCrossProtocolRedirects(final boolean allowCrossProtocolRedirects) { + inner.setAllowCrossProtocolRedirects(allowCrossProtocolRedirects); return this; } - /** - * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a - * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link - * PurifiedHttpDataSource#open(DataSpec)}. - * - *

The default is {@code null}. - * - * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a - * predicate that was previously set. - * @return This factory. - */ - public NiconicoLiveHttpDataSource.Factory setContentTypePredicate(@Nullable Predicate contentTypePredicate) { - this.contentTypePredicate = contentTypePredicate; + public Factory setContentTypePredicate(@Nullable final Predicate predicate) { + inner.setContentTypePredicate(predicate); return this; } - /** - * Sets the {@link TransferListener} that will be used. - * - *

The default is {@code null}. - * - *

See {@link DataSource#addTransferListener(TransferListener)}. - * - * @param transferListener The listener that will be used. - * @return This factory. - */ - public NiconicoLiveHttpDataSource.Factory setTransferListener(@Nullable TransferListener transferListener) { - this.transferListener = transferListener; + public Factory setTransferListener(@Nullable final TransferListener transferListener) { + inner.setTransferListener(transferListener); return this; } - /** - * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for a - * POST request. - */ - public NiconicoLiveHttpDataSource.Factory setKeepPostFor302Redirects(boolean keepPostFor302Redirects) { - this.keepPostFor302Redirects = keepPostFor302Redirects; + public Factory setKeepPostFor302Redirects(final boolean keepPostFor302Redirects) { + inner.setKeepPostFor302Redirects(keepPostFor302Redirects); return this; } @Override public NiconicoLiveHttpDataSource createDataSource() { - NiconicoLiveHttpDataSource dataSource = - new NiconicoLiveHttpDataSource( - userAgent, - connectTimeoutMs, - readTimeoutMs, - allowCrossProtocolRedirects, - defaultRequestProperties, - contentTypePredicate, - keepPostFor302Redirects, - url); - if (transferListener != null) { - dataSource.addTransferListener(transferListener); - } - return dataSource; + return new NiconicoLiveHttpDataSource(inner.createDataSource(), url); } } - NiconicoLiveHttpDataSource(@Nullable String userAgent, int connectTimeoutMillis, int readTimeoutMillis, boolean allowCrossProtocolRedirects, @Nullable RequestProperties defaultRequestProperties - , @Nullable Predicate contentTypePredicate, boolean keepPostFor302Redirects, String liveUrl) { - super(userAgent, connectTimeoutMillis, readTimeoutMillis, allowCrossProtocolRedirects, defaultRequestProperties, contentTypePredicate, keepPostFor302Redirects); + + NiconicoLiveHttpDataSource(final HttpDataSource delegate, final String liveUrl) { + super(delegate); this.liveUrl = liveUrl; } + @Override - public long open(DataSpec dataSpec) throws HttpDataSourceException - { + public long open(final DataSpec dataSpec) throws HttpDataSourceException { // String fetchUrl = dataSpec.uri.toString(); // int type = 0; // List anonStrings = Arrays.asList("anonymous-user-", "anonymous_user_", "ht2_nicolive="); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 0217153e3..5172e98d2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -3,24 +3,24 @@ import android.content.Context; import android.net.Uri; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.source.SingleSampleMediaSource; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.hls.DefaultHlsExtractorFactory; -import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; -import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; -import com.google.android.exoplayer2.upstream.ResolvingDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.ProgressiveMediaSource; +import androidx.media3.exoplayer.source.SingleSampleMediaSource; +import androidx.media3.exoplayer.dash.DashMediaSource; +import androidx.media3.exoplayer.dash.DefaultDashChunkSource; +import androidx.media3.exoplayer.hls.HlsMediaSource; +import androidx.media3.exoplayer.hls.DefaultHlsExtractorFactory; +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistTracker; +import androidx.media3.exoplayer.smoothstreaming.DefaultSsChunkSource; +import androidx.media3.exoplayer.smoothstreaming.SsMediaSource; +import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; +import androidx.media3.datasource.ResolvingDataSource; +import androidx.media3.datasource.TransferListener; +import androidx.media3.datasource.cache.CacheDataSource; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParserException; @@ -48,7 +48,7 @@ import java.util.Map; import java.util.concurrent.TimeUnit; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParserFactory; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedDataSource.java index 903405c3a..c76a5163c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedDataSource.java @@ -20,19 +20,19 @@ import android.net.Uri; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.upstream.AssetDataSource; -import com.google.android.exoplayer2.upstream.ContentDataSource; -import com.google.android.exoplayer2.upstream.DataSchemeDataSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.FileDataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.RawResourceDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.upstream.UdpDataSource; -import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.Util; +import androidx.media3.datasource.AssetDataSource; +import androidx.media3.datasource.ContentDataSource; +import androidx.media3.datasource.DataSchemeDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.FileDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.RawResourceDataSource; +import androidx.media3.datasource.TransferListener; +import androidx.media3.datasource.UdpDataSource; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.Util; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -331,7 +331,7 @@ private DataSource getContentDataSource() { private DataSource getRtmpDataSource() { if (rtmpDataSource == null) { try { - Class clazz = Class.forName("com.google.android.exoplayer2.ext.rtmp.RtmpDataSource"); + Class clazz = Class.forName("androidx.media3.datasource.rtmp.RtmpDataSource"); rtmpDataSource = (DataSource) clazz.getConstructor().newInstance(); addListenersToDataSource(rtmpDataSource); } catch (ClassNotFoundException e) { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedHttpDataSource.java index 6fc957fcc..162145dc9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedHttpDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PurifiedHttpDataSource.java @@ -1,182 +1,139 @@ package org.schabi.newpipe.player.helper; +import android.net.Uri; + import androidx.annotation.Nullable; -import com.google.android.exoplayer2.metadata.icy.IcyHeaders; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.TransferListener; +import androidx.media3.extractor.metadata.icy.IcyHeaders; import com.google.common.base.Predicate; import java.util.HashMap; +import java.util.List; import java.util.Map; -public class PurifiedHttpDataSource extends DefaultHttpDataSource { - public static class Factory implements HttpDataSource.Factory { +/** + * media3 makes {@link DefaultHttpDataSource}'s constructor private, so we can no longer subclass it. + * We wrap one instead and only intercept {@link #open(DataSpec)} to drop the ICY metadata-enable + * header. Subclasses (NiconicoLive) override {@code open} and call {@code super.open}. + */ +public class PurifiedHttpDataSource implements HttpDataSource { + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; + public static final int DEFAULT_READ_TIMEOUT_MILLIS = + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; + + protected final HttpDataSource delegate; + + protected PurifiedHttpDataSource(final HttpDataSource delegate) { + this.delegate = delegate; + } - private final RequestProperties defaultRequestProperties; - - @Nullable - private TransferListener transferListener; - @Nullable private Predicate contentTypePredicate; - @Nullable private String userAgent; - private int connectTimeoutMs; - private int readTimeoutMs; - private boolean allowCrossProtocolRedirects; - private boolean keepPostFor302Redirects; - - /** Creates an instance. */ - public Factory() { - defaultRequestProperties = new RequestProperties(); - connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; - readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; - } + @Override + public long open(final DataSpec dataSpec) throws HttpDataSourceException { + final Map headers = new HashMap<>(dataSpec.httpRequestHeaders); + headers.remove(IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME); + return delegate.open(dataSpec.withRequestHeaders(headers)); + } + + @Override + public int read(final byte[] buffer, final int offset, final int length) + throws HttpDataSourceException { + return delegate.read(buffer, offset, length); + } + + @Override + public void addTransferListener(final TransferListener transferListener) { + delegate.addTransferListener(transferListener); + } + + @Nullable + @Override + public Uri getUri() { + return delegate.getUri(); + } + + @Override + public Map> getResponseHeaders() { + return delegate.getResponseHeaders(); + } + + @Override + public void close() throws HttpDataSourceException { + delegate.close(); + } + + @Override + public void setRequestProperty(final String name, final String value) { + delegate.setRequestProperty(name, value); + } + + @Override + public void clearRequestProperty(final String name) { + delegate.clearRequestProperty(name); + } + + @Override + public void clearAllRequestProperties() { + delegate.clearAllRequestProperties(); + } + + @Override + public int getResponseCode() { + return delegate.getResponseCode(); + } + + public static class Factory implements HttpDataSource.Factory { + protected final DefaultHttpDataSource.Factory inner = new DefaultHttpDataSource.Factory(); @Override - public final PurifiedHttpDataSource.Factory setDefaultRequestProperties(Map defaultRequestProperties) { - this.defaultRequestProperties.clearAndSet(defaultRequestProperties); + public final Factory setDefaultRequestProperties( + final Map defaultRequestProperties) { + inner.setDefaultRequestProperties(defaultRequestProperties); return this; } - /** - * Sets the user agent that will be used. - * - *

The default is {@code null}, which causes the default user agent of the underlying - * platform to be used. - * - * @param userAgent The user agent that will be used, or {@code null} to use the default user - * agent of the underlying platform. - * @return This factory. - */ - public PurifiedHttpDataSource.Factory setUserAgent(@Nullable String userAgent) { - this.userAgent = userAgent; + public Factory setUserAgent(@Nullable final String userAgent) { + inner.setUserAgent(userAgent); return this; } - /** - * Sets the connect timeout, in milliseconds. - * - *

The default is {@link PurifiedHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. - * - * @param connectTimeoutMs The connect timeout, in milliseconds, that will be used. - * @return This factory. - */ - public PurifiedHttpDataSource.Factory setConnectTimeoutMs(int connectTimeoutMs) { - this.connectTimeoutMs = connectTimeoutMs; + public Factory setConnectTimeoutMs(final int connectTimeoutMs) { + inner.setConnectTimeoutMs(connectTimeoutMs); return this; } - /** - * Sets the read timeout, in milliseconds. - * - *

The default is {@link PurifiedHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. - * - * @param readTimeoutMs The connect timeout, in milliseconds, that will be used. - * @return This factory. - */ - public PurifiedHttpDataSource.Factory setReadTimeoutMs(int readTimeoutMs) { - this.readTimeoutMs = readTimeoutMs; + public Factory setReadTimeoutMs(final int readTimeoutMs) { + inner.setReadTimeoutMs(readTimeoutMs); return this; } - /** - * Sets whether to allow cross protocol redirects. - * - *

The default is {@code false}. - * - * @param allowCrossProtocolRedirects Whether to allow cross protocol redirects. - * @return This factory. - */ - public PurifiedHttpDataSource.Factory setAllowCrossProtocolRedirects(boolean allowCrossProtocolRedirects) { - this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + public Factory setAllowCrossProtocolRedirects(final boolean allowCrossProtocolRedirects) { + inner.setAllowCrossProtocolRedirects(allowCrossProtocolRedirects); return this; } - /** - * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a - * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link - * PurifiedHttpDataSource#open(DataSpec)}. - * - *

The default is {@code null}. - * - * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a - * predicate that was previously set. - * @return This factory. - */ - public PurifiedHttpDataSource.Factory setContentTypePredicate(@Nullable Predicate contentTypePredicate) { - this.contentTypePredicate = contentTypePredicate; + public Factory setContentTypePredicate(@Nullable final Predicate predicate) { + inner.setContentTypePredicate(predicate); return this; } - /** - * Sets the {@link TransferListener} that will be used. - * - *

The default is {@code null}. - * - *

See {@link DataSource#addTransferListener(TransferListener)}. - * - * @param transferListener The listener that will be used. - * @return This factory. - */ - public PurifiedHttpDataSource.Factory setTransferListener(@Nullable TransferListener transferListener) { - this.transferListener = transferListener; + public Factory setTransferListener(@Nullable final TransferListener transferListener) { + inner.setTransferListener(transferListener); return this; } - /** - * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for a - * POST request. - */ - public PurifiedHttpDataSource.Factory setKeepPostFor302Redirects(boolean keepPostFor302Redirects) { - this.keepPostFor302Redirects = keepPostFor302Redirects; + public Factory setKeepPostFor302Redirects(final boolean keepPostFor302Redirects) { + inner.setKeepPostFor302Redirects(keepPostFor302Redirects); return this; } @Override public PurifiedHttpDataSource createDataSource() { - PurifiedHttpDataSource dataSource = - new PurifiedHttpDataSource( - userAgent, - connectTimeoutMs, - readTimeoutMs, - allowCrossProtocolRedirects, - defaultRequestProperties, - contentTypePredicate, - keepPostFor302Redirects); - if (transferListener != null) { - dataSource.addTransferListener(transferListener); - } - return dataSource; + return new PurifiedHttpDataSource(inner.createDataSource()); } } - PurifiedHttpDataSource( - @Nullable String userAgent, - int connectTimeoutMillis, - int readTimeoutMillis, - boolean allowCrossProtocolRedirects, - @Nullable RequestProperties defaultRequestProperties, - @Nullable Predicate contentTypePredicate, - boolean keepPostFor302Redirects) { - super( - userAgent, - connectTimeoutMillis, - readTimeoutMillis, - allowCrossProtocolRedirects, - defaultRequestProperties - ); - } - - @Override - public long open(DataSpec dataSpec) throws HttpDataSourceException - { - final Map m1 = dataSpec.httpRequestHeaders; - final Map m2 = new HashMap<>(); - for (Map.Entry entry : m1.entrySet()) - if(!entry.getKey().equals(IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME)) - m2.put(entry.getKey(), entry.getValue()); - - return super.open(dataSpec.withRequestHeaders(m2)); - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/CustomDataSourceFactory.java b/app/src/main/java/org/schabi/newpipe/player/resolver/CustomDataSourceFactory.java index 82dff6ec9..06fe0b365 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/CustomDataSourceFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/CustomDataSourceFactory.java @@ -4,9 +4,9 @@ import androidx.annotation.Nullable; -import com.google.android.exoplayer2.upstream.ByteArrayDataSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.media3.datasource.ByteArrayDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.TransferListener; public class CustomDataSourceFactory implements DataSource.Factory { private final Context context; From 4151d3eb8cec0a6b7f32b42a102b2db76409984c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 11:21:57 +0200 Subject: [PATCH 05/29] feat(player): swap exoplayer2 imports to media3 across the player stack --- .../org/schabi/newpipe/error/ErrorInfo.kt | 2 +- .../fragments/detail/VideoDetailFragment.java | 4 +- .../detail/VideoDetailPlayerCrasher.java | 12 +-- .../newpipe/player/NotificationUtil.java | 20 ++-- .../newpipe/player/PlayQueueActivity.java | 14 +-- .../org/schabi/newpipe/player/Player.java | 91 ++++++++++--------- .../player/event/PlayerEventListener.java | 2 +- .../event/PlayerServiceEventListener.java | 2 +- .../newpipe/player/helper/AudioReactor.java | 4 +- .../helper/CustomMediaCodecVideoRenderer.java | 8 +- .../player/helper/CustomRenderersFactory.java | 8 +- .../newpipe/player/helper/LoadController.java | 2 +- .../NonUriHlsPlaylistParserFactory.java | 10 +- .../newpipe/player/helper/PlayerHelper.java | 26 +++--- .../newpipe/player/helper/PlayerHolder.java | 4 +- .../helper/YoutubeDashLiveManifestParser.java | 12 +-- .../player/mediaitem/MediaItemTag.java | 8 +- .../player/mediaitem/StreamInfoTag.java | 2 +- .../player/mediasource/FailedMediaSource.java | 22 ++--- .../player/mediasource/LoadedMediaSource.java | 14 +-- .../mediasource/ManagedMediaSource.java | 2 +- .../ManagedMediaSourcePlaylist.java | 4 +- .../mediasource/PlaceholderMediaSource.java | 12 +-- .../player/playback/MediaSourceManager.java | 4 +- .../player/playback/PlaybackListener.java | 2 +- .../player/playback/PlayerMediaSession.java | 14 +-- .../playback/SurfaceHolderCallback.java | 4 +- .../resolver/AudioPlaybackResolver.java | 2 +- .../player/resolver/PlaybackResolver.java | 26 +++--- .../resolver/VideoPlaybackResolver.java | 12 +-- .../newpipe/views/ExpandableSurfaceView.java | 6 +- .../giga/hls/manifest/HlsPlaylistParser.kt | 14 +-- .../activity_player_queue_control.xml | 4 +- .../layout/activity_player_queue_control.xml | 4 +- app/src/main/res/layout/player.xml | 4 +- 35 files changed, 191 insertions(+), 190 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt index aa849655a..a34ae5ed2 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt @@ -2,7 +2,7 @@ package org.schabi.newpipe.error import android.os.Parcelable import androidx.annotation.StringRes -import com.google.android.exoplayer2.ExoPlaybackException +import androidx.media3.exoplayer.ExoPlaybackException import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.schabi.newpipe.R diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 63835ed6e..c86d04ecf 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -46,8 +46,8 @@ import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.PlaybackParameters; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.tabs.TabLayout; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java index ae704e88c..af6c41c2b 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java @@ -13,9 +13,9 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.PlaybackException; +import androidx.media3.common.C; +import androidx.media3.exoplayer.ExoPlaybackException; +import androidx.media3.common.PlaybackException; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ListRadioIconItemBinding; @@ -29,9 +29,9 @@ import java.util.Map; import java.util.function.Supplier; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; +import static androidx.media3.common.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; +import static androidx.media3.common.PlaybackException.ERROR_CODE_DECODING_FAILED; +import static androidx.media3.common.PlaybackException.ERROR_CODE_UNSPECIFIED; /** * Outsourced logic for crashing the player in the {@link VideoDetailFragment}. diff --git a/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java index 205b78640..c06540db9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java @@ -26,8 +26,8 @@ import java.util.List; import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; +import static androidx.media3.common.Player.REPEAT_MODE_ALL; +import static androidx.media3.common.Player.REPEAT_MODE_ONE; import static org.schabi.newpipe.player.PlayerService.ACTION_CLOSE; import static org.schabi.newpipe.player.PlayerService.ACTION_FAST_FORWARD; import static org.schabi.newpipe.player.PlayerService.ACTION_FAST_REWIND; @@ -269,19 +269,19 @@ private NotificationCompat.Action getAction( case NotificationConstants.SMART_REWIND_PREVIOUS: if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { - return getAction(player, R.drawable.exo_notification_previous, + return getAction(player, R.drawable.exo_icon_previous, R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS); } else { - return getAction(player, R.drawable.exo_controls_rewind, + return getAction(player, R.drawable.exo_icon_rewind, R.string.exo_controls_rewind_description, ACTION_FAST_REWIND); } case NotificationConstants.SMART_FORWARD_NEXT: if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { - return getAction(player, R.drawable.exo_notification_next, + return getAction(player, R.drawable.exo_icon_next, R.string.exo_controls_next_description, ACTION_PLAY_NEXT); } else { - return getAction(player, R.drawable.exo_controls_fastforward, + return getAction(player, R.drawable.exo_icon_fastforward, R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD); } @@ -303,10 +303,10 @@ private NotificationCompat.Action getAction( || player.getCurrentState() == Player.STATE_PREFLIGHT || player.getCurrentState() == Player.STATE_BLOCKED || player.getCurrentState() == Player.STATE_BUFFERING) { - return getAction(player, R.drawable.exo_notification_pause, + return getAction(player, R.drawable.exo_icon_pause, R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE); } else { - return getAction(player, R.drawable.exo_notification_play, + return getAction(player, R.drawable.exo_icon_play, R.string.exo_controls_play_description, ACTION_PLAY_PAUSE); } @@ -324,10 +324,10 @@ private NotificationCompat.Action getAction( case NotificationConstants.SHUFFLE: if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) { - return getAction(player, R.drawable.exo_controls_shuffle_on, + return getAction(player, R.drawable.exo_icon_shuffle_on, R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE); } else { - return getAction(player, R.drawable.exo_controls_shuffle_off, + return getAction(player, R.drawable.exo_icon_shuffle_off, R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE); } diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index b9fba40c6..ea0c23310 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -21,7 +21,7 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.exoplayer2.PlaybackParameters; +import androidx.media3.common.PlaybackParameters; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding; @@ -553,17 +553,17 @@ private void onStateChanged(final int state) { private void onPlayModeChanged(final int repeatMode, final boolean shuffled) { switch (repeatMode) { - case com.google.android.exoplayer2.Player.REPEAT_MODE_OFF: + case androidx.media3.common.Player.REPEAT_MODE_OFF: queueControlBinding.controlRepeat - .setImageResource(R.drawable.exo_controls_repeat_off); + .setImageResource(R.drawable.exo_icon_repeat_off); break; - case com.google.android.exoplayer2.Player.REPEAT_MODE_ONE: + case androidx.media3.common.Player.REPEAT_MODE_ONE: queueControlBinding.controlRepeat - .setImageResource(R.drawable.exo_controls_repeat_one); + .setImageResource(R.drawable.exo_icon_repeat_one); break; - case com.google.android.exoplayer2.Player.REPEAT_MODE_ALL: + case androidx.media3.common.Player.REPEAT_MODE_ALL: queueControlBinding.controlRepeat - .setImageResource(R.drawable.exo_controls_repeat_all); + .setImageResource(R.drawable.exo_icon_repeat_all); break; } diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 23e803fee..6a0773423 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -1,18 +1,18 @@ package org.schabi.newpipe.player; -import static com.google.android.exoplayer2.PlaybackException.*; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; -import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP; -import static com.google.android.exoplayer2.Player.DiscontinuityReason; -import static com.google.android.exoplayer2.Player.Listener; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; -import static com.google.android.exoplayer2.Player.RepeatMode; +import static androidx.media3.common.PlaybackException.*; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_INTERNAL; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_REMOVE; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; +import static androidx.media3.common.Player.DISCONTINUITY_REASON_SKIP; +import static androidx.media3.common.Player.DiscontinuityReason; +import static androidx.media3.common.Player.Listener; +import static androidx.media3.common.Player.REPEAT_MODE_ALL; +import static androidx.media3.common.Player.REPEAT_MODE_OFF; +import static androidx.media3.common.Player.REPEAT_MODE_ONE; +import static androidx.media3.common.Player.RepeatMode; import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; @@ -78,22 +78,23 @@ import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.exoplayer2.*; -import com.google.android.exoplayer2.Player.PositionInfo; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.Tracks; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.text.CueGroup; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.CaptionStyleCompat; -import com.google.android.exoplayer2.ui.SubtitleView; -import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.util.Util; -import com.google.android.exoplayer2.video.VideoSize; +import androidx.media3.common.*; +import androidx.media3.exoplayer.*; +import androidx.media3.common.Player.PositionInfo; +import androidx.media3.common.Timeline; +import androidx.media3.common.Tracks; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.common.TrackGroup; +import androidx.media3.exoplayer.source.TrackGroupArray; +import androidx.media3.common.text.CueGroup; +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import androidx.media3.exoplayer.trackselection.MappingTrackSelector; +import androidx.media3.ui.AspectRatioFrameLayout; +import androidx.media3.ui.CaptionStyleCompat; +import androidx.media3.ui.SubtitleView; +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; +import androidx.media3.common.util.Util; +import androidx.media3.common.VideoSize; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.squareup.picasso.Picasso; import com.squareup.picasso.Target; @@ -794,7 +795,7 @@ public void handleIntent(@NonNull final Intent intent) { // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case if (simpleExoPlayer.getPlaybackState() - == com.google.android.exoplayer2.Player.STATE_IDLE) { + == androidx.media3.common.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } if (shouldSeek()) { @@ -811,7 +812,7 @@ public void handleIntent(@NonNull final Intent intent) { // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case if (simpleExoPlayer.getPlaybackState() - == com.google.android.exoplayer2.Player.STATE_IDLE) { + == androidx.media3.common.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } simpleExoPlayer.setPlayWhenReady(playWhenReady); @@ -2265,7 +2266,7 @@ public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason + "reason = [" + reason + "]"); } final int playbackState = exoPlayerIsNull() - ? com.google.android.exoplayer2.Player.STATE_IDLE + ? androidx.media3.common.Player.STATE_IDLE : simpleExoPlayer.getPlaybackState(); updatePlaybackState(playWhenReady, playbackState); } @@ -2294,22 +2295,22 @@ private void updatePlaybackState(final boolean playWhenReady, final int playback } switch (playbackState) { - case com.google.android.exoplayer2.Player.STATE_IDLE: // 1 + case androidx.media3.common.Player.STATE_IDLE: // 1 isPrepared = false; break; - case com.google.android.exoplayer2.Player.STATE_BUFFERING: // 2 + case androidx.media3.common.Player.STATE_BUFFERING: // 2 if (isPrepared) { changeState(STATE_BUFFERING); } break; - case com.google.android.exoplayer2.Player.STATE_READY: //3 + case androidx.media3.common.Player.STATE_READY: //3 if (!isPrepared) { isPrepared = true; onPrepared(playWhenReady); } changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); break; - case com.google.android.exoplayer2.Player.STATE_ENDED: // 4 + case androidx.media3.common.Player.STATE_ENDED: // 4 changeState(STATE_COMPLETED); saveStreamProgressStateCompleted(); isPrepared = false; @@ -2826,13 +2827,13 @@ private void setRepeatModeButton(final AppCompatImageButton imageButton, @RepeatMode final int repeatMode) { switch (repeatMode) { case REPEAT_MODE_OFF: - imageButton.setImageResource(R.drawable.exo_controls_repeat_off); + imageButton.setImageResource(R.drawable.exo_icon_repeat_off); break; case REPEAT_MODE_ONE: - imageButton.setImageResource(R.drawable.exo_controls_repeat_one); + imageButton.setImageResource(R.drawable.exo_icon_repeat_one); break; case REPEAT_MODE_ALL: - imageButton.setImageResource(R.drawable.exo_controls_repeat_all); + imageButton.setImageResource(R.drawable.exo_icon_repeat_all); break; } } @@ -2920,13 +2921,13 @@ public void onScreenRotationButtonClicked() { * This is done because not all source resolution errors are {@link PlaybackException}, which * are also captured by {@link ExoPlayer} and stops the playback.

* - * @param player The {@link com.google.android.exoplayer2.Player} whose state changed. - * @param events The {@link com.google.android.exoplayer2.Player.Events} that has triggered + * @param player The {@link androidx.media3.common.Player} whose state changed. + * @param events The {@link androidx.media3.common.Player.Events} that has triggered * the player state changes. **/ @Override - public void onEvents(@NonNull final com.google.android.exoplayer2.Player player, - @NonNull final com.google.android.exoplayer2.Player.Events events) { + public void onEvents(@NonNull final androidx.media3.common.Player player, + @NonNull final androidx.media3.common.Player.Events events) { Listener.super.onEvents(player, events); MediaItemTag.from(player.getCurrentMediaItem()).ifPresent(tag -> { if (tag == currentMetadata) { @@ -3053,7 +3054,7 @@ public void onPrepare() { //region Errors /** - * Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. + * Process exceptions produced by {@link androidx.media3.exoplayer.ExoPlayer ExoPlayer}. *

There are multiple types of errors:

*
    *
  • {@link PlaybackException#ERROR_CODE_BEHIND_LIVE_WINDOW BEHIND_LIVE_WINDOW}: @@ -3078,7 +3079,7 @@ public void onPrepare() { * For any error above that is not explicitly catchable, the player will * create a notification so users are aware. *
- * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException) + * @see androidx.media3.common.Player.Listener#onPlayerError(PlaybackException) * */ // Any error code not explicitly covered here are either unrelated to NewPipe use case // (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java index b5520e8be..553b12c81 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java @@ -1,7 +1,7 @@ package org.schabi.newpipe.player.event; -import com.google.android.exoplayer2.PlaybackParameters; +import androidx.media3.common.PlaybackParameters; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.playqueue.PlayQueue; diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java index 359eab8b2..5b52455bf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java @@ -1,6 +1,6 @@ package org.schabi.newpipe.player.event; -import com.google.android.exoplayer2.PlaybackException; +import androidx.media3.common.PlaybackException; public interface PlayerServiceEventListener extends PlayerEventListener { void onFullscreenStateChanged(boolean fullscreen); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java index 8613ef57b..d880e6aed 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java @@ -15,8 +15,8 @@ import androidx.media.AudioManagerCompat; import androidx.preference.PreferenceManager; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.analytics.AnalyticsListener; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.analytics.AnalyticsListener; import org.schabi.newpipe.R; public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java index 66ac6d50b..5769346cd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java @@ -5,10 +5,10 @@ import androidx.annotation.Nullable; -import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; -import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; -import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; -import com.google.android.exoplayer2.video.VideoRendererEventListener; +import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer; +import androidx.media3.exoplayer.video.VideoRendererEventListener; /** * A {@link MediaCodecVideoRenderer} which always enable the output surface workaround that diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java index 668b48c30..d7f4066a2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java @@ -3,10 +3,10 @@ import android.content.Context; import android.os.Handler; -import com.google.android.exoplayer2.DefaultRenderersFactory; -import com.google.android.exoplayer2.Renderer; -import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; -import com.google.android.exoplayer2.video.VideoRendererEventListener; +import androidx.media3.exoplayer.DefaultRenderersFactory; +import androidx.media3.exoplayer.Renderer; +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import androidx.media3.exoplayer.video.VideoRendererEventListener; import java.util.ArrayList; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index ec0e4e4a7..bcdcd9643 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -1,6 +1,6 @@ package org.schabi.newpipe.player.helper; -import com.google.android.exoplayer2.DefaultLoadControl; +import androidx.media3.exoplayer.DefaultLoadControl; public class LoadController extends DefaultLoadControl { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java index a3a25fd1d..aee0d5ff4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java @@ -5,11 +5,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsMultivariantPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; -import com.google.android.exoplayer2.upstream.ParsingLoadable; +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist; +import androidx.media3.exoplayer.hls.playlist.HlsPlaylist; +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParserFactory; +import androidx.media3.exoplayer.upstream.ParsingLoadable; import java.io.IOException; import java.io.InputStream; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 91258be87..b2c2778a7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -1,8 +1,8 @@ package org.schabi.newpipe.player.helper; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; +import static androidx.media3.common.Player.REPEAT_MODE_ALL; +import static androidx.media3.common.Player.REPEAT_MODE_OFF; +import static androidx.media3.common.Player.REPEAT_MODE_ONE; import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; import static org.schabi.newpipe.player.Player.IDLE_WINDOW_FLAGS; @@ -33,16 +33,16 @@ import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player.RepeatMode; -import com.google.android.exoplayer2.SeekParameters; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; -import com.google.android.exoplayer2.trackselection.ExoTrackSelection; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; -import com.google.android.exoplayer2.ui.CaptionStyleCompat; -import com.google.android.exoplayer2.util.MimeTypes; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Player.RepeatMode; +import androidx.media3.exoplayer.SeekParameters; +import androidx.media3.exoplayer.source.ProgressiveMediaSource; +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.ui.AspectRatioFrameLayout; +import androidx.media3.ui.AspectRatioFrameLayout.ResizeMode; +import androidx.media3.ui.CaptionStyleCompat; +import androidx.media3.common.MimeTypes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java index b853cb37f..33390b078 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java @@ -8,8 +8,8 @@ import android.util.Log; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.PlaybackParameters; import org.schabi.newpipe.App; import org.schabi.newpipe.MainActivity; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java b/app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java index 00f5de071..efaf5c91d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java @@ -5,12 +5,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.source.dash.manifest.DashManifest; -import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; -import com.google.android.exoplayer2.source.dash.manifest.Period; -import com.google.android.exoplayer2.source.dash.manifest.ProgramInformation; -import com.google.android.exoplayer2.source.dash.manifest.ServiceDescriptionElement; -import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; +import androidx.media3.exoplayer.dash.manifest.DashManifest; +import androidx.media3.exoplayer.dash.manifest.DashManifestParser; +import androidx.media3.exoplayer.dash.manifest.Period; +import androidx.media3.exoplayer.dash.manifest.ProgramInformation; +import androidx.media3.exoplayer.dash.manifest.ServiceDescriptionElement; +import androidx.media3.exoplayer.dash.manifest.UtcTimingElement; import java.util.List; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java index d23dd4574..1e00e2b8f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java @@ -2,10 +2,10 @@ import android.net.Uri; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.MediaItem.RequestMetadata; -import com.google.android.exoplayer2.MediaMetadata; -import com.google.android.exoplayer2.Player; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaItem.RequestMetadata; +import androidx.media3.common.MediaMetadata; +import androidx.media3.common.Player; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java index 4095f2bc8..f2ed6dc13 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java @@ -1,6 +1,6 @@ package org.schabi.newpipe.player.mediaitem; -import com.google.android.exoplayer2.MediaItem; +import androidx.media3.common.MediaItem; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index 8aad356d0..9e00cf3a7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -2,15 +2,15 @@ import android.util.Log; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.BaseMediaSource; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.SilenceMediaSource; -import com.google.android.exoplayer2.source.SinglePeriodTimeline; -import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Timeline; +import androidx.media3.exoplayer.source.BaseMediaSource; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.SilenceMediaSource; +import androidx.media3.exoplayer.source.SinglePeriodTimeline; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.datasource.TransferListener; import org.schabi.newpipe.player.mediaitem.ExceptionTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; @@ -120,10 +120,10 @@ protected void prepareSourceInternal(@Nullable final TransferListener mediaTrans * If the error is not known, e.g. network issue, then the exception is not swallowed here in * {@link FailedMediaSource}. The exception is then propagated to the player, which * {@link org.schabi.newpipe.player.Player Player} can react to inside - * {@link com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)}. + * {@link androidx.media3.common.Player.Listener#onPlayerError(PlaybackException)}. * * @throws IOException An error which will always result in - * {@link com.google.android.exoplayer2.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}. + * {@link androidx.media3.common.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}. */ @Override public void maybeThrowSourceInfoRefreshError() throws IOException { diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java index 95524cf69..2832be7fa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -1,12 +1,12 @@ package org.schabi.newpipe.player.mediasource; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.CompositeMediaSource; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Timeline; +import androidx.media3.exoplayer.source.CompositeMediaSource; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.datasource.TransferListener; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java index 9d6b94893..f60bd4981 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java @@ -2,7 +2,7 @@ import androidx.annotation.NonNull; -import com.google.android.exoplayer2.source.MediaSource; +import androidx.media3.exoplayer.source.MediaSource; import org.schabi.newpipe.player.playqueue.PlayQueueItem; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java index 4c0380767..46335fd07 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java @@ -5,8 +5,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.source.ConcatenatingMediaSource; -import com.google.android.exoplayer2.source.ShuffleOrder; +import androidx.media3.exoplayer.source.ConcatenatingMediaSource; +import androidx.media3.exoplayer.source.ShuffleOrder; import org.schabi.newpipe.player.mediaitem.MediaItemTag; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java index 92d4403c8..4e18edf6f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java @@ -1,11 +1,11 @@ package org.schabi.newpipe.player.mediasource; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.CompositeMediaSource; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.upstream.Allocator; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Timeline; +import androidx.media3.exoplayer.source.CompositeMediaSource; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.upstream.Allocator; import org.schabi.newpipe.player.mediaitem.PlaceholderTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index f98466827..5269771b3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -8,7 +8,7 @@ import androidx.annotation.Nullable; import androidx.collection.ArraySet; -import com.google.android.exoplayer2.source.MediaSource; +import androidx.media3.exoplayer.source.MediaSource; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -477,7 +477,7 @@ private void onMediaSourceReceived(@NonNull final PlayQueueItem item, /** * Checks if the corresponding MediaSource in - * {@link com.google.android.exoplayer2.source.ConcatenatingMediaSource} + * {@link androidx.media3.exoplayer.source.ConcatenatingMediaSource} * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback * readiness or playlist desynchronization. *

diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java index 737607001..e48264602 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java @@ -3,7 +3,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.source.MediaSource; +import androidx.media3.exoplayer.source.MediaSource; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.playqueue.PlayQueueItem; diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java index cd0f37715..265c50a1c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java @@ -9,15 +9,15 @@ import org.schabi.newpipe.player.mediasession.MediaSessionCallback; import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; +import static androidx.media3.common.Player.REPEAT_MODE_OFF; import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode; public class PlayerMediaSession implements MediaSessionCallback { public final Player player; - private final com.google.android.exoplayer2.Player exoPlayer; + private final androidx.media3.common.Player exoPlayer; public int mode = 0; - public PlayerMediaSession(final Player player, final com.google.android.exoplayer2.Player exoPlayer) { + public PlayerMediaSession(final Player player, final androidx.media3.common.Player exoPlayer) { this.player = player; this.exoPlayer = exoPlayer; refresh(); @@ -114,10 +114,10 @@ public void changePlayMode() { player.onShuffleClicked(); break; case 1: // repeat_one - player.setRepeatMode(com.google.android.exoplayer2.Player.REPEAT_MODE_ONE); + player.setRepeatMode(androidx.media3.common.Player.REPEAT_MODE_ONE); break; case 2: // repeat_all - player.setRepeatMode(com.google.android.exoplayer2.Player.REPEAT_MODE_ALL); + player.setRepeatMode(androidx.media3.common.Player.REPEAT_MODE_ALL); break; case 3: // repeat_none default: @@ -132,9 +132,9 @@ public void close(){ public void refresh(){ if (exoPlayer.getShuffleModeEnabled()) { this.mode = 1; - } else if (exoPlayer.getRepeatMode() == com.google.android.exoplayer2.Player.REPEAT_MODE_ONE) { + } else if (exoPlayer.getRepeatMode() == androidx.media3.common.Player.REPEAT_MODE_ONE) { this.mode = 2; - } else if (exoPlayer.getRepeatMode() == com.google.android.exoplayer2.Player.REPEAT_MODE_ALL) { + } else if (exoPlayer.getRepeatMode() == androidx.media3.common.Player.REPEAT_MODE_ALL) { this.mode = 3; } else { this.mode = 0; diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java index da6cb36d4..b02a9240d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java @@ -3,8 +3,8 @@ import android.content.Context; import android.view.SurfaceHolder; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.video.PlaceholderSurface; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.video.PlaceholderSurface; /** * Prevent error message: 'Unrecoverable player error occurred' diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java index 7e3f59007..d44b64b55 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java @@ -9,7 +9,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.source.MediaSource; +import androidx.media3.exoplayer.source.MediaSource; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index d8d9ecb0d..e15a73033 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -6,19 +6,19 @@ import android.net.Uri; import android.util.Log; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.manifest.DashManifest; -import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.ProgressiveMediaSource; +import androidx.media3.exoplayer.dash.DashMediaSource; +import androidx.media3.exoplayer.dash.manifest.DashManifest; +import androidx.media3.exoplayer.dash.manifest.DashManifestParser; +import androidx.media3.exoplayer.hls.HlsMediaSource; +import androidx.media3.exoplayer.hls.playlist.HlsPlaylist; +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser; +import androidx.media3.exoplayer.smoothstreaming.SsMediaSource; +import androidx.media3.exoplayer.smoothstreaming.manifest.SsManifest; +import androidx.media3.exoplayer.smoothstreaming.manifest.SsManifestParser; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParserException; diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 9c9324081..ce6fef5d6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -7,11 +7,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MergingMediaSource; -import com.google.android.exoplayer2.source.SingleSampleMediaSource; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.MergingMediaSource; +import androidx.media3.exoplayer.source.SingleSampleMediaSource; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; @@ -30,7 +30,7 @@ import java.util.Optional; import java.util.stream.Collectors; -import static com.google.android.exoplayer2.C.TIME_UNSET; +import static androidx.media3.common.C.TIME_UNSET; import static org.schabi.newpipe.util.ListHelper.*; public class VideoPlaybackResolver implements PlaybackResolver { diff --git a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java index 010e1be56..a6f43415f 100644 --- a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java +++ b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java @@ -5,10 +5,10 @@ import android.util.AttributeSet; import android.view.SurfaceView; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import androidx.media3.ui.AspectRatioFrameLayout; -import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; -import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; +import static androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; +import static androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; public class ExpandableSurfaceView extends SurfaceView { private int resizeMode = RESIZE_MODE_FIT; diff --git a/app/src/main/java/us/shandian/giga/hls/manifest/HlsPlaylistParser.kt b/app/src/main/java/us/shandian/giga/hls/manifest/HlsPlaylistParser.kt index 49590671a..5174924ac 100644 --- a/app/src/main/java/us/shandian/giga/hls/manifest/HlsPlaylistParser.kt +++ b/app/src/main/java/us/shandian/giga/hls/manifest/HlsPlaylistParser.kt @@ -1,13 +1,13 @@ package us.shandian.giga.hls.manifest import android.net.Uri -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.Format -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist as ExoHlsMediaPlaylist -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment as ExoHlsSegment -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.SegmentBase as ExoHlsSegmentBase -import com.google.android.exoplayer2.source.hls.playlist.HlsMultivariantPlaylist as ExoHlsMultivariantPlaylist -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser as ExoHlsPlaylistParser +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist as ExoHlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Segment as ExoHlsSegment +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.SegmentBase as ExoHlsSegmentBase +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist as ExoHlsMultivariantPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser as ExoHlsPlaylistParser import java.io.ByteArrayInputStream import java.io.IOException import java.net.URI diff --git a/app/src/main/res/layout-land/activity_player_queue_control.xml b/app/src/main/res/layout-land/activity_player_queue_control.xml index a055ad7ac..b77e1b6aa 100644 --- a/app/src/main/res/layout-land/activity_player_queue_control.xml +++ b/app/src/main/res/layout-land/activity_player_queue_control.xml @@ -121,7 +121,7 @@ android:clickable="true" android:focusable="true" android:scaleType="fitCenter" - android:src="@drawable/exo_controls_rewind" + android:src="@drawable/exo_icon_rewind" android:tint="?attr/colorAccent" tools:ignore="ContentDescription" /> @@ -171,7 +171,7 @@ android:clickable="true" android:focusable="true" android:scaleType="fitCenter" - android:src="@drawable/exo_controls_fastforward" + android:src="@drawable/exo_icon_fastforward" android:tint="?attr/colorAccent" tools:ignore="ContentDescription" /> diff --git a/app/src/main/res/layout/activity_player_queue_control.xml b/app/src/main/res/layout/activity_player_queue_control.xml index 2cb992209..48c7787cc 100644 --- a/app/src/main/res/layout/activity_player_queue_control.xml +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -204,7 +204,7 @@ android:clickable="true" android:focusable="true" android:scaleType="fitCenter" - android:src="@drawable/exo_controls_rewind" + android:src="@drawable/exo_icon_rewind" android:tint="?attr/colorAccent" /> - Date: Sat, 6 Jun 2026 10:30:56 +0200 Subject: [PATCH 06/29] chore(player): bump media3 to 1.10.1 (raises minSdk to 23) and migrate the HLS tracker factory --- app/build.gradle | 5 +++-- .../newpipe/player/helper/PlayerDataSource.java | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index fc0151f21..06f3a6b5c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,7 +13,8 @@ android { defaultConfig { applicationId "InfinityLoop1309.NewPipeEnhanced" - minSdk 21 + // media3 1.9+ requires API 23 (media3-session). Bumped from 21 -> drops Android 5.0/5.1. + minSdk 23 //noinspection ExpiredTargetSdkVersion targetSdk 33 versionCode 1100 @@ -137,7 +138,7 @@ ext { androidxRoomVersion = '2.4.2' androidxWorkVersion = '2.10.2' - media3Version = '1.4.1' + media3Version = '1.10.1' googleAutoServiceVersion = '1.0.1' groupieVersion = '2.10.0' markwonVersion = '4.6.2' diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 5172e98d2..fa9c4d103 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -117,10 +117,11 @@ public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { return new HlsMediaSource.Factory(cachelessDataSourceFactory) .setAllowChunklessPreparation(true) .setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory) -> + playlistParserFactory, cmcdConfiguration, + downloadExecutorSupplier) -> new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory, - PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)); + playlistParserFactory, cmcdConfiguration, + PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT, downloadExecutorSupplier)); } public DashMediaSource.Factory getLiveDashMediaSourceFactory() { @@ -257,10 +258,12 @@ public HlsMediaSource.Factory getNicoLiveHlsMediaSourceFactory(String liveUrl) { return new HlsMediaSource.Factory(newFactory) .setAllowChunklessPreparation(true) .setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory) -> + playlistParserFactory, cmcdConfiguration, + downloadExecutorSupplier) -> new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory, - PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)).setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy()); + playlistParserFactory, cmcdConfiguration, + PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT, downloadExecutorSupplier)) + .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy()); } // BiliBiliMediaSourceFactories From 57555ecc656b2f6abd6f94933dbdb525a6d9733f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 07/29] feat(player): SABR PO token via headless WebView --- app/src/main/assets/sabr_potoken_poc.js | 301 ++++++++++++++++++ .../datasource/WebViewPoTokenProvider.java | 281 ++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 app/src/main/assets/sabr_potoken_poc.js create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java diff --git a/app/src/main/assets/sabr_potoken_poc.js b/app/src/main/assets/sabr_potoken_poc.js new file mode 100644 index 000000000..88b5ffa82 --- /dev/null +++ b/app/src/main/assets/sabr_potoken_poc.js @@ -0,0 +1,301 @@ +/* + * SABR PO token POC — WebView pipeline (att mode). + * + * Ported from the local research mint (mint-po-token-browser.mjs), adapted to run inside an Android + * WebView instead of puppeteer. It must be injected AFTER https://www.youtube.com/ has finished + * loading (same-origin is required: att/get and GenerateIT are youtube.com endpoints, and the + * BotGuard interpreter is embedded in the challenge). + * + * Flow: read browser context -> att/get challenge -> run BotGuard VM -> snapshot -> GenerateIT + * integrity token -> mint a videoId-bound PO token -> hand the result back through the + * `SabrPocBridge` JavascriptInterface. + * + * INTERNAL / LOCAL POC ONLY. The minted PO token is session-bound; keep it out of any public log. + * API_KEY / REQUEST_KEY are the well-known public ecosystem constants, not secrets. + */ +(function () { + 'use strict'; + + // YouTube ships a Trusted Types CSP (require-trusted-types-for 'script') that blocks + // new Function()/eval of the BotGuard interpreter. Installing an identity "default" policy makes + // Chromium route those sinks through it, restoring dynamic evaluation. If the CSP forbids + // creating the policy, loadBotGuard() will surface the eval error instead. + try { + if (window.trustedTypes && window.trustedTypes.createPolicy + && !window.trustedTypes.defaultPolicy) { + window.trustedTypes.createPolicy('default', { + createHTML: function (value) { return value; }, + createScript: function (value) { return value; }, + createScriptURL: function (value) { return value; } + }); + } + } catch (ttError) { + // ignore; surfaced later as an eval failure + } + + var API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw'; + var REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo'; + + function report(result) { + try { + // eslint-disable-next-line no-undef + SabrPocBridge.onResult(JSON.stringify(result)); + } catch (e) { + // Bridge not present (e.g. plain browser run): fall back to console. + try { + console.log('[sabr-poc] ' + JSON.stringify(result)); + } catch (ignored) { + // nothing else we can do + } + } + } + + function step(message) { + try { console.log('[sabr-poc] ' + message); } catch (e) { /* ignore */ } + } + + function readVisitorData() { + var cfg = window.ytcfg; + var fromCfg = cfg && typeof cfg.get === 'function' ? cfg.get('VISITOR_DATA') : null; + if (fromCfg) { + return fromCfg; + } + var html = document.documentElement.innerHTML; + var marker = '"VISITOR_DATA":"'; + var start = html.indexOf(marker); + if (start < 0) { + throw new Error('Could not find visitor data'); + } + var from = start + marker.length; + var end = html.indexOf('"', from); + if (end < 0) { + throw new Error('Could not find visitor data end'); + } + return html.slice(from, end); + } + + function readClientVersion() { + var cfg = window.ytcfg; + var fromCfg = cfg && typeof cfg.get === 'function' + ? cfg.get('INNERTUBE_CLIENT_VERSION') : null; + return fromCfg || '2.20260114.01.00'; + } + + function normalizeTrustedUrl(value) { + if (!value) { + throw new Error('Missing interpreter url'); + } + return value.indexOf('//') === 0 ? 'https:' + value : value; + } + + function fetchChallenge(ctx) { + var context = { + client: { + clientName: 'WEB', + clientVersion: ctx.clientVersion, + hl: 'en', + gl: 'US', + utcOffsetMinutes: 0, + visitorData: ctx.visitorData + } + }; + return fetch('https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json', { + method: 'POST', + headers: { + 'Accept': '*/*', + 'Content-Type': 'application/json', + 'X-Goog-Visitor-Id': ctx.visitorData, + 'X-Youtube-Client-Version': ctx.clientVersion, + 'X-Youtube-Client-Name': '1' + }, + body: JSON.stringify({ + engagementType: 'ENGAGEMENT_TYPE_UNBOUND', + context: context + }) + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok || !data.bgChallenge) { + throw new Error('att/get failed status=' + response.status); + } + return data.bgChallenge; + }); + }); + } + + function resolveInterpreter(challenge, userAgent) { + var embedded = challenge.interpreterJavascript + && challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue; + if (embedded) { + return Promise.resolve(embedded); + } + var url = normalizeTrustedUrl( + (challenge.interpreterJavascript + && challenge.interpreterJavascript + .privateDoNotAccessOrElseTrustedResourceUrlWrappedValue) + || (challenge.interpreterUrl + && challenge.interpreterUrl + .privateDoNotAccessOrElseTrustedResourceUrlWrappedValue)); + return fetch(url, { headers: { 'User-Agent': userAgent } }).then(function (response) { + return response.text().then(function (js) { + if (!response.ok || !js) { + throw new Error('interpreter fetch failed status=' + response.status); + } + return js; + }); + }); + } + + function loadBotGuard(interpreterJavascript, program, globalName) { + return new Promise(function (resolve, reject) { + try { + new Function(interpreterJavascript)(); + } catch (e) { + reject(new Error('interpreter eval failed: ' + e.message)); + return; + } + var vm = window[globalName]; + if (!vm || typeof vm.a !== 'function') { + reject(new Error('BotGuard VM missing init function')); + return; + } + var timeout = setTimeout(function () { + reject(new Error('BotGuard init timeout')); + }, 10000); + try { + vm.a(program, function (asyncSnapshotFunction) { + clearTimeout(timeout); + resolve({ asyncSnapshotFunction: asyncSnapshotFunction }); + }, true, undefined, function () { }, [[], []]); + } catch (e) { + clearTimeout(timeout); + reject(new Error('BotGuard init threw: ' + e.message)); + } + }); + } + + function snapshot(functions, webPoSignalOutput) { + return new Promise(function (resolve, reject) { + var timeout = setTimeout(function () { + reject(new Error('BotGuard snapshot timeout')); + }, 10000); + functions.asyncSnapshotFunction(function (response) { + clearTimeout(timeout); + resolve(response); + }, [undefined, undefined, webPoSignalOutput, undefined]); + }); + } + + function fetchIntegrityToken(botGuardResponse, userAgent) { + return fetch('https://www.youtube.com/api/jnn/v1/GenerateIT', { + method: 'POST', + headers: { + 'content-type': 'application/json+protobuf', + 'x-goog-api-key': API_KEY, + 'x-user-agent': 'grpc-web-javascript/0.1', + 'User-Agent': userAgent + }, + body: JSON.stringify([REQUEST_KEY, botGuardResponse]) + }).then(function (response) { + return response.json().then(function (data) { + var integrityToken = data[0]; + if (typeof integrityToken !== 'string') { + throw new Error('GenerateIT failed status=' + response.status); + } + return integrityToken; + }); + }); + } + + function base64ToU8(value) { + var normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + var padded = normalized + '==='.slice((normalized.length + 3) % 4); + var binary = atob(padded); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + function u8ToBase64Url(value) { + var binary = ''; + for (var i = 0; i < value.length; i++) { + binary += String.fromCharCode(value[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + function mint(webPoSignalOutput, integrityToken, identifier) { + var getMinter = webPoSignalOutput[0]; + if (typeof getMinter !== 'function') { + return Promise.reject(new Error('Missing PO minter factory')); + } + return Promise.resolve(getMinter(base64ToU8(integrityToken))).then(function (mintCallback) { + if (typeof mintCallback !== 'function') { + throw new Error('Missing PO mint callback'); + } + return Promise.resolve(mintCallback(new TextEncoder().encode(identifier))) + .then(u8ToBase64Url); + }); + } + + function run() { + step('run start, readyState=' + document.readyState + ' origin=' + location.origin); + var videoId = window.__SABR_POC_VIDEO_ID || 'aqz-KE-bpKQ'; + var ctx = { + visitorData: readVisitorData(), + clientVersion: readClientVersion(), + userAgent: navigator.userAgent + }; + step('context ok visitorLen=' + ctx.visitorData.length + ' clientVersion=' + ctx.clientVersion); + var webPoSignalOutput = []; + var integrityTokenLength = -1; + step('fetching att/get challenge...'); + return fetchChallenge(ctx).then(function (challenge) { + step('challenge ok embedded=' + + !!(challenge.interpreterJavascript + && challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue)); + return resolveInterpreter(challenge, ctx.userAgent).then(function (interpreterJs) { + step('interpreter resolved len=' + (interpreterJs ? interpreterJs.length : -1)); + return loadBotGuard(interpreterJs, challenge.program, challenge.globalName); + }); + }).then(function (functions) { + step('botguard loaded, taking snapshot...'); + return snapshot(functions, webPoSignalOutput); + }).then(function (botGuardResponse) { + step('snapshot ok, calling GenerateIT...'); + return fetchIntegrityToken(botGuardResponse, ctx.userAgent); + }).then(function (integrityToken) { + step('integrity token len=' + integrityToken.length + ', minting...'); + integrityTokenLength = integrityToken.length; + return mint(webPoSignalOutput, integrityToken, videoId); + }).then(function (poToken) { + report({ + ok: true, + videoId: videoId, + clientVersion: ctx.clientVersion, + visitorDataLength: ctx.visitorData.length, + integrityTokenLength: integrityTokenLength, + poTokenLength: poToken.length, + poToken: poToken, + userAgent: ctx.userAgent + }); + }); + } + + function reportError(e) { + report({ + ok: false, + error: (e && e.message) ? e.message : String(e), + errorName: e && e.name ? e.name : '', + stack: e && e.stack ? String(e.stack).slice(0, 400) : '', + userAgent: navigator.userAgent + }); + } + + try { + run().catch(reportError); + } catch (e) { + reportError(e); + } +})(); diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java new file mode 100644 index 000000000..70a62df7c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java @@ -0,0 +1,281 @@ +package org.schabi.newpipe.player.datasource; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.webkit.JavascriptInterface; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.Nullable; + +import org.json.JSONObject; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrPoTokenProvider; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Generates YouTube SABR PO tokens by running the official BotGuard challenge inside a headless + * WebView (the legitimate attestation runtime), then handing the minted, videoId-bound token to the + * extractor's SABR session via {@link SabrPoTokenProvider}. + * + *

Validated end to end (emulator + Pixel 8 / GrapheneOS Vanadium): the WebView produces a token + * GenerateIT accepts and that flips SABR protection status 2 -> 1.

+ * + *

The provider blocks the calling (loading) thread on a latch while the WebView, driven on the + * main thread, runs the pipeline. Tokens are cached per videoId (~6h, well under the measured ~7-8h + * lifetime).

+ */ +public final class WebViewPoTokenProvider implements SabrPoTokenProvider { + + private static final String TAG = "WebViewPoToken"; + private static final String ASSET = "sabr_potoken_poc.js"; + private static final String DESKTOP_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"; + private static final long TOKEN_TTL_MS = 6L * 60L * 60L * 1000L; // 6 hours + private static final long PIPELINE_TIMEOUT_MS = 45_000L; + private static final int READY_RETRIES = 20; + private static final long READY_POLL_MS = 250L; + + private static final class CachedToken { + private final byte[] token; + private final long mintedAtMs; + + CachedToken(final byte[] token, final long mintedAtMs) { + this.token = token; + this.mintedAtMs = mintedAtMs; + } + } + + private final Context appContext; + private final Handler mainHandler; + private final Map cache = new ConcurrentHashMap<>(); + // one lock per videoId so two callers (pre-warm + pump) don't both fire the ~45s WebView mint + // for the same video. second one just waits and takes the cached token. + private final Map mintLocks = new ConcurrentHashMap<>(); + + public WebViewPoTokenProvider(final Context context) { + this.appContext = context.getApplicationContext(); + this.mainHandler = new Handler(Looper.getMainLooper()); + } + + @Nullable + @Override + public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamState streamState) { + return getPoToken(info, streamState, false); + } + + @Nullable + @Override + public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamState streamState, + final boolean forceRefresh) { + final String videoId = info.getVideoId(); + if (forceRefresh) { + // Server rejected the cached token (expired): drop it and mint a fresh one. + cache.remove(videoId); + } + synchronized (mintLocks.computeIfAbsent(videoId, k -> new Object())) { + final long now = System.currentTimeMillis(); + final CachedToken cached = cache.get(videoId); + if (cached != null && now - cached.mintedAtMs < TOKEN_TTL_MS) { + return cached.token; + } + final String tokenB64 = mintBlocking(videoId); + if (tokenB64 == null || tokenB64.isEmpty()) { + return null; + } + final byte[] token; + try { + token = Base64.getUrlDecoder().decode(tokenB64); + } catch (final IllegalArgumentException e) { + Log.e(TAG, "could not decode PO token", e); + return null; + } + cache.put(videoId, new CachedToken(token, now)); + return token; + } + } + + @Nullable + private String mintBlocking(final String videoId) { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference tokenRef = new AtomicReference<>(); + final AtomicReference webViewRef = new AtomicReference<>(); + + mainHandler.post(() -> { + try { + webViewRef.set(createWebView(videoId, tokenRef, latch)); + } catch (final Exception e) { + Log.e(TAG, "failed to start WebView pipeline", e); + latch.countDown(); + } + }); + + try { + if (!latch.await(PIPELINE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "PO token pipeline timed out for " + videoId); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + mainHandler.post(() -> destroyWebView(webViewRef.getAndSet(null))); + } + return tokenRef.get(); + } + + @SuppressLint("SetJavaScriptEnabled") + private WebView createWebView(final String videoId, + final AtomicReference tokenRef, + final CountDownLatch latch) { + final WebView webView = new WebView(appContext); + final WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setUserAgentString(DESKTOP_UA); + webView.addJavascriptInterface(new Bridge(tokenRef, latch), "SabrPocBridge"); + webView.setWebViewClient(new WebViewClient() { + private boolean injected = false; + + @Override + public WebResourceResponse shouldInterceptRequest(final WebView view, + final WebResourceRequest request) { + final String url = request.getUrl().toString(); + if (url.contains("/js/th/")) { + return fetchWithCors(url); + } + return super.shouldInterceptRequest(view, request); + } + + @Override + public void onPageFinished(final WebView view, final String url) { + super.onPageFinished(view, url); + if (injected || url == null || !url.contains("youtube.com")) { + return; + } + injected = true; + waitForReadyThenInject(view, videoId, 0); + } + }); + webView.loadUrl("https://www.youtube.com/"); + return webView; + } + + private void waitForReadyThenInject(final WebView view, final String videoId, final int attempt) { + view.evaluateJavascript("document.readyState", value -> { + final boolean complete = value != null && value.contains("complete"); + if (complete || attempt >= READY_RETRIES) { + view.evaluateJavascript( + "window.__SABR_POC_VIDEO_ID=" + jsString(videoId) + ";", null); + view.evaluateJavascript(loadPipelineScript(), null); + } else { + mainHandler.postDelayed( + () -> waitForReadyThenInject(view, videoId, attempt + 1), READY_POLL_MS); + } + }); + } + + private static void destroyWebView(@Nullable final WebView webView) { + if (webView == null) { + return; + } + try { + webView.stopLoading(); + webView.loadUrl("about:blank"); + webView.removeAllViews(); + webView.destroy(); + } catch (final Exception ignored) { + // best effort + } + } + + private static String jsString(final String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String loadPipelineScript() { + try (InputStream in = appContext.getAssets().open(ASSET); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + final byte[] chunk = new byte[8192]; + int read; + while ((read = in.read(chunk)) != -1) { + out.write(chunk, 0, read); + } + return out.toString("UTF-8"); + } catch (final Exception e) { + Log.e(TAG, "could not read pipeline asset", e); + return ""; + } + } + + @Nullable + private static WebResourceResponse fetchWithCors(final String url) { + try { + final HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestProperty("User-Agent", DESKTOP_UA); + connection.setConnectTimeout(15000); + connection.setReadTimeout(15000); + final int code = connection.getResponseCode(); + final InputStream body = code >= 400 + ? connection.getErrorStream() : connection.getInputStream(); + final String contentType = connection.getContentType(); + String mime = "application/javascript"; + if (contentType != null) { + final int sep = contentType.indexOf(';'); + mime = sep > 0 ? contentType.substring(0, sep).trim() : contentType.trim(); + } + final Map headers = new HashMap<>(); + headers.put("Access-Control-Allow-Origin", "*"); + final WebResourceResponse response = new WebResourceResponse(mime, "UTF-8", body); + response.setStatusCodeAndReasonPhrase(code, code >= 400 ? "ERROR" : "OK"); + response.setResponseHeaders(headers); + return response; + } catch (final Exception e) { + Log.e(TAG, "interpreter native fetch failed", e); + return null; + } + } + + private static final class Bridge { + private final AtomicReference tokenRef; + private final CountDownLatch latch; + + Bridge(final AtomicReference tokenRef, final CountDownLatch latch) { + this.tokenRef = tokenRef; + this.latch = latch; + } + + @JavascriptInterface + public void onResult(final String json) { + try { + final JSONObject obj = new JSONObject(json); + if (obj.optBoolean("ok", false)) { + tokenRef.set(obj.optString("poToken", null)); + } else { + Log.w(TAG, "PO token pipeline failed: " + obj.optString("error", "unknown")); + } + } catch (final Exception e) { + Log.e(TAG, "could not parse pipeline result", e); + } finally { + latch.countDown(); + } + } + } +} From f06bdcedb1cb8c7b62641673740d9b14f10f8f29 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 08/29] feat(player): SABR session store and format selection --- .../player/datasource/SabrSessionStore.java | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java new file mode 100644 index 000000000..e250bcc4d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -0,0 +1,289 @@ +package org.schabi.newpipe.player.datasource; + +import android.content.Context; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrPoTokenProvider; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Caches one shared {@link YoutubeSabrSession} per videoId so the audio and video + * {@link SabrDataSource}s drive the same session (a single SABR response carries both formats, so + * the session's segment cache serves both without doubling bandwidth). + * + *

v1: uses the best audio/video formats from the player response and a fixed en/US locale.

+ */ +public final class SabrSessionStore { + + private static final Map SESSIONS = new ConcurrentHashMap<>(); + // Keep only the few most-recent sessions so the map + per-video segment caches + pump threads + // don't accumulate forever as the user browses videos. Mutated only under the class lock. + private static final int MAX_SESSIONS = 3; + private static final java.util.Deque ORDER = new java.util.ArrayDeque<>(); + // Shared across videos so the PO-token cache (videoId-keyed, ~6h) is reused and a single + // WebView is held instead of one per video. + private static volatile WebViewPoTokenProvider sharedProvider; + + private SabrSessionStore() { + } + + @NonNull + private static WebViewPoTokenProvider provider(@NonNull final Context context) { + WebViewPoTokenProvider p = sharedProvider; + if (p == null) { + synchronized (SabrSessionStore.class) { + p = sharedProvider; + if (p == null) { + p = new WebViewPoTokenProvider(context.getApplicationContext()); + sharedProvider = p; + } + } + } + return p; + } + + /** Bundle of the session and its selected formats for a given video. */ + public static final class Holder { + @NonNull public final String videoId; + @NonNull public final YoutubeSabrInfo info; + @NonNull public final YoutubeSabrSession session; + @NonNull public final YoutubeSabrFormat audioFormat; + @NonNull public final YoutubeSabrFormat videoFormat; + + // Real playback position (ms); written by the player loop. Kept for reference but NOT used to + // drive the pump/eviction: it freezes when the player buffers, which deadlocked everything. + private volatile long playerTimeMs; + // What each track's data source has actually read (segment end ms). This is the truth the pump + // and eviction run on: it never goes stale (a stalled reader sits on its last segment, so the + // pump sees edge ~= readerHead and keeps feeding instead of pacing off a frozen play head). + private final Map readerPositions = new ConcurrentHashMap<>(); + private volatile SabrStreamPump pump; + + Holder(@NonNull final String videoId, + @NonNull final YoutubeSabrInfo info, + @NonNull final YoutubeSabrSession session, + @NonNull final YoutubeSabrFormat audioFormat, + @NonNull final YoutubeSabrFormat videoFormat) { + this.videoId = videoId; + this.info = info; + this.session = session; + this.audioFormat = audioFormat; + this.videoFormat = videoFormat; + } + + public long getPlayerTimeMs() { + return playerTimeMs; + } + + void setPlayerTimeMs(final long playerTimeMs) { + this.playerTimeMs = playerTimeMs; + } + + /** A data source reports how far it has read (last served segment end, ms). */ + public void setReaderPositionMs(final int itag, final long ms) { + readerPositions.put(itag, ms); + } + + /** Furthest-read track: the pump keeps the buffered edge a cushion ahead of THIS. */ + public long getReaderHeadMs() { + long head = 0; + final Long a = readerPositions.get(audioFormat.getItag()); + final Long v = readerPositions.get(videoFormat.getItag()); + if (a != null) { + head = Math.max(head, a); + } + if (v != null) { + head = Math.max(head, v); + } + return head; + } + + /** Slowest-read track: nothing before this is needed any more, so eviction starts here. Zero + * until BOTH tracks have read something (else we'd evict the other track's unread segments). */ + public long getReaderTailMs() { + final Long a = readerPositions.get(audioFormat.getItag()); + final Long v = readerPositions.get(videoFormat.getItag()); + if (a == null || v == null) { + return 0; + } + return Math.min(a, v); + } + + /** Lazily create the single background pump that feeds both data sources for this video. */ + synchronized SabrStreamPump getPump(@NonNull final Localization localization) { + if (pump == null) { + pump = new SabrStreamPump(session, this, localization); + } + return pump; + } + + boolean isBeyondEnd(@NonNull final SabrSegmentRequest request) { + return session.isBeyondEnd(request); + } + } + + // Report the real playback position; no-op when the video has no live SABR session. + public static void updatePlayerTime(@NonNull final String videoId, final long playerTimeMs) { + final Holder holder = SESSIONS.get(videoId); + if (holder != null && playerTimeMs >= 0) { + holder.setPlayerTimeMs(playerTimeMs); + } + } + + @NonNull + public static Holder getOrCreate(@NonNull final Context context, + @NonNull final String videoId) + throws IOException, ExtractionException { + final Holder existing = SESSIONS.get(videoId); + if (existing != null) { + return existing; + } + synchronized (SabrSessionStore.class) { + final Holder racing = SESSIONS.get(videoId); + if (racing != null) { + return racing; + } + final Localization localization = new Localization("en", "US"); + final ContentCountry contentCountry = new ContentCountry("US"); + final YoutubeSabrInfo info = YoutubeSabrProbeFetch(videoId, localization, contentCountry); + final YoutubeSabrFormat audioFormat = info.findBestAudioFormat(); + final YoutubeSabrFormat videoFormat = pickHardwareFriendlyVideo(info); + if (audioFormat == null || videoFormat == null) { + throw new IOException("SABR: could not select audio/video formats for " + videoId); + } + final SabrPoTokenProvider provider = provider(context); + final YoutubeSabrSession session = + new YoutubeSabrSession(info, audioFormat, videoFormat, provider); + final Holder holder = new Holder(videoId, info, session, audioFormat, videoFormat); + SESSIONS.put(videoId, holder); + // LRU bound: evict the oldest sessions (their pumps are stopped, caches freed). + ORDER.remove(videoId); + ORDER.addLast(videoId); + while (ORDER.size() > MAX_SESSIONS) { + final String old = ORDER.pollFirst(); + if (old != null && !old.equals(videoId)) { + evict(old); + } + } + // Pre-warm the PO token off-thread so the ~45s WebView mint overlaps the initial probe + // and buffering instead of stalling the pump on its first protected response. + final Thread warm = new Thread(() -> { + try { + provider.getPoToken(info, session.getStreamState()); + } catch (final Exception ignored) { + // Best-effort; the pump mints on demand if this fails. + } + }, "SabrTokenPrewarm"); + warm.setDaemon(true); + warm.start(); + return holder; + } + } + + @NonNull + private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String videoId, + @NonNull final Localization localization, + @NonNull final ContentCountry contentCountry) + throws IOException, ExtractionException { + return org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrProbe.fetchSabrInfo( + videoId, YoutubeSabrClientProfile.WEB, localization, contentCountry); + } + + /** + * Pick the highest-resolution video format the device can decode in HARDWARE. The decoder is + * chosen by ExoPlayer from the container bytes, so a codec the device only decodes in software + * (e.g. AV1 on most phones, or VP9 where there's no HW VP9) melts the CPU and overheats. So we + * allow AVC always (universally HW), VP9 only when a HW VP9 decoder exists, AV1 only when a HW + * AV1 decoder exists; otherwise fall back to the overall best (better some playback than none). + */ + @NonNull + private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info) { + final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); + final boolean hwAv1 = hasHardwareDecoder("video/av01"); + YoutubeSabrFormat best = null; + for (final YoutubeSabrFormat f : info.getFormats()) { + if (!f.isVideo()) { + continue; + } + final String codec = codecFamily(f.getMimeType()); + final boolean decodable = "avc".equals(codec) + || ("vp9".equals(codec) && hwVp9) + || ("av1".equals(codec) && hwAv1); + if (!decodable) { + continue; + } + if (best == null || f.getHeight() > best.getHeight() + || (f.getHeight() == best.getHeight() && f.getBitrate() > best.getBitrate())) { + best = f; + } + } + return best != null ? best : info.findBestVideoFormat(); + } + + /** Normalise a SABR format mimeType ({@code codecs="..."}) to a codec family, or null. */ + @NonNull + private static String codecFamily(final String mimeType) { + if (mimeType == null) { + return ""; + } + if (mimeType.contains("avc1") || mimeType.contains("avc3")) { + return "avc"; + } + if (mimeType.contains("vp9") || mimeType.contains("vp09")) { + return "vp9"; + } + if (mimeType.contains("av01")) { + return "av1"; + } + return ""; + } + + /** True if the device exposes a non-software (hardware) decoder for the given mime type. */ + private static boolean hasHardwareDecoder(@NonNull final String mimeType) { + try { + for (final MediaCodecInfo codec + : new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) { + if (codec.isEncoder()) { + continue; + } + final String name = codec.getName().toLowerCase(); + // Software decoders on Android are named c2.android.* / c2.google.* / omx.google.*. + if (name.startsWith("c2.android.") || name.startsWith("c2.google.") + || name.startsWith("omx.google.")) { + continue; + } + for (final String type : codec.getSupportedTypes()) { + if (type.equalsIgnoreCase(mimeType)) { + return true; + } + } + } + } catch (final Exception e) { + // If capability probing fails, be conservative (treat as no HW decoder). + return false; + } + return false; + } + + /** Evict a cached session, stopping its pump so the thread + buffers are released. */ + public static void evict(@NonNull final String videoId) { + final Holder holder = SESSIONS.remove(videoId); + if (holder != null && holder.pump != null) { + holder.pump.stop(); + } + } +} From aa9d5c85eb9d666f8b9de0f82d3e8245fef3daaa Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 09/29] feat(player): SABR pump and datasource (reader-driven) --- .../player/datasource/SabrDataSource.java | 215 ++++++++++++++++++ .../player/datasource/SabrStreamPump.java | 165 ++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java new file mode 100644 index 000000000..0afe457da --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java @@ -0,0 +1,215 @@ +package org.schabi.newpipe.player.datasource; + +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; + +/** + * ExoPlayer {@link DataSource} exposing one SABR format (audio or video) as a continuous byte + * stream: init segment then media segments. It only reads the session's concurrent cache, which a + * single {@link SabrStreamPump} fills ahead of the play head; so the two data sources never touch + * the network nor block each other. We end the stream past the last segment, on a pump fatal error, + * or if the play head stays frozen long enough to call it a genuine stall. + * + *

v1: sequential read from the start, seeks skip forward, length unknown until end-of-stream.

+ */ +public final class SabrDataSource implements DataSource { + + private static final String TAG = "SabrDataSource"; + + private static final long WAIT_MS = 250; + // only bail once the pump's been dry a while (bailing makes ExoPlayer re-open us, which unsticks + // the flow). be patient at cold start: the ~45s WebView mint = zero segments for a bit, and most + // underruns just sort themselves out once the pump catches up. + // do NOT EOF early on a stall: ExoPlayer re-opens at a byte offset and our v1 byte-skip seek + // fucks the fragmented container = frozen video. so we just ride the stall out. + private static final long STALL_MS = 120_000; + + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Localization localization; + + @Nullable + private Uri uri; + @Nullable + private byte[] current; + private int currentPos; + private boolean initServed; + private int nextSeq; + private boolean ended; + private long skipRemaining; + private volatile boolean canceled; + + public SabrDataSource(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Localization localization) { + this.holder = holder; + this.format = format; + this.localization = localization; + } + + @Override + public void addTransferListener(final TransferListener transferListener) { + // Bandwidth metering not wired for the SABR v1 source. + } + + @Override + public long open(final DataSpec dataSpec) { + this.uri = dataSpec.uri; + this.current = null; + this.currentPos = 0; + this.initServed = false; + this.nextSeq = 1; // SABR media sequence numbers are 1-based (0 is rejected) + this.ended = false; + this.skipRemaining = Math.max(0, dataSpec.position); + this.canceled = false; + return C.LENGTH_UNSET; + } + + @Override + public int read(final byte[] target, final int offset, final int length) throws IOException { + if (length == 0) { + return 0; + } + if (ended) { + return C.RESULT_END_OF_INPUT; + } + // Drop bytes for a forward seek (v1 skips from the start). + while (skipRemaining > 0) { + if (!ensureBuffer()) { + return C.RESULT_END_OF_INPUT; + } + final int available = current.length - currentPos; + final int drop = (int) Math.min(available, skipRemaining); + currentPos += drop; + skipRemaining -= drop; + } + if (!ensureBuffer()) { + return C.RESULT_END_OF_INPUT; + } + final int available = current.length - currentPos; + final int toCopy = Math.min(length, available); + System.arraycopy(current, currentPos, target, offset, toCopy); + currentPos += toCopy; + return toCopy; + } + + /** + * Make sure {@link #current} has unread bytes, waiting for the pump to cache the next segment. + * + * @return false if the stream is exhausted + */ + private boolean ensureBuffer() throws IOException { + if (current != null && currentPos < current.length) { + return true; + } + final SabrStreamPump pump = holder.getPump(localization); + while (true) { + if (canceled) { + ended = true; + return false; + } + final SabrSegmentRequest request = initServed + ? SabrSegmentRequest.media(format, nextSeq) + : SabrSegmentRequest.initialization(format); + pump.ensureStarted(); + final SabrMediaSegment segment = pump.getCached(request); + if (segment != null) { + if (initServed) { + nextSeq++; + } else { + initServed = true; + } + if (!segment.getHeader().isInitSegment()) { + // tell the pump/eviction how far this track has actually read (never stale). + holder.setReaderPositionMs(format.getItag(), + segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); + } + current = segment.getData(); + currentPos = 0; + if (current.length == 0) { + continue; + } + return true; + } + if (holder.isBeyondEnd(request)) { + ended = true; + return false; + } + if (pump.isFatal()) { + // Surface a real error (not a clean EOF) so ExoPlayer reports a playback error + // instead of pretending the video ended. The session was evicted on fatal, so a + // retry rebuilds a fresh one. + throw new IOException("SABR pump fatal for itag=" + format.getItag() + + " at seq=" + nextSeq); + } + // not cached yet: pump's fetching or the server's pacing us. wait, don't signal EOF + // (that triggers a corrupting re-open). only bail if the pump's been fully dry long + // enough to be a real dead stall. + if (pump.millisSinceLastSegment() > STALL_MS) { + Log.i(TAG, "end of SABR stream (stalled) itag=" + format.getItag() + + " at seq=" + nextSeq); + ended = true; + return false; + } + try { + Thread.sleep(WAIT_MS); + } catch (final InterruptedException ie) { + Thread.currentThread().interrupt(); + if (canceled) { + ended = true; + return false; // clean cancellation (close/seek/release), not a playback error + } + throw new IOException("Interrupted during SABR wait", ie); + } + } + } + + @Nullable + @Override + public Uri getUri() { + return uri; + } + + @Override + public void close() { + // Unblock a read() that is waiting for the pump (it polls this flag), so ExoPlayer can + // release this loader thread promptly on stop/seek/track-change. + canceled = true; + current = null; + currentPos = 0; + } + + /** Factory binding a {@link SabrDataSource} to one shared session holder + format. */ + public static final class Factory implements DataSource.Factory { + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Localization localization; + + public Factory(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Localization localization) { + this.holder = holder; + this.format = format; + this.localization = localization; + } + + @Override + public DataSource createDataSource() { + return new SabrDataSource(holder, format, localization); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java new file mode 100644 index 000000000..58af65467 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -0,0 +1,165 @@ +package org.schabi.newpipe.player.datasource; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; + +import java.io.IOException; +import java.util.List; + +/** + * Single consumer of a {@link YoutubeSabrSession}: one daemon thread pumps the server-driven SABR + * stream and fills the session's (concurrent) segment cache ahead of the play head. The server + * paces us with policy-only responses once we are far enough ahead. Both the audio and video + * {@link SabrDataSource}s only read the cache, so they never fight over the session or block each + * other on a network round-trip, which is exactly what starved a track in the old on-demand approach. + */ +final class SabrStreamPump { + + private static final String TAG = "SabrStreamPump"; + private static final long IDLE_POLL_MS = 400; // server paced us / nothing new this round + private static final long ERROR_RETRY_MS = 1000; // transient network error + // no reads for this long -> playback is gone. MUST stay above READAHEAD_CUSHION_MS: once the + // player buffer is full it stops reading us for ~cushion seconds, and killing the pump in that + // window left the cache to drain dry -> periodic rebuffering. + private static final long IDLE_STOP_MS = 90_000; + // Margin the buffered edge stays ahead of the furthest-read track. Driven off the reader (not the + // play head) it only needs to cover a few segments, so it stays small -> bounded memory even at 4K. + private static final long READAHEAD_CUSHION_MS = 30_000; + // Hard byte ceiling on read-ahead so a high-bitrate (4K) stream can't OOM the heap: 50s of 4K is + // ~160MB and crashed. ~100MB still covers the player's ~30s read-ahead, well under the OOM line. + private static final long MAX_AHEAD_BYTES = 100L * 1024 * 1024; + + private final YoutubeSabrSession session; + private final SabrSessionStore.Holder holder; + private final Localization localization; + + private volatile boolean started; + private volatile boolean stopped; + private volatile boolean fatal; + private volatile long lastReadMs; + private volatile long lastSegmentMs; + private Thread thread; + + SabrStreamPump(@NonNull final YoutubeSabrSession session, + @NonNull final SabrSessionStore.Holder holder, + @NonNull final Localization localization) { + this.session = session; + this.holder = holder; + this.localization = localization; + } + + /** Start (or restart, if it idled out) the pump thread, and mark the session as actively read. */ + void ensureStarted() { + lastReadMs = System.currentTimeMillis(); + if (fatal || (started && !stopped)) { + return; + } + synchronized (this) { + if (fatal || (started && !stopped)) { + return; + } + stopped = false; + started = true; + lastSegmentMs = System.currentTimeMillis(); + thread = new Thread(this::loop, "SabrStreamPump"); + thread.setDaemon(true); + thread.start(); + } + } + + /** Stop the pump thread and release it (called on eviction / playback teardown). */ + void stop() { + synchronized (this) { + stopped = true; + // Don't self-interrupt: stop() is also reached from the pump thread itself via + // evict-on-fatal, and setting our own interrupt flag could break a later blocking call. + if (thread != null && thread != Thread.currentThread()) { + thread.interrupt(); + } + } + } + + /** ms since the pump last grabbed a segment. basically "is this thing dead or what". */ + long millisSinceLastSegment() { + return System.currentTimeMillis() - lastSegmentMs; + } + + @Nullable + SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { + // revive the pump if it idled out: any read means playback is live again. + ensureStarted(); + return session.getCachedSegment(request); + } + + boolean isFatal() { + return fatal; + } + + private void loop() { + try { + while (!stopped) { + if (System.currentTimeMillis() - lastReadMs > IDLE_STOP_MS || session.isComplete()) { + break; + } + try { + // Drive off what the player has ACTUALLY read, not the play head: the play head + // freezes while buffering and that deadlocked the pump. readerHead = furthest + // track read; readerTail = slowest track read (safe to evict below). + final long readerHeadMs = holder.getReaderHeadMs(); + // evict everything both tracks have read past, EVERY round (even before we throttle + // below) or a full cache never drains and the throttle latches forever -> freeze. + session.setPlayHeadMs(holder.getReaderTailMs()); + session.evictPlayed(); + final long edgeMs = session.getStreamState().getMinBufferedEndMs(); + if (edgeMs - readerHeadMs > READAHEAD_CUSHION_MS + || session.getCachedBytes() > MAX_AHEAD_BYTES) { + Thread.sleep(IDLE_POLL_MS); + continue; + } + // Report the CONTIGUOUS buffered edge (not readerHead): the server fills from the + // reported position, so reporting readerHead (ahead of a laggard track) made it + // skip past the gap and the slow track's edge never advanced. Pace on readerHead, + // report on edge. + session.getStreamState().setPlayerTimeMs(edgeMs); + final List segments = session.pumpOnce(localization); + if (segments.isEmpty()) { + Thread.sleep(IDLE_POLL_MS); + } else { + lastSegmentMs = System.currentTimeMillis(); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (final IOException e) { + sleepQuietly(ERROR_RETRY_MS); + } catch (final ExtractionException e) { + Log.i(TAG, "SABR pump fatal: " + e.getMessage()); + fatal = true; + // Drop the dead session so a re-open rebuilds a fresh one (new token, new state). + SabrSessionStore.evict(holder.videoId); + break; + } + } + } finally { + synchronized (this) { + stopped = true; + } + } + } + + private static void sleepQuietly(final long ms) { + try { + Thread.sleep(ms); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} From fb1a2c363a37b990d7878804598717d4e8a1b5ff Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 10/29] feat(player): wire SABR into the player, resolver and load control --- app/src/main/AndroidManifest.xml | 1 + .../org/schabi/newpipe/player/Player.java | 44 +++++++++++++++++-- .../newpipe/player/helper/LoadController.java | 28 ++++++++++++ .../player/resolver/PlaybackResolver.java | 33 ++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5152dd485..a9e04a151 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,7 @@ android:banner="@mipmap/tv_banner" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:logo="@mipmap/ic_launcher" android:theme="@style/OpeningTheme" android:resizeableActivity="true" diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 6a0773423..bfd5b53b0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -131,6 +131,7 @@ import org.schabi.newpipe.player.helper.MediaSessionManager; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.datasource.SabrSessionStore; import org.schabi.newpipe.player.listeners.view.PlaybackSpeedClickListener; import org.schabi.newpipe.player.listeners.view.QualityClickListener; import org.schabi.newpipe.player.mediaitem.MediaItemTag; @@ -1781,6 +1782,10 @@ private void onUpdateProgress(final int currentProgress, return; } + // Feed the real play head to any live SABR session (no-op otherwise). + getCurrentStreamInfo().ifPresent(info -> + SabrSessionStore.updatePlayerTime(info.getId(), currentProgress)); + if (duration != binding.playbackSeekBar.getMax()) { setVideoDurationToControls(duration); } @@ -3298,9 +3303,36 @@ public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, final boole } public boolean shouldSeek() { + // our v1 SABR seek is a dumb byte-skip that can't land on a real position, so resuming + // mid-video just freezes the whole thing. so SABR always starts from 0, scrubbing can wait. + // honestly nobody died from rewatching an intro. plays fine from 0. + if (isCurrentStreamSabr()) { + return false; + } return !prefs.getBoolean(context.getString(R.string.always_start_from_beginning_key), false); } + private boolean isCurrentStreamSabr() { + return getCurrentStreamInfo().map(info -> { + for (final VideoStream s : info.getVideoOnlyStreams()) { + if (s.getDeliveryMethod() == DeliveryMethod.SABR) { + return true; + } + } + for (final VideoStream s : info.getVideoStreams()) { + if (s.getDeliveryMethod() == DeliveryMethod.SABR) { + return true; + } + } + for (final AudioStream s : info.getAudioStreams()) { + if (s.getDeliveryMethod() == DeliveryMethod.SABR) { + return true; + } + } + return false; + }).orElse(false); + } + public void seekTo(final long positionMillis) { if (DEBUG) { Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]"); @@ -4001,15 +4033,21 @@ private void buildQualityMenu() { for (int i = 0; i < availableStreams.size(); i++) { final VideoStream videoStream = availableStreams.get(i); - qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, videoStream.getCodec().toUpperCase().split("\\.")[0] + " " + videoStream.resolution); + qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, videoStream.getCodec().toUpperCase().split("\\.")[0] + " " + videoStream.resolution + sabrTag(videoStream)); } if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().resolution + sabrTag(getSelectedVideoStream())); } qualityPopupMenu.setOnMenuItemClickListener(this); qualityPopupMenu.setOnDismissListener(this); } + // PoC marker: flag SABR-delivered streams in the quality UI + private static String sabrTag(final VideoStream stream) { + return stream != null && stream.getDeliveryMethod() == DeliveryMethod.SABR + ? " (SABR)" : ""; + } + private void buildPlaybackSpeedMenu() { if (playbackSpeedPopupMenu == null) { return; @@ -4149,7 +4187,7 @@ public void onDismiss(@Nullable final PopupMenu menu) { } isSomePopupMenuVisible = false; //TODO check if this works if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().resolution + sabrTag(getSelectedVideoStream())); } if (isPlaying()) { hideControls(DEFAULT_CONTROLS_DURATION, 0); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index bcdcd9643..5ee0dacb2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -1,12 +1,40 @@ package org.schabi.newpipe.player.helper; +import androidx.media3.common.C; import androidx.media3.exoplayer.DefaultLoadControl; +import androidx.media3.exoplayer.upstream.DefaultAllocator; public class LoadController extends DefaultLoadControl { public static final String TAG = "LoadController"; + + // Keep the player's buffer target well BELOW the SABR pump's read-ahead cushion (50s). The player + // reads ahead to fill maxBuffer (plus up to one whole segment, ~10s for audio), and if that runs + // past what the pump has fed it blocks on a segment the pump won't fetch yet (pump paces off the + // play head): cold start deadlocked, and the longer-segment track (audio) dropped out mid-play. + // 20s target -> ~30s real read-ahead, comfortably inside the 50s cushion. Fine for DASH/HLS. + private static final int MIN_BUFFER_MS = 12_000; + private static final int MAX_BUFFER_MS = 20_000; + private static final int BUFFER_FOR_PLAYBACK_MS = 2_000; + private static final int BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 3_000; + private boolean preloadingEnabled = true; + public LoadController() { + super(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + MIN_BUFFER_MS, + MAX_BUFFER_MS, + BUFFER_FOR_PLAYBACK_MS, + BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + C.LENGTH_UNSET, // no byte cap: the SABR cache bounds memory, time bounds the player + // MUST be true: with false, media3 prioritises its (huge, ~128MB) default byte target + // and ignores maxBufferMs, reading ~50s ahead = right at the pump cushion, so it + // starved at the edge. true makes maxBufferMs (time) the real limit. + true, + DEFAULT_BACK_BUFFER_DURATION_MS, + DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); + } + @Override public void onPrepared() { preloadingEnabled = true; diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index e15a73033..dfbbd0e72 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -47,6 +47,12 @@ import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.App; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; +import org.schabi.newpipe.player.datasource.SabrDataSource; +import org.schabi.newpipe.player.datasource.SabrSessionStore; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -437,12 +443,39 @@ private static MediaSource createYoutubeMediaSourceOfVideoStr .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); + case SABR: + return buildSabrMediaSource(stream, streamInfo, cacheKey, metadata); default: throw new IOException("Unsupported delivery method for YouTube contents: " + deliveryMethod); } } + @NonNull + private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, + @NonNull final StreamInfo streamInfo, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) + throws IOException { + final String videoId = streamInfo.getId(); + final SabrSessionStore.Holder holder; + try { + holder = SabrSessionStore.getOrCreate(App.getApp(), videoId); + } catch (final ExtractionException e) { + throw new IOException("Could not start SABR session for " + videoId, e); + } + final YoutubeSabrFormat format = (stream instanceof AudioStream) + ? holder.audioFormat : holder.videoFormat; + final SabrDataSource.Factory factory = new SabrDataSource.Factory( + holder, format, new Localization("en", "US")); + return new ProgressiveMediaSource.Factory(factory).createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse("sabr://" + videoId + "/" + format.getItag())) + .setCustomCacheKey(cacheKey) + .build()); + } + @NonNull private static DashMediaSource buildYoutubeManualDashMediaSource( @NonNull final PlayerDataSource dataSource, From 0c2a3edadaf41556c473278342b18fb818c2bf09 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 16:45:34 +0200 Subject: [PATCH 11/29] fix(sabr): cap cached sessions to 2 to stop the cross-video black screen --- .../newpipe/player/datasource/SabrSessionStore.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index e250bcc4d..1bda11239 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -30,9 +30,11 @@ public final class SabrSessionStore { private static final Map SESSIONS = new ConcurrentHashMap<>(); - // Keep only the few most-recent sessions so the map + per-video segment caches + pump threads - // don't accumulate forever as the user browses videos. Mutated only under the class lock. - private static final int MAX_SESSIONS = 3; + // Current video plus one (next-item prefetch). Keeping more let abandoned sessions' pump threads + // linger and bleed into the new playback on a switch, leaving the decoder with no usable frame + // (black screen). Evicting the superseded session promptly (and stopping its pump) fixes that. + // Mutated only under the class lock. + private static final int MAX_SESSIONS = 2; private static final java.util.Deque ORDER = new java.util.ArrayDeque<>(); // Shared across videos so the PO-token cache (videoId-keyed, ~6h) is reused and a single // WebView is held instead of one per video. From 2a99b2dbde87f39aa6ff1838480760843ae348c9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:06:49 +0200 Subject: [PATCH 12/29] feat(sabr): per-segment data source for the chunk-based source (tier2) --- .../datasource/SabrSegmentDataSource.java | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java new file mode 100644 index 000000000..656515589 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -0,0 +1,140 @@ +package org.schabi.newpipe.player.datasource; + +import android.net.Uri; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; + +/** + * Tier-2 chunk source helper: a {@link DataSource} that serves exactly ONE SABR segment (the init + * segment or one media segment) from the session cache, then ends. The chunk framework + * ({@code ChunkSampleStream}) opens one of these per chunk, so seeking is handled by the framework + * picking the chunk index, NOT by byte-skipping a continuous stream (which the v1 source could not + * land). + * + *

The segment is identified by the {@link DataSpec} uri: {@code sabrseg:///init} or + * {@code sabrseg:///}.

+ */ +public final class SabrSegmentDataSource implements DataSource { + + private static final long WAIT_MS = 250; + private static final long STALL_MS = 120_000; + + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Localization localization; + + @Nullable + private Uri uri; + @Nullable + private byte[] data; + private int pos; + private boolean opened; + private volatile boolean canceled; + + public SabrSegmentDataSource(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Localization localization) { + this.holder = holder; + this.format = format; + this.localization = localization; + } + + @Override + public void addTransferListener(final TransferListener transferListener) { + // Bandwidth metering not wired for the SABR source. + } + + @Override + public long open(final DataSpec dataSpec) throws IOException { + this.uri = dataSpec.uri; + this.canceled = false; + this.pos = (int) Math.max(0, dataSpec.position); + final SabrSegmentRequest request = requestFromUri(dataSpec.uri); + this.data = awaitSegment(request); + this.opened = true; + final int remaining = data.length - pos; + return dataSpec.length == C.LENGTH_UNSET ? remaining : Math.min(dataSpec.length, remaining); + } + + @Override + public int read(final byte[] target, final int offset, final int length) { + if (length == 0) { + return 0; + } + if (data == null || pos >= data.length) { + return C.RESULT_END_OF_INPUT; + } + final int toCopy = Math.min(length, data.length - pos); + System.arraycopy(data, pos, target, offset, toCopy); + pos += toCopy; + return toCopy; + } + + private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { + // sabrseg:/// + final String seg = u.getLastPathSegment(); + if (seg == null) { + throw new IOException("Bad SABR segment uri: " + u); + } + if ("init".equals(seg)) { + return SabrSegmentRequest.initialization(format); + } + try { + return SabrSegmentRequest.media(format, Integer.parseInt(seg)); + } catch (final NumberFormatException e) { + throw new IOException("Bad SABR segment uri: " + u, e); + } + } + + /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ + private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { + final SabrStreamPump pump = holder.getPump(localization); + while (true) { + if (canceled) { + throw new IOException("SABR segment read canceled"); + } + pump.ensureStarted(); + final SabrMediaSegment segment = pump.getCached(request); + if (segment != null) { + return segment.getData(); + } + if (pump.isFatal()) { + throw new IOException("SABR pump fatal for itag=" + format.getItag()); + } + if (pump.millisSinceLastSegment() > STALL_MS) { + throw new IOException("SABR segment stalled for itag=" + format.getItag()); + } + try { + Thread.sleep(WAIT_MS); + } catch (final InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted awaiting SABR segment", ie); + } + } + } + + @Nullable + @Override + public Uri getUri() { + return uri; + } + + @Override + public void close() { + canceled = true; + data = null; + opened = false; + } +} From 6b4389898244ab915fe691a3a5d9a85c096aad42 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:14:57 +0200 Subject: [PATCH 13/29] feat(sabr): seekable chunk-based MediaSource core (tier2) --- .../player/datasource/SabrChunkSource.java | 153 ++++++++++ .../player/datasource/SabrMediaPeriod.java | 278 ++++++++++++++++++ .../player/datasource/SabrMediaSource.java | 101 +++++++ 3 files changed, 532 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java new file mode 100644 index 000000000..0c52d801d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java @@ -0,0 +1,153 @@ +package org.schabi.newpipe.player.datasource; + +import android.net.Uri; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.datasource.DataSpec; +import androidx.media3.exoplayer.LoadingInfo; +import androidx.media3.exoplayer.SeekParameters; +import androidx.media3.exoplayer.source.chunk.BundledChunkExtractor; +import androidx.media3.exoplayer.source.chunk.Chunk; +import androidx.media3.exoplayer.source.chunk.ChunkExtractor; +import androidx.media3.exoplayer.source.chunk.ChunkHolder; +import androidx.media3.exoplayer.source.chunk.ChunkSource; +import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk; +import androidx.media3.exoplayer.source.chunk.InitializationChunk; +import androidx.media3.exoplayer.source.chunk.MediaChunk; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.extractor.mp4.FragmentedMp4Extractor; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; +import java.util.List; + +/** + * Tier-2: feeds the media3 chunk framework one SABR segment per chunk. Because the framework drives + * loading by chunk INDEX (mapped from time), seeking is time-based and real, unlike the v1 byte + * stream that could not land a seek. One {@link FragmentedMp4Extractor} is shared per track via a + * {@link BundledChunkExtractor}; the init segment is loaded once as an {@link InitializationChunk}, + * then each media segment is a {@link ContainerMediaChunk}. + */ +final class SabrChunkSource implements ChunkSource { + + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Format trackFormat; + private final int trackType; + private final Localization localization; + private final ChunkExtractor chunkExtractor; + + private boolean initLoaded; + @Nullable + private IOException fatalError; + + SabrChunkSource(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Format trackFormat, + final int trackType, + final Localization localization) { + this.holder = holder; + this.format = format; + this.trackFormat = trackFormat; + this.trackType = trackType; + this.localization = localization; + this.chunkExtractor = new BundledChunkExtractor( + new FragmentedMp4Extractor(), trackType, trackFormat); + } + + @Override + public long getAdjustedSeekPositionUs(final long positionUs, final SeekParameters seekParameters) { + // Snap to the start of the segment that contains positionUs. + final int seq = holder.session.getStreamState() + .getSegmentNumberAtOrAfterTimeMs(format, positionUs / 1000); + final long startMs = holder.session.getStreamState().getSegmentStartMs(format, seq); + return Math.max(0, startMs) * 1000; + } + + @Override + public void maybeThrowError() throws IOException { + if (fatalError != null) { + throw fatalError; + } + } + + @Override + public int getPreferredQueueSize(final long playbackPositionUs, + final List queue) { + return queue.size(); + } + + @Override + public boolean shouldCancelLoad(final long playbackPositionUs, final Chunk loadingChunk, + final List queue) { + return false; + } + + @Override + public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionUs, + final List queue, final ChunkHolder out) { + if (!initLoaded) { + out.chunk = newInitChunk(); + return; + } + final int nextSeq; + if (queue.isEmpty()) { + nextSeq = holder.session.getStreamState() + .getSegmentNumberAtOrAfterTimeMs(format, loadPositionUs / 1000); + } else { + nextSeq = (int) (queue.get(queue.size() - 1).getNextChunkIndex()); + } + final long endSeq = holder.session.getStreamState().getEndSegment(format); + if (endSeq > 0 && nextSeq > endSeq) { + out.endOfStream = true; + return; + } + out.chunk = newMediaChunk(nextSeq); + } + + private Chunk newInitChunk() { + final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/init")); + return new InitializationChunk( + new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, + C.SELECTION_REASON_UNKNOWN, null, chunkExtractor); + } + + private Chunk newMediaChunk(final int seq) { + final long startMs = holder.session.getStreamState().getSegmentStartMs(format, seq); + final long endMs = holder.session.getStreamState().getSegmentEndMs(format, seq); + final long startUs = Math.max(0, startMs) * 1000; + final long endUs = (endMs > 0 ? endMs : startMs) * 1000; + final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/" + seq)); + return new ContainerMediaChunk( + new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, + C.SELECTION_REASON_UNKNOWN, null, + startUs, endUs, /* clippedStartTimeUs= */ startUs, /* clippedEndTimeUs= */ endUs, + /* chunkIndex= */ seq, /* chunkCount= */ 1, /* sampleOffsetUs= */ startUs, + chunkExtractor); + } + + @Override + public void onChunkLoadCompleted(final Chunk chunk) { + if (chunk instanceof InitializationChunk) { + initLoaded = true; + } + } + + @Override + public boolean onChunkLoadError(final Chunk chunk, final boolean cancelable, + final LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo, + final LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + // Let the framework apply its retry/backoff policy. + return false; + } + + @Override + public void release() { + chunkExtractor.release(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java new file mode 100644 index 000000000..12c16094c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -0,0 +1,278 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.TrackGroup; +import androidx.media3.exoplayer.LoadingInfo; +import androidx.media3.exoplayer.SeekParameters; +import androidx.media3.exoplayer.drm.DrmSessionEventListener; +import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.MediaSourceEventListener; +import androidx.media3.exoplayer.source.SampleStream; +import androidx.media3.exoplayer.source.SequenceableLoader; +import androidx.media3.exoplayer.source.TrackGroupArray; +import androidx.media3.exoplayer.source.chunk.ChunkSampleStream; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Tier-2 {@link MediaPeriod} for SABR: exposes the audio and video tracks and backs each selected + * one with a {@link ChunkSampleStream} over a {@link SabrChunkSource}. Seeking is handled by the + * chunk streams (time -> chunk index), so it actually lands, unlike the v1 byte-stream source. + */ +final class SabrMediaPeriod implements MediaPeriod, + SequenceableLoader.Callback> { + + private final SabrSessionStore.Holder holder; + private final Localization localization; + private final long durationUs; + private final Allocator allocator; + private final DrmSessionManager drmSessionManager; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy = + new DefaultLoadErrorHandlingPolicy(); + + private final TrackGroupArray trackGroups; + private final YoutubeSabrFormat[] sabrFormats; + private final int[] trackTypes; + + private final List> streams = new ArrayList<>(); + private SequenceableLoader compositeLoader = new EmptyLoader(); + @Nullable + private MediaPeriod.Callback callback; + + SabrMediaPeriod(final SabrSessionStore.Holder holder, + final Format audioFormat, + final Format videoFormat, + final long durationUs, + final Allocator allocator, + final DrmSessionManager drmSessionManager, + final DrmSessionEventListener.EventDispatcher drmEventDispatcher, + final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + final Localization localization) { + this.holder = holder; + this.localization = localization; + this.durationUs = durationUs; + this.allocator = allocator; + this.drmSessionManager = drmSessionManager; + this.drmEventDispatcher = drmEventDispatcher; + this.mediaSourceEventDispatcher = mediaSourceEventDispatcher; + this.sabrFormats = new YoutubeSabrFormat[]{holder.videoFormat, holder.audioFormat}; + this.trackTypes = new int[]{C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO}; + this.trackGroups = new TrackGroupArray( + new TrackGroup("sabr-video", videoFormat), + new TrackGroup("sabr-audio", audioFormat)); + } + + @Override + public void prepare(final MediaPeriod.Callback cb, final long positionUs) { + this.callback = cb; + cb.onPrepared(this); + } + + @Override + public void maybeThrowPrepareError() { + } + + @Override + public TrackGroupArray getTrackGroups() { + return trackGroups; + } + + @Override + public long selectTracks(final ExoTrackSelection[] selections, final boolean[] mayRetainFlags, + final SampleStream[] outStreams, final boolean[] streamResetFlags, + final long positionUs) { + // Release streams no longer wanted; create streams for newly selected tracks. + for (int i = 0; i < selections.length; i++) { + if (outStreams[i] instanceof ChunkSampleStream && (selections[i] == null + || !mayRetainFlags[i])) { + @SuppressWarnings("unchecked") + final ChunkSampleStream s = + (ChunkSampleStream) outStreams[i]; + streams.remove(s); + s.release(); + outStreams[i] = null; + } + if (outStreams[i] == null && selections[i] != null) { + final ChunkSampleStream s = buildStream(selections[i], positionUs); + streams.add(s); + outStreams[i] = s; + streamResetFlags[i] = true; + } + } + rebuildCompositeLoader(); + return positionUs; + } + + private ChunkSampleStream buildStream(final ExoTrackSelection selection, + final long positionUs) { + final TrackGroup group = selection.getTrackGroup(); + final int groupIndex = trackGroups.indexOf(group); + final Format trackFormat = group.getFormat(0); + final SabrChunkSource chunkSource = new SabrChunkSource(holder, sabrFormats[groupIndex], + trackFormat, trackTypes[groupIndex], localization); + return new ChunkSampleStream<>(trackTypes[groupIndex], null, null, chunkSource, this, + allocator, positionUs, drmSessionManager, drmEventDispatcher, + loadErrorHandlingPolicy, mediaSourceEventDispatcher); + } + + private void rebuildCompositeLoader() { + // Simplest correct loader: drive each stream; report the min buffered / max load position. + compositeLoader = new SequenceableLoader() { + @Override + public long getBufferedPositionUs() { + long min = Long.MAX_VALUE; + for (final ChunkSampleStream s : streams) { + min = Math.min(min, s.getBufferedPositionUs()); + } + return streams.isEmpty() ? C.TIME_END_OF_SOURCE : min; + } + + @Override + public long getNextLoadPositionUs() { + long min = Long.MAX_VALUE; + for (final ChunkSampleStream s : streams) { + final long n = s.getNextLoadPositionUs(); + if (n != C.TIME_END_OF_SOURCE) { + min = Math.min(min, n); + } + } + return min == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : min; + } + + @Override + public boolean continueLoading(final LoadingInfo loadingInfo) { + boolean any = false; + for (final ChunkSampleStream s : streams) { + any |= s.continueLoading(loadingInfo); + } + return any; + } + + @Override + public boolean isLoading() { + for (final ChunkSampleStream s : streams) { + if (s.isLoading()) { + return true; + } + } + return false; + } + + @Override + public void reevaluateBuffer(final long positionUs) { + for (final ChunkSampleStream s : streams) { + s.reevaluateBuffer(positionUs); + } + } + }; + } + + @Override + public void discardBuffer(final long positionUs, final boolean toKeyframe) { + for (final ChunkSampleStream s : streams) { + s.discardBuffer(positionUs, toKeyframe); + } + } + + @Override + public long readDiscontinuity() { + return C.TIME_UNSET; + } + + @Override + public long seekToUs(final long positionUs) { + for (final ChunkSampleStream s : streams) { + s.seekToUs(positionUs); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(final long positionUs, final SeekParameters params) { + for (final ChunkSampleStream s : streams) { + return s.getAdjustedSeekPositionUs(positionUs, params); + } + return positionUs; + } + + @Override + public long getBufferedPositionUs() { + return compositeLoader.getBufferedPositionUs(); + } + + @Override + public long getNextLoadPositionUs() { + return compositeLoader.getNextLoadPositionUs(); + } + + @Override + public boolean continueLoading(final LoadingInfo loadingInfo) { + return compositeLoader.continueLoading(loadingInfo); + } + + @Override + public boolean isLoading() { + return compositeLoader.isLoading(); + } + + @Override + public void reevaluateBuffer(final long positionUs) { + compositeLoader.reevaluateBuffer(positionUs); + } + + @Override + public void onContinueLoadingRequested(final ChunkSampleStream source) { + if (callback != null) { + callback.onContinueLoadingRequested(this); + } + } + + void release() { + for (final ChunkSampleStream s : streams) { + s.release(); + } + streams.clear(); + } + + /** No-op loader used before any track is selected. */ + private static final class EmptyLoader implements SequenceableLoader { + @Override + public long getBufferedPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public long getNextLoadPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public boolean continueLoading(final LoadingInfo loadingInfo) { + return false; + } + + @Override + public boolean isLoading() { + return false; + } + + @Override + public void reevaluateBuffer(final long positionUs) { + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java new file mode 100644 index 000000000..2d928f248 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java @@ -0,0 +1,101 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.Nullable; + +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.datasource.TransferListener; +import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.source.BaseMediaSource; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.SinglePeriodTimeline; +import androidx.media3.exoplayer.upstream.Allocator; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +/** + * Tier-2 {@link androidx.media3.exoplayer.source.MediaSource} for SABR. Unlike the v1 + * ProgressiveMediaSource over a byte stream (which could not seek), this exposes a seekable + * single-period timeline and a {@link SabrMediaPeriod} backed by the chunk framework, so seeking is + * time-based and lands correctly. The session is created by the resolver and handed in. + */ +public final class SabrMediaSource extends BaseMediaSource { + + private final MediaItem mediaItem; + private final SabrSessionStore.Holder holder; + private final Localization localization; + private final Format audioFormat; + private final Format videoFormat; + private final long durationUs; + + public SabrMediaSource(final MediaItem mediaItem, + final SabrSessionStore.Holder holder, + final Localization localization) { + this.mediaItem = mediaItem; + this.holder = holder; + this.localization = localization; + this.audioFormat = toMedia3Format(holder.audioFormat); + this.videoFormat = toMedia3Format(holder.videoFormat); + this.durationUs = Math.max(holder.audioFormat.getApproxDurationMs(), + holder.videoFormat.getApproxDurationMs()) * 1000L; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { + refreshSourceInfo(new SinglePeriodTimeline(durationUs, /* isSeekable= */ true, + /* isDynamic= */ false, /* useLiveConfiguration= */ false, + /* manifest= */ null, mediaItem)); + } + + @Override + public void maybeThrowSourceInfoRefreshError() { + } + + @Override + public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, + final long startPositionUs) { + return new SabrMediaPeriod(holder, audioFormat, videoFormat, durationUs, allocator, + DrmSessionManager.DRM_UNSUPPORTED, createDrmEventDispatcher(id), + createEventDispatcher(id), localization); + } + + @Override + public void releasePeriod(final MediaPeriod mediaPeriod) { + ((SabrMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + } + + private static Format toMedia3Format(final YoutubeSabrFormat f) { + final String mime = f.getMimeType(); + String container = mime; + String codecs = null; + final int sc = mime.indexOf(';'); + if (sc > 0) { + container = mime.substring(0, sc).trim(); + } + final int ci = mime.indexOf("codecs="); + if (ci >= 0) { + codecs = mime.substring(ci + "codecs=".length()).replace("\"", "").trim(); + } + final Format.Builder b = new Format.Builder() + .setId(String.valueOf(f.getItag())) + .setContainerMimeType(container) + .setCodecs(codecs) + .setSampleMimeType(codecs != null ? MimeTypes.getMediaMimeType(codecs) : container) + .setAverageBitrate(f.getBitrate()); + if (f.isVideo()) { + b.setWidth(f.getWidth()).setHeight(f.getHeight()); + } + return b.build(); + } +} From c4870b9c6523d5b4fa0fd230b01b320c27e77937 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:20:36 +0200 Subject: [PATCH 14/29] feat(sabr): wire chunk MediaSource into the resolver (tier2 wip) --- .../player/resolver/PlaybackResolver.java | 21 +++++++++---------- .../resolver/VideoPlaybackResolver.java | 5 ++++- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index dfbbd0e72..0b85bf922 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -51,7 +51,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; -import org.schabi.newpipe.player.datasource.SabrDataSource; +import org.schabi.newpipe.player.datasource.SabrMediaSource; import org.schabi.newpipe.player.datasource.SabrSessionStore; import androidx.annotation.NonNull; @@ -464,16 +464,15 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, } catch (final ExtractionException e) { throw new IOException("Could not start SABR session for " + videoId, e); } - final YoutubeSabrFormat format = (stream instanceof AudioStream) - ? holder.audioFormat : holder.videoFormat; - final SabrDataSource.Factory factory = new SabrDataSource.Factory( - holder, format, new Localization("en", "US")); - return new ProgressiveMediaSource.Factory(factory).createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse("sabr://" + videoId + "/" + format.getItag())) - .setCustomCacheKey(cacheKey) - .build()); + // One source carries both tracks; media3 track selection picks audio-only when there's no + // video renderer (background/popup). Seeking is real because it's chunk-based, not a byte + // stream. The audio resolver path skips its own SABR source (see VideoPlaybackResolver). + final MediaItem mediaItem = new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse("sabr://" + videoId)) + .setCustomCacheKey(cacheKey) + .build(); + return new SabrMediaSource(mediaItem, holder, new Localization("en", "US")); } @NonNull diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index ce6fef5d6..222d51ff5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -147,9 +147,12 @@ public MediaSource resolve(@NonNull final StreamInfo info) { // Use the audio stream if there is no video stream, or // merge with audio stream in case if video does not contain audio + // SABR carries audio + video in one MediaSource, so don't add a separate audio source. + final boolean videoIsSabr = video != null && video.getDeliveryMethod() + == org.schabi.newpipe.extractor.stream.DeliveryMethod.SABR; final boolean videoHasMatchingAudio = video != null && !video.isVideoOnly() && audioTrack != null && audioTrack.equals(video.getAudioTrackId()); - if (audio != null && !videoHasMatchingAudio + if (audio != null && !videoHasMatchingAudio && !videoIsSabr && (video == null || video.isVideoOnly() || audioTrack != null)) { try { final MediaSource audioSource = PlaybackResolver.buildMediaSource( From 8ae69db116ca191d8f09828065bc570e96fb366f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:38:49 +0200 Subject: [PATCH 15/29] feat(sabr): self-contained webm/mp4 chunks, playback works (tier2) --- .../player/datasource/SabrChunkSource.java | 42 ++++++++--------- .../player/datasource/SabrMediaPeriod.java | 2 + .../datasource/SabrSegmentDataSource.java | 46 ++++++++++++++++++- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java index 0c52d801d..b77eae59c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java @@ -18,7 +18,10 @@ import androidx.media3.exoplayer.source.chunk.InitializationChunk; import androidx.media3.exoplayer.source.chunk.MediaChunk; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.mkv.MatroskaExtractor; import androidx.media3.extractor.mp4.FragmentedMp4Extractor; +import androidx.media3.extractor.text.SubtitleParser; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; @@ -40,9 +43,7 @@ final class SabrChunkSource implements ChunkSource { private final Format trackFormat; private final int trackType; private final Localization localization; - private final ChunkExtractor chunkExtractor; - private boolean initLoaded; @Nullable private IOException fatalError; @@ -56,8 +57,6 @@ final class SabrChunkSource implements ChunkSource { this.trackFormat = trackFormat; this.trackType = trackType; this.localization = localization; - this.chunkExtractor = new BundledChunkExtractor( - new FragmentedMp4Extractor(), trackType, trackFormat); } @Override @@ -91,10 +90,6 @@ public boolean shouldCancelLoad(final long playbackPositionUs, final Chunk loadi @Override public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionUs, final List queue, final ChunkHolder out) { - if (!initLoaded) { - out.chunk = newInitChunk(); - return; - } final int nextSeq; if (queue.isEmpty()) { nextSeq = holder.session.getStreamState() @@ -103,6 +98,9 @@ public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionU nextSeq = (int) (queue.get(queue.size() - 1).getNextChunkIndex()); } final long endSeq = holder.session.getStreamState().getEndSegment(format); + android.util.Log.i("SabrChunk", "itag=" + format.getItag() + " nextSeq=" + nextSeq + + " loadPosMs=" + (loadPositionUs / 1000) + " queue=" + queue.size() + + " endSeq=" + endSeq); if (endSeq > 0 && nextSeq > endSeq) { out.endOfStream = true; return; @@ -110,32 +108,31 @@ public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionU out.chunk = newMediaChunk(nextSeq); } - private Chunk newInitChunk() { - final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/init")); - return new InitializationChunk( - new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, - C.SELECTION_REASON_UNKNOWN, null, chunkExtractor); - } - private Chunk newMediaChunk(final int seq) { final long startMs = holder.session.getStreamState().getSegmentStartMs(format, seq); final long endMs = holder.session.getStreamState().getSegmentEndMs(format, seq); final long startUs = Math.max(0, startMs) * 1000; final long endUs = (endMs > 0 ? endMs : startMs) * 1000; final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/" + seq)); + // Fresh extractor per chunk: the data source prepends the init, so each chunk is a complete + // init + one fragment. Absolute fragment timestamps -> sampleOffsetUs = 0. Pick the container + // by mime: YouTube ships VP9/Opus in WebM (Matroska) and AVC/AAC in fragmented mp4. + final String mime = format.getMimeType(); + final Extractor extractorImpl = mime != null && mime.contains("webm") + ? new MatroskaExtractor(SubtitleParser.Factory.UNSUPPORTED) + : new FragmentedMp4Extractor(SubtitleParser.Factory.UNSUPPORTED); + final ChunkExtractor extractor = new BundledChunkExtractor( + extractorImpl, trackType, trackFormat); return new ContainerMediaChunk( - new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, - C.SELECTION_REASON_UNKNOWN, null, + new SabrSegmentDataSource(holder, format, localization, /* prependInit= */ true), + spec, trackFormat, C.SELECTION_REASON_UNKNOWN, null, startUs, endUs, /* clippedStartTimeUs= */ startUs, /* clippedEndTimeUs= */ endUs, - /* chunkIndex= */ seq, /* chunkCount= */ 1, /* sampleOffsetUs= */ startUs, - chunkExtractor); + /* chunkIndex= */ seq, /* chunkCount= */ 1, /* sampleOffsetUs= */ 0L, + extractor); } @Override public void onChunkLoadCompleted(final Chunk chunk) { - if (chunk instanceof InitializationChunk) { - initLoaded = true; - } } @Override @@ -148,6 +145,5 @@ public boolean onChunkLoadError(final Chunk chunk, final boolean cancelable, @Override public void release() { - chunkExtractor.release(); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index 12c16094c..077deda8c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -196,6 +196,8 @@ public long readDiscontinuity() { @Override public long seekToUs(final long positionUs) { + android.util.Log.i("SabrSeek", "seekToUs=" + (positionUs / 1000) + "ms streams=" + + streams.size()); for (final ChunkSampleStream s : streams) { s.seekToUs(positionUs); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 656515589..b8d1cee93 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.player.datasource; import android.net.Uri; +import android.util.Log; import androidx.annotation.Nullable; @@ -34,6 +35,10 @@ public final class SabrSegmentDataSource implements DataSource { private final SabrSessionStore.Holder holder; private final YoutubeSabrFormat format; private final Localization localization; + // Prepend the init segment so each media chunk is a self-contained fmp4 (init + one fragment), + // which a fresh FragmentedMp4Extractor parses fully. SABR's init isn't a clean standalone atom + // boundary, so feeding it on its own (DASH-style InitializationChunk) hit an EOF mid-atom. + private final boolean prependInit; @Nullable private Uri uri; @@ -45,10 +50,12 @@ public final class SabrSegmentDataSource implements DataSource { public SabrSegmentDataSource(final SabrSessionStore.Holder holder, final YoutubeSabrFormat format, - final Localization localization) { + final Localization localization, + final boolean prependInit) { this.holder = holder; this.format = format; this.localization = localization; + this.prependInit = prependInit; } @Override @@ -62,7 +69,16 @@ public long open(final DataSpec dataSpec) throws IOException { this.canceled = false; this.pos = (int) Math.max(0, dataSpec.position); final SabrSegmentRequest request = requestFromUri(dataSpec.uri); - this.data = awaitSegment(request); + if (prependInit && !request.isInitializationSegment()) { + final byte[] init = awaitSegment(SabrSegmentRequest.initialization(format)); + final byte[] media = awaitSegment(request); + final byte[] both = new byte[init.length + media.length]; + System.arraycopy(init, 0, both, 0, init.length); + System.arraycopy(media, 0, both, init.length, media.length); + this.data = both; + } else { + this.data = awaitSegment(request); + } this.opened = true; final int remaining = data.length - pos; return dataSpec.length == C.LENGTH_UNSET ? remaining : Math.min(dataSpec.length, remaining); @@ -101,7 +117,14 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); + int waited = 0; while (true) { + if (waited > 0 && waited % 8 == 0) { + Log.i("SabrSeg", "WAIT itag=" + format.getItag() + " seq=" + + (request.isInitializationSegment() ? "init" : request.getSequenceNumber()) + + " sinceSeg=" + pump.millisSinceLastSegment()); + } + waited++; if (canceled) { throw new IOException("SABR segment read canceled"); } @@ -125,6 +148,25 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException } } + private static String box(final byte[] b, final int off) { + if (b == null || off < 0 || off + 8 > b.length) { + return "EOF@" + off; + } + final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) + | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); + final String type = new String(b, off + 4, 4, java.nio.charset.StandardCharsets.US_ASCII); + return size + ":" + type; + } + + private static int nextBox(final byte[] b, final int off) { + if (b == null || off + 8 > b.length) { + return b == null ? 0 : b.length; + } + final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) + | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); + return size <= 0 ? b.length : off + (int) size; + } + @Nullable @Override public Uri getUri() { From fb6007a0fce0694913af3f3d864041c295eb4506 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:48:09 +0200 Subject: [PATCH 16/29] fix(sabr): track reader position so playback and seek keep feeding (tier2) --- .../newpipe/player/datasource/SabrSegmentDataSource.java | 7 +++++++ .../schabi/newpipe/player/datasource/SabrStreamPump.java | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index b8d1cee93..72eac58ff 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -131,6 +131,13 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException pump.ensureStarted(); final SabrMediaSegment segment = pump.getCached(request); if (segment != null) { + if (!segment.getHeader().isInitSegment()) { + // Tell the pump how far this track has been loaded so it keeps feeding ahead + // (and repositions after a seek). Without this readerHead stayed 0 and the pump + // throttled forever after the initial fill. + holder.setReaderPositionMs(format.getItag(), + segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); + } return segment.getData(); } if (pump.isFatal()) { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 58af65467..c8d68365c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -119,8 +119,12 @@ private void loop() { session.setPlayHeadMs(holder.getReaderTailMs()); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); - if (edgeMs - readerHeadMs > READAHEAD_CUSHION_MS - || session.getCachedBytes() > MAX_AHEAD_BYTES) { + final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS + || session.getCachedBytes() > MAX_AHEAD_BYTES; + Log.i(TAG, "head=" + readerHeadMs + " tail=" + holder.getReaderTailMs() + + " edge=" + edgeMs + " cacheKB=" + (session.getCachedBytes() / 1024) + + " throttled=" + throttled); + if (throttled) { Thread.sleep(IDLE_POLL_MS); continue; } @@ -130,6 +134,7 @@ private void loop() { // report on edge. session.getStreamState().setPlayerTimeMs(edgeMs); final List segments = session.pumpOnce(localization); + Log.i(TAG, "pumpOnce reported=" + edgeMs + " -> segs=" + segments.size()); if (segments.isEmpty()) { Thread.sleep(IDLE_POLL_MS); } else { From ffe612366cb6a4dc6b105f1e136d83db79c14e77 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 18:07:53 +0200 Subject: [PATCH 17/29] chore(sabr): drop tier2 debug logging --- .../player/datasource/SabrChunkSource.java | 4 --- .../player/datasource/SabrMediaPeriod.java | 2 -- .../datasource/SabrSegmentDataSource.java | 27 ------------------- .../player/datasource/SabrStreamPump.java | 4 --- 4 files changed, 37 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java index b77eae59c..151fcbc7d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java @@ -15,7 +15,6 @@ import androidx.media3.exoplayer.source.chunk.ChunkHolder; import androidx.media3.exoplayer.source.chunk.ChunkSource; import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk; -import androidx.media3.exoplayer.source.chunk.InitializationChunk; import androidx.media3.exoplayer.source.chunk.MediaChunk; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.extractor.Extractor; @@ -98,9 +97,6 @@ public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionU nextSeq = (int) (queue.get(queue.size() - 1).getNextChunkIndex()); } final long endSeq = holder.session.getStreamState().getEndSegment(format); - android.util.Log.i("SabrChunk", "itag=" + format.getItag() + " nextSeq=" + nextSeq - + " loadPosMs=" + (loadPositionUs / 1000) + " queue=" + queue.size() - + " endSeq=" + endSeq); if (endSeq > 0 && nextSeq > endSeq) { out.endOfStream = true; return; diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index 077deda8c..12c16094c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -196,8 +196,6 @@ public long readDiscontinuity() { @Override public long seekToUs(final long positionUs) { - android.util.Log.i("SabrSeek", "seekToUs=" + (positionUs / 1000) + "ms streams=" - + streams.size()); for (final ChunkSampleStream s : streams) { s.seekToUs(positionUs); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 72eac58ff..f624ec702 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.player.datasource; import android.net.Uri; -import android.util.Log; import androidx.annotation.Nullable; @@ -117,14 +116,7 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); - int waited = 0; while (true) { - if (waited > 0 && waited % 8 == 0) { - Log.i("SabrSeg", "WAIT itag=" + format.getItag() + " seq=" - + (request.isInitializationSegment() ? "init" : request.getSequenceNumber()) - + " sinceSeg=" + pump.millisSinceLastSegment()); - } - waited++; if (canceled) { throw new IOException("SABR segment read canceled"); } @@ -155,25 +147,6 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException } } - private static String box(final byte[] b, final int off) { - if (b == null || off < 0 || off + 8 > b.length) { - return "EOF@" + off; - } - final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) - | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); - final String type = new String(b, off + 4, 4, java.nio.charset.StandardCharsets.US_ASCII); - return size + ":" + type; - } - - private static int nextBox(final byte[] b, final int off) { - if (b == null || off + 8 > b.length) { - return b == null ? 0 : b.length; - } - final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) - | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); - return size <= 0 ? b.length : off + (int) size; - } - @Nullable @Override public Uri getUri() { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index c8d68365c..27a6882f8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -121,9 +121,6 @@ private void loop() { final long edgeMs = session.getStreamState().getMinBufferedEndMs(); final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS || session.getCachedBytes() > MAX_AHEAD_BYTES; - Log.i(TAG, "head=" + readerHeadMs + " tail=" + holder.getReaderTailMs() - + " edge=" + edgeMs + " cacheKB=" + (session.getCachedBytes() / 1024) - + " throttled=" + throttled); if (throttled) { Thread.sleep(IDLE_POLL_MS); continue; @@ -134,7 +131,6 @@ private void loop() { // report on edge. session.getStreamState().setPlayerTimeMs(edgeMs); final List segments = session.pumpOnce(localization); - Log.i(TAG, "pumpOnce reported=" + edgeMs + " -> segs=" + segments.size()); if (segments.isEmpty()) { Thread.sleep(IDLE_POLL_MS); } else { From a7e9c779ebdbf38ea5f6789353ccb8836964d69a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:09:35 +0200 Subject: [PATCH 18/29] feat(sabr): respect the user-selected video quality and force AAC audio --- .../player/datasource/SabrSessionStore.java | 62 ++++++++++++++++--- .../player/resolver/PlaybackResolver.java | 6 +- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 1bda11239..0d32bc9c0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -148,7 +148,8 @@ public static void updatePlayerTime(@NonNull final String videoId, final long pl @NonNull public static Holder getOrCreate(@NonNull final Context context, - @NonNull final String videoId) + @NonNull final String videoId, + final int preferredVideoItag) throws IOException, ExtractionException { final Holder existing = SESSIONS.get(videoId); if (existing != null) { @@ -162,8 +163,8 @@ public static Holder getOrCreate(@NonNull final Context context, final Localization localization = new Localization("en", "US"); final ContentCountry contentCountry = new ContentCountry("US"); final YoutubeSabrInfo info = YoutubeSabrProbeFetch(videoId, localization, contentCountry); - final YoutubeSabrFormat audioFormat = info.findBestAudioFormat(); - final YoutubeSabrFormat videoFormat = pickHardwareFriendlyVideo(info); + final YoutubeSabrFormat audioFormat = pickAudioFormat(info); + final YoutubeSabrFormat videoFormat = pickVideoFormat(info, preferredVideoItag); if (audioFormat == null || videoFormat == null) { throw new IOException("SABR: could not select audio/video formats for " + videoId); } @@ -205,6 +206,54 @@ private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String video videoId, YoutubeSabrClientProfile.WEB, localization, contentCountry); } + // Force AAC (mp4) audio instead of the "best" (Opus/webm). honestly: Opus/webm audio just does + // NOT work through this chunk pipeline. it under-supplies the audio renderer -> AudioTrack + // underruns -> the play head freezes after ~2min, hundreds of rebuffers, phone cooks. i spent + // ~2h on this: ruled out fetch, cache, chunk timing, the media3 loading contract, buffer size... + // the data IS cached fine, so it's somewhere inside media3's Opus/webm extract->render with the + // way we chunk it, and i still have no fucking idea how to fix it. AAC (itag 140) is mp4, + // hardware-decoded, ~same bitrate (130 vs 136 kbps) and plays perfectly smooth. so: AAC until + // someone cracks the Opus path. (audio codec isn't a user-facing choice, so this isn't a + // band-aid on a user setting, just an internal pick.) + private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo info) { + YoutubeSabrFormat aac = null; + for (final YoutubeSabrFormat f : info.getFormats()) { + if (!f.isAudio()) { + continue; + } + final String mime = f.getMimeType(); + if (mime != null && mime.contains("mp4") && (aac == null + || f.getBitrate() > aac.getBitrate())) { + aac = f; + } + } + return aac != null ? aac : info.findBestAudioFormat(); + } + + /** Honour the user-selected quality when that format is present and hardware-decodable; + * otherwise fall back to the best hardware-friendly one. */ + private static YoutubeSabrFormat pickVideoFormat(@NonNull final YoutubeSabrInfo info, + final int preferredItag) { + if (preferredItag > 0) { + final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); + final boolean hwAv1 = hasHardwareDecoder("video/av01"); + for (final YoutubeSabrFormat f : info.getFormats()) { + if (f.isVideo() && f.getItag() == preferredItag && isDecodable(f, hwVp9, hwAv1)) { + return f; + } + } + } + return pickHardwareFriendlyVideo(info); + } + + private static boolean isDecodable(@NonNull final YoutubeSabrFormat f, + final boolean hwVp9, final boolean hwAv1) { + final String codec = codecFamily(f.getMimeType()); + return "avc".equals(codec) + || ("vp9".equals(codec) && hwVp9) + || ("av1".equals(codec) && hwAv1); + } + /** * Pick the highest-resolution video format the device can decode in HARDWARE. The decoder is * chosen by ExoPlayer from the container bytes, so a codec the device only decodes in software @@ -212,7 +261,6 @@ private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String video * allow AVC always (universally HW), VP9 only when a HW VP9 decoder exists, AV1 only when a HW * AV1 decoder exists; otherwise fall back to the overall best (better some playback than none). */ - @NonNull private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info) { final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); final boolean hwAv1 = hasHardwareDecoder("video/av01"); @@ -221,11 +269,7 @@ private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final Youtub if (!f.isVideo()) { continue; } - final String codec = codecFamily(f.getMimeType()); - final boolean decodable = "avc".equals(codec) - || ("vp9".equals(codec) && hwVp9) - || ("av1".equals(codec) && hwAv1); - if (!decodable) { + if (!isDecodable(f, hwVp9, hwAv1)) { continue; } if (best == null || f.getHeight() > best.getHeight() diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 0b85bf922..fbd6334f0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -458,9 +458,13 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, @NonNull final MediaItemTag metadata) throws IOException { final String videoId = streamInfo.getId(); + // Honour the user-selected video quality instead of forcing the highest (4K is heavy and + // hits the device VP9 decoder wall); audio-only playback passes 0 and keeps the best audio. + final int preferredVideoItag = + (stream instanceof VideoStream) ? ((VideoStream) stream).getItag() : 0; final SabrSessionStore.Holder holder; try { - holder = SabrSessionStore.getOrCreate(App.getApp(), videoId); + holder = SabrSessionStore.getOrCreate(App.getApp(), videoId, preferredVideoItag); } catch (final ExtractionException e) { throw new IOException("Could not start SABR session for " + videoId, e); } From 8b94111bad509915f76f07eb871c86579cb6df87 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:09:35 +0200 Subject: [PATCH 19/29] perf(sabr): persist the PO token on disk and harden the mint timeout/retry --- .../datasource/WebViewPoTokenProvider.java | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java index 70a62df7c..6f9358dc2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java @@ -51,7 +51,12 @@ public final class WebViewPoTokenProvider implements SabrPoTokenProvider { private static final String DESKTOP_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"; private static final long TOKEN_TTL_MS = 6L * 60L * 60L * 1000L; // 6 hours - private static final long PIPELINE_TIMEOUT_MS = 45_000L; + // The WebView BotGuard mint can occasionally run long; a single 45s shot timing out returned a + // null token -> token-less SABR -> cold-start failure. 60s + one retry (in getPoToken) is robust. + private static final long PIPELINE_TIMEOUT_MS = 60_000L; + // Persist minted tokens across process restarts so an app cold-start doesn't pay the ~45s mint + // again while the videoId-bound token is still valid (<6h). + private static final String PREFS = "sabr_potoken_cache"; private static final int READY_RETRIES = 20; private static final long READY_POLL_MS = 250L; @@ -67,6 +72,7 @@ private static final class CachedToken { private final Context appContext; private final Handler mainHandler; + private final android.content.SharedPreferences prefs; private final Map cache = new ConcurrentHashMap<>(); // one lock per videoId so two callers (pre-warm + pump) don't both fire the ~45s WebView mint // for the same video. second one just waits and takes the cached token. @@ -75,6 +81,7 @@ private static final class CachedToken { public WebViewPoTokenProvider(final Context context) { this.appContext = context.getApplicationContext(); this.mainHandler = new Handler(Looper.getMainLooper()); + this.prefs = this.appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE); } @Nullable @@ -89,16 +96,28 @@ public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamStat final boolean forceRefresh) { final String videoId = info.getVideoId(); if (forceRefresh) { - // Server rejected the cached token (expired): drop it and mint a fresh one. + // Server rejected the cached token (expired): drop it (memory + disk) and mint fresh. cache.remove(videoId); + prefs.edit().remove(videoId).apply(); } synchronized (mintLocks.computeIfAbsent(videoId, k -> new Object())) { final long now = System.currentTimeMillis(); - final CachedToken cached = cache.get(videoId); + CachedToken cached = cache.get(videoId); + if (cached == null) { + cached = diskLoad(videoId); // survive process restart, skip the ~45s mint + if (cached != null) { + cache.put(videoId, cached); + } + } if (cached != null && now - cached.mintedAtMs < TOKEN_TTL_MS) { return cached.token; } - final String tokenB64 = mintBlocking(videoId); + // One retry: the BotGuard mint occasionally times out, and a single null killed playback. + String tokenB64 = mintBlocking(videoId); + if (tokenB64 == null || tokenB64.isEmpty()) { + Log.w(TAG, "PO token mint returned null, retrying once for " + videoId); + tokenB64 = mintBlocking(videoId); + } if (tokenB64 == null || tokenB64.isEmpty()) { return null; } @@ -110,10 +129,39 @@ public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamStat return null; } cache.put(videoId, new CachedToken(token, now)); + diskSave(videoId, tokenB64, now); return token; } } + @Nullable + private CachedToken diskLoad(final String videoId) { + final String v = prefs.getString(videoId, null); + if (v == null) { + return null; + } + final int sep = v.indexOf('|'); + if (sep <= 0) { + return null; + } + try { + final long mintedAt = Long.parseLong(v.substring(0, sep)); + if (System.currentTimeMillis() - mintedAt >= TOKEN_TTL_MS) { + prefs.edit().remove(videoId).apply(); + return null; + } + return new CachedToken(Base64.getUrlDecoder().decode(v.substring(sep + 1)), mintedAt); + } catch (final IllegalArgumentException e) { + return null; + } + } + + private void diskSave(final String videoId, final String tokenB64, final long mintedAt) { + // commit() (sync) not apply(): the token must hit disk before a fast force-stop/process kill, + // else an app cold-start re-mints (~45s) even though a valid token was just minted. + prefs.edit().putString(videoId, mintedAt + "|" + tokenB64).commit(); + } + @Nullable private String mintBlocking(final String videoId) { final CountDownLatch latch = new CountDownLatch(1); From 5a7df9464e4fad8caea05c4f16c512882e2f35b6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:09:35 +0200 Subject: [PATCH 20/29] fix(sabr): ignore ended tracks when reporting the buffered position --- .../newpipe/player/datasource/SabrMediaPeriod.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index 12c16094c..aac403d26 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -135,11 +135,17 @@ private void rebuildCompositeLoader() { compositeLoader = new SequenceableLoader() { @Override public long getBufferedPositionUs() { + // Skip tracks already buffered to the end (END_OF_SOURCE = Long.MIN_VALUE), else a + // finished shorter track (audio) would collapse the min and make media3 think the + // whole period is buffered to the end, starving the still-loading video near the end. long min = Long.MAX_VALUE; for (final ChunkSampleStream s : streams) { - min = Math.min(min, s.getBufferedPositionUs()); + final long b = s.getBufferedPositionUs(); + if (b != C.TIME_END_OF_SOURCE) { + min = Math.min(min, b); + } } - return streams.isEmpty() ? C.TIME_END_OF_SOURCE : min; + return min == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : min; } @Override From 06926b28aaaebca2ebf07fec29d20547cd3af614 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:58:29 +0200 Subject: [PATCH 21/29] fix(sabr): time segment stalls per-request so a throttled pump can't false-stall --- .../newpipe/player/datasource/SabrSegmentDataSource.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index f624ec702..b33bc2798 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -116,6 +116,7 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); + final long waitStart = System.currentTimeMillis(); while (true) { if (canceled) { throw new IOException("SABR segment read canceled"); @@ -135,7 +136,12 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException if (pump.isFatal()) { throw new IOException("SABR pump fatal for itag=" + format.getItag()); } - if (pump.millisSinceLastSegment() > STALL_MS) { + // Stall = THIS segment hasn't arrived within STALL_MS of us actually waiting for it. Do + // NOT use the pump's "time since it last produced a segment": the pump legitimately stops + // producing while throttled (buffer full, edge far ahead), so that clock goes stale and + // the first cache miss after a long throttle false-stalls at ~STALL_MS. That was the + // recurring ~2min freeze on longer/higher-bitrate streams. + if (System.currentTimeMillis() - waitStart > STALL_MS) { throw new IOException("SABR segment stalled for itag=" + format.getItag()); } try { From 9d9f5c95c151c343fd6fa2fa666e848a1f7fc828 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 10:03:04 +0200 Subject: [PATCH 22/29] chore(sabr): remove the dead v1 byte-stream data source --- .../player/datasource/SabrDataSource.java | 215 ------------------ .../player/datasource/SabrSessionStore.java | 2 +- .../player/datasource/SabrStreamPump.java | 11 +- 3 files changed, 2 insertions(+), 226 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java deleted file mode 100644 index 0afe457da..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java +++ /dev/null @@ -1,215 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.Nullable; - -import androidx.media3.common.C; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DataSpec; -import androidx.media3.datasource.TransferListener; - -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; - -import java.io.IOException; - -/** - * ExoPlayer {@link DataSource} exposing one SABR format (audio or video) as a continuous byte - * stream: init segment then media segments. It only reads the session's concurrent cache, which a - * single {@link SabrStreamPump} fills ahead of the play head; so the two data sources never touch - * the network nor block each other. We end the stream past the last segment, on a pump fatal error, - * or if the play head stays frozen long enough to call it a genuine stall. - * - *

v1: sequential read from the start, seeks skip forward, length unknown until end-of-stream.

- */ -public final class SabrDataSource implements DataSource { - - private static final String TAG = "SabrDataSource"; - - private static final long WAIT_MS = 250; - // only bail once the pump's been dry a while (bailing makes ExoPlayer re-open us, which unsticks - // the flow). be patient at cold start: the ~45s WebView mint = zero segments for a bit, and most - // underruns just sort themselves out once the pump catches up. - // do NOT EOF early on a stall: ExoPlayer re-opens at a byte offset and our v1 byte-skip seek - // fucks the fragmented container = frozen video. so we just ride the stall out. - private static final long STALL_MS = 120_000; - - private final SabrSessionStore.Holder holder; - private final YoutubeSabrFormat format; - private final Localization localization; - - @Nullable - private Uri uri; - @Nullable - private byte[] current; - private int currentPos; - private boolean initServed; - private int nextSeq; - private boolean ended; - private long skipRemaining; - private volatile boolean canceled; - - public SabrDataSource(final SabrSessionStore.Holder holder, - final YoutubeSabrFormat format, - final Localization localization) { - this.holder = holder; - this.format = format; - this.localization = localization; - } - - @Override - public void addTransferListener(final TransferListener transferListener) { - // Bandwidth metering not wired for the SABR v1 source. - } - - @Override - public long open(final DataSpec dataSpec) { - this.uri = dataSpec.uri; - this.current = null; - this.currentPos = 0; - this.initServed = false; - this.nextSeq = 1; // SABR media sequence numbers are 1-based (0 is rejected) - this.ended = false; - this.skipRemaining = Math.max(0, dataSpec.position); - this.canceled = false; - return C.LENGTH_UNSET; - } - - @Override - public int read(final byte[] target, final int offset, final int length) throws IOException { - if (length == 0) { - return 0; - } - if (ended) { - return C.RESULT_END_OF_INPUT; - } - // Drop bytes for a forward seek (v1 skips from the start). - while (skipRemaining > 0) { - if (!ensureBuffer()) { - return C.RESULT_END_OF_INPUT; - } - final int available = current.length - currentPos; - final int drop = (int) Math.min(available, skipRemaining); - currentPos += drop; - skipRemaining -= drop; - } - if (!ensureBuffer()) { - return C.RESULT_END_OF_INPUT; - } - final int available = current.length - currentPos; - final int toCopy = Math.min(length, available); - System.arraycopy(current, currentPos, target, offset, toCopy); - currentPos += toCopy; - return toCopy; - } - - /** - * Make sure {@link #current} has unread bytes, waiting for the pump to cache the next segment. - * - * @return false if the stream is exhausted - */ - private boolean ensureBuffer() throws IOException { - if (current != null && currentPos < current.length) { - return true; - } - final SabrStreamPump pump = holder.getPump(localization); - while (true) { - if (canceled) { - ended = true; - return false; - } - final SabrSegmentRequest request = initServed - ? SabrSegmentRequest.media(format, nextSeq) - : SabrSegmentRequest.initialization(format); - pump.ensureStarted(); - final SabrMediaSegment segment = pump.getCached(request); - if (segment != null) { - if (initServed) { - nextSeq++; - } else { - initServed = true; - } - if (!segment.getHeader().isInitSegment()) { - // tell the pump/eviction how far this track has actually read (never stale). - holder.setReaderPositionMs(format.getItag(), - segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); - } - current = segment.getData(); - currentPos = 0; - if (current.length == 0) { - continue; - } - return true; - } - if (holder.isBeyondEnd(request)) { - ended = true; - return false; - } - if (pump.isFatal()) { - // Surface a real error (not a clean EOF) so ExoPlayer reports a playback error - // instead of pretending the video ended. The session was evicted on fatal, so a - // retry rebuilds a fresh one. - throw new IOException("SABR pump fatal for itag=" + format.getItag() - + " at seq=" + nextSeq); - } - // not cached yet: pump's fetching or the server's pacing us. wait, don't signal EOF - // (that triggers a corrupting re-open). only bail if the pump's been fully dry long - // enough to be a real dead stall. - if (pump.millisSinceLastSegment() > STALL_MS) { - Log.i(TAG, "end of SABR stream (stalled) itag=" + format.getItag() - + " at seq=" + nextSeq); - ended = true; - return false; - } - try { - Thread.sleep(WAIT_MS); - } catch (final InterruptedException ie) { - Thread.currentThread().interrupt(); - if (canceled) { - ended = true; - return false; // clean cancellation (close/seek/release), not a playback error - } - throw new IOException("Interrupted during SABR wait", ie); - } - } - } - - @Nullable - @Override - public Uri getUri() { - return uri; - } - - @Override - public void close() { - // Unblock a read() that is waiting for the pump (it polls this flag), so ExoPlayer can - // release this loader thread promptly on stop/seek/track-change. - canceled = true; - current = null; - currentPos = 0; - } - - /** Factory binding a {@link SabrDataSource} to one shared session holder + format. */ - public static final class Factory implements DataSource.Factory { - private final SabrSessionStore.Holder holder; - private final YoutubeSabrFormat format; - private final Localization localization; - - public Factory(final SabrSessionStore.Holder holder, - final YoutubeSabrFormat format, - final Localization localization) { - this.holder = holder; - this.format = format; - this.localization = localization; - } - - @Override - public DataSource createDataSource() { - return new SabrDataSource(holder, format, localization); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 0d32bc9c0..5b5e6bdb1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -22,7 +22,7 @@ /** * Caches one shared {@link YoutubeSabrSession} per videoId so the audio and video - * {@link SabrDataSource}s drive the same session (a single SABR response carries both formats, so + * {@link SabrSegmentDataSource}s drive the same session (a single SABR response carries both formats, so * the session's segment cache serves both without doubling bandwidth). * *

v1: uses the best audio/video formats from the player response and a fixed en/US locale.

diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 27a6882f8..7b1c1c154 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -18,7 +18,7 @@ * Single consumer of a {@link YoutubeSabrSession}: one daemon thread pumps the server-driven SABR * stream and fills the session's (concurrent) segment cache ahead of the play head. The server * paces us with policy-only responses once we are far enough ahead. Both the audio and video - * {@link SabrDataSource}s only read the cache, so they never fight over the session or block each + * {@link SabrSegmentDataSource}s only read the cache, so they never fight over the session or block each * other on a network round-trip, which is exactly what starved a track in the old on-demand approach. */ final class SabrStreamPump { @@ -45,7 +45,6 @@ final class SabrStreamPump { private volatile boolean stopped; private volatile boolean fatal; private volatile long lastReadMs; - private volatile long lastSegmentMs; private Thread thread; SabrStreamPump(@NonNull final YoutubeSabrSession session, @@ -68,7 +67,6 @@ void ensureStarted() { } stopped = false; started = true; - lastSegmentMs = System.currentTimeMillis(); thread = new Thread(this::loop, "SabrStreamPump"); thread.setDaemon(true); thread.start(); @@ -87,11 +85,6 @@ void stop() { } } - /** ms since the pump last grabbed a segment. basically "is this thing dead or what". */ - long millisSinceLastSegment() { - return System.currentTimeMillis() - lastSegmentMs; - } - @Nullable SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { // revive the pump if it idled out: any read means playback is live again. @@ -133,8 +126,6 @@ private void loop() { final List segments = session.pumpOnce(localization); if (segments.isEmpty()) { Thread.sleep(IDLE_POLL_MS); - } else { - lastSegmentMs = System.currentTimeMillis(); } } catch (final InterruptedException e) { Thread.currentThread().interrupt(); From 8e637650c4e3d48667d03cd3a5388e7ac899556d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 10:50:12 +0200 Subject: [PATCH 23/29] fix(sabr): adapt LoadControl and ChunkSampleStream to the media3 1.10 APIs --- .../newpipe/player/datasource/SabrMediaPeriod.java | 5 ++++- .../schabi/newpipe/player/helper/LoadController.java | 12 +++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index aac403d26..885ca558e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -125,9 +125,12 @@ private ChunkSampleStream buildStream(final ExoTrackSelection s final Format trackFormat = group.getFormat(0); final SabrChunkSource chunkSource = new SabrChunkSource(holder, sabrFormats[groupIndex], trackFormat, trackTypes[groupIndex], localization); + // Last 3 args are new in media3 1.10 (handleInitialDiscontinuity, firstChunkStartTimeUs, + // downloadExecutor); false / TIME_UNSET / null reproduces the pre-1.10 behaviour. return new ChunkSampleStream<>(trackTypes[groupIndex], null, null, chunkSource, this, allocator, positionUs, drmSessionManager, drmEventDispatcher, - loadErrorHandlingPolicy, mediaSourceEventDispatcher); + loadErrorHandlingPolicy, mediaSourceEventDispatcher, + false, C.TIME_UNSET, null); } private void rebuildCompositeLoader() { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index 5ee0dacb2..fa9abca98 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -21,16 +21,18 @@ public class LoadController extends DefaultLoadControl { private boolean preloadingEnabled = true; public LoadController() { + // media3 1.10 split every buffer param into a normal + a "ForLocalPlayback" variant; we use + // the same value for both so behaviour is unchanged whether the source is local or remote. super(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), - MIN_BUFFER_MS, - MAX_BUFFER_MS, - BUFFER_FOR_PLAYBACK_MS, - BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + MIN_BUFFER_MS, MIN_BUFFER_MS, + MAX_BUFFER_MS, MAX_BUFFER_MS, + BUFFER_FOR_PLAYBACK_MS, BUFFER_FOR_PLAYBACK_MS, + BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, C.LENGTH_UNSET, // no byte cap: the SABR cache bounds memory, time bounds the player // MUST be true: with false, media3 prioritises its (huge, ~128MB) default byte target // and ignores maxBufferMs, reading ~50s ahead = right at the pump cushion, so it // starved at the edge. true makes maxBufferMs (time) the real limit. - true, + true, true, DEFAULT_BACK_BUFFER_DURATION_MS, DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); } From 7672d42ed3049036e90c366106398b4f41ee8574 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 11:47:26 +0200 Subject: [PATCH 24/29] docs(sabr): clarify the AAC-over-Opus comment, separate the pump false-stall --- .../player/datasource/SabrSessionStore.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 5b5e6bdb1..745df7c9b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -208,13 +208,14 @@ private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String video // Force AAC (mp4) audio instead of the "best" (Opus/webm). honestly: Opus/webm audio just does // NOT work through this chunk pipeline. it under-supplies the audio renderer -> AudioTrack - // underruns -> the play head freezes after ~2min, hundreds of rebuffers, phone cooks. i spent - // ~2h on this: ruled out fetch, cache, chunk timing, the media3 loading contract, buffer size... - // the data IS cached fine, so it's somewhere inside media3's Opus/webm extract->render with the - // way we chunk it, and i still have no fucking idea how to fix it. AAC (itag 140) is mp4, - // hardware-decoded, ~same bitrate (130 vs 136 kbps) and plays perfectly smooth. so: AAC until - // someone cracks the Opus path. (audio codec isn't a user-facing choice, so this isn't a - // band-aid on a user setting, just an internal pick.) + // underruns -> constant rebuffering (hundreds vs ~2 on AAC, phone cooks). re-confirmed on media3 + // 1.10 AFTER fixing the separate ~2min pump false-stall, so it's its own bug, not that one. i + // spent ~2h on it: ruled out fetch, cache, chunk timing, the media3 loading contract, buffer + // size... the data IS cached fine, so it's somewhere inside media3's Opus/webm extract->render + // with the way we chunk it, and i still have no fucking idea how to fix it. AAC (itag 140) is + // mp4, hardware-decoded, ~same bitrate (130 vs 136 kbps) and plays perfectly smooth. so: AAC + // until someone cracks the Opus path. (audio codec isn't user-facing, so this isn't a band-aid + // on a user setting, just an internal pick.) private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo info) { YoutubeSabrFormat aac = null; for (final YoutubeSabrFormat f : info.getFormats()) { From 17f6c51643091cd05bbb374067b302dc0a18e82e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 13:58:20 +0200 Subject: [PATCH 25/29] fix(sabr): rebuild the session when the user changes video quality/codec --- .../player/datasource/SabrSessionStore.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 745df7c9b..bcfdd57a9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -146,19 +146,40 @@ public static void updatePlayerTime(@NonNull final String videoId, final long pl } } + // <=0 = audio-only / no preference -> any cached session is fine. Otherwise the session matches + // when the requested itag RESOLVES to the same format the session already holds. Comparing the + // raw itag is wrong: pickVideoFormat falls back when the requested itag isn't hw-decodable, so + // the session's format legitimately differs from the requested itag and we'd rebuild on every + // normal resolve (-> evict/rebuild loop -> endless buffering). Only a real quality change, which + // resolves to a different format, triggers a rebuild. + private static boolean sessionMatchesItag(@NonNull final Holder holder, + final int preferredVideoItag) { + if (preferredVideoItag <= 0) { + return true; + } + final YoutubeSabrFormat wanted = pickVideoFormat(holder.info, preferredVideoItag); + return wanted != null && wanted.getItag() == holder.videoFormat.getItag(); + } + @NonNull public static Holder getOrCreate(@NonNull final Context context, @NonNull final String videoId, final int preferredVideoItag) throws IOException, ExtractionException { final Holder existing = SESSIONS.get(videoId); - if (existing != null) { + if (existing != null && sessionMatchesItag(existing, preferredVideoItag)) { return existing; } synchronized (SabrSessionStore.class) { - final Holder racing = SESSIONS.get(videoId); - if (racing != null) { - return racing; + final Holder current = SESSIONS.get(videoId); + if (current != null) { + if (sessionMatchesItag(current, preferredVideoItag)) { + return current; + } + // Quality/codec change: the resolver re-asks with a different video itag for the same + // video. The cached session is locked to its formats, so returning it would re-prepare + // the player on the old codec and dead-buffer. Drop it (stops the pump) + rebuild below. + evict(videoId); } final Localization localization = new Localization("en", "US"); final ContentCountry contentCountry = new ContentCountry("US"); From b8bcb0c0a68656f2f2a64fd80ec6ab90a4970bd8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 13:58:20 +0200 Subject: [PATCH 26/29] fix(sabr): keep a 30s back-buffer so short backward seeks land in cache --- .../newpipe/player/datasource/SabrStreamPump.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 7b1c1c154..e9f7bd22a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -36,6 +36,11 @@ final class SabrStreamPump { // Hard byte ceiling on read-ahead so a high-bitrate (4K) stream can't OOM the heap: 50s of 4K is // ~160MB and crashed. ~100MB still covers the player's ~30s read-ahead, well under the OOM line. private static final long MAX_AHEAD_BYTES = 100L * 1024 * 1024; + // Keep this much already-played video in the cache so a short backward seek lands on cached + // segments instead of a hole (eviction used to drop everything the reader passed, so any rewind + // hit an evicted segment the pump never re-fetches -> dead buffer). Bounded, same order as the + // forward cushion. Rewinds beyond this still need a session re-request (separate follow-up). + private static final long BACK_BUFFER_MS = 30_000; private final YoutubeSabrSession session; private final SabrSessionStore.Holder holder; @@ -107,9 +112,10 @@ private void loop() { // freezes while buffering and that deadlocked the pump. readerHead = furthest // track read; readerTail = slowest track read (safe to evict below). final long readerHeadMs = holder.getReaderHeadMs(); - // evict everything both tracks have read past, EVERY round (even before we throttle - // below) or a full cache never drains and the throttle latches forever -> freeze. - session.setPlayHeadMs(holder.getReaderTailMs()); + // Evict what both tracks have read past, EVERY round (or a full cache never drains + // and the throttle latches forever -> freeze), but keep BACK_BUFFER_MS behind the + // reader so a short backward seek finds its segments cached instead of a hole. + session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - BACK_BUFFER_MS)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS From b8234b72e214cf56dfbd627267aee99ad1879b89 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 15:28:49 +0200 Subject: [PATCH 27/29] fix(sabr): re-fetch evicted segments on a backward seek past the back-buffer --- .../datasource/SabrSegmentDataSource.java | 22 +++++++++++++++++++ .../player/datasource/SabrStreamPump.java | 21 ++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index b33bc2798..168e2b990 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -30,6 +30,9 @@ public final class SabrSegmentDataSource implements DataSource { private static final long WAIT_MS = 250; private static final long STALL_MS = 120_000; + // After waiting this long for a media segment that's BEHIND the buffered edge, treat it as a + // backward seek onto an evicted segment and ask the pump to reposition the session there. + private static final long REFETCH_AFTER_MS = 2_000; private final SabrSessionStore.Holder holder; private final YoutubeSabrFormat format; @@ -117,6 +120,7 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); final long waitStart = System.currentTimeMillis(); + long lastRefetchMs = 0; while (true) { if (canceled) { throw new IOException("SABR segment read canceled"); @@ -136,6 +140,24 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException if (pump.isFatal()) { throw new IOException("SABR pump fatal for itag=" + format.getItag()); } + // Backward seek to an evicted segment behind the buffered edge: the forward pump never + // re-fetches it, so it would never arrive. Drop our read position onto it (so eviction + + // pacing follow the rewind, not the stale pre-seek position) and ask the pump to + // reposition the session there. The edge check leaves a merely-slow forward fetch (the + // segment is still ahead of the edge) to the normal pump, so forward playback is untouched. + if (!request.isInitializationSegment()) { + final long now = System.currentTimeMillis(); + if (now - waitStart > REFETCH_AFTER_MS && now - lastRefetchMs > REFETCH_AFTER_MS) { + final long edgeMs = holder.session.getStreamState().getMinBufferedEndMs(); + final long segStartMs = holder.session.getStreamState() + .getSegmentStartMs(format, request.getSequenceNumber()); + if (segStartMs < edgeMs) { + holder.setReaderPositionMs(format.getItag(), segStartMs); + pump.requestRefetchFrom(request); + lastRefetchMs = now; + } + } + } // Stall = THIS segment hasn't arrived within STALL_MS of us actually waiting for it. Do // NOT use the pump's "time since it last produced a segment": the pump legitimately stops // producing while throttled (buffer full, edge far ahead), so that clock goes stale and diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index e9f7bd22a..f6628e602 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -50,6 +50,9 @@ final class SabrStreamPump { private volatile boolean stopped; private volatile boolean fatal; private volatile long lastReadMs; + // Set by a reader blocked on an evicted segment behind the edge (backward seek); the loop + // repositions the session onto it next round. Single-slot: the latest rewind target wins. + private volatile SabrSegmentRequest pendingRefetch; private Thread thread; SabrStreamPump(@NonNull final YoutubeSabrSession session, @@ -101,6 +104,13 @@ boolean isFatal() { return fatal; } + /** A reader is blocked on an evicted segment behind the buffered edge (backward seek). Ask the + * loop to reposition the session onto it so the server re-sends from there. */ + void requestRefetchFrom(@NonNull final SabrSegmentRequest request) { + pendingRefetch = request; + ensureStarted(); + } + private void loop() { try { while (!stopped) { @@ -118,6 +128,17 @@ private void loop() { session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - BACK_BUFFER_MS)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); + // Backward seek beyond the back-buffer: a reader is blocked on an evicted segment + // behind the edge. Reposition the session onto it (prepareForMediaSegment sets + // buffered=up-to-(seg-1) + playerTime=seg start, so the server re-sends from there) + // instead of fetching forward this round. Bypasses the throttle by design. + final SabrSegmentRequest refetch = pendingRefetch; + if (refetch != null) { + pendingRefetch = null; + session.prepareForRewind(refetch); + session.pumpOnce(localization); + continue; + } final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS || session.getCachedBytes() > MAX_AHEAD_BYTES; if (throttled) { From 77229b8f9903f9f07107e42a4634802c7aa7b80e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 18:01:20 +0200 Subject: [PATCH 28/29] fix(sabr): fall back to a decodable codec at the chosen resolution, not the highest --- .../player/datasource/SabrSessionStore.java | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index bcfdd57a9..34918fe07 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -256,16 +256,26 @@ private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo * otherwise fall back to the best hardware-friendly one. */ private static YoutubeSabrFormat pickVideoFormat(@NonNull final YoutubeSabrInfo info, final int preferredItag) { + final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); + final boolean hwAv1 = hasHardwareDecoder("video/av01"); + int preferredHeight = 0; if (preferredItag > 0) { - final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); - final boolean hwAv1 = hasHardwareDecoder("video/av01"); for (final YoutubeSabrFormat f : info.getFormats()) { - if (f.isVideo() && f.getItag() == preferredItag && isDecodable(f, hwVp9, hwAv1)) { - return f; + if (f.isVideo() && f.getItag() == preferredItag) { + if (isDecodable(f, hwVp9, hwAv1)) { + return f; + } + // Right resolution, wrong codec for this device (e.g. AV1 1080p with no HW AV1): + // remember the height so we fall back to a decodable codec at the SAME resolution. + preferredHeight = f.getHeight(); + break; } } } - return pickHardwareFriendlyVideo(info); + // Fall back to the highest decodable format AT the user's chosen resolution, not the absolute + // highest: otherwise an undecodable AV1 1080p pick would jump to VP9 4K (heavier, and on the + // Pixel it claims HW it can't sustain). preferredHeight 0 (no preference) = no cap. + return pickHardwareFriendlyVideo(info, preferredHeight); } private static boolean isDecodable(@NonNull final YoutubeSabrFormat f, @@ -283,7 +293,8 @@ private static boolean isDecodable(@NonNull final YoutubeSabrFormat f, * allow AVC always (universally HW), VP9 only when a HW VP9 decoder exists, AV1 only when a HW * AV1 decoder exists; otherwise fall back to the overall best (better some playback than none). */ - private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info) { + private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info, + final int maxHeight) { final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); final boolean hwAv1 = hasHardwareDecoder("video/av01"); YoutubeSabrFormat best = null; @@ -294,6 +305,9 @@ private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final Youtub if (!isDecodable(f, hwVp9, hwAv1)) { continue; } + if (maxHeight > 0 && f.getHeight() > maxHeight) { + continue; // don't exceed the user's chosen resolution + } if (best == null || f.getHeight() > best.getHeight() || (f.getHeight() == best.getHeight() && f.getBitrate() > best.getBitrate())) { best = f; From d58473a336d72b14bde7e8cf0e430a16131a9585 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 18:36:20 +0200 Subject: [PATCH 29/29] fix(sabr): shrink the back-buffer when the cache is over budget so eviction can drain --- .../newpipe/player/datasource/SabrStreamPump.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index f6628e602..9ff39c424 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -41,6 +41,11 @@ final class SabrStreamPump { // hit an evicted segment the pump never re-fetches -> dead buffer). Bounded, same order as the // forward cushion. Rewinds beyond this still need a session re-request (separate follow-up). private static final long BACK_BUFFER_MS = 30_000; + // Fallback back-buffer used when the cache is already over the byte budget: at high bitrate (4K) + // a 30s back-buffer + readahead exceeds MAX_AHEAD_BYTES, and since eviction can't drop segments + // within the back-buffer window the cache can't drain -> the pump throttles forever and stalls. + // Shrinking the back-buffer when over budget lets eviction free bytes so playback keeps fetching. + private static final long MIN_BACK_BUFFER_MS = 5_000; private final YoutubeSabrSession session; private final SabrSessionStore.Holder holder; @@ -123,9 +128,13 @@ private void loop() { // track read; readerTail = slowest track read (safe to evict below). final long readerHeadMs = holder.getReaderHeadMs(); // Evict what both tracks have read past, EVERY round (or a full cache never drains - // and the throttle latches forever -> freeze), but keep BACK_BUFFER_MS behind the - // reader so a short backward seek finds its segments cached instead of a hole. - session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - BACK_BUFFER_MS)); + // and the throttle latches forever -> freeze), keeping BACK_BUFFER_MS behind the + // reader so a short backward seek finds its segments cached. But when the cache is + // already over the byte budget (high bitrate), shrink the back-buffer so eviction + // can actually drain it, otherwise the pump throttles forever and playback stalls. + final long backBufferMs = session.getCachedBytes() > MAX_AHEAD_BYTES + ? MIN_BACK_BUFFER_MS : BACK_BUFFER_MS; + session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - backBufferMs)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); // Backward seek beyond the back-buffer: a reader is blocked on an evicted segment