mirror of
https://github.com/libre-tube/LibreTube.git
synced 2024-12-13 22:00:30 +05:30
Merge pull request #5934 from Bnyro/master
feat: preference to force maximum audio quality
This commit is contained in:
commit
faecb1dceb
@ -1,14 +1,15 @@
|
|||||||
package com.github.libretube.helpers
|
package com.github.libretube.helpers
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import android.view.accessibility.CaptioningManager
|
import android.view.accessibility.CaptioningManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import androidx.core.app.RemoteActionCompat
|
import androidx.core.app.RemoteActionCompat
|
||||||
@ -19,7 +20,9 @@ import androidx.media3.common.AudioAttributes
|
|||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.PlaybackParameters
|
import androidx.media3.common.PlaybackParameters
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.datasource.cronet.CronetDataSource
|
import androidx.media3.datasource.cronet.CronetDataSource
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
@ -45,14 +48,14 @@ import com.github.libretube.extensions.updateParameters
|
|||||||
import com.github.libretube.obj.VideoStats
|
import com.github.libretube.obj.VideoStats
|
||||||
import com.github.libretube.util.PlayingQueue
|
import com.github.libretube.util.PlayingQueue
|
||||||
import com.github.libretube.util.TextUtils
|
import com.github.libretube.util.TextUtils
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
object PlayerHelper {
|
object PlayerHelper {
|
||||||
private const val ACTION_MEDIA_CONTROL = "media_control"
|
private const val ACTION_MEDIA_CONTROL = "media_control"
|
||||||
@ -99,7 +102,7 @@ object PlayerHelper {
|
|||||||
/**
|
/**
|
||||||
* Get the system's default captions style
|
* Get the system's default captions style
|
||||||
*/
|
*/
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
fun getCaptionStyle(context: Context): CaptionStyleCompat {
|
fun getCaptionStyle(context: Context): CaptionStyleCompat {
|
||||||
val captioningManager = context.getSystemService<CaptioningManager>()!!
|
val captioningManager = context.getSystemService<CaptioningManager>()!!
|
||||||
return if (!captioningManager.isEnabled) {
|
return if (!captioningManager.isEnabled) {
|
||||||
@ -339,15 +342,15 @@ object PlayerHelper {
|
|||||||
|
|
||||||
val playAutomatically: Boolean
|
val playAutomatically: Boolean
|
||||||
get() = PreferenceHelper.getBoolean(
|
get() = PreferenceHelper.getBoolean(
|
||||||
PreferenceKeys.PLAY_AUTOMATICALLY,
|
PreferenceKeys.PLAY_AUTOMATICALLY,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
val disablePipedProxy: Boolean
|
val disablePipedProxy: Boolean
|
||||||
get() = PreferenceHelper.getBoolean(
|
get() = PreferenceHelper.getBoolean(
|
||||||
PreferenceKeys.DISABLE_VIDEO_IMAGE_PROXY,
|
PreferenceKeys.DISABLE_VIDEO_IMAGE_PROXY,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
fun shouldPlayNextVideo(isPlaylist: Boolean = false): Boolean {
|
fun shouldPlayNextVideo(isPlaylist: Boolean = false): Boolean {
|
||||||
// if there is no next video, it obviously should not be played
|
// if there is no next video, it obviously should not be played
|
||||||
@ -356,11 +359,11 @@ object PlayerHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return autoPlayEnabled || (
|
return autoPlayEnabled || (
|
||||||
isPlaylist && PreferenceHelper.getBoolean(
|
isPlaylist && PreferenceHelper.getBoolean(
|
||||||
PreferenceKeys.AUTOPLAY_PLAYLISTS,
|
PreferenceKeys.AUTOPLAY_PLAYLISTS,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val handleAudioFocus
|
private val handleAudioFocus
|
||||||
@ -382,19 +385,44 @@ object PlayerHelper {
|
|||||||
.toIntOrNull()
|
.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@OptIn(UnstableApi::class)
|
||||||
* Apply the preferred audio quality: auto or worst
|
fun setPreferredAudioQuality(
|
||||||
*/
|
context: Context,
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
player: Player,
|
||||||
fun applyPreferredAudioQuality(context: Context, trackSelector: DefaultTrackSelector) {
|
trackSelector: DefaultTrackSelector
|
||||||
|
) {
|
||||||
val prefKey = if (NetworkHelper.isNetworkMetered(context)) {
|
val prefKey = if (NetworkHelper.isNetworkMetered(context)) {
|
||||||
PreferenceKeys.PLAYER_AUDIO_QUALITY_MOBILE
|
PreferenceKeys.PLAYER_AUDIO_QUALITY_MOBILE
|
||||||
} else {
|
} else {
|
||||||
PreferenceKeys.PLAYER_AUDIO_QUALITY
|
PreferenceKeys.PLAYER_AUDIO_QUALITY
|
||||||
}
|
}
|
||||||
when (PreferenceHelper.getString(prefKey, "auto")) {
|
|
||||||
"worst" -> trackSelector.updateParameters {
|
val qualityPref = PreferenceHelper.getString(prefKey, "auto")
|
||||||
setMaxAudioBitrate(1)
|
if (qualityPref == "auto") return
|
||||||
|
|
||||||
|
// multiple groups due to different possible audio languages
|
||||||
|
val audioTrackGroups = player.currentTracks.groups
|
||||||
|
.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||||
|
|
||||||
|
for (audioTrackGroup in audioTrackGroups) {
|
||||||
|
// find the best audio bitrate
|
||||||
|
val streams = (0 until audioTrackGroup.length).map { index ->
|
||||||
|
index to audioTrackGroup.getTrackFormat(index).bitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no bitrate info is available, fallback to the
|
||||||
|
// - first stream for lowest quality
|
||||||
|
// - last stream for highest quality
|
||||||
|
val streamIndex = if (qualityPref == "best") {
|
||||||
|
streams.maxByOrNull { it.second }?.takeIf { it.second != -1 }?.first
|
||||||
|
?: (streams.size - 1)
|
||||||
|
} else {
|
||||||
|
streams.minByOrNull { it.second }?.takeIf { it.second != -1 }?.first ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trackSelector.updateParameters {
|
||||||
|
val override = TrackSelectionOverride(audioTrackGroup.mediaTrackGroup, streamIndex)
|
||||||
|
setOverrideForType(override)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -412,7 +440,8 @@ object PlayerHelper {
|
|||||||
val intent = Intent(getIntentActionName(activity))
|
val intent = Intent(getIntentActionName(activity))
|
||||||
.setPackage(activity.packageName)
|
.setPackage(activity.packageName)
|
||||||
.putExtra(CONTROL_TYPE, event)
|
.putExtra(CONTROL_TYPE, event)
|
||||||
val pendingIntent = PendingIntentCompat.getBroadcast(activity, event.ordinal, intent, 0, false)!!
|
val pendingIntent =
|
||||||
|
PendingIntentCompat.getBroadcast(activity, event.ordinal, intent, 0, false)!!
|
||||||
|
|
||||||
val text = activity.getString(title)
|
val text = activity.getString(title)
|
||||||
val icon = IconCompat.createWithResource(activity, id)
|
val icon = IconCompat.createWithResource(activity, id)
|
||||||
@ -468,7 +497,7 @@ object PlayerHelper {
|
|||||||
/**
|
/**
|
||||||
* Create a basic player, that is used for all types of playback situations inside the app
|
* Create a basic player, that is used for all types of playback situations inside the app
|
||||||
*/
|
*/
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
fun createPlayer(
|
fun createPlayer(
|
||||||
context: Context,
|
context: Context,
|
||||||
trackSelector: DefaultTrackSelector,
|
trackSelector: DefaultTrackSelector,
|
||||||
@ -501,7 +530,7 @@ object PlayerHelper {
|
|||||||
/**
|
/**
|
||||||
* Get the load controls for the player (buffering, etc)
|
* Get the load controls for the player (buffering, etc)
|
||||||
*/
|
*/
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
fun getLoadControl(): LoadControl {
|
fun getLoadControl(): LoadControl {
|
||||||
return DefaultLoadControl.Builder()
|
return DefaultLoadControl.Builder()
|
||||||
// cache the last three minutes
|
// cache the last three minutes
|
||||||
@ -518,7 +547,7 @@ object PlayerHelper {
|
|||||||
/**
|
/**
|
||||||
* Load playback parameters such as speed and skip silence
|
* Load playback parameters such as speed and skip silence
|
||||||
*/
|
*/
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
fun ExoPlayer.loadPlaybackParams(isBackgroundMode: Boolean = false): ExoPlayer {
|
fun ExoPlayer.loadPlaybackParams(isBackgroundMode: Boolean = false): ExoPlayer {
|
||||||
skipSilenceEnabled = skipSilence
|
skipSilenceEnabled = skipSilence
|
||||||
val speed = if (isBackgroundMode) backgroundSpeed else playbackSpeed
|
val speed = if (isBackgroundMode) backgroundSpeed else playbackSpeed
|
||||||
@ -767,12 +796,12 @@ object PlayerHelper {
|
|||||||
*/
|
*/
|
||||||
fun haveAudioTrackRoleFlagSet(@C.RoleFlags roleFlags: Int): Boolean {
|
fun haveAudioTrackRoleFlagSet(@C.RoleFlags roleFlags: Int): Boolean {
|
||||||
return isFlagSet(roleFlags, C.ROLE_FLAG_DESCRIBES_VIDEO) ||
|
return isFlagSet(roleFlags, C.ROLE_FLAG_DESCRIBES_VIDEO) ||
|
||||||
isFlagSet(roleFlags, C.ROLE_FLAG_DUB) ||
|
isFlagSet(roleFlags, C.ROLE_FLAG_DUB) ||
|
||||||
isFlagSet(roleFlags, C.ROLE_FLAG_MAIN) ||
|
isFlagSet(roleFlags, C.ROLE_FLAG_MAIN) ||
|
||||||
isFlagSet(roleFlags, C.ROLE_FLAG_ALTERNATE)
|
isFlagSet(roleFlags, C.ROLE_FLAG_ALTERNATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
fun getVideoStats(player: ExoPlayer, videoId: String): VideoStats {
|
fun getVideoStats(player: ExoPlayer, videoId: String): VideoStats {
|
||||||
val videoInfo = "${player.videoFormat?.codecs.orEmpty()} ${
|
val videoInfo = "${player.videoFormat?.codecs.orEmpty()} ${
|
||||||
TextUtils.formatBitrate(
|
TextUtils.formatBitrate(
|
||||||
@ -812,12 +841,12 @@ object PlayerHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PlayerEvent.Forward -> {
|
PlayerEvent.Forward -> {
|
||||||
player.seekBy(PlayerHelper.seekIncrement)
|
player.seekBy(seekIncrement)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerEvent.Rewind -> {
|
PlayerEvent.Rewind -> {
|
||||||
player.seekBy(-PlayerHelper.seekIncrement)
|
player.seekBy(-seekIncrement)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,6 +80,7 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
* The [ExoPlayer] player. Followed tutorial [here](https://developer.android.com/codelabs/exoplayer-intro)
|
* The [ExoPlayer] player. Followed tutorial [here](https://developer.android.com/codelabs/exoplayer-intro)
|
||||||
*/
|
*/
|
||||||
var player: ExoPlayer? = null
|
var player: ExoPlayer? = null
|
||||||
|
private var trackSelector: DefaultTrackSelector? = null
|
||||||
private var isTransitioning = true
|
private var isTransitioning = true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -163,6 +164,14 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
|
super.onEvents(player, events)
|
||||||
|
|
||||||
|
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||||
|
PlayerHelper.setPreferredAudioQuality(this@OnlinePlayerService, player, trackSelector ?: return)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val playerActionReceiver = object : BroadcastReceiver() {
|
private val playerActionReceiver = object : BroadcastReceiver() {
|
||||||
@ -321,13 +330,12 @@ class OnlinePlayerService : LifecycleService() {
|
|||||||
private fun initializePlayer() {
|
private fun initializePlayer() {
|
||||||
if (player != null) return
|
if (player != null) return
|
||||||
|
|
||||||
val trackSelector = DefaultTrackSelector(this)
|
trackSelector = DefaultTrackSelector(this)
|
||||||
PlayerHelper.applyPreferredAudioQuality(this, trackSelector)
|
trackSelector!!.updateParameters {
|
||||||
trackSelector.updateParameters {
|
|
||||||
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
player = PlayerHelper.createPlayer(this, trackSelector, true)
|
player = PlayerHelper.createPlayer(this, trackSelector!!, true)
|
||||||
// prevent android from putting LibreTube to sleep when locked
|
// prevent android from putting LibreTube to sleep when locked
|
||||||
player!!.setWakeMode(WAKE_MODE_NETWORK)
|
player!!.setWakeMode(WAKE_MODE_NETWORK)
|
||||||
|
|
||||||
|
@ -273,6 +273,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
override fun onEvents(player: Player, events: Player.Events) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
updateDisplayedDuration()
|
updateDisplayedDuration()
|
||||||
super.onEvents(player, events)
|
super.onEvents(player, events)
|
||||||
|
|
||||||
if (events.containsAny(
|
if (events.containsAny(
|
||||||
Player.EVENT_PLAYBACK_STATE_CHANGED,
|
Player.EVENT_PLAYBACK_STATE_CHANGED,
|
||||||
Player.EVENT_IS_PLAYING_CHANGED,
|
Player.EVENT_IS_PLAYING_CHANGED,
|
||||||
@ -281,6 +282,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
) {
|
) {
|
||||||
updatePlayPauseButton()
|
updatePlayPauseButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||||
|
PlayerHelper.setPreferredAudioQuality(requireContext(), exoPlayer, trackSelector)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
@ -586,7 +591,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
playOnBackground()
|
playOnBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.relPlayerPip.isVisible = PictureInPictureCompat.isPictureInPictureAvailable(requireContext())
|
binding.relPlayerPip.isVisible =
|
||||||
|
PictureInPictureCompat.isPictureInPictureAvailable(requireContext())
|
||||||
|
|
||||||
binding.relPlayerPip.setOnClickListener {
|
binding.relPlayerPip.setOnClickListener {
|
||||||
PictureInPictureCompat.enterPictureInPictureMode(requireActivity(), pipParams)
|
PictureInPictureCompat.enterPictureInPictureMode(requireActivity(), pipParams)
|
||||||
@ -916,7 +922,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
|
|
||||||
val videoStream = streams.videoStreams.firstOrNull()
|
val videoStream = streams.videoStreams.firstOrNull()
|
||||||
isShort = PlayingQueue.getCurrent()?.isShort == true ||
|
isShort = PlayingQueue.getCurrent()?.isShort == true ||
|
||||||
(videoStream?.height ?: 0) > (videoStream?.width ?: 0)
|
(videoStream?.height ?: 0) > (videoStream?.width ?: 0)
|
||||||
|
|
||||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
PlayingQueue.setOnQueueTapListener { streamItem ->
|
||||||
streamItem.url?.toID()?.let { playNextVideo(it) }
|
streamItem.url?.toID()?.let { playNextVideo(it) }
|
||||||
@ -952,7 +958,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
if (binding.playerMotionLayout.progress != 1.0f) {
|
if (binding.playerMotionLayout.progress != 1.0f) {
|
||||||
// show controllers when not in picture in picture mode
|
// show controllers when not in picture in picture mode
|
||||||
val inPipMode = PlayerHelper.pipEnabled &&
|
val inPipMode = PlayerHelper.pipEnabled &&
|
||||||
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
|
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
|
||||||
if (!inPipMode) {
|
if (!inPipMode) {
|
||||||
binding.player.useController = true
|
binding.player.useController = true
|
||||||
}
|
}
|
||||||
@ -1349,7 +1355,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
this.setPreferredVideoMimeType(mimeType)
|
this.setPreferredVideoMimeType(mimeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerHelper.applyPreferredAudioQuality(requireContext(), trackSelector)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1570,7 +1575,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
|
|
||||||
private fun shouldStartPiP(): Boolean {
|
private fun shouldStartPiP(): Boolean {
|
||||||
return shouldUsePip() && exoPlayer.isPlaying &&
|
return shouldUsePip() && exoPlayer.isPlaying &&
|
||||||
!BackgroundHelper.isBackgroundServiceRunning(requireContext())
|
!BackgroundHelper.isBackgroundServiceRunning(requireContext())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun killPlayerFragment() {
|
private fun killPlayerFragment() {
|
||||||
|
@ -265,11 +265,13 @@
|
|||||||
<string-array name="audioQuality">
|
<string-array name="audioQuality">
|
||||||
<item>@string/auto</item>
|
<item>@string/auto</item>
|
||||||
<item>@string/worst_quality</item>
|
<item>@string/worst_quality</item>
|
||||||
|
<item>@string/best_quality</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string-array name="audioQualityValues">
|
<string-array name="audioQualityValues">
|
||||||
<item>auto</item>
|
<item>auto</item>
|
||||||
<item>worst</item>
|
<item>worst</item>
|
||||||
|
<item>best</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string-array name="audioQualityBitrates">
|
<string-array name="audioQualityBitrates">
|
||||||
|
@ -234,6 +234,7 @@
|
|||||||
<string name="playerAudioFormat">Audio format for player</string>
|
<string name="playerAudioFormat">Audio format for player</string>
|
||||||
<string name="playerAudioQuality">Audio quality</string>
|
<string name="playerAudioQuality">Audio quality</string>
|
||||||
<string name="worst_quality">Worst</string>
|
<string name="worst_quality">Worst</string>
|
||||||
|
<string name="best_quality">Best</string>
|
||||||
<string name="default_subtitle_language">Subtitle language</string>
|
<string name="default_subtitle_language">Subtitle language</string>
|
||||||
<string name="notifications">Notifications</string>
|
<string name="notifications">Notifications</string>
|
||||||
<string name="notify_new_streams">Show notifications for new streams</string>
|
<string name="notify_new_streams">Show notifications for new streams</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user