Source Code added
Some checks are pending
Repo / Label merge conflict / Triage (push) Waiting to run

This commit is contained in:
Fr4nz D13trich 2026-02-02 14:56:38 +01:00
parent ac679f452a
commit 3f20680501
477 changed files with 25051 additions and 2 deletions

View file

@ -0,0 +1,8 @@
<gradient xmlns:android="http://schemas.android.com/apk/res/android"
android:endColor="#fdc92f"
android:endX="752"
android:endY="692"
android:startColor="#f2364d"
android:startX="366"
android:startY="469"
android:type="linear" />

View file

@ -0,0 +1,46 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="252dp"
android:height="72dp"
android:viewportWidth="252"
android:viewportHeight="72">
<path android:pathData="M24.71,49.16c-1.55,-3.12 8.63,-21.57 11.79,-21.57 3.17,0 13.32,18.49 11.79,21.57 -1.53,3.08 -22.02,3.12 -23.58,0z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="72.5"
android:endY="63"
android:startX="12.5"
android:startY="30"
android:tileMode="clamp">
<item
android:color="#F2364D"
android:offset="0" />
<item
android:color="#FDC92F"
android:offset="1" />
</gradient>
</aapt:attr>
</path>
<path
android:fillType="evenOdd"
android:pathData="M0.98,65C-3.69,55.61 26.98,0 36.5,0c9.53,0 40.15,55.71 35.53,65s-66.37,9.39 -71.04,0m12.26,-8.15c3.07,6.15 43.52,6.08 46.55,0 3.03,-6.09 -17.03,-42.59 -23.27,-42.59S10.17,50.69 13.23,56.85z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="72.5"
android:endY="63"
android:startX="12.5"
android:startY="30"
android:tileMode="clamp">
<item
android:color="#F2364D"
android:offset="0" />
<item
android:color="#FDC92F"
android:offset="1" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFF"
android:pathData="M143.24,14.25c-0.28,0 -0.42,0 -0.52,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53L142.44,55.2c0,0.28 0,0.42 0.05,0.53 0.05,0.09 0.13,0.17 0.22,0.22 0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L149.67,15.06c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM154.86,14.25c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53L154.06,55.2c0,0.28 0,0.42 0.05,0.53 0.05,0.09 0.12,0.17 0.22,0.22 0.11,0.05 0.25,0.05 0.53,0.05h5.62c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L161.28,15.06c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM208.55,14.25q-5.18,0 -8.27,2.81 -3.09,2.81 -3.09,7.94L197.19,26h-10.17c-0.3,0 -0.45,0 -0.58,0.05a0.75,0.75 0,0 0,-0.29 0.21c-0.09,0.1 -0.14,0.24 -0.25,0.52l-7.43,19.9 -7.48,-19.9c-0.1,-0.28 -0.16,-0.42 -0.25,-0.52a0.76,0.76 0,0 0,-0.3 -0.2c-0.13,-0.05 -0.28,-0.05 -0.58,-0.05h-5.77c-0.39,0 -0.59,0 -0.72,0.08a0.5,0.5 0,0 0,-0.21 0.31c-0.03,0.15 0.04,0.33 0.19,0.7L174.78,56l-0.66,1.6q-0.88,1.87 -2.04,3.03 -1.1,1.16 -3.58,1.16 -0.88,0 -1.88,-0.17a13,13 0,0 1,-0.73 -0.1c-0.39,-0.06 -0.58,-0.1 -0.71,-0.05a0.47,0.47 0,0 0,-0.26 0.22c-0.07,0.12 -0.07,0.3 -0.07,0.66v4.33c0,0.24 0,0.37 0.05,0.48a0.74,0.74 0,0 0,0.19 0.27c0.09,0.08 0.19,0.12 0.39,0.18a8,8 0,0 0,1.47 0.35q1.16,0.22 2.37,0.22 4.24,0 7.06,-2.43 2.87,-2.37 4.58,-6.73l10.52,-26.58h5.72v22.75c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.52,0.05h5.63c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L204.42,32.45h5.87c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L211.09,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.52,-0.05h-5.87v-0.99q0,-2.26 1.32,-3.31 1.38,-1.1 3.75,-1.1 0.46,0 0.94,0.05c0.34,0.03 0.52,0.05 0.63,-0a0.48,0.48 0,0 0,0.24 -0.22c0.06,-0.11 0.06,-0.28 0.06,-0.6v-4.43c0,-0.3 0,-0.46 -0.06,-0.59a0.7,0.7 0,0 0,-0.25 -0.29c-0.12,-0.08 -0.26,-0.1 -0.54,-0.13a14,14 0,0 0,-1.97 -0.13zM99.46,14.92c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.52v27.82q0,2.54 -1.6,4.08 -1.54,1.49 -4.19,1.49h-1.57c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v5.29c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h1.57q4.08,0 7.06,-1.6 3.03,-1.6 4.63,-4.47 1.65,-2.87 1.66,-6.67L106.22,15.72c0,-0.28 -0,-0.42 -0.06,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM213.99,14.92c-0.16,0 -0.26,0.01 -0.34,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.52v6.12c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53v-6.12c0,-0.28 0,-0.42 -0.05,-0.52a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.52,-0.05zM124.56,25.34q-4.19,0 -7.61,2.04 -3.36,2.04 -5.35,5.57 -1.93,3.47 -1.93,8 0,4.36 1.93,7.94c1.93,3.59 3.09,4.28 5.4,5.68q3.47,2.1 8.11,2.1 4.58,0 8,-2.04 3.09,-1.87 4.53,-4.61c0.11,-0.22 0.17,-0.33 0.17,-0.45a0.53,0.53 0,0 0,-0.1 -0.3c-0.07,-0.1 -0.19,-0.16 -0.43,-0.27l-4.27,-2.09c-0.31,-0.15 -0.47,-0.23 -0.61,-0.24a0.6,0.6 0,0 0,-0.35 0.08c-0.12,0.07 -0.24,0.22 -0.48,0.54a8.2,8.2 0,0 1,-2.21 1.99q-1.71,1.05 -4.19,1.05 -3.26,0 -5.46,-1.98 -2.2,-1.99 -2.54,-5.3h21.06c0.2,0 0.3,0 0.39,-0.04a0.55,0.55 0,0 0,0.21 -0.17c0.06,-0.08 0.08,-0.17 0.12,-0.33q0.09,-0.39 0.12,-0.84 0.11,-0.83 0.11,-1.65 0,-4.03 -1.71,-7.33t-4.96,-5.3q-3.26,-2.04 -7.94,-2.04m115.64,0q-2.81,0 -5.07,1.1a7.9,7.9 0,0 0,-3.42 3.25L231.71,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05h-5.18c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v28.4c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.62c0.28,0 0.42,0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53v-16.79q0,-2.92 1.65,-4.69 1.71,-1.77 4.41,-1.77 2.7,0 4.36,1.77 1.71,1.71 1.71,4.69v16.79c0,0.28 -0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.06,-0.25 0.06,-0.53v-18.5q-0,-3.37 -1.44,-5.9a10.1,10.1 0,0 0,-4.03 -4.03q-2.54,-1.43 -5.85,-1.43M214.18,26c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v28.4c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L220.61,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM124.56,31.3q2.87,0 4.74,1.76 1.93,1.71 2.15,4.47h-14.12q0.61,-2.98 2.54,-4.58 1.99,-1.65 4.69,-1.65" />
</vector>

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path android:pathData="m512 284.789466c-57.344 0-241.850182 334.568734-213.736727 391.074914 28.113454 56.50618 399.639277 55.85454 427.473457 0 27.83418-55.85455-156.39273-391.074914-213.73673-391.074914zm140.10182 342.109094c-18.24582 36.58473-261.67855 37.05018-280.11055 0-18.431997-37.05018 102.49309-256.27928 140.00873-256.27928s158.34764 219.60146 140.10182 256.27928zm-140.10182-176.128c-18.99055 0-80.24436 111.05745-70.93527 129.76873 9.30909 18.71127 132.65454 18.52509 141.87054 0s-51.85163-129.76873-70.93527-129.76873z">
<aapt:attr name="android:fillColor">
<gradient
android:endColor="#FDC92F"
android:endX="752"
android:endY="692"
android:startColor="#F2364D"
android:startX="366"
android:startY="469"
android:type="linear" />
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Jellyfin Debug</string>
</resources>

View file

@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<meta-data
android:name="com.google.android.gms.version"
tools:node="remove" />
</application>
</manifest>

View file

@ -0,0 +1,10 @@
package org.jellyfin.mobile.player.cast
import com.google.android.exoplayer2.Player
import org.jellyfin.mobile.player.audio.MediaService
class CastPlayerProvider(@Suppress("UNUSED_PARAMETER") mediaService: MediaService) : ICastPlayerProvider {
override val isCastSessionAvailable: Boolean = false
override fun get(): Player? = null
}

View file

@ -0,0 +1,11 @@
package org.jellyfin.mobile.player.cast
import android.app.Activity
import org.jellyfin.mobile.bridge.JavascriptCallback
import org.json.JSONArray
class Chromecast : IChromecast {
override fun initializePlugin(activity: Activity) = Unit
override fun execute(action: String, args: JSONArray, cbContext: JavascriptCallback) = false
override fun destroy() = Unit
}

View file

@ -0,0 +1,108 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30"
android:required="false" />
<uses-permission
android:name="android.permission.BLUETOOTH_CONNECT"
android:required="false"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage"
tools:node="remove" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
<package android:name="com.mxtech.videoplayer.ad" />
<package android:name="com.mxtech.videoplayer.pro" />
<package android:name="is.xyz.mpv" />
<package android:name="org.videolan.vlc" />
</queries>
<application
android:name=".JellyfinApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_content"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme.Starting"
tools:ignore="UnusedAttribute">
<activity
android:name=".MainActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden|navigation|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:supportsPictureInPicture="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- declare legacy support for voice actions -->
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".webapp.RemotePlayerService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
</service>
<service
android:name="org.jellyfin.mobile.player.audio.MediaService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true"
tools:ignore="ExportedReceiver" />
<meta-data
android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version" />
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="org.jellyfin.mobile.player.cast.CastOptionsProvider" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@mipmap/ic_launcher_round" />
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="false" />
</application>
</manifest>

View file

@ -0,0 +1,442 @@
/*!
* EventEmitter v4.2.11 - git.io/ee
* Unlicense - http://unlicense.org/
* Oliver Caldwell - http://oli.me.uk/
* @preserve
*/
(function () {
'use strict';
/**
* Class for managing events.
* Can be extended to provide event functionality in other classes.
*
* @class EventEmitter Manages event registering and emitting.
*/
function EventEmitter () {}
// Shortcuts to improve speed and size
var proto = EventEmitter.prototype;
/**
* Finds the index of the listener for the event in its storage array.
*
* @param {Function[]} listeners Array of listeners to search through.
* @param {Function} listener Method to look for.
* @return {Number} Index of the specified listener, -1 if not found
* @api private
*/
function indexOfListener (listeners, listener) {
var i = listeners.length;
while (i--) {
if (listeners[i].listener === listener) {
return i;
}
}
return -1;
}
/**
* Alias a method while keeping the context correct, to allow for overwriting of target method.
*
* @param {String} name The name of the target method.
* @return {Function} The aliased method
* @api private
*/
function alias (name) {
return function aliasClosure () {
return this[name].apply(this, arguments);
};
}
/**
* Returns the listener array for the specified event.
* Will initialise the event object and listener arrays if required.
* Will return an object if you use a regex search. The object contains keys for each matched event. So /ba[rz]/ might return an object containing bar and baz. But only if you have either defined them with defineEvent or added some listeners to them.
* Each property in the object response is an array of listener functions.
*
* @param {String|RegExp} evt Name of the event to return the listeners from.
* @return {Function[]|Object} All listener functions for the event.
*/
proto.getListeners = function getListeners (evt) {
var events = this._getEvents();
var response;
var key;
// Return a concatenated array of all matching events if
// the selector is a regular expression.
if (evt instanceof RegExp) {
response = {};
for (key in events) {
if (events.hasOwnProperty(key) && evt.test(key)) {
response[key] = events[key];
}
}
} else {
response = events[evt] || (events[evt] = []);
}
return response;
};
/**
* Takes a list of listener objects and flattens it into a list of listener functions.
*
* @param {Object[]} listeners Raw listener objects.
* @return {Function[]} Just the listener functions.
*/
proto.flattenListeners = function flattenListeners (listeners) {
var flatListeners = [];
var i;
for (i = 0; i < listeners.length; i += 1) {
flatListeners.push(listeners[i].listener);
}
return flatListeners;
};
/**
* Fetches the requested listeners via getListeners but will always return the results inside an object. This is mainly for internal use but others may find it useful.
*
* @param {String|RegExp} evt Name of the event to return the listeners from.
* @return {Object} All listener functions for an event in an object.
*/
proto.getListenersAsObject = function getListenersAsObject (evt) {
var listeners = this.getListeners(evt);
var response;
if (listeners instanceof Array) {
response = {};
response[evt] = listeners;
}
return response || listeners;
};
/**
* Adds a listener function to the specified event.
* The listener will not be added if it is a duplicate.
* If the listener returns true then it will be removed after it is called.
* If you pass a regular expression as the event name then the listener will be added to all events that match it.
*
* @param {String|RegExp} evt Name of the event to attach the listener to.
* @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.addListener = function addListener (evt, listener) {
var listeners = this.getListenersAsObject(evt);
var listenerIsWrapped = typeof listener === 'object';
var key;
for (key in listeners) {
if (listeners.hasOwnProperty(key) && indexOfListener(listeners[key], listener) === -1) {
listeners[key].push(listenerIsWrapped ? listener : {
listener: listener,
once: false
});
}
}
return this;
};
/**
* Alias of addListener
*/
proto.on = alias('addListener');
/**
* Semi-alias of addListener. It will add a listener that will be
* automatically removed after its first execution.
*
* @param {String|RegExp} evt Name of the event to attach the listener to.
* @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.addOnceListener = function addOnceListener (evt, listener) {
return this.addListener(evt, {
listener: listener,
once: true
});
};
/**
* Alias of addOnceListener.
*/
proto.once = alias('addOnceListener');
/**
* Defines an event name. This is required if you want to use a regex to add a listener to multiple events at once. If you don't do this then how do you expect it to know what event to add to? Should it just add to every possible match for a regex? No. That is scary and bad.
* You need to tell it what event names should be matched by a regex.
*
* @param {String} evt Name of the event to create.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.defineEvent = function defineEvent (evt) {
this.getListeners(evt);
return this;
};
/**
* Uses defineEvent to define multiple events.
*
* @param {String[]} evts An array of event names to define.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.defineEvents = function defineEvents (evts) {
for (var i = 0; i < evts.length; i += 1) {
this.defineEvent(evts[i]);
}
return this;
};
/**
* Removes a listener function from the specified event.
* When passed a regular expression as the event name, it will remove the listener from all events that match it.
*
* @param {String|RegExp} evt Name of the event to remove the listener from.
* @param {Function} listener Method to remove from the event.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.removeListener = function removeListener (evt, listener) {
var listeners = this.getListenersAsObject(evt);
var index;
var key;
for (key in listeners) {
if (listeners.hasOwnProperty(key)) {
index = indexOfListener(listeners[key], listener);
if (index !== -1) {
listeners[key].splice(index, 1);
}
}
}
return this;
};
/**
* Alias of removeListener
*/
proto.off = alias('removeListener');
/**
* Adds listeners in bulk using the manipulateListeners method.
* If you pass an object as the second argument you can add to multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. You can also pass it an event name and an array of listeners to be added.
* You can also pass it a regular expression to add the array of listeners to all events that match it.
* Yeah, this function does quite a bit. That's probably a bad thing.
*
* @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add to multiple events at once.
* @param {Function[]} [listeners] An optional array of listener functions to add.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.addListeners = function addListeners (evt, listeners) {
// Pass through to manipulateListeners
return this.manipulateListeners(false, evt, listeners);
};
/**
* Removes listeners in bulk using the manipulateListeners method.
* If you pass an object as the second argument you can remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays.
* You can also pass it an event name and an array of listeners to be removed.
* You can also pass it a regular expression to remove the listeners from all events that match it.
*
* @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to remove from multiple events at once.
* @param {Function[]} [listeners] An optional array of listener functions to remove.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.removeListeners = function removeListeners (evt, listeners) {
// Pass through to manipulateListeners
return this.manipulateListeners(true, evt, listeners);
};
/**
* Edits listeners in bulk. The addListeners and removeListeners methods both use this to do their job. You should really use those instead, this is a little lower level.
* The first argument will determine if the listeners are removed (true) or added (false).
* If you pass an object as the second argument you can add/remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays.
* You can also pass it an event name and an array of listeners to be added/removed.
* You can also pass it a regular expression to manipulate the listeners of all events that match it.
*
* @param {Boolean} remove True if you want to remove listeners, false if you want to add.
* @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add/remove from multiple events at once.
* @param {Function[]} [listeners] An optional array of listener functions to add/remove.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.manipulateListeners = function manipulateListeners (remove, evt, listeners) {
var i;
var value;
var single = remove ? this.removeListener : this.addListener;
var multiple = remove ? this.removeListeners : this.addListeners;
// If evt is an object then pass each of its properties to this method
if (typeof evt === 'object' && !(evt instanceof RegExp)) {
for (i in evt) {
if (evt.hasOwnProperty(i) && (value = evt[i])) {
// Pass the single listener straight through to the singular method
if (typeof value === 'function') {
single.call(this, i, value);
} else {
// Otherwise pass back to the multiple function
multiple.call(this, i, value);
}
}
}
} else {
// So evt must be a string
// And listeners must be an array of listeners
// Loop over it and pass each one to the multiple method
i = listeners.length;
while (i--) {
single.call(this, evt, listeners[i]);
}
}
return this;
};
/**
* Removes all listeners from a specified event.
* If you do not specify an event then all listeners will be removed.
* That means every event will be emptied.
* You can also pass a regex to remove all events that match it.
*
* @param {String|RegExp} [evt] Optional name of the event to remove all listeners for. Will remove from every event if not passed.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.removeEvent = function removeEvent (evt) {
var type = typeof evt;
var events = this._getEvents();
var key;
// Remove different things depending on the state of evt
if (type === 'string') {
// Remove all listeners for the specified event
delete events[evt];
} else if (evt instanceof RegExp) {
// Remove all events matching the regex.
for (key in events) {
if (events.hasOwnProperty(key) && evt.test(key)) {
delete events[key];
}
}
} else {
// Remove all listeners in all events
delete this._events;
}
return this;
};
/**
* Alias of removeEvent.
*
* Added to mirror the node API.
*/
proto.removeAllListeners = alias('removeEvent');
/**
* Emits an event of your choice.
* When emitted, every listener attached to that event will be executed.
* If you pass the optional argument array then those arguments will be passed to every listener upon execution.
* Because it uses `apply`, your array of arguments will be passed as if you wrote them out separately.
* So they will not arrive within the array on the other side, they will be separate.
* You can also pass a regular expression to emit to all events that match it.
*
* @param {String|RegExp} evt Name of the event to emit and execute listeners for.
* @param {Array} [args] Optional array of arguments to be passed to each listener.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.emitEvent = function emitEvent (evt, args) {
var listeners = this.getListenersAsObject(evt);
var listener;
var i;
var key;
var response;
for (key in listeners) {
if (listeners.hasOwnProperty(key)) {
i = listeners[key].length;
while (i--) {
// If the listener returns true then it shall be removed from the event
// The function is executed either with a basic call or an apply if there is an args array
listener = listeners[key][i];
if (listener.once === true) {
this.removeListener(evt, listener.listener);
}
response = listener.listener.apply(this, args || []);
if (response === this._getOnceReturnValue()) {
this.removeListener(evt, listener.listener);
}
}
}
}
return this;
};
/**
* Alias of emitEvent
*/
proto.trigger = alias('emitEvent');
/**
* Subtly different from emitEvent in that it will pass its arguments on to the listeners, as opposed to taking a single array of arguments to pass on.
* As with emitEvent, you can pass a regex in place of the event name to emit to all events that match it.
*
* @param {String|RegExp} evt Name of the event to emit and execute listeners for.
* @param {...*} Optional additional arguments to be passed to each listener.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.emit = function emit (evt) {
var args = Array.prototype.slice.call(arguments, 1);
return this.emitEvent(evt, args);
};
/**
* Sets the current value to check against when executing listeners. If a
* listeners return value matches the one set here then it will be removed
* after execution. This value defaults to true.
*
* @param {*} value The new value to check for when executing listeners.
* @return {Object} Current instance of EventEmitter for chaining.
*/
proto.setOnceReturnValue = function setOnceReturnValue (value) {
this._onceReturnValue = value;
return this;
};
/**
* Fetches the current value to check against when executing listeners. If
* the listeners return value matches this one then it should be removed
* automatically. It will return true by default.
*
* @return {*|Boolean} The current value to check for or the default, true.
* @api private
*/
proto._getOnceReturnValue = function _getOnceReturnValue () {
if (this.hasOwnProperty('_onceReturnValue')) {
return this._onceReturnValue;
} else {
return true;
}
};
/**
* Fetches the events object and creates one if required.
*
* @return {Object} The events storage object.
* @api private
*/
proto._getEvents = function _getEvents () {
return this._events || (this._events = {});
};
window.CastPluginEventEmitter = EventEmitter;
}());

View file

@ -0,0 +1,179 @@
export class ExoPlayerPlugin {
constructor({ events, playbackManager, loading }) {
window['ExoPlayer'] = this;
this.events = events;
this.playbackManager = playbackManager;
this.loading = loading;
this.name = 'ExoPlayer';
this.type = 'mediaplayer';
this.id = 'exoplayer';
// Prioritize first
this.priority = -1;
this.isLocalPlayer = true;
// Current playback position in milliseconds
this._currentTime = 0;
this._paused = true;
this._nativePlayer = window['NativePlayer'];
}
async play(options) {
// Sanitize input
options.ids = options.items.map(item => item.Id);
delete options.items;
this._paused = false;
this._nativePlayer.loadPlayer(JSON.stringify(options));
this.loading.hide();
}
shuffle(item) {}
instantMix(item) {}
queue(options) {}
queueNext(options) {}
canPlayMediaType(mediaType) {
return mediaType === 'Video';
}
canQueueMediaType(mediaType) {
return this.canPlayMediaType(mediaType);
}
canPlayItem(item, playOptions) {
return this._nativePlayer.isEnabled() &&
playOptions.fullscreen &&
!this.playbackManager.syncPlayEnabled;
}
async stop(destroyPlayer) {
this._nativePlayer.stopPlayer();
if (destroyPlayer) {
this.destroy();
}
}
nextTrack() {}
previousTrack() {}
seek(ticks) {
this._nativePlayer.seek(ticks);
}
currentTime(ms) {
if (ms !== undefined) {
this._nativePlayer.seekMs(ms);
}
return this._currentTime;
}
duration(val) {
return null;
}
/**
* Get or set volume percentage as as string
*/
volume(volume) {
if (volume !== undefined) {
this.setVolume(volume);
}
return null;
}
getVolume() {}
setVolume(vol) {
let volume = parseInt(vol);
this._nativePlayer.setVolume(volume);
}
volumeUp() {}
volumeDown() {}
isMuted() {
return false;
}
setMute(mute) {
// Assume 30% as default when unmuting
this._nativePlayer.setVolume(mute ? 0 : 30);
}
toggleMute() {}
paused() {
return this._paused;
}
pause() {
this._paused = true;
this._nativePlayer.pausePlayer();
}
unpause() {
this._paused = false;
this._nativePlayer.resumePlayer();
}
playPause() {
if (this._paused) {
this.unpause();
} else {
this.pause();
}
}
canSetAudioStreamIndex() {
return false;
}
setAudioStreamIndex(index) {}
setSubtitleStreamIndex(index) {}
async changeAudioStream(index) {}
async changeSubtitleStream(index) {}
getPlaylist() {
return Promise.resolve([]);
}
getCurrentPlaylistItemId() {}
setCurrentPlaylistItem() {
return Promise.resolve();
}
removeFromPlaylist() {
return Promise.resolve();
}
destroy() {
this._nativePlayer.destroyPlayer();
}
async getDeviceProfile() {
return {
Name: 'ExoPlayer Stub',
MaxStreamingBitrate: 100000000,
MaxStaticBitrate: 100000000,
MusicStreamingTranscodingBitrate: 320000,
DirectPlayProfiles: [{Type: 'Video'}, {Type: 'Audio'}],
CodecProfiles: [],
SubtitleProfiles: [],
TranscodingProfiles: []
};
}
}

View file

@ -0,0 +1,152 @@
export class ExternalPlayerPlugin {
constructor({ events, playbackManager }) {
window['ExtPlayer'] = this;
this.events = events;
this.playbackManager = playbackManager;
this.name = 'External Player';
this.type = 'mediaplayer';
this.id = 'externalplayer';
this.subtitleStreamIndex = -1;
this.audioStreamIndex = -1;
this.cachedDeviceProfile = null;
// Prioritize first
this.priority = -2;
this.supportsProgress = false;
this.isLocalPlayer = true;
// Disable orientation lock
this.isExternalPlayer = true;
// _currentTime is in milliseconds
this._currentTime = 0;
this._paused = true;
this._volume = 100;
this._currentSrc = null;
this._isIntro = false;
this._externalPlayer = window['ExternalPlayer'];
}
canPlayMediaType(mediaType) {
return mediaType === 'Video';
}
canPlayItem(item, playOptions) {
return this._externalPlayer.isEnabled() &&
playOptions.fullscreen &&
!this.playbackManager.syncPlayEnabled;
}
currentSrc() {
return this._currentSrc;
}
async play(options) {
this._currentTime = options.playerStartPositionTicks / 10000 || 0;
this._paused = false;
this._currentSrc = options.url;
this._isIntro = options.item && options.item.ProviderIds && options.item.ProviderIds.hasOwnProperty("prerolls.video");
const playOptions = options.item.playOptions;
playOptions.ids = options.item ? [options.item.Id] : [];
this._externalPlayer.initPlayer(JSON.stringify(playOptions));
}
setSubtitleStreamIndex(index) { }
canSetAudioStreamIndex() {
return false;
}
setAudioStreamIndex(index) {
}
duration(val) {
return null;
}
destroy() { }
pause() { }
unpause() { }
paused() {
return this._paused;
}
async stop(destroyPlayer) {
if (destroyPlayer) {
this.destroy();
}
}
volume(val) {
return this._volume;
}
setMute(mute) {
}
isMuted() {
return this._volume == 0;
}
async notifyEnded() {
let stopInfo = {
src: this._currentSrc
};
this.playbackManager._playNextAfterEnded = this._isIntro;
this.events.trigger(this, 'stopped', [stopInfo]);
this._currentSrc = this._currentTime = null;
}
async notifyTimeUpdate(currentTime) {
// Use duration (as if playback completed) if no time is provided
currentTime = currentTime || this.playbackManager.duration(this) / 10000;
this._timeUpdated = this._currentTime != currentTime;
this._currentTime = currentTime;
this.events.trigger(this, 'timeupdate');
}
notifyCanceled() {
// required to not mark an item as seen / completed without time changes
let currentTime = this._currentTime || 0;
this.notifyTimeUpdate(currentTime - 1);
if (currentTime > 0) {
this.notifyTimeUpdate(currentTime);
}
this.notifyEnded();
}
/**
* Returns the currently known player time in milliseconds
*/
currentTime() {
return this._currentTime || 0;
}
async changeSubtitleStream(index) {
var innerIndex = Number(index);
this.subtitleStreamIndex = innerIndex;
}
async changeAudioStream(index) {
var innerIndex = Number(index);
this.audioStreamIndex = innerIndex;
}
async getDeviceProfile() {
return {
Name: 'Android External Player Stub',
MaxStreamingBitrate: 1_000_000_000,
MaxStaticBitrate: 1_000_000_000,
DirectPlayProfiles: [{Type: 'Video'}, {Type: 'Audio'}],
CodecProfiles: [],
SubtitleProfiles: [{Method: 'Embed'}, {Method: 'External'}, {Method: 'Drop'}],
TranscodingProfiles: []
};
}
}

View file

