create initial design for playlist stream icons and chapter icons

This commit is contained in:
Christian Schabesberger 2024-08-20 12:36:23 +02:00
parent 8ac9a5a6ff
commit 3d0fdabcf4
9 changed files with 365 additions and 40 deletions

View File

@ -42,6 +42,7 @@ lifecycleRuntimeKtx = "2.8.3"
kotlinParcelize = "2.0.20-Beta2"
newplayer = "master-SNAPSHOT"
okhttpAndroid = "5.0.0-alpha.14"
coil = "2.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -75,6 +76,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
newplayer = { group = "com.github.theScrabi.NewPlayer", name = "new-player", version.ref = "newplayer" }
okhttp-android = { group = "com.squareup.okhttp3", name = "okhttp-android", version.ref = "okhttpAndroid" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }

View File

@ -66,6 +66,7 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.media3.common)
implementation(libs.coil.compose)
ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler)

View File

@ -44,7 +44,10 @@ data class VideoPlayerUIState(
) : Parcelable {
companion object {
val DEFAULT = VideoPlayerUIState(
uiMode = UIModeState.PLACEHOLDER,
// TODO: replace this with the placeholder state.
// The actual initial state upon starting to play is dictated by the NewPlayer instance
uiMode = UIModeState.EMBEDDED_VIDEO,
//uiMode = UIModeState.PLACEHOLDER,
playing = false,
contentRatio = 16 / 9f,
embeddedUiRatio = 16f / 9f,

View File

@ -51,6 +51,8 @@ import net.newpipe.newplayer.ui.seeker.Seeker
import net.newpipe.newplayer.ui.seeker.SeekerColors
import net.newpipe.newplayer.ui.seeker.SeekerDefaults
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs
import java.util.Locale
import kotlin.math.min
@ -104,42 +106,6 @@ private fun customizedSeekerColors(): SeekerColors {
return colors
}
@Composable
@ReadOnlyComposable
fun getLocale(): Locale? {
val configuration = LocalConfiguration.current
return ConfigurationCompat.getLocales(configuration).get(0)
}
private const val HOURS_PER_DAY = 24
private const val MINUTES_PER_HOUR = 60
private const val SECONDS_PER_MINUTE = 60
private const val MILLIS_PER_SECOND = 1000
private const val MILLIS_PER_DAY =
HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
private const val MILLIS_PER_HOUR = MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
private const val MILLIS_PER_MINUTE = SECONDS_PER_MINUTE * MILLIS_PER_SECOND
private fun getTimeStringFromMs(timeSpanInMs: Long, locale: Locale): String {
val days = timeSpanInMs / MILLIS_PER_DAY
val millisThisDay = timeSpanInMs - days * MILLIS_PER_DAY
val hours = millisThisDay / MILLIS_PER_HOUR
val millisThisHour = millisThisDay - hours * MILLIS_PER_HOUR
val minutes = millisThisHour / MILLIS_PER_MINUTE
val milliesThisMinute = millisThisHour - minutes * MILLIS_PER_MINUTE
val seconds = milliesThisMinute / MILLIS_PER_SECOND
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)
return time_string
}
///////////////////////////////////////////////////////////////////
// Preview
///////////////////////////////////////////////////////////////////

View File

@ -1,17 +1,69 @@
/* 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
import android.view.MotionEvent
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
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 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
@Composable
fun StreamSelectUI(
@ -22,25 +74,233 @@ fun StreamSelectUI(
Surface(modifier = Modifier.fillMaxSize(), color = CONTROLLER_UI_BACKGROUND_COLOR) {
Scaffold(
topBar = {
if (isChapterSelect) {
ChapterSelectTopBar(onClose = {
TODO("implement me")
})
} else {
StreamSelectTopBar()
}
}
) { innerPadding ->
Surface(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(), color = Color.Red
) {
}
) {
}
}
}
@Composable
private fun ChapterSelectTopBar(modifier: Modifier = Modifier, onClose: () -> Unit) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Spacer(modifier = Modifier.width(20.dp))
//Text(stringResource(R.string.chapter))
Text("Chapter TODO")
Spacer(modifier = Modifier.weight(1F))
IconButton(
onClick = onClose
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.close_chapter_selection)
)
}
}
}
private fun ChapterSelectTopBar() {
@Composable
private fun StreamSelectTopBar() {
}
@Composable
private fun ChapterItem(
modifier: Modifier = Modifier,
id: Int,
thumbnailUrl: String?,
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
)
.clickable { onClicked(id) }
) {
if (thumbnailUrl != null) {
AsyncImage(
model = thumbnailUrl,
contentDescription = stringResource(R.string.chapter)
)
} 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?,
thumbnailUrl: String?,
lengthInMs: Long,
onDragStart: (Int) -> Unit,
onDragEnd: (Int) -> Unit,
onClicked: (Int) -> Unit
) {
val locale = getLocale()!!
Row(modifier = modifier.clickable { onClicked(id) }) {
Box {
if (thumbnailUrl != null) {
AsyncImage(
model = thumbnailUrl,
contentDescription = stringResource(R.string.chapter)
)
} 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=300px,dpi=440,orientation=landscape")
@Composable
fun ChapterItemPreview() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.DarkGray) {
ChapterItem(
id = 0,
thumbnailUrl = 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",
thumbnailUrl = 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() {
VideoPlayerTheme {
Surface(modifier = Modifier.fillMaxSize(), color = Color.Green) {
StreamSelectUI(
isChapterSelect = true,
viewModel = VideoPlayerViewModelDummy(),
uiState = VideoPlayerUIState.DEFAULT
)

View File

@ -27,7 +27,11 @@ import android.content.ContextWrapper
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.core.os.ConfigurationCompat
import java.util.Locale
@Composable
fun LockScreenOrientation(orientation: Int) {
@ -63,3 +67,40 @@ fun Context.findActivity(): Activity? = when (this) {
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@Composable
@ReadOnlyComposable
fun getLocale(): Locale? {
val configuration = LocalConfiguration.current
return ConfigurationCompat.getLocales(configuration).get(0)
}
private const val HOURS_PER_DAY = 24
private const val MINUTES_PER_HOUR = 60
private const val SECONDS_PER_MINUTE = 60
private const val MILLIS_PER_SECOND = 1000
private const val MILLIS_PER_DAY =
HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
private const val MILLIS_PER_HOUR = MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
private const val MILLIS_PER_MINUTE = SECONDS_PER_MINUTE * MILLIS_PER_SECOND
fun getTimeStringFromMs(timeSpanInMs: Long, locale: Locale): String {
val days = timeSpanInMs / MILLIS_PER_DAY
val millisThisDay = timeSpanInMs - days * MILLIS_PER_DAY
val hours = millisThisDay / MILLIS_PER_HOUR
val millisThisHour = millisThisDay - hours * MILLIS_PER_HOUR
val minutes = millisThisHour / MILLIS_PER_MINUTE
val milliesThisMinute = millisThisHour - minutes * MILLIS_PER_MINUTE
val seconds = milliesThisMinute / MILLIS_PER_SECOND
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)
return time_string
}

View File

@ -1,3 +1,23 @@
<!-- 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/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="20dp"

View File

@ -0,0 +1,27 @@
<!-- 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/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="720dp" android:viewportHeight="190.5" android:viewportWidth="338.67" android:width="1280dp">
<path android:fillAlpha="0.2358" android:fillColor="#000000" android:pathData="M0,0h338.67v190.5h-338.67z" android:strokeColor="#00000000" android:strokeWidth="0.264583"/>
<path android:fillAlpha="0.671068" android:fillColor="#ffffff" android:pathData="M125.56,40.66V149.84l91.77,-52.99z" android:strokeColor="#00000000" android:strokeWidth="0.264583"/>
</vector>

View File

@ -40,4 +40,9 @@
<string name="seconds">Seconds</string>
<string name="volume_indicator">Volume indicator</string>
<string name="brightness_indicator">Brightness indicator</string>
<string name="close_chapter_selection">Close chapter selection</string>
<string name="close_stream_selection">Close stream selection</string>
<string name="chapter">Chapter</string>
<string name="chapter_thumbnail">Chapter Thumbnail</string>
<string name="stream_item_drag_handle">Stream item drag handle</string>
</resources>