make playlist items draggable

This commit is contained in:
Christian Schabesberger 2024-09-03 12:48:16 +02:00
parent 38bf37e88a
commit 8ed25f5039
11 changed files with 172 additions and 281 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-07-23T15:40:12.466915691Z"> <DropdownSelection timestamp="2024-09-02T13:31:45.216735120Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=981f7af2" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=981f7af2" />

View File

@ -43,6 +43,7 @@ kotlinParcelize = "2.0.20-Beta2"
newplayer = "master-SNAPSHOT" newplayer = "master-SNAPSHOT"
okhttpAndroid = "5.0.0-alpha.14" okhttpAndroid = "5.0.0-alpha.14"
coil = "2.7.0" coil = "2.7.0"
reorderable = "2.4.0-alpha02"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -77,6 +78,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
newplayer = { group = "com.github.theScrabi.NewPlayer", name = "new-player", version.ref = "newplayer" } newplayer = { group = "com.github.theScrabi.NewPlayer", name = "new-player", version.ref = "newplayer" }
okhttp-android = { group = "com.squareup.okhttp3", name = "okhttp-android", version.ref = "okhttpAndroid" } okhttp-android = { group = "com.squareup.okhttp3", name = "okhttp-android", version.ref = "okhttpAndroid" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" }

View File

@ -67,6 +67,7 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.media3.common) implementation(libs.androidx.media3.common)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.reorderable)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)

View File