@ -0,0 +1,16 @@
export class NavigationPlugin {
constructor({ playbackManager }) {
window['NavigationHelper'] = this;
this.playbackManager = playbackManager;
}
goBack() {
var appRouter = window['Emby']['Page'];
if (appRouter.canGoBack()) {
appRouter.back();
} else {
window['NativeInterface'].exitApp();
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
(() => {
const scripts = [
'/native/nativeshell.js',
'/native/EventEmitter.js',
document.currentScript.src.concat('?deferred=true&ts=', Date.now())
];
for (const script of scripts) {
const scriptElement = document.createElement('script');
scriptElement.src = script;
scriptElement.charset = 'utf-8';
scriptElement.setAttribute('defer', '');
document.body.appendChild(scriptElement);
}
document.currentScript.remove();
})();

View file

@ -0,0 +1,189 @@
const features = [
"filedownload",
"displaylanguage",
"subtitleappearancesettings",
"subtitleburnsettings",
//'sharing',
"exit",
"htmlaudioautoplay",
"htmlvideoautoplay",
"externallinks",
"clientsettings",
"multiserver",
"physicalvolumecontrol",
"remotecontrol",
"castmenuhashchange"
];
const plugins = [
'NavigationPlugin',
'ExoPlayerPlugin',
'ExternalPlayerPlugin'
];
// Add plugin loaders
for (const plugin of plugins) {
window[plugin] = async () => {
const pluginDefinition = await import(`/native/${plugin}.js`);
return pluginDefinition[plugin];
};
}
let deviceId;
let deviceName;
let appName;
let appVersion;
window.NativeShell = {
enableFullscreen() {
window.NativeInterface.enableFullscreen();
},
disableFullscreen() {
window.NativeInterface.disableFullscreen();
},
openUrl(url, target) {
window.NativeInterface.openUrl(url);
},
updateMediaSession(mediaInfo) {
window.NativeInterface.updateMediaSession(JSON.stringify(mediaInfo));
},
hideMediaSession() {
window.NativeInterface.hideMediaSession();
},
updateVolumeLevel(value) {
window.NativeInterface.updateVolumeLevel(value);
},
downloadFile(downloadInfo) {
window.NativeInterface.downloadFiles(JSON.stringify([downloadInfo]));
},
downloadFiles(downloadInfo) {
window.NativeInterface.downloadFiles(JSON.stringify(downloadInfo));
},
openClientSettings() {
window.NativeInterface.openClientSettings();
},
selectServer() {
window.NativeInterface.openServerSelection();
},
getPlugins() {
return plugins;
},
async execCast(action, args, callback) {
this.castCallbacks = this.castCallbacks || {};
this.castCallbacks[action] = callback;
window.NativeInterface.execCast(action, JSON.stringify(args));
},
async castCallback(action, keep, err, result) {
const callbacks = this.castCallbacks || {};
const callback = callbacks[action];
callback && callback(err || null, result);
if (!keep) {
delete callbacks[action];
}
}
};
function getDeviceProfile(profileBuilder, item) {
const profile = profileBuilder({
enableMkvProgressive: false
});
profile.CodecProfiles = profile.CodecProfiles.filter(function (i) {
return i.Type === "Audio";
});
profile.CodecProfiles.push({
Type: "Video",
Container: "avi",
Conditions: [
{
Condition: "NotEquals",
Property: "VideoCodecTag",
Value: "xvid"
}
]
});
profile.CodecProfiles.push({
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "EqualsAny",
Property: "VideoProfile",
Value: "high|main|baseline|constrained baseline"
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "41"
}]
});
profile.TranscodingProfiles.reduce(function (profiles, p) {
if (p.Type === "Video" && p.CopyTimestamps === true && p.VideoCodec === "h264") {
p.AudioCodec += ",ac3";
profiles.push(p);
}
return profiles;
}, []);
return profile;
}
window.NativeShell.AppHost = {
init() {
try {
const result = JSON.parse(window.NativeInterface.getDeviceInformation());
// set globally so they can be used elsewhere
deviceId = result.deviceId;
deviceName = result.deviceName;
appName = result.appName;
appVersion = result.appVersion;
return Promise.resolve({
deviceId,
deviceName,
appName,
appVersion,
});
} catch (e) {
return Promise.reject(e);
}
},
getDefaultLayout() {
return "mobile";
},
supports(command) {
return features.includes(command.toLowerCase());
},
getDeviceProfile,
getSyncProfile: getDeviceProfile,
deviceName() {
return deviceName;
},
deviceId() {
return deviceId;
},
appName() {
return appName;
},
appVersion() {
return appVersion;
},
exit() {
window.NativeInterface.exitApp();
}
};

View file

@ -0,0 +1,41 @@
package org.jellyfin.mobile
import android.app.Application
import android.webkit.WebView
import org.jellyfin.mobile.app.apiModule
import org.jellyfin.mobile.app.applicationModule
import org.jellyfin.mobile.data.databaseModule
import org.jellyfin.mobile.utils.JellyTree
import org.jellyfin.mobile.utils.isWebViewSupported
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.fragment.koin.fragmentFactory
import org.koin.core.context.startKoin
import timber.log.Timber
@Suppress("unused")
class JellyfinApplication : Application() {
override fun onCreate() {
super.onCreate()
// Setup logging
Timber.plant(JellyTree())
if (BuildConfig.DEBUG) {
// Enable WebView debugging
if (isWebViewSupported()) {
WebView.setWebContentsDebuggingEnabled(true)
}
}
startKoin {
androidContext(this@JellyfinApplication)
fragmentFactory()
modules(
applicationModule,
apiModule,
databaseModule,
)
}
}
}

View file

@ -0,0 +1,207 @@
package org.jellyfin.mobile
import android.app.Service
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.view.OrientationEventListener
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.cast.Chromecast
import org.jellyfin.mobile.player.cast.IChromecast
import org.jellyfin.mobile.player.ui.PlayerFragment
import org.jellyfin.mobile.setup.ConnectFragment
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.BackPressInterceptor
import org.jellyfin.mobile.utils.BluetoothPermissionHelper
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.utils.SmartOrientationListener
import org.jellyfin.mobile.utils.extensions.replaceFragment
import org.jellyfin.mobile.utils.isWebViewSupported
import org.jellyfin.mobile.webapp.RemotePlayerService
import org.jellyfin.mobile.webapp.WebViewFragment
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.fragment.android.setupKoinFragmentFactory
import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : AppCompatActivity() {
private val activityEventHandler: ActivityEventHandler = get()
val mainViewModel: MainViewModel by viewModel()
val bluetoothPermissionHelper: BluetoothPermissionHelper = BluetoothPermissionHelper(this, get())
val chromecast: IChromecast = Chromecast()
private val permissionRequestHelper: PermissionRequestHelper by inject()
var serviceBinder: RemotePlayerService.ServiceBinder? = null
private set
private val serviceConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
serviceBinder = binder as? RemotePlayerService.ServiceBinder
}
override fun onServiceDisconnected(componentName: ComponentName) {
serviceBinder = null
}
}
private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(this) }
/**
* Passes back press events onto the currently visible [Fragment] if it implements the [BackPressInterceptor] interface.
*
* If the current fragment does not implement [BackPressInterceptor] or has decided not to intercept the event
* (see result of [BackPressInterceptor.onInterceptBackPressed]), the topmost backstack entry will be popped.
*
* If there is no topmost backstack entry, the event will be passed onto the dispatcher's fallback handler.
*/
private val onBackPressedCallback: OnBackPressedCallback.() -> Unit = callback@{
val currentFragment = supportFragmentManager.findFragmentById(R.id.fragment_container)
if (currentFragment is BackPressInterceptor && currentFragment.onInterceptBackPressed()) {
// Top fragment handled back press
return@callback
}
// This is the same default action as in Activity.onBackPressed
if (!supportFragmentManager.isStateSaved && supportFragmentManager.popBackStackImmediate()) {
// Removed fragment from back stack
return@callback
}
// Let the system handle the back press
isEnabled = false
// Make sure that we *really* call the fallback handler
assert(!onBackPressedDispatcher.hasEnabledCallbacks()) {
"MainActivity should be the lowest onBackPressCallback"
}
onBackPressedDispatcher.onBackPressed()
isEnabled = true // re-enable callback in case activity isn't finished
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
setupKoinFragmentFactory()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Check WebView support
if (!isWebViewSupported()) {
AlertDialog.Builder(this).apply {
setTitle(R.string.dialog_web_view_not_supported)
setMessage(R.string.dialog_web_view_not_supported_message)
setCancelable(false)
if (AndroidVersion.isAtLeastN) {
setNeutralButton(R.string.dialog_button_open_settings) { _, _ ->
startActivity(Intent(Settings.ACTION_WEBVIEW_SETTINGS))
Toast.makeText(context, R.string.toast_reopen_after_change, Toast.LENGTH_LONG).show()
finishAfterTransition()
}
}
setNegativeButton(R.string.dialog_button_close_app) { _, _ ->
finishAfterTransition()
}
}.show()
return
}
// Bind player service
bindService(Intent(this, RemotePlayerService::class.java), serviceConnection, Service.BIND_AUTO_CREATE)
// Subscribe to activity events
with(activityEventHandler) { subscribe() }
// Load UI
lifecycleScope.launch {
mainViewModel.serverState.collectLatest { state ->
lifecycle.withStarted {
handleServerState(state)
}
}
}
// Handle back presses
onBackPressedDispatcher.addCallback(this, onBackPressed = onBackPressedCallback)
// Setup Chromecast
chromecast.initializePlugin(this)
}
override fun onStart() {
super.onStart()
orientationListener.enable()
}
private fun handleServerState(state: ServerState) {
with(supportFragmentManager) {
val currentFragment = findFragmentById(R.id.fragment_container)
when (state) {
ServerState.Pending -> {
// TODO add loading indicator
}
is ServerState.Unset -> {
if (currentFragment !is ConnectFragment) {
replaceFragment<ConnectFragment>()
}
}
is ServerState.Available -> {
if (currentFragment !is WebViewFragment || currentFragment.server != state.server) {
replaceFragment<WebViewFragment>(
Bundle().apply {
putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server)
},
)
}
}
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionRequestHelper.handleRequestPermissionsResult(requestCode, permissions, grantResults)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
override fun onUserLeaveHint() {
for (fragment in supportFragmentManager.fragments) {
if (fragment is PlayerFragment && fragment.isVisible) {
fragment.onUserLeaveHint()
}
}
}
override fun onStop() {
super.onStop()
orientationListener.disable()
}
override fun onDestroy() {
unbindService(serviceConnection)
chromecast.destroy()
super.onDestroy()
}
}

View file

@ -0,0 +1,49 @@
package org.jellyfin.mobile
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.mobile.data.entity.ServerEntity
class MainViewModel(
app: Application,
private val apiClientController: ApiClientController,
) : AndroidViewModel(app) {
private val _serverState: MutableStateFlow<ServerState> = MutableStateFlow(ServerState.Pending)
val serverState: StateFlow<ServerState> get() = _serverState
init {
viewModelScope.launch {
refreshServer()
}
}
suspend fun switchServer(hostname: String) {
apiClientController.setupServer(hostname)
refreshServer()
}
private suspend fun refreshServer() {
val serverEntity = apiClientController.loadSavedServer()
_serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset
}
/**
* Temporarily unset the selected server to be able to connect to a different one
*/
fun resetServer() {
_serverState.value = ServerState.Unset
}
}
sealed class ServerState {
open val server: ServerEntity? = null
object Pending : ServerState()
object Unset : ServerState()
class Available(override val server: ServerEntity) : ServerState()
}

View file

@ -0,0 +1,88 @@
package org.jellyfin.mobile.app
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.mobile.data.dao.ServerDao
import org.jellyfin.mobile.data.dao.UserDao
import org.jellyfin.mobile.data.entity.ServerEntity
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.DeviceInfo
class ApiClientController(
private val appPreferences: AppPreferences,
private val jellyfin: Jellyfin,
private val apiClient: ApiClient,
private val serverDao: ServerDao,
private val userDao: UserDao,
) {
private val baseDeviceInfo: DeviceInfo
get() = jellyfin.options.deviceInfo!!
/**
* Store server with [hostname] in the database.
*/
suspend fun setupServer(hostname: String) {
appPreferences.currentServerId = withContext(Dispatchers.IO) {
serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname)
}
apiClient.update(baseUrl = hostname)
}
suspend fun setupUser(serverId: Long, userId: String, accessToken: String) {
appPreferences.currentUserId = withContext(Dispatchers.IO) {
userDao.upsert(serverId, userId, accessToken)
}
configureApiClientUser(userId, accessToken)
}
suspend fun loadSavedServer(): ServerEntity? {
val server = withContext(Dispatchers.IO) {
val serverId = appPreferences.currentServerId ?: return@withContext null
serverDao.getServer(serverId)
}
configureApiClientServer(server)
return server
}
suspend fun loadSavedServerUser() {
val serverUser = withContext(Dispatchers.IO) {
val serverId = appPreferences.currentServerId ?: return@withContext null
val userId = appPreferences.currentUserId ?: return@withContext null
userDao.getServerUser(serverId, userId)
}
configureApiClientServer(serverUser?.server)
if (serverUser?.user?.accessToken != null) {
configureApiClientUser(serverUser.user.userId, serverUser.user.accessToken)
} else {
resetApiClientUser()
}
}
suspend fun loadPreviouslyUsedServers(): List<ServerEntity> = withContext(Dispatchers.IO) {
serverDao.getAllServers().filterNot { server ->
server.id == appPreferences.currentServerId
}
}
private fun configureApiClientServer(server: ServerEntity?) {
apiClient.update(baseUrl = server?.hostname)
}
private fun configureApiClientUser(userId: String, accessToken: String) {
apiClient.update(
accessToken = accessToken,
// Append user id to device id to ensure uniqueness across sessions
deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId),
)
}
private fun resetApiClientUser() {
apiClient.update(
accessToken = null,
deviceInfo = baseDeviceInfo,
)
}
}

View file

@ -0,0 +1,19 @@
package org.jellyfin.mobile.app
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val apiModule = module {
// Jellyfin API builder and API client instance
single {
createJellyfin {
context = androidContext()
clientInfo = ClientInfo(name = Constants.APP_INFO_NAME, version = Constants.APP_INFO_VERSION)
}
}
single { get<Jellyfin>().createApi() }
}

View file

@ -0,0 +1,153 @@
package org.jellyfin.mobile.app
import android.content.Context
import androidx.core.net.toUri
import coil.ImageLoader
import com.google.android.exoplayer2.ext.cronet.CronetDataSource
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.extractor.ts.TsExtractor
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.SingleSampleMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.ResolvingDataSource
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import org.chromium.net.CronetProvider
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.bridge.NativePlayer
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.player.ui.PlayerFragment
import org.jellyfin.mobile.setup.ConnectionHelper
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.utils.isLowRamDevice
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.mobile.webapp.WebViewFragment
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.koin.android.ext.koin.androidApplication
import org.koin.androidx.fragment.dsl.fragment
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import java.util.concurrent.Executors
const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel"
private const val HTTP_CACHE_SIZE: Long = 16 * 1024 * 1024
private const val TS_SEARCH_PACKETS = 1800
val applicationModule = module {
single { AppPreferences(androidApplication()) }
single { OkHttpClient() }
single { ImageLoader(androidApplication()) }
single { PermissionRequestHelper() }
single { RemoteVolumeProvider(get()) }
single(named(PLAYER_EVENT_CHANNEL)) { Channel<PlayerEvent>() }
// Controllers
single { ApiClientController(get(), get(), get(), get(), get()) }
// Event handlers and channels
single { ActivityEventHandler(get()) }
single { WebappFunctionChannel() }
// Bridge interfaces
single { NativePlayer(get(), get(), get(named(PLAYER_EVENT_CHANNEL))) }
// ViewModels
viewModel { MainViewModel(get(), get()) }
// Fragments
fragment { WebViewFragment() }
fragment { PlayerFragment() }
// Connection helper
single { ConnectionHelper(get(), get()) }
// Media player helpers
single { MediaSourceResolver(get()) }
single { DeviceProfileBuilder(get()) }
single { QualityOptionsProvider() }
// ExoPlayer factories
single<DataSource.Factory> {
val context: Context = get()
val apiClient: ApiClient = get()
val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
(provider.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) && provider.isEnabled
}
val baseDataSourceFactory = if (provider != null) {
val cronetEngine = provider.createBuilder()
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_IN_MEMORY, HTTP_CACHE_SIZE)
.build()
CronetDataSource.Factory(cronetEngine, Executors.newCachedThreadPool()).apply {
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
}
} else {
DefaultHttpDataSource.Factory().apply {
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
}
}
val dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory)
// Add authorization header. This is needed as we don't pass the
// access token in the URL for Android Auto.
ResolvingDataSource.Factory(dataSourceFactory) { dataSpec: DataSpec ->
// Only send authorization header if URI matches the jellyfin server
val baseUrlAuthority = apiClient.baseUrl?.toUri()?.authority
if (dataSpec.uri.authority == baseUrlAuthority) {
val authorizationHeaderString = AuthorizationHeaderBuilder.buildHeader(
clientName = apiClient.clientInfo.name,
clientVersion = apiClient.clientInfo.version,
deviceId = apiClient.deviceInfo.id,
deviceName = apiClient.deviceInfo.name,
accessToken = apiClient.accessToken,
)
dataSpec.withRequestHeaders(hashMapOf("Authorization" to authorizationHeaderString))
} else {
dataSpec
}
}
}
single<MediaSource.Factory> {
val context: Context = get()
val extractorsFactory = DefaultExtractorsFactory().apply {
// https://github.com/google/ExoPlayer/issues/8571
setTsExtractorTimestampSearchBytes(
when {
!context.isLowRamDevice -> TS_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE // 3x default
else -> TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
},
)
}
DefaultMediaSourceFactory(get<DataSource.Factory>(), extractorsFactory)
}
single { ProgressiveMediaSource.Factory(get()) }
single { HlsMediaSource.Factory(get<DataSource.Factory>()) }
single { SingleSampleMediaSource.Factory(get()) }
// Media components
single { LibraryBrowser(get(), get()) }
}

View file

@ -0,0 +1,127 @@
package org.jellyfin.mobile.app
import android.content.Context
import android.content.SharedPreferences
import android.os.Environment
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import androidx.core.content.edit
import org.jellyfin.mobile.settings.ExternalPlayerPackage
import org.jellyfin.mobile.settings.VideoPlayerType
import org.jellyfin.mobile.utils.Constants
import java.io.File
class AppPreferences(context: Context) {
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
var currentServerId: Long?
get() = sharedPreferences.getLong(Constants.PREF_SERVER_ID, -1).takeIf { it >= 0 }
set(value) {
sharedPreferences.edit {
if (value != null) putLong(Constants.PREF_SERVER_ID, value) else remove(Constants.PREF_SERVER_ID)
}
}
var currentUserId: Long?
get() = sharedPreferences.getLong(Constants.PREF_USER_ID, -1).takeIf { it >= 0 }
set(value) {
sharedPreferences.edit {
if (value != null) putLong(Constants.PREF_USER_ID, value) else remove(Constants.PREF_USER_ID)
}
}
var ignoreBatteryOptimizations: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_BATTERY_OPTIMIZATIONS, false)
set(value) {
sharedPreferences.edit {
putBoolean(Constants.PREF_IGNORE_BATTERY_OPTIMIZATIONS, value)
}
}
var ignoreWebViewChecks: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_WEBVIEW_CHECKS, false)
set(value) {
sharedPreferences.edit {
putBoolean(Constants.PREF_IGNORE_WEBVIEW_CHECKS, value)
}
}
var ignoreBluetoothPermission: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, false)
set(value) {
sharedPreferences.edit {
putBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, value)
}
}
var downloadMethod: Int?
get() = sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1).takeIf { it >= 0 }
set(value) {
if (value != null) {
sharedPreferences.edit {
putInt(Constants.PREF_DOWNLOAD_METHOD, value)
}
}
}
var downloadLocation: String
get() {
val savedStorage = sharedPreferences.getString(Constants.PREF_DOWNLOAD_LOCATION, null)
if (savedStorage != null) {
if (File(savedStorage).parentFile?.isDirectory == true) {
// Saved location is still valid
return savedStorage
} else {
// Reset download option if corrupt
sharedPreferences.edit {
remove(Constants.PREF_DOWNLOAD_LOCATION)
}
}
}
// Return default storage location
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
}
set(value) {
sharedPreferences.edit {
if (File(value).parentFile?.isDirectory == true) {
putString(Constants.PREF_DOWNLOAD_LOCATION, value)
}
}
}
val musicNotificationAlwaysDismissible: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE, false)
@VideoPlayerType
val videoPlayerType: String
get() = sharedPreferences.getString(Constants.PREF_VIDEO_PLAYER_TYPE, VideoPlayerType.WEB_PLAYER)!!
val exoPlayerStartLandscapeVideoInLandscape: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE, false)
val exoPlayerAllowSwipeGestures: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES, true)
val exoPlayerRememberBrightness: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_REMEMBER_BRIGHTNESS, false)
var exoPlayerBrightness: Float
get() = sharedPreferences.getFloat(Constants.PREF_EXOPLAYER_BRIGHTNESS, BRIGHTNESS_OVERRIDE_NONE)
set(value) {
sharedPreferences.edit {
putFloat(Constants.PREF_EXOPLAYER_BRIGHTNESS, value)
}
}
val exoPlayerAllowBackgroundAudio: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO, false)
val exoPlayerDirectPlayAss: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS, false)
@ExternalPlayerPackage
var externalPlayerApp: String
get() = sharedPreferences.getString(Constants.PREF_EXTERNAL_PLAYER_APP, ExternalPlayerPackage.SYSTEM_DEFAULT)!!
set(value) = sharedPreferences.edit { putString(Constants.PREF_EXTERNAL_PLAYER_APP, value) }
}

View file

