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 00000000..2d9f04f1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification.png differ 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 00000000..ba6c329a Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_notification.png differ 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 00000000..ba6c329a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification.png differ 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 00000000..5f507b5e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 00000000..ba6badab Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification.png differ 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 00000000..99097a31 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