push player further to playlist handling

This commit is contained in:
Christian Schabesberger 2024-08-21 14:43:23 +02:00
parent 3d0fdabcf4
commit a2e8f6c4ad
7 changed files with 322 additions and 90 deletions

View File

@ -1,20 +1,47 @@
/* NewPlayer
*
* @author Christian Schabesberger
*
* Copyright (C) NewPipe e.V. 2024 <code(at)newpipe-ev.de>
*
* NewPlayer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPlayer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPlayer. If not, see <http://www.gnu.org/licenses/>.
*/
package net.newpipe.newplayer package net.newpipe.newplayer
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import net.newpipe.newplayer.utils.Thumbnail
data class Chapter(val chapterStartInMs: Long, val chapterTitle: String?)
interface MediaRepository { interface MediaRepository {
suspend fun getTitle(item: String) : String suspend fun getTitle(item: String) : String
suspend fun getChannelName(item: String): String suspend fun getChannelName(item: String): String
suspend fun getThumbnail(item: String): Bitmap suspend fun getThumbnail(item: String): Thumbnail
suspend fun getAvailableStreamVariants(item: String): List<String>
suspend fun getAvailableSubtitleVariants(item: String): List<String>
suspend fun getAvailableStreams(item: String): List<String> suspend fun getStream(item: String, streamSelector: String) : Uri
suspend fun getSubtitle(item: String, )
suspend fun getStream(item: String, streamSelector: String) : MediaItem suspend fun getPreviewThumbnails(item: String) : HashMap<Long, Thumbnail>?
suspend fun getLinkWithStreamOffset(item: String) : String suspend fun getChapters(item: String): List<Chapter>
suspend fun getChapterThumbnail(item: String, chapter: Long) : Thumbnail
suspend fun getPreviewThumbnails(item: String) : List<Bitmap> suspend fun getTimestampLink(item: String, timestampInSeconds: Long)
suspend fun getChapters(item: String): List<Long>
suspend fun getChapterThumbnail(item: String, chapter: Long) : Bitmap suspend fun tryAndRescueError(item: String?, exception: PlaybackException) : Uri?
} }

View File