@ -0,0 +1,317 @@
package org.jellyfin.mobile.bridge
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.JavascriptInterface
import android.widget.Toast
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.text.isDigitsOnly
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.settings.ExternalPlayerPackage
import org.jellyfin.mobile.settings.VideoPlayerType
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.isPackageInstalled
import org.jellyfin.mobile.utils.toast
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.VideosApi
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.json.JSONObject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import timber.log.Timber
class ExternalPlayer(
private val context: Context,
lifecycleOwner: LifecycleOwner,
registry: ActivityResultRegistry,
) : KoinComponent {
private val coroutinesScope = MainScope()
private val appPreferences: AppPreferences by inject()
private val webappFunctionChannel: WebappFunctionChannel by inject()
private val mediaSourceResolver: MediaSourceResolver by inject()
private val deviceProfileBuilder: DeviceProfileBuilder by inject()
private val externalPlayerProfile: DeviceProfile = deviceProfileBuilder.getExternalPlayerProfile()
private val apiClient: ApiClient = get()
private val videosApi: VideosApi = apiClient.videosApi
private val playerContract = registry.register(
"externalplayer",
lifecycleOwner,
ActivityResultContracts.StartActivityForResult(),
) { result ->
val resultCode = result.resultCode
val intent = result.data
when (val action = intent?.action) {
Constants.MPV_PLAYER_RESULT_ACTION -> handleMPVPlayer(resultCode, intent)
Constants.MX_PLAYER_RESULT_ACTION -> handleMXPlayer(resultCode, intent)
Constants.VLC_PLAYER_RESULT_ACTION -> handleVLCPlayer(resultCode, intent)
else -> {
if (action != null && resultCode != Activity.RESULT_CANCELED) {
Timber.d("Unknown action $action [resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_not_supported_yet, Toast.LENGTH_LONG)
} else {
Timber.d("Playback canceled: no player selected or player without action result")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_invalid_player, Toast.LENGTH_LONG)
}
}
}
}
@JavascriptInterface
fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER
@JavascriptInterface
fun initPlayer(args: String) {
val playOptions = PlayOptions.fromJson(JSONObject(args))
val itemId = playOptions?.run {
ids.firstOrNull() ?: mediaSourceId?.toUUIDOrNull() // fallback if ids is empty
}
if (playOptions == null || itemId == null) {
context.toast(R.string.player_error_invalid_play_options)
return
}
coroutinesScope.launch {
// Resolve media source to query info about external (subtitle) streams
mediaSourceResolver.resolveMediaSource(
itemId = itemId,
mediaSourceId = playOptions.mediaSourceId,
deviceProfile = externalPlayerProfile,
startTimeTicks = playOptions.startPositionTicks,
audioStreamIndex = playOptions.audioStreamIndex,
subtitleStreamIndex = playOptions.subtitleStreamIndex,
maxStreamingBitrate = Int.MAX_VALUE, // ensure we always direct play
autoOpenLiveStream = false,
).onSuccess { jellyfinMediaSource ->
playMediaSource(playOptions, jellyfinMediaSource)
}.onFailure { error ->
when (error as? PlayerException) {
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
null -> throw error // Unknown error, rethrow from here
}
}
}
}
private fun playMediaSource(playOptions: PlayOptions, source: JellyfinMediaSource) {
// Create direct play URL
val url = videosApi.getVideoStreamUrl(
itemId = source.itemId,
static = true,
mediaSourceId = source.id,
playSessionId = source.playSessionId,
)
// Select correct subtitle
val selectedSubtitleStream = playOptions.subtitleStreamIndex?.let { index ->
source.mediaStreams.getOrNull(index)
}
source.selectSubtitleStream(selectedSubtitleStream)
// Build playback intent
val playerIntent = Intent(Intent.ACTION_VIEW).apply {
if (context.packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) {
component = getComponent(appPreferences.externalPlayerApp)
}
setDataAndType(Uri.parse(url), "video/*")
putExtra("title", source.name)
putExtra("position", source.startTimeMs.toInt())
putExtra("return_result", true)
putExtra("secure_uri", true)
val externalSubs = source.externalSubtitleStreams
val enabledSubUrl = when {
source.selectedSubtitleStream != null -> {
externalSubs.find { stream -> stream.index == source.selectedSubtitleStream?.index }?.let { sub ->
apiClient.createUrl(sub.deliveryUrl)
}
}
else -> null
}
// MX Player API / MPV
val subtitleUris = externalSubs.map { stream ->
Uri.parse(apiClient.createUrl(stream.deliveryUrl))
}
putExtra("subs", subtitleUris.toTypedArray())
putExtra("subs.name", externalSubs.map(ExternalSubtitleStream::displayTitle).toTypedArray())
putExtra("subs.filename", externalSubs.map(ExternalSubtitleStream::language).toTypedArray())
putExtra("subs.enable", enabledSubUrl?.let { url -> arrayOf(Uri.parse(url)) } ?: emptyArray())
// VLC
if (enabledSubUrl != null) putExtra("subtitles_location", enabledSubUrl)
}
playerContract.launch(playerIntent)
Timber.d(
"Starting playback [id=${source.itemId}, title=${source.name}, " +
"playMethod=${source.playMethod}, startTimeMs=${source.startTimeMs}]",
)
}
private fun notifyEvent(event: String, parameters: String = "") {
if (event in ALLOWED_EVENTS && parameters.isDigitsOnly()) {
webappFunctionChannel.call("window.ExtPlayer.notify$event($parameters)")
}
}
// https://github.com/mpv-android/mpv-android/commit/f70298fe23c4872ea04fe4f2a8b378b986460d98
private fun handleMPVPlayer(resultCode: Int, data: Intent) {
val player = "MPV Player"
when (resultCode) {
Activity.RESULT_OK -> {
val position = data.getIntExtra("position", 0)
if (position > 0) {
Timber.d("Playback stopped [player=$player, position=$position]")
notifyEvent(Constants.EVENT_TIME_UPDATE, "$position")
notifyEvent(Constants.EVENT_ENDED)
} else {
Timber.d("Playback completed [player=$player]")
notifyEvent(Constants.EVENT_TIME_UPDATE)
notifyEvent(Constants.EVENT_ENDED)
}
}
Activity.RESULT_CANCELED -> {
Timber.d("Playback stopped by unknown error [player=$player]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
else -> {
Timber.d("Invalid state [player=$player, resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
}
// https://sites.google.com/site/mxvpen/api
private fun handleMXPlayer(resultCode: Int, data: Intent) {
val player = "MX Player"
when (resultCode) {
Activity.RESULT_OK -> {
when (val endBy = data.getStringExtra("end_by")) {
"playback_completion" -> {
Timber.d("Playback completed [player=$player]")
notifyEvent(Constants.EVENT_TIME_UPDATE)
notifyEvent(Constants.EVENT_ENDED)
}
"user" -> {
val position = data.getIntExtra("position", 0)
val duration = data.getIntExtra("duration", 0)
if (position > 0) {
Timber.d("Playback stopped [player=$player, position=$position, duration=$duration]")
notifyEvent(Constants.EVENT_TIME_UPDATE, "$position")
notifyEvent(Constants.EVENT_ENDED)
} else {
Timber.d("Invalid state [player=$player, position=$position, duration=$duration]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
else -> {
Timber.d("Invalid state [player=$player, endBy=$endBy]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
}
Activity.RESULT_CANCELED -> {
Timber.d("Playback stopped by user [player=$player]")
notifyEvent(Constants.EVENT_CANCELED)
}
Activity.RESULT_FIRST_USER -> {
Timber.d("Playback stopped by unknown error [player=$player]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
else -> {
Timber.d("Invalid state [player=$player, resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
}
// https://wiki.videolan.org/Android_Player_Intents/
private fun handleVLCPlayer(resultCode: Int, data: Intent) {
val player = "VLC Player"
when (resultCode) {
Activity.RESULT_OK -> {
val extraPosition = data.getLongExtra("extra_position", 0L)
val extraDuration = data.getLongExtra("extra_duration", 0L)
if (extraPosition > 0L) {
Timber.d(
"Playback stopped [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]",
)
notifyEvent(Constants.EVENT_TIME_UPDATE, "$extraPosition")
notifyEvent(Constants.EVENT_ENDED)
} else {
if (extraDuration == 0L && extraPosition == 0L) {
Timber.d("Playback completed [player=$player]")
notifyEvent(Constants.EVENT_TIME_UPDATE)
notifyEvent(Constants.EVENT_ENDED)
} else {
Timber.d(
"Invalid state [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]",
)
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
}
else -> {
Timber.d("Playback failed [player=$player, resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
}
/**
* To ensure that the correct activity is called.
*/
private fun getComponent(@ExternalPlayerPackage packageName: String): ComponentName? {
return when (packageName) {
ExternalPlayerPackage.MPV_PLAYER -> {
ComponentName(packageName, "$packageName.MPVActivity")
}
ExternalPlayerPackage.MX_PLAYER_FREE, ExternalPlayerPackage.MX_PLAYER_PRO -> {
ComponentName(packageName, "$packageName.ActivityScreen")
}
ExternalPlayerPackage.VLC_PLAYER -> {
ComponentName(packageName, "$packageName.StartActivity")
}
else -> null
}
}
companion object {
private val ALLOWED_EVENTS = arrayOf(
Constants.EVENT_CANCELED,
Constants.EVENT_ENDED,
Constants.EVENT_TIME_UPDATE,
)
}
}

View file

@ -0,0 +1,23 @@
package org.jellyfin.mobile.bridge
import org.json.JSONArray
import org.json.JSONObject
abstract class JavascriptCallback {
protected abstract fun callback(keep: Boolean, err: String?, result: String?)
@JvmOverloads
fun success(keep: Boolean = false, result: String? = null) = callback(keep, null, result?.let { """"$it"""" })
@JvmOverloads
fun success(keep: Boolean = false, result: JSONObject?) = callback(keep, null, result.toString())
@JvmOverloads
fun success(keep: Boolean = false, result: JSONArray?) = callback(keep, null, result.toString())
@JvmOverloads
fun error(keep: Boolean = false, message: String) = callback(keep, """"$message"""", null)
@JvmOverloads
fun error(keep: Boolean = false, error: JSONObject) = callback(keep, error.toString(), null)
}

View file

@ -0,0 +1,171 @@
package org.jellyfin.mobile.bridge
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.media.session.PlaybackState
import android.net.Uri
import android.webkit.JavascriptInterface
import androidx.core.content.ContextCompat
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.EXTRA_ALBUM
import org.jellyfin.mobile.utils.Constants.EXTRA_ARTIST
import org.jellyfin.mobile.utils.Constants.EXTRA_CAN_SEEK
import org.jellyfin.mobile.utils.Constants.EXTRA_DURATION
import org.jellyfin.mobile.utils.Constants.EXTRA_IMAGE_URL
import org.jellyfin.mobile.utils.Constants.EXTRA_IS_LOCAL_PLAYER
import org.jellyfin.mobile.utils.Constants.EXTRA_IS_PAUSED
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.webapp.RemotePlayerService
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import timber.log.Timber
@Suppress("unused")
class NativeInterface(private val context: Context) : KoinComponent {
private val activityEventHandler: ActivityEventHandler = get()
private val remoteVolumeProvider: RemoteVolumeProvider by inject()
@SuppressLint("HardwareIds")
@JavascriptInterface
fun getDeviceInformation(): String? = try {
val apiClient: ApiClient = get()
val deviceInfo = apiClient.deviceInfo
val clientInfo = apiClient.clientInfo
JSONObject().apply {
put("deviceId", deviceInfo.id)
// normalize the name by removing special characters
// and making sure it's at least 1 character long
// otherwise the webui will fail to send it to the server
val name = AuthorizationHeaderBuilder.encodeParameterValue(deviceInfo.name).padStart(1)
put("deviceName", name)
put("appName", clientInfo.name)
put("appVersion", clientInfo.version)
}.toString()
} catch (e: JSONException) {
null
}
@JavascriptInterface
fun enableFullscreen(): Boolean {
emitEvent(ActivityEvent.ChangeFullscreen(true))
return true
}
@JavascriptInterface
fun disableFullscreen(): Boolean {
emitEvent(ActivityEvent.ChangeFullscreen(false))
return true
}
@JavascriptInterface
fun openUrl(uri: String): Boolean {
emitEvent(ActivityEvent.OpenUrl(uri))
return true
}
@JavascriptInterface
fun updateMediaSession(args: String): Boolean {
val options = try {
JSONObject(args)
} catch (e: JSONException) {
Timber.e("updateMediaSession: %s", e.message)
return false
}
val intent = Intent(context, RemotePlayerService::class.java).apply {
action = Constants.ACTION_REPORT
putExtra(EXTRA_PLAYER_ACTION, options.optString(EXTRA_PLAYER_ACTION))
putExtra(EXTRA_ITEM_ID, options.optString(EXTRA_ITEM_ID))
putExtra(EXTRA_TITLE, options.optString(EXTRA_TITLE))
putExtra(EXTRA_ARTIST, options.optString(EXTRA_ARTIST))
putExtra(EXTRA_ALBUM, options.optString(EXTRA_ALBUM))
putExtra(EXTRA_IMAGE_URL, options.optString(EXTRA_IMAGE_URL))
putExtra(EXTRA_POSITION, options.optLong(EXTRA_POSITION, PlaybackState.PLAYBACK_POSITION_UNKNOWN))
putExtra(EXTRA_DURATION, options.optLong(EXTRA_DURATION))
putExtra(EXTRA_CAN_SEEK, options.optBoolean(EXTRA_CAN_SEEK))
putExtra(EXTRA_IS_LOCAL_PLAYER, options.optBoolean(EXTRA_IS_LOCAL_PLAYER, true))
putExtra(EXTRA_IS_PAUSED, options.optBoolean(EXTRA_IS_PAUSED, true))
}
ContextCompat.startForegroundService(context, intent)
// We may need to request bluetooth permission to react to bluetooth disconnect events
activityEventHandler.emit(ActivityEvent.RequestBluetoothPermission)
return true
}
@JavascriptInterface
fun hideMediaSession(): Boolean {
val intent = Intent(context, RemotePlayerService::class.java).apply {
action = Constants.ACTION_REPORT
putExtra(EXTRA_PLAYER_ACTION, "playbackstop")
}
context.startService(intent)
return true
}
@JavascriptInterface
fun updateVolumeLevel(value: Int) {
remoteVolumeProvider.currentVolume = value
}
@JavascriptInterface
fun downloadFiles(args: String): Boolean {
try {
val files = JSONArray(args)
repeat(files.length()) { index ->
val file = files.getJSONObject(index)
val title: String = file.getString("title")
val filename: String = file.getString("filename")
val url: String = file.getString("url")
emitEvent(ActivityEvent.DownloadFile(Uri.parse(url), title, filename))
}
} catch (e: JSONException) {
Timber.e("Download failed: %s", e.message)
return false
}
return true
}
@JavascriptInterface
fun openClientSettings() {
emitEvent(ActivityEvent.OpenSettings)
}
@JavascriptInterface
fun openServerSelection() {
emitEvent(ActivityEvent.SelectServer)
}
@JavascriptInterface
fun exitApp() {
emitEvent(ActivityEvent.ExitApp)
}
@JavascriptInterface
fun execCast(action: String, args: String) {
emitEvent(ActivityEvent.CastMessage(action, JSONArray(args)))
}
@Suppress("NOTHING_TO_INLINE")
private inline fun emitEvent(event: ActivityEvent) {
activityEventHandler.emit(event)
}
}

View file

@ -0,0 +1,65 @@
package org.jellyfin.mobile.bridge
import android.webkit.JavascriptInterface
import kotlinx.coroutines.channels.Channel
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.settings.VideoPlayerType
import org.jellyfin.mobile.utils.Constants
import org.json.JSONObject
@Suppress("unused")
class NativePlayer(
private val appPreferences: AppPreferences,
private val activityEventHandler: ActivityEventHandler,
private val playerEventChannel: Channel<PlayerEvent>,
) {
@JavascriptInterface
fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
@JavascriptInterface
fun loadPlayer(args: String) {
PlayOptions.fromJson(JSONObject(args))?.let { options ->
activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(options))
}
}
@JavascriptInterface
fun pausePlayer() {
playerEventChannel.trySend(PlayerEvent.Pause)
}
@JavascriptInterface
fun resumePlayer() {
playerEventChannel.trySend(PlayerEvent.Resume)
}
@JavascriptInterface
fun stopPlayer() {
playerEventChannel.trySend(PlayerEvent.Stop)
}
@JavascriptInterface
fun destroyPlayer() {
playerEventChannel.trySend(PlayerEvent.Destroy)
}
@JavascriptInterface
fun seek(ticks: Long) {
playerEventChannel.trySend(PlayerEvent.Seek(ticks / Constants.TICKS_PER_MILLISECOND))
}
@JavascriptInterface
fun seekMs(ms: Long) {
playerEventChannel.trySend(PlayerEvent.Seek(ms))
}
@JavascriptInterface
fun setVolume(volume: Int) {
playerEventChannel.trySend(PlayerEvent.SetVolume(volume))
}
}

View file

@ -0,0 +1,17 @@
package org.jellyfin.mobile.data
import androidx.room.Room
import org.koin.android.ext.koin.androidApplication
import org.koin.dsl.module
val databaseModule = module {
single {
Room.databaseBuilder(androidApplication(), JellyfinDatabase::class.java, "jellyfin")
.addMigrations()
.fallbackToDestructiveMigrationFrom(1)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
}
single { get<JellyfinDatabase>().serverDao }
single { get<JellyfinDatabase>().userDao }
}

View file

@ -0,0 +1,14 @@
package org.jellyfin.mobile.data
import androidx.room.Database
import androidx.room.RoomDatabase
import org.jellyfin.mobile.data.dao.ServerDao
import org.jellyfin.mobile.data.dao.UserDao
import org.jellyfin.mobile.data.entity.ServerEntity
import org.jellyfin.mobile.data.entity.UserEntity
@Database(entities = [ServerEntity::class, UserEntity::class], version = 2)
abstract class JellyfinDatabase : RoomDatabase() {
abstract val serverDao: ServerDao
abstract val userDao: UserDao
}

View file

@ -0,0 +1,25 @@
package org.jellyfin.mobile.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import org.jellyfin.mobile.data.entity.ServerEntity
import org.jellyfin.mobile.data.entity.ServerEntity.Key.TABLE_NAME
@Dao
interface ServerDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(entity: ServerEntity): Long
fun insert(hostname: String) = insert(ServerEntity(hostname))
@Query("SELECT * FROM $TABLE_NAME WHERE id = :id")
fun getServer(id: Long): ServerEntity?
@Query("SELECT * FROM $TABLE_NAME ORDER BY last_used_timestamp DESC")
fun getAllServers(): List<ServerEntity>
@Query("SELECT * FROM $TABLE_NAME WHERE hostname = :hostname")
fun getServerByHostname(hostname: String): ServerEntity?
}

View file

@ -0,0 +1,54 @@
package org.jellyfin.mobile.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import org.jellyfin.mobile.data.entity.ServerUser
import org.jellyfin.mobile.data.entity.UserEntity
import org.jellyfin.mobile.data.entity.UserEntity.Key.ACCESS_TOKEN
import org.jellyfin.mobile.data.entity.UserEntity.Key.ID
import org.jellyfin.mobile.data.entity.UserEntity.Key.SERVER_ID
import org.jellyfin.mobile.data.entity.UserEntity.Key.TABLE_NAME
import org.jellyfin.mobile.data.entity.UserEntity.Key.USER_ID
@Dao
@Suppress("TooManyFunctions")
interface UserDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(entity: UserEntity): Long
fun insert(serverId: Long, userId: String, accessToken: String?) = insert(UserEntity(serverId, userId, accessToken))
@Transaction
fun upsert(serverId: Long, userId: String, accessToken: String?): Long {
return when (val user = getByUserId(serverId, userId)) {
null -> insert(serverId, userId, accessToken)
else -> {
update(user.id, accessToken)
user.id
}
}
}
@Transaction
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $ID = :userId")
fun getServerUser(serverId: Long, userId: Long): ServerUser?
@Transaction
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId")
fun getServerUser(serverId: Long, userId: String): ServerUser?
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId")
fun getByUserId(serverId: Long, userId: String): UserEntity?
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId")
fun getAllForServer(serverId: Long): List<UserEntity>
@Query("UPDATE $TABLE_NAME SET access_token = :accessToken WHERE $ID = :userId")
fun update(userId: Long, accessToken: String?): Int
@Query("UPDATE $TABLE_NAME SET $ACCESS_TOKEN = NULL WHERE $ID = :userId")
fun logout(userId: Long)
}

View file

@ -0,0 +1,31 @@
package org.jellyfin.mobile.data.entity
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import org.jellyfin.mobile.data.entity.ServerEntity.Key.HOSTNAME
import org.jellyfin.mobile.data.entity.ServerEntity.Key.TABLE_NAME
@Parcelize
@Entity(tableName = TABLE_NAME, indices = [Index(value = arrayOf(HOSTNAME), unique = true)])
data class ServerEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = ID)
val id: Long,
@ColumnInfo(name = HOSTNAME)
val hostname: String,
@ColumnInfo(name = LAST_USED_TIMESTAMP)
val lastUsedTimestamp: Long,
) : Parcelable {
constructor(hostname: String) : this(0, hostname, System.currentTimeMillis())
companion object Key {
const val TABLE_NAME = "Server"
const val ID = "id"
const val HOSTNAME = "hostname"
const val LAST_USED_TIMESTAMP = "last_used_timestamp"
}
}

View file

@ -0,0 +1,13 @@
package org.jellyfin.mobile.data.entity
import androidx.room.Embedded
import androidx.room.Relation
data class ServerUser(
@Embedded val user: UserEntity,
@Relation(
parentColumn = UserEntity.SERVER_ID,
entityColumn = ServerEntity.ID,
)
val server: ServerEntity,
)

View file

@ -0,0 +1,45 @@
package org.jellyfin.mobile.data.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import org.jellyfin.mobile.data.entity.UserEntity.Key.SERVER_ID
import org.jellyfin.mobile.data.entity.UserEntity.Key.TABLE_NAME
import org.jellyfin.mobile.data.entity.UserEntity.Key.USER_ID
@Entity(
tableName = TABLE_NAME,
indices = [
Index(value = [SERVER_ID, USER_ID], unique = true),
],
foreignKeys = [
ForeignKey(entity = ServerEntity::class, parentColumns = [ServerEntity.ID], childColumns = [SERVER_ID]),
],
)
data class UserEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = ID)
val id: Long,
@ColumnInfo(name = SERVER_ID)
val serverId: Long,
@ColumnInfo(name = USER_ID)
val userId: String,
@ColumnInfo(name = ACCESS_TOKEN)
val accessToken: String?,
@ColumnInfo(name = LAST_LOGIN_TIMESTAMP)
val lastLoginTimestamp: Long,
) {
constructor(serverId: Long, userId: String, accessToken: String?) :
this(0, serverId, userId, accessToken, System.currentTimeMillis())
companion object Key {
const val TABLE_NAME = "User"
const val ID = "id"
const val SERVER_ID = "server_id"
const val USER_ID = "user_id"
const val ACCESS_TOKEN = "access_token"
const val LAST_LOGIN_TIMESTAMP = "last_login_timestamp"
}
}

View file

@ -0,0 +1,17 @@
package org.jellyfin.mobile.events
import android.net.Uri
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.json.JSONArray
sealed class ActivityEvent {
class ChangeFullscreen(val isFullscreen: Boolean) : ActivityEvent()
class LaunchNativePlayer(val playOptions: PlayOptions) : ActivityEvent()
class OpenUrl(val uri: String) : ActivityEvent()
class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent()
class CastMessage(val action: String, val args: JSONArray) : ActivityEvent()
data object RequestBluetoothPermission : ActivityEvent()
data object OpenSettings : ActivityEvent()
data object SelectServer : ActivityEvent()
data object ExitApp : ActivityEvent()
}

View file

@ -0,0 +1,118 @@
package org.jellyfin.mobile.events
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.bridge.JavascriptCallback
import org.jellyfin.mobile.player.ui.PlayerFragment
import org.jellyfin.mobile.player.ui.PlayerFullscreenHelper
import org.jellyfin.mobile.settings.SettingsFragment
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.addFragment
import org.jellyfin.mobile.utils.requestDownload
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import timber.log.Timber
class ActivityEventHandler(
private val webappFunctionChannel: WebappFunctionChannel,
) {
private val eventsFlow = MutableSharedFlow<ActivityEvent>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
fun MainActivity.subscribe() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
eventsFlow.collect { event ->
handleEvent(event)
}
}
}
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun MainActivity.handleEvent(event: ActivityEvent) {
when (event) {
is ActivityEvent.ChangeFullscreen -> {
val fullscreenHelper = PlayerFullscreenHelper(window)
if (event.isFullscreen) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
fullscreenHelper.enableFullscreen()
window.setBackgroundDrawable(null)
} else {
// Reset screen orientation
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
fullscreenHelper.disableFullscreen()
// Reset window background color
window.setBackgroundDrawableResource(R.color.theme_background)
}
}
is ActivityEvent.LaunchNativePlayer -> {
val args = Bundle().apply {
putParcelable(Constants.EXTRA_MEDIA_PLAY_OPTIONS, event.playOptions)
}
supportFragmentManager.addFragment<PlayerFragment>(args)
}
is ActivityEvent.OpenUrl -> {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.uri))
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e("openIntent: %s", e.message)
}
}
is ActivityEvent.DownloadFile -> {
lifecycleScope.launch {
with(event) { requestDownload(uri, title, filename) }
}
}
is ActivityEvent.CastMessage -> {
val action = event.action
chromecast.execute(
action,
event.args,
object : JavascriptCallback() {
override fun callback(keep: Boolean, err: String?, result: String?) {
webappFunctionChannel.call(
"""window.NativeShell.castCallback("$action", $keep, $err, $result);""",
)
}
},
)
}
ActivityEvent.RequestBluetoothPermission -> {
lifecycleScope.launch {
bluetoothPermissionHelper.requestBluetoothPermissionIfNecessary()
}
}
ActivityEvent.OpenSettings -> {
supportFragmentManager.addFragment<SettingsFragment>()
}
ActivityEvent.SelectServer -> {
mainViewModel.resetServer()
}
ActivityEvent.ExitApp -> {
if (serviceBinder?.isPlaying == true) {
moveTaskToBack(false)
} else {
finish()
}
}
}
}
fun emit(event: ActivityEvent) {
eventsFlow.tryEmit(event)
}
}

View file

@ -0,0 +1,7 @@
package org.jellyfin.mobile.player
sealed class PlayerException(cause: Throwable?) : Exception(cause) {
class InvalidPlayOptions(cause: Throwable? = null) : PlayerException(cause)
class NetworkFailure(cause: Throwable? = null) : PlayerException(cause)
class UnsupportedContent(cause: Throwable? = null) : PlayerException(cause)
}

View file

@ -0,0 +1,551 @@
package org.jellyfin.mobile.player
import android.annotation.SuppressLint
import android.app.Application
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.session.MediaSession
import android.media.session.PlaybackState
import androidx.core.content.getSystemService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultRenderersFactory
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector
import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException
import com.google.android.exoplayer2.mediacodec.MediaCodecInfo
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.util.Clock
import com.google.android.exoplayer2.util.EventLogger
import com.google.android.exoplayer2.util.MimeTypes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.app.PLAYER_EVENT_CHANNEL
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.player.interaction.PlayerLifecycleObserver
import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback
import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper
import org.jellyfin.mobile.player.queue.QueueManager
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.player.ui.DecoderType
import org.jellyfin.mobile.player.ui.DisplayPreferences
import org.jellyfin.mobile.player.ui.PlayState
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
import org.jellyfin.mobile.utils.applyDefaultAudioAttributes
import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes
import org.jellyfin.mobile.utils.extensions.scaleInRange
import org.jellyfin.mobile.utils.extensions.width
import org.jellyfin.mobile.utils.getVolumeLevelPercent
import org.jellyfin.mobile.utils.getVolumeRange
import org.jellyfin.mobile.utils.logTracks
import org.jellyfin.mobile.utils.seekToOffset
import org.jellyfin.mobile.utils.setPlaybackState
import org.jellyfin.mobile.utils.toMediaMetadata
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.operations.DisplayPreferencesApi
import org.jellyfin.sdk.api.operations.HlsSegmentApi
import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import org.koin.core.qualifier.named
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
@Suppress("TooManyFunctions")
class PlayerViewModel(application: Application) : AndroidViewModel(application), KoinComponent, Player.Listener {
private val apiClient: ApiClient = get()
private val displayPreferencesApi: DisplayPreferencesApi = apiClient.displayPreferencesApi
private val playStateApi: PlayStateApi = apiClient.playStateApi
private val hlsSegmentApi: HlsSegmentApi = apiClient.hlsSegmentApi
private val lifecycleObserver = PlayerLifecycleObserver(this)
private val audioManager: AudioManager by lazy { getApplication<Application>().getSystemService()!! }
val notificationHelper: PlayerNotificationHelper by lazy { PlayerNotificationHelper(this) }
// Media source handling
private val trackSelector = DefaultTrackSelector(getApplication())
val trackSelectionHelper = TrackSelectionHelper(this, trackSelector)
val queueManager = QueueManager(this)
val mediaSourceOrNull: JellyfinMediaSource?
get() = queueManager.currentMediaSourceOrNull
// ExoPlayer
private val _player = MutableLiveData<ExoPlayer?>()
private val _playerState = MutableLiveData<Int>()
private val _decoderType = MutableLiveData<DecoderType>()
val player: LiveData<ExoPlayer?> get() = _player
val playerState: LiveData<Int> get() = _playerState
val decoderType: LiveData<DecoderType> get() = _decoderType
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
private val eventLogger = EventLogger()
private var analyticsCollector = buildAnalyticsCollector()
private val initialTracksSelected = AtomicBoolean(false)
private var fallbackPreferExtensionRenderers = false
private var progressUpdateJob: Job? = null
/**
* Returns the current ExoPlayer instance or null
*/
val playerOrNull: ExoPlayer? get() = _player.value
private val playerEventChannel: Channel<PlayerEvent> by inject(named(PLAYER_EVENT_CHANNEL))
val mediaSession: MediaSession by lazy {
MediaSession(
getApplication<Application>().applicationContext,
javaClass.simpleName.removePrefix(BuildConfig.APPLICATION_ID),
).apply {
@Suppress("DEPRECATION")
setFlags(MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS or MediaSession.FLAG_HANDLES_MEDIA_BUTTONS)
setCallback(mediaSessionCallback)
applyDefaultLocalAudioAttributes(AudioAttributes.CONTENT_TYPE_MOVIE)
}
}
private val mediaSessionCallback = PlayerMediaSessionCallback(this)
private var displayPreferences = DisplayPreferences()
init {
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
// Load display preferences
viewModelScope.launch {
try {
val displayPreferencesDto by displayPreferencesApi.getDisplayPreferences(
displayPreferencesId = Constants.DISPLAY_PREFERENCES_ID_USER_SETTINGS,
client = Constants.DISPLAY_PREFERENCES_CLIENT_EMBY,
)
val customPrefs = displayPreferencesDto.customPrefs
displayPreferences = DisplayPreferences(
skipBackLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_BACK_LENGTH]?.toLongOrNull()
?: Constants.DEFAULT_SEEK_TIME_MS,
skipForwardLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH]?.toLongOrNull()
?: Constants.DEFAULT_SEEK_TIME_MS,
)
} catch (e: ApiClientException) {
Timber.e(e, "Failed to load display preferences")
}
}
// Subscribe to player events from webapp
viewModelScope.launch {
for (event in playerEventChannel) {
when (event) {
PlayerEvent.Pause -> mediaSessionCallback.onPause()
PlayerEvent.Resume -> mediaSessionCallback.onPlay()
PlayerEvent.Stop, PlayerEvent.Destroy -> mediaSessionCallback.onStop()
is PlayerEvent.Seek -> playerOrNull?.seekTo(event.ms)
is PlayerEvent.SetVolume -> {
setVolume(event.volume)
playerOrNull?.reportPlaybackState()
}
}
}
}
}
private fun buildAnalyticsCollector() = DefaultAnalyticsCollector(Clock.DEFAULT).apply {
addListener(eventLogger)
}
/**
* Setup a new [ExoPlayer] for video playback, register callbacks and set attributes
*/
fun setupPlayer() {
val renderersFactory = DefaultRenderersFactory(getApplication()).apply {
setEnableDecoderFallback(true) // Fallback only works if initialization fails, not decoding at playback time
val rendererMode = when {
fallbackPreferExtensionRenderers -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
setExtensionRendererMode(rendererMode)
setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
val decoderInfoList = MediaCodecSelector.DEFAULT.getDecoderInfos(
mimeType,
requiresSecureDecoder,
requiresTunnelingDecoder,
)
// Allow decoder selection only for video track
if (!MimeTypes.isVideo(mimeType)) {
return@setMediaCodecSelector decoderInfoList
}
val filteredDecoderList = when (decoderType.value) {
DecoderType.HARDWARE -> decoderInfoList.filter(MediaCodecInfo::hardwareAccelerated)
DecoderType.SOFTWARE -> decoderInfoList.filterNot(MediaCodecInfo::hardwareAccelerated)
else -> decoderInfoList
}
// Update the decoderType based on the first decoder selected
filteredDecoderList.firstOrNull()?.let { decoder ->
val decoderType = when {
decoder.hardwareAccelerated -> DecoderType.HARDWARE
else -> DecoderType.SOFTWARE
}
_decoderType.postValue(decoderType)
}
filteredDecoderList
}
}
_player.value = ExoPlayer.Builder(getApplication(), renderersFactory, get()).apply {
setUsePlatformDiagnostics(false)
setTrackSelector(trackSelector)
setAnalyticsCollector(analyticsCollector)
}.build().apply {
addListener(this@PlayerViewModel)
applyDefaultAudioAttributes(C.AUDIO_CONTENT_TYPE_MOVIE)
}
}
/**
* Release the current ExoPlayer and stop/release the current MediaSession
*/
private fun releasePlayer() {
notificationHelper.dismissNotification()
mediaSession.isActive = false
mediaSession.release()
playerOrNull?.run {
removeListener(this@PlayerViewModel)
release()
}
_player.value = null
}
fun load(jellyfinMediaSource: JellyfinMediaSource, exoMediaSource: MediaSource, playWhenReady: Boolean) {
val player = playerOrNull ?: return
player.setMediaSource(exoMediaSource)
player.prepare()
initialTracksSelected.set(false)
val startTime = jellyfinMediaSource.startTimeMs
if (startTime > 0) player.seekTo(startTime)
player.playWhenReady = playWhenReady
mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata())
viewModelScope.launch {
player.reportPlaybackStart(jellyfinMediaSource)
}
}
private fun startProgressUpdates() {
progressUpdateJob = viewModelScope.launch {
while (true) {
delay(Constants.PLAYER_TIME_UPDATE_RATE)
playerOrNull?.reportPlaybackState()
}
}
}
private fun stopProgressUpdates() {
progressUpdateJob?.cancel()
}
/**
* Updates the decoder of the [Player]. This will destroy the current player and
* recreate the player with the selected decoder type
*/
fun updateDecoderType(type: DecoderType) {
_decoderType.postValue(type)
analyticsCollector.release()
val playedTime = playerOrNull?.currentPosition ?: 0L
// Stop and release the player without ending playback
playerOrNull?.run {
removeListener(this@PlayerViewModel)
release()
}
analyticsCollector = buildAnalyticsCollector()
setupPlayer()
queueManager.currentMediaSourceOrNull?.startTimeMs = playedTime
queueManager.tryRestartPlayback()
}
private suspend fun Player.reportPlaybackStart(mediaSource: JellyfinMediaSource) {
try {
playStateApi.reportPlaybackStart(
PlaybackStartInfo(
itemId = mediaSource.itemId,
playMethod = mediaSource.playMethod,
playSessionId = mediaSource.playSessionId,
audioStreamIndex = mediaSource.selectedAudioStream?.index,
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
isPaused = !isPlaying,
isMuted = false,
canSeek = true,
positionTicks = mediaSource.startTimeMs * Constants.TICKS_PER_MILLISECOND,
volumeLevel = audioManager.getVolumeLevelPercent(),
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
)
} catch (e: ApiClientException) {
Timber.e(e, "Failed to report playback start")
}
}
private suspend fun Player.reportPlaybackState() {
val mediaSource = mediaSourceOrNull ?: return
val playbackPositionMillis = currentPosition
if (playbackState != Player.STATE_ENDED) {
val stream = AudioManager.STREAM_MUSIC
val volumeRange = audioManager.getVolumeRange(stream)
val currentVolume = audioManager.getStreamVolume(stream)
try {
playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = mediaSource.itemId,
playMethod = mediaSource.playMethod,
playSessionId = mediaSource.playSessionId,
audioStreamIndex = mediaSource.selectedAudioStream?.index,
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
isPaused = !isPlaying,
isMuted = false,
canSeek = true,
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND,
volumeLevel = (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
)
} catch (e: ApiClientException) {
Timber.e(e, "Failed to report playback progress")
}
}
}
private fun reportPlaybackStop() {
val mediaSource = mediaSourceOrNull ?: return
val player = playerOrNull ?: return
val hasFinished = player.playbackState == Player.STATE_ENDED
val lastPositionTicks = when {
hasFinished -> mediaSource.runTimeTicks
else -> player.currentPosition * Constants.TICKS_PER_MILLISECOND
}
// viewModelScope may already be cancelled at this point, so we need to fallback
CoroutineScope(Dispatchers.Main).launch {
try {
// Report stopped playback
playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = mediaSource.itemId,
positionTicks = lastPositionTicks,
playSessionId = mediaSource.playSessionId,
liveStreamId = mediaSource.liveStreamId,
failed = false,
),
)
// Mark video as watched if playback finished
if (hasFinished) {
playStateApi.markPlayedItem(itemId = mediaSource.itemId)
}
// Stop active encoding if transcoding
stopTranscoding(mediaSource)
} catch (e: ApiClientException) {
Timber.e(e, "Failed to report playback stop")
}
}
}
suspend fun stopTranscoding(mediaSource: JellyfinMediaSource) {
if (mediaSource.playMethod == PlayMethod.TRANSCODE) {
hlsSegmentApi.stopEncodingProcess(
deviceId = apiClient.deviceInfo.id,
playSessionId = mediaSource.playSessionId,
)
}
}
// Player controls
fun play() {
playerOrNull?.play()
}
fun pause() {
playerOrNull?.pause()
}
fun rewind() {
playerOrNull?.seekToOffset(displayPreferences.skipBackLength.unaryMinus())
}
fun fastForward() {
playerOrNull?.seekToOffset(displayPreferences.skipForwardLength)
}
fun skipToPrevious() {
val player = playerOrNull ?: return
when {
// Skip to previous element
player.currentPosition <= Constants.MAX_SKIP_TO_PREV_MS -> viewModelScope.launch {
pause()
if (!queueManager.previous()) {
// Skip to previous failed, go to start of video anyway
playerOrNull?.seekTo(0)
play()
}
}
// Rewind to start of track if not at the start already
else -> player.seekTo(0)
}
}
fun skipToNext() {
viewModelScope.launch {
queueManager.next()
}
}
fun getStateAndPause(): PlayState? {
val player = playerOrNull ?: return null
val playWhenReady = player.playWhenReady
player.pause()
val position = player.contentPosition
return PlayState(playWhenReady, position)
}
fun logTracks() {
playerOrNull?.logTracks(analyticsCollector)
}
suspend fun changeBitrate(bitrate: Int?): Boolean {
return queueManager.changeBitrate(bitrate)
}
/**
* Set the playback speed to [speed]
*
* @return true if the speed was changed
*/
fun setPlaybackSpeed(speed: Float): Boolean {
val player = playerOrNull ?: return false
val parameters = player.playbackParameters
if (parameters.speed != speed) {
player.playbackParameters = parameters.withSpeed(speed)
return true
}
return false
}
fun stop() {
pause()
reportPlaybackStop()
releasePlayer()
}
private fun setVolume(percent: Int) {
if (audioManager.isVolumeFixed) return
val stream = AudioManager.STREAM_MUSIC
val volumeRange = audioManager.getVolumeRange(stream)
val scaled = volumeRange.scaleInRange(percent)
audioManager.setStreamVolume(stream, scaled, 0)
}
@SuppressLint("SwitchIntDef")
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
val player = playerOrNull ?: return
// Notify fragment of current state
_playerState.value = playbackState
// Initialise various components
if (playbackState == Player.STATE_READY) {
if (!initialTracksSelected.getAndSet(true)) {
trackSelectionHelper.selectInitialTracks()
}
mediaSession.isActive = true
notificationHelper.postNotification()
}
// Setup or stop regular progress updates
if (playbackState == Player.STATE_READY && playWhenReady) {
startProgressUpdates()
} else {
stopProgressUpdates()
}
// Update media session
var playbackActions = SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
if (queueManager.hasPrevious()) {
playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
}
if (queueManager.hasNext()) {
playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_NEXT
}
mediaSession.setPlaybackState(player, playbackActions)
// Force update playback state and position
viewModelScope.launch {
when (playbackState) {
Player.STATE_READY, Player.STATE_BUFFERING -> {
player.reportPlaybackState()
}
Player.STATE_ENDED -> {
reportPlaybackStop()
if (!queueManager.next()) {
releasePlayer()
}
}
}
}
}
override fun onPlayerError(error: PlaybackException) {
if (error.cause is MediaCodecDecoderException && !fallbackPreferExtensionRenderers) {
Timber.e(error.cause, "Decoder failed, attempting to restart playback with decoder extensions preferred")
playerOrNull?.run {
removeListener(this@PlayerViewModel)
release()
}
fallbackPreferExtensionRenderers = true
setupPlayer()
queueManager.tryRestartPlayback()
} else {
_error.postValue(error.localizedMessage.orEmpty())
}
}
override fun onCleared() {
reportPlaybackStop()
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
releasePlayer()
}
}

View file

@ -0,0 +1,173 @@
package org.jellyfin.mobile.player
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType
import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
class TrackSelectionHelper(
private val viewModel: PlayerViewModel,
private val trackSelector: DefaultTrackSelector,
) {
private val mediaSourceOrNull: JellyfinMediaSource?
get() = viewModel.mediaSourceOrNull
fun selectInitialTracks() {
val mediaSource = mediaSourceOrNull ?: return
mediaSource.selectedAudioStream?.let { stream ->
selectPlayerAudioTrack(mediaSource, stream, initial = true)
}
selectSubtitleTrack(mediaSource, mediaSource.selectedSubtitleStream, initial = true)
}
/**
* Select an audio track in the media source and apply changes to the current player, if necessary and possible.
*
* @param mediaStreamIndex the [MediaStream.index] that should be selected
* @return true if the audio track was changed
*/
suspend fun selectAudioTrack(mediaStreamIndex: Int): Boolean {
val mediaSource = mediaSourceOrNull ?: return false
val selectedMediaStream = mediaSource.mediaStreams[mediaStreamIndex]
require(selectedMediaStream.type == MediaStreamType.AUDIO)
// For transcoding and external streams, we need to restart playback
if (mediaSource.playMethod == PlayMethod.TRANSCODE || selectedMediaStream.isExternal) {
return viewModel.queueManager.selectAudioStreamAndRestartPlayback(selectedMediaStream)
}
return selectPlayerAudioTrack(mediaSource, selectedMediaStream, initial = false).also { success ->
if (success) viewModel.logTracks()
}
}
/**
* Select the audio track in the player.
*
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
* @see selectPlayerAudioTrack
*/
@Suppress("ReturnCount")
private fun selectPlayerAudioTrack(mediaSource: JellyfinMediaSource, audioStream: MediaStream, initial: Boolean): Boolean {
if (mediaSource.playMethod == PlayMethod.TRANSCODE) {
// Transcoding does not require explicit audio selection
return true
}
when {
// Fast-pass: Skip execution on subsequent calls with the correct selection or if only one track exists
mediaSource.audioStreams.size == 1 || !initial && audioStream === mediaSource.selectedAudioStream -> return true
// Apply selection in media source, abort on failure
!mediaSource.selectAudioStream(audioStream) -> return false
}
val player = viewModel.playerOrNull ?: return false
val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(audioStream)
val sortedTrackGroups = player.currentTracks.groups.sortedBy { group ->
val formatId = group.mediaTrackGroup.getFormat(0).id
// Sort by format ID, but pad number string with zeroes to ensure proper sorting
formatId?.toIntOrNull()?.let { id -> "%05d".format(id) } ?: formatId
}
val audioGroup = sortedTrackGroups.getOrNull(embeddedStreamIndex) ?: return false
return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, audioGroup.mediaTrackGroup)
}
/**
* Select a subtitle track in the media source and apply changes to the current player, if necessary.
*
* @param mediaStreamIndex the [MediaStream.index] that should be selected, or -1 to disable subtitles
* @return true if the subtitle was changed
*/
suspend fun selectSubtitleTrack(mediaStreamIndex: Int): Boolean {
val mediaSource = viewModel.mediaSourceOrNull ?: return false
val selectedMediaStream = mediaSource.mediaStreams.getOrNull(mediaStreamIndex)
require(selectedMediaStream == null || selectedMediaStream.type == MediaStreamType.SUBTITLE)
// If the selected subtitle stream requires encoding or the current subtitle is baked into the stream,
// we need to restart playback
if (
selectedMediaStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE ||
mediaSource.selectedSubtitleStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE
) {
return viewModel.queueManager.selectSubtitleStreamAndRestartPlayback(selectedMediaStream)
}
return selectSubtitleTrack(mediaSource, selectedMediaStream, initial = false).also { success ->
if (success) viewModel.logTracks()
}
}
/**
* Select the subtitle track in the player.
*
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
* @see selectSubtitleTrack
*/
@Suppress("ReturnCount")
private fun selectSubtitleTrack(mediaSource: JellyfinMediaSource, subtitleStream: MediaStream?, initial: Boolean): Boolean {
when {
// Fast-pass: Skip execution on subsequent calls with the same selection
!initial && subtitleStream === mediaSource.selectedSubtitleStream -> return true
// Apply selection in media source, abort on failure
!mediaSource.selectSubtitleStream(subtitleStream) -> return false
}
// Apply selection in player
if (subtitleStream == null) {
// If no subtitle is selected, simply clear the selection and disable the subtitle renderer
trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT)
return true
}
val player = viewModel.playerOrNull ?: return false
when (subtitleStream.deliveryMethod) {
SubtitleDeliveryMethod.ENCODE -> {
// Normally handled in selectSubtitleTrack(int) by restarting playback,
// initial selection is always considered successful
return true
}
SubtitleDeliveryMethod.EMBED -> {
// For embedded subtitles, we can match by the index of this stream in all embedded streams.
val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(subtitleStream)
val subtitleGroup = player.currentTracks.groups.getOrNull(embeddedStreamIndex) ?: return false
return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, subtitleGroup.mediaTrackGroup)
}
SubtitleDeliveryMethod.EXTERNAL -> {
// For external subtitles, we can simply match the ID that we set when creating the player media source.
for (group in player.currentTracks.groups) {
if (group.getTrackFormat(0).id == "${ExternalSubtitleStream.ID_PREFIX}${subtitleStream.index}") {
return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, group.mediaTrackGroup)
}
}
return false
}
else -> return false
}
}
/**
* Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple.
*
* @return true if subtitles are enabled now, false if not
*/
suspend fun toggleSubtitles(): Boolean {
val mediaSource = mediaSourceOrNull ?: return false
val newSubtitleIndex = when (mediaSource.selectedSubtitleStream) {
null -> mediaSource.subtitleStreams.firstOrNull()?.index ?: -1
else -> -1
}
selectSubtitleTrack(newSubtitleIndex)
// Media source may have changed by now
return mediaSourceOrNull?.selectedSubtitleStream != null
}
}

View file

@ -0,0 +1,133 @@
// Taken and adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/UampNotificationManager.kt
/*
* Copyright 2020 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jellyfin.mobile.player.audio
import android.app.PendingIntent
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.ForwardingPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.mobile.R
import org.jellyfin.mobile.utils.Constants.MEDIA_NOTIFICATION_CHANNEL_ID
import org.jellyfin.mobile.utils.Constants.MEDIA_PLAYER_NOTIFICATION_ID
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* A wrapper class for ExoPlayer's PlayerNotificationManager. It sets up the notification shown to
* the user during audio playback and provides track metadata, such as track title and icon image.
*/
class AudioNotificationManager(
private val context: Context,
sessionToken: MediaSessionCompat.Token,
notificationListener: PlayerNotificationManager.NotificationListener,
) : KoinComponent {
private val imageLoader: ImageLoader by inject()
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
private val notificationManager: PlayerNotificationManager
init {
val mediaController = MediaControllerCompat(context, sessionToken)
notificationManager = PlayerNotificationManager
.Builder(context, MEDIA_PLAYER_NOTIFICATION_ID, MEDIA_NOTIFICATION_CHANNEL_ID)
.setChannelNameResourceId(R.string.music_notification_channel)
.setChannelDescriptionResourceId(R.string.music_notification_channel_description)
.setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
.setNotificationListener(notificationListener)
.build()
notificationManager.apply {
setMediaSessionToken(sessionToken)
setSmallIcon(R.drawable.ic_notification)
}
}
fun showNotificationForPlayer(player: Player) {
notificationManager.setPlayer(NotificationForwardingPlayer(player))
}
fun hideNotification() {
notificationManager.setPlayer(null)
}
/**
* Removes rewind and fast-forward buttons from notification
*/
private class NotificationForwardingPlayer(player: Player) : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands = super.getAvailableCommands().buildUpon().removeAll(
COMMAND_SEEK_BACK,
COMMAND_SEEK_FORWARD,
).build()
}
private inner class DescriptionAdapter(
private val controller: MediaControllerCompat,
) : PlayerNotificationManager.MediaDescriptionAdapter {
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
override fun createCurrentContentIntent(player: Player): PendingIntent? =
controller.sessionActivity
override fun getCurrentContentText(player: Player) =
controller.metadata.description.subtitle.toString()
override fun getCurrentContentTitle(player: Player) =
controller.metadata.description.title.toString()
override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? {
val iconUri = controller.metadata.description.iconUri
return when {
currentIconUri != iconUri || currentBitmap == null -> {
// Cache the bitmap for the current song so that successive calls to
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = iconUri
serviceScope.launch {
currentBitmap = iconUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
}
else -> currentBitmap
}
}
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? = withContext(Dispatchers.IO) {
imageLoader.execute(ImageRequest.Builder(context).data(uri).build()).drawable?.toBitmap()
}
}
}

View file

@ -0,0 +1,429 @@
// Contains code adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/MediaService.kt
package org.jellyfin.mobile.player.audio
import android.app.Notification
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import androidx.mediarouter.media.MediaRouterParams
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.audio.car.LibraryPage
import org.jellyfin.mobile.player.cast.CastPlayerProvider
import org.jellyfin.mobile.player.cast.ICastPlayerProvider
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.mediaUri
import org.jellyfin.mobile.utils.toast
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import timber.log.Timber
import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem
class MediaService : MediaBrowserServiceCompat() {
private val apiClientController: ApiClientController by inject()
private val libraryBrowser: LibraryBrowser by inject()
private val serviceScope = MainScope()
private var isForegroundService = false
private lateinit var loadingJob: Job
// The current player will either be an ExoPlayer (for local playback) or a CastPlayer (for
// remote playback through a Cast device).
private lateinit var currentPlayer: Player
private lateinit var notificationManager: AudioNotificationManager
private lateinit var mediaController: MediaControllerCompat
private lateinit var mediaSession: MediaSessionCompat
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var mediaRouteSelector: MediaRouteSelector
private lateinit var mediaRouter: MediaRouter
private val mediaRouterCallback = MediaRouterCallback()
private var currentPlaylistItems: List<MediaMetadataCompat> = emptyList()
private val playerAudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
@Suppress("MemberVisibilityCanBePrivate")
val playerListener: Player.Listener = PlayerEventListener()
private val exoPlayer: Player by lazy {
ExoPlayer.Builder(this, get<MediaSource.Factory>()).apply {
setUsePlatformDiagnostics(false)
}.build().apply {
setAudioAttributes(playerAudioAttributes, true)
setHandleAudioBecomingNoisy(true)
addListener(playerListener)
}
}
private val castPlayerProvider: ICastPlayerProvider by lazy {
CastPlayerProvider(this)
}
override fun onCreate() {
super.onCreate()
loadingJob = serviceScope.launch {
apiClientController.loadSavedServerUser()
}
val sessionActivityPendingIntent = packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, Constants.PENDING_INTENT_FLAGS)
}
mediaSession = MediaSessionCompat(this, "MediaService").apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
}
sessionToken = mediaSession.sessionToken
notificationManager = AudioNotificationManager(
this,
mediaSession.sessionToken,
PlayerNotificationListener(),
)
mediaController = MediaControllerCompat(this, mediaSession)
mediaSessionConnector = MediaSessionConnector(mediaSession).apply {
setPlayer(exoPlayer)
setPlaybackPreparer(MediaPlaybackPreparer())
setQueueNavigator(MediaQueueNavigator(mediaSession))
}
mediaRouter = MediaRouter.getInstance(this)
mediaRouter.setMediaSessionCompat(mediaSession)
mediaRouteSelector = MediaRouteSelector.Builder().apply {
addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
}.build()
mediaRouter.routerParams = MediaRouterParams.Builder().apply {
setTransferToLocalEnabled(true)
}.build()
mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
switchToPlayer(
previousPlayer = null,
newPlayer = if (castPlayerProvider.isCastSessionAvailable) castPlayerProvider.get()!! else exoPlayer,
)
notificationManager.showNotificationForPlayer(currentPlayer)
}
override fun onDestroy() {
mediaSession.run {
isActive = false
release()
}
// Cancel coroutines when the service is going away
serviceScope.cancel()
// Free ExoPlayer resources
exoPlayer.removeListener(playerListener)
exoPlayer.release()
// Stop listening for route changes.
mediaRouter.removeCallback(mediaRouterCallback)
}
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?,
): BrowserRoot = libraryBrowser.getRoot(rootHints)
override fun onLoadChildren(parentId: String, result: Result<List<MediaItem>>) {
result.detach()
serviceScope.launch(Dispatchers.IO) {
// Ensure that server and credentials are available
loadingJob.join()
val items = try {
libraryBrowser.loadLibrary(parentId)
} catch (e: ApiClientException) {
Timber.e(e)
null
}
result.sendResult(items ?: emptyList())
}
}
/**
* Load the supplied list of songs and the song to play into the current player.
*/
private fun preparePlaylist(
metadataList: List<MediaMetadataCompat>,
initialPlaybackIndex: Int = 0,
playWhenReady: Boolean,
playbackStartPositionMs: Long = 0,
) {
currentPlaylistItems = metadataList
val mediaItems = metadataList.map { metadata ->
ExoPlayerMediaItem.Builder().apply {
setUri(metadata.mediaUri)
setTag(metadata)
}.build()
}
currentPlayer.playWhenReady = playWhenReady
with(currentPlayer) {
stop()
clearMediaItems()
}
if (currentPlayer == exoPlayer) {
with(exoPlayer) {
setMediaItems(mediaItems)
prepare()
seekTo(initialPlaybackIndex, playbackStartPositionMs)
}
} else {
val castPlayer = castPlayerProvider.get()
if (currentPlayer == castPlayer) {
castPlayer.setMediaItems(
mediaItems,
initialPlaybackIndex,
playbackStartPositionMs,
)
}
}
}
private fun switchToPlayer(previousPlayer: Player?, newPlayer: Player) {
if (previousPlayer == newPlayer) {
return
}
currentPlayer = newPlayer
if (previousPlayer != null) {
val playbackState = previousPlayer.playbackState
if (currentPlaylistItems.isEmpty()) {
// We are joining a playback session.
// Loading the session from the new player is not supported, so we stop playback.
with(currentPlayer) {
stop()
clearMediaItems()
}
} else if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) {
preparePlaylist(
metadataList = currentPlaylistItems,
initialPlaybackIndex = previousPlayer.currentMediaItemIndex,
playWhenReady = previousPlayer.playWhenReady,
playbackStartPositionMs = previousPlayer.currentPosition,
)
}
}
mediaSessionConnector.setPlayer(newPlayer)
previousPlayer?.run {
stop()
clearMediaItems()
}
}
private fun setPlaybackError() {
val errorState = PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_ERROR, 0, 1f)
.setErrorMessage(
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED,
getString(R.string.media_service_item_not_found),
)
.build()
mediaSession.setPlaybackState(errorState)
}
@Suppress("unused")
fun onCastSessionAvailable() {
val castPlayer = castPlayerProvider.get() ?: return
switchToPlayer(currentPlayer, castPlayer)
}
@Suppress("unused")
fun onCastSessionUnavailable() {
switchToPlayer(currentPlayer, exoPlayer)
}
private inner class MediaQueueNavigator(mediaSession: MediaSessionCompat) : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat =
currentPlaylistItems[windowIndex].description
}
private inner class MediaPlaybackPreparer : MediaSessionConnector.PlaybackPreparer {
override fun getSupportedPrepareActions(): Long = 0L or
PlaybackStateCompat.ACTION_PREPARE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
override fun onPrepare(playWhenReady: Boolean) {
serviceScope.launch {
val recents = try {
libraryBrowser.getDefaultRecents()
} catch (e: ApiClientException) {
Timber.e(e)
null
}
if (recents != null) {
preparePlaylist(recents, 0, playWhenReady)
} else {
setPlaybackError()
}
}
}
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
if (mediaId == LibraryPage.RESUME) {
// Requested recents
onPrepare(playWhenReady)
} else {
serviceScope.launch {
val result = libraryBrowser.buildPlayQueue(mediaId)
if (result != null) {
val (playbackQueue, initialPlaybackIndex) = result
preparePlaylist(playbackQueue, initialPlaybackIndex, playWhenReady)
} else {
setPlaybackError()
}
}
}
}
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
if (query.isEmpty()) {
// No search provided, fallback to recents
onPrepare(playWhenReady)
} else {
serviceScope.launch {
val results = try {
libraryBrowser.getSearchResults(query, extras)
} catch (e: ApiClientException) {
Timber.e(e)
null
}
if (results != null) {
preparePlaylist(results, 0, playWhenReady)
} else {
setPlaybackError()
}
}
}
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit
override fun onCommand(
player: Player,
command: String,
extras: Bundle?,
cb: ResultReceiver?,
): Boolean = false
}
/**
* Listen for notification events.
*/
private inner class PlayerNotificationListener : PlayerNotificationManager.NotificationListener {
override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) {
if (ongoing && !isForegroundService) {
val serviceIntent = Intent(applicationContext, this@MediaService.javaClass)
ContextCompat.startForegroundService(applicationContext, serviceIntent)
startForeground(notificationId, notification)
isForegroundService = true
}
}
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
stopForeground(true)
isForegroundService = false
stopSelf()
}
}
/**
* Listen for events from ExoPlayer.
*/
private inner class PlayerEventListener : Player.Listener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING,
Player.STATE_READY,
-> {
notificationManager.showNotificationForPlayer(currentPlayer)
if (playbackState == Player.STATE_READY) {
// TODO: When playing/paused save the current media item in persistent storage
// so that playback can be resumed between device reboots
if (!playWhenReady) {
// If playback is paused we remove the foreground state which allows the
// notification to be dismissed. An alternative would be to provide a
// "close" button in the notification which stops playback and clears
// the notification.
stopForeground(false)
}
}
}
else -> notificationManager.hideNotification()
}
}
override fun onPlayerError(error: PlaybackException) {
toast("${getString(R.string.media_service_generic_error)}: ${error.errorCodeName}", Toast.LENGTH_LONG)
}
}
/**
* Listen for MediaRoute changes
*/
private inner class MediaRouterCallback : MediaRouter.Callback() {
override fun onRouteSelected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) {
if (reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) {
Timber.d("Unselected because route changed, continue playback")
} else if (reason == MediaRouter.UNSELECT_REASON_STOPPED) {
Timber.d("Unselected because route was stopped, stop playback")
currentPlayer.stop()
}
}
}
companion object {
/** Declares that content style is supported */
const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
}
}