@ -23,7 +23,6 @@ package net.newpipe.newplayer
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
@ -40,10 +39,11 @@ 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.playerInternals.PlayList
import net.newpipe.newplayer.playerInternals.PlaylistItem import net.newpipe.newplayer.playerInternals.PlaylistItem
import net.newpipe.newplayer.playerInternals.getPlaylistItemsFromItemList import net.newpipe.newplayer.playerInternals.getPlaylistItemsFromExoplayer
import net.newpipe.newplayer.utils.Thumbnail
import kotlin.Exception import kotlin.Exception
import kotlin.random.Random
enum class PlayMode { enum class PlayMode {
EMBEDDED_VIDEO, EMBEDDED_VIDEO,
@ -69,8 +69,7 @@ interface NewPlayer {
var playBackMode: PlayMode var playBackMode: PlayMode
var playMode: StateFlow<PlayMode?> var playMode: StateFlow<PlayMode?>
val playlist: PlayList val playlist: StateFlow<List<PlaylistItem>>
val playlistInPlaylistItems: StateFlow<List<PlaylistItem>>
// callbacks // callbacks
@ -82,6 +81,8 @@ interface NewPlayer {
fun play() fun play()
fun pause() fun pause()
fun addToPlaylist(item: String) fun addToPlaylist(item: String)
fun movePlaylistItem(fromIndex: Int, toIndex: Int)
fun removePlaylistItem(index: Int)
fun playStream(item: String, playMode: PlayMode) fun playStream(item: String, playMode: PlayMode)
fun playStream(item: String, streamVariant: String, playMode: PlayMode) fun playStream(item: String, streamVariant: String, playMode: PlayMode)
fun setPlayMode(playMode: PlayMode) fun setPlayMode(playMode: PlayMode)
@ -131,6 +132,8 @@ class NewPlayerImpl(
override val sharingLinkWithOffsetPossible: Boolean override val sharingLinkWithOffsetPossible: Boolean
) : NewPlayer { ) : NewPlayer {
private var uniqueIdToIdLookup = HashMap<Long, String>()
var mutableErrorFlow = MutableSharedFlow<Exception>() var mutableErrorFlow = MutableSharedFlow<Exception>()
override val errorFlow = mutableErrorFlow.asSharedFlow() override val errorFlow = mutableErrorFlow.asSharedFlow()
@ -165,11 +168,9 @@ class NewPlayerImpl(
override val duration: Long override val duration: Long
get() = internalPlayer.duration get() = internalPlayer.duration
override val playlist = PlayList(internalPlayer) val mutablePlaylist = MutableStateFlow<List<PlaylistItem>>(emptyList())
override val playlist: StateFlow<List<PlaylistItem>> =
val mutablePlaylistAsPlaylistItems = MutableStateFlow<List<PlaylistItem>>(emptyList()) mutablePlaylist.asStateFlow()
override val playlistInPlaylistItems: StateFlow<List<PlaylistItem>> =
mutablePlaylistAsPlaylistItems.asStateFlow()
init { init {
println("gurken init") println("gurken init")
@ -202,13 +203,13 @@ class NewPlayerImpl(
private fun updatePlaylistItems() { private fun updatePlaylistItems() {
playerScope.launch { playerScope.launch {
val playlist = getPlaylistItemsFromItemList(playlist, repository) val playlist = getPlaylistItemsFromExoplayer(internalPlayer, repository, uniqueIdToIdLookup)
var playlistDuration = 0 var playlistDuration = 0
for (item in playlist) { for (item in playlist) {
playlistDuration += item.lengthInS playlistDuration += item.lengthInS
} }
mutablePlaylistAsPlaylistItems.update { mutablePlaylist.update {
playlist playlist
} }
} }
@ -237,6 +238,14 @@ class NewPlayerImpl(
} }
} }
override fun movePlaylistItem(fromIndex: Int, toIndex: Int) {
internalPlayer.moveMediaItem(fromIndex, toIndex)
}
override fun removePlaylistItem(index: Int) {
internalPlayer.removeMediaItem(index)
}
override fun playStream(item: String, playMode: PlayMode) { override fun playStream(item: String, playMode: PlayMode) {
launchJobAndCollectError { launchJobAndCollectError {
val mediaItem = toMediaItem(item) val mediaItem = toMediaItem(item)
@ -266,7 +275,9 @@ 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 mediaItem = MediaItem.Builder().setMediaId(item).setUri(dataStream) val uniqueId = Random.nextLong()
uniqueIdToIdLookup.set(uniqueId, item)
val mediaItem = MediaItem.Builder().setMediaId(uniqueId.toString()).setUri(dataStream)
return mediaItem.build() return mediaItem.build()
} }

View File

@ -63,4 +63,6 @@ interface VideoPlayerViewModel {
fun setRepeatmode(repeatMode: Int) fun setRepeatmode(repeatMode: Int)
fun setSuffleEnabled(enabled: Boolean) fun setSuffleEnabled(enabled: Boolean)
fun onStorePlaylist() fun onStorePlaylist()
fun movePlaylistItem(from: Int, to: Int)
fun removePlaylistItem(index: Int)
} }

View File

@ -30,7 +30,6 @@ 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.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -46,7 +45,6 @@ import kotlinx.coroutines.launch
import net.newpipe.newplayer.Chapter import net.newpipe.newplayer.Chapter
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.playerInternals.getPlaylistItemsFromItemList
import net.newpipe.newplayer.ui.ContentScale import net.newpipe.newplayer.ui.ContentScale
val VIDEOPLAYER_UI_STATE = "video_player_ui_state" val VIDEOPLAYER_UI_STATE = "video_player_ui_state"
@ -172,7 +170,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
} }
} }
viewModelScope.launch { viewModelScope.launch {
newPlayer.playlistInPlaylistItems.collect { playlist -> newPlayer.playlist.collect { playlist ->
mutableUiState.update { it.copy(playList = playlist) } mutableUiState.update { it.copy(playList = playlist) }
} }
} }
@ -423,6 +421,14 @@ class VideoPlayerViewModelImpl @Inject constructor(
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override fun movePlaylistItem(from: Int, to: Int) {
newPlayer?.movePlaylistItem(from, to)
}
override fun removePlaylistItem(index: Int) {
newPlayer?.removePlaylistItem(index)
}
private fun updateUiMode(newState: UIModeState) { private fun updateUiMode(newState: UIModeState) {
val newPlayMode = newState.toPlayMode() val newPlayMode = newState.toPlayMode()
val currentPlayMode = mutableUiState.value.uiMode.toPlayMode() val currentPlayMode = mutableUiState.value.uiMode.toPlayMode()

View File

@ -100,7 +100,15 @@ open class VideoPlayerViewModelDummy : VideoPlayerViewModel {
} }
override fun onStorePlaylist() { override fun onStorePlaylist() {
TODO("Not yet implemented") println("dummy impl")
}
override fun movePlaylistItem(from: Int, to: Int) {
println("dummy impl")
}
override fun removePlaylistItem(index: Int) {
println("dummy impl")
} }
override fun pause() { override fun pause() {

View File

@ -1,162 +0,0 @@
/* 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.playerInternals
import androidx.media3.common.Player
// TODO: This is cool, but it might still contains all raceconditions since two actors are mutating the
// same time.
// Be aware when using this list and this iterator: There can alwas be out of bounds exceptions
// even if the size in a previous query said otherwise, since between the size query and
// a get element query the count of elements might have been changed by exoplayer itself
// due to this reason some functions force the user to handle elements out of bounds exceptions.
class PlayListIterator(
val exoPlayer: Player,
val fromIndex: Int,
val toIndex: Int
) : ListIterator<String> {
var index = fromIndex
override fun hasNext() =
index < minOf(exoPlayer.mediaItemCount, toIndex)
override fun hasPrevious() = fromIndex < index
@Throws(IndexOutOfBoundsException::class)
override fun next(): String {
if (exoPlayer.mediaItemCount <= index)
throw NoSuchElementException("No Stream with index $index in the playlist")
val item = exoPlayer.getMediaItemAt(index).mediaId
index++
return item
}
@Throws(IndexOutOfBoundsException::class)
override fun nextIndex() =
if (exoPlayer.mediaItemCount <= index)
exoPlayer.mediaItemCount - fromIndex
else
(index + 1) - fromIndex
@Throws(IndexOutOfBoundsException::class)
override fun previous(): String {
if (index <= fromIndex)
throw NoSuchElementException("No Stream with index ${index - 1} in the playlist")
index--
val item = exoPlayer.getMediaItemAt(index).mediaId
return item
}
override fun previousIndex() =
if (index <= fromIndex)
0
else
(index - 1) - fromIndex
}
class PlayList(val exoPlayer: Player, val fromIndex: Int = 0, val toIndex: Int = Int.MAX_VALUE) :
List<String> {
override val size: Int
get() = minOf(exoPlayer.mediaItemCount, toIndex) - fromIndex
// TODO: This contains a race condition. When the player might change the playlist while this function runns
override fun contains(element: String): Boolean {
for (i in fromIndex..minOf(exoPlayer.mediaItemCount, toIndex)) {
try {
if (exoPlayer.getMediaItemAt(i).mediaId == element)
return true
} catch (e: IndexOutOfBoundsException) {
return false
}
}
return false
}
// TODO: This contains a race condition. When the player might change the playlist while this function runns
override fun containsAll(elements: Collection<String>): Boolean {
for (element in elements) {
if (!this.contains(element)) {
return false
}
}
return true
}
@Throws(IndexOutOfBoundsException::class)
override fun get(index: Int) =
if (index < 0 || toIndex < index + fromIndex)
throw IndexOutOfBoundsException("Accessed playlist item outside of permitted Playlist ListWindow: $index with [$fromIndex;$toIndex[")
else
exoPlayer.getMediaItemAt(index + fromIndex).mediaId
override fun isEmpty() = exoPlayer.mediaItemCount == 0 || fromIndex == toIndex
override fun iterator() = PlayListIterator(exoPlayer, fromIndex, toIndex)
override fun listIterator() = PlayListIterator(exoPlayer, fromIndex, toIndex)
override fun listIterator(index: Int) = PlayListIterator(exoPlayer, index, toIndex)
override fun subList(fromIndex: Int, toIndex: Int): List<String> =
PlayList(
exoPlayer,
fromIndex = this.fromIndex + fromIndex,
toIndex = this.fromIndex + toIndex
)
override fun lastIndexOf(element: String): Int {
var mediaItemCount = 0
var newMediaItemCount = minOf(toIndex, exoPlayer.mediaItemCount)
// this while loop is there to catch raceconditions
while(mediaItemCount != newMediaItemCount) {
mediaItemCount = newMediaItemCount
for (i in minOf(toIndex, exoPlayer.mediaItemCount) downTo fromIndex) {
try {
if (exoPlayer.getMediaItemAt(i).mediaId == element) {
return i - fromIndex
}
} catch (_: IndexOutOfBoundsException) {}
}
newMediaItemCount = minOf(toIndex, exoPlayer.mediaItemCount)
}
return -1
}
override fun indexOf(element: String): Int {
for (i in fromIndex..minOf(toIndex, exoPlayer.mediaItemCount)) {
try {
if (exoPlayer.getMediaItemAt(i).mediaId == element) {
return i - fromIndex
}
} catch (e: IndexOutOfBoundsException) {
return -1
}
}
return -1
}
}

View File

@ -21,34 +21,48 @@
package net.newpipe.newplayer.playerInternals package net.newpipe.newplayer.playerInternals
import androidx.media3.common.Player
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import net.newpipe.newplayer.MediaRepository import net.newpipe.newplayer.MediaRepository
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
import kotlin.random.Random
data class PlaylistItem( data class PlaylistItem(
val title: String, val title: String,
val creator: String, val creator: String,
val id: String, val id: String,
val uniqueId: Long,
val thumbnail: Thumbnail?, val thumbnail: Thumbnail?,
val lengthInS: Int val lengthInS: Int
) )
suspend fun getPlaylistItemsFromItemList(items: List<String>, mediaRepo: MediaRepository) = suspend fun getPlaylistItemsFromExoplayer(player: Player, mediaRepo: MediaRepository, idLookupTable: HashMap<Long, String>) =
with(CoroutineScope(coroutineContext)) { with(CoroutineScope(coroutineContext)) {
items.map { item -> (0..player.mediaItemCount-1).map { index ->
println("gurken index: $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 { Pair(item, async {
mediaRepo.getMetaInfo(item) mediaRepo.getMetaInfo(item.second)
}) })
}.map { }.map {
val uniqueId = it.first.first
val id = it.first.second
val metaInfo = it.second.await() val metaInfo = it.second.await()
PlaylistItem( PlaylistItem(
title = metaInfo.title, title = metaInfo.title,
creator = metaInfo.channelName, creator = metaInfo.channelName,
id = it.first, id = id,
thumbnail = metaInfo.thumbnail, thumbnail = metaInfo.thumbnail,
lengthInS = metaInfo.lengthInS lengthInS = metaInfo.lengthInS,
uniqueId = uniqueId
) )
} }
} }

View File

@ -20,42 +20,31 @@
package net.newpipe.newplayer.ui.videoplayer package net.newpipe.newplayer.ui.videoplayer
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.lazy.rememberLazyListState
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.Scaffold
import androidx.compose.material3.Surface 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.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.exoplayer.ExoPlayer
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
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy 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.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.Chapter import net.newpipe.newplayer.Chapter
import net.newpipe.newplayer.NewPlayerException
import net.newpipe.newplayer.playerInternals.PlaylistItem import net.newpipe.newplayer.playerInternals.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
@ -63,6 +52,9 @@ import net.newpipe.newplayer.ui.videoplayer.streamselect.ChapterSelectTopBar
import net.newpipe.newplayer.ui.videoplayer.streamselect.StreamItem import net.newpipe.newplayer.ui.videoplayer.streamselect.StreamItem
import net.newpipe.newplayer.ui.videoplayer.streamselect.StreamSelectTopBar import net.newpipe.newplayer.ui.videoplayer.streamselect.StreamSelectTopBar
import net.newpipe.newplayer.utils.getInsets import net.newpipe.newplayer.utils.getInsets
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable @Composable
fun StreamSelectUI( fun StreamSelectUI(
@ -91,13 +83,14 @@ fun StreamSelectUI(
} }
} }
) { innerPadding -> ) { innerPadding ->
LazyColumn( if (isChapterSelect) {
modifier = Modifier LazyColumn(
.padding(innerPadding) modifier = Modifier
.fillMaxSize(), .padding(innerPadding)
contentPadding = PaddingValues(start = 8.dp, end = 4.dp) .fillMaxSize(),
) { contentPadding = PaddingValues(start = 8.dp, end = 4.dp)
if (isChapterSelect) { ) {
items(uiState.chapters.size) { chapterIndex -> items(uiState.chapters.size) { chapterIndex ->
val chapter = uiState.chapters[chapterIndex] val chapter = uiState.chapters[chapterIndex]
ChapterItem( ChapterItem(
@ -110,26 +103,58 @@ fun StreamSelectUI(
} }
) )
} }
} else {
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) }
)
}
} }
} else {
ReorderableStreamItemsList(
innerPadding = innerPadding,
viewModel = viewModel,
uiState = uiState
)
} }
} }
} }
} }
@Composable
fun ReorderableStreamItemsList(
innerPadding: PaddingValues,
viewModel: VideoPlayerViewModel,
uiState: VideoPlayerUIState
) {
val lazyListState = rememberLazyListState()
val reorderableLazyListState =
rememberReorderableLazyListState(lazyListState = lazyListState) { from, to ->
viewModel.movePlaylistItem(from.index, to.index)
}
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
state = lazyListState
) {
itemsIndexed(uiState.playList, key = {_, item -> item.uniqueId}) { index, playlistItem ->
ReorderableItem(
state = reorderableLazyListState,
key = playlistItem.uniqueId
) { isDragging ->
StreamItem(
id = index,
title = playlistItem.title,
creator = playlistItem.creator,
thumbnail = playlistItem.thumbnail,
lengthInMs = playlistItem.lengthInS.toLong() * 1000,
onClicked = { viewModel.streamSelected(it) },
reorderableScope = this@ReorderableItem
)
}
}
}
}
@Preview(device = "id:pixel_5") @Preview(device = "id:pixel_5")
@Composable @Composable
@ -178,21 +203,24 @@ fun VideoPlayerStreamSelectUIPreview() {
title = "Stream 1", title = "Stream 1",
creator = "The Creator", creator = "The Creator",
lengthInS = 6 * 60 + 5, lengthInS = 6 * 60 + 5,
thumbnail = null thumbnail = null,
uniqueId = 0
), ),
PlaylistItem( PlaylistItem(
id = "6502", id = "6502",
title = "Stream 2", title = "Stream 2",
creator = "The Creator 2", creator = "The Creator 2",
lengthInS = 2 * 60 + 5, lengthInS = 2 * 60 + 5,
thumbnail = null thumbnail = null,
uniqueId = 1
), ),
PlaylistItem( PlaylistItem(
id = "6502", id = "6502",
title = "Stream 3", title = "Stream 3",
creator = "The Creator 3", creator = "The Creator 3",
lengthInS = 29 * 60 + 5, lengthInS = 29 * 60 + 5,
thumbnail = null thumbnail = null,
uniqueId = 2
) )
) )
) )

View File

@ -20,33 +20,28 @@
package net.newpipe.newplayer.ui.videoplayer.streamselect package net.newpipe.newplayer.ui.videoplayer.streamselect
import android.view.MotionEvent
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -64,6 +59,7 @@ import net.newpipe.newplayer.utils.Thumbnail
import net.newpipe.newplayer.utils.VectorThumbnail import net.newpipe.newplayer.utils.VectorThumbnail
import net.newpipe.newplayer.utils.getLocale import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs import net.newpipe.newplayer.utils.getTimeStringFromMs
import sh.calvin.reorderable.ReorderableCollectionItemScope
@Composable @Composable
private fun Thumbnail(thumbnail: Thumbnail?, contentDescription: String) { private fun Thumbnail(thumbnail: Thumbnail?, contentDescription: String) {
@ -95,38 +91,6 @@ private fun Thumbnail(thumbnail: Thumbnail?, contentDescription: String) {
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun DragComposable(onDragStart: () -> Unit, onDragEnd: () -> Unit) {
Box(modifier = Modifier
.aspectRatio(1f)
.fillMaxSize()
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_UP -> {
onDragEnd()
false
}
MotionEvent.ACTION_DOWN -> {
onDragStart()
false
}
else -> true
}
}) {
Icon(
modifier = Modifier
.size(25.dp)
.align(Alignment.Center),
imageVector = Icons.Filled.DragHandle,
//contentDescription = stringResource(R.string.stream_item_drag_handle)
contentDescription = "placeholer, TODO: FIXME"
)
}
}
@Composable @Composable
fun StreamItem( fun StreamItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -135,19 +99,19 @@ fun StreamItem(
creator: String?, creator: String?,
thumbnail: Thumbnail?, thumbnail: Thumbnail?,
lengthInMs: Long, lengthInMs: Long,
onDragStart: (Int) -> Unit, onClicked: (Int) -> Unit,
onDragEnd: (Int) -> Unit, reorderableScope: ReorderableCollectionItemScope?
onClicked: (Int) -> Unit
) { ) {
val locale = getLocale()!! val locale = getLocale()!!
Row(modifier = modifier Row(
.clickable { onClicked(id) } modifier = modifier
.padding(5.dp) .padding(5.dp)
.height(IntrinsicSize.Min)) { .height(60.dp)
) {
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.wrapContentSize() .fillMaxSize()
) { ) {
val contentDescription = stringResource(R.string.stream_item_thumbnail) val contentDescription = stringResource(R.string.stream_item_thumbnail)
Thumbnail(thumbnail, contentDescription) Thumbnail(thumbnail, contentDescription)
@ -189,12 +153,30 @@ fun StreamItem(
) )
} }
} }
IconButton(
DragComposable(onDragStart = { onDragStart(id) }, onDragEnd = { onDragEnd(id) }) modifier = if (reorderableScope != null) {
with(reorderableScope) {
Modifier
.aspectRatio(1f)
.fillMaxSize()
.draggableHandle()
}
} else {
Modifier
.aspectRatio(1f)
.fillMaxSize()
},
onClick = {}
) {
Icon(
imageVector = Icons.Filled.DragHandle,
contentDescription = stringResource(R.string.stream_item_drag_handle)
)
}
} }
} }
@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() {
@ -207,9 +189,8 @@ fun StreamItemPreview() {
creator = "Video Creator", creator = "Video Creator",
thumbnail = null, thumbnail = null,
lengthInMs = 15 * 60 * 1000, lengthInMs = 15 * 60 * 1000,
onDragStart = {}, onClicked = {},
onDragEnd = {}, reorderableScope = null
onClicked = {}
) )
} }
} }