Integrate webapp into system

Implement media session, playback notification and Javascript interface for communication between host and webapp.
This commit is contained in:
Maxr1998
2020-07-25 01:55:31 +02:00
parent c38d84cd86
commit 5ebf7df3db
16 changed files with 977 additions and 6 deletions
+24 -1
View File
@@ -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)
}
+8 -1
View File
@@ -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>
+183
View File
@@ -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