View file

@ -0,0 +1,457 @@
package org.jellyfin.mobile.player.audio.car
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import org.jellyfin.mobile.R
import org.jellyfin.mobile.player.audio.MediaService
import org.jellyfin.mobile.utils.extensions.mediaId
import org.jellyfin.mobile.utils.extensions.setAlbum
import org.jellyfin.mobile.utils.extensions.setAlbumArtUri
import org.jellyfin.mobile.utils.extensions.setAlbumArtist
import org.jellyfin.mobile.utils.extensions.setArtist
import org.jellyfin.mobile.utils.extensions.setDisplayIconUri
import org.jellyfin.mobile.utils.extensions.setMediaId
import org.jellyfin.mobile.utils.extensions.setMediaUri
import org.jellyfin.mobile.utils.extensions.setTitle
import org.jellyfin.mobile.utils.extensions.setTrackNumber
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.GenresApi
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.PlaylistsApi
import org.jellyfin.sdk.api.operations.UniversalAudioApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.*
@Suppress("TooManyFunctions")
class LibraryBrowser(
private val context: Context,
private val apiClient: ApiClient,
) {
private val itemsApi: ItemsApi = apiClient.itemsApi
private val userViewsApi: UserViewsApi = apiClient.userViewsApi
private val genresApi: GenresApi = apiClient.genresApi
private val playlistsApi: PlaylistsApi = apiClient.playlistsApi
private val imageApi: ImageApi = apiClient.imageApi
private val universalAudioApi: UniversalAudioApi = apiClient.universalAudioApi
fun getRoot(hints: Bundle?): MediaBrowserServiceCompat.BrowserRoot {
/**
* By default return the browsable root. Treat the EXTRA_RECENT flag as a special case
* and return the recent root instead.
*/
val isRecentRequest = hints?.getBoolean(MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT) ?: false
val browserRoot = if (isRecentRequest) LibraryPage.RESUME else LibraryPage.LIBRARIES
val rootExtras = Bundle().apply {
putBoolean(MediaService.CONTENT_STYLE_SUPPORTED, true)
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM,
)
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM,
)
}
return MediaBrowserServiceCompat.BrowserRoot(browserRoot, rootExtras)
}
suspend fun loadLibrary(parentId: String): List<MediaBrowserCompat.MediaItem>? {
if (parentId == LibraryPage.RESUME) {
return getDefaultRecents()?.browsable()
}
val split = parentId.split('|')
@Suppress("MagicNumber")
if (split.size !in 1..3) {
Timber.e("Invalid libraryId format '$parentId'")
return null
}
val type = split[0]
val libraryId = split.getOrNull(1)?.toUUIDOrNull()
val itemId = split.getOrNull(2)?.toUUIDOrNull()
return when {
libraryId != null -> {
when {
itemId != null -> when (type) {
LibraryPage.ARTIST_ALBUMS -> getAlbums(libraryId, filterArtist = itemId)
LibraryPage.GENRE_ALBUMS -> getAlbums(libraryId, filterGenre = itemId)
else -> null
}
else -> when (type) {
LibraryPage.LIBRARY -> getLibraryViews(context, libraryId)
LibraryPage.RECENTS -> getRecents(libraryId)?.playable()
LibraryPage.ALBUMS -> getAlbums(libraryId)
LibraryPage.ARTISTS -> getArtists(libraryId)
LibraryPage.GENRES -> getGenres(libraryId)
LibraryPage.PLAYLISTS -> getPlaylists(libraryId)
LibraryPage.ALBUM -> getAlbum(libraryId)?.playable()
LibraryPage.PLAYLIST -> getPlaylist(libraryId)?.playable()
else -> null
}
}
}
else -> when (type) {
LibraryPage.LIBRARIES -> getLibraries()
else -> null
}
}
}
suspend fun buildPlayQueue(mediaId: String): Pair<List<MediaMetadataCompat>, Int>? {
val split = mediaId.split('|')
@Suppress("MagicNumber")
if (split.size != 3) {
Timber.e("Invalid mediaId format '$mediaId'")
return null
}
val type = split[0]
val collectionId = split[1].toUUID()
val playQueue = try {
when (type) {
LibraryPage.RECENTS -> getRecents(collectionId)
LibraryPage.ALBUM -> getAlbum(collectionId)
LibraryPage.PLAYLIST -> getPlaylist(collectionId)
else -> null
}
} catch (e: ApiClientException) {
Timber.e(e)
null
} ?: return null
val playIndex = playQueue.indexOfFirst { item ->
item.mediaId == mediaId
}.coerceAtLeast(0)
return playQueue to playIndex
}
suspend fun getSearchResults(searchQuery: String, extras: Bundle?): List<MediaMetadataCompat>? {
when (extras?.getString(MediaStore.EXTRA_MEDIA_FOCUS)) {
MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> {
// Search for specific album
extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.let { albumQuery ->
Timber.d("Searching for album $albumQuery")
searchItems(albumQuery, BaseItemKind.MUSIC_ALBUM)
}?.let { albumId ->
getAlbum(albumId)
}?.let { albumContent ->
Timber.d("Got result, starting playback")
return albumContent
}
}
MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> {
// Search for specific artist
extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.let { artistQuery ->
Timber.d("Searching for artist $artistQuery")
searchItems(artistQuery, BaseItemKind.MUSIC_ARTIST)
}?.let { artistId ->
itemsApi.getItems(
artistIds = listOf(artistId),
includeItemTypes = listOf(BaseItemKind.AUDIO),
sortBy = listOf(ItemSortBy.RANDOM),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 50,
).content.extractItems()
}?.let { artistTracks ->
Timber.d("Got result, starting playback")
return artistTracks
}
}
}
// Fallback to generic search
Timber.d("Searching for '$searchQuery'")
val result by itemsApi.getItems(
searchTerm = searchQuery,
includeItemTypes = listOf(BaseItemKind.AUDIO),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 50,
)
return result.extractItems()
}
/**
* Find a single specific item for the given [searchQuery] with a specific [type]
*/
private suspend fun searchItems(searchQuery: String, type: BaseItemKind): UUID? {
val result by itemsApi.getItems(
searchTerm = searchQuery,
includeItemTypes = listOf(type),
recursive = true,
enableImages = false,
enableTotalRecordCount = false,
limit = 1,
)
return result.items.firstOrNull()?.id
}
suspend fun getDefaultRecents(): List<MediaMetadataCompat>? = getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary ->
val libraryId = defaultLibrary.split('|').getOrNull(1) ?: return@let null
getRecents(libraryId.toUUID())
}
private suspend fun getLibraries(): List<MediaBrowserCompat.MediaItem> {
val userViews by userViewsApi.getUserViews()
return userViews.items
.filter { item -> item.collectionType == CollectionType.MUSIC }
.map { item ->
val itemImageUrl = imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.PRIMARY,
)
val description = MediaDescriptionCompat.Builder().apply {
setMediaId(LibraryPage.LIBRARY + "|" + item.id)
setTitle(item.name)
setIconUri(Uri.parse(itemImageUrl))
}.build()
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
}
.toList()
}
private fun getLibraryViews(context: Context, libraryId: UUID): List<MediaBrowserCompat.MediaItem> {
val libraryViews = arrayOf(
LibraryPage.RECENTS to R.string.media_service_car_section_recents,
LibraryPage.ALBUMS to R.string.media_service_car_section_albums,
LibraryPage.ARTISTS to R.string.media_service_car_section_artists,
LibraryPage.GENRES to R.string.media_service_car_section_genres,
LibraryPage.PLAYLISTS to R.string.media_service_car_section_playlists,
)
return libraryViews.map { item ->
val description = MediaDescriptionCompat.Builder().apply {
setMediaId(item.first + "|" + libraryId)
setTitle(context.getString(item.second))
if (item.first == LibraryPage.ALBUMS) {
setExtras(
Bundle().apply {
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM,
)
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM,
)
},
)
}
}.build()
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
}
}
private suspend fun getRecents(libraryId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.AUDIO),
filters = listOf(ItemFilter.IS_PLAYED),
sortBy = listOf(ItemSortBy.DATE_PLAYED),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 50,
)
return result.extractItems("${LibraryPage.RECENTS}|$libraryId")
}
private suspend fun getAlbums(
libraryId: UUID,
filterArtist: UUID? = null,
filterGenre: UUID? = null,
): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
parentId = libraryId,
artistIds = filterArtist?.let(::listOf),
genreIds = filterGenre?.let(::listOf),
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 400,
)
return result.extractItems()?.browsable()
}
private suspend fun getArtists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 200,
)
return result.extractItems(libraryId.toString())?.browsable()
}
private suspend fun getGenres(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by genresApi.getGenres(
parentId = libraryId,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 50,
)
return result.extractItems(libraryId.toString())?.browsable()
}
private suspend fun getPlaylists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 50,
)
return result.extractItems()?.browsable()
}
private suspend fun getAlbum(albumId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItems(
parentId = albumId,
sortBy = listOf(ItemSortBy.SORT_NAME),
)
return result.extractItems("${LibraryPage.ALBUM}|$albumId")
}
private suspend fun getPlaylist(playlistId: UUID): List<MediaMetadataCompat>? {
val result by playlistsApi.getPlaylistItems(
playlistId = playlistId,
)
return result.extractItems("${LibraryPage.PLAYLIST}|$playlistId")
}
private fun BaseItemDtoQueryResult.extractItems(libraryId: String? = null): List<MediaMetadataCompat>? =
items?.map { item -> buildMediaMetadata(item, libraryId) }?.toList()
private fun buildMediaMetadata(item: BaseItemDto, libraryId: String?): MediaMetadataCompat {
val builder = MediaMetadataCompat.Builder()
builder.setMediaId(buildMediaId(item, libraryId))
builder.setTitle(item.name ?: context.getString(R.string.media_service_car_item_no_title))
val isAlbum = item.albumId != null
val itemId = when {
item.imageTags?.containsKey(ImageType.PRIMARY) == true -> item.id
isAlbum -> item.albumId
else -> null
}
val primaryImageUrl = itemId?.let {
imageApi.getItemImageUrl(
itemId = itemId,
imageType = ImageType.PRIMARY,
tag = if (isAlbum) item.albumPrimaryImageTag else item.imageTags?.get(ImageType.PRIMARY),
)
}
if (item.type == BaseItemKind.AUDIO) {
val uri = universalAudioApi.getUniversalAudioStreamUrl(
itemId = item.id,
deviceId = apiClient.deviceInfo.id,
maxStreamingBitrate = 140000000,
container = listOf(
"opus",
"mp3|mp3",
"aac",
"m4a",
"m4b|aac",
"flac",
"webma",
"webm",
"wav",
"ogg",
),
transcodingProtocol = MediaStreamProtocol.HLS,
transcodingContainer = "ts",
audioCodec = "aac",
enableRemoteMedia = true,
)
builder.setMediaUri(uri)
item.album?.let(builder::setAlbum)
item.artists?.let { builder.setArtist(it.joinToString()) }
item.albumArtist?.let(builder::setAlbumArtist)
primaryImageUrl?.let(builder::setAlbumArtUri)
item.indexNumber?.toLong()?.let(builder::setTrackNumber)
} else {
primaryImageUrl?.let(builder::setDisplayIconUri)
}
return builder.build()
}
private fun buildMediaId(item: BaseItemDto, extra: String?) = when (item.type) {
BaseItemKind.MUSIC_ARTIST -> "${LibraryPage.ARTIST_ALBUMS}|$extra|${item.id}"
BaseItemKind.MUSIC_GENRE -> "${LibraryPage.GENRE_ALBUMS}|$extra|${item.id}"
BaseItemKind.MUSIC_ALBUM -> "${LibraryPage.ALBUM}|${item.id}"
BaseItemKind.PLAYLIST -> "${LibraryPage.PLAYLIST}|${item.id}"
BaseItemKind.AUDIO -> "$extra|${item.id}"
else -> throw IllegalArgumentException("Unhandled item type ${item.type}")
}
private fun List<MediaMetadataCompat>.browsable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
MediaBrowserCompat.MediaItem(metadata.description, FLAG_BROWSABLE)
}
private fun List<MediaMetadataCompat>.playable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
MediaBrowserCompat.MediaItem(metadata.description, FLAG_PLAYABLE)
}
}

View file

@ -0,0 +1,63 @@
package org.jellyfin.mobile.player.audio.car
object LibraryPage {
/**
* List of music libraries that the user can access (referred to as "user views" in Jellyfin)
*/
const val LIBRARIES = "libraries"
/**
* Special root id for use with [EXTRA_RECENT][androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT]
*/
const val RESUME = "resume"
/**
* A single music library
*/
const val LIBRARY = "library"
/**
* A list of recently added tracks
*/
const val RECENTS = "recents"
/**
* A list of albums
*/
const val ALBUMS = "albums"
/**
* A list of artists
*/
const val ARTISTS = "artists"
/**
* A list of albums by a specific artist
*/
const val ARTIST_ALBUMS = "artist_albums"
/**
* A list of genres
*/
const val GENRES = "genres"
/**
* A list of albums with a specific genre
*/
const val GENRE_ALBUMS = "genre_albums"
/**
* A list of playlists
*/
const val PLAYLISTS = "playlists"
/**
* An individual album
*/
const val ALBUM = "album"
/**
* An individual playlist
*/
const val PLAYLIST = "playlist"
}

View file

@ -0,0 +1,9 @@
package org.jellyfin.mobile.player.cast
import com.google.android.exoplayer2.Player
interface ICastPlayerProvider {
val isCastSessionAvailable: Boolean
fun get(): Player?
}

View file

@ -0,0 +1,15 @@
package org.jellyfin.mobile.player.cast
import android.app.Activity
import org.jellyfin.mobile.bridge.JavascriptCallback
import org.json.JSONArray
import org.json.JSONException
interface IChromecast {
fun initializePlugin(activity: Activity)
@Throws(JSONException::class)
fun execute(action: String, args: JSONArray, cbContext: JavascriptCallback): Boolean
fun destroy()
}

View file

@ -0,0 +1,272 @@
package org.jellyfin.mobile.player.deviceprofile
import android.media.MediaCodecInfo.CodecProfileLevel
import android.media.MediaFormat
import com.google.android.exoplayer2.util.MimeTypes
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
object CodecHelpers {
fun getVideoCodec(mimeType: String): String? = when (mimeType) {
MediaFormat.MIMETYPE_VIDEO_MPEG2 -> "mpeg2video"
MediaFormat.MIMETYPE_VIDEO_H263 -> "h263"
MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
MediaFormat.MIMETYPE_VIDEO_HEVC, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION -> "hevc"
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
else -> null
}
fun getAudioCodec(mimeType: String): String? = when (mimeType) {
MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
MediaFormat.MIMETYPE_AUDIO_AC3 -> "ac3"
MediaFormat.MIMETYPE_AUDIO_AMR_WB, MediaFormat.MIMETYPE_AUDIO_AMR_NB -> "3gpp"
MediaFormat.MIMETYPE_AUDIO_EAC3 -> "eac3"
MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
MediaFormat.MIMETYPE_AUDIO_MPEG -> "mp3"
MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
MediaFormat.MIMETYPE_AUDIO_RAW -> "raw"
MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
MediaFormat.MIMETYPE_AUDIO_QCELP, MediaFormat.MIMETYPE_AUDIO_MSGSM, MediaFormat.MIMETYPE_AUDIO_G711_MLAW, MediaFormat.MIMETYPE_AUDIO_G711_ALAW -> null
else -> null
}
fun getVideoProfile(codec: String, profile: Int): String? = when (codec) {
"mpeg2video" -> getMPEG2VideoProfile(profile)
"h263" -> getH263Profile(profile)
"mpeg4" -> getMPEG4Profile(profile)
"h264" -> getAVCProfile(profile)
"hevc" -> getHEVCProfile(profile)
"vp8" -> getVP8Profile(profile)
"vp9" -> getVP9Profile(profile)
else -> null
}
private fun getMPEG2VideoProfile(profile: Int): String? = when (profile) {
CodecProfileLevel.MPEG2ProfileSimple -> "simple profile"
CodecProfileLevel.MPEG2ProfileMain -> "main profile"
CodecProfileLevel.MPEG2Profile422 -> "422 profile"
CodecProfileLevel.MPEG2ProfileSNR -> "snr profile"
CodecProfileLevel.MPEG2ProfileSpatial -> "spatial profile"
CodecProfileLevel.MPEG2ProfileHigh -> "high profile"
else -> null
}
private fun getH263Profile(profile: Int): String? = when (profile) {
CodecProfileLevel.H263ProfileBaseline -> "baseline"
CodecProfileLevel.H263ProfileH320Coding -> "h320 coding"
CodecProfileLevel.H263ProfileBackwardCompatible -> "backward compatible"
CodecProfileLevel.H263ProfileISWV2 -> "isw v2"
CodecProfileLevel.H263ProfileISWV3 -> "isw v3"
CodecProfileLevel.H263ProfileHighCompression -> "high compression"
CodecProfileLevel.H263ProfileInternet -> "internet"
CodecProfileLevel.H263ProfileInterlace -> "interlace"
CodecProfileLevel.H263ProfileHighLatency -> "high latency"
else -> null
}
private fun getMPEG4Profile(profile: Int): String? = when (profile) {
CodecProfileLevel.MPEG4ProfileAdvancedCoding -> "advanced coding profile"
CodecProfileLevel.MPEG4ProfileAdvancedCore -> "advanced core profile"
CodecProfileLevel.MPEG4ProfileAdvancedRealTime -> "advanced realtime profile"
CodecProfileLevel.MPEG4ProfileAdvancedSimple -> "advanced simple profile"
CodecProfileLevel.MPEG4ProfileBasicAnimated -> "basic animated profile"
CodecProfileLevel.MPEG4ProfileCore -> "core profile"
CodecProfileLevel.MPEG4ProfileCoreScalable -> "core scalable profile"
CodecProfileLevel.MPEG4ProfileHybrid -> "hybrid profile"
CodecProfileLevel.MPEG4ProfileNbit -> "nbit profile"
CodecProfileLevel.MPEG4ProfileScalableTexture -> "scalable texture profile"
CodecProfileLevel.MPEG4ProfileSimple -> "simple profile"
CodecProfileLevel.MPEG4ProfileSimpleFBA -> "simple fba profile"
CodecProfileLevel.MPEG4ProfileSimpleFace -> "simple face profile"
CodecProfileLevel.MPEG4ProfileSimpleScalable -> "simple scalable profile"
CodecProfileLevel.MPEG4ProfileMain -> "main profile"
else -> null
}
private fun getAVCProfile(profile: Int): String? = when (profile) {
CodecProfileLevel.AVCProfileBaseline -> "baseline"
CodecProfileLevel.AVCProfileMain -> "main"
CodecProfileLevel.AVCProfileExtended -> "extended"
CodecProfileLevel.AVCProfileHigh -> "high"
CodecProfileLevel.AVCProfileHigh10 -> "high 10"
CodecProfileLevel.AVCProfileHigh422 -> "high 422"
CodecProfileLevel.AVCProfileHigh444 -> "high 444"
CodecProfileLevel.AVCProfileConstrainedBaseline -> "constrained baseline"
CodecProfileLevel.AVCProfileConstrainedHigh -> "constrained high"
else -> null
}
private fun getHEVCProfile(profile: Int): String? = when (profile) {
CodecProfileLevel.HEVCProfileMain -> "Main"
CodecProfileLevel.HEVCProfileMain10 -> "Main 10"
CodecProfileLevel.HEVCProfileMain10HDR10 -> "Main 10 HDR 10"
CodecProfileLevel.HEVCProfileMain10HDR10Plus -> "Main 10 HDR 10 Plus"
CodecProfileLevel.HEVCProfileMainStill -> "Main Still"
else -> null
}
private fun getVP8Profile(profile: Int): String? = when (profile) {
CodecProfileLevel.VP8ProfileMain -> "main"
else -> null
}
private fun getVP9Profile(profile: Int): String? = when (profile) {
CodecProfileLevel.VP9Profile0 -> "Profile 0"
CodecProfileLevel.VP9Profile1 -> "Profile 1"
CodecProfileLevel.VP9Profile2,
CodecProfileLevel.VP9Profile2HDR,
-> "Profile 2"
CodecProfileLevel.VP9Profile3,
CodecProfileLevel.VP9Profile3HDR,
-> "Profile 3"
else -> null
}
fun getVideoLevel(codec: String, level: Int): Int? = when (codec) {
"mpeg2video" -> getMPEG2VideoLevel(level)
"h263" -> getH263Level(level)
"mpeg4" -> getMPEG4Level(level)
"avc", "h264" -> getAVCLevel(level)
"hevc" -> getHEVCLevel(level)
"vp8" -> getVP8Level(level)
"vp9" -> getVP9Level(level)
else -> null
}?.let { Integer.valueOf(it) }
/**
* Level numbers taken from FFmpeg `libavcodec/mpeg12enc.c`.
*/
private fun getMPEG2VideoLevel(level: Int): String? = when (level) {
CodecProfileLevel.MPEG2LevelLL -> "10"
CodecProfileLevel.MPEG2LevelML -> "8"
CodecProfileLevel.MPEG2LevelH14 -> "6"
CodecProfileLevel.MPEG2LevelHL -> "4"
else -> null
}
private fun getH263Level(level: Int): String? = when (level) {
CodecProfileLevel.H263Level10 -> "10"
CodecProfileLevel.H263Level20 -> "20"
CodecProfileLevel.H263Level30 -> "30"
CodecProfileLevel.H263Level40 -> "40"
CodecProfileLevel.H263Level45 -> "45"
CodecProfileLevel.H263Level50 -> "50"
CodecProfileLevel.H263Level60 -> "60"
CodecProfileLevel.H263Level70 -> "70"
else -> null
}
private fun getMPEG4Level(level: Int): String? = when (level) {
CodecProfileLevel.MPEG4Level0 -> "0"
CodecProfileLevel.MPEG4Level1 -> "1"
CodecProfileLevel.MPEG4Level2 -> "2"
CodecProfileLevel.MPEG4Level3 -> "3"
CodecProfileLevel.MPEG4Level4 -> "4"
CodecProfileLevel.MPEG4Level5 -> "5"
CodecProfileLevel.MPEG4Level6 -> "6"
CodecProfileLevel.MPEG4Level0b, CodecProfileLevel.MPEG4Level3b, CodecProfileLevel.MPEG4Level4a -> null
else -> null
}
private fun getAVCLevel(level: Int): String? = when (level) {
CodecProfileLevel.AVCLevel1 -> "1"
CodecProfileLevel.AVCLevel11 -> "11"
CodecProfileLevel.AVCLevel12 -> "12"
CodecProfileLevel.AVCLevel13 -> "13"
CodecProfileLevel.AVCLevel2 -> "2"
CodecProfileLevel.AVCLevel21 -> "21"
CodecProfileLevel.AVCLevel22 -> "22"
CodecProfileLevel.AVCLevel3 -> "3"
CodecProfileLevel.AVCLevel31 -> "31"
CodecProfileLevel.AVCLevel32 -> "32"
CodecProfileLevel.AVCLevel4 -> "4"
CodecProfileLevel.AVCLevel41 -> "41"
CodecProfileLevel.AVCLevel42 -> "42"
CodecProfileLevel.AVCLevel5 -> "5"
CodecProfileLevel.AVCLevel51 -> "51"
CodecProfileLevel.AVCLevel52 -> "52"
CodecProfileLevel.AVCLevel1b -> null
else -> null
}
private fun getHEVCLevel(level: Int): String? = when (level) {
CodecProfileLevel.HEVCMainTierLevel1, CodecProfileLevel.HEVCHighTierLevel1 -> "30"
CodecProfileLevel.HEVCMainTierLevel2, CodecProfileLevel.HEVCHighTierLevel2 -> "60"
CodecProfileLevel.HEVCMainTierLevel21, CodecProfileLevel.HEVCHighTierLevel21 -> "63"
CodecProfileLevel.HEVCMainTierLevel3, CodecProfileLevel.HEVCHighTierLevel3 -> "90"
CodecProfileLevel.HEVCMainTierLevel31, CodecProfileLevel.HEVCHighTierLevel31 -> "93"
CodecProfileLevel.HEVCMainTierLevel4, CodecProfileLevel.HEVCHighTierLevel4 -> "120"
CodecProfileLevel.HEVCMainTierLevel41, CodecProfileLevel.HEVCHighTierLevel41 -> "123"
CodecProfileLevel.HEVCMainTierLevel5, CodecProfileLevel.HEVCHighTierLevel5 -> "150"
CodecProfileLevel.HEVCMainTierLevel51, CodecProfileLevel.HEVCHighTierLevel51 -> "153"
CodecProfileLevel.HEVCMainTierLevel52, CodecProfileLevel.HEVCHighTierLevel52 -> "156"
CodecProfileLevel.HEVCMainTierLevel6, CodecProfileLevel.HEVCHighTierLevel6 -> "180"
CodecProfileLevel.HEVCMainTierLevel61, CodecProfileLevel.HEVCHighTierLevel61 -> "183"
CodecProfileLevel.HEVCMainTierLevel62, CodecProfileLevel.HEVCHighTierLevel62 -> "186"
else -> null
}
private fun getVP8Level(level: Int): String? = when (level) {
CodecProfileLevel.VP8Level_Version0 -> "0"
CodecProfileLevel.VP8Level_Version1 -> "1"
CodecProfileLevel.VP8Level_Version2 -> "2"
CodecProfileLevel.VP8Level_Version3 -> "3"
else -> null
}
private fun getVP9Level(level: Int): String? = when (level) {
CodecProfileLevel.VP9Level1 -> "1"
CodecProfileLevel.VP9Level11 -> "11"
CodecProfileLevel.VP9Level2 -> "2"
CodecProfileLevel.VP9Level21 -> "21"
CodecProfileLevel.VP9Level3 -> "3"
CodecProfileLevel.VP9Level31 -> "31"
CodecProfileLevel.VP9Level4 -> "4"
CodecProfileLevel.VP9Level41 -> "41"
CodecProfileLevel.VP9Level5 -> "5"
CodecProfileLevel.VP9Level51 -> "51"
CodecProfileLevel.VP9Level52 -> "52"
CodecProfileLevel.VP9Level6 -> "6"
CodecProfileLevel.VP9Level61 -> "61"
CodecProfileLevel.VP9Level62 -> "62"
else -> null
}
/**
* Get the mimeType for a subtitle codec if supported.
*
* @param codec Subtitle codec given by Jellyfin.
* @return The mimeType or null if not supported.
*/
fun getSubtitleMimeType(codec: String?): String? {
return when (codec) {
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
"ssa", "ass" -> MimeTypes.TEXT_SSA
"ttml" -> MimeTypes.APPLICATION_TTML
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"idx", "sub" -> MimeTypes.APPLICATION_VOBSUB
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
"smi", "smil" -> "application/smil+xml"
else -> null
}
}
fun getAudioProfile(codec: String, profile: Int): String? = when (codec) {
"aac" -> getAACProfile(profile)
else -> null
}
private fun getAACProfile(profile: Int): String? = when (profile) {
CodecProfileLevel.AACObjectELD -> "ELD"
CodecProfileLevel.AACObjectHE -> "HE-AAC"
CodecProfileLevel.AACObjectHE_PS -> "HE-AACv2"
CodecProfileLevel.AACObjectLC -> "LC"
CodecProfileLevel.AACObjectLD -> "LD"
CodecProfileLevel.AACObjectLTP -> "LTP"
CodecProfileLevel.AACObjectMain -> "Main"
CodecProfileLevel.AACObjectSSR -> "SSR"
else -> null
}
}

