diff --git a/app/src/main/assets/native/ExoPlayerPlugin.js b/app/src/main/assets/native/ExoPlayerPlugin.js index de8ed18b..6226bfe3 100644 --- a/app/src/main/assets/native/ExoPlayerPlugin.js +++ b/app/src/main/assets/native/ExoPlayerPlugin.js @@ -44,7 +44,7 @@ export class ExoPlayerPlugin { this._paused = false; this._currentSrc = options.url; - this._nativePlayer.loadPlayer(JSON.stringify(options)); + this._nativePlayer.loadPlayer(this.prepareMedia(options)); this.loading.hide(); } @@ -137,6 +137,24 @@ export class ExoPlayerPlugin { this.audioStreamIndex = innerIndex; } + getMaxStreamingBitrate() { + const endpointInfo = window.NativeShell.getSavedEndpointInfo(); + return this.appSettings.maxStreamingBitrate(endpointInfo.IsInNetwork, 'Video'); + } + + enableAutomaticBitrateDetection() { + const endpointInfo = window.NativeShell.getSavedEndpointInfo(); + return this.appSettings.enableAutomaticBitrateDetection(endpointInfo.IsInNetwork, 'Video'); + } + + changeBitrate(bitrate) { + this.loading.show(); + this.playbackManager.setMaxStreamingBitrate({ + enableAutomaticBitrateDetection: bitrate ? false : true, + maxBitrate: bitrate + }, this); + } + prepareAudioTracksCapabilities(options) { var directPlayProfiles = this.cachedDeviceProfile.DirectPlayProfiles; var container = options.mediaSource.Container; @@ -152,6 +170,29 @@ export class ExoPlayerPlugin { }); } + prepareMedia(options) { + options.currentMaxBitrate = this.getMaxStreamingBitrate(); + options.isAutomaticBitrateEnabled = this.enableAutomaticBitrateDetection(); + if (options.mediaType === "Video") { + options.qualityOptions = window.NativeShell.getVideoQualityOptions({ + currentMaxBitrate: options.currentMaxBitrate, + isAutomaticBitrateEnabled: options.isAutomaticBitrateEnabled, + videoWidth: options.item.Width, + videoHeight: options.item.Height, + enableAuto: true + }).map(function (opt, index) { + return { + Index: index, + DisplayTitle: opt.name, + maxHeight: opt.maxHeight, + bitrate: opt.bitrate, + selected: opt.selected || false + } + }); + } + return JSON.stringify(options); + } + async notifyStopped() { let stopInfo = { src: this._currentSrc @@ -162,12 +203,13 @@ export class ExoPlayerPlugin { } async getDeviceProfile() { + var bitrateSetting = this.getMaxStreamingBitrate(); + if (this.cachedDeviceProfile) { + this.cachedDeviceProfile.MaxStreamingBitrate = bitrateSetting; return this.cachedDeviceProfile; } - var bitrateSetting = this.appSettings.maxStreamingBitrate(); - var profile = {}; profile.Name = "Android ExoPlayer" profile.MaxStreamingBitrate = bitrateSetting; @@ -194,10 +236,10 @@ export class ExoPlayerPlugin { var audioProfiles = { '3gp': ['aac', '3gpp', 'flac'], 'mp4': ['mp3', 'aac', 'mp1', 'mp2'], - 'ts': ['mp3', 'aac', 'mp1', 'mp2', 'ac3', 'dts'], + 'ts': ['aac', 'mp3', 'mp1', 'mp2', 'ac3', 'dts'], 'flac': ['flac'], 'aac': ['aac'], - 'mkv': ['mp3', 'aac', 'dts', 'flac', 'vorbis', 'opus', 'ac3', 'wma', 'mp1', 'mp2'], + 'mkv': ['aac', 'mp3', 'dts', 'flac', 'vorbis', 'opus', 'ac3', 'wma', 'mp1', 'mp2'], 'mp3': ['mp3'], 'ogg': ['ogg', 'opus', 'vorbis'], 'webm': ['vorbis', 'opus'], @@ -208,16 +250,7 @@ export class ExoPlayerPlugin { 'mov': ['mp3', 'aac', 'ac3', 'dts-hd', 'pcm'] }; - var subtitleProfiles = ['ass', 'idx', 'pgs', 'pgssub', 'smi', 'srt', 'ssa', 'subrip']; - - subtitleProfiles.forEach(function (format) { - profile.SubtitleProfiles.push({ - Format: format, - Method: 'Embed' - }); - }); - - var externalSubtitleProfiles = ['srt', 'sub', 'subrip', 'vtt']; + var externalSubtitleProfiles = ['srt', 'sub', 'ass', 'ssa', 'vtt', 'ttml'] externalSubtitleProfiles.forEach(function (format) { profile.SubtitleProfiles.push({ @@ -226,9 +259,13 @@ export class ExoPlayerPlugin { }); }); - profile.SubtitleProfiles.push({ - Format: 'dvdsub', - Method: 'Encode' + var subtitleProfiles = ['srt', 'ass', 'ssa', 'dvdsub', 'pgs', 'pgssub', 'sub', 'subrip']; + + subtitleProfiles.forEach(function (format) { + profile.SubtitleProfiles.push({ + Format: format, + Method: 'Embed' + }); }); var codecs = JSON.parse(this._nativePlayer.getSupportedFormats()); @@ -290,6 +327,16 @@ export class ExoPlayerPlugin { var profiles = videoCodec.profiles.join('|'); var maxLevel = videoCodec.levels.length && Math.max.apply(null, videoCodec.levels); var conditions = [{ + Condition: 'LessThanEqual', + Property: 'Width', + Value: videoCodec.maxWidth.toString() + }, + { + Condition: 'LessThanEqual', + Property: 'Height', + Value: videoCodec.maxHeight.toString() + }, + { Condition: 'LessThanEqual', Property: 'VideoBitrate', Value: videoCodec.maxBitrate.toString() @@ -358,7 +405,7 @@ export class ExoPlayerPlugin { VideoCodec: 'h264', Context: 'Streaming', Protocol: 'hls', - MinSegments: 1 + MinSegments: '1' }, { Container: 'mkv', @@ -375,8 +422,7 @@ export class ExoPlayerPlugin { AudioCodec: 'mp3', Context: 'Streaming', Protocol: 'http' - }, - + } ]; this.cachedDeviceProfile = profile; diff --git a/app/src/main/assets/native/ExternalPlayerPlugin.js b/app/src/main/assets/native/ExternalPlayerPlugin.js index f30eb00f..3a71a9db 100644 --- a/app/src/main/assets/native/ExternalPlayerPlugin.js +++ b/app/src/main/assets/native/ExternalPlayerPlugin.js @@ -37,10 +37,6 @@ export class ExternalPlayerPlugin { return this._externalPlayer.isEnabled(); } - supportsPlayMethod(playMethod, item) { - return playMethod === 'DirectStream'; - } - currentSrc() { return this._currentSrc; } @@ -50,7 +46,7 @@ export class ExternalPlayerPlugin { this._paused = false; this._currentSrc = options.url; this._isIntro = options.item && options.item.ProviderIds && options.item.ProviderIds.hasOwnProperty("prerolls.video"); - this._externalPlayer.initPlayer(JSON.stringify(options)); + this._externalPlayer.initPlayer(this.prepareMedia(options)); } setSubtitleStreamIndex(index) { } @@ -136,13 +132,47 @@ export class ExternalPlayerPlugin { this.audioStreamIndex = innerIndex; } + getMaxStreamingBitrate() { + const endpointInfo = window.NativeShell.getSavedEndpointInfo(); + return this.appSettings.maxStreamingBitrate(endpointInfo.IsInNetwork, 'Video'); + } + + enableAutomaticBitrateDetection() { + const endpointInfo = window.NativeShell.getSavedEndpointInfo(); + return this.appSettings.enableAutomaticBitrateDetection(endpointInfo.IsInNetwork, 'Video'); + } + + prepareMedia(options) { + options.currentMaxBitrate = this.getMaxStreamingBitrate(); + options.isAutomaticBitrateEnabled = this.enableAutomaticBitrateDetection(); + if (options.mediaType === "Video") { + options.qualityOptions = window.NativeShell.getVideoQualityOptions({ + currentMaxBitrate: options.currentMaxBitrate, + isAutomaticBitrateEnabled: options.isAutomaticBitrateEnabled, + videoWidth: options.item.Width, + videoHeight: options.item.Height, + enableAuto: true + }).map(function (opt, index) { + return { + Index: index, + DisplayTitle: opt.name, + maxHeight: opt.maxHeight, + bitrate: opt.bitrate, + selected: opt.selected || false + } + }); + } + return JSON.stringify(options); + } + async getDeviceProfile() { + var bitrateSetting = this.getMaxStreamingBitrate(); + if (this.cachedDeviceProfile) { + this.cachedDeviceProfile.MaxStreamingBitrate = bitrateSetting; return this.cachedDeviceProfile; } - var bitrateSetting = this.appSettings.maxStreamingBitrate(); - var profile = {}; profile.Name = "Android External Player" profile.MaxStreamingBitrate = bitrateSetting; @@ -161,36 +191,114 @@ export class ExternalPlayerPlugin { Type: 'Audio' }); + var codecs = JSON.parse(window.NativePlayer.getSupportedFormats()); profile.CodecProfiles = []; + for (var index in codecs.videoCodecs) { + if (codecs.videoCodecs.hasOwnProperty(index)) { + var videoCodec = codecs.videoCodecs[index]; + if (videoCodec.codec == 'h264') { + var profiles = videoCodec.profiles.join('|'); + var maxLevel = videoCodec.levels.length && Math.max.apply(null, videoCodec.levels); + var conditions = [{ + Condition: 'LessThanEqual', + Property: 'Width', + Value: videoCodec.maxWidth.toString() + }, + { + Condition: 'LessThanEqual', + Property: 'Height', + Value: videoCodec.maxHeight.toString() + }, + { + Condition: 'LessThanEqual', + Property: 'VideoBitrate', + Value: videoCodec.maxBitrate.toString() + }]; + if (profiles) { + conditions.push({ + Condition: 'EqualsAny', + Property: 'VideoProfile', + Value: profiles + }); + } + if (maxLevel) { + conditions.push({ + Condition: 'LessThanEqual', + Property: 'VideoLevel', + Value: maxLevel.toString() + }); + } + if (conditions.length) { + profile.CodecProfiles.push({ + Type: 'Video', + Codec: videoCodec.codec, + Conditions: conditions + }); + } + break; + } + } + } + profile.SubtitleProfiles = []; - var subtitleProfiles = ['ass', 'idx', 'smi', 'srt', 'ssa', 'subrip']; + var subtitleProfiles = ['ass', 'ssa', 'srt', 'sub']; - var embedSubtitleProfiles = ['pgs', 'pgssub']; - - subtitleProfiles.concat(embedSubtitleProfiles).forEach(function (format) { - profile.SubtitleProfiles.push({ - Format: format, - Method: 'Embed' - }); - }); - - var externalSubtitleProfiles = ['sub', 'vtt']; - - subtitleProfiles.concat(externalSubtitleProfiles).forEach(function (format) { + subtitleProfiles.forEach(function (format) { profile.SubtitleProfiles.push({ Format: format, Method: 'External' }); }); + subtitleProfiles.forEach(function (format) { + profile.SubtitleProfiles.push({ + Format: format, + Method: 'Embed' + }); + }); + profile.SubtitleProfiles.push({ Format: 'dvdsub', Method: 'Encode' }); - profile.TranscodingProfiles = []; + var audioProfiles = { + 'ts': ['aac', 'mp3', 'mp1', 'mp2', 'ac3', 'dts'], + 'mkv': ['aac', 'mp3', 'dts', 'flac', 'vorbis', 'opus', 'ac3', 'wma', 'mp1', 'mp2'] + }; + var audioCodecs = []; + + for (var index in codecs.audioCodecs) { + if (codecs.audioCodecs.hasOwnProperty(index)) { + var audioCodec = codecs.audioCodecs[index]; + audioCodecs.push(audioCodec.codec); + } + } + + profile.TranscodingProfiles = [ + { + Container: 'ts', + Type: 'Video', + AudioCodec: audioProfiles['ts'].filter(function (codec) { + return audioCodecs.indexOf(codec) !== -1; + }).join(','), + VideoCodec: 'h264', + Context: 'Streaming', + Protocol: 'hls', + MinSegments: '1' + }, + { + Container: 'mkv', + Type: 'Video', + AudioCodec: audioProfiles['mkv'].filter(function (codec) { + return audioCodecs.indexOf(codec) !== -1; + }).join(','), + VideoCodec: 'h264', + Context: 'Streaming' + } + ]; this.cachedDeviceProfile = profile; diff --git a/app/src/main/assets/native/NavigationPlugin.js b/app/src/main/assets/native/NavigationPlugin.js index cb1826b6..d51defac 100644 --- a/app/src/main/assets/native/NavigationPlugin.js +++ b/app/src/main/assets/native/NavigationPlugin.js @@ -5,12 +5,11 @@ export class NavigationPlugin { this.playbackManager = playbackManager; } - goBack() { - var appRouter = window['Emby']['Page']; - if (appRouter.canGoBack()) { - appRouter.back(); - } else { + async goBack() { + if (!window.ApiClient._currentUser) { window['NativeInterface'].exitApp(); + } else { + window.ApiClient.sendCommand(await window.NativeShell.getSavedSessionId(), { "Name": "Back" }); } } } diff --git a/app/src/main/assets/native/nativeshell.js b/app/src/main/assets/native/nativeshell.js index 412f2364..6d96c00a 100644 --- a/app/src/main/assets/native/nativeshell.js +++ b/app/src/main/assets/native/nativeshell.js @@ -32,6 +32,8 @@ let deviceId; let deviceName; let appName; let appVersion; +let endpointInfo; +let sessionId; window.NativeShell = { enableFullscreen() { @@ -87,6 +89,25 @@ window.NativeShell = { if (!keep) { delete callbacks[action]; } + }, + + getVideoQualityOptions(options) { + return JSON.parse(window.NativeInterface.getVideoQualityOptions(JSON.stringify(options))); + }, + + getSavedEndpointInfo() { + if (!endpointInfo) { + endpointInfo = JSON.parse(window.NativeInterface.getEndpointInfo()); + } + return endpointInfo; + }, + + async getSavedSessionId() { + if (!sessionId) { + var sessions = await window.ApiClient.getSessions({ deviceId: window.ApiClient._deviceId }); + sessionId = sessions[0].Id; + } + return sessionId; } }; diff --git a/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt b/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt index a1597078..94a36375 100644 --- a/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/ApplicationModule.kt @@ -15,6 +15,7 @@ import org.jellyfin.mobile.player.PlayerEvent import org.jellyfin.mobile.player.PlayerFragment import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.PermissionRequestHelper +import org.jellyfin.mobile.utils.QualityOptions import org.jellyfin.mobile.viewmodel.MainViewModel import org.jellyfin.mobile.webapp.RemoteVolumeProvider import org.jellyfin.mobile.webapp.WebappFunctionChannel @@ -44,6 +45,7 @@ val applicationModule = module { single { WebappFunctionChannel() } single { RemoteVolumeProvider(get()) } single(named(PLAYER_EVENT_CHANNEL)) { Channel() } + single { QualityOptions() } // Controllers single { ServerController(get(), get(), get(), get()) } diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt index e5f95221..cbcf37a2 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt @@ -36,32 +36,28 @@ class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent { @JavascriptInterface fun initPlayer(args: String) { try { - val mediaSource = JellyfinMediaSource(JSONObject(args)) - if (mediaSource.playMethod == "DirectStream") { - val playerIntent = Intent(Intent.ACTION_VIEW).apply { + with(JellyfinMediaSource(JSONObject(args))) { + Intent(Intent.ACTION_VIEW).apply { if (fragment.isPackageInstalled(appPreferences.externalPlayerApp)) { component = getComponent(appPreferences.externalPlayerApp) } - setDataAndType(mediaSource.uri, mediaSource.mimeType) - putExtra("title", mediaSource.title) - putExtra("position", mediaSource.mediaStartMs.toInt()) + setDataAndType(uri, mimeType) + putExtra("title", title) + putExtra("position", mediaStartMs.toInt()) putExtra("return_result", true) putExtra("secure_uri", true) - val selectedTrack = mediaSource.subtitleTracksGroup.tracks.getOrNull(mediaSource.subtitleTracksGroup.selectedTrack)?.url - if (selectedTrack != null) { - val externalTracks = mediaSource.subtitleTracksGroup.tracks.filter { !it.embedded && it.url != null } - putExtra("subs", externalTracks.map { Uri.parse(it.url) }.toTypedArray()) - putExtra("subs.name", externalTracks.map { it.language }.toTypedArray()) - putExtra("subs.filename", externalTracks.map { it.title }.toTypedArray()) - putExtra("subs.enable", arrayOf(Uri.parse(selectedTrack))) + subtitleTracksGroup.tracks.getOrNull(subtitleTracksGroup.selectedTrack)?.url?.run { + subtitleTracksGroup.tracks.filter { !it.embedded && it.url != null }.run { + putExtra("subs", map { Uri.parse(it.url) }.toTypedArray()) + putExtra("subs.name", map { it.language }.toTypedArray()) + putExtra("subs.filename", map { it.title }.toTypedArray()) + } + putExtra("subs.enable", arrayOf(Uri.parse(this))) } + }.run { + fragment.startActivityForResult(this, Constants.HANDLE_EXTERNAL_PLAYER) + Timber.d("Starting playback [id: ${id}, title: ${title}, playMethod: ${playMethod}, mediaStartMs: ${mediaStartMs}]") } - fragment.startActivityForResult(playerIntent, Constants.HANDLE_EXTERNAL_PLAYER) - Timber.d("Starting playback [id: ${mediaSource.id}, title: ${mediaSource.title}, playMethod: ${mediaSource.playMethod}, mediaStartMs: ${mediaSource.mediaStartMs}]") - } else { - Timber.d("Play Method '${mediaSource.playMethod}' not tested, ignoring...") - notifyEvent(Constants.EVENT_CANCELED) - context.toast(R.string.external_player_invalid_play_method, Toast.LENGTH_LONG) } } catch (e: JSONException) { Timber.e(e) diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt index 30d33a7f..2e197e6e 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt @@ -10,6 +10,7 @@ import android.webkit.JavascriptInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.jellyfin.apiclient.interaction.AndroidDevice +import org.jellyfin.apiclient.interaction.ApiClient import org.jellyfin.mobile.R import org.jellyfin.mobile.fragment.WebViewFragment import org.jellyfin.mobile.settings.SettingsFragment @@ -27,9 +28,11 @@ import org.jellyfin.mobile.utils.Constants.EXTRA_ITEM_ID import org.jellyfin.mobile.utils.Constants.EXTRA_PLAYER_ACTION import org.jellyfin.mobile.utils.Constants.EXTRA_POSITION import org.jellyfin.mobile.utils.Constants.EXTRA_TITLE +import org.jellyfin.mobile.utils.QualityOptions import org.jellyfin.mobile.utils.addFragment import org.jellyfin.mobile.utils.disableFullscreen import org.jellyfin.mobile.utils.enableFullscreen +import org.jellyfin.mobile.utils.getEndpointInfo import org.jellyfin.mobile.utils.requestDownload import org.jellyfin.mobile.utils.requireMainActivity import org.jellyfin.mobile.utils.runOnUiThread @@ -47,6 +50,8 @@ class NativeInterface(private val fragment: WebViewFragment) : KoinComponent { private val context: Context = fragment.requireContext() private val webappFunctionChannel: WebappFunctionChannel by inject() private val remoteVolumeProvider: RemoteVolumeProvider by inject() + private val apiClient: ApiClient by inject() + private val qualityOptions: QualityOptions by inject() @SuppressLint("HardwareIds") @JavascriptInterface @@ -196,4 +201,23 @@ class NativeInterface(private val fragment: WebViewFragment) : KoinComponent { } }) } + + @JavascriptInterface + fun getVideoQualityOptions(options: String): String { + return qualityOptions.getVideoQualityOptions(options) + } + + @JavascriptInterface + fun getEndpointInfo(): String { + return runBlocking(Dispatchers.Main) { + with(apiClient.getEndpointInfo()) { + if (this != null) { + JSONObject().apply { + put("IsLocal", isLocal) + put("IsInNetwork", isInNetwork) + }.toString() + } else "{}" + } + } + } } diff --git a/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt b/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt index e2cef587..9894ceaf 100644 --- a/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/fragment/ConnectFragment.kt @@ -135,11 +135,15 @@ class ConnectFragment : Fragment() { } private fun chooseServer() { + chooseServerButton.isEnabled = false AlertDialog.Builder(activity).apply { setTitle(R.string.available_servers_title) setItems(serverList.map { "${it.name}\n${it.address}" }.toTypedArray()) { _, index -> connect(serverList[index].address) } + setOnCancelListener { + chooseServerButton.isEnabled = true + } }.show() } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerCodec.kt b/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerCodec.kt index b004cbaa..54f9b76d 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerCodec.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerCodec.kt @@ -17,6 +17,8 @@ class ExoPlayerCodec(codecCapabilities: CodecCapabilities) { val isAudio: Boolean private val profiles: MutableList = ArrayList() private val levels: MutableList = ArrayList() + private val maxWidth: Int? + private val maxHeight: Int? private val maxBitrate: Int private val maxChannels: Int? private val maxSampleRate: Int? @@ -27,6 +29,8 @@ class ExoPlayerCodec(codecCapabilities: CodecCapabilities) { if (videoCodec != null) { codec = videoCodec isAudio = false + maxWidth = codecCapabilities.videoCapabilities.supportedWidths.upper + maxHeight = codecCapabilities.videoCapabilities.supportedHeights.upper maxBitrate = codecCapabilities.videoCapabilities.bitrateRange.upper maxChannels = null maxSampleRate = null @@ -36,6 +40,8 @@ class ExoPlayerCodec(codecCapabilities: CodecCapabilities) { if (audioCodec != null) { isAudio = true codec = audioCodec + maxWidth = null + maxHeight = null maxBitrate = codecCapabilities.audioCapabilities.bitrateRange.upper maxChannels = codecCapabilities.audioCapabilities.maxInputChannelCount val sampleRates = codecCapabilities.audioCapabilities.supportedSampleRateRanges @@ -44,6 +50,8 @@ class ExoPlayerCodec(codecCapabilities: CodecCapabilities) { // mimeType is neither, abort codec = null isAudio = false + maxWidth = null + maxHeight = null maxBitrate = 0 maxChannels = null maxSampleRate = null @@ -51,7 +59,7 @@ class ExoPlayerCodec(codecCapabilities: CodecCapabilities) { } if (codec != null) { val profileLevels = codecCapabilities.profileLevels - for (profileLevel in profileLevels) { + for (profileLevel in profileLevels.reversed()) { val profile: String? val level: Int? if (isAudio) { @@ -91,6 +99,8 @@ class ExoPlayerCodec(codecCapabilities: CodecCapabilities) { put("isAudio", isAudio) put("profiles", JSONArray(profiles)) put("levels", JSONArray(levels)) + put("maxWidth", maxWidth) + put("maxHeight", maxHeight) put("maxBitrate", maxBitrate) put("maxChannels", maxChannels) put("maxSampleRate", maxSampleRate) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerFormats.kt b/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerFormats.kt index 6390d832..deafcd63 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerFormats.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ExoPlayerFormats.kt @@ -269,6 +269,8 @@ object ExoPlayerFormats { "vtt", "webvtt" -> MimeTypes.TEXT_VTT "ttml" -> MimeTypes.APPLICATION_TTML "srt", "sub", "subrip" -> MimeTypes.APPLICATION_SUBRIP + "dvdsub" -> MimeTypes.APPLICATION_DVBSUBS + "pgs", "pgssub" -> MimeTypes.APPLICATION_PGS else -> null } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt index aef9b6b0..fca77106 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt @@ -15,6 +15,7 @@ import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.player.source.ExoPlayerTracksGroup import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.utils.toHumanBitRate /** * Provides a menu UI for audio, subtitle and video stream selection @@ -28,10 +29,12 @@ class PlaybackMenus( private val lockScreenButton: View by playerControlsBinding::lockScreenButton private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton + private val qualityButton: View by playerControlsBinding::qualityButton private val infoButton: View by playerControlsBinding::infoButton private val playbackInfo: TextView by playerBinding::playbackInfo private val audioStreamsMenu: PopupMenu = createAudioStreamsMenu() private val subtitlesMenu: PopupMenu = createSubtitlesMenu() + private val qualityMenu: PopupMenu = createQualityMenu() private var subtitleCount = 0 private var selectedSubtitle = -1 @@ -60,6 +63,10 @@ class PlaybackMenus( } } } + qualityButton.setOnClickListener { + fragment.suppressControllerAutoHide(true) + qualityMenu.show() + } infoButton.setOnClickListener { playbackInfo.isVisible = !playbackInfo.isVisible } @@ -71,12 +78,21 @@ class PlaybackMenus( fun onItemChanged(item: JellyfinMediaSource) { buildMenuItems(subtitlesMenu.menu, SUBTITLES_MENU_GROUP, item.subtitleTracksGroup, true) buildMenuItems(audioStreamsMenu.menu, AUDIO_MENU_GROUP, item.audioTracksGroup) + buildMenuItems(qualityMenu.menu, QUALITY_MENU_GROUP, item.qualityTracksGroup) + subtitleCount = item.subtitleTracksCount selectedSubtitle = item.subtitleTracksGroup.selectedTrack updateSubtitlesButton() + qualityButton.isVisible = item.qualityTracksCount > 0 + val playMethod = context.getString(R.string.playback_info_play_method, item.playMethod) val transcodingInfo = context.getString(R.string.playback_info_transcoding, item.isTranscoding) + val maxBitrateInfo = context.getString(R.string.playback_info_max_bitrate, listOf( + item.currentMaxBitrate.toHumanBitRate(), + if (item.isAutomaticBitrateEnabled) "- Auto" else "", + "(${item.bitrate.toHumanBitRate()})" + ).joinToString(" ")) val videoTracksInfo = item.videoTracksGroup.tracks.run { joinToString( "\n", @@ -99,11 +115,12 @@ class PlaybackMenus( "${fragment.getString(R.string.playback_info_audio_streams)}:\n", limit = 5, truncated = fragment.getString(R.string.playback_info_and_x_more, size - 3) - ) { "- ${it.title} (${it.language})" } + ) { "- ${it.title} (${it.language}) @ ${it.bitrate.toHumanBitRate()}" } } playbackInfo.text = listOf( playMethod, transcodingInfo, + maxBitrateInfo, videoTracksInfo, audioTracksInfo, ).joinToString("\n\n") @@ -136,6 +153,18 @@ class PlaybackMenus( setOnDismissListener(this@PlaybackMenus) } + private fun createQualityMenu() = PopupMenu(context, qualityButton).apply { + setOnMenuItemClickListener { clickedItem -> + fragment.setMaxStreamingBitrate(clickedItem.itemId).also { success -> + if (success) { + menu.forEach { it.isChecked = false } + clickedItem.isChecked = true + } + } + } + setOnDismissListener(this@PlaybackMenus) + } + private fun buildMenuItems(menu: Menu, groupId: Int, tracksGroup: ExoPlayerTracksGroup<*>, showNone: Boolean = false) { menu.clear() if (showNone) menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none)) @@ -175,5 +204,6 @@ class PlaybackMenus( companion object { private const val SUBTITLES_MENU_GROUP = 0 private const val AUDIO_MENU_GROUP = 1 + private const val QUALITY_MENU_GROUP = 2 } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt index d967e468..70c74b19 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt @@ -457,6 +457,10 @@ class PlayerFragment : Fragment() { return viewModel.mediaSourceManager.selectSubtitle(index) } + fun setMaxStreamingBitrate(index: Int): Boolean { + return viewModel.setMaxStreamingBitrate(index) + } + fun onUserLeaveHint() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && viewModel.playerOrNull?.isPlaying == true) { with(requireActivity()) { diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 8cd236a8..3aeabef2 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -223,6 +223,21 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), audioManager.setStreamVolume(stream, scaled, 0) } + fun setMaxStreamingBitrate(index: Int): Boolean { + val mediaSource = mediaSourceManager.jellyfinMediaSource.value + if (mediaSource != null && !mediaSource.qualityTracksGroup.tracks[index].selected) { + mediaSession.isActive = false + mediaSession.release() + playerOrNull?.apply { + removeListener(this@PlayerViewModel) + release() + } + _player.value = null + return webappFunctionChannel.exoPlayerChangeBitrate(mediaSource.qualityTracksGroup.tracks[index].bitrate) + } + return false + } + fun getPlayerRendererIndex(type: Int): Int { return playerOrNull?.getRendererIndexByType(type) ?: -1 } diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/ExoPlayerTrack.kt b/app/src/main/java/org/jellyfin/mobile/player/source/ExoPlayerTrack.kt index 89ce8ba4..40880246 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/ExoPlayerTrack.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/ExoPlayerTrack.kt @@ -20,6 +20,7 @@ sealed class ExoPlayerTrack(track: JSONObject) { class Audio(track: JSONObject) : ExoPlayerTrack(track) { val language: String = track.optString("Language", Constants.LANGUAGE_UNDEFINED) + val bitrate: Long = track.optLong("BitRate") val supportsDirectPlay: Boolean = track.optBoolean("supportsDirectPlay", false) override fun toString() = "ExoPlayerTrack.Audio#$index(title=$title, lang=$language)" @@ -32,4 +33,12 @@ sealed class ExoPlayerTrack(track: JSONObject) { override fun toString() = "ExoPlayerTrack.Text#$index(title=$title, lang=$language, fmt=$format, url=$url)" } + + class Quality(track: JSONObject) : ExoPlayerTrack(track) { + val bitrate: Long = track.optLong("bitrate") + val maxHeight: Int = track.optInt("maxHeight") + val selected: Boolean = track.optString("selected", "") == "true" + + override fun toString() = "ExoPlayerTrack.Quality#$index(title=$title, bitrate=$bitrate, selected=$selected)" + } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt index 87b441b6..eb963aee 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt @@ -3,7 +3,9 @@ package org.jellyfin.mobile.player.source import android.net.Uri import com.google.android.exoplayer2.util.MimeTypes import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.appendQueryParameter import org.jellyfin.mobile.utils.asIterable +import org.jellyfin.mobile.utils.replaceQueryParameter import org.json.JSONArray import org.json.JSONObject @@ -11,7 +13,7 @@ class JellyfinMediaSource(item: JSONObject) { val id: String = item.optJSONObject("item")?.optString("Id") ?: "" val title: String = item.optString("title") val artists: String? = item.optJSONObject("item")?.optJSONArray("Artists")?.asIterable()?.joinToString() - val uri: Uri = Uri.parse(item.optString("url")) + val uri: Uri val mimeType: String = item.optString("mimeType") val playMethod: String = item.optString("playMethod") val mediaStartMs: Long = item.optLong("playerStartPositionTicks") / Constants.TICKS_PER_MILLISECOND @@ -19,7 +21,11 @@ class JellyfinMediaSource(item: JSONObject) { val videoTracksGroup: ExoPlayerTracksGroup val audioTracksGroup: ExoPlayerTracksGroup val subtitleTracksGroup: ExoPlayerTracksGroup + val qualityTracksGroup: ExoPlayerTracksGroup val isTranscoding: Boolean + val bitrate: Long + val currentMaxBitrate: Long = item.optLong("currentMaxBitrate") + val isAutomaticBitrateEnabled: Boolean = item.optBoolean("isAutomaticBitrateEnabled") val selectedVideoTrack: ExoPlayerTrack.Video? get() = videoTracksGroup.tracks.getOrNull(videoTracksGroup.selectedTrack) @@ -30,11 +36,13 @@ class JellyfinMediaSource(item: JSONObject) { val audioTracksCount: Int get() = audioTracksGroup.tracks.size val subtitleTracksCount: Int get() = subtitleTracksGroup.tracks.size + val qualityTracksCount: Int get() = qualityTracksGroup.tracks.size init { val mediaSource = item.optJSONObject("mediaSource") if (mediaSource != null) { mediaDurationTicks = mediaSource.optLong("RunTimeTicks") + bitrate = mediaSource.optLong("Bitrate") val textTrackUrls = HashMap().apply { item.optJSONArray("textTracks")?.let { textTracks -> for (i in 0 until textTracks.length()) { @@ -63,11 +71,30 @@ class JellyfinMediaSource(item: JSONObject) { subtitleTracksGroup = loadSubtitleTracks(mediaSource, subtitleTracks, textTrackUrls) } else { mediaDurationTicks = 0 + bitrate = 0 videoTracksGroup = ExoPlayerTracksGroup(-1, emptyList()) audioTracksGroup = ExoPlayerTracksGroup(-1, emptyList()) subtitleTracksGroup = ExoPlayerTracksGroup(-1, emptyList()) } + qualityTracksGroup = loadQualityTracks(item.optJSONArray("qualityOptions") ?: JSONArray()) isTranscoding = mediaSource?.optString("TranscodingSubProtocol") == "hls" + uri = Uri.parse(item.optString("url")).run { + if (isTranscoding && qualityTracksGroup.selectedTrack != -1) { + val qualityMaxHeight = qualityTracksGroup.tracks[qualityTracksGroup.selectedTrack].maxHeight + if (qualityMaxHeight > 0) { + val currentMaxHeight = getQueryParameter("MaxHeight")?.toInt() + when { + currentMaxHeight == null -> { + appendQueryParameter("MaxHeight", "$qualityMaxHeight") + } + currentMaxHeight > qualityMaxHeight -> { + replaceQueryParameter("MaxHeight", "$qualityMaxHeight") + } + else -> this /* Same max height in request */ + } + } else this /* No max height set in quality option */ + } else this /* No transcoding or quality option selected */ + } } private fun loadVideoTracks(tracks: List): ExoPlayerTracksGroup { @@ -115,4 +142,19 @@ class JellyfinMediaSource(item: JSONObject) { } return ExoPlayerTracksGroup(currentSelection, subtitleTracks) } + + private fun loadQualityTracks(tracks: JSONArray): ExoPlayerTracksGroup { + val qualityTracks: MutableList = ArrayList() + for (index in 0 until tracks.length()) { + val track = tracks.getJSONObject(index) + qualityTracks += ExoPlayerTrack.Quality(track) + } + qualityTracks.sortBy(ExoPlayerTrack.Quality::index) + var currentSelection = -1 + qualityTracks.forEachIndexed { index, track -> + if (track.selected) + currentSelection = index + } + return ExoPlayerTracksGroup(currentSelection, qualityTracks) + } } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/ApiExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/ApiExtensions.kt index d8826c26..c1a3aaad 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/ApiExtensions.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/ApiExtensions.kt @@ -6,6 +6,7 @@ import org.jellyfin.apiclient.interaction.Response import org.jellyfin.apiclient.model.configuration.ServerConfiguration import org.jellyfin.apiclient.model.dto.BaseItemDto import org.jellyfin.apiclient.model.dto.UserItemDataDto +import org.jellyfin.apiclient.model.net.EndPointInfo import org.jellyfin.apiclient.model.playlists.PlaylistItemQuery import org.jellyfin.apiclient.model.querying.ArtistsQuery import org.jellyfin.apiclient.model.querying.ItemQuery @@ -24,6 +25,10 @@ import kotlin.coroutines.suspendCoroutine val PRODUCT_NAME_SUPPORTED_SINCE: Pair = 10 to 7 // Can be removed/replaced once the api client supports coroutines natively +suspend fun ApiClient.getEndpointInfo(): EndPointInfo? = suspendCoroutine { continuation -> + GetEndPointInfo(ContinuationResponse(continuation)) +} + suspend fun ApiClient.getPublicSystemInfo(): PublicSystemInfo? = suspendCoroutine { continuation -> GetPublicSystemInfoAsync(ContinuationResponse(continuation)) } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/QualityOptions.kt b/app/src/main/java/org/jellyfin/mobile/utils/QualityOptions.kt new file mode 100644 index 00000000..73d29665 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/QualityOptions.kt @@ -0,0 +1,180 @@ +// Taken and adapted from https://github.com/jellyfin/jellyfin-web/blob/ebb4b050817d4d1920ec28209dee0539410c06ef/src/components/qualityOptions.js + +package org.jellyfin.mobile.utils + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.text.DecimalFormat +import java.text.StringCharacterIterator + +class QualityOptions { + fun getVideoQualityOptions(options: String): String { + with(JSONObject(options)) { + return getVideoQualityOptions( + currentMaxBitrate = optLong("currentMaxBitrate"), + isAutomaticBitrateEnabled = optBoolean("isAutomaticBitrateEnabled"), + videoWidth = optInt("videoWidth", 4096), + videoHeight = optInt("videoHeight"), + enableAuto = optBoolean("enableAuto") + ).toJSONString() + } + } + + private fun getVideoQualityOptions( + currentMaxBitrate: Long, + isAutomaticBitrateEnabled: Boolean, + videoWidth: Int, + videoHeight: Int, + enableAuto: Boolean + ): List { + var width = videoWidth + + // If the aspect ratio is less than 16/9 (1.77), set the width as if it were pillarboxed. + // 4:3 1440x1080 -> 1920x1080 + if (width / videoHeight < 16 / 9) { + width = videoHeight * (16 / 9) + } + + val qualityOptions = mutableListOf() + + if (enableAuto) { + qualityOptions.add(QualityOption(name = "Auto", selected = isAutomaticBitrateEnabled)) + } + + // Quality options are indexed by bitrate. If you must duplicate them, make sure each of them are unique (by making the last digit a 1) + if (width >= 3800) { + qualityOptions.add(QualityOption(name = "4K - 120 Mbps", maxHeight = 2160, bitrate = 120000000)) + qualityOptions.add(QualityOption(name = "4K - 80 Mbps", maxHeight = 2160, bitrate = 80000000)) + } + // Some 1080- videos are reported as 1912? + if (width >= 1900) { + qualityOptions.add(QualityOption(name = "1080p - 60 Mbps", maxHeight = 1080, bitrate = 60000000)) + qualityOptions.add(QualityOption(name = "1080p - 40 Mbps", maxHeight = 1080, bitrate = 40000000)) + qualityOptions.add(QualityOption(name = "1080p - 20 Mbps", maxHeight = 1080, bitrate = 20000000)) + qualityOptions.add(QualityOption(name = "1080p - 15 Mbps", maxHeight = 1080, bitrate = 15000000)) + qualityOptions.add(QualityOption(name = "1080p - 10 Mbps", maxHeight = 1080, bitrate = 10000000)) + } + if (width >= 1260) { + qualityOptions.add(QualityOption(name = "720p - 8 Mbps", maxHeight = 720, bitrate = 8000000)) + qualityOptions.add(QualityOption(name = "720p - 6 Mbps", maxHeight = 720, bitrate = 6000000)) + qualityOptions.add(QualityOption(name = "720p - 4 Mbps", maxHeight = 720, bitrate = 4000000)) + } + if (width >= 620) { + qualityOptions.add(QualityOption(name = "480p - 3 Mbps", maxHeight = 480, bitrate = 3000000)) + qualityOptions.add(QualityOption(name = "480p - 1.5 Mbps", maxHeight = 480, bitrate = 1500000)) + qualityOptions.add(QualityOption(name = "480p - 720 kbps", maxHeight = 480, bitrate = 720000)) + } + + qualityOptions.add(QualityOption(name = "360p - 420 kbps", maxHeight = 360, bitrate = 420000)) + + if (currentMaxBitrate > 0) { + var selectedIndex = qualityOptions.lastIndex + + for (index in 0..qualityOptions.size) { + if (qualityOptions[index].bitrate in 1..currentMaxBitrate) { + selectedIndex = index + break + } + } + + if (!isAutomaticBitrateEnabled) { + qualityOptions[selectedIndex].selected = true + } + } + + return qualityOptions + } + + /*fun getAudioQualityOptions(options: String): String { + with(JSONObject(options)) { + return getAudioQualityOptions( + currentMaxBitrate = optInt("currentMaxBitrate"), + isAutomaticBitrateEnabled = optBoolean("isAutomaticBitrateEnabled"), + enableAuto = optBoolean("enableAuto") + ).toJSONString() + } + } + + private fun getAudioQualityOptions( + currentMaxBitrate: Int, + isAutomaticBitrateEnabled: Boolean, + enableAuto: Boolean + ): List { + val qualityOptions = mutableListOf() + + if (enableAuto) { + qualityOptions.add(QualityOption(name = "Auto", selected = isAutomaticBitrateEnabled)) + } + + qualityOptions.add(QualityOption(name = "2 Mbps", bitrate = 2000000)) + qualityOptions.add(QualityOption(name = "1.5 Mbps", bitrate = 1500000)) + qualityOptions.add(QualityOption(name = "1 Mbps", bitrate = 1000000)) + qualityOptions.add(QualityOption(name = "320 kbps", bitrate = 320000)) + qualityOptions.add(QualityOption(name = "256 kbps", bitrate = 256000)) + qualityOptions.add(QualityOption(name = "192 kbps", bitrate = 192000)) + qualityOptions.add(QualityOption(name = "128 kbps", bitrate = 128000)) + qualityOptions.add(QualityOption(name = "96 kbps", bitrate = 96000)) + qualityOptions.add(QualityOption(name = "64 kbps", bitrate = 64000)) + + if (currentMaxBitrate > 0) { + var selectedIndex = qualityOptions.lastIndex + + for (index in 0..qualityOptions.size) { + if (qualityOptions[index].bitrate in 1..currentMaxBitrate) { + selectedIndex = index + break + } + } + + if (!isAutomaticBitrateEnabled) { + qualityOptions[selectedIndex].selected = true + } + } + + return qualityOptions + }*/ + + private data class QualityOption( + val name: String, + val maxHeight: Int? = null, + val bitrate: Long = 0, + var selected: Boolean? = null + ) + + private fun QualityOption.toJSONObject(): JSONObject? = try { + JSONObject().apply { + put("name", name) + put("maxHeight", maxHeight) + put("bitrate", bitrate) + put("selected", selected) + } + } catch (e: JSONException) { + null + } + + private fun List.toJSONString() = try { + JSONArray().apply { + for (qualityOption in this@toJSONString) { + put(qualityOption.toJSONObject()) + } + } + } catch (e: JSONException) { + JSONObject() + }.toString() +} + +fun Long.toHumanBitRate(): String { + var bits = if (this < 0) 0 else this + if (bits < 1_000) { + return "$bits bps" + } + val ci = StringCharacterIterator("kMGTPE") + while (bits >= 1_000_000) { + bits /= 1_000 + ci.next() + } + with(DecimalFormat("0.##")) { + return "${format(bits / 1_000.0)} ${ci.current()}bps" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/WebAppUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/WebAppUtils.kt index aefd9730..31453076 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/WebAppUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/WebAppUtils.kt @@ -1,6 +1,7 @@ package org.jellyfin.mobile.utils import android.content.Context +import android.net.Uri import android.webkit.WebResourceResponse import timber.log.Timber import java.io.IOException @@ -37,3 +38,24 @@ fun Context.loadAsset(url: String, mimeType: String = "application/javascript"): } val emptyResponse = WebResourceResponse("text/html", Charsets.UTF_8.toString(), "".byteInputStream()) + +fun Uri.appendQueryParameter(key: String, value: String): Uri { + return with(buildUpon()) { + appendQueryParameter(key, value) + build() + } +} + +fun Uri.replaceQueryParameter(key: String, value: String): Uri { + return with(buildUpon()) { + clearQuery() + queryParameterNames.forEach { + if (it != key) { + appendQueryParameter(it, getQueryParameter(it)) + } else { + appendQueryParameter(key, value) + } + } + build() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/WebappFunctionChannel.kt b/app/src/main/java/org/jellyfin/mobile/webapp/WebappFunctionChannel.kt index cc7f47be..ebb49346 100644 --- a/app/src/main/java/org/jellyfin/mobile/webapp/WebappFunctionChannel.kt +++ b/app/src/main/java/org/jellyfin/mobile/webapp/WebappFunctionChannel.kt @@ -20,6 +20,7 @@ class WebappFunctionChannel { fun goBack() = call("$NAVIGATION_HELPER.goBack();") // ExoPlayer helpers + fun exoPlayerChangeBitrate(bitrate: Long) = call("$EXO_PLAYER.changeBitrate($bitrate);") fun exoPlayerNotifyStopped() = call("$EXO_PLAYER.notifyStopped();") fun exoPlayerUpdateProgress(position: Long) = call("$EXO_PLAYER._currentTime = $position;") diff --git a/app/src/main/res/drawable/ic_settings_white_24dp.xml b/app/src/main/res/drawable/ic_settings_white_24dp.xml new file mode 100644 index 00000000..8cc3d19a --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml index 5416ec2e..01e15b15 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -155,6 +155,19 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@id/audio_streams_button" /> + + + app:layout_constraintStart_toEndOf="@id/quality_button" /> None Play method: %s Transcoding: %b + Max bitrate: %s Video streams Audio streams …and %d more - Invalid play method. Playback tracking not supported in selected external player yet. Contact developers to try to add support for it. No valid external player selected. Playback tracking disabled. Unknown error in external player. Playback tracking disabled.