diff --git a/new-player/src/main/java/net/newpipe/newplayer/MediaRepository.kt b/new-player/src/main/java/net/newpipe/newplayer/MediaRepository.kt index 1f8d714..5ce7c4c 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/MediaRepository.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/MediaRepository.kt @@ -1,20 +1,47 @@ +/* NewPlayer + * + * @author Christian Schabesberger + * + * Copyright (C) NewPipe e.V. 2024 + * + * 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 . + */ + 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 + suspend fun getAvailableSubtitleVariants(item: String): List - suspend fun getAvailableStreams(item: String): List + 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? + suspend fun getChapters(item: String): List + suspend fun getChapterThumbnail(item: String, chapter: Long) : Thumbnail - suspend fun getPreviewThumbnails(item: String) : List - suspend fun getChapters(item: String): List - suspend fun getChapterThumbnail(item: String, chapter: Long) : Bitmap + suspend fun getTimestampLink(item: String, timestampInSeconds: Long) + + suspend fun tryAndRescueError(item: String?, exception: PlaybackException) : Uri? } \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt b/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt index 02adb6c..249742b 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt @@ -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 + + 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 + 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 = emptyList() fun setMediaSourceFactory(mediaSourceFactory: MediaSource.Factory) { this.mediaSourceFactory = mediaSourceFactory } + fun setPreferredStreamVariants(preferredStreamVariants: List) { + 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 = ArrayList() - - override val duartion: Long - get() = internal_player.duration +class NewPlayerImpl( + val app: Application, + override val internalPlayer: Player, + override val preferredStreamVariants: List, + 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 = ArrayList() + + private var callbackListener: ArrayList = 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) } } \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/model/UIModeState.kt b/new-player/src/main/java/net/newpipe/newplayer/model/UIModeState.kt index 3b21797..af5b6d5 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/model/UIModeState.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/model/UIModeState.kt @@ -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() + } + } } \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt index c26bac9..3f8745c 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt @@ -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) { diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt index 5952125..5776b15 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt @@ -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 = {}, diff --git a/new-player/src/main/java/net/newpipe/newplayer/utils/Thumbnail.kt b/new-player/src/main/java/net/newpipe/newplayer/utils/Thumbnail.kt new file mode 100644 index 0000000..8814edd --- /dev/null +++ b/new-player/src/main/java/net/newpipe/newplayer/utils/Thumbnail.kt @@ -0,0 +1,32 @@ +/* NewPlayer + * + * @author Christian Schabesberger + * + * Copyright (C) NewPipe e.V. 2024 + * + * 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 . + */ + +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 \ No newline at end of file diff --git a/test-app/src/main/java/net/newpipe/newplayer/testapp/TestMediaRepository.kt b/test-app/src/main/java/net/newpipe/newplayer/testapp/TestMediaRepository.kt index 6290c48..9c3fa98 100644 --- a/test-app/src/main/java/net/newpipe/newplayer/testapp/TestMediaRepository.kt +++ b/test-app/src/main/java/net/newpipe/newplayer/testapp/TestMediaRepository.kt @@ -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 = + override suspend fun getAvailableStreamVariants(item: String): List = 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 { + override suspend fun getPreviewThumbnails(item: String): HashMap? { + 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() + + 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 { TODO("Not yet implemented") } - override suspend fun getChapters(item: String): List { - 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") } } \ No newline at end of file