View file

@ -0,0 +1,104 @@
package org.jellyfin.mobile.player.deviceprofile
import android.media.MediaCodecInfo.CodecCapabilities
import android.util.Range
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getAudioCodec
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getAudioProfile
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoCodec
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoLevel
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoProfile
import kotlin.math.max
sealed class DeviceCodec(
val name: String,
val mimeType: String,
val profiles: Set<String>,
val maxBitrate: Int,
) {
class Video(
name: String,
mimeType: String,
profiles: Set<String>,
private val levels: Set<Int>,
maxBitrate: Int,
) : DeviceCodec(name, mimeType, profiles, maxBitrate) {
fun mergeCodec(codecToMerge: Video): Video = Video(
name = name,
mimeType = mimeType,
profiles = profiles + codecToMerge.profiles,
levels = levels + codecToMerge.levels,
maxBitrate = max(maxBitrate, codecToMerge.maxBitrate),
)
}
class Audio(
name: String,
mimeType: String,
profiles: Set<String>,
maxBitrate: Int,
private val maxChannels: Int,
private val maxSampleRate: Int?,
) : DeviceCodec(name, mimeType, profiles, maxBitrate) {
fun mergeCodec(codecToMerge: Audio): Audio = Audio(
name = name,
mimeType = mimeType,
profiles = profiles + codecToMerge.profiles,
maxBitrate = max(maxBitrate, codecToMerge.maxBitrate),
maxChannels = max(maxChannels, codecToMerge.maxChannels),
maxSampleRate = when {
maxSampleRate != null -> when {
codecToMerge.maxSampleRate != null -> max(maxSampleRate, codecToMerge.maxSampleRate)
else -> maxSampleRate
}
else -> codecToMerge.maxSampleRate
},
)
}
companion object {
fun from(codecCapabilities: CodecCapabilities): DeviceCodec? {
val mimeType = codecCapabilities.mimeType
// Check if this mimeType represents a video or audio codec
val videoCodec = getVideoCodec(mimeType)
val audioCodec = getAudioCodec(mimeType)
return when {
videoCodec != null -> {
val profiles = HashSet<String>()
val levels = HashSet<Int>()
for (profileLevel in codecCapabilities.profileLevels) {
getVideoProfile(videoCodec, profileLevel.profile)?.let(profiles::add)
getVideoLevel(videoCodec, profileLevel.level)?.let(levels::add)
}
Video(
name = videoCodec,
mimeType = mimeType,
profiles = profiles,
levels = levels,
maxBitrate = codecCapabilities.videoCapabilities.bitrateRange.upper,
)
}
audioCodec != null -> {
val profiles = HashSet<String>()
for (profileLevel in codecCapabilities.profileLevels) {
getAudioProfile(audioCodec, profileLevel.profile)?.let(profiles::add)
}
Audio(
name = audioCodec,
mimeType = mimeType,
profiles = profiles,
maxBitrate = codecCapabilities.audioCapabilities.bitrateRange.upper,
maxChannels = codecCapabilities.audioCapabilities.maxInputChannelCount,
maxSampleRate = codecCapabilities.audioCapabilities.supportedSampleRateRanges
.maxOfOrNull(Range<Int>::getUpper),
)
}
else -> return null
}
}
}
}

View file

@ -0,0 +1,300 @@
package org.jellyfin.mobile.player.deviceprofile
import android.media.MediaCodecList
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.model.api.CodecProfile
import org.jellyfin.sdk.model.api.ContainerProfile
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodingProfile
class DeviceProfileBuilder(
private val appPreferences: AppPreferences,
) {
private val supportedVideoCodecs: Array<Array<String>>
private val supportedAudioCodecs: Array<Array<String>>
private val transcodingProfiles: List<TranscodingProfile>
init {
require(
SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_VIDEO_CODECS.size && SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_AUDIO_CODECS.size,
)
// Load Android-supported codecs
val videoCodecs: MutableMap<String, DeviceCodec.Video> = HashMap()
val audioCodecs: MutableMap<String, DeviceCodec.Audio> = HashMap()
val androidCodecs = MediaCodecList(MediaCodecList.REGULAR_CODECS)
for (codecInfo in androidCodecs.codecInfos) {
if (codecInfo.isEncoder) continue
for (mimeType in codecInfo.supportedTypes) {
val codec = DeviceCodec.from(codecInfo.getCapabilitiesForType(mimeType)) ?: continue
val name = codec.name
when (codec) {
is DeviceCodec.Video -> {
if (videoCodecs.containsKey(name)) {
videoCodecs[name] = videoCodecs[name]!!.mergeCodec(codec)
} else {
videoCodecs[name] = codec
}
}
is DeviceCodec.Audio -> {
if (audioCodecs.containsKey(mimeType)) {
audioCodecs[name] = audioCodecs[name]!!.mergeCodec(codec)
} else {
audioCodecs[name] = codec
}
}
}
}
}
// Build map of supported codecs from device support and hardcoded data
supportedVideoCodecs = Array(AVAILABLE_VIDEO_CODECS.size) { i ->
AVAILABLE_VIDEO_CODECS[i].filter { codec ->
videoCodecs.containsKey(codec)
}.toTypedArray()
}
supportedAudioCodecs = Array(AVAILABLE_AUDIO_CODECS.size) { i ->
AVAILABLE_AUDIO_CODECS[i].filter { codec ->
audioCodecs.containsKey(codec) || codec in FORCED_AUDIO_CODECS
}.toTypedArray()
}
transcodingProfiles = listOf(
TranscodingProfile(
type = DlnaProfileType.VIDEO,
container = "ts",
videoCodec = "h264",
audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd",
protocol = MediaStreamProtocol.HLS,
conditions = emptyList(),
),
TranscodingProfile(
type = DlnaProfileType.VIDEO,
container = "mkv",
videoCodec = "h264",
audioCodec = AVAILABLE_AUDIO_CODECS[SUPPORTED_CONTAINER_FORMATS.indexOf("mkv")].joinToString(","),
protocol = MediaStreamProtocol.HLS,
conditions = emptyList(),
),
TranscodingProfile(
type = DlnaProfileType.AUDIO,
container = "mp3",
videoCodec = "",
audioCodec = "mp3",
protocol = MediaStreamProtocol.HTTP,
conditions = emptyList(),
),
)
}
fun getDeviceProfile(): DeviceProfile {
val containerProfiles = ArrayList<ContainerProfile>()
val directPlayProfiles = ArrayList<DirectPlayProfile>()
val codecProfiles = ArrayList<CodecProfile>()
for (i in SUPPORTED_CONTAINER_FORMATS.indices) {
val container = SUPPORTED_CONTAINER_FORMATS[i]
if (supportedVideoCodecs[i].isNotEmpty()) {
containerProfiles.add(
ContainerProfile(type = DlnaProfileType.VIDEO, container = container, conditions = emptyList()),
)
directPlayProfiles.add(
DirectPlayProfile(
type = DlnaProfileType.VIDEO,
container = SUPPORTED_CONTAINER_FORMATS[i],
videoCodec = supportedVideoCodecs[i].joinToString(","),
audioCodec = supportedAudioCodecs[i].joinToString(","),
),
)
}
if (supportedAudioCodecs[i].isNotEmpty()) {
containerProfiles.add(
ContainerProfile(type = DlnaProfileType.AUDIO, container = container, conditions = emptyList()),
)
directPlayProfiles.add(
DirectPlayProfile(
type = DlnaProfileType.AUDIO,
container = SUPPORTED_CONTAINER_FORMATS[i],
audioCodec = supportedAudioCodecs[i].joinToString(","),
),
)
}
}
val subtitleProfiles = when {
appPreferences.exoPlayerDirectPlayAss -> {
getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES + SUBTITLES_SSA, EXO_EXTERNAL_SUBTITLES + SUBTITLES_SSA)
}
else -> getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES, EXO_EXTERNAL_SUBTITLES)
}
return DeviceProfile(
name = Constants.APP_INFO_NAME,
directPlayProfiles = directPlayProfiles,
transcodingProfiles = transcodingProfiles,
containerProfiles = containerProfiles,
codecProfiles = codecProfiles,
subtitleProfiles = subtitleProfiles,
maxStreamingBitrate = MAX_STREAMING_BITRATE,
maxStaticBitrate = MAX_STATIC_BITRATE,
musicStreamingTranscodingBitrate = MAX_MUSIC_TRANSCODING_BITRATE,
)
}
private fun getSubtitleProfiles(embedded: Array<String>, external: Array<String>): List<SubtitleProfile> = ArrayList<SubtitleProfile>().apply {
for (format in embedded) {
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED))
}
for (format in external) {
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL))
}
}
fun getExternalPlayerProfile(): DeviceProfile = DeviceProfile(
name = EXTERNAL_PLAYER_PROFILE_NAME,
directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO, container = ""),
DirectPlayProfile(type = DlnaProfileType.AUDIO, container = ""),
),
transcodingProfiles = emptyList(),
containerProfiles = emptyList(),
codecProfiles = emptyList(),
subtitleProfiles = buildList {
EXTERNAL_PLAYER_SUBTITLES.mapTo(this) { format ->
SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED)
}
EXTERNAL_PLAYER_SUBTITLES.mapTo(this) { format ->
SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL)
}
},
maxStreamingBitrate = Int.MAX_VALUE,
maxStaticBitrate = Int.MAX_VALUE,
musicStreamingTranscodingBitrate = Int.MAX_VALUE,
)
companion object {
private const val EXTERNAL_PLAYER_PROFILE_NAME = Constants.APP_INFO_NAME + " External Player"
/**
* List of container formats supported by ExoPlayer
*
* IMPORTANT: Don't change without updating [AVAILABLE_VIDEO_CODECS] and [AVAILABLE_AUDIO_CODECS]
*/
private val SUPPORTED_CONTAINER_FORMATS = arrayOf(
"mp4", "fmp4", "webm", "mkv", "mp3", "ogg", "wav", "mpegts", "flv", "aac", "flac", "3gp",
)
/**
* IMPORTANT: Must have same length as [SUPPORTED_CONTAINER_FORMATS],
* as it maps the codecs to the containers with the same index!
*/
private val AVAILABLE_VIDEO_CODECS = arrayOf(
// mp4
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
// fmp4
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
// webm
arrayOf("vp8", "vp9", "av1"),
// mkv
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp8", "vp9", "av1"),
// mp3
emptyArray(),
// ogg
emptyArray(),
// wav
emptyArray(),
// mpegts
arrayOf("mpeg1video", "mpeg2video", "mpeg4", "h264", "hevc"),
// flv
arrayOf("mpeg4", "h264"),
// aac
emptyArray(),
// flac
emptyArray(),
// 3gp
arrayOf("h263", "mpeg4", "h264", "hevc"),
)
/**
* List of PCM codecs supported by ExoPlayer by default
*/
private val PCM_CODECS = arrayOf(
"pcm_s8",
"pcm_s16be",
"pcm_s16le",
"pcm_s24le",
"pcm_s32le",
"pcm_f32le",
"pcm_alaw",
"pcm_mulaw",
)
/**
* IMPORTANT: Must have same length as [SUPPORTED_CONTAINER_FORMATS],
* as it maps the codecs to the containers with the same index!
*/
private val AVAILABLE_AUDIO_CODECS = arrayOf(
// mp4
arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3"),
// fmp4
arrayOf("mp3", "aac", "ac3", "eac3"),
// webm
arrayOf("vorbis", "opus"),
// mkv
arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts", "mlp", "truehd"),
// mp3
arrayOf("mp3"),
// ogg
arrayOf("vorbis", "opus", "flac"),
// wav
PCM_CODECS,
// mpegts
arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "ac3", "eac3", "dts", "mlp", "truehd"),
// flv
arrayOf("mp3", "aac"),
// aac
arrayOf("aac"),
// flac
arrayOf("flac"),
// 3gp
arrayOf("3gpp", "aac", "flac"),
)
/**
* List of audio codecs that will be added to the device profile regardless of [MediaCodecList] advertising them.
* This is especially useful for codecs supported by decoders integrated to ExoPlayer or added through an extension.
*/
private val FORCED_AUDIO_CODECS = arrayOf(*PCM_CODECS, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd")
private val EXO_EMBEDDED_SUBTITLES = arrayOf("dvbsub", "pgssub", "srt", "subrip", "ttml")
private val EXO_EXTERNAL_SUBTITLES = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt")
private val SUBTITLES_SSA = arrayOf("ssa", "ass")
private val EXTERNAL_PLAYER_SUBTITLES = arrayOf("ass", "dvbsub", "pgssub", "srt", "srt", "ssa", "subrip", "subrip", "ttml", "ttml", "vtt", "webvtt")
/**
* Taken from Jellyfin Web:
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L276
*/
private const val MAX_STREAMING_BITRATE = 120000000
/**
* Taken from Jellyfin Web:
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L372
*/
private const val MAX_STATIC_BITRATE = 100000000
/**
* Taken from Jellyfin Web:
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L373
*/
private const val MAX_MUSIC_TRANSCODING_BITRATE = 384000
}
}

View file

@ -0,0 +1,42 @@
package org.jellyfin.mobile.player.interaction
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.jellyfin.mobile.utils.extensions.size
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.util.UUID
@Parcelize
data class PlayOptions(
val ids: List<UUID>,
val mediaSourceId: String?,
val startIndex: Int,
val startPositionTicks: Long?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
) : Parcelable {
companion object {
fun fromJson(json: JSONObject): PlayOptions? = try {
PlayOptions(
ids = json.optJSONArray("ids")?.let { array ->
ArrayList<UUID>().apply {
for (i in 0 until array.size) {
array.getString(i).toUUIDOrNull()?.let(this::add)
}
}
} ?: emptyList(),
mediaSourceId = json.optString("mediaSourceId"),
startIndex = json.optInt("startIndex"),
startPositionTicks = json.optLong("startPositionTicks").takeIf { it > 0 },
audioStreamIndex = json.optString("audioStreamIndex").toIntOrNull(),
subtitleStreamIndex = json.optString("subtitleStreamIndex").toIntOrNull(),
)
} catch (e: JSONException) {
Timber.e(e, "Failed to parse playback options: %s", json)
null
}
}
}

View file

@ -0,0 +1,10 @@
package org.jellyfin.mobile.player.interaction
sealed class PlayerEvent {
object Pause : PlayerEvent()
object Resume : PlayerEvent()
object Stop : PlayerEvent()
object Destroy : PlayerEvent()
data class Seek(val ms: Long) : PlayerEvent()
data class SetVolume(val volume: Int) : PlayerEvent()
}

View file

@ -0,0 +1,18 @@
package org.jellyfin.mobile.player.interaction
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.jellyfin.mobile.player.PlayerViewModel
class PlayerLifecycleObserver(private val viewModel: PlayerViewModel) : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
viewModel.setupPlayer()
}
override fun onStop(owner: LifecycleOwner) {
if (!viewModel.notificationHelper.allowBackgroundAudio) {
viewModel.pause()
}
}
}

View file

@ -0,0 +1,40 @@
package org.jellyfin.mobile.player.interaction
import android.annotation.SuppressLint
import android.media.session.MediaSession
import org.jellyfin.mobile.player.PlayerViewModel
@SuppressLint("MissingOnPlayFromSearch")
class PlayerMediaSessionCallback(private val viewModel: PlayerViewModel) : MediaSession.Callback() {
override fun onPlay() {
viewModel.play()
}
override fun onPause() {
viewModel.pause()
}
override fun onSeekTo(pos: Long) {
viewModel.playerOrNull?.seekTo(pos)
}
override fun onRewind() {
viewModel.rewind()
}
override fun onFastForward() {
viewModel.fastForward()
}
override fun onSkipToPrevious() {
viewModel.skipToPrevious()
}
override fun onSkipToNext() {
viewModel.skipToNext()
}
override fun onStop() {
viewModel.stop()
}
}

View file

@ -0,0 +1,48 @@
package org.jellyfin.mobile.player.interaction
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.jellyfin.mobile.R
import org.jellyfin.mobile.utils.Constants
enum class PlayerNotificationAction(
val action: String,
@DrawableRes val icon: Int,
@StringRes val label: Int,
) {
PLAY(
Constants.ACTION_PLAY,
R.drawable.ic_play_black_42dp,
R.string.notification_action_play,
),
PAUSE(
Constants.ACTION_PAUSE,
R.drawable.ic_pause_black_42dp,
R.string.notification_action_pause,
),
REWIND(
Constants.ACTION_REWIND,
R.drawable.ic_rewind_black_32dp,
R.string.notification_action_rewind,
),
FAST_FORWARD(
Constants.ACTION_FAST_FORWARD,
R.drawable.ic_fast_forward_black_32dp,
R.string.notification_action_fast_forward,
),
PREVIOUS(
Constants.ACTION_PREVIOUS,
R.drawable.ic_skip_previous_black_32dp,
R.string.notification_action_previous,
),
NEXT(
Constants.ACTION_NEXT,
R.drawable.ic_skip_next_black_32dp,
R.string.notification_action_next,
),
STOP(
Constants.ACTION_STOP,
0,
R.string.notification_action_stop,
),
}

View file

@ -0,0 +1,198 @@
package org.jellyfin.mobile.player.interaction
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.media.MediaMetadata
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.Player
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.player.PlayerViewModel
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID
import org.jellyfin.mobile.utils.createMediaNotificationChannel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.model.api.ImageType
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import java.util.concurrent.atomic.AtomicBoolean
class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinComponent {
private val context: Context = viewModel.getApplication()
private val appPreferences: AppPreferences by inject()
private val notificationManager: NotificationManager? by lazy { context.getSystemService() }
private val imageApi: ImageApi = get<ApiClient>().imageApi
private val imageLoader: ImageLoader by inject()
private val receiverRegistered = AtomicBoolean(false)
val allowBackgroundAudio: Boolean
get() = appPreferences.exoPlayerAllowBackgroundAudio
private val notificationActionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Constants.ACTION_PLAY -> viewModel.play()
Constants.ACTION_PAUSE -> viewModel.pause()
Constants.ACTION_REWIND -> viewModel.rewind()
Constants.ACTION_FAST_FORWARD -> viewModel.fastForward()
Constants.ACTION_PREVIOUS -> viewModel.skipToPrevious()
Constants.ACTION_NEXT -> viewModel.skipToNext()
Constants.ACTION_STOP -> viewModel.stop()
}
}
}
@Suppress("DEPRECATION", "LongMethod", "CyclomaticComplexMethod")
fun postNotification() {
val nm = notificationManager ?: return
val player = viewModel.playerOrNull ?: return
val currentMediaSource = viewModel.queueManager.currentMediaSourceOrNull ?: return
val hasPrevious = viewModel.queueManager.hasPrevious()
val hasNext = viewModel.queueManager.hasNext()
val playbackState = player.playbackState
if (playbackState != Player.STATE_READY && playbackState != Player.STATE_BUFFERING) return
// Create notification channel
context.createMediaNotificationChannel(nm)
viewModel.viewModelScope.launch {
val mediaIcon: Bitmap? = withContext(Dispatchers.IO) {
loadImage(currentMediaSource)
}
val style = Notification.MediaStyle().apply {
setMediaSession(viewModel.mediaSession.sessionToken)
setShowActionsInCompactView(0, 1, 2)
}
val notification = Notification.Builder(context).apply {
if (AndroidVersion.isAtLeastO) {
// Set notification channel on Android O and above
setChannelId(Constants.MEDIA_NOTIFICATION_CHANNEL_ID)
setColorized(true)
} else {
setPriority(Notification.PRIORITY_LOW)
}
setSmallIcon(R.drawable.ic_notification)
mediaIcon?.let(::setLargeIcon)
setContentTitle(currentMediaSource.name)
currentMediaSource.item?.artists?.joinToString()?.let(::setContentText)
setStyle(style)
setVisibility(Notification.VISIBILITY_PUBLIC)
when {
hasPrevious -> addAction(generateAction(PlayerNotificationAction.PREVIOUS))
else -> addAction(generateAction(PlayerNotificationAction.REWIND))
}
val playbackAction = when {
!player.playWhenReady -> PlayerNotificationAction.PLAY
else -> PlayerNotificationAction.PAUSE
}
addAction(generateAction(playbackAction))
when {
hasNext -> addAction(generateAction(PlayerNotificationAction.NEXT))
else -> addAction(generateAction(PlayerNotificationAction.FAST_FORWARD))
}
setContentIntent(buildContentIntent())
setDeleteIntent(buildDeleteIntent())
// prevents the notification from being dismissed while playback is ongoing
setOngoing(player.isPlaying)
}.build()
nm.notify(VIDEO_PLAYER_NOTIFICATION_ID, notification)
mediaIcon?.let {
viewModel.mediaSession.controller.metadata?.let {
if (!it.containsKey(MediaMetadata.METADATA_KEY_ART)) {
viewModel.mediaSession.setMetadata(
MediaMetadata.Builder(it)
.putBitmap(MediaMetadata.METADATA_KEY_ART, mediaIcon)
.build(),
)
}
}
}
}
if (receiverRegistered.compareAndSet(false, true)) {
val filter = IntentFilter()
for (notificationAction in PlayerNotificationAction.values()) {
filter.addAction(notificationAction.action)
}
ContextCompat.registerReceiver(
context,
notificationActionReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
}
fun dismissNotification() {
notificationManager?.cancel(VIDEO_PLAYER_NOTIFICATION_ID)
if (receiverRegistered.compareAndSet(true, false)) {
context.unregisterReceiver(notificationActionReceiver)
}
}
private suspend fun loadImage(mediaSource: JellyfinMediaSource): Bitmap? {
val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
val imageUrl = imageApi.getItemImageUrl(
itemId = mediaSource.itemId,
imageType = ImageType.PRIMARY,
maxWidth = size,
maxHeight = size,
)
val imageRequest = ImageRequest.Builder(context).data(imageUrl).build()
return imageLoader.execute(imageRequest).drawable?.toBitmap()
}
private fun generateAction(playerNotificationAction: PlayerNotificationAction): Notification.Action {
val intent = Intent(playerNotificationAction.action).apply {
`package` = BuildConfig.APPLICATION_ID
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, Constants.PENDING_INTENT_FLAGS)
@Suppress("DEPRECATION")
return Notification.Action.Builder(
playerNotificationAction.icon,
context.getString(playerNotificationAction.label),
pendingIntent,
).build()
}
private fun buildContentIntent(): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
}
return PendingIntent.getActivity(context, 0, intent, Constants.PENDING_INTENT_FLAGS)
}
private fun buildDeleteIntent(): PendingIntent {
val intent = Intent(Constants.ACTION_STOP).apply {
`package` = BuildConfig.APPLICATION_ID
}
return PendingIntent.getBroadcast(context, 0, intent, Constants.PENDING_INTENT_FLAGS)
}
}

View file

@ -0,0 +1,6 @@
package org.jellyfin.mobile.player.qualityoptions
data class QualityOption(
val maxHeight: Int,
val bitrate: Int,
)

View file

@ -0,0 +1,46 @@
package org.jellyfin.mobile.player.qualityoptions
import android.util.Rational
import org.jellyfin.mobile.utils.Constants
class QualityOptionsProvider {
private val defaultQualityOptions = listOf(
QualityOption(maxHeight = 2160, bitrate = 120000000),
QualityOption(maxHeight = 2160, bitrate = 80000000),
QualityOption(maxHeight = 1080, bitrate = 60000000),
QualityOption(maxHeight = 1080, bitrate = 40000000),
QualityOption(maxHeight = 1080, bitrate = 20000000),
QualityOption(maxHeight = 1080, bitrate = 15000000),
QualityOption(maxHeight = 1080, bitrate = 10000000),
QualityOption(maxHeight = 720, bitrate = 8000000),
QualityOption(maxHeight = 720, bitrate = 6000000),
QualityOption(maxHeight = 720, bitrate = 4000000),
QualityOption(maxHeight = 480, bitrate = 3000000),
QualityOption(maxHeight = 480, bitrate = 1500000),
QualityOption(maxHeight = 480, bitrate = 720000),
QualityOption(maxHeight = 360, bitrate = 420000),
QualityOption(maxHeight = 0, bitrate = 0), // auto
)
@Suppress("MagicNumber")
fun getApplicableQualityOptions(videoWidth: Int, videoHeight: Int): List<QualityOption> {
// If the aspect ratio is less than 16/9, set the width as if it were pillarboxed
// i.e. 4:3 1440x1080 -> 1920x1080
val maxAllowedWidth = when {
Rational(videoWidth, videoHeight) < Constants.ASPECT_RATIO_16_9 -> videoHeight * 16 / 9
else -> videoWidth
}
val maxAllowedHeight = when {
maxAllowedWidth >= 3800 -> 2160
// Some 1080p videos are apparently reported as 1912
maxAllowedWidth >= 1900 -> 1080
maxAllowedWidth >= 1260 -> 720
maxAllowedWidth >= 620 -> 480
else -> 360
}
return defaultQualityOptions.takeLastWhile { option -> option.maxHeight <= maxAllowedHeight }
}
}

View file

