make NewPlayer handle playlist updates and repository accesses

This commit is contained in:
Christian Schabesberger 2024-08-30 16:43:38 +02:00
parent 22d7bcf552
commit cdcfeaedd7
16 changed files with 729 additions and 319 deletions

View File

@ -0,0 +1,4 @@
kotlin version: 2.0.20-Beta2
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@ -23,8 +23,10 @@ package net.newpipe.newplayer
import android.app.Application
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import kotlinx.coroutines.CoroutineScope
@ -39,6 +41,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import net.newpipe.newplayer.playerInternals.PlayList
import net.newpipe.newplayer.playerInternals.PlaylistItem
import net.newpipe.newplayer.playerInternals.getPlaylistItemsFromItemList
import kotlin.Exception
enum class PlayMode {
@ -59,14 +63,14 @@ interface NewPlayer {
var playWhenReady: Boolean
val duration: Long
val bufferedPercentage: Int
val repository: MediaRepository
val sharingLinkWithOffsetPossible: Boolean
var currentPosition: Long
var fastSeekAmountSec: Int
var playBackMode: PlayMode
var playMode: StateFlow<PlayMode?>
var playlist: PlayList
val playlist: PlayList
val playlistInPlaylistItems: StateFlow<List<PlaylistItem>>
// callbacks
@ -87,17 +91,17 @@ interface NewPlayer {
private var preferredStreamVariants: List<String> = emptyList()
private var sharingLinkWithOffsetPossible = false
fun setMediaSourceFactory(mediaSourceFactory: MediaSource.Factory) : Builder {
fun setMediaSourceFactory(mediaSourceFactory: MediaSource.Factory): Builder {
this.mediaSourceFactory = mediaSourceFactory
return this
}
fun setPreferredStreamVariants(preferredStreamVariants: List<String>) : Builder {
fun setPreferredStreamVariants(preferredStreamVariants: List<String>): Builder {
this.preferredStreamVariants = preferredStreamVariants
return this
}
fun setSharingLinkWithOffsetPossible(possible: Boolean) : Builder {
fun setSharingLinkWithOffsetPossible(possible: Boolean): Builder {
this.sharingLinkWithOffsetPossible = false
return this
}
@ -123,7 +127,7 @@ class NewPlayerImpl(
val app: Application,
override val internalPlayer: Player,
override val preferredStreamVariants: List<String>,
override val repository: MediaRepository,
private val repository: MediaRepository,
override val sharingLinkWithOffsetPossible: Boolean
) : NewPlayer {
@ -161,9 +165,14 @@ class NewPlayerImpl(
override val duration: Long
get() = internalPlayer.duration
override var playlist = PlayList(internalPlayer)
override val playlist = PlayList(internalPlayer)
val mutablePlaylistAsPlaylistItems = MutableStateFlow<List<PlaylistItem>>(emptyList())
override val playlistInPlaylistItems: StateFlow<List<PlaylistItem>> =
mutablePlaylistAsPlaylistItems.asStateFlow()
init {
println("gurken init")
internalPlayer.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
launchJobAndCollectError {
@ -183,9 +192,28 @@ class NewPlayerImpl(
mutableOnEvent.emit(Pair(player, events))
}
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
super.onTimelineChanged(timeline, reason)
updatePlaylistItems()
}
})
}
private fun updatePlaylistItems() {
playerScope.launch {
val playlist = getPlaylistItemsFromItemList(playlist, repository)
var playlistDuration = 0
for (item in playlist) {
playlistDuration += item.lengthInS
}
mutablePlaylistAsPlaylistItems.update {
playlist
}
}
}
override fun prepare() {
internalPlayer.prepare()
}

View File

@ -20,8 +20,7 @@
package net.newpipe.newplayer.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import androidx.media3.common.Player
import net.newpipe.newplayer.Chapter
import net.newpipe.newplayer.playerInternals.PlaylistItem
import net.newpipe.newplayer.ui.ContentScale
@ -42,7 +41,10 @@ data class VideoPlayerUIState(
val brightness: Float?, // when null use system value
val embeddedUiConfig: EmbeddedUiConfig?,
val playList: List<PlaylistItem>,
val chapters: List<Chapter>
val chapters: List<Chapter>,
val shuffleEnabled: Boolean,
val repeatMode: Int,
val playListDurationInS: Int
) {
companion object {
val DEFAULT = VideoPlayerUIState(
@ -64,7 +66,10 @@ data class VideoPlayerUIState(
brightness = null,
embeddedUiConfig = null,
playList = emptyList(),
chapters = emptyList()
chapters = emptyList(),
shuffleEnabled = false,
repeatMode = Player.REPEAT_MODE_OFF,
playListDurationInS = 0
)
}
}

View File

@ -32,7 +32,6 @@ import net.newpipe.newplayer.utils.Thumbnail
interface VideoPlayerViewModel {
var newPlayer: NewPlayer?
val internalPlayer: Player?
val uiState: StateFlow<VideoPlayerUIState>
var minContentRatio: Float
var maxContentRatio: Float
@ -61,4 +60,7 @@ interface VideoPlayerViewModel {
fun closeStreamSelection()
fun chapterSelected(chapter: Chapter)
fun streamSelected(streamId: Int)
fun setRepeatmode(repeatMode: Int)
fun setSuffleEnabled(enabled: Boolean)
fun onStorePlaylist()
}

View File

@ -90,9 +90,6 @@ class VideoPlayerViewModelImpl @Inject constructor(
override val uiState = mutableUiState.asStateFlow()
override val internalPlayer: Player?
get() = newPlayer?.internalPlayer
override var minContentRatio: Float = 4F / 3F
set(value) {
if (value <= 0 || maxContentRatio < value) Log.e(
@ -133,7 +130,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
override val onBackPressed: SharedFlow<Unit> = mutableOnBackPressed.asSharedFlow()
private fun installNewPlayer() {
internalPlayer?.let { player ->
newPlayer?.internalPlayer?.let { player ->
Log.d(TAG, "Install player: ${player.videoSize.width}")
player.addListener(object : Player.Listener {
@ -158,11 +155,6 @@ class VideoPlayerViewModelImpl @Inject constructor(
it.copy(isLoading = isLoading)
}
}
override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) {
super.onPlaylistMetadataChanged(mediaMetadata)
updatePlaylist()
}
})
}
newPlayer?.let { newPlayer ->
@ -179,8 +171,12 @@ class VideoPlayerViewModelImpl @Inject constructor(
}
}
}
viewModelScope.launch {
newPlayer.playlistInPlaylistItems.collect { playlist ->
mutableUiState.update { it.copy(playList = playlist) }
}
}
}
updatePlaylist()
}
fun updateContentRatio(videoSize: VideoSize) {
@ -266,9 +262,10 @@ class VideoPlayerViewModelImpl @Inject constructor(
}
private fun updateProgressOnce() {
val progress = internalPlayer?.currentPosition ?: 0
val duration = internalPlayer?.duration ?: 1
val bufferedPercentage = (internalPlayer?.bufferedPercentage?.toFloat() ?: 0f) / 100f
val progress = newPlayer?.currentPosition ?: 0
val duration = newPlayer?.duration ?: 1
val bufferedPercentage =
(newPlayer?.bufferedPercentage?.toFloat() ?: 0f) / 100f
val progressPercentage = progress.toFloat() / duration.toFloat()
mutableUiState.update {
@ -297,13 +294,13 @@ class VideoPlayerViewModelImpl @Inject constructor(
override fun seekingFinished() {
resetHideUiDelayedJob()
val seekerPosition = mutableUiState.value.seekerPosition
val seekPositionInMs = (internalPlayer?.duration?.toFloat() ?: 0F) * seekerPosition
val seekPositionInMs = (newPlayer?.duration?.toFloat() ?: 0F) * seekerPosition
newPlayer?.currentPosition = seekPositionInMs.toLong()
Log.i(TAG, "Seek to Ms: $seekPositionInMs")
}
override fun embeddedDraggedDown(offset: Float) {
saveTryEmit(mutableEmbeddedPlayerDraggedDownBy, offset)
safeTryEmit(mutableEmbeddedPlayerDraggedDownBy, offset)
}
override fun fastSeek(count: Int) {
@ -362,7 +359,6 @@ class VideoPlayerViewModelImpl @Inject constructor(
}
override fun openStreamSelection(selectChapter: Boolean, embeddedUiConfig: EmbeddedUiConfig) {
println("gurken openSelection ${embeddedUiConfig}")
uiVisibilityJob?.cancel()
if (!uiState.value.uiMode.fullscreen) {
this.embeddedUiConfig = embeddedUiConfig
@ -388,7 +384,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
if (nextMode != null) {
updateUiMode(nextMode)
} else {
saveTryEmit(mutableOnBackPressed, Unit)
safeTryEmit(mutableOnBackPressed, Unit)
}
}
@ -408,6 +404,25 @@ class VideoPlayerViewModelImpl @Inject constructor(
println("stream selected: $streamId")
}
override fun setRepeatmode(repeatMode: Int) {
assert(
repeatMode == Player.REPEAT_MODE_ALL
|| repeatMode == Player.REPEAT_MODE_OFF
|| repeatMode == Player.REPEAT_MODE_ONE
) {
"Illegal repeat mode: $repeatMode"
}
TODO("Not yet implemented")
}
override fun setSuffleEnabled(enabled: Boolean) {
TODO("Not yet implemented")
}
override fun onStorePlaylist() {
TODO("Not yet implemented")
}
private fun updateUiMode(newState: UIModeState) {
val newPlayMode = newState.toPlayMode()
val currentPlayMode = mutableUiState.value.uiMode.toPlayMode()
@ -420,7 +435,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
}
}
private fun getEmbeddedUiRatio() = internalPlayer?.let { player ->
private fun getEmbeddedUiRatio() = newPlayer?.internalPlayer?.let { player ->
val videoRatio = VideoSize.fromMedia3VideoSize(player.videoSize).getRatio()
return (if (videoRatio.isNaN()) currentContentRatio
else videoRatio).coerceIn(minContentRatio, maxContentRatio)
@ -428,21 +443,8 @@ class VideoPlayerViewModelImpl @Inject constructor(
} ?: minContentRatio
private fun updatePlaylist() {
newPlayer?.let { newPlayer ->
viewModelScope.launch {
val playlist = getPlaylistItemsFromItemList(
newPlayer.playlist, newPlayer.repository
)
mutableUiState.update {
it.copy(playList = playlist)
}
}
}
}
private fun <T> saveTryEmit(sharedFlow: MutableSharedFlow<T>, value: T) {
if(sharedFlow.tryEmit(value)) {
private fun <T> safeTryEmit(sharedFlow: MutableSharedFlow<T>, value: T) {
if (!sharedFlow.tryEmit(value)) {
viewModelScope.launch {
sharedFlow.emit(value)
}

View File

@ -12,7 +12,6 @@ import net.newpipe.newplayer.ui.ContentScale
open class VideoPlayerViewModelDummy : VideoPlayerViewModel {
override var newPlayer: NewPlayer? = null
override val internalPlayer: Player? = null
override val uiState = MutableStateFlow(VideoPlayerUIState.DEFAULT)
override var minContentRatio = 4F / 3F
override var maxContentRatio = 16F / 9F
@ -92,6 +91,18 @@ open class VideoPlayerViewModelDummy : VideoPlayerViewModel {
println("dummy impl stream selected: $streamId")
}
override fun setRepeatmode(repeatMode: Int) {
println("dummy impl repeat mode: $repeatMode")
}
override fun setSuffleEnabled(enabled: Boolean) {
println("dummy impl shuffle enabled: $enabled")
}
override fun onStorePlaylist() {
TODO("Not yet implemented")
}
override fun pause() {
println("dummy pause")
}

View File

@ -53,4 +53,10 @@ suspend fun getPlaylistItemsFromItemList(items: List<String>, mediaRepo: MediaRe
}
}
fun getPlaylistDurationInS(items: List<PlaylistItem>) : Int {
var duration = 0
for(item in items) {
duration += item.lengthInS
}
return duration
}

View File

@ -71,7 +71,7 @@ fun VideoPlayerUI(
) {
if (viewModel == null) {
VideoPlayerLoadingPlaceholder()
} else if (viewModel.internalPlayer == null) {
} else if (viewModel.newPlayer == null) {
VideoPlayerLoadingPlaceholder(viewModel.uiState.collectAsState().value.embeddedUiRatio)
} else {
val uiState by viewModel.uiState.collectAsState()
@ -171,7 +171,7 @@ fun VideoPlayerUI(
) {
Box(contentAlignment = Alignment.Center) {
PlaySurface(
player = viewModel.internalPlayer,
player = viewModel.newPlayer?.internalPlayer,
lifecycle = lifecycle,
fitMode = uiState.contentFitMode,
uiRatio = if (uiState.uiMode.fullscreen) screenRatio

View File

@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.viewModelScope
import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.UIModeState
import net.newpipe.newplayer.model.VideoPlayerUIState
@ -68,7 +69,8 @@ fun BottomUI(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
) {
Text(getTimeStringFromMs(uiState.playbackPositionInMs, getLocale() ?: Locale.US))
val locale = getLocale()!!
Text(getTimeStringFromMs(uiState.playbackPositionInMs, getLocale() ?: locale))
Seeker(
Modifier.weight(1F),
value = uiState.seekerPosition,
@ -80,7 +82,7 @@ fun BottomUI(
//Slider(value = 0.4F, onValueChange = {}, modifier = Modifier.weight(1F))
Text(getTimeStringFromMs(uiState.durationInMs, getLocale() ?: Locale.US))
Text(getTimeStringFromMs(uiState.durationInMs, getLocale() ?: locale))
val embeddedUiConfig = getEmbeddedUiConfig(LocalContext.current as Activity)
IconButton(

View File

@ -20,59 +20,47 @@
package net.newpipe.newplayer.ui.videoplayer
import android.view.MotionEvent
import android.view.Surface
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOn
import androidx.compose.material.icons.filled.RepeatOne
import androidx.compose.material.icons.filled.RepeatOneOn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.exoplayer.ExoPlayer
import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy
import net.newpipe.newplayer.ui.CONTROLLER_UI_BACKGROUND_COLOR
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.Chapter
import net.newpipe.newplayer.utils.BitmapThumbnail
import net.newpipe.newplayer.utils.OnlineThumbnail
import net.newpipe.newplayer.utils.Thumbnail
import net.newpipe.newplayer.utils.VectorThumbnail
import net.newpipe.newplayer.NewPlayerException
import net.newpipe.newplayer.playerInternals.PlaylistItem
import net.newpipe.newplayer.ui.videoplayer.streamselect.ChapterItem
import net.newpipe.newplayer.ui.videoplayer.streamselect.ChapterSelectTopBar
import net.newpipe.newplayer.ui.videoplayer.streamselect.StreamItem
import net.newpipe.newplayer.ui.videoplayer.streamselect.StreamSelectTopBar
import net.newpipe.newplayer.utils.getInsets
@Composable
@ -93,11 +81,12 @@ fun StreamSelectUI(
containerColor = Color.Transparent,
topBar = {
if (isChapterSelect) {
ChapterSelectTopBar(onClose = {
viewModel.closeStreamSelection()
})
ChapterSelectTopBar(
onClose =
viewModel::closeStreamSelection
)
} else {
StreamSelectTopBar()
StreamSelectTopBar(viewModel = viewModel, uiState = uiState)
}
}
) { innerPadding ->
@ -105,6 +94,8 @@ fun StreamSelectUI(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(start = 8.dp, end = 4.dp)
) {
if (isChapterSelect) {
items(uiState.chapters.size) { chapterIndex ->
@ -120,252 +111,29 @@ fun StreamSelectUI(
)
}
} else {
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChapterSelectTopBar(modifier: Modifier = Modifier, onClose: () -> Unit) {
TopAppBar(modifier = modifier,
colors = topAppBarColors(containerColor = Color.Transparent),
title = {
Text("Chapter TODO")
//Text(stringResource(R.string.chapter))
}, actions = {
IconButton(
onClick = onClose
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.close_chapter_selection)
)
}
})
}
@Composable
private fun StreamSelectTopBar() {
}
@Composable
private fun ChapterItem(
modifier: Modifier = Modifier,
id: Int,
thumbnail: Thumbnail?,
chapterTitle: String,
chapterStartInMs: Long,
onClicked: (Int) -> Unit
) {
val locale = getLocale()!!
Row(
modifier = modifier
.padding(
start = 8.dp,
top = 4.dp,
bottom = 4.dp,
end = 4.dp
).height(80.dp)
.clickable { onClicked(id) }
) {
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 = thumbnail,
contentDescription = contentDescription
)
} else {
Image(
painterResource(R.drawable.tiny_placeholder),
contentDescription = stringResource(R.string.chapter_thumbnail)
)
}
Column(
modifier = Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.Start,
) {
Text(text = chapterTitle, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Text(getTimeStringFromMs(chapterStartInMs, locale))
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun StreamItem(
modifier: Modifier = Modifier,
id: Int,
title: String,
creator: String?,
thumbnail: Thumbnail?,
lengthInMs: Long,
onDragStart: (Int) -> Unit,
onDragEnd: (Int) -> Unit,
onClicked: (Int) -> Unit
) {
val locale = getLocale()!!
Row(modifier = modifier.clickable { onClicked(id) }) {
Box {
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 = thumbnail,
contentDescription = contentDescription
)
} else {
Image(
painterResource(R.drawable.tiny_placeholder),
contentDescription = stringResource(R.string.chapter_thumbnail)
)
}
Surface(
color = CONTROLLER_UI_BACKGROUND_COLOR,
modifier = Modifier
.wrapContentSize()
.align(Alignment.BottomEnd)
.padding(4.dp)
) {
Text(
modifier = Modifier.padding(
start = 4.dp,
end = 4.dp,
top = 2.dp,
bottom = 2.dp
), text = getTimeStringFromMs(lengthInMs, locale)
)
}
}
Column(
modifier = Modifier
.padding(8.dp)
.weight(1f)
.fillMaxSize()
) {
Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold)
if (creator != null) {
Text(text = creator)
}
}
Box(modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_UP -> {
onDragEnd(id)
false
items(uiState.playList.size) { playlistItemIndex ->
val playlistItem = uiState.playList[playlistItemIndex]
StreamItem(
id = playlistItemIndex,
title = playlistItem.title,
creator = playlistItem.creator,
thumbnail = playlistItem.thumbnail,
lengthInMs = playlistItem.lengthInS.toLong() * 1000,
onDragStart = {},
onDragEnd = {},
onClicked = { viewModel.streamSelected(playlistItemIndex) }
)
}
MotionEvent.ACTION_DOWN -> {
onDragStart(id)
false
}
else -> true
}
}) {
Icon(
modifier = Modifier
.size(40.dp)
.align(Alignment.Center),
imageVector = Icons.Filled.DragHandle,
//contentDescription = stringResource(R.string.stream_item_drag_handle)
contentDescription = "placeholer, TODO: FIXME"
)
}
}
}
}
@Preview(device = "spec:width=1080px,height=300px,dpi=440,orientation=landscape")
@Composable
fun ChapterItemPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
ChapterItem(
id = 0,
thumbnail = null,
modifier = Modifier.fillMaxSize(),
chapterTitle = "Chapter Title",
chapterStartInMs = (4 * 60 + 32) * 1000,
onClicked = {}
)
}
}
}
@Preview(device = "spec:width=1080px,height=200px,dpi=440,orientation=landscape")
@Composable
fun StreamItemPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
StreamItem(
id = 0,
modifier = Modifier.fillMaxSize(),
title = "Video Title",
creator = "Video Creator",
thumbnail = null,
lengthInMs = 15 * 60 * 1000,
onDragStart = {},
onDragEnd = {},
onClicked = {}
)
}
}
}
@Preview(device = "spec:width=1080px,height=150px,dpi=440,orientation=landscape")
@Composable
fun ChapterTopBarPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
ChapterSelectTopBar(modifier = Modifier.fillMaxSize()) {}
}
}
}
@Preview(device = "id:pixel_5")
@Composable
fun VideoPlayerStreamSelectUIPreview() {
fun VideoPlayerChannelSelectUIPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.Red) {
StreamSelectUI(
@ -394,3 +162,41 @@ fun VideoPlayerStreamSelectUIPreview() {
}
}
}
@Preview(device = "id:pixel_5")
@Composable
fun VideoPlayerStreamSelectUIPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.Red) {
StreamSelectUI(
isChapterSelect = false,
viewModel = VideoPlayerViewModelDummy(),
uiState = VideoPlayerUIState.DEFAULT.copy(
playList = arrayListOf(
PlaylistItem(
id = "6502",
title = "Stream 1",
creator = "The Creator",
lengthInS = 6 * 60 + 5,
thumbnail = null
),
PlaylistItem(
id = "6502",
title = "Stream 2",
creator = "The Creator 2",
lengthInS = 2 * 60 + 5,
thumbnail = null
),
PlaylistItem(
id = "6502",
title = "Stream 3",
creator = "The Creator 3",
lengthInS = 29 * 60 + 5,
thumbnail = null
)
)
)
)
}
}
}

View File

@ -0,0 +1,123 @@
/* 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.ui.videoplayer.streamselect
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import net.newpipe.newplayer.R
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.BitmapThumbnail
import net.newpipe.newplayer.utils.OnlineThumbnail
import net.newpipe.newplayer.utils.Thumbnail
import net.newpipe.newplayer.utils.VectorThumbnail
import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs
@Composable
fun ChapterItem(
modifier: Modifier = Modifier,
id: Int,
thumbnail: Thumbnail?,
chapterTitle: String,
chapterStartInMs: Long,
onClicked: (Int) -> Unit
) {
val locale = getLocale()!!
Row(
modifier = modifier
.height(80.dp)
.clickable { onClicked(id) }
) {
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 = thumbnail,
contentDescription = contentDescription
)
} else {
Image(
painterResource(R.drawable.tiny_placeholder),
contentDescription = stringResource(R.string.chapter_thumbnail)
)
}
Column(
modifier = Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.Start,
) {
Text(text = chapterTitle, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Text(getTimeStringFromMs(chapterStartInMs, locale))
}
}
}
@Preview(device = "spec:width=1080px,height=300px,dpi=440,orientation=landscape")
@Composable
fun ChapterItemPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
ChapterItem(
id = 0,
thumbnail = null,
modifier = Modifier.fillMaxSize(),
chapterTitle = "Chapter Title",
chapterStartInMs = (4 * 60 + 32) * 1000,
onClicked = {}
)
}
}
}

View File

@ -0,0 +1,70 @@
/* 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.ui.videoplayer.streamselect
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import net.newpipe.newplayer.R
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChapterSelectTopBar(modifier: Modifier = Modifier, onClose: () -> Unit) {
TopAppBar(modifier = modifier,
colors = topAppBarColors(containerColor = Color.Transparent),
title = {
Text("Chapter TODO")
//Text(stringResource(R.string.chapter))
}, actions = {
IconButton(
onClick = onClose
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.close_chapter_selection)
)
}
})
}
@Preview(device = "spec:width=1080px,height=150px,dpi=440,orientation=landscape")
@Composable
fun ChapterTopBarPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
ChapterSelectTopBar(modifier = Modifier.fillMaxSize()) {}
}
}
}

View File

@ -0,0 +1,188 @@
/* 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.ui.videoplayer.streamselect
import android.view.MotionEvent
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import net.newpipe.newplayer.R
import net.newpipe.newplayer.ui.CONTROLLER_UI_BACKGROUND_COLOR
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.BitmapThumbnail
import net.newpipe.newplayer.utils.OnlineThumbnail
import net.newpipe.newplayer.utils.Thumbnail
import net.newpipe.newplayer.utils.VectorThumbnail
import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun StreamItem(
modifier: Modifier = Modifier,
id: Int,
title: String,
creator: String?,
thumbnail: Thumbnail?,
lengthInMs: Long,
onDragStart: (Int) -> Unit,
onDragEnd: (Int) -> Unit,
onClicked: (Int) -> Unit
) {
val locale = getLocale()!!
Row(modifier = modifier
.clickable { onClicked(id) }
.height(80.dp)) {
Box {
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 = thumbnail,
contentDescription = contentDescription
)
} else {
Image(
painterResource(R.drawable.tiny_placeholder),
contentDescription = stringResource(R.string.chapter_thumbnail)
)
}
Surface(
color = CONTROLLER_UI_BACKGROUND_COLOR,
modifier = Modifier
.wrapContentSize()
.align(Alignment.BottomEnd)
.padding(4.dp)
) {
Text(
modifier = Modifier.padding(
start = 4.dp,
end = 4.dp,
top = 2.dp,
bottom = 2.dp
), text = getTimeStringFromMs(lengthInMs, locale)
)
}
}
Column(
modifier = Modifier
.padding(8.dp)
.weight(1f)
.fillMaxSize()
) {
Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold)
if (creator != null) {
Text(text = creator)
}
}
Box(modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_UP -> {
onDragEnd(id)
false
}
MotionEvent.ACTION_DOWN -> {
onDragStart(id)
false
}
else -> true
}
}) {
Icon(
modifier = Modifier
.size(40.dp)
.align(Alignment.Center),
imageVector = Icons.Filled.DragHandle,
//contentDescription = stringResource(R.string.stream_item_drag_handle)
contentDescription = "placeholer, TODO: FIXME"
)
}
}
}
@Preview(device = "spec:width=1080px,height=200px,dpi=440,orientation=landscape")
@Composable
fun StreamItemPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
StreamItem(
id = 0,
modifier = Modifier.fillMaxSize(),
title = "Video Title",
creator = "Video Creator",
thumbnail = null,
lengthInMs = 15 * 60 * 1000,
onDragStart = {},
onDragEnd = {},
onClicked = {}
)
}
}
}

View File

@ -0,0 +1,157 @@
/* 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.ui.videoplayer.streamselect
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOn
import androidx.compose.material.icons.filled.RepeatOneOn
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.ShuffleOn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.media3.common.Player
import net.newpipe.newplayer.NewPlayerException
import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy
import net.newpipe.newplayer.playerInternals.getPlaylistDurationInS
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StreamSelectTopBar(
modifier: Modifier = Modifier,
viewModel: VideoPlayerViewModel,
uiState: VideoPlayerUIState
) {
TopAppBar(modifier = modifier,
colors = topAppBarColors(containerColor = Color.Transparent),
title = {
val locale = getLocale()!!
val duration = getPlaylistDurationInS(uiState.playList).toLong() * 1000
val durationString = getTimeStringFromMs(timeSpanInMs = duration, locale)
Text(
text = "00:00/$durationString"
)
}, actions = {
IconButton(
onClick = {
viewModel.setRepeatmode(
when (uiState.repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_OFF
else -> throw NewPlayerException("Unknown repeat mode: ${uiState.repeatMode}")
}
)
}
) {
when (uiState.repeatMode) {
Player.REPEAT_MODE_OFF -> Icon(
imageVector = Icons.Filled.Repeat,
contentDescription = stringResource(R.string.repeat_mode_no_repeat)
)
Player.REPEAT_MODE_ALL -> Icon(
imageVector = Icons.Filled.RepeatOn,
contentDescription = stringResource(R.string.repeat_mode_repeat_all)
)
Player.REPEAT_MODE_ONE -> Icon(
imageVector = Icons.Filled.RepeatOneOn,
contentDescription = stringResource(R.string.repeat_mode_repeat_all)
)
else -> throw NewPlayerException("Unknown repeat mode: ${uiState.repeatMode}")
}
}
IconButton(
onClick = {
viewModel.setSuffleEnabled(!uiState.shuffleEnabled)
}
) {
if (uiState.shuffleEnabled) {
Icon(
imageVector = Icons.Filled.ShuffleOn,
contentDescription = stringResource(R.string.shuffle_off)
)
} else {
Icon(
imageVector = Icons.Filled.Shuffle,
contentDescription = stringResource(R.string.shuffle_on)
)
}
}
IconButton(
onClick = viewModel::onStorePlaylist
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = stringResource(R.string.store_playlist)
)
}
IconButton(
onClick = viewModel::closeStreamSelection
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.close_stream_selection)
)
}
})
}
@Preview(device = "spec:width=1080px,height=150px,dpi=440,orientation=landscape")
@Composable
fun StreamSelectTopBarPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
StreamSelectTopBar(
modifier = Modifier.fillMaxSize(),
viewModel = VideoPlayerViewModelDummy(),
uiState = VideoPlayerUIState.DEFAULT
)
}
}
}

View File

@ -126,7 +126,7 @@ fun getTimeStringFromMs(timeSpanInMs: Long, locale: Locale): String {
val time_string =
if (0L < days) String.format(locale, "%d:%02d:%02d:%02d", days, hours, minutes, seconds)
else if (0L < hours) String.format(locale, "%d:%02d:%02d", hours, minutes, seconds)
else String.format(locale, "%d:%02d", minutes, seconds)
else String.format(locale, "%02d:%02d", minutes, seconds)
return time_string
}

View File

@ -45,4 +45,10 @@
<string name="chapter">Chapter</string>
<string name="chapter_thumbnail">Chapter Thumbnail</string>
<string name="stream_item_drag_handle">Stream item drag handle</string>
<string name="repeat_mode_no_repeat">Repeat mode: No repeat</string>
<string name="repeat_mode_repeat_all">Repeat mode: Repeat all</string>
<string name="repeat_mode_repeat_current">Repeat mode: Repeat currently playing</string>
<string name="shuffle_on">Shuffle playlist enabled</string>
<string name="shuffle_off">Shuffle playlist disabled</string>
<string name="store_playlist">Save current playlist</string>
</resources>