Merge pull request #5669 from Bnyro/master

feat: watch positions support for downloaded media
This commit is contained in:
Bnyro 2024-02-27 14:38:36 +01:00 committed by GitHub
commit 698c0ff865
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 123 additions and 35 deletions

View File

@ -36,11 +36,15 @@ import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.WatchPosition
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.SbSkipOptions
import com.github.libretube.extensions.updateParameters
import com.github.libretube.obj.VideoStats
import com.github.libretube.util.TextUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.Locale
import java.util.concurrent.Executors
@ -53,6 +57,7 @@ object PlayerHelper {
const val SPONSOR_HIGHLIGHT_CATEGORY = "poi_highlight"
const val ROLE_FLAG_AUTO_GEN_SUBTITLE = C.ROLE_FLAG_SUPPLEMENTARY
private const val MINIMUM_BUFFER_DURATION = 1000 * 10 // exo default is 50s
const val WATCH_POSITION_TIMER_DELAY_MS = 1000L
/**
* The maximum amount of time to wait until the video starts playing: 10 minutes
@ -606,7 +611,7 @@ object PlayerHelper {
}
}
fun getPosition(videoId: String, duration: Long?): Long? {
fun getStoredWatchPosition(videoId: String, duration: Long?): Long? {
if (duration == null) return null
runCatching {
@ -780,4 +785,15 @@ object PlayerHelper {
player.playbackState == Player.STATE_ENDED -> R.drawable.ic_restart
else -> R.drawable.ic_play
}
fun saveWatchPosition(player: ExoPlayer, videoId: String) {
if (player.duration == C.TIME_UNSET || player.currentPosition in listOf(0L, C.TIME_UNSET)) {
return
}
val watchPosition = WatchPosition(videoId, player.currentPosition)
CoroutineScope(Dispatchers.IO).launch {
DatabaseHolder.Database.watchPositionDao().insert(watchPosition)
}
}
}

View File

@ -1,7 +1,9 @@
package com.github.libretube.services
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
@ -29,17 +31,39 @@ import kotlin.io.path.exists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Timer
import java.util.TimerTask
/**
* A service to play downloaded audio in the background
*/
class OfflinePlayerService : LifecycleService() {
val handler = Handler(Looper.getMainLooper())
private var player: ExoPlayer? = null
private var nowPlayingNotification: NowPlayingNotification? = null
private lateinit var videoId: String
private var downloadsWithItems: List<DownloadWithItems> = emptyList()
private var watchPositionTimer: Timer? = null
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
// Start or pause watch position timer
if (isPlaying) {
watchPositionTimer = Timer()
watchPositionTimer!!.scheduleAtFixedRate(object : TimerTask() {
override fun run() {
handler.post(this@OfflinePlayerService::saveWatchPosition)
}
}, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS)
} else {
watchPositionTimer?.cancel()
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
@ -142,10 +166,24 @@ class OfflinePlayerService : LifecycleService() {
player?.playWhenReady = PlayerHelper.playAutomatically
player?.prepare()
if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let {
player?.seekTo(it)
}
}
return true
}
private fun saveWatchPosition() {
if (!PlayerHelper.watchPositionsVideo) return
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
}
override fun onDestroy() {
saveWatchPosition()
nowPlayingNotification?.destroySelf()
player?.stop()
@ -153,6 +191,8 @@ class OfflinePlayerService : LifecycleService() {
player = null
nowPlayingNotification = null
watchPositionTimer?.cancel()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()

View File

@ -28,8 +28,6 @@ import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams
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.WatchPosition
import com.github.libretube.enums.NotificationId
import com.github.libretube.extensions.parcelableExtra
import com.github.libretube.extensions.setMetadata
@ -42,11 +40,12 @@ import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.parcelable.PlayerData
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import java.util.Timer
import java.util.TimerTask
/**
* Loads the selected videos audio in background mode with a notification area.
@ -103,10 +102,24 @@ class OnlinePlayerService : LifecycleService() {
var onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null
var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null
private var watchPositionTimer: Timer? = null
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
onStateOrPlayingChanged?.invoke(isPlaying)
// Start or pause watch position timer
if (isPlaying) {
watchPositionTimer = Timer()
watchPositionTimer!!.scheduleAtFixedRate(object : TimerTask() {
override fun run() {
handler.post(this@OnlinePlayerService::saveWatchPosition)
}
}, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS)
} else {
watchPositionTimer?.cancel()
}
}
override fun onPlaybackStateChanged(state: Int) {
@ -180,25 +193,14 @@ class OnlinePlayerService : LifecycleService() {
PlayingQueue.setOnQueueTapListener { streamItem ->
streamItem.url?.toID()?.let { playNextVideo(it) }
}
if (PlayerHelper.watchPositionsAudio) {
updateWatchPosition()
}
}
return super.onStartCommand(intent, flags, startId)
}
private fun updateWatchPosition() {
player?.currentPosition?.let {
if (isTransitioning) return@let
private fun saveWatchPosition() {
if (isTransitioning || !PlayerHelper.watchPositionsAudio) return
val watchPosition = WatchPosition(videoId, it)
CoroutineScope(Dispatchers.IO).launch {
Database.watchPositionDao().insert(watchPosition)
}
}
handler.postDelayed(this::updateWatchPosition, 500)
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
}
/**
@ -248,7 +250,7 @@ class OnlinePlayerService : LifecycleService() {
if (seekToPosition != 0L) {
player?.seekTo(seekToPosition)
} else if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getPosition(videoId, streams?.duration)?.let {
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
player?.seekTo(it)
}
}
@ -392,6 +394,8 @@ class OnlinePlayerService : LifecycleService() {
player?.stop()
player?.release()
watchPositionTimer?.cancel()
// called when the user pressed stop in the notification
// stop the service from being in the foreground and remove the notification
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)

View File

@ -4,6 +4,8 @@ import android.content.pm.ActivityInfo
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.DateUtils
import android.view.KeyEvent
import androidx.activity.viewModels
@ -42,9 +44,13 @@ import kotlin.io.path.exists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Timer
import java.util.TimerTask
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class OfflinePlayerActivity : BaseActivity() {
private val handler = Handler(Looper.getMainLooper())
private lateinit var binding: ActivityOfflinePlayerBinding
private lateinit var videoId: String
private lateinit var player: ExoPlayer
@ -55,6 +61,8 @@ class OfflinePlayerActivity : BaseActivity() {
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
private val playerViewModel: PlayerViewModel by viewModels()
private var watchPositionTimer: Timer? = null
private val playerListener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
@ -64,6 +72,22 @@ class OfflinePlayerActivity : BaseActivity() {
)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
// Start or pause watch position timer
if (isPlaying) {
watchPositionTimer = Timer()
watchPositionTimer!!.scheduleAtFixedRate(object : TimerTask() {
override fun run() {
handler.post(this@OfflinePlayerActivity::saveWatchPosition)
}
}, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS)
} else {
watchPositionTimer?.cancel()
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
// setup seekbar preview
@ -154,6 +178,12 @@ class OfflinePlayerActivity : BaseActivity() {
player.playWhenReady = PlayerHelper.playAutomatically
player.prepare()
if (PlayerHelper.watchPositionsVideo) {
PlayerHelper.getStoredWatchPosition(videoId, downloadInfo.download.duration)?.let {
player.seekTo(it)
}
}
}
}
@ -205,6 +235,12 @@ class OfflinePlayerActivity : BaseActivity() {
}
}
private fun saveWatchPosition() {
if (!PlayerHelper.watchPositionsVideo) return
PlayerHelper.saveWatchPosition(player, videoId)
}
override fun onResume() {
playerViewModel.isFullscreen.value = true
super.onResume()
@ -216,7 +252,11 @@ class OfflinePlayerActivity : BaseActivity() {
}
override fun onDestroy() {
saveWatchPosition()
player.release()
watchPositionTimer?.cancel()
super.onDestroy()
}

View File

@ -62,8 +62,6 @@ import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentPlayerBinding
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.WatchPosition
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.formatShort
@ -114,7 +112,6 @@ import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.TextUtils
import com.github.libretube.util.TextUtils.toTimeInSeconds
import com.github.libretube.util.YoutubeHlsPlaylistParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -275,7 +272,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun run() {
handler.post(this@PlayerFragment::saveWatchPosition)
}
}, 1000, 1000)
}, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS, PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS)
} else {
watchPositionTimer?.cancel()
}
@ -861,17 +858,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// save the watch position if video isn't finished and option enabled
private fun saveWatchPosition() {
if (!this::exoPlayer.isInitialized || !PlayerHelper.watchPositionsVideo || isTransitioning ||
exoPlayer.duration == C.TIME_UNSET || exoPlayer.currentPosition in listOf(
0L,
C.TIME_UNSET
)
) {
return
}
val watchPosition = WatchPosition(videoId, exoPlayer.currentPosition)
CoroutineScope(Dispatchers.IO).launch {
Database.watchPositionDao().insert(watchPosition)
if (this::exoPlayer.isInitialized && !isTransitioning && PlayerHelper.watchPositionsVideo) {
PlayerHelper.saveWatchPosition(exoPlayer, videoId)
}
}
@ -1290,7 +1278,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
timeStamp = 0L
} else if (!streams.livestream) {
// seek to the saved watch position
PlayerHelper.getPosition(videoId, streams.duration)?.let {
PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let {
exoPlayer.seekTo(it)
}
}