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.api.obj.Streams
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.WatchPosition
import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.SbSkipOptions import com.github.libretube.enums.SbSkipOptions
import com.github.libretube.extensions.updateParameters import com.github.libretube.extensions.updateParameters
import com.github.libretube.obj.VideoStats import com.github.libretube.obj.VideoStats
import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.util.Locale import java.util.Locale
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -53,6 +57,7 @@ object PlayerHelper {
const val SPONSOR_HIGHLIGHT_CATEGORY = "poi_highlight" const val SPONSOR_HIGHLIGHT_CATEGORY = "poi_highlight"
const val ROLE_FLAG_AUTO_GEN_SUBTITLE = C.ROLE_FLAG_SUPPLEMENTARY const val ROLE_FLAG_AUTO_GEN_SUBTITLE = C.ROLE_FLAG_SUPPLEMENTARY
private const val MINIMUM_BUFFER_DURATION = 1000 * 10 // exo default is 50s 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 * 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 if (duration == null) return null
runCatching { runCatching {
@ -780,4 +785,15 @@ object PlayerHelper {
player.playbackState == Player.STATE_ENDED -> R.drawable.ic_restart player.playbackState == Player.STATE_ENDED -> R.drawable.ic_restart
else -> R.drawable.ic_play 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 package com.github.libretube.services
import android.content.Intent import android.content.Intent
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
@ -29,17 +31,39 @@ import kotlin.io.path.exists
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.Timer
import java.util.TimerTask
/** /**
* A service to play downloaded audio in the background * A service to play downloaded audio in the background
*/ */
class OfflinePlayerService : LifecycleService() { class OfflinePlayerService : LifecycleService() {
val handler = Handler(Looper.getMainLooper())
private var player: ExoPlayer? = null private var player: ExoPlayer? = null
private var nowPlayingNotification: NowPlayingNotification? = null private var nowPlayingNotification: NowPlayingNotification? = null
private lateinit var videoId: String private lateinit var videoId: String
private var downloadsWithItems: List<DownloadWithItems> = emptyList() private var downloadsWithItems: List<DownloadWithItems> = emptyList()
private var watchPositionTimer: Timer? = null
private val playerListener = object : Player.Listener { 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) { override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState) super.onPlaybackStateChanged(playbackState)
@ -142,10 +166,24 @@ class OfflinePlayerService : LifecycleService() {
player?.playWhenReady = PlayerHelper.playAutomatically player?.playWhenReady = PlayerHelper.playAutomatically
player?.prepare() player?.prepare()
if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let {
player?.seekTo(it)
}
}
return true return true
} }
private fun saveWatchPosition() {
if (!PlayerHelper.watchPositionsVideo) return
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
}
override fun onDestroy() { override fun onDestroy() {
saveWatchPosition()
nowPlayingNotification?.destroySelf() nowPlayingNotification?.destroySelf()
player?.stop() player?.stop()
@ -153,6 +191,8 @@ class OfflinePlayerService : LifecycleService() {
player = null player = null
nowPlayingNotification = null nowPlayingNotification = null
watchPositionTimer?.cancel()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf() 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.api.obj.Streams
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHelper 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.enums.NotificationId
import com.github.libretube.extensions.parcelableExtra import com.github.libretube.extensions.parcelableExtra
import com.github.libretube.extensions.setMetadata 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.parcelable.PlayerData
import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import java.util.Timer
import java.util.TimerTask
/** /**
* Loads the selected videos audio in background mode with a notification area. * 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 onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null
var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null
private var watchPositionTimer: Timer? = null
private val playerListener = object : Player.Listener { private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
onStateOrPlayingChanged?.invoke(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) { override fun onPlaybackStateChanged(state: Int) {
@ -180,25 +193,14 @@ class OnlinePlayerService : LifecycleService() {
PlayingQueue.setOnQueueTapListener { streamItem -> PlayingQueue.setOnQueueTapListener { streamItem ->
streamItem.url?.toID()?.let { playNextVideo(it) } streamItem.url?.toID()?.let { playNextVideo(it) }
} }
if (PlayerHelper.watchPositionsAudio) {
updateWatchPosition()
}
} }
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
private fun updateWatchPosition() { private fun saveWatchPosition() {
player?.currentPosition?.let { if (isTransitioning || !PlayerHelper.watchPositionsAudio) return
if (isTransitioning) return@let
val watchPosition = WatchPosition(videoId, it) player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
CoroutineScope(Dispatchers.IO).launch {
Database.watchPositionDao().insert(watchPosition)
}
}
handler.postDelayed(this::updateWatchPosition, 500)
} }
/** /**
@ -248,7 +250,7 @@ class OnlinePlayerService : LifecycleService() {
if (seekToPosition != 0L) { if (seekToPosition != 0L) {
player?.seekTo(seekToPosition) player?.seekTo(seekToPosition)
} else if (PlayerHelper.watchPositionsAudio) { } else if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getPosition(videoId, streams?.duration)?.let { PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
player?.seekTo(it) player?.seekTo(it)
} }
} }
@ -392,6 +394,8 @@ class OnlinePlayerService : LifecycleService() {
player?.stop() player?.stop()
player?.release() player?.release()
watchPositionTimer?.cancel()
// called when the user pressed stop in the notification // called when the user pressed stop in the notification
// stop the service from being in the foreground and remove the notification // stop the service from being in the foreground and remove the notification
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) 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.media.session.PlaybackState
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.viewModels import androidx.activity.viewModels
@ -42,9 +44,13 @@ import kotlin.io.path.exists
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.Timer
import java.util.TimerTask
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class OfflinePlayerActivity : BaseActivity() { class OfflinePlayerActivity : BaseActivity() {
private val handler = Handler(Looper.getMainLooper())
private lateinit var binding: ActivityOfflinePlayerBinding private lateinit var binding: ActivityOfflinePlayerBinding
private lateinit var videoId: String private lateinit var videoId: String
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
@ -55,6 +61,8 @@ class OfflinePlayerActivity : BaseActivity() {
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
private val playerViewModel: PlayerViewModel by viewModels() private val playerViewModel: PlayerViewModel by viewModels()
private var watchPositionTimer: Timer? = null
private val playerListener = object : Player.Listener { private val playerListener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(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) { override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState) super.onPlaybackStateChanged(playbackState)
// setup seekbar preview // setup seekbar preview
@ -154,6 +178,12 @@ class OfflinePlayerActivity : BaseActivity() {
player.playWhenReady = PlayerHelper.playAutomatically player.playWhenReady = PlayerHelper.playAutomatically
player.prepare() 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() { override fun onResume() {
playerViewModel.isFullscreen.value = true playerViewModel.isFullscreen.value = true
super.onResume() super.onResume()
@ -216,7 +252,11 @@ class OfflinePlayerActivity : BaseActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
saveWatchPosition()
player.release() player.release()
watchPositionTimer?.cancel()
super.onDestroy() super.onDestroy()
} }

View File

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