package com.github.libretube.services import android.content.Intent import android.os.Bundle import androidx.annotation.OptIn import androidx.core.os.bundleOf import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem.SubtitleConfiguration import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.FileDataSource import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import com.github.libretube.constants.IntentData import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.obj.DownloadWithItems import com.github.libretube.db.obj.filterByTab import com.github.libretube.enums.FileType import com.github.libretube.extensions.parcelable import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.extensions.toAndroidUri import com.github.libretube.extensions.toID import com.github.libretube.extensions.updateParameters import com.github.libretube.helpers.PlayerHelper import com.github.libretube.parcelable.PlayerData import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.activities.NoInternetActivity import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.fragments.DownloadsFragmentPage.Companion.sortDownloadList import com.github.libretube.util.PlayingQueue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlin.io.path.exists import kotlin.io.path.fileSize /** * A service to play downloaded audio in the background */ @OptIn(UnstableApi::class) open class OfflinePlayerService : AbstractPlayerService() { override val isOfflinePlayer: Boolean = true private var noInternetService: Boolean = false private var downloadWithItems: DownloadWithItems? = null private lateinit var playerData: PlayerData private var lastHistoryVideoId: String? = null private val scope = CoroutineScope(Dispatchers.Main) private val playerListener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_ENDED) { if (exoPlayer?.hasNextMediaItem() == true) return playNextVideo() } if (playbackState == Player.STATE_READY && PlayerHelper.watchHistoryEnabled) { val readyVideoId = videoId if (lastHistoryVideoId != readyVideoId) { lastHistoryVideoId = readyVideoId scope.launch(Dispatchers.IO) { val currentDownload = Database.downloadDao().findById(readyVideoId) ?: return@launch val watchHistoryItem = currentDownload.download .toStreamItem() .toWatchHistoryItem(readyVideoId) DatabaseHelper.addToWatchHistory(watchHistoryItem) } } } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { super.onMediaItemTransition(mediaItem, reason) val newVideoId = mediaItem?.mediaMetadata?.extras?.getString(IntentData.videoId) ?: return if (newVideoId == videoId) return videoId = newVideoId updatePlaylistMetadata { setExtras(bundleOf(IntentData.videoId to newVideoId)) } PlayingQueue.getStreams() .firstOrNull { it.url?.toID() == newVideoId } ?.let(PlayingQueue::updateCurrent) resetSponsorBlockSegments() scope.launch(Dispatchers.IO) { val currentDownload = Database.downloadDao().findById(newVideoId) ?: return@launch downloadWithItems = currentDownload val segments = currentDownload.downloadSponsorBlockSegments.map { it.toSegment() } withContext(Dispatchers.Main) { configureOfflineTracks(currentDownload) setSponsorBlockSegments(segments) addNextOfflineMediaItemIfAvailable() } } } } override suspend fun onServiceCreated(args: Bundle) { if (args.isEmpty) return playerData = args.parcelable(IntentData.playerData)!! noInternetService = args.getBoolean(IntentData.noInternet, false) isAudioOnlyPlayer = args.getBoolean(IntentData.audioOnly, false) PlayingQueue.clear() this.videoId = if (playerData.shuffle) { runBlocking(Dispatchers.IO) { if (playerData.downloadTab == DownloadTab.PLAYLIST) { Database.downloadDao() .getDownloadPlaylistById(playerData.playlistId!!).downloadVideos.randomOrNull() } else { Database.downloadDao().getAll().filterByTab(playerData.downloadTab!!) .randomOrNull()?.download } }?.videoId } else { playerData.videoId } ?: return exoPlayer?.addListener(playerListener) trackSelector?.updateParameters { setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioOnlyPlayer) } fillQueue() } override fun getIntentActivity(): Class<*> { return if (noInternetService) NoInternetActivity::class.java else MainActivity::class.java } /** * Attempt to start an audio player with the given download items */ override suspend fun startPlayback() { super.startPlayback() val downloadWithItems = withContext(Dispatchers.IO) { Database.downloadDao().findById(videoId) } ?: return this.downloadWithItems = downloadWithItems PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem()) withContext(Dispatchers.Main) { setSponsorBlockSegments( downloadWithItems.downloadSponsorBlockSegments.map { it.toSegment() } ) if (!setMediaItem(downloadWithItems)) { // Keep the UI/service alive and fail gracefully instead of tearing down the app // when an old/interrupted download has no complete playable file. toastFromMainThread(com.github.libretube.R.string.downloadfailed) return@withContext } addNextOfflineMediaItemIfAvailable() // automatically start playback when using the audio player exoPlayer?.playWhenReady = PlayerHelper.playAutomatically || isAudioOnlyPlayer exoPlayer?.prepare() // Disable restore from saved watch position to always start playback from the beginning. // if (watchPositionsEnabled) { // DatabaseHelper.getWatchPosition(videoId)?.let { // if (!DatabaseHelper.isVideoWatched( // it, // downloadWithItems.download.duration // ) // ) exoPlayer?.seekTo(it) // } // } } } private fun setMediaItem(downloadWithItems: DownloadWithItems): Boolean { configureOfflineTracks(downloadWithItems) val mediaSource = buildOfflineMediaSource(downloadWithItems) ?: return false exoPlayer?.setMediaSource(mediaSource) exoPlayer?.seekTo(0L) return true } private fun buildOfflineMediaSource(downloadWithItems: DownloadWithItems): MediaSource? { val downloadFiles = downloadWithItems.downloadItems.filter { item -> runCatching { item.path.exists() && item.path.fileSize() > 0L }.getOrDefault(false) } if (downloadFiles.isEmpty()) return null val videoUri = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.toAndroidUri() val audioUri = downloadFiles.firstOrNull { it.type == FileType.AUDIO }?.path?.toAndroidUri() val subtitleInfo = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE } val videoSource = videoUri?.let { uri -> val item = MediaItem.Builder() .setUri(uri) .setMetadata(downloadWithItems) .build() ProgressiveMediaSource.Factory(FileDataSource.Factory()).createMediaSource(item) } val audioSource = audioUri?.let { uri -> val item = MediaItem.Builder() .setUri(uri) .setMetadata(downloadWithItems) .build() ProgressiveMediaSource.Factory(FileDataSource.Factory()).createMediaSource(item) } val subtitleSource = subtitleInfo?.let { subtitle -> val config = SubtitleConfiguration.Builder(subtitle.path.toAndroidUri()) .setMimeType(MimeTypes.APPLICATION_TTML) .setLanguage(subtitle.language ?: "en") .build() SingleSampleMediaSource.Factory(FileDataSource.Factory()) .createMediaSource(config, C.TIME_UNSET) } if (isAudioOnlyPlayer && audioSource == null) return null var merged: MediaSource? = null listOfNotNull(videoSource, audioSource, subtitleSource).forEach { source -> merged = if (merged == null) source else MergingMediaSource(merged!!, source) } return merged } private fun configureOfflineTracks(downloadWithItems: DownloadWithItems) { val subtitleLanguage = downloadWithItems.downloadItems .firstOrNull { it.type == FileType.SUBTITLE && it.path.exists() } ?.language ?: "en" trackSelector?.updateParameters { setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) setPreferredTextLanguage(subtitleLanguage) setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioOnlyPlayer) } } private suspend fun fillQueue() { if (playerData.downloadTab == DownloadTab.PLAYLIST) { var videos = withContext(Dispatchers.IO) { Database.downloadDao().getDownloadPlaylistById(playerData.playlistId!!) }.downloadVideos if (playerData.shuffle) videos = listOf(videos.first { it.videoId == videoId }) + videos.filter { it.videoId != videoId }.shuffled() else if (playerData.downloadSortingOrder != null) videos = sortDownloadList(videos, playerData.downloadSortingOrder!!) PlayingQueue.setStreams(videos.map { it.toStreamItem() }) } else { var downloads = withContext(Dispatchers.IO) { Database.downloadDao().getAll() } .filterByTab(playerData.downloadTab!!) .map { it.download } if (playerData.shuffle) downloads = downloads.shuffled() else if (playerData.downloadSortingOrder != null) downloads = sortDownloadList(downloads, playerData.downloadSortingOrder!!) PlayingQueue.add(*downloads.map { it.toStreamItem() }.toTypedArray()) } } private fun addNextOfflineMediaItemIfAvailable() { val nextVideoId = PlayingQueue.getNext() ?: return if (nextVideoId == videoId || isOfflineMediaQueued(nextVideoId)) return scope.launch(Dispatchers.IO) { val nextDownloadWithItems = Database.downloadDao().findById(nextVideoId) ?: return@launch val nextMediaSource = buildOfflineMediaSource(nextDownloadWithItems) ?: return@launch withContext(Dispatchers.Main) { if (PlayingQueue.getNext() == nextVideoId && !isOfflineMediaQueued(nextVideoId)) { exoPlayer?.addMediaSource(nextMediaSource) } } } } private fun isOfflineMediaQueued(videoId: String): Boolean { val player = exoPlayer ?: return false for (index in 0 until player.mediaItemCount) { val itemVideoId = player.getMediaItemAt(index).mediaMetadata.extras ?.getString(IntentData.videoId) if (itemVideoId == videoId) return true } return false } private fun playNextVideo(videoId: String? = null) { if (!PlayerHelper.isAutoPlayEnabled() || !shouldHandleAutoplay) return val nextId = videoId ?: PlayingQueue.getNext() ?: return navigateVideo(nextId) } override fun onTaskRemoved(rootIntent: Intent?) { // Keep downloaded playback alive when the UI is dismissed. super.onTaskRemoved(rootIntent) } }