remove playlist item and replace it with MediaItem and MediaMetadata

This commit is contained in:
Christian Schabesberger 2024-09-09 21:59:09 +02:00
parent 5710596972
commit a297a4c08f
13 changed files with 193 additions and 249 deletions

View File

@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="TestApp"> <SelectionState runConfigName="TestApp">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-09-02T13:31:45.216735120Z"> <DropdownSelection timestamp="2024-09-09T16:54:32.015849656Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=981f7af2" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=981f7af2" />

View File

@ -21,22 +21,15 @@
package net.newpipe.newplayer package net.newpipe.newplayer
import android.net.Uri import android.net.Uri
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import net.newpipe.newplayer.utils.Thumbnail import net.newpipe.newplayer.utils.Thumbnail
data class Chapter(val chapterStartInMs: Long, val chapterTitle: String?, val thumbnail: Uri?) data class Chapter(val chapterStartInMs: Long, val chapterTitle: String?, val thumbnail: Uri?)
data class MetaInfo(
val title: String,
val channelName: String,
val thumbnail: Uri?,
val lengthInS: Int
)
interface MediaRepository { interface MediaRepository {
suspend fun getMetaInfo(item: String): MetaInfo suspend fun getMetaInfo(item: String): MediaMetadata
suspend fun getAvailableStreamVariants(item: String): List<String> suspend fun getAvailableStreamVariants(item: String): List<String>
suspend fun getStream(item: String, streamSelector: String): Uri suspend fun getStream(item: String, streamSelector: String): Uri

View File

@ -21,29 +21,14 @@
package net.newpipe.newplayer package net.newpipe.newplayer
import android.app.Application import android.app.Application
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.common.Timeline
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.newpipe.newplayer.model.PlaylistItem
import net.newpipe.newplayer.model.fetchPlaylistItem
import net.newpipe.newplayer.model.getPlaylistItemsFromExoplayer
import kotlin.Exception import kotlin.Exception
import kotlin.random.Random
enum class PlayMode { enum class PlayMode {
IDLE, IDLE,
@ -74,8 +59,8 @@ interface NewPlayer {
var shuffle: Boolean var shuffle: Boolean
var repeatMode: RepeatMode var repeatMode: RepeatMode
val playlist: StateFlow<List<PlaylistItem>> val playlist: StateFlow<List<MediaItem>>
val currentlyPlaying: StateFlow<PlaylistItem?> val currentlyPlaying: StateFlow<MediaItem?>
var currentlyPlayingPlaylistItem: Int var currentlyPlayingPlaylistItem: Int
val currentChapters: StateFlow<List<Chapter>> val currentChapters: StateFlow<List<Chapter>>

View File

@ -43,9 +43,6 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.newpipe.newplayer.model.PlaylistItem
import net.newpipe.newplayer.model.fetchPlaylistItem
import net.newpipe.newplayer.model.getPlaylistItemsFromExoplayer
import net.newpipe.newplayer.service.NewPlayerService import net.newpipe.newplayer.service.NewPlayerService
import kotlin.random.Random import kotlin.random.Random
@ -116,12 +113,12 @@ class NewPlayerImpl(
override val duration: Long override val duration: Long
get() = internalPlayer.duration get() = internalPlayer.duration
private val mutablePlaylist = MutableStateFlow<List<PlaylistItem>>(emptyList()) private val mutablePlaylist = MutableStateFlow<List<MediaItem>>(emptyList())
override val playlist: StateFlow<List<PlaylistItem>> = override val playlist: StateFlow<List<MediaItem>> =
mutablePlaylist.asStateFlow() mutablePlaylist.asStateFlow()
private val mutableCurrentlyPlaying = MutableStateFlow<PlaylistItem?>(null) private val mutableCurrentlyPlaying = MutableStateFlow<MediaItem?>(null)
override val currentlyPlaying: StateFlow<PlaylistItem?> = mutableCurrentlyPlaying.asStateFlow() override val currentlyPlaying: StateFlow<MediaItem?> = mutableCurrentlyPlaying.asStateFlow()
private val mutableCurrentChapter = MutableStateFlow<List<Chapter>>(emptyList()) private val mutableCurrentChapter = MutableStateFlow<List<Chapter>>(emptyList())
override val currentChapters: StateFlow<List<Chapter>> = mutableCurrentChapter.asStateFlow() override val currentChapters: StateFlow<List<Chapter>> = mutableCurrentChapter.asStateFlow()
@ -157,29 +154,16 @@ class NewPlayerImpl(
} }
override fun onTimelineChanged(timeline: Timeline, reason: Int) { override fun onTimelineChanged(timeline: Timeline, reason: Int) {
super.onTimelineChanged(timeline, reason) mutablePlaylist.update {
updatePlaylistItems() (0..<internalPlayer.mediaItemCount).map {
internalPlayer.getMediaItemAt(it)
}
}
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason) super.onMediaItemTransition(mediaItem, reason)
mediaItem?.let { mutableCurrentlyPlaying.update { mediaItem }
val playlistItem = getPlaylistItem(mediaItem.mediaId.toLong())
if (playlistItem != null) {
mutableCurrentlyPlaying.update {
playlistItem
}
} else {
launchJobAndCollectError {
val item = fetchPlaylistItem(
uniqueId = mediaItem.mediaId.toLong(),
mediaRepo = repository,
idLookupTable = uniqueIdToIdLookup
)
mutableCurrentlyPlaying.update { item }
}
}
}
} }
}) })
@ -187,7 +171,8 @@ class NewPlayerImpl(
currentlyPlaying.collect { playing -> currentlyPlaying.collect { playing ->
playing?.let { playing?.let {
try { try {
val chapters = repository.getChapters(playing.id) val chapters =
repository.getChapters(uniqueIdToIdLookup[playing.mediaId.toLong()]!!)
mutableCurrentChapter.update { chapters } mutableCurrentChapter.update { chapters }
} catch (e: Exception) { } catch (e: Exception) {
mutableErrorFlow.emit(e) mutableErrorFlow.emit(e)
@ -197,35 +182,6 @@ class NewPlayerImpl(
} }
} }
private fun updatePlaylistItems() {
if (internalPlayer.mediaItemCount == 0) {
playBackMode.update {
PlayMode.IDLE
}
}
playerScope.launch {
val playlist =
getPlaylistItemsFromExoplayer(internalPlayer, repository, uniqueIdToIdLookup)
var playlistDuration = 0
for (item in playlist) {
playlistDuration += item.lengthInS
}
mutablePlaylist.update {
playlist
}
}
}
private fun getPlaylistItem(uniqueId: Long): PlaylistItem? {
for (item in playlist.value) {
if (item.uniqueId == uniqueId) {
return item
}
}
return null
}
override fun prepare() { override fun prepare() {
internalPlayer.prepare() internalPlayer.prepare()
if (mediaController == null) { if (mediaController == null) {
@ -309,10 +265,21 @@ class NewPlayerImpl(
private suspend fun toMediaItem(item: String, streamVariant: String): MediaItem { private suspend fun toMediaItem(item: String, streamVariant: String): MediaItem {
val dataStream = repository.getStream(item, streamVariant) val dataStream = repository.getStream(item, streamVariant)
val uniqueId = Random.nextLong() val uniqueId = Random.nextLong()
uniqueIdToIdLookup[uniqueId] = item uniqueIdToIdLookup[uniqueId] = item
val mediaItem = MediaItem.Builder().setMediaId(uniqueId.toString()).setUri(dataStream) val mediaItemBuilder = MediaItem.Builder()
return mediaItem.build() .setMediaId(uniqueId.toString())
.setUri(dataStream)
try {
val metadata = repository.getMetaInfo(item)
mediaItemBuilder.setMediaMetadata(metadata)
} catch (e: Exception) {
mutableErrorFlow.emit(e)
}
return mediaItemBuilder.build()
} }
private suspend fun toMediaItem(item: String): MediaItem { private suspend fun toMediaItem(item: String): MediaItem {

View File

@ -22,6 +22,7 @@
package net.newpipe.newplayer.model package net.newpipe.newplayer.model
import android.net.Uri import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -30,90 +31,3 @@ import net.newpipe.newplayer.NewPlayerException
import net.newpipe.newplayer.utils.Thumbnail import net.newpipe.newplayer.utils.Thumbnail
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
data class PlaylistItem(
val title: String,
val creator: String,
val id: String,
val uniqueId: Long,
val thumbnail: Uri?,
val lengthInS: Int
) {
companion object {
val DEFAULT = PlaylistItem(
title = "",
creator = "",
id = "",
uniqueId = -1L,
thumbnail = null,
lengthInS = 0
)
val DUMMY = PlaylistItem(
title = "Superawesome Video",
creator = "Yours truely",
id = "some_id",
uniqueId = 12345L,
thumbnail = null,
lengthInS = 420
)
}
}
suspend fun fetchPlaylistItem(
uniqueId: Long,
mediaRepo: MediaRepository,
idLookupTable: HashMap<Long, String>
) : PlaylistItem {
val id = idLookupTable[uniqueId]
?: throw NewPlayerException("Unknown uniqueId: $uniqueId, uniqueId Id mapping error. Something went wrong during datafetching.")
val metaInfo = mediaRepo.getMetaInfo(id)
return PlaylistItem(
title = metaInfo.title,
creator = metaInfo.channelName,
id = id,
thumbnail = metaInfo.thumbnail,
lengthInS = metaInfo.lengthInS,
uniqueId = uniqueId
)
}
suspend fun getPlaylistItemsFromExoplayer(
player: Player,
mediaRepo: MediaRepository,
idLookupTable: HashMap<Long, String>
) =
with(CoroutineScope(coroutineContext)) {
(0..<player.mediaItemCount).map { index ->
val mediaItem = player.getMediaItemAt(index)
val uniqueId = mediaItem.mediaId.toLong()
val id = idLookupTable.get(uniqueId)
?: throw NewPlayerException("Unknown uniqueId: $uniqueId, uniqueId Id mapping error. Something went wrong during datafetching.")
Pair(uniqueId, id)
}.map { item ->
Pair(item, async {
mediaRepo.getMetaInfo(item.second)
})
}.map {
val uniqueId = it.first.first
val id = it.first.second
val metaInfo = it.second.await()
PlaylistItem(
title = metaInfo.title,
creator = metaInfo.channelName,
id = id,
thumbnail = metaInfo.thumbnail,
lengthInS = metaInfo.lengthInS,
uniqueId = uniqueId
)
}
}
fun getPlaylistDurationInS(items: List<PlaylistItem>): Int {
var duration = 0
for (item in items) {
duration += item.lengthInS
}
return duration
}

View File

@ -20,10 +20,38 @@
package net.newpipe.newplayer.model package net.newpipe.newplayer.model
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import net.newpipe.newplayer.Chapter import net.newpipe.newplayer.Chapter
import net.newpipe.newplayer.RepeatMode import net.newpipe.newplayer.RepeatMode
import net.newpipe.newplayer.ui.ContentScale import net.newpipe.newplayer.ui.ContentScale
data class GurkenItem(
val title: String,
val creator: String,
val id: String,
val uniqueId: Long,
val thumbnail: Uri?,
val lengthInS: Int
) {
companion object {
val DEFAULT = GurkenItem(
title = "",
creator = "",
id = "",
uniqueId = -1L,
thumbnail = null,
lengthInS = 0
)
}
}
@UnstableApi
data class VideoPlayerUIState( data class VideoPlayerUIState(
val uiMode: UIModeState, val uiMode: UIModeState,
val playing: Boolean, val playing: Boolean,
@ -40,12 +68,12 @@ data class VideoPlayerUIState(
val soundVolume: Float, val soundVolume: Float,
val brightness: Float?, // when null use system value val brightness: Float?, // when null use system value
val embeddedUiConfig: EmbeddedUiConfig?, val embeddedUiConfig: EmbeddedUiConfig?,
val playList: List<PlaylistItem>, val playList: List<MediaItem>,
val chapters: List<Chapter>, val chapters: List<Chapter>,
val shuffleEnabled: Boolean, val shuffleEnabled: Boolean,
val repeatMode: RepeatMode, val repeatMode: RepeatMode,
val playListDurationInS: Int, val playListDurationInS: Int,
val currentlyPlaying: PlaylistItem, val currentlyPlaying: MediaItem?,
val currentPlaylistItemIndex: Int val currentPlaylistItemIndex: Int
) { ) {
companion object { companion object {
@ -70,7 +98,7 @@ data class VideoPlayerUIState(
shuffleEnabled = false, shuffleEnabled = false,
repeatMode = RepeatMode.DONT_REPEAT, repeatMode = RepeatMode.DONT_REPEAT,
playListDurationInS = 0, playListDurationInS = 0,
currentlyPlaying = PlaylistItem.DEFAULT, currentlyPlaying = null,
currentPlaylistItemIndex = 0 currentPlaylistItemIndex = 0
) )
@ -88,7 +116,16 @@ data class VideoPlayerUIState(
brightness = 0.2f, brightness = 0.2f,
shuffleEnabled = true, shuffleEnabled = true,
playListDurationInS = 5493, playListDurationInS = 5493,
currentlyPlaying = PlaylistItem.DUMMY, currentlyPlaying = MediaItem.Builder()
.setUri("https://ftp.fau.de/cdn.media.ccc.de/congress/2010/mp4-h264-HQ/27c3-4159-en-reverse_engineering_mos_6502.mp4")
.setMediaId("0")
.setMediaMetadata(MediaMetadata.Builder()
.setTitle("Superawesome Video")
.setArtist("Yours truely")
.setArtworkUri(null)
.setDurationMs(4201000L)
.build())
.build(),
currentPlaylistItemIndex = 1, currentPlaylistItemIndex = 1,
chapters = arrayListOf( chapters = arrayListOf(
Chapter( Chapter(
@ -108,30 +145,36 @@ data class VideoPlayerUIState(
), ),
), ),
playList = arrayListOf( playList = arrayListOf(
PlaylistItem( MediaItem.Builder()
id = "6502", .setUri("https://ftp.fau.de/cdn.media.ccc.de/congress/2010/mp4-h264-HQ/27c3-4159-en-reverse_engineering_mos_6502.mp4")
title = "Stream 1", .setMediaId("0")
creator = "The Creator", .setMediaMetadata(MediaMetadata.Builder()
lengthInS = 6 * 60 + 5, .setTitle("Stream 1")
thumbnail = null, .setArtist("Yours truely")
uniqueId = 0 .setArtworkUri(null)
), .setDurationMs(4201000L)
PlaylistItem( .build())
id = "6502", .build(),
title = "Stream 2", MediaItem.Builder()
creator = "The Creator 2", .setUri("https://ftp.fau.de/cdn.media.ccc.de/congress/2010/mp4-h264-HQ/27c3-4159-en-reverse_engineering_mos_6502.mp4")
lengthInS = 2 * 60 + 5, .setMediaId("1")
thumbnail = null, .setMediaMetadata(MediaMetadata.Builder()
uniqueId = 1 .setTitle("Stream 2")
), .setArtist("Yours truely")
PlaylistItem( .setArtworkUri(null)
id = "6502", .setDurationMs(3201000L)
title = "Stream 3", .build())
creator = "The Creator 3", .build(),
lengthInS = 29 * 60 + 5, MediaItem.Builder()
thumbnail = null, .setUri("https://ftp.fau.de/cdn.media.ccc.de/congress/2010/mp4-h264-HQ/27c3-4159-en-reverse_engineering_mos_6502.mp4")
uniqueId = 2 .setMediaId("2")
) .setMediaMetadata(MediaMetadata.Builder()
.setTitle("Stream 3")
.setArtist("Yours truely")
.setArtworkUri(null)
.setDurationMs(2201000L)
.build())
.build(),
) )
) )
} }

View File

@ -25,12 +25,14 @@ import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat.getSystemService import androidx.core.content.ContextCompat.getSystemService
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -44,6 +46,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.newpipe.newplayer.utils.VideoSize import net.newpipe.newplayer.utils.VideoSize
import net.newpipe.newplayer.NewPlayer import net.newpipe.newplayer.NewPlayer
import net.newpipe.newplayer.NewPlayerException
import net.newpipe.newplayer.RepeatMode import net.newpipe.newplayer.RepeatMode
import net.newpipe.newplayer.ui.ContentScale import net.newpipe.newplayer.ui.ContentScale
import java.util.LinkedList import java.util.LinkedList
@ -209,7 +212,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
newPlayer.currentlyPlaying.collect { playlistItem -> newPlayer.currentlyPlaying.collect { playlistItem ->
mutableUiState.update { mutableUiState.update {
it.copy( it.copy(
currentlyPlaying = playlistItem ?: PlaylistItem.DEFAULT, currentlyPlaying = playlistItem,
currentPlaylistItemIndex = newPlayer.currentlyPlayingPlaylistItem currentPlaylistItemIndex = newPlayer.currentlyPlayingPlaylistItem
) )
} }
@ -243,6 +246,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
Log.d(TAG, "viewmodel cleared") Log.d(TAG, "viewmodel cleared")
} }
@OptIn(UnstableApi::class)
@RequiresApi(Build.VERSION_CODES.TIRAMISU) @RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun initUIState(instanceState: Bundle) { override fun initUIState(instanceState: Bundle) {
@ -341,18 +345,20 @@ class VideoPlayerViewModelImpl @Inject constructor(
} }
} }
@OptIn(UnstableApi::class)
private fun updateProgressInPlaylistOnce() { private fun updateProgressInPlaylistOnce() {
var progress = 0 var progress = 0L
val currentlyPlaying = uiState.value.currentlyPlaying.uniqueId val currentlyPlaying = uiState.value.currentlyPlaying?.mediaId?.toLong() ?: 0L
for (item in uiState.value.playList) { for (item in uiState.value.playList) {
if (item.uniqueId == currentlyPlaying) if (item.mediaId.toLong() == currentlyPlaying)
break; break;
progress += item.lengthInS progress += item.mediaMetadata.durationMs
?: throw NewPlayerException("Media Item not containing duration. Media Item in question: ${item.mediaMetadata.title}")
} }
progress += ((newPlayer?.currentPosition ?: 0) / 1000).toInt() progress += ((newPlayer?.currentPosition ?: 0) / 1000)
mutableUiState.update { mutableUiState.update {
it.copy( it.copy(
playbackPositionInPlaylistS = progress playbackPositionInPlaylistS = progress.toInt()
) )
} }
} }

View File

@ -20,6 +20,7 @@
package net.newpipe.newplayer.ui.videoplayer package net.newpipe.newplayer.ui.videoplayer
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -37,11 +38,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import net.newpipe.newplayer.model.VideoPlayerUIState import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel import net.newpipe.newplayer.model.VideoPlayerViewModel
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy import net.newpipe.newplayer.model.VideoPlayerViewModelDummy
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.model.PlaylistItem
import net.newpipe.newplayer.ui.STREAMSELECT_UI_BACKGROUND_COLOR import net.newpipe.newplayer.ui.STREAMSELECT_UI_BACKGROUND_COLOR
import net.newpipe.newplayer.ui.videoplayer.streamselect.ChapterItem import net.newpipe.newplayer.ui.videoplayer.streamselect.ChapterItem
import net.newpipe.newplayer.ui.videoplayer.streamselect.ChapterSelectTopBar import net.newpipe.newplayer.ui.videoplayer.streamselect.ChapterSelectTopBar
@ -56,6 +57,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
val ITEM_CORNER_SHAPE = RoundedCornerShape(10.dp) val ITEM_CORNER_SHAPE = RoundedCornerShape(10.dp)
@OptIn(UnstableApi::class)
@Composable @Composable
fun StreamSelectUI( fun StreamSelectUI(
isChapterSelect: Boolean = false, isChapterSelect: Boolean = false,
@ -122,6 +124,7 @@ fun StreamSelectUI(
} }
} }
@OptIn(UnstableApi::class)
@Composable @Composable
fun ReorderableStreamItemsList( fun ReorderableStreamItemsList(
padding: PaddingValues, padding: PaddingValues,
@ -144,10 +147,10 @@ fun ReorderableStreamItemsList(
verticalArrangement = Arrangement.spacedBy(5.dp), verticalArrangement = Arrangement.spacedBy(5.dp),
state = lazyListState state = lazyListState
) { ) {
itemsIndexed(uiState.playList, key = { _, item -> item.uniqueId }) { index, playlistItem -> itemsIndexed(uiState.playList, key = { _, item -> item.mediaId.toLong() }) { index, playlistItem ->
ReorderableItem( ReorderableItem(
state = reorderableLazyListState, state = reorderableLazyListState,
key = playlistItem.uniqueId key = playlistItem.mediaId.toLong()
) { isDragging -> ) { isDragging ->
StreamItem( StreamItem(
playlistItem = playlistItem, playlistItem = playlistItem,
@ -156,9 +159,9 @@ fun ReorderableStreamItemsList(
haptic = haptic, haptic = haptic,
onDragFinished = viewModel::onStreamItemDragFinished, onDragFinished = viewModel::onStreamItemDragFinished,
isDragging = isDragging, isDragging = isDragging,
isCurrentlyPlaying = playlistItem.uniqueId == uiState.currentlyPlaying.uniqueId, isCurrentlyPlaying = playlistItem.mediaId.toLong() == uiState.currentlyPlaying?.mediaId?.toLong(),
onDelete = { onDelete = {
viewModel.removePlaylistItem(playlistItem.uniqueId) viewModel.removePlaylistItem(playlistItem.mediaId.toLong())
} }
) )
} }
@ -180,6 +183,7 @@ fun VideoPlayerChannelSelectUIPreview() {
} }
} }
@OptIn(UnstableApi::class)
@Preview(device = "id:pixel_5") @Preview(device = "id:pixel_5")
@Composable @Composable
fun VideoPlayerStreamSelectUIPreview() { fun VideoPlayerStreamSelectUIPreview() {
@ -188,9 +192,7 @@ fun VideoPlayerStreamSelectUIPreview() {
StreamSelectUI( StreamSelectUI(
isChapterSelect = false, isChapterSelect = false,
viewModel = VideoPlayerViewModelDummy(), viewModel = VideoPlayerViewModelDummy(),
uiState = VideoPlayerUIState.DUMMY.copy( uiState = VideoPlayerUIState.DUMMY
currentlyPlaying = PlaylistItem.DUMMY.copy(uniqueId = 1)
)
) )
} }
} }

View File

@ -21,6 +21,7 @@
package net.newpipe.newplayer.ui.videoplayer package net.newpipe.newplayer.ui.videoplayer
import android.app.Activity import android.app.Activity
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -47,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.media3.common.util.UnstableApi
import net.newpipe.newplayer.R import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.VideoPlayerUIState import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel import net.newpipe.newplayer.model.VideoPlayerViewModel
@ -67,14 +69,14 @@ fun TopUI(
) { ) {
Column(horizontalAlignment = Alignment.Start, modifier = Modifier.weight(1F)) { Column(horizontalAlignment = Alignment.Start, modifier = Modifier.weight(1F)) {
Text( Text(
uiState.currentlyPlaying.title, uiState.currentlyPlaying?.mediaMetadata?.title.toString() ?: "",
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
uiState.currentlyPlaying.creator, uiState.currentlyPlaying?.mediaMetadata?.artist.toString() ?: "",
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@ -131,6 +133,7 @@ fun TopUI(
// Preview // Preview
/////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////
@OptIn(UnstableApi::class)
@Preview(device = "spec:width=1080px,height=600px,dpi=440,orientation=landscape") @Preview(device = "spec:width=1080px,height=600px,dpi=440,orientation=landscape")
@Composable @Composable
fun VideoPlayerControllerTopUIPreview() { fun VideoPlayerControllerTopUIPreview() {

View File

@ -59,8 +59,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import net.newpipe.newplayer.R import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.PlaylistItem import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.ui.CONTROLLER_UI_BACKGROUND_COLOR import net.newpipe.newplayer.ui.CONTROLLER_UI_BACKGROUND_COLOR
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.ui.videoplayer.ITEM_CORNER_SHAPE import net.newpipe.newplayer.ui.videoplayer.ITEM_CORNER_SHAPE
@ -71,11 +73,12 @@ import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs import net.newpipe.newplayer.utils.getTimeStringFromMs
import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableCollectionItemScope
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun StreamItem( fun StreamItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playlistItem: PlaylistItem, playlistItem: MediaItem,
onClicked: (Long) -> Unit, onClicked: (Long) -> Unit,
onDragFinished: () -> Unit, onDragFinished: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
@ -113,7 +116,7 @@ fun StreamItem(
.fillMaxSize() .fillMaxSize()
.clip(ITEM_CORNER_SHAPE) .clip(ITEM_CORNER_SHAPE)
.clickable { .clickable {
onClicked(playlistItem.uniqueId) onClicked(playlistItem.mediaId.toLong())
}) { }) {
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
@ -152,7 +155,7 @@ fun StreamItem(
val contentDescription = stringResource(R.string.stream_item_thumbnail) val contentDescription = stringResource(R.string.stream_item_thumbnail)
Thumbnail( Thumbnail(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
thumbnail = playlistItem.thumbnail, thumbnail = playlistItem.mediaMetadata.artworkUri,
contentDescription = contentDescription, contentDescription = contentDescription,
shape = ITEM_CORNER_SHAPE shape = ITEM_CORNER_SHAPE
) )
@ -172,7 +175,7 @@ fun StreamItem(
bottom = 0.5.dp bottom = 0.5.dp
), ),
text = getTimeStringFromMs( text = getTimeStringFromMs(
playlistItem.lengthInS * 1000L, playlistItem.mediaMetadata.durationMs ?: 0L,
locale, locale,
leadingZerosForMinutes = false leadingZerosForMinutes = false
), ),
@ -189,14 +192,14 @@ fun StreamItem(
.fillMaxWidth() .fillMaxWidth()
) { ) {
Text( Text(
text = playlistItem.title, text = playlistItem.mediaMetadata.title.toString(),
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = playlistItem.creator, text = playlistItem.mediaMetadata.artist.toString(),
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
maxLines = 1, maxLines = 1,
@ -238,6 +241,7 @@ fun StreamItem(
} }
@androidx.annotation.OptIn(UnstableApi::class)
@Preview(device = "spec:width=1080px,height=400px,dpi=440,orientation=landscape") @Preview(device = "spec:width=1080px,height=400px,dpi=440,orientation=landscape")
@Composable @Composable
fun StreamItemPreview() { fun StreamItemPreview() {
@ -245,7 +249,7 @@ fun StreamItemPreview() {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) { Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
StreamItem( StreamItem(
playlistItem = PlaylistItem.DUMMY, playlistItem = VideoPlayerUIState.DUMMY.currentlyPlaying!!,
onClicked = {}, onClicked = {},
reorderableScope = null, reorderableScope = null,
haptic = null, haptic = null,

View File

@ -42,16 +42,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.media3.common.util.UnstableApi
import net.newpipe.newplayer.R import net.newpipe.newplayer.R
import net.newpipe.newplayer.RepeatMode import net.newpipe.newplayer.RepeatMode
import net.newpipe.newplayer.model.VideoPlayerUIState import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel import net.newpipe.newplayer.model.VideoPlayerViewModel
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy import net.newpipe.newplayer.model.VideoPlayerViewModelDummy
import net.newpipe.newplayer.model.getPlaylistDurationInS
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.getLocale import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getPlaylistDurationInMS
import net.newpipe.newplayer.utils.getTimeStringFromMs import net.newpipe.newplayer.utils.getTimeStringFromMs
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun StreamSelectTopBar( fun StreamSelectTopBar(
@ -64,7 +66,7 @@ fun StreamSelectTopBar(
colors = topAppBarColors(containerColor = Color.Transparent), colors = topAppBarColors(containerColor = Color.Transparent),
title = { title = {
val locale = getLocale()!! val locale = getLocale()!!
val duration = getPlaylistDurationInS(uiState.playList).toLong() * 1000 val duration = getPlaylistDurationInMS(uiState.playList)
val durationString = getTimeStringFromMs(timeSpanInMs = duration, locale) val durationString = getTimeStringFromMs(timeSpanInMs = duration, locale)
val playbackPositionString = getTimeStringFromMs( val playbackPositionString = getTimeStringFromMs(
timeSpanInMs = uiState.playbackPositionInPlaylistS.toLong() * 1000, locale = locale timeSpanInMs = uiState.playbackPositionInPlaylistS.toLong() * 1000, locale = locale
@ -133,6 +135,7 @@ fun StreamSelectTopBar(
} }
@androidx.annotation.OptIn(UnstableApi::class)
@Preview(device = "spec:width=1080px,height=150px,dpi=440,orientation=landscape") @Preview(device = "spec:width=1080px,height=150px,dpi=440,orientation=landscape")
@Composable @Composable
fun StreamSelectTopBarPreview() { fun StreamSelectTopBarPreview() {

View File

@ -27,6 +27,8 @@ import android.content.ContextWrapper
import android.graphics.drawable.shapes.Shape import android.graphics.drawable.shapes.Shape
import android.net.Uri import android.net.Uri
import android.view.WindowManager import android.view.WindowManager
import androidx.annotation.OptIn
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.displayCutout
@ -47,7 +49,10 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import net.newpipe.newplayer.NewPlayerException
import net.newpipe.newplayer.R import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.EmbeddedUiConfig import net.newpipe.newplayer.model.EmbeddedUiConfig
import java.util.Locale import java.util.Locale
@ -178,3 +183,14 @@ fun Thumbnail(
) )
} }
} }
@OptIn(UnstableApi::class)
fun getPlaylistDurationInMS(playlist: List<MediaItem>) : Long {
var duration = 0L
for(item in playlist) {
val itemDuration = item.mediaMetadata.durationMs ?:
throw NewPlayerException("Can not calculate duration of a playlist if an item does not have a duration: MediItem in question: ${item.mediaMetadata.title}")
duration += itemDuration
}
return duration
}

View File

@ -2,11 +2,12 @@ package net.newpipe.newplayer.testapp
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.util.UnstableApi
import net.newpipe.newplayer.Chapter import net.newpipe.newplayer.Chapter
import net.newpipe.newplayer.MediaRepository import net.newpipe.newplayer.MediaRepository
import net.newpipe.newplayer.MetaInfo
import net.newpipe.newplayer.utils.Thumbnail
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -21,28 +22,35 @@ class TestMediaRepository(val context: Context) : MediaRepository {
return client.newCall(request).execute() return client.newCall(request).execute()
} }
override suspend fun getMetaInfo(item: String) = @OptIn(UnstableApi::class)
override suspend fun getMetaInfo(item: String): MediaMetadata =
when (item) { when (item) {
"6502" -> MetaInfo( "6502" -> MediaMetadata.Builder()
title = context.getString(R.string.ccc_6502_title), .setTitle(context.getString(R.string.ccc_6502_title))
channelName = context.getString(R.string.ccc_6502_channel), .setArtist(context.getString(R.string.ccc_6502_channel))
thumbnail = Uri.parse(context.getString(R.string.ccc_6502_thumbnail)), .setArtworkUri(Uri.parse(context.getString(R.string.ccc_6502_thumbnail)))
lengthInS = context.resources.getInteger(R.integer.ccc_6502_length) .setDurationMs(
context.resources.getInteger(R.integer.ccc_6502_length).toLong() * 1000L
) )
.build()
"imu" -> MetaInfo( "imu" -> MediaMetadata.Builder()
title = context.getString(R.string.ccc_imu_title), .setTitle(context.getString(R.string.ccc_imu_title))
channelName = context.getString(R.string.ccc_imu_channel), .setArtist(context.getString(R.string.ccc_imu_channel))
thumbnail = Uri.parse(context.getString(R.string.ccc_imu_thumbnail)), .setArtworkUri(Uri.parse(context.getString(R.string.ccc_imu_thumbnail)))
lengthInS = context.resources.getInteger(R.integer.ccc_imu_length) .setDurationMs(
context.resources.getInteger(R.integer.ccc_imu_length).toLong() * 1000L
) )
.build()
"portrait" -> MetaInfo( "portrait" -> MediaMetadata.Builder()
title = context.getString(R.string.portrait_title), .setTitle(context.getString(R.string.portrait_title))
channelName = context.getString(R.string.portrait_channel), .setArtist(context.getString(R.string.portrait_channel))
thumbnail = null, .setArtworkUri(null)
lengthInS = context.resources.getInteger(R.integer.portrait_length) .setDurationMs(
context.resources.getInteger(R.integer.portrait_length).toLong() * 1000L
) )
.build()
else -> throw Exception("Unknown stream: $item") else -> throw Exception("Unknown stream: $item")
} }