@ -0,0 +1,330 @@
package org.jellyfin.mobile.player.queue
import android.net.Uri
import androidx.annotation.CheckResult
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.MergingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.SingleSampleMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.mobile.player.PlayerViewModel
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.player.source.MediaSourceResolver
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.VideosApi
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import java.util.UUID
class QueueManager(
private val viewModel: PlayerViewModel,
) : KoinComponent {
private val apiClient: ApiClient = get()
private val videosApi: VideosApi = apiClient.videosApi
private val mediaSourceResolver: MediaSourceResolver by inject()
private val deviceProfileBuilder: DeviceProfileBuilder by inject()
private val deviceProfile = deviceProfileBuilder.getDeviceProfile()
private var currentQueue: List<UUID> = emptyList()
private var currentQueueIndex: Int = 0
private val _currentMediaSource: MutableLiveData<JellyfinMediaSource> = MutableLiveData()
val currentMediaSource: LiveData<JellyfinMediaSource>
get() = _currentMediaSource
inline val currentMediaSourceOrNull: JellyfinMediaSource?
get() = currentMediaSource.value
/**
* Handle initial playback options from fragment.
* Start of a playback session that can contain one or multiple played videos.
*
* @return an error of type [PlayerException] or null on success.
*/
suspend fun initializePlaybackQueue(playOptions: PlayOptions): PlayerException? {
currentQueue = playOptions.ids
currentQueueIndex = playOptions.startIndex
val itemId = when {
currentQueue.isNotEmpty() -> currentQueue[currentQueueIndex]
else -> playOptions.mediaSourceId?.toUUIDOrNull()
} ?: return PlayerException.InvalidPlayOptions()
startPlayback(
itemId = itemId,
mediaSourceId = playOptions.mediaSourceId,
maxStreamingBitrate = null,
startTimeTicks = playOptions.startPositionTicks,
audioStreamIndex = playOptions.audioStreamIndex,
subtitleStreamIndex = playOptions.subtitleStreamIndex,
playWhenReady = true,
)
return null
}
/**
* Play a specific media item specified by [itemId] and [mediaSourceId].
*
* @return an error of type [PlayerException] or null on success.
*/
private suspend fun startPlayback(
itemId: UUID,
mediaSourceId: String?,
maxStreamingBitrate: Int?,
startTimeTicks: Long? = null,
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
playWhenReady: Boolean = true,
): PlayerException? {
mediaSourceResolver.resolveMediaSource(
itemId = itemId,
mediaSourceId = mediaSourceId,
deviceProfile = deviceProfile,
maxStreamingBitrate = maxStreamingBitrate,
startTimeTicks = startTimeTicks,
audioStreamIndex = audioStreamIndex,
subtitleStreamIndex = subtitleStreamIndex,
).onSuccess { jellyfinMediaSource ->
// Ensure transcoding of the current element is stopped
currentMediaSourceOrNull?.let { oldMediaSource ->
viewModel.stopTranscoding(oldMediaSource)
}
_currentMediaSource.value = jellyfinMediaSource
// Load new media source
viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady)
}.onFailure { error ->
// Should always be of this type, other errors are silently dropped
return error as? PlayerException
}
return null
}
/**
* Reinitialize current media source without changing settings
*/
fun tryRestartPlayback() {
val currentMediaSource = currentMediaSourceOrNull ?: return
viewModel.load(currentMediaSource, prepareStreams(currentMediaSource), playWhenReady = true)
}
/**
* Change the maximum bitrate to the specified value.
*/
suspend fun changeBitrate(bitrate: Int?): Boolean {
val currentMediaSource = currentMediaSourceOrNull ?: return false
// Bitrate didn't change, ignore
if (currentMediaSource.maxStreamingBitrate == bitrate) return true
val currentPlayState = viewModel.getStateAndPause() ?: return false
return startPlayback(
itemId = currentMediaSource.itemId,
mediaSourceId = currentMediaSource.id,
maxStreamingBitrate = bitrate,
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
playWhenReady = currentPlayState.playWhenReady,
) == null
}
fun hasPrevious(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex > 0
fun hasNext(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex < currentQueue.lastIndex
suspend fun previous(): Boolean {
if (!hasPrevious()) return false
val currentMediaSource = currentMediaSourceOrNull ?: return false
startPlayback(
itemId = currentQueue[--currentQueueIndex],
mediaSourceId = null,
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
)
return true
}
suspend fun next(): Boolean {
if (!hasNext()) return false
val currentMediaSource = currentMediaSourceOrNull ?: return false
startPlayback(
itemId = currentQueue[++currentQueueIndex],
mediaSourceId = null,
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
)
return true
}
/**
* Builds the [MediaSource] to be played by ExoPlayer.
*
* @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played.
* @return A [MediaSource]. This can be the media stream of the correct type for the playback method or
* a [MergingMediaSource] containing the mentioned media stream and all external subtitle streams.
*/
@CheckResult
private fun prepareStreams(source: JellyfinMediaSource): MediaSource {
val videoSource = createVideoMediaSource(source)
val subtitleSources = createExternalSubtitleMediaSources(source)
return when {
subtitleSources.isNotEmpty() -> MergingMediaSource(videoSource, *subtitleSources)
else -> videoSource
}
}
/**
* Builds the [MediaSource] for the main media stream (video/audio/embedded subs).
*
* @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played.
* @return A [MediaSource]. The type of MediaSource depends on the playback method/protocol.
*/
@CheckResult
private fun createVideoMediaSource(source: JellyfinMediaSource): MediaSource {
val sourceInfo = source.sourceInfo
val (url, factory) = when (source.playMethod) {
PlayMethod.DIRECT_PLAY -> {
when (sourceInfo.protocol) {
MediaProtocol.FILE -> {
val url = videosApi.getVideoStreamUrl(
itemId = source.itemId,
static = true,
playSessionId = source.playSessionId,
mediaSourceId = source.id,
deviceId = apiClient.deviceInfo.id,
)
url to get<ProgressiveMediaSource.Factory>()
}
MediaProtocol.HTTP -> {
val url = requireNotNull(sourceInfo.path)
val factory = get<HlsMediaSource.Factory>().setAllowChunklessPreparation(true)
url to factory
}
else -> throw IllegalArgumentException("Unsupported protocol ${sourceInfo.protocol}")
}
}
PlayMethod.DIRECT_STREAM -> {
val container = requireNotNull(sourceInfo.container) { "Missing direct stream container" }
val url = videosApi.getVideoStreamByContainerUrl(
itemId = source.itemId,
container = container,
playSessionId = source.playSessionId,
mediaSourceId = source.id,
deviceId = apiClient.deviceInfo.id,
)
url to get<ProgressiveMediaSource.Factory>()
}
PlayMethod.TRANSCODE -> {
val transcodingPath = requireNotNull(sourceInfo.transcodingUrl) { "Missing transcode URL" }
val protocol = sourceInfo.transcodingSubProtocol
require(protocol == MediaStreamProtocol.HLS) { "Unsupported transcode protocol '$protocol'" }
val transcodingUrl = apiClient.createUrl(transcodingPath)
val factory = get<HlsMediaSource.Factory>().setAllowChunklessPreparation(true)
transcodingUrl to factory
}
}
val mediaItem = MediaItem.Builder()
.setMediaId(source.itemId.toString())
.setUri(url)
.build()
return factory.createMediaSource(mediaItem)
}
/**
* Creates [MediaSource]s for all external subtitle streams in the [JellyfinMediaSource].
*
* @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played.
* @return The parsed MediaSources for the subtitles.
*/
@CheckResult
private fun createExternalSubtitleMediaSources(
source: JellyfinMediaSource,
): Array<MediaSource> {
val factory = get<SingleSampleMediaSource.Factory>()
return source.externalSubtitleStreams.map { stream ->
val uri = Uri.parse(apiClient.createUrl(stream.deliveryUrl))
val mediaItem = MediaItem.SubtitleConfiguration.Builder(uri).apply {
setId("${ExternalSubtitleStream.ID_PREFIX}${stream.index}")
setLabel(stream.displayTitle)
setMimeType(stream.mimeType)
setLanguage(stream.language)
}.build()
factory.createMediaSource(mediaItem, source.runTimeMs)
}.toTypedArray()
}
/**
* Switch to the specified [audio stream][stream] and restart playback, for example while transcoding.
*
* @return true if playback was restarted with the new selection.
*/
suspend fun selectAudioStreamAndRestartPlayback(stream: MediaStream): Boolean {
require(stream.type == MediaStreamType.AUDIO)
val currentMediaSource = currentMediaSourceOrNull ?: return false
val currentPlayState = viewModel.getStateAndPause() ?: return false
startPlayback(
itemId = currentMediaSource.itemId,
mediaSourceId = currentMediaSource.id,
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
audioStreamIndex = stream.index,
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
playWhenReady = currentPlayState.playWhenReady,
)
return true
}
/**
* Switch to the specified [subtitle stream][stream] and restart playback,
* for example because the selected subtitle has to be encoded into the video.
*
* @param stream The subtitle stream to select, or null to disable subtitles.
* @return true if playback was restarted with the new selection.
*/
suspend fun selectSubtitleStreamAndRestartPlayback(stream: MediaStream?): Boolean {
require(stream == null || stream.type == MediaStreamType.SUBTITLE)
val currentMediaSource = currentMediaSourceOrNull ?: return false
val currentPlayState = viewModel.getStateAndPause() ?: return false
startPlayback(
itemId = currentMediaSource.itemId,
mediaSourceId = currentMediaSource.id,
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle
playWhenReady = currentPlayState.playWhenReady,
)
return true
}
}

View file

@ -0,0 +1,13 @@
package org.jellyfin.mobile.player.source
data class ExternalSubtitleStream(
val index: Int,
val deliveryUrl: String,
val mimeType: String,
val displayTitle: String,
val language: String,
) {
companion object {
const val ID_PREFIX = "external:"
}
}

View file

@ -0,0 +1,166 @@
package org.jellyfin.mobile.player.source
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import java.util.UUID
class JellyfinMediaSource(
val itemId: UUID,
val item: BaseItemDto?,
val sourceInfo: MediaSourceInfo,
val playSessionId: String,
val liveStreamId: String?,
val maxStreamingBitrate: Int?,
private var startTimeTicks: Long? = null,
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
) {
val id: String = requireNotNull(sourceInfo.id) { "Media source has no id" }
val name: String = item?.name ?: sourceInfo.name.orEmpty()
val playMethod: PlayMethod = when {
sourceInfo.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
sourceInfo.supportsDirectStream -> PlayMethod.DIRECT_STREAM
sourceInfo.supportsTranscoding -> PlayMethod.TRANSCODE
else -> throw IllegalArgumentException("No play method found for $name ($itemId)")
}
var startTimeMs: Long
get() = (startTimeTicks ?: 0L) / Constants.TICKS_PER_MILLISECOND
set(value) {
startTimeTicks = value * Constants.TICKS_PER_MILLISECOND
}
val runTimeTicks: Long = sourceInfo.runTimeTicks ?: 0
val runTimeMs: Long = runTimeTicks / Constants.TICKS_PER_MILLISECOND
val mediaStreams: List<MediaStream> = sourceInfo.mediaStreams.orEmpty()
val audioStreams: List<MediaStream>
val subtitleStreams: List<MediaStream>
val externalSubtitleStreams: List<ExternalSubtitleStream>
var selectedVideoStream: MediaStream? = null
private set
var selectedAudioStream: MediaStream? = null
private set
var selectedSubtitleStream: MediaStream? = null
private set
val selectedAudioStreamIndex: Int?
get() = selectedAudioStream?.index
val selectedSubtitleStreamIndex: Int
// -1 disables subtitles, null would select the default subtitle
// If the default should be played, it would be explicitly set above
get() = selectedSubtitleStream?.index ?: -1
init {
// Classify MediaStreams
val audio = ArrayList<MediaStream>()
val subtitles = ArrayList<MediaStream>()
val externalSubtitles = ArrayList<ExternalSubtitleStream>()
for (mediaStream in mediaStreams) {
when (mediaStream.type) {
MediaStreamType.VIDEO -> {
// Always select the first available video stream
if (selectedVideoStream == null) {
selectedVideoStream = mediaStream
}
}
MediaStreamType.AUDIO -> {
audio += mediaStream
if (mediaStream.index == (audioStreamIndex ?: sourceInfo.defaultAudioStreamIndex)) {
selectedAudioStream = mediaStream
}
}
MediaStreamType.SUBTITLE -> {
subtitles += mediaStream
if (mediaStream.index == (subtitleStreamIndex ?: sourceInfo.defaultSubtitleStreamIndex)) {
selectedSubtitleStream = mediaStream
}
// External subtitles as specified by the deliveryMethod.
// It is set to external either for external subtitle files or when transcoding.
// In the latter case, subtitles are extracted from the source file by the server.
if (mediaStream.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL) {
val deliveryUrl = mediaStream.deliveryUrl
val mimeType = CodecHelpers.getSubtitleMimeType(mediaStream.codec)
if (deliveryUrl != null && mimeType != null) {
externalSubtitles += ExternalSubtitleStream(
index = mediaStream.index,
deliveryUrl = deliveryUrl,
mimeType = mimeType,
displayTitle = mediaStream.displayTitle.orEmpty(),
language = mediaStream.language ?: Constants.LANGUAGE_UNDEFINED,
)
}
}
}
MediaStreamType.EMBEDDED_IMAGE,
MediaStreamType.DATA,
MediaStreamType.LYRIC,
-> Unit // ignore
}
}
audioStreams = audio
subtitleStreams = subtitles
externalSubtitleStreams = externalSubtitles
}
/**
* Select the specified [audio stream][stream] in the source.
*
* @param stream The stream to select.
* @return true if the stream was found and selected, false otherwise.
*/
fun selectAudioStream(stream: MediaStream): Boolean {
require(stream.type == MediaStreamType.AUDIO)
if (mediaStreams[stream.index] !== stream) {
return false
}
selectedAudioStream = stream
return true
}
/**
* Select the specified [subtitle stream][stream] in the source.
*
* @param stream The stream to select, or null to disable subtitles.
* @return true if the stream was found and selected, false otherwise.
*/
fun selectSubtitleStream(stream: MediaStream?): Boolean {
if (stream == null) {
selectedSubtitleStream = null
return true
}
require(stream.type == MediaStreamType.SUBTITLE)
if (mediaStreams[stream.index] !== stream) {
return false
}
selectedSubtitleStream = stream
return true
}
/**
* Returns the index of the media stream within the embedded streams.
* Useful for handling track selection in ExoPlayer, where embedded streams are mapped first.
*/
fun getEmbeddedStreamIndex(mediaStream: MediaStream): Int {
var index = 0
for (stream in mediaStreams) {
when {
stream === mediaStream -> return index
!stream.isExternal -> index++
}
}
throw IllegalArgumentException("Invalid media stream")
}
}

View file

@ -0,0 +1,87 @@
package org.jellyfin.mobile.player.source
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.MediaInfoApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
class MediaSourceResolver(private val apiClient: ApiClient) {
private val mediaInfoApi: MediaInfoApi = apiClient.mediaInfoApi
private val userLibraryApi: UserLibraryApi = apiClient.userLibraryApi
@Suppress("ReturnCount")
suspend fun resolveMediaSource(
itemId: UUID,
mediaSourceId: String? = null,
deviceProfile: DeviceProfile? = null,
maxStreamingBitrate: Int? = null,
startTimeTicks: Long? = null,
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
autoOpenLiveStream: Boolean = true,
): Result<JellyfinMediaSource> {
// Load media source info
val playSessionId: String
val mediaSourceInfo = try {
val response by mediaInfoApi.getPostedPlaybackInfo(
itemId = itemId,
data = PlaybackInfoDto(
// We need to remove the dashes so that the server can find the correct media source.
// And if we didn't pass the mediaSourceId, our stream indices would silently get ignored.
// https://github.com/jellyfin/jellyfin/blob/9a35fd673203cfaf0098138b2768750f4818b3ab/Jellyfin.Api/Helpers/MediaInfoHelper.cs#L196-L201
mediaSourceId = mediaSourceId ?: itemId.toString().replace("-", ""),
deviceProfile = deviceProfile,
maxStreamingBitrate = maxStreamingBitrate,
startTimeTicks = startTimeTicks,
audioStreamIndex = audioStreamIndex,
subtitleStreamIndex = subtitleStreamIndex,
autoOpenLiveStream = autoOpenLiveStream,
),
)
playSessionId = response.playSessionId ?: return Result.failure(PlayerException.UnsupportedContent())
response.mediaSources.let { sources ->
sources.find { source -> source.id?.toUUIDOrNull() == itemId } ?: sources.firstOrNull()
} ?: return Result.failure(PlayerException.UnsupportedContent())
} catch (e: ApiClientException) {
Timber.e(e, "Failed to load media source $itemId")
return Result.failure(PlayerException.NetworkFailure(e))
}
// Load additional item info if possible
val item = try {
userLibraryApi.getItem(itemId).content
} catch (e: ApiClientException) {
Timber.e(e, "Failed to load item for media source $itemId")
null
}
// Create JellyfinMediaSource
return try {
val source = JellyfinMediaSource(
itemId = itemId,
item = item,
sourceInfo = mediaSourceInfo,
playSessionId = playSessionId,
liveStreamId = mediaSourceInfo.liveStreamId,
maxStreamingBitrate = maxStreamingBitrate,
startTimeTicks = startTimeTicks,
audioStreamIndex = audioStreamIndex,
subtitleStreamIndex = subtitleStreamIndex,
)
Result.success(source)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Cannot create JellyfinMediaSource")
Result.failure(PlayerException.UnsupportedContent(e))
}
}
}

View file

@ -0,0 +1,9 @@
package org.jellyfin.mobile.player.ui
/**
* Represents the type of decoder
*/
enum class DecoderType {
HARDWARE,
SOFTWARE,
}

View file

@ -0,0 +1,8 @@
package org.jellyfin.mobile.player.ui
import org.jellyfin.mobile.utils.Constants
data class DisplayPreferences(
val skipBackLength: Long = Constants.DEFAULT_SEEK_TIME_MS,
val skipForwardLength: Long = Constants.DEFAULT_SEEK_TIME_MS,
)

View file

@ -0,0 +1,6 @@
package org.jellyfin.mobile.player.ui
data class PlayState(
val playWhenReady: Boolean,
val position: Long,
)

View file

@ -0,0 +1,413 @@
package org.jellyfin.mobile.player.ui
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.OrientationEventListener
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import android.widget.ImageButton
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerView
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.mobile.player.PlayerViewModel
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.BackPressInterceptor
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS
import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL
import org.jellyfin.mobile.utils.Constants.PIP_MIN_RATIONAL
import org.jellyfin.mobile.utils.SmartOrientationListener
import org.jellyfin.mobile.utils.brightness
import org.jellyfin.mobile.utils.extensions.aspectRational
import org.jellyfin.mobile.utils.extensions.getParcelableCompat
import org.jellyfin.mobile.utils.extensions.isLandscape
import org.jellyfin.mobile.utils.extensions.keepScreenOn
import org.jellyfin.mobile.utils.toast
import org.jellyfin.sdk.model.api.MediaStream
import org.koin.android.ext.android.inject
import com.google.android.exoplayer2.ui.R as ExoplayerR
class PlayerFragment : Fragment(), BackPressInterceptor {
private val appPreferences: AppPreferences by inject()
private val viewModel: PlayerViewModel by viewModels()
private var _playerBinding: FragmentPlayerBinding? = null
private val playerBinding: FragmentPlayerBinding get() = _playerBinding!!
private val playerView: PlayerView get() = playerBinding.playerView
private val playerOverlay: View get() = playerBinding.playerOverlay
private val loadingIndicator: View get() = playerBinding.loadingIndicator
private var _playerControlsBinding: ExoPlayerControlViewBinding? = null
private val playerControlsBinding: ExoPlayerControlViewBinding get() = _playerControlsBinding!!
private val playerControlsView: View get() = playerControlsBinding.root
private val toolbar: Toolbar get() = playerControlsBinding.toolbar
private val fullscreenSwitcher: ImageButton get() = playerControlsBinding.fullscreenSwitcher
private var playerMenus: PlayerMenus? = null
private lateinit var playerFullscreenHelper: PlayerFullscreenHelper
lateinit var playerLockScreenHelper: PlayerLockScreenHelper
lateinit var playerGestureHelper: PlayerGestureHelper
private val currentVideoStream: MediaStream?
get() = viewModel.mediaSourceOrNull?.selectedVideoStream
/**
* Listener that watches the current device orientation.
* It makes sure that the orientation sensor can still be used (if enabled)
* after toggling the orientation through the fullscreen button.
*
* If the requestedOrientation was reset directly after setting it in the fullscreenSwitcher click handler,
* the orientation would get reverted before the user had any chance to rotate the device to the desired position.
*/
private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(requireActivity()) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val window = requireActivity().window
playerFullscreenHelper = PlayerFullscreenHelper(window)
// Observe ViewModel
viewModel.player.observe(this) { player ->
playerView.player = player
if (player == null) parentFragmentManager.popBackStack()
}
viewModel.playerState.observe(this) { playerState ->
val isPlaying = viewModel.playerOrNull?.isPlaying == true
requireActivity().window.keepScreenOn = isPlaying
loadingIndicator.isVisible = playerState == Player.STATE_BUFFERING
}
viewModel.decoderType.observe(this) { type ->
playerMenus?.updatedSelectedDecoder(type)
}
viewModel.error.observe(this) { message ->
val safeMessage = message.ifEmpty { requireContext().getString(R.string.player_error_unspecific_exception) }
requireContext().toast(safeMessage)
}
viewModel.queueManager.currentMediaSource.observe(this) { mediaSource ->
if (mediaSource.selectedVideoStream?.isLandscape == false) {
// For portrait videos, immediately enable fullscreen
playerFullscreenHelper.enableFullscreen()
} else if (appPreferences.exoPlayerStartLandscapeVideoInLandscape) {
// Auto-switch to landscape for landscape videos if enabled
requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
// Update title and player menus
toolbar.title = mediaSource.name
playerMenus?.onQueueItemChanged(mediaSource, viewModel.queueManager.hasNext())
}
// Handle fragment arguments, extract playback options and start playback
lifecycleScope.launch {
val context = requireContext()
val playOptions = requireArguments().getParcelableCompat<PlayOptions>(Constants.EXTRA_MEDIA_PLAY_OPTIONS)
if (playOptions == null) {
context.toast(R.string.player_error_invalid_play_options)
return@launch
}
when (viewModel.queueManager.initializePlaybackQueue(playOptions)) {
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
null -> Unit // success
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_playerBinding = FragmentPlayerBinding.inflate(layoutInflater)
_playerControlsBinding = ExoPlayerControlViewBinding.bind(playerBinding.root.findViewById(R.id.player_controls))
return playerBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Insets handling
ViewCompat.setOnApplyWindowInsetsListener(playerBinding.root) { _, insets ->
playerFullscreenHelper.onWindowInsetsChanged(insets)
val systemInsets = when {
AndroidVersion.isAtLeastR -> insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars())
else -> insets.getInsets(WindowInsetsCompat.Type.systemBars())
}
if (playerFullscreenHelper.isFullscreen) {
playerView.setPadding(0)
playerControlsView.updatePadding(
left = systemInsets.left,
top = systemInsets.top,
right = systemInsets.right,
bottom = systemInsets.bottom,
)
} else {
playerView.updatePadding(
left = systemInsets.left,
top = systemInsets.top,
right = systemInsets.right,
bottom = systemInsets.bottom,
)
playerControlsView.setPadding(0) // Padding is handled by PlayerView
}
playerOverlay.updatePadding(
left = systemInsets.left,
top = systemInsets.top,
right = systemInsets.right,
bottom = systemInsets.bottom,
)
// Update fullscreen switcher icon
val fullscreenDrawable = when {
playerFullscreenHelper.isFullscreen -> R.drawable.ic_fullscreen_exit_white_32dp
else -> R.drawable.ic_fullscreen_enter_white_32dp
}
fullscreenSwitcher.setImageResource(fullscreenDrawable)
insets
}
// Handle toolbar back button
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
// Create playback menus
playerMenus = PlayerMenus(this, playerBinding, playerControlsBinding)
// Set controller timeout
suppressControllerAutoHide(false)
playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)
// Handle fullscreen switcher
fullscreenSwitcher.setOnClickListener {
toggleFullscreen()
}
}
override fun onStart() {
super.onStart()
orientationListener.enable()
}
override fun onResume() {
super.onResume()
// When returning from another app, fullscreen mode for landscape orientation has to be set again
if (isLandscape()) {
playerFullscreenHelper.enableFullscreen()
}
}
/**
* Handle current orientation and update fullscreen state and switcher icon
*/
private fun updateFullscreenState(configuration: Configuration) {
// Do not handle any orientation changes while being in Picture-in-Picture mode
if (AndroidVersion.isAtLeastN && requireActivity().isInPictureInPictureMode) {
return
}
when {
isLandscape(configuration) -> {
// Landscape orientation is always fullscreen
playerFullscreenHelper.enableFullscreen()
}
currentVideoStream?.isLandscape != false -> {
// Disable fullscreen for landscape video in portrait orientation
playerFullscreenHelper.disableFullscreen()
}
}
}
/**
* Toggle fullscreen.
*
* If playing a portrait video, this just hides the status and navigation bars.
* For landscape videos, additionally the screen gets rotated.
*/
private fun toggleFullscreen() {
val videoTrack = currentVideoStream
if (videoTrack == null || videoTrack.isLandscape) {
val current = resources.configuration.orientation
requireActivity().requestedOrientation = when (current) {
Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// No need to call playerFullscreenHelper in this case,
// since the configuration change triggers updateFullscreenState,
// which does it for us.
} else {
playerFullscreenHelper.toggleFullscreen()
}
}
/**
* If true, the player controls will show indefinitely
*/
fun suppressControllerAutoHide(suppress: Boolean) {
playerView.controllerShowTimeoutMs = if (suppress) -1 else DEFAULT_CONTROLS_TIMEOUT_MS
}
fun isLandscape(configuration: Configuration = resources.configuration) =
configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
fun onRewind() = viewModel.rewind()
fun onFastForward() = viewModel.fastForward()
/**
* @param callback called if track selection was successful and UI needs to be updated
*/
fun onAudioTrackSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch {
if (viewModel.trackSelectionHelper.selectAudioTrack(index)) {
callback.onTrackSelected(true)
}
}
/**
* @param callback called if track selection was successful and UI needs to be updated
*/
fun onSubtitleSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch {
if (viewModel.trackSelectionHelper.selectSubtitleTrack(index)) {
callback.onTrackSelected(true)
}
}
/**
* Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple.
*
* @return true if subtitles are enabled now, false if not
*/
fun toggleSubtitles(callback: TrackSelectionCallback) = lifecycleScope.launch {
callback.onTrackSelected(viewModel.trackSelectionHelper.toggleSubtitles())
}
fun onBitrateChanged(bitrate: Int?, callback: TrackSelectionCallback) = lifecycleScope.launch {
callback.onTrackSelected(viewModel.changeBitrate(bitrate))
}
/**
* @return true if the playback speed was changed
*/
fun onSpeedSelected(speed: Float): Boolean {
return viewModel.setPlaybackSpeed(speed)
}
fun onDecoderSelected(type: DecoderType) {
viewModel.updateDecoderType(type)
}
fun onSkipToPrevious() {
viewModel.skipToPrevious()
}
fun onSkipToNext() {
viewModel.skipToNext()
}
fun onPopupDismissed() {
if (!AndroidVersion.isAtLeastR) {
updateFullscreenState(resources.configuration)
}
}
fun onUserLeaveHint() {
if (AndroidVersion.isAtLeastN && viewModel.playerOrNull?.isPlaying == true) {
requireActivity().enterPictureInPicture()
}
}
@Suppress("NestedBlockDepth")
@RequiresApi(Build.VERSION_CODES.N)
private fun Activity.enterPictureInPicture() {
if (AndroidVersion.isAtLeastO) {
val params = PictureInPictureParams.Builder().apply {
val aspectRational = currentVideoStream?.aspectRational?.let { aspectRational ->
when {
aspectRational < PIP_MIN_RATIONAL -> PIP_MIN_RATIONAL
aspectRational > PIP_MAX_RATIONAL -> PIP_MAX_RATIONAL
else -> aspectRational
}
}
setAspectRatio(aspectRational)
val contentFrame: View = playerView.findViewById(ExoplayerR.id.exo_content_frame)
val contentRect = with(contentFrame) {
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
Rect(x, y, x + width, y + height)
}
setSourceRectHint(contentRect)
}.build()
enterPictureInPictureMode(params)
} else {
@Suppress("DEPRECATION")
enterPictureInPictureMode()
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
playerView.useController = !isInPictureInPictureMode
if (isInPictureInPictureMode) {
playerMenus?.dismissPlaybackInfo()
playerLockScreenHelper.hideUnlockButton()
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
Handler(Looper.getMainLooper()).post {
updateFullscreenState(newConfig)
playerGestureHelper.handleConfiguration(newConfig)
}
}
override fun onStop() {
super.onStop()
orientationListener.disable()
}
override fun onDestroyView() {
super.onDestroyView()
// Detach player from PlayerView
playerView.player = null
// Set binding references to null
_playerBinding = null
_playerControlsBinding = null
playerMenus = null
}
override fun onDestroy() {
super.onDestroy()
with(requireActivity()) {
// Reset screen orientation
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
playerFullscreenHelper.disableFullscreen()
// Reset screen brightness
window.brightness = BRIGHTNESS_OVERRIDE_NONE
}
}
}

View file

@ -0,0 +1,42 @@
package org.jellyfin.mobile.player.ui
import android.view.View
import android.view.Window
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.extensions.hasFlag
class PlayerFullscreenHelper(private val window: Window) {
private val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
var isFullscreen: Boolean = false
private set
fun onWindowInsetsChanged(insets: WindowInsetsCompat) {
isFullscreen = when {
AndroidVersion.isAtLeastR -> {
// Type.systemBars() doesn't work here because this would also check for the navigation bar
// which doesn't exist on all devices
!insets.isVisible(WindowInsetsCompat.Type.statusBars())
}
else -> {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility.hasFlag(View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}
}
fun enableFullscreen() {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
fun disableFullscreen() {
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
}
fun toggleFullscreen() {
if (isFullscreen) disableFullscreen() else enableFullscreen()
}
}

View file

@ -0,0 +1,270 @@
package org.jellyfin.mobile.player.ui
import android.content.res.Configuration
import android.media.AudioManager
import android.provider.Settings
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import androidx.core.content.getSystemService
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.PlayerView
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.brightness
import org.jellyfin.mobile.utils.dip
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.math.abs
class PlayerGestureHelper(
private val fragment: PlayerFragment,
private val playerBinding: FragmentPlayerBinding,
private val playerLockScreenHelper: PlayerLockScreenHelper,
) : KoinComponent {
private val appPreferences: AppPreferences by inject()
private val audioManager: AudioManager by lazy { fragment.requireActivity().getSystemService()!! }
private val playerView: PlayerView by playerBinding::playerView
private val gestureIndicatorOverlayLayout: LinearLayout by playerBinding::gestureOverlayLayout
private val gestureIndicatorOverlayImage: ImageView by playerBinding::gestureOverlayImage
private val gestureIndicatorOverlayProgress: ProgressBar by playerBinding::gestureOverlayProgress
init {
if (appPreferences.exoPlayerRememberBrightness) {
fragment.requireActivity().window.brightness = appPreferences.exoPlayerBrightness
}
}
/**
* Tracks whether video content should fill the screen, cutting off unwanted content on the sides.
* Useful on wide-screen phones to remove black bars from some movies.
*/
private var isZoomEnabled = false
/**
* Tracks a value during a swipe gesture (between multiple onScroll calls).
* When the gesture starts it's reset to an initial value and gets increased or decreased
* (depending on the direction) as the gesture progresses.
*/
private var swipeGestureValueTracker = -1f
/**
* Runnable that hides [playerView] controller
*/
private val hidePlayerViewControllerAction = Runnable {
playerView.hideController()
}
/**
* Runnable that hides [gestureIndicatorOverlayLayout]
*/
private val hideGestureIndicatorOverlayAction = Runnable {
gestureIndicatorOverlayLayout.isVisible = false
}
/**
* Handles taps when controls are locked
*/
private val unlockDetector = GestureDetector(
playerView.context,
object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
playerLockScreenHelper.peekUnlockButton()
return true
}
},
)
/**
* Handles double tap to seek and brightness/volume gestures
*/
private val gestureDetector = GestureDetector(
playerView.context,
object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
val viewWidth = playerView.measuredWidth
val viewHeight = playerView.measuredHeight
val viewCenterX = viewWidth / 2
val viewCenterY = viewHeight / 2
val isFastForward = e.x.toInt() > viewCenterX
// Show ripple effect
playerView.foreground?.apply {
val left = if (isFastForward) viewCenterX else 0
val right = if (isFastForward) viewWidth else viewCenterX
setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2)
setHotspot(e.x, e.y)
state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed)
playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) {
state = IntArray(0)
}
}
// Fast-forward/rewind
with(fragment) { if (isFastForward) onFastForward() else onRewind() }
// Cancel previous runnable to not hide controller while seeking
playerView.removeCallbacks(hidePlayerViewControllerAction)
// Ensure controller gets hidden after seeking
playerView.postDelayed(hidePlayerViewControllerAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong())
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
playerView.apply {
if (!isControllerVisible) showController() else hideController()
}
return true
}
override fun onScroll(
firstEvent: MotionEvent?,
currentEvent: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
if (!appPreferences.exoPlayerAllowSwipeGestures) {
return false
}
// Check whether swipe was started in excluded region
val exclusionSize = playerView.resources.dip(Constants.SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL)
if (
firstEvent == null ||
firstEvent.y < exclusionSize ||
firstEvent.y > playerView.height - exclusionSize
) {
return false
}
// Check whether swipe was oriented vertically
if (abs(distanceY / distanceX) < 2) {
return false
}
val viewCenterX = playerView.measuredWidth / 2
// Distance to swipe to go from min to max
val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO
val ratioChange = distanceY / distanceFull
if (firstEvent.x.toInt() > viewCenterX) {
// Swiping on the right, change volume
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat()
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val change = ratioChange * maxVolume
swipeGestureValueTracker += change
val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0)
gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_volume_white_24dp)
gestureIndicatorOverlayProgress.max = maxVolume
gestureIndicatorOverlayProgress.progress = toSet
} else {
// Swiping on the left, change brightness
val window = fragment.requireActivity().window
val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL
// Initialize on first swipe
if (swipeGestureValueTracker == -1f) {
val brightness = window.brightness
swipeGestureValueTracker = when (brightness) {
in brightnessRange -> brightness
else -> {
Settings.System.getFloat(
fragment.requireActivity().contentResolver,
Settings.System.SCREEN_BRIGHTNESS,
) / Constants.SCREEN_BRIGHTNESS_MAX
}
}
}
swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange)
window.brightness = swipeGestureValueTracker
if (appPreferences.exoPlayerRememberBrightness) {
appPreferences.exoPlayerBrightness = swipeGestureValueTracker
}
gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_brightness_white_24dp)
gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX
gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt()
}
gestureIndicatorOverlayLayout.isVisible = true
return true
}
},
)
/**
* Handles scale/zoom gesture
*/
private val zoomGestureDetector = ScaleGestureDetector(
playerView.context,
object : ScaleGestureDetector.OnScaleGestureListener {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fragment.isLandscape()
override fun onScale(detector: ScaleGestureDetector): Boolean {
val scaleFactor = detector.scaleFactor
if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) {
isZoomEnabled = scaleFactor > 1
updateZoomMode(isZoomEnabled)
}
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) = Unit
},
).apply { isQuickScaleEnabled = false }
init {
@Suppress("ClickableViewAccessibility")
playerView.setOnTouchListener { _, event ->
if (playerView.useController) {
when (event.pointerCount) {
1 -> gestureDetector.onTouchEvent(event)
2 -> zoomGestureDetector.onTouchEvent(event)
}
} else {
unlockDetector.onTouchEvent(event)
}
if (event.action == MotionEvent.ACTION_UP) {
// Hide gesture indicator after timeout, if shown
gestureIndicatorOverlayLayout.apply {
if (isVisible) {
removeCallbacks(hideGestureIndicatorOverlayAction)
postDelayed(
hideGestureIndicatorOverlayAction,
Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS.toLong(),
)
}
}
swipeGestureValueTracker = -1f
}
true
}
}
fun handleConfiguration(newConfig: Configuration) {
updateZoomMode(fragment.isLandscape(newConfig) && isZoomEnabled)
}
private fun updateZoomMode(enabled: Boolean) {
playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
}
}

View file

@ -0,0 +1,67 @@
package org.jellyfin.mobile.player.ui
import android.content.pm.ActivityInfo
import android.view.OrientationEventListener
import android.widget.ImageButton
import androidx.core.view.isVisible
import com.google.android.exoplayer2.ui.PlayerView
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.lockOrientation
import org.jellyfin.mobile.utils.isAutoRotateOn
class PlayerLockScreenHelper(
private val playerFragment: PlayerFragment,
private val playerBinding: FragmentPlayerBinding,
private val orientationListener: OrientationEventListener,
) {
private val playerView: PlayerView by playerBinding::playerView
private val unlockScreenButton: ImageButton by playerBinding::unlockScreenButton
/**
* Runnable that hides the unlock screen button, used by [peekUnlockButton]
*/
private val hideUnlockButtonAction = Runnable {
hideUnlockButton()
}
init {
// Handle unlock action
unlockScreenButton.setOnClickListener {
unlockScreen()
}
}
fun lockScreen() {
playerView.useController = false
orientationListener.disable()
playerFragment.requireActivity().lockOrientation()
peekUnlockButton()
}
private fun unlockScreen() {
hideUnlockButton()
val activity = playerFragment.requireActivity()
if (activity.isAutoRotateOn()) {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
orientationListener.enable()
if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) {
playerView.useController = true
playerView.apply {
if (!isControllerVisible) showController()
}
}
}
fun peekUnlockButton() {
playerView.removeCallbacks(hideUnlockButtonAction)
unlockScreenButton.isVisible = true
playerView.postDelayed(hideUnlockButtonAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong())
}
fun hideUnlockButton() {
unlockScreenButton.isVisible = false
}
}

View file

