mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-10-26 16:45:26 +00:00
Add bitrate/quality menu to ExoPlayer
Allow transcoding in ExternalPlayer And a bit of everything else...
This commit is contained in:
1 parent
f2941be316
commit
7ef11319e9
22 files changed
+613
-71
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<PlayerEvent>() }
|
||||
single { QualityOptions() }
|
||||
|
||||
// Controllers
|
||||
single { ServerController(get(), get(), get(), get()) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "{}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ class ExoPlayerCodec(codecCapabilities: CodecCapabilities) {
|
||||
val isAudio: Boolean
|
||||
private val profiles: MutableList<String> = ArrayList()
|
||||
private val levels: MutableList<Int> = 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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<ExoPlayerTrack.Video>
|
||||
val audioTracksGroup: ExoPlayerTracksGroup<ExoPlayerTrack.Audio>
|
||||
val subtitleTracksGroup: ExoPlayerTracksGroup<ExoPlayerTrack.Text>
|
||||
val qualityTracksGroup: ExoPlayerTracksGroup<ExoPlayerTrack.Quality>
|
||||
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<Int, String>().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<JSONObject>): ExoPlayerTracksGroup<ExoPlayerTrack.Video> {
|
||||
@@ -115,4 +142,19 @@ class JellyfinMediaSource(item: JSONObject) {
|
||||
}
|
||||
return ExoPlayerTracksGroup(currentSelection, subtitleTracks)
|
||||
}
|
||||
|
||||
private fun loadQualityTracks(tracks: JSONArray): ExoPlayerTracksGroup<ExoPlayerTrack.Quality> {
|
||||
val qualityTracks: MutableList<ExoPlayerTrack.Quality> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<Int, Int> = 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))
|
||||
}
|
||||
|
||||
@@ -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<QualityOption> {
|
||||
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<QualityOption>()
|
||||
|
||||
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<QualityOption> {
|
||||
val qualityOptions = mutableListOf<QualityOption>()
|
||||
|
||||
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<QualityOption>.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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;")
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
|
||||
</vector>
|
||||
@@ -155,6 +155,19 @@
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/audio_streams_button" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/quality_button"
|
||||
android:layout_width="@dimen/exo_bottom_controls_size"
|
||||
android:layout_height="@dimen/exo_bottom_controls_size"
|
||||
android:layout_marginBottom="@dimen/exo_bottom_controls_margin"
|
||||
android:background="@drawable/ripple_background_circular"
|
||||
android:padding="@dimen/exo_bottom_controls_padding"
|
||||
android:src="@drawable/ic_settings_white_24dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/subtitles_button"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/info_button"
|
||||
android:layout_width="@dimen/exo_bottom_controls_size"
|
||||
@@ -164,7 +177,7 @@
|
||||
android:padding="@dimen/exo_bottom_controls_padding"
|
||||
android:src="@drawable/ic_info_white_24dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/subtitles_button" />
|
||||
app:layout_constraintStart_toEndOf="@id/quality_button" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/fullscreen_switcher"
|
||||
|
||||
@@ -42,11 +42,11 @@
|
||||
<string name="menu_item_none">None</string>
|
||||
<string name="playback_info_play_method">Play method: %s</string>
|
||||
<string name="playback_info_transcoding">Transcoding: %b</string>
|
||||
<string name="playback_info_max_bitrate">Max bitrate: %s</string>
|
||||
<string name="playback_info_video_streams">Video streams</string>
|
||||
<string name="playback_info_audio_streams">Audio streams</string>
|
||||
<string name="playback_info_and_x_more">…and %d more</string>
|
||||
|
||||
<string name="external_player_invalid_play_method">Invalid play method.</string>
|
||||
<string name="external_player_not_supported_yet">Playback tracking not supported in selected external player yet. Contact developers to try to add support for it.</string>
|
||||
<string name="external_player_invalid_player">No valid external player selected. Playback tracking disabled.</string>
|
||||
<string name="external_player_unknown_error">Unknown error in external player. Playback tracking disabled.</string>
|
||||
|
||||
Reference in new issue
Block a user