From 3d0fdabcf4c8e9613ddfeaf058ef55379659d307 Mon Sep 17 00:00:00 2001 From: Christian Schabesberger Date: Tue, 20 Aug 2024 12:36:23 +0200 Subject: [PATCH] create initial design for playlist stream icons and chapter icons --- gradle/libs.versions.toml | 2 + new-player/build.gradle.kts | 1 + .../newplayer/model/VideoPlayerUIState.kt | 5 +- .../newplayer/ui/videoplayer/BottomUI.kt | 38 +-- .../ui/videoplayer/StreamSelectUI.kt | 266 +++++++++++++++++- .../java/net/newpipe/newplayer/utils/utils.kt | 41 +++ .../res/drawable/ic_play_seek_triangle.xml | 20 ++ .../main/res/drawable/tiny_placeholder.xml | 27 ++ new-player/src/main/res/values/strings.xml | 5 + 9 files changed, 365 insertions(+), 40 deletions(-) create mode 100644 new-player/src/main/res/drawable/tiny_placeholder.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 756960f..79c6dda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/new-player/build.gradle.kts b/new-player/build.gradle.kts index 263fa04..da7d33d 100644 --- a/new-player/build.gradle.kts +++ b/new-player/build.gradle.kts @@ -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) diff --git a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt index 4e75109..61c91c6 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt @@ -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, diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/BottomUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/BottomUI.kt index c67e04a..854732b 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/BottomUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/BottomUI.kt @@ -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 /////////////////////////////////////////////////////////////////// diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt index ede50da..5952125 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/StreamSelectUI.kt @@ -1,17 +1,69 @@ +/* NewPlayer + * + * @author Christian Schabesberger + * + * Copyright (C) NewPipe e.V. 2024 + * + * NewPlayer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPlayer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPlayer. If not, see . + */ + package net.newpipe.newplayer.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 ) diff --git a/new-player/src/main/java/net/newpipe/newplayer/utils/utils.kt b/new-player/src/main/java/net/newpipe/newplayer/utils/utils.kt index 706c456..3fbf21a 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/utils/utils.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/utils/utils.kt @@ -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 +} diff --git a/new-player/src/main/res/drawable/ic_play_seek_triangle.xml b/new-player/src/main/res/drawable/ic_play_seek_triangle.xml index 9c257c4..9c65f5e 100644 --- a/new-player/src/main/res/drawable/ic_play_seek_triangle.xml +++ b/new-player/src/main/res/drawable/ic_play_seek_triangle.xml @@ -1,3 +1,23 @@ + + + + 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 . +--> + + + + + + + + diff --git a/new-player/src/main/res/values/strings.xml b/new-player/src/main/res/values/strings.xml index 87a2d21..84754bf 100644 --- a/new-player/src/main/res/values/strings.xml +++ b/new-player/src/main/res/values/strings.xml @@ -40,4 +40,9 @@ Seconds Volume indicator Brightness indicator + Close chapter selection + Close stream selection + Chapter + Chapter Thumbnail + Stream item drag handle \ No newline at end of file