@ -23,114 +23,218 @@ package net.newpipe.newplayer
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import java.lang.Exception import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.Exception
enum class PlayMode { enum class PlayMode {
EMBEDDED_VIDEO, EMBEDDED_VIDEO,
FULLSCREEN_VIDEO, FULLSCREEN_VIDEO,
PIP, PIP,
BACKGROND, BACKGROUND,
AUDIO_FORGROUND, AUDIO_FOREGROUND,
} }
private val TAG = "NewPlayer" private val TAG = "NewPlayer"
interface NewPlayer { interface NewPlayer {
val internal_player: Player // preferences
val preferredStreamVariants: List<String>
val internalPlayer: Player
var playWhenReady: Boolean var playWhenReady: Boolean
val duartion: Long val duration: Long
val bufferedPercentage: Int val bufferedPercentage: Int
val repository: MediaRepository val repository: MediaRepository
var currentPosition: Long var currentPosition: Long
var fastSeekAmountSec: Int var fastSeekAmountSec: Int
var playBackMode: PlayMode var playBackMode: PlayMode
var playList: MutableList<String> var playMode: PlayMode?
// calbacks
interface Listener {
fun playModeChange(playMode: PlayMode) {}
fun onError(exception: Exception) {}
}
// methods
fun prepare() fun prepare()
fun play() fun play()
fun pause() fun pause()
fun addToPlaylist(newItem: String) fun addToPlaylist(item: String)
fun addListener(callbackListener: Listener) fun playStream(item: String, playMode: PlayMode)
fun playStream(item: String, streamVariant: String, playMode: PlayMode)
//TODO: This is only temporary fun addCallbackListener(listener: Listener?)
fun setStream(stream: MediaItem)
data class Builder(val app: Application, val repository: MediaRepository) { data class Builder(val app: Application, val repository: MediaRepository) {
private var mediaSourceFactory : MediaSource.Factory? = null private var mediaSourceFactory: MediaSource.Factory? = null
private var preferredStreamVariants: List<String> = emptyList()
fun setMediaSourceFactory(mediaSourceFactory: MediaSource.Factory) { fun setMediaSourceFactory(mediaSourceFactory: MediaSource.Factory) {
this.mediaSourceFactory = mediaSourceFactory this.mediaSourceFactory = mediaSourceFactory
} }
fun setPreferredStreamVariants(preferredStreamVariants: List<String>) {
this.preferredStreamVariants = preferredStreamVariants
}
fun build(): NewPlayer { fun build(): NewPlayer {
val exoPlayerBuilder = ExoPlayer.Builder(app) val exoPlayerBuilder = ExoPlayer.Builder(app)
mediaSourceFactory?.let { mediaSourceFactory?.let {
exoPlayerBuilder.setMediaSourceFactory(it) exoPlayerBuilder.setMediaSourceFactory(it)
} }
return NewPlayerImpl(exoPlayerBuilder.build(), repository = repository) return NewPlayerImpl(
app = app,
internalPlayer = exoPlayerBuilder.build(),
repository = repository,
preferredStreamVariants = preferredStreamVariants
)
} }
} }
interface Listener {
fun onError(exception: Exception)
}
} }
class NewPlayerImpl(override val internal_player: Player, override val repository: MediaRepository) : NewPlayer { class NewPlayerImpl(
val app: Application,
private var callbackListeners: MutableList<NewPlayer.Listener> = ArrayList() override val internalPlayer: Player,
override val preferredStreamVariants: List<String>,
override val duartion: Long override val repository: MediaRepository,
get() = internal_player.duration ) : NewPlayer {
override val bufferedPercentage: Int override val bufferedPercentage: Int
get() = internal_player.bufferedPercentage get() = internalPlayer.bufferedPercentage
override var currentPosition: Long override var currentPosition: Long
get() = internal_player.currentPosition get() = internalPlayer.currentPosition
set(value) {internal_player.seekTo(value)} set(value) {
internalPlayer.seekTo(value)
}
override var fastSeekAmountSec: Int = 10 override var fastSeekAmountSec: Int = 10
override var playBackMode: PlayMode = PlayMode.EMBEDDED_VIDEO override var playBackMode: PlayMode = PlayMode.EMBEDDED_VIDEO
override var playList: MutableList<String> = ArrayList<String>()
private var callbackListener: ArrayList<NewPlayer.Listener?> = ArrayList()
private var playerScope = CoroutineScope(Dispatchers.Default + Job())
override var playMode: PlayMode? = null
set(value) {
field = value
if (field != null) {
callbackListener.forEach { it?.playModeChange(field!!) }
}
}
override var playWhenReady: Boolean override var playWhenReady: Boolean
set(value) { set(value) {
internal_player.playWhenReady = value internalPlayer.playWhenReady = value
} }
get() = internal_player.playWhenReady get() = internalPlayer.playWhenReady
override val duration: Long
get() = internalPlayer.duration
init {
internalPlayer.addListener(object: Player.Listener {
override fun onPlayerError(error: PlaybackException) {
launchJobAndCollectError {
val item = internalPlayer.currentMediaItem?.mediaId
val newUri = repository.tryAndRescueError(item, exception = error)
if (newUri != null) {
TODO("Implement handing new uri on fixed error")
} else {
callbackListener.forEach {
it?.onError(error)
}
}
}
}
})
}
override fun prepare() { override fun prepare() {
internal_player.prepare() internalPlayer.prepare()
} }
override fun play() { override fun play() {
if(internal_player.currentMediaItem != null) { if (internalPlayer.currentMediaItem != null) {
internal_player.play() internalPlayer.play()
} else { } else {
Log.i(TAG, "Tried to start playing but no media Item was cued") Log.i(TAG, "Tried to start playing but no media Item was cued")
} }
} }
override fun pause() { override fun pause() {
internal_player.pause() internalPlayer.pause()
} }
override fun addToPlaylist(newItem: String) { override fun addToPlaylist(item: String) {
Log.d(TAG, "Not implemented add to playlist") launchJobAndCollectError {
val mediaItem = toMediaItem(item)
internalPlayer.addMediaItem(mediaItem)
}
} }
override fun addListener(callbackListener: NewPlayer.Listener) { override fun playStream(item: String, playMode: PlayMode) {
callbackListeners.add(callbackListener) launchJobAndCollectError {
val mediaItem = toMediaItem(item)
internalPlayStream(mediaItem, playMode)
}
} }
override fun setStream(stream: MediaItem) { override fun playStream(item: String, streamVariant: String, playMode: PlayMode) {
if (internal_player.playbackState == Player.STATE_IDLE) { launchJobAndCollectError {
internal_player.prepare() val stream = toMediaItem(item)
internalPlayStream(stream, playMode)
}
}
private fun internalPlayStream(mediaItem: MediaItem, playMode: PlayMode) {
if (internalPlayer.playbackState == Player.STATE_IDLE) {
internalPlayer.prepare()
}
this.playMode = playMode
}
private suspend fun toMediaItem(item: String, streamVariant: String): MediaItem {
val dataStream = repository.getStream(item, streamVariant)
val mediaItem = MediaItem.Builder().setMediaId(item).setUri(dataStream)
return mediaItem.build()
}
private suspend fun toMediaItem(item: String): MediaItem {
val availableStream = repository.getAvailableStreamVariants(item)
var selectedStream = availableStream[availableStream.size / 2]
for (preferredStream in preferredStreamVariants) {
if (preferredStream in availableStream) {
selectedStream = preferredStream
break;
}
} }
internal_player.setMediaItem(stream) return toMediaItem(item, selectedStream)
}
private fun launchJobAndCollectError(task: suspend () -> Unit) =
playerScope.launch {
try {
task()
} catch (e: Exception) {
callbackListener.forEach {
it?.onError(e)
}
}
}
override fun addCallbackListener(listener: NewPlayer.Listener?) {
callbackListener.add(listener)
} }
} }