@ -0,0 +1,350 @@
package org.jellyfin.mobile.player.ui
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.view.get
import androidx.core.view.isVisible
import androidx.core.view.size
import org.jellyfin.mobile.R
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.sdk.model.api.MediaStream
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.util.Locale
/**
* Provides a menu UI for audio, subtitle and video stream selection
*/
class PlayerMenus(
private val fragment: PlayerFragment,
private val playerBinding: FragmentPlayerBinding,
private val playerControlsBinding: ExoPlayerControlViewBinding,
) : PopupMenu.OnDismissListener,
KoinComponent {
private val context = playerBinding.root.context
private val qualityOptionsProvider: QualityOptionsProvider by inject()
private val previousButton: View by playerControlsBinding::previousButton
private val nextButton: View by playerControlsBinding::nextButton
private val lockScreenButton: View by playerControlsBinding::lockScreenButton
private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton
private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton
private val speedButton: View by playerControlsBinding::speedButton
private val qualityButton: View by playerControlsBinding::qualityButton
private val decoderButton: View by playerControlsBinding::decoderButton
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 speedMenu: PopupMenu = createSpeedMenu()
private val qualityMenu: PopupMenu = createQualityMenu()
private val decoderMenu: PopupMenu = createDecoderMenu()
private var subtitleCount = 0
private var subtitlesEnabled = false
init {
previousButton.setOnClickListener {
fragment.onSkipToPrevious()
}
nextButton.setOnClickListener {
fragment.onSkipToNext()
}
lockScreenButton.setOnClickListener {
fragment.playerLockScreenHelper.lockScreen()
}
audioStreamsButton.setOnClickListener {
fragment.suppressControllerAutoHide(true)
audioStreamsMenu.show()
}
subtitlesButton.setOnClickListener {
when (subtitleCount) {
0 -> return@setOnClickListener
1 -> {
fragment.toggleSubtitles { enabled ->
subtitlesEnabled = enabled
updateSubtitlesButton()
}
}
else -> {
fragment.suppressControllerAutoHide(true)
subtitlesMenu.show()
}
}
}
speedButton.setOnClickListener {
fragment.suppressControllerAutoHide(true)
speedMenu.show()
}
qualityButton.setOnClickListener {
fragment.suppressControllerAutoHide(true)
qualityMenu.show()
}
decoderButton.setOnClickListener {
fragment.suppressControllerAutoHide(true)
decoderMenu.show()
}
infoButton.setOnClickListener {
playbackInfo.isVisible = !playbackInfo.isVisible
}
playbackInfo.setOnClickListener {
dismissPlaybackInfo()
}
}
fun onQueueItemChanged(mediaSource: JellyfinMediaSource, hasNext: Boolean) {
// previousButton is always enabled and will rewind if at the start of the queue
nextButton.isEnabled = hasNext
val videoStream = mediaSource.selectedVideoStream
val audioStreams = mediaSource.audioStreams
buildMenuItems(
audioStreamsMenu.menu,
AUDIO_MENU_GROUP,
audioStreams,
mediaSource.selectedAudioStream,
)
val subtitleStreams = mediaSource.subtitleStreams
val selectedSubtitleStream = mediaSource.selectedSubtitleStream
buildMenuItems(
subtitlesMenu.menu,
SUBTITLES_MENU_GROUP,
subtitleStreams,
selectedSubtitleStream,
true,
)
subtitleCount = subtitleStreams.size
subtitlesEnabled = selectedSubtitleStream != null
updateSubtitlesButton()
val height = videoStream?.height
val width = videoStream?.width
if (height != null && width != null) {
buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height)
} else {
qualityButton.isVisible = false
}
val playMethod = context.getString(R.string.playback_info_play_method, mediaSource.playMethod)
val videoTracksInfo = buildMediaStreamsInfo(
mediaStreams = listOfNotNull(videoStream),
prefix = R.string.playback_info_video_streams,
maxStreams = MAX_VIDEO_STREAMS_DISPLAY,
streamSuffix = { stream ->
stream.bitRate?.let { bitrate -> " (${formatBitrate(bitrate.toDouble())})" }.orEmpty()
},
)
val audioTracksInfo = buildMediaStreamsInfo(
mediaStreams = audioStreams,
prefix = R.string.playback_info_audio_streams,
maxStreams = MAX_AUDIO_STREAMS_DISPLAY,
streamSuffix = { stream ->
stream.language?.let { lang -> " ($lang)" }.orEmpty()
},
)
playbackInfo.text = listOf(
playMethod,
videoTracksInfo,
audioTracksInfo,
).joinToString("\n\n")
}
private fun buildMediaStreamsInfo(
mediaStreams: List<MediaStream>,
@StringRes prefix: Int,
maxStreams: Int,
streamSuffix: (MediaStream) -> String,
): String = mediaStreams.joinToString(
"\n",
"${fragment.getString(prefix)}:\n",
limit = maxStreams,
truncated = fragment.getString(R.string.playback_info_and_x_more, mediaStreams.size - maxStreams),
) { stream ->
val title = stream.displayTitle?.takeUnless(String::isEmpty)
?: fragment.getString(R.string.playback_info_stream_unknown_title)
val suffix = streamSuffix(stream)
"- $title$suffix"
}
private fun createSubtitlesMenu() = PopupMenu(context, subtitlesButton).apply {
setOnMenuItemClickListener { clickedItem ->
// Immediately apply changes to the menu, necessary when direct playing
// When transcoding, the updated media source will cause the menu to be rebuilt
clickedItem.isChecked = true
// The itemId is the MediaStream.index of the track
val selectedSubtitleStreamIndex = clickedItem.itemId
fragment.onSubtitleSelected(selectedSubtitleStreamIndex) {
subtitlesEnabled = selectedSubtitleStreamIndex >= 0
updateSubtitlesButton()
}
true
}
setOnDismissListener(this@PlayerMenus)
}
private fun createAudioStreamsMenu() = PopupMenu(context, audioStreamsButton).apply {
setOnMenuItemClickListener { clickedItem: MenuItem ->
// Immediately apply changes to the menu, necessary when direct playing
// When transcoding, the updated media source will cause the menu to be rebuilt
clickedItem.isChecked = true
// The itemId is the MediaStream.index of the track
fragment.onAudioTrackSelected(clickedItem.itemId) {}
true
}
setOnDismissListener(this@PlayerMenus)
}
private fun createSpeedMenu() = PopupMenu(context, speedButton).apply {
for (step in SPEED_MENU_STEP_MIN..SPEED_MENU_STEP_MAX) {
val newSpeed = step * SPEED_MENU_STEP_SIZE
menu.add(SPEED_MENU_GROUP, step, Menu.NONE, "${newSpeed}x").isChecked = newSpeed == 1f
}
menu.setGroupCheckable(SPEED_MENU_GROUP, true, true)
setOnMenuItemClickListener { clickedItem: MenuItem ->
fragment.onSpeedSelected(clickedItem.itemId * SPEED_MENU_STEP_SIZE).also { success ->
if (success) clickedItem.isChecked = true
}
}
setOnDismissListener(this@PlayerMenus)
}
private fun createQualityMenu() = PopupMenu(context, qualityButton).apply {
setOnMenuItemClickListener { item: MenuItem ->
val newBitrate = item.itemId.takeUnless { bitrate -> bitrate == 0 }
fragment.onBitrateChanged(newBitrate) {
// Ignore callback - menu will be recreated if bitrate changes
}
true
}
setOnDismissListener(this@PlayerMenus)
}
private fun createDecoderMenu() = PopupMenu(context, qualityButton).apply {
menu.add(
DECODER_MENU_GROUP,
DecoderType.HARDWARE.ordinal,
Menu.NONE,
context.getString(R.string.menu_item_hardware_decoding),
)
menu.add(
DECODER_MENU_GROUP,
DecoderType.SOFTWARE.ordinal,
Menu.NONE,
context.getString(R.string.menu_item_software_decoding),
)
menu.setGroupCheckable(DECODER_MENU_GROUP, true, true)
setOnMenuItemClickListener { clickedItem: MenuItem ->
val type = DecoderType.values()[clickedItem.itemId]
fragment.onDecoderSelected(type)
clickedItem.isChecked = true
true
}
setOnDismissListener(this@PlayerMenus)
}
fun updatedSelectedDecoder(type: DecoderType) {
decoderMenu.menu.findItem(type.ordinal).isChecked = true
}
private fun buildMenuItems(
menu: Menu,
groupId: Int,
mediaStreams: List<MediaStream>,
selectedStream: MediaStream?,
showNone: Boolean = false,
) {
menu.clear()
val itemNone = when {
showNone -> menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none))
else -> null
}
var selectedItem: MenuItem? = itemNone
val menuItems = mediaStreams.map { mediaStream ->
val title = mediaStream.displayTitle ?: "${mediaStream.language} (${mediaStream.codec})"
menu.add(groupId, mediaStream.index, Menu.NONE, title).also { item ->
if (mediaStream === selectedStream) {
selectedItem = item
}
}
}
menu.setGroupCheckable(groupId, true, true)
// Check selected item or first item if possible
(selectedItem ?: menuItems.firstOrNull())?.isChecked = true
}
private fun updateSubtitlesButton() {
subtitlesButton.isVisible = subtitleCount > 0
val stateSet = intArrayOf(android.R.attr.state_checked * if (subtitlesEnabled) 1 else -1)
subtitlesButton.setImageState(stateSet, true)
}
private fun buildQualityMenu(menu: Menu, maxStreamingBitrate: Int?, videoWidth: Int, videoHeight: Int) {
menu.clear()
val options = qualityOptionsProvider.getApplicableQualityOptions(videoWidth, videoHeight)
options.forEach { option ->
val title = when (val bitrate = option.bitrate) {
0 -> context.getString(R.string.menu_item_auto)
else -> "${option.maxHeight}p - ${formatBitrate(bitrate.toDouble())}"
}
menu.add(QUALITY_MENU_GROUP, option.bitrate, Menu.NONE, title)
}
menu.setGroupCheckable(QUALITY_MENU_GROUP, true, true)
val selection = maxStreamingBitrate?.let(menu::findItem) ?: menu[menu.size - 1] // Last element is "auto"
selection.isChecked = true
}
fun dismissPlaybackInfo() {
playbackInfo.isVisible = false
}
override fun onDismiss(menu: PopupMenu) {
fragment.suppressControllerAutoHide(false)
fragment.onPopupDismissed()
}
private fun formatBitrate(bitrate: Double): String {
val (value, unit) = when {
bitrate > BITRATE_MEGA_BIT -> bitrate / BITRATE_MEGA_BIT to " Mbps"
bitrate > BITRATE_KILO_BIT -> bitrate / BITRATE_KILO_BIT to " kbps"
else -> bitrate to " bps"
}
// Remove unnecessary trailing zeros
val formatted = "%.2f".format(Locale.getDefault(), value).removeSuffix(".00")
return formatted + unit
}
companion object {
private const val SUBTITLES_MENU_GROUP = 0
private const val AUDIO_MENU_GROUP = 1
private const val SPEED_MENU_GROUP = 2
private const val QUALITY_MENU_GROUP = 3
private const val DECODER_MENU_GROUP = 4
private const val MAX_VIDEO_STREAMS_DISPLAY = 3
private const val MAX_AUDIO_STREAMS_DISPLAY = 5
private const val BITRATE_MEGA_BIT = 1_000_000
private const val BITRATE_KILO_BIT = 1_000
private const val SPEED_MENU_STEP_SIZE = 0.25f
private const val SPEED_MENU_STEP_MIN = 2 // → 0.5x
private const val SPEED_MENU_STEP_MAX = 8 // → 2x
}
}

View file

@ -0,0 +1,5 @@
package org.jellyfin.mobile.player.ui
fun interface TrackSelectionCallback {
fun onTrackSelected(success: Boolean)
}

View file

@ -0,0 +1,19 @@
package org.jellyfin.mobile.settings;
import androidx.annotation.StringDef;
@StringDef({
ExternalPlayerPackage.MPV_PLAYER,
ExternalPlayerPackage.MX_PLAYER_FREE,
ExternalPlayerPackage.MX_PLAYER_PRO,
ExternalPlayerPackage.VLC_PLAYER,
ExternalPlayerPackage.SYSTEM_DEFAULT
})
public @interface ExternalPlayerPackage {
String MPV_PLAYER = "is.xyz.mpv";
String MX_PLAYER_FREE = "com.mxtech.videoplayer.ad";
String MX_PLAYER_PRO = "com.mxtech.videoplayer.pro";
String VLC_PLAYER = "org.videolan.vlc";
String SYSTEM_DEFAULT = "~system~";
}

View file

@ -0,0 +1,235 @@
package org.jellyfin.mobile.settings
import android.content.Intent
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import androidx.fragment.app.Fragment
import de.Maxr1998.modernpreferences.Preference
import de.Maxr1998.modernpreferences.PreferencesAdapter
import de.Maxr1998.modernpreferences.helpers.categoryHeader
import de.Maxr1998.modernpreferences.helpers.checkBox
import de.Maxr1998.modernpreferences.helpers.defaultOnCheckedChange
import de.Maxr1998.modernpreferences.helpers.defaultOnClick
import de.Maxr1998.modernpreferences.helpers.defaultOnSelectionChange
import de.Maxr1998.modernpreferences.helpers.pref
import de.Maxr1998.modernpreferences.helpers.screen
import de.Maxr1998.modernpreferences.helpers.singleChoice
import de.Maxr1998.modernpreferences.preferences.CheckBoxPreference
import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.databinding.FragmentSettingsBinding
import org.jellyfin.mobile.utils.BackPressInterceptor
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.DownloadMethod
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
import org.jellyfin.mobile.utils.extensions.requireMainActivity
import org.jellyfin.mobile.utils.getDownloadsPaths
import org.jellyfin.mobile.utils.isPackageInstalled
import org.jellyfin.mobile.utils.withThemedContext
import org.koin.android.ext.android.inject
class SettingsFragment : Fragment(), BackPressInterceptor {
private val appPreferences: AppPreferences by inject()
private val settingsAdapter: PreferencesAdapter by lazy { PreferencesAdapter(buildSettingsScreen()) }
private lateinit var startLandscapeVideoInLandscapePreference: CheckBoxPreference
private lateinit var swipeGesturesPreference: CheckBoxPreference
private lateinit var rememberBrightnessPreference: Preference
private lateinit var backgroundAudioPreference: Preference
private lateinit var directPlayAssPreference: Preference
private lateinit var externalPlayerChoicePreference: Preference
init {
Preference.Config.titleMaxLines = 2
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val localInflater = inflater.withThemedContext(requireContext(), R.style.AppTheme_Settings)
val binding = FragmentSettingsBinding.inflate(localInflater, container, false)
binding.root.applyWindowInsetsAsMargins()
binding.toolbar.setTitle(R.string.activity_name_settings)
requireMainActivity().apply {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
binding.recyclerView.adapter = settingsAdapter
return binding.root
}
override fun onInterceptBackPressed(): Boolean {
return settingsAdapter.goBack()
}
override fun onDestroyView() {
super.onDestroyView()
requireMainActivity().setSupportActionBar(null)
}
@Suppress("LongMethod")
private fun buildSettingsScreen() = screen(requireContext()) {
collapseIcon = true
categoryHeader(PREF_CATEGORY_MUSIC_PLAYER) {
titleRes = R.string.pref_category_music_player
}
checkBox(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE) {
titleRes = R.string.pref_music_notification_always_dismissible_title
summaryRes = R.string.pref_music_notification_always_dismissible_summary_off
summaryOnRes = R.string.pref_music_notification_always_dismissible_summary_on
}
categoryHeader(PREF_CATEGORY_VIDEO_PLAYER) {
titleRes = R.string.pref_category_video_player
}
val videoPlayerOptions = listOf(
SelectionItem(VideoPlayerType.WEB_PLAYER, R.string.video_player_web, R.string.video_player_web_description),
SelectionItem(
VideoPlayerType.EXO_PLAYER,
R.string.video_player_integrated,
R.string.video_player_native_description,
),
SelectionItem(
VideoPlayerType.EXTERNAL_PLAYER,
R.string.video_player_external,
R.string.video_player_external_description,
),
)
singleChoice(Constants.PREF_VIDEO_PLAYER_TYPE, videoPlayerOptions) {
titleRes = R.string.pref_video_player_type_title
initialSelection = VideoPlayerType.WEB_PLAYER
defaultOnSelectionChange { selection ->
startLandscapeVideoInLandscapePreference.enabled = selection == VideoPlayerType.EXO_PLAYER
swipeGesturesPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
rememberBrightnessPreference.enabled = selection == VideoPlayerType.EXO_PLAYER && swipeGesturesPreference.checked
backgroundAudioPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
directPlayAssPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
externalPlayerChoicePreference.enabled = selection == VideoPlayerType.EXTERNAL_PLAYER
}
}
startLandscapeVideoInLandscapePreference = checkBox(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE) {
titleRes = R.string.pref_exoplayer_start_landscape_video_in_landscape
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
}
swipeGesturesPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES) {
titleRes = R.string.pref_exoplayer_allow_brightness_volume_gesture
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
defaultValue = true
defaultOnCheckedChange { checked ->
rememberBrightnessPreference.enabled = checked
}
}
rememberBrightnessPreference = checkBox(Constants.PREF_EXOPLAYER_REMEMBER_BRIGHTNESS) {
titleRes = R.string.pref_exoplayer_remember_brightness
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER && appPreferences.exoPlayerAllowSwipeGestures
defaultOnCheckedChange { checked ->
if (!checked) appPreferences.exoPlayerBrightness = BRIGHTNESS_OVERRIDE_NONE
}
}
backgroundAudioPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO) {
titleRes = R.string.pref_exoplayer_allow_background_audio
summaryRes = R.string.pref_exoplayer_allow_background_audio_summary
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
}
directPlayAssPreference = checkBox(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS) {
titleRes = R.string.pref_exoplayer_direct_play_ass
summaryRes = R.string.pref_exoplayer_direct_play_ass_summary
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
}
// Generate available external player options
val packageManager = requireContext().packageManager
val externalPlayerOptions = listOf(
SelectionItem(
ExternalPlayerPackage.SYSTEM_DEFAULT,
R.string.external_player_system_default,
R.string.external_player_system_default_description,
),
SelectionItem(
ExternalPlayerPackage.MPV_PLAYER,
R.string.external_player_mpv,
R.string.external_player_mpv_description,
),
SelectionItem(
ExternalPlayerPackage.MX_PLAYER_FREE,
R.string.external_player_mx_player_free,
R.string.external_player_mx_player_free_description,
),
SelectionItem(
ExternalPlayerPackage.MX_PLAYER_PRO,
R.string.external_player_mx_player_pro,
R.string.external_player_mx_player_pro_description,
),
SelectionItem(
ExternalPlayerPackage.VLC_PLAYER,
R.string.external_player_vlc_player,
R.string.external_player_vlc_player_description,
),
).filter { item ->
item.key == ExternalPlayerPackage.SYSTEM_DEFAULT || packageManager.isPackageInstalled(item.key)
}
// Revert if current selection isn't available
if (!packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) {
appPreferences.externalPlayerApp = ExternalPlayerPackage.SYSTEM_DEFAULT
}
externalPlayerChoicePreference = singleChoice(Constants.PREF_EXTERNAL_PLAYER_APP, externalPlayerOptions) {
titleRes = R.string.external_player_app
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER
}
val subtitleSettingsIntent = Intent(Settings.ACTION_CAPTIONING_SETTINGS)
if (subtitleSettingsIntent.resolveActivity(requireContext().packageManager) != null) {
pref(Constants.PREF_SUBTITLE_STYLE) {
titleRes = R.string.pref_subtitle_style
summaryRes = R.string.pref_subtitle_style_summary
defaultOnClick {
startActivity(subtitleSettingsIntent)
}
}
}
categoryHeader(PREF_CATEGORY_DOWNLOADS) {
titleRes = R.string.pref_category_downloads
}
val downloadMethods = listOf(
SelectionItem(
DownloadMethod.WIFI_ONLY,
R.string.wifi_only,
R.string.wifi_only_summary,
),
SelectionItem(
DownloadMethod.MOBILE_DATA,
R.string.mobile_data,
R.string.mobile_data_summary,
),
SelectionItem(
DownloadMethod.MOBILE_AND_ROAMING,
R.string.mobile_data_and_roaming,
R.string.mobile_data_and_roaming_summary,
),
)
singleChoice(Constants.PREF_DOWNLOAD_METHOD, downloadMethods) {
titleRes = R.string.network_title
}
val downloadsDirs = requireContext().getDownloadsPaths().map { path ->
SelectionItem(path, path, null)
}
singleChoice(Constants.PREF_DOWNLOAD_LOCATION, downloadsDirs) {
titleRes = R.string.pref_download_location
initialSelection = Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
.absolutePath
}
}
companion object {
const val PREF_CATEGORY_MUSIC_PLAYER = "pref_category_music"
const val PREF_CATEGORY_VIDEO_PLAYER = "pref_category_video"
const val PREF_CATEGORY_DOWNLOADS = "pref_category_downloads"
}
}

View file

@ -0,0 +1,15 @@
package org.jellyfin.mobile.settings;
import androidx.annotation.StringDef;
@StringDef({
VideoPlayerType.WEB_PLAYER,
VideoPlayerType.EXO_PLAYER,
VideoPlayerType.EXTERNAL_PLAYER
})
public @interface VideoPlayerType {
String WEB_PLAYER = "webui";
String EXO_PLAYER = "exoplayer";
String EXTERNAL_PLAYER = "external";
}

View file

@ -0,0 +1,46 @@
package org.jellyfin.mobile.setup
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.databinding.FragmentComposeBinding
import org.jellyfin.mobile.ui.screens.connect.ConnectScreen
import org.jellyfin.mobile.ui.utils.AppTheme
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
import org.koin.androidx.viewmodel.ext.android.activityViewModel
class ConnectFragment : Fragment() {
private val mainViewModel: MainViewModel by activityViewModel()
private var _viewBinding: FragmentComposeBinding? = null
private val viewBinding get() = _viewBinding!!
private val composeView: ComposeView get() = viewBinding.composeView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_viewBinding = FragmentComposeBinding.inflate(inflater, container, false)
return composeView.apply { applyWindowInsetsAsMargins() }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Apply window insets
ViewCompat.requestApplyInsets(composeView)
val encounteredConnectionError = arguments?.getBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR) == true
composeView.setContent {
AppTheme {
ConnectScreen(
mainViewModel = mainViewModel,
showExternalConnectionError = encounteredConnectionError,
)
}
}
}
}

View file

@ -0,0 +1,94 @@
package org.jellyfin.mobile.setup
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import org.jellyfin.mobile.R
import org.jellyfin.mobile.ui.state.CheckUrlState
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.discovery.LocalServerDiscovery
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import timber.log.Timber
class ConnectionHelper(
private val context: Context,
private val jellyfin: Jellyfin,
) {
suspend fun checkServerUrl(enteredUrl: String): CheckUrlState {
Timber.i("checkServerUrlAndConnection $enteredUrl")
val candidates = jellyfin.discovery.getAddressCandidates(enteredUrl)
Timber.i("Address candidates are $candidates")
// Find servers and classify them into groups.
// BAD servers are collected in case we need an error message,
// GOOD are kept if there's no GREAT one.
val badServers = mutableListOf<RecommendedServerInfo>()
val goodServers = mutableListOf<RecommendedServerInfo>()
val greatServer = jellyfin.discovery.getRecommendedServers(candidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodServers += recommendedServer
false
}
RecommendedServerInfoScore.OK,
RecommendedServerInfoScore.BAD,
-> {
badServers += recommendedServer
false
}
}
}
val server = greatServer ?: goodServers.firstOrNull()
if (server != null) {
val systemInfo = requireNotNull(server.systemInfo)
val serverVersion = systemInfo.getOrNull()?.version
Timber.i("Found valid server at ${server.address} with rating ${server.score} and version $serverVersion")
return CheckUrlState.Success(server.address)
}
// No valid server found, log and show error message
val loggedServers = badServers.joinToString { "${it.address}/${it.systemInfo}" }
Timber.i("No valid servers found, invalid candidates were: $loggedServers")
val error = when {
badServers.isNotEmpty() -> {
val count = badServers.size
val (unreachableServers, incompatibleServers) = badServers.partition { result -> result.systemInfo.getOrNull() == null }
StringBuilder(context.resources.getQuantityString(R.plurals.connection_error_prefix, count, count)).apply {
if (unreachableServers.isNotEmpty()) {
append("\n\n")
append(context.getString(R.string.connection_error_unable_to_reach_sever))
append(":\n")
append(
unreachableServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" },
)
}
if (incompatibleServers.isNotEmpty()) {
append("\n\n")
append(context.getString(R.string.connection_error_unsupported_version_or_product))
append(":\n")
append(
incompatibleServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" },
)
}
}.toString()
}
else -> null
}
return CheckUrlState.Error(error)
}
fun discoverServersAsFlow(): Flow<ServerDiscoveryInfo> =
jellyfin.discovery
.discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS)
.flowOn(Dispatchers.IO)
}

View file

@ -0,0 +1,54 @@
package org.jellyfin.mobile.ui.screens.connect
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.R
import org.jellyfin.mobile.ui.utils.CenterRow
@Composable
fun ConnectScreen(
mainViewModel: MainViewModel,
showExternalConnectionError: Boolean,
) {
Surface(color = MaterialTheme.colors.background) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
LogoHeader()
ServerSelection(
showExternalConnectionError = showExternalConnectionError,
onConnected = { hostname ->
mainViewModel.switchServer(hostname)
},
)
}
}
}
@Stable
@Composable
fun LogoHeader() {
CenterRow(
modifier = Modifier.padding(vertical = 25.dp),
) {
Image(
painter = painterResource(R.drawable.app_logo),
modifier = Modifier
.height(72.dp),
contentDescription = null,
)
}
}

View file

@ -0,0 +1,356 @@
package org.jellyfin.mobile.ui.screens.connect
import android.view.KeyEvent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.ListItem
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.mobile.setup.ConnectionHelper
import org.jellyfin.mobile.ui.state.CheckUrlState
import org.jellyfin.mobile.ui.state.ServerSelectionMode
import org.jellyfin.mobile.ui.utils.CenterRow
import org.koin.compose.koinInject
@OptIn(ExperimentalComposeUiApi::class)
@Suppress("LongMethod")
@Composable
fun ServerSelection(
showExternalConnectionError: Boolean,
apiClientController: ApiClientController = koinInject(),
connectionHelper: ConnectionHelper = koinInject(),
onConnected: suspend (String) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
var serverSelectionMode by remember { mutableStateOf(ServerSelectionMode.ADDRESS) }
var hostname by remember { mutableStateOf("") }
val serverSuggestions = remember { mutableStateListOf<ServerSuggestion>() }
var checkUrlState by remember<MutableState<CheckUrlState>> { mutableStateOf(CheckUrlState.Unchecked) }
var externalError by remember { mutableStateOf(showExternalConnectionError) }
// Prefill currently selected server if available
LaunchedEffect(Unit) {
val server = apiClientController.loadSavedServer()
if (server != null) {
hostname = server.hostname
}
}
LaunchedEffect(Unit) {
// Suggest saved servers
apiClientController.loadPreviouslyUsedServers().mapTo(serverSuggestions) { server ->
ServerSuggestion(
type = ServerSuggestion.Type.SAVED,
name = server.hostname,
address = server.hostname,
timestamp = server.lastUsedTimestamp,
)
}
// Prepend discovered servers to suggestions
connectionHelper.discoverServersAsFlow().collect { serverInfo ->
serverSuggestions.removeIf { existing -> existing.address == serverInfo.address }
serverSuggestions.add(
index = 0,
ServerSuggestion(
type = ServerSuggestion.Type.DISCOVERED,
name = serverInfo.name,
address = serverInfo.address,
timestamp = System.currentTimeMillis(),
),
)
}
}
fun onSubmit() {
externalError = false
checkUrlState = CheckUrlState.Pending
coroutineScope.launch {
val state = connectionHelper.checkServerUrl(hostname)
checkUrlState = state
if (state is CheckUrlState.Success) {
onConnected(state.address)
}
}
}
Column {
Text(
text = stringResource(R.string.connect_to_server_title),
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5,
)
Crossfade(
targetState = serverSelectionMode,
label = "Server selection mode",
) { selectionType ->
when (selectionType) {
ServerSelectionMode.ADDRESS -> AddressSelection(
text = hostname,
errorText = when {
externalError -> stringResource(R.string.connection_error_cannot_connect)
else -> (checkUrlState as? CheckUrlState.Error)?.message
},
loading = checkUrlState is CheckUrlState.Pending,
onTextChange = { value ->
externalError = false
checkUrlState = CheckUrlState.Unchecked
hostname = value
},
onDiscoveryClick = {
externalError = false
keyboardController?.hide()
serverSelectionMode = ServerSelectionMode.AUTO_DISCOVERY
},
onSubmit = {
onSubmit()
},
)
ServerSelectionMode.AUTO_DISCOVERY -> ServerDiscoveryList(
serverSuggestions = serverSuggestions,
onGoBack = {
serverSelectionMode = ServerSelectionMode.ADDRESS
},
onSelectServer = { url ->
hostname = url
serverSelectionMode = ServerSelectionMode.ADDRESS
onSubmit()
},
)
}
}
}
}
@Stable
@Composable
private fun AddressSelection(
text: String,
errorText: String?,
loading: Boolean,
onTextChange: (String) -> Unit,
onDiscoveryClick: () -> Unit,
onSubmit: () -> Unit,
) {
Column {
ServerUrlField(
text = text,
errorText = errorText,
onTextChange = onTextChange,
onSubmit = onSubmit,
)
AnimatedErrorText(errorText = errorText)
if (!loading) {
Spacer(modifier = Modifier.height(12.dp))
StyledTextButton(
text = stringResource(R.string.connect_button_text),
enabled = text.isNotBlank(),
onClick = onSubmit,
)
StyledTextButton(
text = stringResource(R.string.choose_server_button_text),
onClick = onDiscoveryClick,
)
} else {
CenterRow {
CircularProgressIndicator(modifier = Modifier.padding(top = 16.dp, bottom = 8.dp))
}
}
}
}
@Stable
@Composable
private fun ServerUrlField(
text: String,
errorText: String?,
onTextChange: (String) -> Unit,
onSubmit: () -> Unit,
) {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.onKeyEvent { keyEvent ->
when (keyEvent.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_ENTER -> {
onSubmit()
true
}
else -> false
}
},
label = {
Text(text = stringResource(R.string.host_input_hint))
},
isError = errorText != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(
onGo = {
onSubmit()
},
),
singleLine = true,
)
}
@Stable
@Composable
private fun AnimatedErrorText(
errorText: String?,
) {
AnimatedVisibility(
visible = errorText != null,
exit = ExitTransition.None,
) {
Text(
text = errorText.orEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
)
}
}
@Stable
@Composable
private fun StyledTextButton(
text: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
enabled = enabled,
colors = ButtonDefaults.buttonColors(),
) {
Text(text = text)
}
}
@Stable
@Composable
private fun ServerDiscoveryList(
serverSuggestions: SnapshotStateList<ServerSuggestion>,
onGoBack: () -> Unit,
onSelectServer: (String) -> Unit,
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onGoBack) {
Icon(imageVector = Icons.Outlined.ArrowBack, contentDescription = null)
}
Text(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
text = stringResource(R.string.available_servers_title),
)
CircularProgressIndicator(
modifier = Modifier
.padding(horizontal = 12.dp)
.size(24.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier
.padding(bottom = 16.dp)
.fillMaxSize()
.background(
color = MaterialTheme.colors.surface,
shape = MaterialTheme.shapes.medium,
),
) {
items(serverSuggestions) { server ->
ServerDiscoveryItem(
serverSuggestion = server,
onClickServer = {
onSelectServer(server.address)
},
)
}
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Stable
@Composable
private fun ServerDiscoveryItem(
serverSuggestion: ServerSuggestion,
onClickServer: () -> Unit,
) {
ListItem(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.clickable(onClick = onClickServer),
text = {
Text(text = serverSuggestion.name)
},
secondaryText = {
Text(text = serverSuggestion.address)
},
)
}

View file

@ -0,0 +1,18 @@
package org.jellyfin.mobile.ui.screens.connect
data class ServerSuggestion(
val type: Type,
val name: String,
val address: String,
/**
* A timestamp for this suggestion, used for sorting.
* For discovered servers, this should be the discovery time,
* for saved servers, this should be the last used time.
*/
val timestamp: Long,
) {
enum class Type {
DISCOVERED,
SAVED,
}
}

View file

@ -0,0 +1,8 @@
package org.jellyfin.mobile.ui.state
sealed class CheckUrlState {
object Unchecked : CheckUrlState()
object Pending : CheckUrlState()
class Success(val address: String) : CheckUrlState()
class Error(val message: String?) : CheckUrlState()
}

View file

@ -0,0 +1,6 @@
package org.jellyfin.mobile.ui.state
enum class ServerSelectionMode {
ADDRESS,
AUTO_DISCOVERY,
}

View file

@ -0,0 +1,38 @@
package org.jellyfin.mobile.ui.utils
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Shapes
import androidx.compose.material.darkColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val colors = remember {
@Suppress("MagicNumber")
darkColors(
primary = Color(0xFF00A4DC),
primaryVariant = Color(0xFF202020),
background = Color(0xFF101010),
surface = Color(0xFF363636),
error = Color(0xFFCF6679),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
onError = Color.White,
)
}
MaterialTheme(
colors = colors,
shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(0.dp),
),
content = content,
)
}

View file

@ -0,0 +1,22 @@
package org.jellyfin.mobile.ui.utils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
inline fun CenterRow(
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit,
) = Row(
modifier = Modifier
.fillMaxWidth()
.then(modifier),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)

View file

@ -0,0 +1,92 @@
package org.jellyfin.mobile.utils
import android.os.Build
/**
* Helper class to check the current Android version.
*
* Comparisons will be made against the current device's Android SDK version number in [Build.VERSION.SDK_INT].
*
* @see Build.VERSION.SDK_INT
*/
object AndroidVersion {
/**
* Checks whether the current Android version is at least Android 6 Marshmallow, API 23.
*
* @see Build.VERSION_CODES.M
*/
inline val isAtLeastM: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
/**
* Checks whether the current Android version is at least Android 7 Nougat, API 24.
*
* @see Build.VERSION_CODES.N
*/
inline val isAtLeastN: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
/**
* Checks whether the current Android version is at least Android 7.1 Nougat, API 25.
*
* @see Build.VERSION_CODES.N_MR1
*/
inline val isAtLeastNMR1: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1
/**
* Checks whether the current Android version is at least Android 8 Oreo, API 26.
*
* @see Build.VERSION_CODES.O
*/
inline val isAtLeastO: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
/**
* Checks whether the current Android version is at least Android 9 Pie, API 28.
*
* @see Build.VERSION_CODES.P
*/
inline val isAtLeastP: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
/**
* Checks whether the current Android version is at least Android 10 Q, API 29.
*
* @see Build.VERSION_CODES.Q
*/
inline val isAtLeastQ: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
/**
* Checks whether the current Android version is at least Android 11 R, API 30.
*
* @see Build.VERSION_CODES.R
*/
inline val isAtLeastR: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
/**
* Checks whether the current Android version is at least Android 12 S, API 31.
*
* @see Build.VERSION_CODES.S
*/
inline val isAtLeastS: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* Checks whether the current Android version is at least Android 12 S V2, API 32.
*
* @see Build.VERSION_CODES.S_V2
*/
inline val isAtLeastSV2: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2
/**
* Checks whether the current Android version is at least Android 13 Tiramisu, API 33.
*
* @see Build.VERSION_CODES.TIRAMISU
*/
inline val isAtLeastT: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
}

View file

@ -0,0 +1,41 @@
package org.jellyfin.mobile.utils
import androidx.activity.OnBackPressedDispatcher
import androidx.fragment.app.Fragment
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.utils.extensions.addFragment
/**
* Additional hook for handling back presses in [Fragments][Fragment] (see [onInterceptBackPressed]).
*
* This hook is introduced since the AndroidX onBackPressedDispatcher system does not play well with the way we handle fragments:
* The WebViewFragment always needs to be active, since it contains the state of the web interface.
* To achieve this, we only add fragments (see [addFragment]) instead of doing the more common way
* and replacing the current fragment.
*
* This keeps the WebViewFragment alive, but unless the new fragment registers its own onBackPressedCallback,
* this also means that the WebViewFragment's onBackPressedCallbacks would still be the topmost dispatcher and therefore
* would be called (see [OnBackPressedDispatcher.onBackPressed]).
*
* This wouldn't be a problem if there was some way for the WebViewFragment (or any other fragment that's active) to
* know if it is the currently displayed fragment, since then it could deactivate its own onBackPressedCallback
* and the next callback would be called instead.
* The [MainActivity's][MainActivity] callback would then default to popping the backstack.
*
* There might be a way to implement this by using the backstack to determine if the current fragment is the topmost fragment,
* but sadly it seems that this isn't possible in a non-hacky way (as in hardcoding names of backstack entries).
*
* Instead, the MainActivity determines the currently visible fragment,
* and passes the back press event to it via the [onInterceptBackPressed] method.
*/
interface BackPressInterceptor {
/**
* Called when a back press is performed while this fragment is currently visible.
*
* @return `true` if the event was intercepted by the fragment,
* `false` if the back press was not handled by the fragment.
* The latter will result in a default action that closes the fragment
* @see MainActivity.onBackPressedCallback
*/
fun onInterceptBackPressed(): Boolean = false
}

View file

@ -0,0 +1,67 @@
package org.jellyfin.mobile.utils
import android.Manifest.permission.BLUETOOTH_CONNECT
import android.app.AlertDialog
import android.content.pm.PackageManager
import android.content.pm.PackageManager.PERMISSION_GRANTED
import kotlinx.coroutines.suspendCancellableCoroutine
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import kotlin.coroutines.resume
class BluetoothPermissionHelper(
private val activity: MainActivity,
private val appPreferences: AppPreferences,
) {
/**
* This is used to prevent the dialog from showing multiple times in a single session (activity creation).
* Otherwise, the package manager and permission would need to be queried on every media event.
*/
private var wasDialogShowThisSession = false
@Suppress("ComplexCondition")
suspend fun requestBluetoothPermissionIfNecessary() {
// Check conditions by increasing complexity
if (
!AndroidVersion.isAtLeastS ||
wasDialogShowThisSession ||
activity.checkSelfPermission(BLUETOOTH_CONNECT) == PERMISSION_GRANTED ||
appPreferences.ignoreBluetoothPermission ||
!activity.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)
) {
return
}
wasDialogShowThisSession = true
val shouldRequestPermission = suspendCancellableCoroutine { continuation ->
AlertDialog.Builder(activity)
.setTitle(R.string.bluetooth_permission_title)
.setMessage(R.string.bluetooth_permission_message)
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
continuation.resume(false)
}
.setPositiveButton(R.string.bluetooth_permission_continue) { dialog, _ ->
dialog.dismiss()
continuation.resume(true)
}
.setOnCancelListener {
continuation.resume(false)
}
.show()
}
if (!shouldRequestPermission) {
appPreferences.ignoreBluetoothPermission = true
return
}
activity.requestPermission(BLUETOOTH_CONNECT) { requestPermissionsResult ->
if (requestPermissionsResult[BLUETOOTH_CONNECT] == PERMISSION_GRANTED) {
activity.toast(R.string.bluetooth_permission_granted)
}
}
}
}

View file

@ -0,0 +1,5 @@
package org.jellyfin.mobile.utils
class CombinedIntRange(private vararg val ranges: IntRange) {
operator fun contains(value: Int) = ranges.any { range -> range.contains(value) }
}

View file

@ -0,0 +1,148 @@
package org.jellyfin.mobile.utils
import android.app.PendingIntent
import android.media.session.PlaybackState
import android.util.Rational
import org.jellyfin.mobile.BuildConfig
@Suppress("MagicNumber")
object Constants {
// App Info
const val APP_INFO_NAME = "Jellyfin Android"
const val APP_INFO_VERSION: String = BuildConfig.VERSION_NAME
// Webapp constants
const val MINIMUM_WEB_VIEW_VERSION = 80
const val SHOW_PROGRESS_BAR_DELAY = 1000L
const val INITIAL_CONNECTION_TIMEOUT = 10000L // 10 seconds
val MAIN_BUNDLE_PATH_REGEX = Regex(""".*/main\.[^/\s]+\.bundle\.js""")
const val CAST_SDK_PATH = "cast_sender.js"
const val SESSION_CAPABILITIES_PATH = "sessions/capabilities/full"
const val SERVICE_WORKER_PATH = "serviceworker.js"
const val FRAGMENT_CONNECT_EXTRA_ERROR = "org.jellyfin.mobile.intent.extra.ERROR"
const val FRAGMENT_WEB_VIEW_EXTRA_SERVER = "org.jellyfin.mobile.intent.extra.SERVER"
// Preference keys
const val PREF_SERVER_ID = "pref_server_id"
const val PREF_USER_ID = "pref_user_id"
const val PREF_INSTANCE_URL = "pref_instance_url"
const val PREF_IGNORE_BATTERY_OPTIMIZATIONS = "pref_ignore_battery_optimizations"
const val PREF_IGNORE_WEBVIEW_CHECKS = "pref_ignore_webview_checks"
const val PREF_IGNORE_BLUETOOTH_PERMISSION = "pref_ignore_bluetooth_permission"
const val PREF_DOWNLOAD_METHOD = "pref_download_method"
const val PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE = "pref_music_notification_always_dismissible"
const val PREF_VIDEO_PLAYER_TYPE = "pref_video_player_type"
const val PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE = "pref_exoplayer_start_landscape_video_in_landscape"
const val PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES = "pref_exoplayer_allow_swipe_gestures"
const val PREF_EXOPLAYER_REMEMBER_BRIGHTNESS = "pref_exoplayer_remember_brightness"
const val PREF_EXOPLAYER_BRIGHTNESS = "pref_exoplayer_brightness"
const val PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO = "pref_exoplayer_allow_background_audio"
const val PREF_EXOPLAYER_DIRECT_PLAY_ASS = "pref_exoplayer_direct_play_ass"
const val PREF_EXTERNAL_PLAYER_APP = "pref_external_player_app"
const val PREF_SUBTITLE_STYLE = "pref_subtitle_style"
const val PREF_DOWNLOAD_LOCATION = "pref_download_location"
// InputManager commands
const val PLAYBACK_MANAGER_COMMAND_PLAY = "unpause"
const val PLAYBACK_MANAGER_COMMAND_PAUSE = "pause"
const val PLAYBACK_MANAGER_COMMAND_PREVIOUS = "previousTrack"
const val PLAYBACK_MANAGER_COMMAND_NEXT = "nextTrack"
const val PLAYBACK_MANAGER_COMMAND_REWIND = "rewind"
const val PLAYBACK_MANAGER_COMMAND_FAST_FORWARD = "fastForward"
const val PLAYBACK_MANAGER_COMMAND_STOP = "stop"
const val PLAYBACK_MANAGER_COMMAND_VOL_UP = "volumeUp"
const val PLAYBACK_MANAGER_COMMAND_VOL_DOWN = "volumeDown"
// Notification
val PENDING_INTENT_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or when {
AndroidVersion.isAtLeastM -> PendingIntent.FLAG_IMMUTABLE
else -> 0
}
const val MEDIA_NOTIFICATION_CHANNEL_ID = "org.jellyfin.mobile.media.NOW_PLAYING"
// Music player constants
const val SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or
PlaybackState.ACTION_PLAY or
PlaybackState.ACTION_PAUSE or
PlaybackState.ACTION_STOP or
PlaybackState.ACTION_SKIP_TO_NEXT or
PlaybackState.ACTION_SKIP_TO_PREVIOUS or
PlaybackState.ACTION_SET_RATING
const val MEDIA_PLAYER_NOTIFICATION_ID = 42
const val REMOTE_PLAYER_CONTENT_INTENT_REQUEST_CODE = 100
// Music player intent actions
const val ACTION_SHOW_PLAYER = "org.jellyfin.mobile.intent.action.SHOW_PLAYER"
const val ACTION_PLAY = "org.jellyfin.mobile.intent.action.PLAY"
const val ACTION_PAUSE = "org.jellyfin.mobile.intent.action.PAUSE"
const val ACTION_REWIND = "org.jellyfin.mobile.intent.action.REWIND"
const val ACTION_FAST_FORWARD = "org.jellyfin.mobile.intent.action.FAST_FORWARD"
const val ACTION_PREVIOUS = "org.jellyfin.mobile.intent.action.PREVIOUS"
const val ACTION_NEXT = "org.jellyfin.mobile.intent.action.NEXT"
const val ACTION_STOP = "org.jellyfin.mobile.intent.action.STOP"
const val ACTION_REPORT = "org.jellyfin.mobile.intent.action.REPORT"
// Music player intent extras
const val EXTRA_PLAYER_ACTION = "action"
const val EXTRA_ITEM_ID = "itemId"
const val EXTRA_TITLE = "title"
const val EXTRA_ARTIST = "artist"
const val EXTRA_ALBUM = "album"
const val EXTRA_IMAGE_URL = "imageUrl"
const val EXTRA_POSITION = "position"
const val EXTRA_DURATION = "duration"
const val EXTRA_CAN_SEEK = "canSeek"
const val EXTRA_IS_LOCAL_PLAYER = "isLocalPlayer"
const val EXTRA_IS_PAUSED = "isPaused"
// Video player constants
const val LANGUAGE_UNDEFINED = "und"
const val TICKS_PER_MILLISECOND = 10000
const val PLAYER_TIME_UPDATE_RATE = 10000L
const val DEFAULT_CONTROLS_TIMEOUT_MS = 2500
const val SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL = 64
const val DEFAULT_CENTER_OVERLAY_TIMEOUT_MS = 250
const val DISPLAY_PREFERENCES_ID_USER_SETTINGS = "usersettings"
const val DISPLAY_PREFERENCES_CLIENT_EMBY = "emby"
const val DISPLAY_PREFERENCES_SKIP_BACK_LENGTH = "skipBackLength"
const val DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH = "skipForwardLength"
const val DEFAULT_SEEK_TIME_MS = 10000L
const val MAX_SKIP_TO_PREV_MS = 3000L
const val DOUBLE_TAP_RIPPLE_DURATION_MS = 100L
const val FULL_SWIPE_RANGE_SCREEN_RATIO = 0.66f
const val SCREEN_BRIGHTNESS_MAX = 255
const val ZOOM_SCALE_BASE = 1f
const val ZOOM_SCALE_THRESHOLD = 0.01f
val ASPECT_RATIO_16_9 = Rational(16, 9)
val PIP_MIN_RATIONAL = Rational(100, 239)
val PIP_MAX_RATIONAL = Rational(239, 100)
const val SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or
PlaybackState.ACTION_PLAY or
PlaybackState.ACTION_PAUSE or
PlaybackState.ACTION_SEEK_TO or
PlaybackState.ACTION_REWIND or
PlaybackState.ACTION_FAST_FORWARD or
PlaybackState.ACTION_STOP
const val VIDEO_PLAYER_NOTIFICATION_ID = 99
// Video player intent extras
const val EXTRA_MEDIA_PLAY_OPTIONS = "org.jellyfin.mobile.MEDIA_PLAY_OPTIONS"
// External player result actions
const val MPV_PLAYER_RESULT_ACTION = "is.xyz.mpv.MPVActivity.result"
const val MX_PLAYER_RESULT_ACTION = "com.mxtech.intent.result.VIEW"
const val VLC_PLAYER_RESULT_ACTION = "org.videolan.vlc.player.result"
// External player webapp events
const val EVENT_ENDED = "Ended"
const val EVENT_TIME_UPDATE = "TimeUpdate"
const val EVENT_CANCELED = "Canceled"
// Orientation constants
val ORIENTATION_PORTRAIT_RANGE = CombinedIntRange(340..360, 0..20)
val ORIENTATION_LANDSCAPE_RANGE = CombinedIntRange(70..110, 250..290)
// Misc
const val PERCENT_MAX = 100
}

View file

@ -0,0 +1,14 @@
package org.jellyfin.mobile.utils;
import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_AND_ROAMING;
import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_DATA;
import static org.jellyfin.mobile.utils.DownloadMethod.WIFI_ONLY;
import androidx.annotation.IntDef;
@IntDef({WIFI_ONLY, MOBILE_DATA, MOBILE_AND_ROAMING})
public @interface DownloadMethod {
int WIFI_ONLY = 0;
int MOBILE_DATA = 1;
int MOBILE_AND_ROAMING = 2;
}

View file

@ -0,0 +1,11 @@
package org.jellyfin.mobile.utils
import android.util.Log
import org.jellyfin.mobile.BuildConfig
import timber.log.Timber
class JellyTree : Timber.DebugTree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (BuildConfig.DEBUG || priority >= Log.INFO) super.log(priority, tag, message, t)
}
}

View file

@ -0,0 +1,55 @@
package org.jellyfin.mobile.utils
import android.content.Context
import android.content.res.Configuration
import android.webkit.WebView
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun WebView.initLocale(userId: String) {
// Try to set locale via user settings
val userSettings = suspendCoroutine { continuation ->
evaluateJavascript("window.localStorage.getItem('$userId-language')") { result ->
try {
continuation.resume(JSONObject("{locale:$result}").getString("locale"))
} catch (e: JSONException) {
continuation.resume(null)
}
}
}
if (context.setLocale(userSettings)) return
// Fallback to device locale
Timber.i("Couldn't acquire locale from config, keeping current")
}
private fun Context.setLocale(localeString: String?): Boolean {
if (localeString.isNullOrEmpty()) return false
val localeSplit = localeString.split('-')
val locale = when (localeSplit.size) {
1 -> Locale(localeString, "")
2 -> Locale(localeSplit[0], localeSplit[1])
else -> return false
}
val configuration = resources.configuration
if (locale != configuration.primaryLocale) {
Locale.setDefault(locale)
configuration.setLocale(locale)
@Suppress("DEPRECATION")
resources.updateConfiguration(configuration, resources.displayMetrics)
Timber.i("Updated locale from web: '$locale'")
} // else: Locale is already applied
return true
}
@Suppress("DEPRECATION")
private val Configuration.primaryLocale: Locale
get() = if (AndroidVersion.isAtLeastN) locales[0] else locale

View file

@ -0,0 +1,97 @@
@file:Suppress("NOTHING_TO_INLINE")
package org.jellyfin.mobile.utils
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.analytics.AnalyticsCollector
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.utils.extensions.width
import com.google.android.exoplayer2.audio.AudioAttributes as ExoPlayerAudioAttributes
inline fun MediaSession.applyDefaultLocalAudioAttributes(contentType: Int) {
val audioAttributes = AudioAttributes.Builder().apply {
setUsage(AudioAttributes.USAGE_MEDIA)
setContentType(contentType)
if (AndroidVersion.isAtLeastQ) {
setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_ALL)
}
}.build()
setPlaybackToLocal(audioAttributes)
}
fun JellyfinMediaSource.toMediaMetadata(): MediaMetadata = MediaMetadata.Builder().apply {
putString(MediaMetadata.METADATA_KEY_MEDIA_ID, itemId.toString())
putString(MediaMetadata.METADATA_KEY_TITLE, name)
putLong(MediaMetadata.METADATA_KEY_DURATION, runTimeMs)
}.build()
fun MediaSession.setPlaybackState(playbackState: Int, position: Long, playbackActions: Long) {
val state = PlaybackState.Builder().apply {
setState(playbackState, position, 1.0f)
setActions(playbackActions)
}.build()
setPlaybackState(state)
}
fun MediaSession.setPlaybackState(isPlaying: Boolean, position: Long, playbackActions: Long) {
setPlaybackState(
if (isPlaying) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
position,
playbackActions,
)
}
fun MediaSession.setPlaybackState(player: Player, playbackActions: Long) {
val playbackState = when (val playerState = player.playbackState) {
Player.STATE_IDLE, Player.STATE_ENDED -> PlaybackState.STATE_NONE
Player.STATE_READY -> if (player.isPlaying) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED
Player.STATE_BUFFERING -> PlaybackState.STATE_BUFFERING
else -> error("Invalid player playbackState $playerState")
}
setPlaybackState(playbackState, player.currentPosition, playbackActions)
}
fun AudioManager.getVolumeRange(streamType: Int): IntRange {
val minVolume = (if (AndroidVersion.isAtLeastP) getStreamMinVolume(streamType) else 0)
val maxVolume = getStreamMaxVolume(streamType)
return minVolume..maxVolume
}
fun AudioManager.getVolumeLevelPercent(): Int {
val stream = AudioManager.STREAM_MUSIC
val volumeRange = getVolumeRange(stream)
val currentVolume = getStreamVolume(stream)
return (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width
}
/**
* Set ExoPlayer [ExoPlayerAudioAttributes], make ExoPlayer handle audio focus
*/
inline fun ExoPlayer.applyDefaultAudioAttributes(@C.AudioContentType contentType: Int) {
val audioAttributes = ExoPlayerAudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(contentType)
.build()
setAudioAttributes(audioAttributes, true)
}
fun Player.seekToOffset(offsetMs: Long) {
var positionMs = currentPosition + offsetMs
val durationMs = duration
if (durationMs != C.TIME_UNSET) {
positionMs = positionMs.coerceAtMost(durationMs)
}
positionMs = positionMs.coerceAtLeast(0)
seekTo(positionMs)
}
fun Player.logTracks(analyticsCollector: AnalyticsCollector) {
analyticsCollector.onTracksChanged(currentTracks)
}

View file

@ -0,0 +1,55 @@
package org.jellyfin.mobile.utils
import android.app.Activity
import android.content.pm.PackageManager
import android.util.SparseArray
import androidx.core.app.ActivityCompat
import org.koin.android.ext.android.getKoin
import java.util.concurrent.atomic.AtomicInteger
class PermissionRequestHelper {
private val permissionRequests: SparseArray<PermissionRequestCallback> = SparseArray<PermissionRequestCallback>()
@Suppress("MagicNumber")
private var requestCode = AtomicInteger(50000) // start at a high number to prevent collisions
fun getRequestCode() = requestCode.getAndIncrement()
fun addCallback(requestCode: Int, callback: PermissionRequestCallback) {
permissionRequests.put(requestCode, callback)
}
fun handleRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
// Change to a map
val permissionsMap = permissions
.mapIndexed { index, permission ->
Pair(permission, grantResults.getOrElse(index) { PackageManager.PERMISSION_DENIED })
}
.toMap()
// Execute and remove if it exists
permissionRequests[requestCode]?.invoke(permissionsMap)
permissionRequests.delete(requestCode)
}
}
typealias PermissionRequestCallback = (Map<String, Int>) -> Unit
fun Activity.requestPermission(vararg permissions: String, callback: PermissionRequestCallback) {
val skipRequest = permissions.all { permission ->
ActivityCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
if (skipRequest) {
callback(permissions.associateWith { PackageManager.PERMISSION_GRANTED })
} else {
val helper = getKoin().get<PermissionRequestHelper>()
val code = helper.getRequestCode()
helper.addCallback(code, callback)
ActivityCompat.requestPermissions(this, permissions, code)
}
}

View file

@ -0,0 +1,26 @@
package org.jellyfin.mobile.utils
import android.app.Activity
import android.content.pm.ActivityInfo
import android.view.OrientationEventListener
/**
* Listener that watches the current device orientation.
* It makes sure that the orientation sensor can still be used (if enabled)
* after toggling the orientation manually.
*/
class SmartOrientationListener(private val activity: Activity) : OrientationEventListener(activity) {
override fun onOrientationChanged(orientation: Int) {
if (!activity.isAutoRotateOn()) return
val isAtTarget = when (activity.requestedOrientation) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT -> orientation in Constants.ORIENTATION_PORTRAIT_RANGE
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE -> orientation in Constants.ORIENTATION_LANDSCAPE_RANGE
else -> false
}
if (isAtTarget) {
// Reset to unspecified orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}

View file

@ -0,0 +1,167 @@
package org.jellyfin.mobile.utils
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Activity
import android.app.ActivityManager
import android.app.AlertDialog
import android.app.DownloadManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Environment
import android.os.PowerManager
import android.provider.Settings
import android.provider.Settings.System.ACCELEROMETER_ROTATION
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.getSystemService
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.settings.ExternalPlayerPackage
import org.jellyfin.mobile.webapp.WebViewFragment
import org.koin.android.ext.android.get
import timber.log.Timber
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) {
if (AndroidVersion.isAtLeastM) {
val powerManager: PowerManager = requireContext().getSystemService(Activity.POWER_SERVICE) as PowerManager
if (
!appPreferences.ignoreBatteryOptimizations &&
!powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)
) {
Snackbar.make(rootView, R.string.battery_optimizations_message, Snackbar.LENGTH_INDEFINITE).apply {
setAction(android.R.string.ok) {
try {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e(e)
}
// Ignore after the user interacted with the snackbar at least once
appPreferences.ignoreBatteryOptimizations = true
}
show()
}
}
}
}
suspend fun MainActivity.requestDownload(uri: Uri, title: String, filename: String) {
val appPreferences: AppPreferences = get()
// Storage permission for downloads isn't necessary from Android 10 onwards
if (!AndroidVersion.isAtLeastQ) {
@Suppress("MagicNumber")
val granted = withTimeout(2 * 60 * 1000 /* 2 minutes */) {
suspendCoroutine { continuation ->
requestPermission(WRITE_EXTERNAL_STORAGE) { requestPermissionsResult ->
continuation.resume(requestPermissionsResult[WRITE_EXTERNAL_STORAGE] == PERMISSION_GRANTED)
}
}
}
if (!granted) {
toast(R.string.download_no_storage_permission)
return
}
}
val downloadMethod = appPreferences.downloadMethod ?: suspendCancellableCoroutine { continuation ->
AlertDialog.Builder(this)
.setTitle(R.string.network_title)
.setMessage(R.string.network_message)
.setPositiveButton(R.string.wifi_only) { _, _ ->
val selectedDownloadMethod = DownloadMethod.WIFI_ONLY
appPreferences.downloadMethod = selectedDownloadMethod
continuation.resume(selectedDownloadMethod)
}
.setNegativeButton(R.string.mobile_data) { _, _ ->
val selectedDownloadMethod = DownloadMethod.MOBILE_DATA
appPreferences.downloadMethod = selectedDownloadMethod
continuation.resume(selectedDownloadMethod)
}
.setNeutralButton(R.string.mobile_data_and_roaming) { _, _ ->
val selectedDownloadMethod = DownloadMethod.MOBILE_AND_ROAMING
appPreferences.downloadMethod = selectedDownloadMethod
continuation.resume(selectedDownloadMethod)
}
.setOnDismissListener {
continuation.cancel(null)
}
.setCancelable(false)
.show()
}
val downloadRequest = DownloadManager.Request(uri)
.setTitle(title)
.setDescription(getString(R.string.downloading))
.setDestinationUri(Uri.fromFile(File(appPreferences.downloadLocation, filename)))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
downloadFile(downloadRequest, downloadMethod)
}
private fun Context.downloadFile(request: DownloadManager.Request, @DownloadMethod downloadMethod: Int) {
require(downloadMethod >= 0) { "Download method hasn't been set" }
request.apply {
setAllowedOverMetered(downloadMethod >= DownloadMethod.MOBILE_DATA)
setAllowedOverRoaming(downloadMethod == DownloadMethod.MOBILE_AND_ROAMING)
}
getSystemService<DownloadManager>()?.enqueue(request)
}
fun Activity.isAutoRotateOn() = Settings.System.getInt(contentResolver, ACCELEROMETER_ROTATION, 0) == 1
fun PackageManager.isPackageInstalled(@ExternalPlayerPackage packageName: String) = try {
packageName.isNotEmpty() && getApplicationInfo(packageName, 0).enabled
} catch (e: PackageManager.NameNotFoundException) {
false
}
fun Context.createMediaNotificationChannel(notificationManager: NotificationManager) {
if (AndroidVersion.isAtLeastO) {
val notificationChannel = NotificationChannel(
Constants.MEDIA_NOTIFICATION_CHANNEL_ID,
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Media notifications"
}
notificationManager.createNotificationChannel(notificationChannel)
}
}
fun Context.getDownloadsPaths(): List<String> = ArrayList<String>().apply {
for (directory in getExternalFilesDirs(null)) {
// Ignore currently unavailable shared storage
if (directory == null) continue
val path = directory.absolutePath
val androidFolderIndex = path.indexOf("/Android")
if (androidFolderIndex == -1) continue
val storageDirectory = File(path.substring(0, androidFolderIndex))
if (storageDirectory.isDirectory) {
add(File(storageDirectory, Environment.DIRECTORY_DOWNLOADS).absolutePath)
}
}
if (isEmpty()) {
add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath)
}
}
val Context.isLowRamDevice: Boolean
get() = getSystemService<ActivityManager>()!!.isLowRamDevice

View file

@ -0,0 +1,36 @@
package org.jellyfin.mobile.utils
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.trackselection.TrackSelectionOverride
/**
* Select the [trackGroup] of the specified [type] and ensure the type is enabled.
*
* @param type One of the TRACK_TYPE_* constants defined in [C].
* @param trackGroup the [TrackGroup] to select.
*/
fun DefaultTrackSelector.selectTrackByTypeAndGroup(type: Int, trackGroup: TrackGroup): Boolean {
val parameters = with(buildUponParameters()) {
clearOverridesOfType(type)
addOverride(TrackSelectionOverride(trackGroup, 0))
setTrackTypeDisabled(type, false)
}
setParameters(parameters)
return true
}
/**
* Clear selection overrides for all renderers of the specified [type] and disable them.
*
* @param type One of the TRACK_TYPE_* constants defined in [C].
*/
fun DefaultTrackSelector.clearSelectionAndDisableRendererByType(type: Int): Boolean {
val parameters = with(buildUponParameters()) {
clearOverridesOfType(type)
setTrackTypeDisabled(type, true)
}
setParameters(parameters)
return true
}

View file

@ -0,0 +1,70 @@
@file:Suppress("unused", "NOTHING_TO_INLINE")
package org.jellyfin.mobile.utils
import android.content.Context
import android.content.res.Resources
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.view.Window
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) =
Toast.makeText(this, text, duration).show()
inline fun Context.toast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) =
Toast.makeText(this, text, duration).show()
inline fun LifecycleOwner.runOnUiThread(noinline block: suspend CoroutineScope.() -> Unit) {
lifecycleScope.launch(Dispatchers.Main, block = block)
}
fun LayoutInflater.withThemedContext(context: Context, @StyleRes style: Int): LayoutInflater {
return cloneInContext(ContextThemeWrapper(context, style))
}
fun View.applyWindowInsetsAsMargins() {
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
updateLayoutParams<MarginLayoutParams> {
setMargins(insets.left, insets.top, insets.right, insets.bottom)
}
windowInsets
}
}
fun View.fadeIn() {
alpha = 0f
isVisible = true
animate().apply {
alpha(1f)
@Suppress("MagicNumber")
duration = 300L
interpolator = LinearOutSlowInInterpolator()
withLayer()
}
}
inline fun Resources.dip(px: Int) = (px * displayMetrics.density).toInt()
inline var Window.brightness: Float
get() = attributes.screenBrightness
set(value) {
attributes = attributes.apply {
screenBrightness = value
}
}

View file

@ -0,0 +1,120 @@
/**
* Taken and adapted from https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt
*
* Copyright 2015 Javier Tomás
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jellyfin.mobile.utils
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.webkit.ServiceWorkerClientCompat
import androidx.webkit.ServiceWorkerControllerCompat
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewFeature
import io.ktor.http.HttpStatusCode
import timber.log.Timber
import java.util.Locale
fun Context.isWebViewSupported(): Boolean {
@Suppress("TooGenericExceptionCaught")
try {
// May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView is not installed
CookieManager.getInstance()
} catch (e: Exception) {
Timber.e(e)
return false
}
return packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW)
}
fun WebView.isOutdated(): Boolean =
getWebViewMajorVersion() < Constants.MINIMUM_WEB_VIEW_VERSION
private fun WebView.getWebViewMajorVersion(): Int {
val userAgent = getDefaultUserAgentString()
val version = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(userAgent)?.let { match ->
match.groupValues.getOrNull(1)?.toInt()
} ?: 0
Timber.i("WebView user agent is $userAgent, detected version is $version")
return version
}
// Based on https://stackoverflow.com/a/29218966
private fun WebView.getDefaultUserAgentString(): String {
val originalUA: String = settings.userAgentString
// Next call to getUserAgentString() will get us the default
settings.userAgentString = null
val defaultUserAgentString = settings.userAgentString
// Revert to original UA string
settings.userAgentString = originalUA
return defaultUserAgentString
}
/**
* Workaround for service worker breaking script injections
*/
fun enableServiceWorkerWorkaround() {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_BASIC_USAGE)) {
return
}
val serviceWorkerClient = object : ServiceWorkerClientCompat() {
override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
val path = request.url.path?.lowercase(Locale.ROOT) ?: return null
return when {
path.endsWith(Constants.SERVICE_WORKER_PATH) -> {
WebResourceResponse("application/javascript", "utf-8", null).apply {
with(HttpStatusCode.NotFound) { setStatusCodeAndReasonPhrase(value, description) }
}
}
else -> null
}
}
}
ServiceWorkerControllerCompat.getInstance().setServiceWorkerClient(serviceWorkerClient)
}
@SuppressLint("SetJavaScriptEnabled")
fun WebSettings.applyDefault() {
javaScriptEnabled = true
domStorageEnabled = true
}
/**
* Opens the requested file from the application's assets directory.
*
* On some devices Android doesn't set the JavaScript MIME type,
* thus manually set it to "application/javascript" where applicable.
*
* @see WebViewAssetLoader.AssetsPathHandler.handle
*/
fun WebViewAssetLoader.AssetsPathHandler.inject(path: String): WebResourceResponse? = handle(path)?.apply {
if (path.endsWith(".js", ignoreCase = true)) {
mimeType = "application/javascript"
}
}

View file

@ -0,0 +1,20 @@
package org.jellyfin.mobile.utils.extensions
import android.app.Activity
import android.content.pm.ActivityInfo
import android.graphics.Point
import android.view.Surface
@Suppress("DEPRECATION")
fun Activity.lockOrientation() {
val display = windowManager.defaultDisplay
val size = Point().also(display::getSize)
val height = size.y
val width = size.x
requestedOrientation = when (display.rotation) {
Surface.ROTATION_90 -> if (width > height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
Surface.ROTATION_180 -> if (height > width) ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
Surface.ROTATION_270 -> if (width > height) ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else -> if (height > width) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
}

View file

@ -0,0 +1,10 @@
package org.jellyfin.mobile.utils.extensions
import android.os.Bundle
import org.jellyfin.mobile.utils.AndroidVersion
@Suppress("DEPRECATION")
inline fun <reified T> Bundle.getParcelableCompat(key: String?): T? = when {
AndroidVersion.isAtLeastT -> getParcelable(key, T::class.java)
else -> getParcelable(key)
}

View file

@ -0,0 +1,8 @@
@file:Suppress("NOTHING_TO_INLINE")
package org.jellyfin.mobile.utils.extensions
import androidx.fragment.app.Fragment
import org.jellyfin.mobile.MainActivity
inline fun Fragment.requireMainActivity(): MainActivity = requireActivity() as MainActivity

View file

@ -0,0 +1,21 @@
@file:Suppress("NOTHING_TO_INLINE")
package org.jellyfin.mobile.utils.extensions
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.add
import androidx.fragment.app.replace
import org.jellyfin.mobile.R
inline fun <reified T : Fragment> FragmentManager.addFragment(args: Bundle? = null) {
beginTransaction().apply {
add<T>(R.id.fragment_container, args = args)
addToBackStack(null)
}.commit()
}
inline fun <reified T : Fragment> FragmentManager.replaceFragment(args: Bundle? = null) {
beginTransaction().replace<T>(R.id.fragment_container, args = args).commit()
}

Some files were not shown because too many files have changed in this diff Show more