diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a392331b6..629711906 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { minSdk = 21 targetSdk = 34 versionCode = 121 - versionName = "2.27.4" + versionName = "2.28.0" resourceConfigurations += listOf("ar", "de", "en", "es", "fr", "in", "ja", "pt-rBR", "ru", "tr", "zh-rTW") } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/repository/PlayerRepository.kt b/app/src/main/java/com/github/andreyasadchy/xtra/repository/PlayerRepository.kt index 44547762b..38ff75c6f 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/repository/PlayerRepository.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/repository/PlayerRepository.kt @@ -37,6 +37,7 @@ import org.json.JSONObject import retrofit2.Response import java.net.InetSocketAddress import java.net.Proxy +import java.net.URLEncoder import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -72,7 +73,7 @@ class PlayerRepository @Inject constructor( }.build()).execute().use { it.body.string() } } - suspend fun loadStreamPlaylistUrl(gqlHeaders: Map, channelLogin: String, randomDeviceId: Boolean?, xDeviceId: String?, playerType: String?, proxyPlaybackAccessToken: Boolean, proxyMultivariantPlaylist: Boolean, proxyHost: String?, proxyPort: Int?, proxyUser: String?, proxyPassword: String?): String = withContext(Dispatchers.IO) { + suspend fun loadStreamPlaylistUrl(gqlHeaders: Map, channelLogin: String, randomDeviceId: Boolean?, xDeviceId: String?, playerType: String?, supportedCodecs: String?, proxyPlaybackAccessToken: Boolean, proxyMultivariantPlaylist: Boolean, proxyHost: String?, proxyPort: Int?, proxyUser: String?, proxyPassword: String?): String = withContext(Dispatchers.IO) { val accessTokenHeaders = getPlaybackAccessTokenHeaders(gqlHeaders, randomDeviceId, xDeviceId) val accessToken = if (proxyPlaybackAccessToken && !proxyHost.isNullOrBlank() && proxyPort != null) { val json = JsonObject().apply { @@ -119,8 +120,10 @@ class PlayerRepository @Inject constructor( "allow_audio_only", "true", "fast_bread", "true", //low latency "p", Random.nextInt(9999999).toString(), - "sig", accessToken?.signature ?: "", - "token", accessToken?.token ?: "" + "platform", if (supportedCodecs?.contains("av1", true) == true) "web" else null, + "sig", accessToken?.signature, + "supported_codecs", supportedCodecs, + "token", accessToken?.token ).toString() if (proxyMultivariantPlaylist) { val response = getResponse( @@ -134,15 +137,17 @@ class PlayerRepository @Inject constructor( } else url } - suspend fun loadVideoPlaylistUrl(gqlHeaders: Map, videoId: String?, playerType: String?): Uri = withContext(Dispatchers.IO) { + suspend fun loadVideoPlaylistUrl(gqlHeaders: Map, videoId: String?, playerType: String?, supportedCodecs: String?): Uri = withContext(Dispatchers.IO) { val accessToken = loadVideoPlaybackAccessToken(gqlHeaders, videoId, playerType) buildUrl( "https://usher.ttvnw.net/vod/$videoId.m3u8?", "allow_source", "true", "allow_audio_only", "true", "p", Random.nextInt(9999999).toString(), - "sig", accessToken?.signature ?: "", - "token", accessToken?.token ?: "", + "platform", if (supportedCodecs?.contains("av1", true) == true) "web" else null, + "sig", accessToken?.signature, + "supported_codecs", supportedCodecs, + "token", accessToken?.token, ) } @@ -182,16 +187,19 @@ class PlayerRepository @Inject constructor( } } - private fun buildUrl(url: String, vararg queryParams: String): Uri { + private fun buildUrl(url: String, vararg queryParams: String?): Uri { val stringBuilder = StringBuilder(url) stringBuilder.append(queryParams[0]) .append("=") - .append(queryParams[1]) + .append(URLEncoder.encode(queryParams[1], Charsets.UTF_8.name())) for (i in 2 until queryParams.size step 2) { - stringBuilder.append("&") - .append(queryParams[i]) - .append("=") - .append(queryParams[i + 1]) + val value = queryParams[i + 1] + if (!value.isNullOrBlank()) { + stringBuilder.append("&") + .append(queryParams[i]) + .append("=") + .append(URLEncoder.encode(value, Charsets.UTF_8.name())) + } } return stringBuilder.toString().toUri() } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainActivity.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainActivity.kt index 759d0800b..0b58c2180 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainActivity.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/main/MainActivity.kt @@ -28,6 +28,8 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.FragmentTransaction +import androidx.media3.common.MimeTypes +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.NavigationUI @@ -159,6 +161,19 @@ class MainActivity : AppCompatActivity(), SlidingLayout.Listener { putBoolean(C.FIRST_LAUNCH6, false) } } + if (prefs.getBoolean(C.FIRST_LAUNCH7, true)) { + prefs.edit { + when { + MediaCodecSelector.DEFAULT.getDecoderInfos(MimeTypes.VIDEO_H265, false, false).none { it.hardwareAccelerated } -> { + putString(C.TOKEN_SUPPORTED_CODECS, "h264") + } + MediaCodecSelector.DEFAULT.getDecoderInfos(MimeTypes.VIDEO_AV1, false, false).none { it.hardwareAccelerated } -> { + putString(C.TOKEN_SUPPORTED_CODECS, "h265,h264") + } + } + putBoolean(C.FIRST_LAUNCH7, false) + } + } viewModel.integrity.observe(this) { if (prefs.getBoolean(C.ENABLE_INTEGRITY, false) && prefs.getBoolean(C.USE_WEBVIEW_INTEGRITY, true)) { IntegrityDialog.show(supportFragmentManager) diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt index 829d712c5..688313d96 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt @@ -212,22 +212,49 @@ class PlaybackService : MediaSessionService() { override fun onTimelineChanged(timeline: Timeline, reason: Int) { val manifest = mediaSession?.player?.currentManifest as? HlsManifest if (urls.isEmpty() && manifest is HlsManifest) { - manifest.multivariantPlaylist.let { - val tags = it.tags + manifest.multivariantPlaylist.let { playlist -> + val tags = playlist.tags + val qualityNames = mutableListOf() + val codecs = mutableListOf() val map = mutableMapOf() val appContext = XtraApp.INSTANCE.applicationContext val audioOnly = ContextCompat.getString(appContext, R.string.audio_only) - val pattern = Pattern.compile("NAME=\"(.+?)\"") + val qualityPattern = Pattern.compile("NAME=\"(.+?)\"") + val codecPattern = Pattern.compile("CODECS=\"(.+?)\\.") var trackIndex = 0 tags.forEach { tag -> if (tag.startsWith("#EXT-X-MEDIA")) { - val matcher = pattern.matcher(tag) + val matcher = qualityPattern.matcher(tag) if (matcher.find()) { val quality = matcher.group(1)!! - val url = it.variants[trackIndex++].url.toString() - map[if (!quality.startsWith("audio", true)) quality else audioOnly] = url + qualityNames.add(quality) } } + if (tag.startsWith("#EXT-X-STREAM-INF")) { + val matcher = codecPattern.matcher(tag) + if (matcher.find()) { + val codec = matcher.group(1)!! + codecs.add(when(codec) { + "av01" -> "AV1" + "hvc1" -> "H.265" + "avc1" -> "H.264" + else -> codec + }) + } + } + } + if (codecs.all { it == "H.264" || it == "mp4a" }) { + codecs.clear() + } + qualityNames.forEachIndexed { index, quality -> + val url = playlist.variants[trackIndex++].url.toString() + map[if (!quality.startsWith("audio", true)) { + codecs.getOrNull(index)?.let { codec -> + "$quality $codec" + } ?: quality + } else { + audioOnly + }] = url } urls = map.apply { if (containsKey(audioOnly)) { @@ -752,8 +779,8 @@ class PlaybackService : MediaSessionService() { } private fun setQualityIndex() { - val defaultQuality = prefs().getString(C.PLAYER_DEFAULTQUALITY, "saved") - val savedQuality = prefs().getString(C.PLAYER_QUALITY, "720p60") + val defaultQuality = prefs().getString(C.PLAYER_DEFAULTQUALITY, "saved")?.substringBefore(" ") + val savedQuality = prefs().getString(C.PLAYER_QUALITY, "720p60")?.substringBefore(" ") val index = when (defaultQuality) { "Source" -> { if (usingAutoQuality) { diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerFragment.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerFragment.kt index 1da431328..e8eb83dad 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerFragment.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerFragment.kt @@ -261,6 +261,7 @@ class StreamPlayerFragment : BasePlayerFragment() { randomDeviceId = prefs.getBoolean(C.TOKEN_RANDOM_DEVICEID, true), xDeviceId = prefs.getString(C.TOKEN_XDEVICEID, "twitch-web-wall-mason"), playerType = prefs.getString(C.TOKEN_PLAYERTYPE, "site"), + supportedCodecs = prefs.getString(C.TOKEN_SUPPORTED_CODECS, "av1,h265,h264"), proxyPlaybackAccessToken = prefs.getBoolean(C.PROXY_PLAYBACK_ACCESS_TOKEN, false), proxyMultivariantPlaylist = proxyMultivariantPlaylist, proxyHost = proxyHost, diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerViewModel.kt index 06a454bc7..3ff21ccb8 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/stream/StreamPlayerViewModel.kt @@ -70,10 +70,10 @@ class StreamPlayerViewModel @Inject constructor( } } - fun load(gqlHeaders: Map, channelLogin: String, randomDeviceId: Boolean?, xDeviceId: String?, playerType: String?, proxyPlaybackAccessToken: Boolean, proxyMultivariantPlaylist: Boolean, proxyHost: String?, proxyPort: Int?, proxyUser: String?, proxyPassword: String?) { + fun load(gqlHeaders: Map, channelLogin: String, randomDeviceId: Boolean?, xDeviceId: String?, playerType: String?, supportedCodecs: String?, proxyPlaybackAccessToken: Boolean, proxyMultivariantPlaylist: Boolean, proxyHost: String?, proxyPort: Int?, proxyUser: String?, proxyPassword: String?) { viewModelScope.launch { try { - playerRepository.loadStreamPlaylistUrl(gqlHeaders, channelLogin, randomDeviceId, xDeviceId, playerType, proxyPlaybackAccessToken, proxyMultivariantPlaylist, proxyHost, proxyPort, proxyUser, proxyPassword) + playerRepository.loadStreamPlaylistUrl(gqlHeaders, channelLogin, randomDeviceId, xDeviceId, playerType, supportedCodecs, proxyPlaybackAccessToken, proxyMultivariantPlaylist, proxyHost, proxyPort, proxyUser, proxyPassword) } catch (e: Exception) { if (e.message == "failed integrity check") { _integrity.postValue(true) diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerFragment.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerFragment.kt index b5d70c39d..56d3858b6 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerFragment.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerFragment.kt @@ -242,7 +242,8 @@ class VideoPlayerFragment : BasePlayerFragment(), HasDownloadDialog, ChatReplayP viewModel.load( gqlHeaders = TwitchApiHelper.getGQLHeaders(requireContext(), prefs.getBoolean(C.TOKEN_INCLUDE_TOKEN_VIDEO, true)), videoId = video.id, - playerType = prefs.getString(C.TOKEN_PLAYERTYPE_VIDEO, "channel_home_live") + playerType = prefs.getString(C.TOKEN_PLAYERTYPE_VIDEO, "channel_home_live"), + supportedCodecs = prefs.getString(C.TOKEN_SUPPORTED_CODECS, "av1,h265,h264") ) viewModel.result.observe(viewLifecycleOwner) { url -> player?.sendCustomCommand(SessionCommand(PlaybackService.START_VIDEO, bundleOf( diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerViewModel.kt index 56e36017c..3387de639 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/video/VideoPlayerViewModel.kt @@ -42,10 +42,10 @@ class VideoPlayerViewModel @Inject constructor( val gamesList = MutableLiveData>() var shouldRetry = true - fun load(gqlHeaders: Map, videoId: String?, playerType: String?) { + fun load(gqlHeaders: Map, videoId: String?, playerType: String?, supportedCodecs: String?) { viewModelScope.launch { try { - playerRepository.loadVideoPlaylistUrl(gqlHeaders, videoId, playerType) + playerRepository.loadVideoPlaylistUrl(gqlHeaders, videoId, playerType, supportedCodecs) } catch (e: Exception) { if (e.message == "failed integrity check") { _integrity.postValue(true) diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt b/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt index 9023dbc9c..7e5ade0d4 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/util/C.kt @@ -30,6 +30,7 @@ object C { const val FIRST_LAUNCH4 = "first_launch4" const val FIRST_LAUNCH5 = "first_launch5" const val FIRST_LAUNCH6 = "first_launch6" + const val FIRST_LAUNCH7 = "first_launch7" const val LANDSCAPE_CHAT_WIDTH = "landscape_chat_width" const val KEY_CHAT_OPENED = "key_chat_opened" const val KEY_CHAT_BAR_VISIBLE = "key_chat_bar_visible" @@ -176,6 +177,7 @@ object C { const val TOKEN_PLAYERTYPE_VIDEO = "token_playertype_video" const val TOKEN_INCLUDE_TOKEN_STREAM = "token_include_token_stream" const val TOKEN_INCLUDE_TOKEN_VIDEO = "token_include_token_video" + const val TOKEN_SUPPORTED_CODECS = "token_supported_codecs" const val TOKEN_SKIP_VIDEO_ACCESS_TOKEN = "token_skip_video_access_token" const val TOKEN_SKIP_CLIP_ACCESS_TOKEN = "token_skip_clip_access_token" const val HELIX = "Helix" diff --git a/app/src/main/res/xml/token_preferences.xml b/app/src/main/res/xml/token_preferences.xml index 006f2c704..8a3d1ec3a 100644 --- a/app/src/main/res/xml/token_preferences.xml +++ b/app/src/main/res/xml/token_preferences.xml @@ -52,6 +52,15 @@ app:iconSpaceReserved="false" app:singleLineTitle="false" /> + +