View File

@ -1,5 +1,7 @@
package net.newpipe.newplayer.model package net.newpipe.newplayer.model
import net.newpipe.newplayer.PlayMode
enum class UIModeState { enum class UIModeState {
PLACEHOLDER, PLACEHOLDER,
@ -93,4 +95,16 @@ enum class UIModeState {
else -> this else -> this
} }
companion object {
fun fromPlayMode(playMode: PlayMode) =
when (playMode) {
PlayMode.EMBEDDED_VIDEO -> EMBEDDED_VIDEO
PlayMode.FULLSCREEN_VIDEO -> FULLSCREEN_VIDEO
PlayMode.PIP -> TODO()
PlayMode.BACKGROUND -> TODO()
PlayMode.AUDIO_FOREGROUND -> TODO()
}
}
} }

View File

@ -83,7 +83,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
override val uiState = mutableUiState.asStateFlow() override val uiState = mutableUiState.asStateFlow()
override val internalPlayer: Player? override val internalPlayer: Player?
get() = newPlayer?.internal_player get() = newPlayer?.internalPlayer
override var minContentRatio: Float = 4F / 3F override var minContentRatio: Float = 4F / 3F
set(value) { set(value) {

View File

@ -64,6 +64,10 @@ import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.getLocale import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs import net.newpipe.newplayer.utils.getTimeStringFromMs
import coil.compose.AsyncImage import coil.compose.AsyncImage
import net.newpipe.newplayer.utils.BitmapThumbnail
import net.newpipe.newplayer.utils.OnlineThumbnail
import net.newpipe.newplayer.utils.Thumbnail
import net.newpipe.newplayer.utils.VectorThumbnail
@Composable @Composable
fun StreamSelectUI( fun StreamSelectUI(
@ -125,7 +129,7 @@ private fun StreamSelectTopBar() {
private fun ChapterItem( private fun ChapterItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
id: Int, id: Int,
thumbnailUrl: String?, thumbnail: Thumbnail?,
chapterTitle: String, chapterTitle: String,
chapterStartInMs: Long, chapterStartInMs: Long,
onClicked: (Int) -> Unit onClicked: (Int) -> Unit
@ -141,10 +145,27 @@ private fun ChapterItem(
) )
.clickable { onClicked(id) } .clickable { onClicked(id) }
) { ) {
if (thumbnailUrl != null) { val contentDescription = stringResource(R.string.chapter)
if (thumbnail != null) {
when (thumbnail) {
is OnlineThumbnail -> AsyncImage(
model = thumbnail.url,
contentDescription =contentDescription
)
is BitmapThumbnail -> Image(
bitmap = thumbnail.img,
contentDescription = contentDescription
)
is VectorThumbnail -> Image(
imageVector = thumbnail.vec,
contentDescription = contentDescription
)
}
AsyncImage( AsyncImage(
model = thumbnailUrl, model = thumbnail,
contentDescription = stringResource(R.string.chapter) contentDescription = contentDescription
) )
} else { } else {
Image( Image(
@ -171,7 +192,7 @@ private fun StreamItem(
id: Int, id: Int,
title: String, title: String,
creator: String?, creator: String?,
thumbnailUrl: String?, thumbnail: Thumbnail?,
lengthInMs: Long, lengthInMs: Long,
onDragStart: (Int) -> Unit, onDragStart: (Int) -> Unit,
onDragEnd: (Int) -> Unit, onDragEnd: (Int) -> Unit,
@ -180,10 +201,27 @@ private fun StreamItem(
val locale = getLocale()!! val locale = getLocale()!!
Row(modifier = modifier.clickable { onClicked(id) }) { Row(modifier = modifier.clickable { onClicked(id) }) {
Box { Box {
if (thumbnailUrl != null) { val contentDescription = stringResource(R.string.chapter)
if (thumbnail != null) {
when (thumbnail) {
is OnlineThumbnail -> AsyncImage(
model = thumbnail.url,
contentDescription =contentDescription
)
is BitmapThumbnail -> Image(
bitmap = thumbnail.img,
contentDescription = contentDescription
)
is VectorThumbnail -> Image(
imageVector = thumbnail.vec,
contentDescription = contentDescription
)
}
AsyncImage( AsyncImage(
model = thumbnailUrl, model = thumbnail,
contentDescription = stringResource(R.string.chapter) contentDescription = contentDescription
) )
} else { } else {
Image( Image(
@ -209,10 +247,12 @@ private fun StreamItem(
} }
} }
Column(modifier = Modifier Column(
.padding(8.dp) modifier = Modifier
.weight(1f) .padding(8.dp)
.fillMaxSize()) { .weight(1f)
.fillMaxSize()
) {
Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold) Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold)
if (creator != null) { if (creator != null) {
Text(text = creator) Text(text = creator)
@ -223,15 +263,17 @@ private fun StreamItem(
.fillMaxHeight() .fillMaxHeight()
.aspectRatio(1f) .aspectRatio(1f)
.pointerInteropFilter { .pointerInteropFilter {
when(it.action) { when (it.action) {
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
onDragEnd(id) onDragEnd(id)
false false
} }
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
onDragStart(id) onDragStart(id)
false false
} }
else -> true else -> true
} }
}) { }) {
@ -254,7 +296,7 @@ fun ChapterItemPreview() {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) { Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
ChapterItem( ChapterItem(
id = 0, id = 0,
thumbnailUrl = null, thumbnail = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
chapterTitle = "Chapter Title", chapterTitle = "Chapter Title",
chapterStartInMs = (4 * 60 + 32) * 1000, chapterStartInMs = (4 * 60 + 32) * 1000,
@ -274,7 +316,7 @@ fun StreamItemPreview() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
title = "Video Title", title = "Video Title",
creator = "Video Creator", creator = "Video Creator",
thumbnailUrl = null, thumbnail = null,
lengthInMs = 15 * 60 * 1000, lengthInMs = 15 * 60 * 1000,
onDragStart = {}, onDragStart = {},
onDragEnd = {}, onDragEnd = {},

View File

@ -0,0 +1,32 @@
/* NewPlayer
*
* @author Christian Schabesberger
*
* Copyright (C) NewPipe e.V. 2024 <code(at)newpipe-ev.de>
*
* NewPlayer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPlayer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPlayer. If not, see <http://www.gnu.org/licenses/>.
*/
package net.newpipe.newplayer.utils
import android.graphics.Bitmap
import android.graphics.drawable.VectorDrawable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
interface Thumbnail
data class OnlineThumbnail(val url: String) : Thumbnail
data class BitmapThumbnail(val img: ImageBitmap) : Thumbnail
data class VectorThumbnail(val vec:ImageVector) : Thumbnail

View File

@ -8,7 +8,10 @@ import android.net.Uri
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.newpipe.newplayer.Chapter
import net.newpipe.newplayer.MediaRepository import net.newpipe.newplayer.MediaRepository
import net.newpipe.newplayer.utils.OnlineThumbnail
import net.newpipe.newplayer.utils.Thumbnail
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -41,33 +44,20 @@ class TestMediaRepository(val context: Context) : MediaRepository {
override suspend fun getThumbnail(item: String) = override suspend fun getThumbnail(item: String) =
when (item) { when (item) {
"6502" -> withContext(Dispatchers.IO) { "6502" ->
val response = OnlineThumbnail("https://static.media.ccc.de/media/congress/2010/27c3-4159-en-reverse_engineering_mos_6502_preview.jpg")
get("https://static.media.ccc.de/media/congress/2010/27c3-4159-en-reverse_engineering_mos_6502_preview.jpg")
BitmapFactory.decodeStream(response.body.byteStream()) "portrait" ->
} OnlineThumbnail("https://64.media.tumblr.com/13f7e4065b4c583573a9a3e40750ccf8/9e8cf97a92704864-4b/s540x810/d966c97f755384b46dbe6d5350d35d0e9d4128ad.jpg")
"imu" ->
"portrait" -> withContext(Dispatchers.IO) { OnlineThumbnail("https://static.media.ccc.de/media/congress/2019/10694-hd_preview.jpg")
val response =
get("https://64.media.tumblr.com/13f7e4065b4c583573a9a3e40750ccf8/9e8cf97a92704864-4b/s540x810/d966c97f755384b46dbe6d5350d35d0e9d4128ad.jpg")
BitmapFactory.decodeStream(response.body.byteStream())
}
"imu" -> withContext(Dispatchers.IO) {
val response =
get("https://static.media.ccc.de/media/congress/2019/10694-hd_preview.jpg")
BitmapFactory.decodeStream(response.body.byteStream())
}
else -> throw Exception("Unknown stream: $item") else -> throw Exception("Unknown stream: $item")
} }
override suspend fun getAvailableStreams(item: String): List<String> = override suspend fun getAvailableStreamVariants(item: String): List<String> =
when (item) { when (item) {
"6502" -> listOf("576p") "6502" -> listOf("576p")
"portrait" -> listOf("720p") "portrait" -> listOf("720p")
@ -95,15 +85,38 @@ class TestMediaRepository(val context: Context) : MediaRepository {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getPreviewThumbnails(item: String): List<Bitmap> { override suspend fun getPreviewThumbnails(item: String): HashMap<Long, Thumbnail>? {
val templateUrl = when (item) {
"6502" -> context.getString(R.string.ccc_6502_preview_thumbnails)
"imu" -> context.getString(R.string.ccc_imu_preview_thumbnails)
"portrait" -> null
else -> throw Exception("Unknown stream: $item")
}
if(templateUrl != null) {
val thumbCount = when(item) {
"6502" -> 312
"imu" -> 361
else -> throw Exception("Unknown stream: $item") }
var thumbMap = HashMap<Long, Thumbnail>()
for (i in 1..thumbCount) {
val timeStamp= (i-1) * 10 * 1000
thumbMap.put(timeStamp.toLong(), OnlineThumbnail(String.format(templateUrl, i)))
}
return thumbMap
} else {
return null
}
}
override suspend fun getChapters(item: String): List<Chapter> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getChapters(item: String): List<Long> { override suspend fun getChapterThumbnail(item: String, chapter: Long): Thumbnail {
TODO("Not yet implemented")
}
override suspend fun getChapterThumbnail(item: String, chapter: Long): Bitmap {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }