mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-09-02 21:04:10 +03:00
Integrate webapp into system
Implement media session, playback notification and Javascript interface for communication between host and webapp.
This commit is contained in:
+24
-1
@@ -51,7 +51,30 @@ afterEvaluate {
|
||||
dependsOn(assembleWebapp)
|
||||
|
||||
from(assembleWebapp.outputs)
|
||||
into(mergeTask.outputDir.get().dir("www"))
|
||||
val outputDir = mergeTask.outputDir.get().dir("www")
|
||||
into(outputDir)
|
||||
|
||||
doLast {
|
||||
val indexFile = outputDir.file("index.html").asFile
|
||||
val reader = indexFile.bufferedReader()
|
||||
val writer = outputDir.file("index_app.html").asFile.bufferedWriter()
|
||||
var line: String?
|
||||
do {
|
||||
line = reader.readLine()?.let {
|
||||
if (it == "</body>") {
|
||||
outputDir.dir("native").asFile.list()?.forEach { script ->
|
||||
writer.write("<script src=\"native/$script\" defer></script>")
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
it
|
||||
}
|
||||
} while (line != null)
|
||||
reader.close()
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
mergeTask.finalizedBy(copyTask)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
package="org.jellyfin.android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -10,11 +12,16 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity android:name=".WebappActivity">
|
||||
|
||||
<activity
|
||||
android:name=".WebappActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service android:name=".RemotePlayerService" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,183 @@
|
||||
var deviceId;
|
||||
var deviceName;
|
||||
var appName;
|
||||
var appVersion;
|
||||
|
||||
var features = [
|
||||
'filedownload',
|
||||
'displaylanguage',
|
||||
//'externalplayerintent',
|
||||
'subtitleappearancesettings',
|
||||
//'sharing',
|
||||
'exit',
|
||||
'htmlaudioautoplay',
|
||||
'htmlvideoautoplay',
|
||||
'externallinks',
|
||||
'multiserver',
|
||||
'physicalvolumecontrol',
|
||||
'remotecontrol',
|
||||
'castmenuhashchange'
|
||||
];
|
||||
|
||||
function getDeviceProfile(profileBuilder, item) {
|
||||
var profile = profileBuilder({
|
||||
enableMkvProgressive: false
|
||||
});
|
||||
|
||||
profile.CodecProfiles = profile.CodecProfiles.filter(function (i) {
|
||||
return i.Type == 'Audio';
|
||||
});
|
||||
|
||||
profile.SubtitleProfiles.push(
|
||||
{
|
||||
Format: 'ssa',
|
||||
Method: 'External'
|
||||
},
|
||||
{
|
||||
Format: 'ass',
|
||||
Method: 'External'
|
||||
}
|
||||
);
|
||||
|
||||
profile.CodecProfiles.push({
|
||||
Type: 'Video',
|
||||
Container: 'avi',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'NotEqual',
|
||||
Property: 'CodecTag',
|
||||
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;
|
||||
};
|
||||
|
||||
function getDeviceProfileForVideo(item) {
|
||||
var container = item.Container;
|
||||
var videoTracks = audioTracks = subtitleTracks = [];
|
||||
|
||||
for (var i = 0; i < item.MediaStreams.lengh; i++) {
|
||||
var track = item.MediaStreams[i];
|
||||
|
||||
switch (track.Type) {
|
||||
case 'Video':
|
||||
videoTracks.push(parseVideoTrack(track));
|
||||
break;
|
||||
case 'Audio':
|
||||
audioTracks.push(parseAudioTrack(track));
|
||||
break;
|
||||
case 'Subtitle':
|
||||
subtitleTracks.push(parseSubtitleTrack(track));
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var supportedTracks = window.ExoPlayer.checkTracksSupport(container, videoTracks, audioTracks, subtitleTracks);
|
||||
// TODO: check if the given tracks are supported. If not, they are not added up to directPlayProfiles
|
||||
}
|
||||
|
||||
function parseVideoTrack(track) {
|
||||
return {
|
||||
codec: track.Codec,
|
||||
bitRate: track.BitRate,
|
||||
width: track.Width,
|
||||
height: track.Height,
|
||||
frameRate: track.RealFrameRate
|
||||
};
|
||||
}
|
||||
|
||||
function parseAudioTrack(track) {
|
||||
return {
|
||||
codec: track.Codec,
|
||||
bitRate: track.BitRate,
|
||||
channelCount: track.Channels,
|
||||
sampleRate: track.SampleRate
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubtitleTrack(track) {
|
||||
return {
|
||||
codec: track.Codec
|
||||
};
|
||||
}
|
||||
|
||||
window.NativeShell.AppHost = {
|
||||
exit: function () {
|
||||
if (navigator.app && navigator.app.exitApp) {
|
||||
navigator.app.exitApp();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
},
|
||||
supports: function (command) {
|
||||
return features.indexOf(command.toLowerCase()) != -1;
|
||||
},
|
||||
getSyncProfile: getDeviceProfile,
|
||||
getDefaultLayout: function() {
|
||||
return 'mobile';
|
||||
},
|
||||
getDeviceProfile: getDeviceProfile,
|
||||
init: function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
window.NativeShell.getDeviceInformation(function(result) {
|
||||
// set globally so they can be used elsewhere
|
||||
deviceId = result.deviceId;
|
||||
deviceName = result.deviceName;
|
||||
appName = result.appName;
|
||||
appVersion = result.appVersion;
|
||||
|
||||
appInfo = {
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
appName: appName,
|
||||
appVersion: appVersion
|
||||
};
|
||||
|
||||
resolve(appInfo);
|
||||
}, function(err) {
|
||||
console.log(err);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
},
|
||||
deviceName: function() {
|
||||
return deviceName;
|
||||
},
|
||||
deviceId: function() {
|
||||
return deviceId;
|
||||
},
|
||||
appName: function() {
|
||||
return appName;
|
||||
},
|
||||
appVersion: function() {
|
||||
return appVersion;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
function NativeShell() {}
|
||||
|
||||
NativeShell.prototype.getDeviceInformation = function(successCallback, errorCallback) {
|
||||
var result = window.NativeInterface.getDeviceInformation();
|
||||
if (result) {
|
||||
if (successCallback) successCallback(JSON.parse(result));
|
||||
} else {
|
||||
if (errorCallback) errorCallback();
|
||||
}
|
||||
};
|
||||
|
||||
NativeShell.prototype.enableFullscreen = function(successCallback, errorCallback) {
|
||||
var result = window.NativeInterface.enableFullscreen();
|
||||
if (result) {
|
||||
if (successCallback) successCallback();
|
||||
} else {
|
||||
if (errorCallback) errorCallback();
|
||||
}
|
||||
};
|
||||
|
||||
NativeShell.prototype.disableFullscreen = function(successCallback, errorCallback) {
|
||||
var result = window.NativeInterface.disableFullscreen();
|
||||
if (result) {
|
||||
if (successCallback) successCallback();
|
||||
} else {
|
||||
if (errorCallback) errorCallback();
|
||||
}
|
||||
};
|
||||
|
||||
NativeShell.prototype.openUrl = function(url, target, successCallback, errorCallback) {
|
||||
var result = window.NativeInterface.openIntent(url);
|
||||
if (result) {
|
||||
if (successCallback) successCallback();
|
||||
} else {
|
||||
if (errorCallback) errorCallback();
|
||||
}
|
||||
};
|
||||
|
||||
NativeShell.prototype.updateMediaSession = function(options, successCallback, errorCallback) {
|
||||
var result = window.NativeInterface.updateMediaSession(JSON.stringify(options));
|
||||
if (result) {
|
||||
if (successCallback) successCallback();
|
||||
} else {
|
||||
if (errorCallback) errorCallback();
|
||||
}
|
||||
};
|
||||
|
||||
NativeShell.prototype.hideMediaSession = function(successCallback, errorCallback) {
|
||||
var result = window.NativeInterface.hideMediaSession();
|
||||
if (result) {
|
||||
if (successCallback) successCallback();
|
||||
} else {
|
||||
if (errorCallback) errorCallback();
|
||||
}
|
||||
};
|
||||
|
||||
NativeShell.prototype.downloadFile = function(options, successCallback, errorCallback) {
|
||||
var result = window.NativeInterface.downloadFile(JSON.stringify(options));
|
||||
if (result) {
|
||||
if (successCallback) successCallback();
|
||||
} else {
|
||||
if (errorCallback) errorCallback();
|
||||
}
|
||||
};
|
||||
|
||||
NativeShell.prototype.getPlugins = function() {
|
||||
return [];
|
||||
};
|
||||
|
||||
window.NativeShell = new NativeShell();
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.jellyfin.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import androidx.core.content.edit
|
||||
|
||||
class AppPreferences(context: Context) {
|
||||
private val context: Context = context.applicationContext
|
||||
private val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
var ignoreBatteryOptimizations: Boolean
|
||||
get() = sharedPreferences.getBoolean(context.getString(R.string.pref_ignore_battery_optimizations), false)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(context.getString(R.string.pref_ignore_battery_optimizations), value)
|
||||
}
|
||||
}
|
||||
|
||||
var downloadMethodDialogShown: Boolean
|
||||
get() = sharedPreferences.getBoolean(context.getString(R.string.pref_download_method_dialog_shown), false)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(context.getString(R.string.pref_download_method_dialog_shown), value)
|
||||
}
|
||||
}
|
||||
|
||||
var downloadMethod: Int
|
||||
get() = sharedPreferences.getInt(context.getString(R.string.pref_download_method), 0)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(context.getString(R.string.pref_download_method), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package org.jellyfin.android
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings.Secure
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import org.jellyfin.android.utils.Constants
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
|
||||
class NativeInterface(private val activity: WebappActivity) {
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
@JavascriptInterface
|
||||
fun getDeviceInformation(): String? = try {
|
||||
JSONObject().apply {
|
||||
// TODO: replace this later with a randomly generated persistent string stored in local settings
|
||||
put("deviceId", Secure.getString(activity.contentResolver, Secure.ANDROID_ID))
|
||||
put("deviceName", Build.MODEL)
|
||||
put("appName", "Jellyfin Android")
|
||||
put("appVersion", BuildConfig.VERSION_CODE.toString())
|
||||
}.toString()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@JavascriptInterface
|
||||
fun enableFullscreen(): Boolean {
|
||||
activity.runOnUiThread {
|
||||
val visibility = (View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
|
||||
activity.window.apply {
|
||||
decorView.systemUiVisibility = visibility
|
||||
addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@JavascriptInterface
|
||||
fun disableFullscreen(): Boolean {
|
||||
activity.runOnUiThread {
|
||||
activity.window.apply {
|
||||
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openIntent(uri: String): Boolean = try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Timber.e("openIntent: %s", e.message)
|
||||
false
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun updateMediaSession(args: String): Boolean {
|
||||
val options = JSONObject(args)
|
||||
val intent = Intent(activity, RemotePlayerService::class.java).apply {
|
||||
action = Constants.ACTION_REPORT
|
||||
try {
|
||||
putExtra("playerAction", options.getString("action"))
|
||||
putExtra("title", options.getString("title"))
|
||||
putExtra("artist", options.getString("artist"))
|
||||
putExtra("album", options.getString("album"))
|
||||
putExtra("duration", options.getInt("duration"))
|
||||
putExtra("position", options.getInt("position"))
|
||||
putExtra("imageUrl", options.getString("imageUrl"))
|
||||
putExtra("canSeek", options.getBoolean("canSeek"))
|
||||
putExtra("isPaused", options.getBoolean("isPaused"))
|
||||
putExtra("itemId", options.getString("itemId"))
|
||||
putExtra("isLocalPlayer", options.getBoolean("isLocalPlayer"))
|
||||
} catch (e: Exception) {
|
||||
Timber.e("updateMediaSession: %s", e.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
activity.startService(intent)
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun hideMediaSession(): Boolean {
|
||||
val intent = Intent(activity, RemotePlayerService::class.java).apply {
|
||||
action = Constants.ACTION_REPORT
|
||||
putExtra("playerAction", "playbackstop")
|
||||
}
|
||||
activity.startService(intent)
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun downloadFile(args: String): Boolean {
|
||||
val title: String
|
||||
val url: String
|
||||
try {
|
||||
val options = JSONObject(args)
|
||||
title = options.getString("title")
|
||||
url = options.getString("url")
|
||||
} catch (e: Exception) {
|
||||
Timber.e("download: %s", e.message)
|
||||
return false
|
||||
}
|
||||
val context: Context = activity
|
||||
val uri = Uri.parse(url)
|
||||
val request = DownloadManager.Request(uri)
|
||||
.setTitle(title)
|
||||
.setDescription(activity.getString(R.string.downloading))
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
if (activity.appPreferences.downloadMethodDialogShown) {
|
||||
startDownload(request)
|
||||
} else {
|
||||
activity.runOnUiThread {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(context.getString(R.string.network_title))
|
||||
.setMessage(context.getString(R.string.network_message))
|
||||
.setNegativeButton(context.getString(R.string.wifi_only)) { _, _ ->
|
||||
activity.appPreferences.downloadMethod = 0
|
||||
startDownload(request)
|
||||
}
|
||||
.setPositiveButton(activity.getString(R.string.mobile_data)) { _, _ ->
|
||||
activity.appPreferences.downloadMethod = 1
|
||||
startDownload(request)
|
||||
}
|
||||
.setPositiveButton(activity.getString(R.string.mobile_data_and_roaming)) { _, _ ->
|
||||
activity.appPreferences.downloadMethod = 2
|
||||
startDownload(request)
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
activity.appPreferences.downloadMethodDialogShown = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun startDownload(request: DownloadManager.Request) {
|
||||
when (activity.appPreferences.downloadMethod) {
|
||||
0 -> request.setAllowedOverMetered(false).setAllowedOverRoaming(false)
|
||||
1 -> request.setAllowedOverMetered(true).setAllowedOverRoaming(false)
|
||||
2 -> request.setAllowedOverMetered(true).setAllowedOverRoaming(true)
|
||||
}
|
||||
val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
downloadManager.enqueue(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package org.jellyfin.android
|
||||
|
||||
import android.app.*
|
||||
import android.app.Notification.MediaStyle
|
||||
import android.bluetooth.BluetoothA2dp
|
||||
import android.bluetooth.BluetoothHeadset
|
||||
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 android.media.Rating
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.Coil
|
||||
import coil.request.GetRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.android.utils.Constants
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class RemotePlayerService : Service(), CoroutineScope {
|
||||
|
||||
private lateinit var job: Job
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = job + Dispatchers.Main
|
||||
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
|
||||
private var mediaController: MediaController? = null
|
||||
private var mediaSessionManager: MediaSessionManager? = null
|
||||
private var mediaSession: MediaSession? = null
|
||||
private var largeItemIcon: Bitmap? = null
|
||||
private var mediaSessionId: String? = null
|
||||
private val notifyId = 84
|
||||
|
||||
private val binder = ServiceBinder()
|
||||
|
||||
var webViewController: WebViewController? = null
|
||||
|
||||
/**
|
||||
* only trip this flag if the user switches from headphones to speaker
|
||||
* prevent stopping music when inserting headphones for the first time
|
||||
*/
|
||||
private var headphoneFlag = false
|
||||
private val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_HEADSET_PLUG) {
|
||||
val state = intent.getIntExtra("state", 2)
|
||||
if (state == 0) {
|
||||
sendCommand("playpause")
|
||||
headphoneFlag = true
|
||||
} else if (headphoneFlag) {
|
||||
sendCommand("playpause")
|
||||
}
|
||||
} else if (intent.action == BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED) {
|
||||
val extras = intent.extras ?: return
|
||||
val state = extras.getInt(BluetoothA2dp.EXTRA_STATE)
|
||||
val previousState = extras.getInt(BluetoothA2dp.EXTRA_PREVIOUS_STATE)
|
||||
if ((state == BluetoothA2dp.STATE_DISCONNECTED || state == BluetoothA2dp.STATE_DISCONNECTING) && previousState == BluetoothA2dp.STATE_CONNECTED) {
|
||||
sendCommand("pause")
|
||||
}
|
||||
} else if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) {
|
||||
val extras = intent.extras ?: return
|
||||
val state = extras.getInt(BluetoothHeadset.EXTRA_STATE)
|
||||
val previousState = extras.getInt(BluetoothHeadset.EXTRA_PREVIOUS_STATE)
|
||||
if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED && previousState == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
|
||||
sendCommand("pause")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
job = Job()
|
||||
|
||||
// create wakelock for the music service
|
||||
val powerManager: PowerManager = getSystemService(AppCompatActivity.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jellyfin:WakeLock")
|
||||
|
||||
// add intent filter to watch for headphone state
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_HEADSET_PLUG)
|
||||
|
||||
// bluetooth related filters - needs BLUETOOTH permission
|
||||
addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
|
||||
addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
|
||||
}
|
||||
registerReceiver(receiver, filter)
|
||||
|
||||
// create notification channel
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val name = "Jellyfin"
|
||||
val description = "Media notifications"
|
||||
val importance = NotificationManager.IMPORTANCE_LOW
|
||||
val notificationChannel = NotificationChannel(CHANNEL_ID, name, importance)
|
||||
notificationChannel.description = description
|
||||
notificationManager.createNotificationChannel(notificationChannel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent): Boolean {
|
||||
onStopped()
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (mediaSessionManager == null) {
|
||||
initMediaSessions()
|
||||
}
|
||||
handleIntent(intent)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun startWakelock() {
|
||||
if (!wakeLock.isHeld) wakeLock.acquire(4 * 60 * 60 * 1000L /* 4 hours */)
|
||||
}
|
||||
|
||||
private fun stopWakelock() {
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
if (intent == null || intent.action == null) return
|
||||
val action = intent.action
|
||||
if (action == Constants.ACTION_REPORT) {
|
||||
notify(intent)
|
||||
return
|
||||
}
|
||||
val transportControls = mediaController?.transportControls ?: return
|
||||
when (action) {
|
||||
Constants.ACTION_PLAY -> {
|
||||
transportControls.play()
|
||||
startWakelock()
|
||||
}
|
||||
Constants.ACTION_PAUSE -> {
|
||||
transportControls.pause()
|
||||
stopWakelock()
|
||||
}
|
||||
Constants.ACTION_FAST_FORWARD -> transportControls.fastForward()
|
||||
Constants.ACTION_REWIND -> transportControls.rewind()
|
||||
Constants.ACTION_PREVIOUS -> transportControls.skipToPrevious()
|
||||
Constants.ACTION_NEXT -> transportControls.skipToNext()
|
||||
Constants.ACTION_STOP -> transportControls.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(handledIntent: Intent) {
|
||||
val playerAction = handledIntent.getStringExtra("playerAction")
|
||||
if (playerAction == "playbackstop") {
|
||||
onStopped()
|
||||
return
|
||||
}
|
||||
val itemId = handledIntent.getStringExtra("itemId")
|
||||
val imageUrl = handledIntent.getStringExtra("imageUrl")
|
||||
if (largeItemIcon != null && mediaSessionId == itemId) {
|
||||
notifyWithBitmap(handledIntent, largeItemIcon)
|
||||
return
|
||||
}
|
||||
if (imageUrl != null && imageUrl.isNotEmpty()) {
|
||||
launch {
|
||||
val request = GetRequest.Builder(this@RemotePlayerService).data(imageUrl).build()
|
||||
val bitmap = Coil.imageLoader(this@RemotePlayerService).execute(request).drawable?.toBitmap()
|
||||
largeItemIcon = bitmap
|
||||
notifyWithBitmap(handledIntent, bitmap);
|
||||
}
|
||||
} else {
|
||||
notifyWithBitmap(handledIntent, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyWithBitmap(handledIntent: Intent, largeIcon: Bitmap?) {
|
||||
val artist = handledIntent.getStringExtra("artist")
|
||||
val album = handledIntent.getStringExtra("album")
|
||||
val title = handledIntent.getStringExtra("title")
|
||||
val itemId = handledIntent.getStringExtra("itemId")
|
||||
val isPaused = handledIntent.getBooleanExtra("isPaused", false)
|
||||
val canSeek = handledIntent.getBooleanExtra("canSeek", false)
|
||||
val isLocalPlayer = handledIntent.getBooleanExtra("isLocalPlayer", false)
|
||||
val position = handledIntent.getIntExtra("position", 0)
|
||||
val duration = handledIntent.getIntExtra("duration", 0)
|
||||
|
||||
// system will recognize notification as media playback
|
||||
// show cover art and controls on lock screen
|
||||
if (mediaSessionId == null || mediaSessionId != itemId) {
|
||||
setMediaSessionMetadata(mediaSession, itemId, artist, album, title, duration, largeIcon)
|
||||
mediaSessionId = itemId
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val action = when {
|
||||
isPaused -> generateAction(android.R.drawable.ic_media_play, "Play", Constants.ACTION_PLAY)
|
||||
else -> generateAction(android.R.drawable.ic_media_pause, "Pause", Constants.ACTION_PAUSE)
|
||||
}
|
||||
val style = MediaStyle()
|
||||
.setMediaSession(mediaSession!!.sessionToken)
|
||||
.setShowActionsInCompactView(0, 2, 4)
|
||||
|
||||
val state = PlaybackState.Builder().apply {
|
||||
setActiveQueueItemId(MediaSession.QueueItem.UNKNOWN_ID.toLong())
|
||||
setActions(PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_SET_RATING or PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE)
|
||||
setState(if (isPaused) PlaybackState.STATE_PAUSED else PlaybackState.STATE_PLAYING, position.toLong(), 1.0f)
|
||||
}.build()
|
||||
|
||||
mediaSession!!.setPlaybackState(state)
|
||||
|
||||
val builder = Notification.Builder(this)
|
||||
.setContentTitle(title)
|
||||
.setContentText(artist)
|
||||
.setSubText(album)
|
||||
.setPriority(Notification.PRIORITY_LOW)
|
||||
.setDeleteIntent(createDeleteIntent())
|
||||
.setContentIntent(createContentIntent())
|
||||
.setProgress(duration, position, duration == 0)
|
||||
.setStyle(style)
|
||||
|
||||
// newer versions of android require notification channel to display
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
builder.setChannelId(CHANNEL_ID)
|
||||
// color notification based on cover art
|
||||
builder.setColorized(true)
|
||||
}
|
||||
|
||||
// swipe to dismiss if paused
|
||||
builder.setOngoing(!isPaused)
|
||||
|
||||
// show current position in "when" field pre-O
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
builder.setShowWhen(!isPaused)
|
||||
builder.setUsesChronometer(!isPaused)
|
||||
builder.setWhen(System.currentTimeMillis() - position)
|
||||
}
|
||||
|
||||
// privacy value for lock screen
|
||||
builder.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
|
||||
if (largeIcon != null) {
|
||||
builder.setLargeIcon(largeIcon)
|
||||
builder.setSmallIcon(R.drawable.ic_notification)
|
||||
} else {
|
||||
builder.setSmallIcon(R.drawable.ic_notification)
|
||||
}
|
||||
|
||||
// setup actions
|
||||
builder.addAction(generateAction(android.R.drawable.ic_media_previous, "Previous", Constants.ACTION_PREVIOUS))
|
||||
builder.addAction(generateAction(android.R.drawable.ic_media_rew, "Rewind", Constants.ACTION_REWIND))
|
||||
builder.addAction(action)
|
||||
builder.addAction(generateAction(android.R.drawable.ic_media_ff, "Fast Forward", Constants.ACTION_FAST_FORWARD))
|
||||
builder.addAction(generateAction(android.R.drawable.ic_media_next, "Next", Constants.ACTION_NEXT))
|
||||
try {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(notifyId, builder.build())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDeleteIntent(): PendingIntent {
|
||||
val intent = Intent(applicationContext, RemotePlayerService::class.java).apply {
|
||||
action = Constants.ACTION_STOP
|
||||
}
|
||||
return PendingIntent.getService(applicationContext, 1, intent, 0)
|
||||
}
|
||||
|
||||
private fun createContentIntent(): PendingIntent {
|
||||
val intent = Intent(this, WebappActivity::class.java).apply {
|
||||
action = Constants.ACTION_SHOW_PLAYER
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
return PendingIntent.getActivity(this, 100, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
||||
}
|
||||
|
||||
private fun generateAction(icon: Int, title: String, intentAction: String): Notification.Action {
|
||||
val intent = Intent(applicationContext, RemotePlayerService::class.java).apply {
|
||||
action = intentAction
|
||||
}
|
||||
val pendingIntent = PendingIntent.getService(applicationContext, notifyId, intent, 0)
|
||||
return Notification.Action(icon, title, pendingIntent)
|
||||
}
|
||||
|
||||
private fun initMediaSessions() {
|
||||
mediaSessionManager = getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
|
||||
mediaSession = MediaSession(applicationContext, javaClass.toString()).apply {
|
||||
mediaController = MediaController(applicationContext, sessionToken)
|
||||
isActive = true
|
||||
setFlags(MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS or MediaSession.FLAG_HANDLES_MEDIA_BUTTONS)
|
||||
setCallback(object : MediaSession.Callback() {
|
||||
override fun onPlay() {
|
||||
sendCommand("playpause")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
sendCommand("playpause")
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
sendCommand("next")
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
sendCommand("previous")
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
sendCommand("fastforward")
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
sendCommand("rewind")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
sendCommand("stop")
|
||||
onStopped()
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
sendSeekCommand(pos)
|
||||
}
|
||||
|
||||
override fun onSetRating(rating: Rating) {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMediaSessionMetadata(
|
||||
mediaSession: MediaSession?,
|
||||
itemId: String?,
|
||||
artist: String?,
|
||||
album: String?,
|
||||
title: String?,
|
||||
duration: Int,
|
||||
largeIcon: Bitmap?
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
|
||||
.putString(MediaMetadata.METADATA_KEY_ALBUM, album)
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, title)
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, duration.toLong())
|
||||
.putString(MediaMetadata.METADATA_KEY_MEDIA_ID, itemId)
|
||||
if (largeIcon != null) {
|
||||
metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, largeIcon)
|
||||
}
|
||||
mediaSession!!.setMetadata(metadataBuilder.build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendCommand(action: String) {
|
||||
webViewController?.loadUrl("javascript:require(['inputManager'], function(inputManager){inputManager.trigger('$action');});")
|
||||
}
|
||||
|
||||
private fun sendSeekCommand(pos: Long) {
|
||||
webViewController?.loadUrl("javascript:require(['inputManager'], function(inputManager){inputManager.trigger('seek', $pos);});")
|
||||
}
|
||||
|
||||
private fun onStopped() {
|
||||
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.cancel(notifyId)
|
||||
mediaSession!!.release()
|
||||
headphoneFlag = false
|
||||
stopWakelock()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(receiver)
|
||||
job.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
inner class ServiceBinder : Binder() {
|
||||
val service get() = this@RemotePlayerService
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "JellyfinChannelId"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.android
|
||||
|
||||
interface WebViewController {
|
||||
fun loadUrl(url: String)
|
||||
}
|
||||
@@ -1,36 +1,98 @@
|
||||
package org.jellyfin.android
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.jellyfin.android.utils.lazyView
|
||||
|
||||
class WebappActivity : AppCompatActivity() {
|
||||
class WebappActivity : AppCompatActivity(), WebViewController {
|
||||
|
||||
val appPreferences: AppPreferences by lazy { AppPreferences(this) }
|
||||
|
||||
private var serviceBinder: RemotePlayerService.ServiceBinder? = null
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
|
||||
serviceBinder = binder as? RemotePlayerService.ServiceBinder
|
||||
serviceBinder?.run { service.webViewController = this@WebappActivity }
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(componentName: ComponentName) {
|
||||
serviceBinder?.run { service.webViewController = null }
|
||||
}
|
||||
}
|
||||
private val webView: WebView by lazyView<WebView>(R.id.web_view)
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_webapp)
|
||||
|
||||
// Bind player service
|
||||
bindService(Intent(this, RemotePlayerService::class.java), serviceConnection, Service.BIND_AUTO_CREATE)
|
||||
|
||||
// Setup WebView
|
||||
setContentView(R.layout.activity_webapp)
|
||||
webView.webChromeClient = WebChromeClient()
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowUniversalAccessFromFileURLs = true
|
||||
}
|
||||
webView.addJavascriptInterface(NativeInterface(this), "NativeInterface")
|
||||
|
||||
webView.loadUrl("file:///android_asset/www/index.html")
|
||||
// Load main page
|
||||
webView.loadUrl("file:///android_asset/www/index_app.html")
|
||||
|
||||
requestNoBatteryOptimizations()
|
||||
}
|
||||
|
||||
private fun requestNoBatteryOptimizations() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val powerManager: PowerManager = getSystemService(POWER_SERVICE) as PowerManager
|
||||
if (!appPreferences.ignoreBatteryOptimizations && !powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) {
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
|
||||
builder.setTitle(getString(R.string.battery_optimizations_title))
|
||||
builder.setMessage(getString(R.string.battery_optimizations_message))
|
||||
builder.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
appPreferences.ignoreBatteryOptimizations = true
|
||||
}
|
||||
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
try {
|
||||
val intent = Intent(ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadUrl(url: String) {
|
||||
webView.loadUrl(url)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
val history = webView.copyBackForwardList()
|
||||
if (webView.canGoBack() && history.currentIndex > 1) {
|
||||
webView.goBack()
|
||||
} else super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unbindService(serviceConnection)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.jellyfin.android.utils
|
||||
|
||||
object Constants {
|
||||
const val ACTION_PLAYPAUSE = "action_playpause"
|
||||
const val ACTION_PLAY = "action_play"
|
||||
const val ACTION_PAUSE = "action_pause"
|
||||
const val ACTION_UNPAUSE = "action_unpause"
|
||||
const val ACTION_REWIND = "action_rewind"
|
||||
const val ACTION_FAST_FORWARD = "action_fast_foward"
|
||||
const val ACTION_NEXT = "action_next"
|
||||
const val ACTION_PREVIOUS = "action_previous"
|
||||
const val ACTION_STOP = "action_stop"
|
||||
const val ACTION_REPORT = "action_report"
|
||||
const val ACTION_SEEK = "action_seek"
|
||||
const val ACTION_SHOW_PLAYER = "ACTION_SHOW_PLAYER"
|
||||
const val TICKS_PER_MILLISECOND = 10000
|
||||
|
||||
/**
|
||||
* exoplayer events
|
||||
*/
|
||||
const val EVENT_VOLUME_CHANGE = "VolumeChange"
|
||||
const val EVENT_PLAY = "Play"
|
||||
const val EVENT_PLAYING = "Playing"
|
||||
const val EVENT_PAUSE = "Pause"
|
||||
const val EVENT_ENDED = "Ended"
|
||||
const val EVENT_TIME_UPDATE = "TimeUpdate"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Reference in New Issue
Block a user