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
import android.graphics.Bitmap
import android.net.Uri
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 {
suspend fun getTitle(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 getLinkWithStreamOffset(item: String) : String
suspend fun getPreviewThumbnails(item: String) : HashMap<Long, Thumbnail>?
suspend fun getChapters(item: String): List<Chapter>
suspend fun getChapterThumbnail(item: String, chapter: Long) : Thumbnail
suspend fun getPreviewThumbnails(item: String) : List<Bitmap>
suspend fun getChapters(item: String): List<Long>
suspend fun getChapterThumbnail(item: String, chapter: Long) : Bitmap
suspend fun getTimestampLink(item: String, timestampInSeconds: Long)
suspend fun tryAndRescueError(item: String?, exception: PlaybackException) : Uri?
}

View File

@ -23,114 +23,218 @@ package net.newpipe.newplayer
import android.app.Application
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
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 {
EMBEDDED_VIDEO,
FULLSCREEN_VIDEO,
PIP,
BACKGROND,
AUDIO_FORGROUND,
BACKGROUND,
AUDIO_FOREGROUND,
}
private val TAG = "NewPlayer"
interface NewPlayer {
val internal_player: Player
// preferences
val preferredStreamVariants: List<String>
val internalPlayer: Player
var playWhenReady: Boolean
val duartion: Long
val duration: Long
val bufferedPercentage: Int
val repository: MediaRepository
var currentPosition: Long
var fastSeekAmountSec: Int
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 play()
fun pause()
fun addToPlaylist(newItem: String)
fun addListener(callbackListener: Listener)
//TODO: This is only temporary
fun setStream(stream: MediaItem)
fun addToPlaylist(item: String)
fun playStream(item: String, playMode: PlayMode)
fun playStream(item: String, streamVariant: String, playMode: PlayMode)
fun addCallbackListener(listener: Listener?)
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) {
this.mediaSourceFactory = mediaSourceFactory
}
fun setPreferredStreamVariants(preferredStreamVariants: List<String>) {
this.preferredStreamVariants = preferredStreamVariants
}
fun build(): NewPlayer {
val exoPlayerBuilder = ExoPlayer.Builder(app)
mediaSourceFactory?.let {
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 {
private var callbackListeners: MutableList<NewPlayer.Listener> = ArrayList()
override val duartion: Long
get() = internal_player.duration
class NewPlayerImpl(
val app: Application,
override val internalPlayer: Player,
override val preferredStreamVariants: List<String>,
override val repository: MediaRepository,
) : NewPlayer {
override val bufferedPercentage: Int
get() = internal_player.bufferedPercentage
get() = internalPlayer.bufferedPercentage
override var currentPosition: Long
get() = internal_player.currentPosition
set(value) {internal_player.seekTo(value)}
get() = internalPlayer.currentPosition
set(value) {
internalPlayer.seekTo(value)
}
override var fastSeekAmountSec: Int = 10
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
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() {
internal_player.prepare()
internalPlayer.prepare()
}
override fun play() {
if(internal_player.currentMediaItem != null) {
internal_player.play()
if (internalPlayer.currentMediaItem != null) {
internalPlayer.play()
} else {
Log.i(TAG, "Tried to start playing but no media Item was cued")
}
}
override fun pause() {
internal_player.pause()
internalPlayer.pause()
}
override fun addToPlaylist(newItem: String) {
Log.d(TAG, "Not implemented add to playlist")
override fun addToPlaylist(item: String) {
launchJobAndCollectError {
val mediaItem = toMediaItem(item)
internalPlayer.addMediaItem(mediaItem)
}
}
override fun addListener(callbackListener: NewPlayer.Listener) {
callbackListeners.add(callbackListener)
override fun playStream(item: String, playMode: PlayMode) {
launchJobAndCollectError {
val mediaItem = toMediaItem(item)
internalPlayStream(mediaItem, playMode)
}
}
override fun setStream(stream: MediaItem) {
if (internal_player.playbackState == Player.STATE_IDLE) {
internal_player.prepare()
override fun playStream(item: String, streamVariant: String, playMode: PlayMode) {
launchJobAndCollectError {
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
import net.newpipe.newplayer.PlayMode
enum class UIModeState {
PLACEHOLDER,
@ -93,4 +95,16 @@ enum class UIModeState {
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 internalPlayer: Player?
get() = newPlayer?.internal_player
get() = newPlayer?.internalPlayer
override var minContentRatio: Float = 4F / 3F
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.getTimeStringFromMs
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
fun StreamSelectUI(
@ -125,7 +129,7 @@ private fun StreamSelectTopBar() {
private fun ChapterItem(
modifier: Modifier = Modifier,
id: Int,
thumbnailUrl: String?,
thumbnail: Thumbnail?,
chapterTitle: String,
chapterStartInMs: Long,
onClicked: (Int) -> Unit
@ -141,10 +145,27 @@ private fun ChapterItem(
)
.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(
model = thumbnailUrl,
contentDescription = stringResource(R.string.chapter)
model = thumbnail,
contentDescription = contentDescription
)
} else {
Image(
@ -171,7 +192,7 @@ private fun StreamItem(
id: Int,
title: String,
creator: String?,
thumbnailUrl: String?,
thumbnail: Thumbnail?,
lengthInMs: Long,
onDragStart: (Int) -> Unit,
onDragEnd: (Int) -> Unit,
@ -180,10 +201,27 @@ private fun StreamItem(
val locale = getLocale()!!
Row(modifier = modifier.clickable { onClicked(id) }) {
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(
model = thumbnailUrl,
contentDescription = stringResource(R.string.chapter)
model = thumbnail,
contentDescription = contentDescription
)
} else {
Image(
@ -209,10 +247,12 @@ private fun StreamItem(
}
}
Column(modifier = Modifier
.padding(8.dp)
.weight(1f)
.fillMaxSize()) {
Column(
modifier = Modifier
.padding(8.dp)
.weight(1f)
.fillMaxSize()
) {
Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold)
if (creator != null) {
Text(text = creator)
@ -223,15 +263,17 @@ private fun StreamItem(
.fillMaxHeight()
.aspectRatio(1f)
.pointerInteropFilter {
when(it.action) {
when (it.action) {
MotionEvent.ACTION_UP -> {
onDragEnd(id)
false
}
MotionEvent.ACTION_DOWN -> {
onDragStart(id)
false
}
else -> true
}
}) {
@ -254,7 +296,7 @@ fun ChapterItemPreview() {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
ChapterItem(
id = 0,
thumbnailUrl = null,
thumbnail = null,
modifier = Modifier.fillMaxSize(),
chapterTitle = "Chapter Title",
chapterStartInMs = (4 * 60 + 32) * 1000,
@ -274,7 +316,7 @@ fun StreamItemPreview() {
modifier = Modifier.fillMaxSize(),
title = "Video Title",
creator = "Video Creator",
thumbnailUrl = null,
thumbnail = null,
lengthInMs = 15 * 60 * 1000,
onDragStart = {},
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.newpipe.newplayer.Chapter
import net.newpipe.newplayer.MediaRepository
import net.newpipe.newplayer.utils.OnlineThumbnail
import net.newpipe.newplayer.utils.Thumbnail
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@ -41,33 +44,20 @@ class TestMediaRepository(val context: Context) : MediaRepository {
override suspend fun getThumbnail(item: String) =
when (item) {
"6502" -> withContext(Dispatchers.IO) {
val response =
get("https://static.media.ccc.de/media/congress/2010/27c3-4159-en-reverse_engineering_mos_6502_preview.jpg")
"6502" ->
OnlineThumbnail("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")
"portrait" -> withContext(Dispatchers.IO) {
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())
}
"imu" ->
OnlineThumbnail("https://static.media.ccc.de/media/congress/2019/10694-hd_preview.jpg")
else -> throw Exception("Unknown stream: $item")
}
override suspend fun getAvailableStreams(item: String): List<String> =
override suspend fun getAvailableStreamVariants(item: String): List<String> =
when (item) {
"6502" -> listOf("576p")
"portrait" -> listOf("720p")
@ -95,15 +85,38 @@ class TestMediaRepository(val context: Context) : MediaRepository {
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")
}
override suspend fun getChapters(item: String): List<Long> {
TODO("Not yet implemented")
}
override suspend fun getChapterThumbnail(item: String, chapter: Long): Bitmap {
override suspend fun getChapterThumbnail(item: String, chapter: Long): Thumbnail {
TODO("Not yet implemented")
}
}