package com.github.libretube.services import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper import android.widget.Toast import com.fasterxml.jackson.databind.ObjectMapper import com.github.libretube.R import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Segments import com.github.libretube.api.obj.Streams import com.github.libretube.constants.BACKGROUND_CHANNEL_ID import com.github.libretube.constants.IntentData import com.github.libretube.constants.PLAYER_NOTIFICATION_ID import com.github.libretube.constants.PreferenceKeys import com.github.libretube.extensions.toID import com.github.libretube.util.AutoPlayHelper import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PreferenceHelper import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.audio.AudioAttributes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch /** * Loads the selected videos audio in background mode with a notification area. */ class BackgroundMode : Service() { /** * VideoId of the video */ private lateinit var videoId: String /** *PlaylistId for autoplay */ private var playlistId: String? = null /** * The response that gets when called the Api. */ private var streams: com.github.libretube.api.obj.Streams? = null /** * The [ExoPlayer] player. Followed tutorial [here](https://developer.android.com/codelabs/exoplayer-intro) */ private var player: ExoPlayer? = null private var playWhenReadyPlayer = true /** * The [AudioAttributes] handle the audio focus of the [player] */ private lateinit var audioAttributes: AudioAttributes /** * SponsorBlock Segment data */ private var segmentData: com.github.libretube.api.obj.Segments? = null /** * [Notification] for the player */ private lateinit var nowPlayingNotification: NowPlayingNotification /** * The [videoId] of the next stream for autoplay */ private var nextStreamId: String? = null /** * Helper for finding the next video in the playlist */ private lateinit var autoPlayHelper: AutoPlayHelper /** * Autoplay Preference */ private val autoplay = PreferenceHelper.getBoolean(PreferenceKeys.AUTO_PLAY, true) private val handler = Handler(Looper.getMainLooper()) /** * Setting the required [Notification] for running as a foreground service */ override fun onCreate() { super.onCreate() if (Build.VERSION.SDK_INT >= 26) { val channelId = BACKGROUND_CHANNEL_ID val channel = NotificationChannel( channelId, "Background Service", NotificationManager.IMPORTANCE_DEFAULT ) val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) val notification: Notification = Notification.Builder(this, channelId) .setContentTitle(getString(R.string.app_name)) .setContentText(getString(R.string.playingOnBackground)).build() startForeground(PLAYER_NOTIFICATION_ID, notification) } } /** * Initializes the [player] with the [MediaItem]. */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { try { // clear the playing queue PlayingQueue.clear() // get the intent arguments videoId = intent?.getStringExtra(IntentData.videoId)!! playlistId = intent.getStringExtra(IntentData.playlistId) val position = intent.getLongExtra(IntentData.position, 0L) // initialize the playlist autoPlay Helper autoPlayHelper = AutoPlayHelper(playlistId) // play the audio in the background loadAudio(videoId, position) } catch (e: Exception) { onDestroy() } return super.onStartCommand(intent, flags, startId) } /** * Gets the video data and prepares the [player]. */ private fun loadAudio( videoId: String, seekToPosition: Long = 0 ) { // append the video to the playing queue PlayingQueue.add(videoId) CoroutineScope(Dispatchers.IO).launch { try { streams = RetrofitInstance.api.getStreams(videoId) } catch (e: Exception) { return@launch } handler.post { playAudio(seekToPosition) } } } private fun playAudio( seekToPosition: Long ) { PlayingQueue.updateCurrent(videoId) initializePlayer() setMediaItem() // create the notification if (!this@BackgroundMode::nowPlayingNotification.isInitialized) { nowPlayingNotification = NowPlayingNotification(this@BackgroundMode, player!!) } nowPlayingNotification.updatePlayerNotification(streams!!) player?.apply { playWhenReady = playWhenReadyPlayer prepare() } // seek to the previous position if available if (seekToPosition != 0L) player?.seekTo(seekToPosition) // set the playback speed val playbackSpeed = PreferenceHelper.getString( PreferenceKeys.BACKGROUND_PLAYBACK_SPEED, "1" ).toFloat() player?.setPlaybackSpeed(playbackSpeed) fetchSponsorBlockSegments() if (autoplay) setNextStream() } /** * create the player */ private fun initializePlayer() { if (player != null) return audioAttributes = AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build() player = ExoPlayer.Builder(this) .setHandleAudioBecomingNoisy(true) .setAudioAttributes(audioAttributes, true) .build() /** * Listens for changed playbackStates (e.g. pause, end) * Plays the next video when the current one ended */ player?.addListener(object : Player.Listener { override fun onPlaybackStateChanged(@Player.State state: Int) { when (state) { Player.STATE_ENDED -> { if (autoplay) playNextVideo() } Player.STATE_IDLE -> { onDestroy() } Player.STATE_BUFFERING -> {} Player.STATE_READY -> {} } } }) } /** * set the videoId of the next stream for autoplay */ private fun setNextStream() { if (streams!!.relatedStreams!!.isNotEmpty()) { nextStreamId = streams?.relatedStreams!![0].url!!.toID() } if (playlistId == null) return if (!this::autoPlayHelper.isInitialized) autoPlayHelper = AutoPlayHelper(playlistId!!) // search for the next videoId in the playlist CoroutineScope(Dispatchers.IO).launch { nextStreamId = autoPlayHelper.getNextVideoId(videoId, streams!!.relatedStreams!!) } } /** * Plays the first related video to the current (used when the playback of the current video ended) */ private fun playNextVideo() { if (nextStreamId == null || nextStreamId == videoId) return val nextQueueVideo = PlayingQueue.getNext() if (nextQueueVideo != null) nextStreamId = nextQueueVideo // play new video on background this.videoId = nextStreamId!! this.segmentData = null loadAudio(videoId) } /** * Sets the [MediaItem] with the [streams] into the [player] */ private fun setMediaItem() { streams?.let { val uri = if (streams!!.hls != null) { streams!!.hls } else if (streams!!.audioStreams!!.isNotEmpty()) { PlayerHelper.getAudioSource( this, streams!!.audioStreams!! ) } else { return } val mediaItem = MediaItem.Builder() .setUri(uri) .build() player?.setMediaItem(mediaItem) } } /** * fetch the segments for SponsorBlock */ private fun fetchSponsorBlockSegments() { CoroutineScope(Dispatchers.IO).launch { kotlin.runCatching { val categories = PlayerHelper.getSponsorBlockCategories() if (categories.size > 0) { segmentData = RetrofitInstance.api.getSegments( videoId, ObjectMapper().writeValueAsString(categories) ) checkForSegments() } } } } /** * check for SponsorBlock segments */ private fun checkForSegments() { Handler(Looper.getMainLooper()).postDelayed(this::checkForSegments, 100) if (segmentData == null || segmentData!!.segments.isEmpty()) return segmentData!!.segments.forEach { segment: com.github.libretube.api.obj.Segment -> val segmentStart = (segment.segment!![0] * 1000f).toLong() val segmentEnd = (segment.segment[1] * 1000f).toLong() val currentPosition = player?.currentPosition if (currentPosition in segmentStart until segmentEnd) { if (PreferenceHelper.getBoolean( "sb_notifications_key", true ) ) { try { Toast.makeText(this, R.string.segment_skipped, Toast.LENGTH_SHORT) .show() } catch (e: Exception) { // Do nothing. } } player?.seekTo(segmentEnd) } } } /** * destroy the [BackgroundMode] foreground service */ override fun onDestroy() { // clear the playing queue PlayingQueue.clear() // called when the user pressed stop in the notification // stop the service from being in the foreground and remove the notification if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) } else { @Suppress("DEPRECATION") stopForeground(true) } // destroy the service stopSelf() if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroy() super.onDestroy() } override fun onBind(p0: Intent?): IBinder? { return null } }