From 5ebf7df3db56e0645eeb0170f5481b75897e9ac3 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Sat, 25 Jul 2020 01:55:31 +0200 Subject: [PATCH] Integrate webapp into system Implement media session, playback notification and Javascript interface for communication between host and webapp. --- app/build.gradle.kts | 25 +- app/src/main/AndroidManifest.xml | 9 +- app/src/main/assets/www/native/apphost.js | 183 ++++++++ app/src/main/assets/www/native/nativeshell.js | 70 ++++ .../org/jellyfin/android/AppPreferences.kt | 35 ++ .../org/jellyfin/android/NativeInterface.kt | 164 ++++++++ .../jellyfin/android/RemotePlayerService.kt | 395 ++++++++++++++++++ .../org/jellyfin/android/WebViewController.kt | 5 + .../org/jellyfin/android/WebappActivity.kt | 70 +++- .../org/jellyfin/android/utils/Constants.kt | 27 ++ .../res/drawable-hdpi/ic_notification.png | Bin 0 -> 1807 bytes .../res/drawable-ldpi/ic_notification.png | Bin 0 -> 1193 bytes .../res/drawable-mdpi/ic_notification.png | Bin 0 -> 1193 bytes .../res/drawable-xhdpi/ic_notification.png | Bin 0 -> 2423 bytes .../res/drawable-xxhdpi/ic_notification.png | Bin 0 -> 3747 bytes .../res/drawable-xxxhdpi/ic_notification.png | Bin 0 -> 5392 bytes 16 files changed, 977 insertions(+), 6 deletions(-) create mode 100644 app/src/main/assets/www/native/apphost.js create mode 100644 app/src/main/assets/www/native/nativeshell.js create mode 100644 app/src/main/java/org/jellyfin/android/AppPreferences.kt create mode 100644 app/src/main/java/org/jellyfin/android/NativeInterface.kt create mode 100644 app/src/main/java/org/jellyfin/android/RemotePlayerService.kt create mode 100644 app/src/main/java/org/jellyfin/android/WebViewController.kt create mode 100644 app/src/main/java/org/jellyfin/android/utils/Constants.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_notification.png create mode 100644 app/src/main/res/drawable-ldpi/ic_notification.png create mode 100644 app/src/main/res/drawable-mdpi/ic_notification.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_notification.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_notification.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_notification.png diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bd7be795..95231644 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 == "") { + outputDir.dir("native").asFile.list()?.forEach { script -> + writer.write("") + writer.newLine() + } + } + writer.write(it) + writer.newLine() + it + } + } while (line != null) + reader.close() + writer.close() + } } mergeTask.finalizedBy(copyTask) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3a9c0c88..df28f0a6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ package="org.jellyfin.android"> + + - + + + + \ No newline at end of file diff --git a/app/src/main/assets/www/native/apphost.js b/app/src/main/assets/www/native/apphost.js new file mode 100644 index 00000000..616f5f5f --- /dev/null +++ b/app/src/main/assets/www/native/apphost.js @@ -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; + } +}; diff --git a/app/src/main/assets/www/native/nativeshell.js b/app/src/main/assets/www/native/nativeshell.js new file mode 100644 index 00000000..ae456788 --- /dev/null +++ b/app/src/main/assets/www/native/nativeshell.js @@ -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(); \ No newline at end of file diff --git a/app/src/main/java/org/jellyfin/android/AppPreferences.kt b/app/src/main/java/org/jellyfin/android/AppPreferences.kt new file mode 100644 index 00000000..67779368 --- /dev/null +++ b/app/src/main/java/org/jellyfin/android/AppPreferences.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/jellyfin/android/NativeInterface.kt b/app/src/main/java/org/jellyfin/android/NativeInterface.kt new file mode 100644 index 00000000..fc839a89 --- /dev/null +++ b/app/src/main/java/org/jellyfin/android/NativeInterface.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/jellyfin/android/RemotePlayerService.kt b/app/src/main/java/org/jellyfin/android/RemotePlayerService.kt new file mode 100644 index 00000000..1aeb438a --- /dev/null +++ b/app/src/main/java/org/jellyfin/android/RemotePlayerService.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/jellyfin/android/WebViewController.kt b/app/src/main/java/org/jellyfin/android/WebViewController.kt new file mode 100644 index 00000000..f0886a35 --- /dev/null +++ b/app/src/main/java/org/jellyfin/android/WebViewController.kt @@ -0,0 +1,5 @@ +package org.jellyfin.android + +interface WebViewController { + fun loadUrl(url: String) +} \ No newline at end of file diff --git a/app/src/main/java/org/jellyfin/android/WebappActivity.kt b/app/src/main/java/org/jellyfin/android/WebappActivity.kt index 91550f60..46cf8f67 100644 --- a/app/src/main/java/org/jellyfin/android/WebappActivity.kt +++ b/app/src/main/java/org/jellyfin/android/WebappActivity.kt @@ -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(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 { diff --git a/app/src/main/java/org/jellyfin/android/utils/Constants.kt b/app/src/main/java/org/jellyfin/android/utils/Constants.kt new file mode 100644 index 00000000..451bc787 --- /dev/null +++ b/app/src/main/java/org/jellyfin/android/utils/Constants.kt @@ -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" +} \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_notification.png b/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..2d9f04f100f216030e39ca86e1fc657d3b111f58 GIT binary patch literal 1807 zcmV+q2k`ibP)BWK~#90?VMYPRaF?r|I2HdSIRqxS|+Al(9$x=iY$8wG(ixB zqA0qMqEOk3FBL()bc5(ZWf5kkQWjAnDrjnUK`AOVnG`QmmgZ&DQRmmgI=jxCIkVTd z&)#SDi1UNZ%sI2p|GS*M*ZJ1B*BPmeHrl8nE^h}SfV(BlC_G&1ockkM=t$#Y za22*9@*RWyoj|8-qE0~JoeuFg&?lhdfVa!M_I(1Mc=(RN5rZF(WgC3}peMFgMz9_s ze!_jk@cQ`%KWc&Q*+g#uc+TL|j|AotU?fl%qj3Tl5zy!WY&ZDxbhcS&-l6{ZnkK|; z0iBM(w+5ZfK>L8!9R{7lw8Uy3-`onZ#-KCLpc4_CLx6P#o#(U3Li0`x08SblxjCTI z7ypi0u}t<84LZj|I;p_cy!yhRQ|?nM#Ons#H3pq$v&lwNVX%jTr zVB|2+C7@$j3MA#$fX!~eQG?EeIGs?J&$5&21Lxeyz?i!XI!T!l_eba4aY-K=&|Bf}U;nj`5a<5&yZCCxRCU1=EeP;-AM+tS3Z#@8E! zZ*tBBPxT_*%Ou?{X^f=7g`>Zu9+FCHk~5NyNjf0ucS(CBeJ^RXbFL8QTuqMOMLfz&?ZEhAacACcq{`JZ0yqa&yoGNdpab z_oVW=WvMZY8A_SOcDV#ux3V2Ij%y7ZF^-#F`b4<|#a5_Aj5LmG4aD9VDI)epLv+9E ztl@eEzcQbi-uLPPOiK_`JH7Rq5=4rR`|UMd~c?3_DPadZ`c-!L^^(nLv9C0$;j)b*17+Z9rppftO(Mo3x^JpgJX?JOL> zNvf5!ukiDPd=XghoI4RGif@g!m(*K+Sumh*43soX(r`&vNE(#F_hJSVKPXL5mS1@s zQaC2Z4FV+Tl%$iA>IzSHEgX4#yRcCXN)vQZ3p!G2_agbTBySq^wn{(dceS7+t(8U< zb!mbY%P$fyRL)3xzxatp33ASDmo!^`KDf}RlQhdYx2^ceCY7{v?juQ8$@h91TE@3r zIV)+2q|wf~CimPzTik}o|3{YT^7TQh`EM~PX^^x@(h5n-oO64d8)yy~7%ab5887KZ zNu$*CQ?RviM$!+Gwn*A6X`OR!zvYE#z7}w?e3FLC7m33Q#~}GyBC~zbM(QQ~A!)CC zak@kQ7pgxy=T223T9r!%zh$nkd}TgRQZGr}CG{*E-6eG@{2nFJ6~;-@;Uc*@Nv8`x xkIA=Lj>@+%j!4=ssn$97XEkDJqm4GI%D-A3Wg1)_sC57U002ovPDHLkV1mnwJu3hJ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi/ic_notification.png b/app/src/main/res/drawable-ldpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..ba6c329ad0bac67aef9f12de61d6c570d30420e3 GIT binary patch literal 1193 zcmV;a1XlZrP)jpaZft6Ndk(E#}h|e@rbZ3z>MVD60!RQm|I2y??*&! z4%o+9?AxstzcM1`wTR#A5x+ZHCB75*(Gam0m=^J!27G1s_5mHONZ=_$z)9e$9R7>3 z_l%9r7T*K>X#{_)0MFwFM-`aY%mm&u0&fRg0iHHshr#t$v&1hq0uBQg8$RtoyYcQ4 zeA(4v4CrmN_;%nsBY{nZPY-@v=mX{(Hdbr>8fa@|0&9#wM}V0D``N($jNP{c<{n@= z&~I=**eLN+vD;>F3?Muo1?L{%UW5C4jPfg*z*-}~=Z5_~hCRRv!{ z$j?}4#7hF;Ffb$FcdKFl1C#j{fOQ7(HpBkei1-vw8@`_z_7B%9z7rTQNS6id=K@1H z5~u>_1^jO^?E9%_!~oVCgj)^!7jlSC@tnc2-LPL52zM1pV5dRysA0dijs$iaT-KnsBd_?Y4T37+ zuf0OP^PpOc5?;))a=fC}K2%qa7`c7moEr)}Umz*>Bw7=@r}m+9?u4Wd4WD>R#cxEv z8`)dCXdAE*Uq+)jKlwet6Br8q*Ic9lXO&4zjJPQ0-o8Dew8#O@Oq_l?jy_b9V7F7v4G70Ez|b z0o)*In|yb(@UPbFsCvx=+`W?a00)x*yCwBWI^vu=UZ>cK{Gz{1z5;HObZHLPYtx%- z0_W$9yC}J`LQYERm+vy8l1?Ol(|w~$zN5@ZVqPHWB1!d@WhK4ICNQ;5GD(^)X>oG3 z0%Pe-b`5;mqTr3d7iT=dpIg4d_@77rYd8hGOyvAkz?Jy({G+8x9ghLvC16plUULY; z&nZjgyTgr=mdN*JJNw39SkfU$`y}m>v{TYI&N)KHg{H_PsRcSc?^NWw*Btrv)Ggm_ zy5yTex-nMe=c%+i{UPaZ`7Sjm-@Sg8bl5pJP(;&Nl#PD?DW#Ii9oK$o00000NkvXX Hu0mjfUEwbA literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_notification.png b/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..ba6c329ad0bac67aef9f12de61d6c570d30420e3 GIT binary patch literal 1193 zcmV;a1XlZrP)jpaZft6Ndk(E#}h|e@rbZ3z>MVD60!RQm|I2y??*&! z4%o+9?AxstzcM1`wTR#A5x+ZHCB75*(Gam0m=^J!27G1s_5mHONZ=_$z)9e$9R7>3 z_l%9r7T*K>X#{_)0MFwFM-`aY%mm&u0&fRg0iHHshr#t$v&1hq0uBQg8$RtoyYcQ4 zeA(4v4CrmN_;%nsBY{nZPY-@v=mX{(Hdbr>8fa@|0&9#wM}V0D``N($jNP{c<{n@= z&~I=**eLN+vD;>F3?Muo1?L{%UW5C4jPfg*z*-}~=Z5_~hCRRv!{ z$j?}4#7hF;Ffb$FcdKFl1C#j{fOQ7(HpBkei1-vw8@`_z_7B%9z7rTQNS6id=K@1H z5~u>_1^jO^?E9%_!~oVCgj)^!7jlSC@tnc2-LPL52zM1pV5dRysA0dijs$iaT-KnsBd_?Y4T37+ zuf0OP^PpOc5?;))a=fC}K2%qa7`c7moEr)}Umz*>Bw7=@r}m+9?u4Wd4WD>R#cxEv z8`)dCXdAE*Uq+)jKlwet6Br8q*Ic9lXO&4zjJPQ0-o8Dew8#O@Oq_l?jy_b9V7F7v4G70Ez|b z0o)*In|yb(@UPbFsCvx=+`W?a00)x*yCwBWI^vu=UZ>cK{Gz{1z5;HObZHLPYtx%- z0_W$9yC}J`LQYERm+vy8l1?Ol(|w~$zN5@ZVqPHWB1!d@WhK4ICNQ;5GD(^)X>oG3 z0%Pe-b`5;mqTr3d7iT=dpIg4d_@77rYd8hGOyvAkz?Jy({G+8x9ghLvC16plUULY; z&nZjgyTgr=mdN*JJNw39SkfU$`y}m>v{TYI&N)KHg{H_PsRcSc?^NWw*Btrv)Ggm_ zy5yTex-nMe=c%+i{UPaZ`7Sjm-@Sg8bl5pJP(;&Nl#PD?DW#Ii9oK$o00000NkvXX Hu0mjfUEwbA literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..5f507b5efff895cab1f9020481d45bac6bf5b50d GIT binary patch literal 2423 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 N002ovPDHLkV1gt={A~aL literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..99097a3161eabace9d851fe397d5b53a4ed6c097 GIT binary patch literal 5392 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