Skip to content

Commit

Permalink
Migrate related items fragment to Jetpack Compose (#11383)
Browse files Browse the repository at this point in the history
* Rename .java to .kt

* Migrate related items fragment to Jetpack Compose

* Specify mode parameter explicitly

* Rm unused class

* Fix list item size

* Added stream progress bar, separate stream and playlist thumbnails

* Display message if no related streams are available

* Dispose of related items when closing the video player

* Add modifiers for no items message function

* Implement remaining stream menu items

* Improved stream composables

* Use view model lifecycle scope

* Make live color solid red

* Use nested scroll modifier

* Simplify determineItemViewMode()
Isira-Seneviratne authored Aug 23, 2024
1 parent 9d04a73 commit 2836191
Showing 30 changed files with 940 additions and 395 deletions.
19 changes: 12 additions & 7 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ plugins {
id "kotlin-parcelize"
id "checkstyle"
id "org.sonarqube" version "4.0.0.2929"
id "org.jetbrains.kotlin.plugin.compose" version "${kotlin_version}"
}

android {
@@ -104,10 +105,6 @@ android {
'META-INF/COPYRIGHT']
}
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}
}

ext {
@@ -267,7 +264,7 @@ dependencies {
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"

// Image loading
implementation 'io.coil-kt:coil:2.7.0'
implementation 'io.coil-kt:coil-compose:2.7.0'

// Markdown library for Android
implementation "io.noties.markwon:core:${markwonVersion}"
@@ -289,10 +286,18 @@ dependencies {
implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final"

// Jetpack Compose
implementation(platform('androidx.compose:compose-bom:2024.02.01'))
implementation 'androidx.compose.material3:material3'
implementation(platform('androidx.compose:compose-bom:2024.06.00'))
implementation 'androidx.compose.material3:material3:1.3.0-beta05'
implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-beta04'
implementation 'androidx.activity:activity-compose'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.ui:ui-text:1.7.0-beta07' // Needed for parsing HTML to AnnotatedString
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose'
implementation 'androidx.paging:paging-compose:3.3.2'
implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0'

// Coroutines interop
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.8.1'

/** Debugging **/
// Memory leak detection
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
@@ -27,7 +28,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>

@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>

@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.schabi.newpipe.database.stream.dao;

import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;

import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
@@ -12,9 +15,7 @@
import java.util.List;

import io.reactivex.rxjava3.core.Flowable;

import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
import io.reactivex.rxjava3.core.Maybe;

@Dao
public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
@@ -32,7 +33,7 @@ default Flowable<List<StreamStateEntity>> listByService(final int serviceId) {
}

@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
Flowable<List<StreamStateEntity>> getState(long streamId);
Maybe<StreamStateEntity> getState(long streamId);

@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
int deleteState(long streamId);

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.schabi.newpipe.fragments.list.videos

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.ktx.serializable
import org.schabi.newpipe.ui.components.video.RelatedItems
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_INFO

class RelatedItemsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
}
}
}
}
}

companion object {
@JvmStatic
fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
arguments = bundleOf(KEY_INFO to info)
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -41,10 +41,11 @@
* </p>
*/
public enum StreamDialogDefaultEntry {
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
),
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
final var activity = fragment.requireActivity();
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
}),

/**
* Enqueues the stream automatically to the current PlayerType.
Original file line number Diff line number Diff line change
@@ -64,8 +64,7 @@ public void updateFromItem(final InfoItem infoItem,
StreamStateEntity state2 = null;
if (DependentPreferenceHelper
.getPositionsInListsEnabled(itemProgressView.getContext())) {
state2 = historyRecordManager.loadStreamState(infoItem)
.blockingGet()[0];
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
}
if (state2 != null) {
itemProgressView.setVisibility(View.VISIBLE);
@@ -120,7 +119,7 @@ public void updateState(final InfoItem infoItem,
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
state = historyRecordManager
.loadStreamState(infoItem)
.blockingGet()[0];
.blockingGet();
}
if (state != null && item.getDuration() > 0
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
5 changes: 5 additions & 0 deletions app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
Original file line number Diff line number Diff line change
@@ -3,7 +3,12 @@ package org.schabi.newpipe.ktx
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
import java.io.Serializable

inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}

inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
return BundleCompat.getSerializable(this, key, T::class.java)
}
Original file line number Diff line number Diff line change
@@ -202,6 +202,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
// Menu
// /////////////////////////////////////////////////////////////////////////

@Deprecated("Deprecated in Java")
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)

@@ -212,6 +213,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
inflater.inflate(R.menu.menu_feed_fragment, menu)
}

@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_item_feed_help) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
@@ -253,7 +255,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
viewModel.getShowFutureItemsFromPreferences()
)

AlertDialog.Builder(context!!)
AlertDialog.Builder(requireContext())
.setTitle(R.string.feed_hide_streams_title)
.setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked ->
checkedDialogItems[which] = isChecked
@@ -267,6 +269,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
.show()
}

@Deprecated("Deprecated in Java")
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
activity?.supportActionBar?.subtitle = null
Original file line number Diff line number Diff line change
@@ -18,10 +18,13 @@
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/

import static org.schabi.newpipe.util.ExtractorHelper.getStreamInfo;

import android.content.Context;
import android.content.SharedPreferences;

import androidx.annotation.NonNull;
import androidx.collection.LongLongPair;
import androidx.preference.PreferenceManager;

import org.schabi.newpipe.NewPipeDatabase;
@@ -45,7 +48,6 @@
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.local.feed.FeedViewModel;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.ExtractorHelper;

import java.time.OffsetDateTime;
import java.time.ZoneOffset;
@@ -91,47 +93,39 @@ public HistoryRecordManager(final Context context) {
* @param info the item to mark as watched
* @return a Maybe containing the ID of the item if successful
*/
public Maybe<Long> markAsWatched(final StreamInfoItem info) {
public Completable markAsWatched(final StreamInfoItem info) {
if (!isStreamHistoryEnabled()) {
return Maybe.empty();
return Completable.complete();
}

final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
final long streamId;
final long duration;
// Duration will not exist if the item was loaded with fast mode, so fetch it if empty
if (info.getDuration() < 0) {
final StreamInfo completeInfo = ExtractorHelper.getStreamInfo(
info.getServiceId(),
info.getUrl(),
false
)
.subscribeOn(Schedulers.io())
.blockingGet();
duration = completeInfo.getDuration();
streamId = streamTable.upsert(new StreamEntity(completeInfo));
} else {
duration = info.getDuration();
streamId = streamTable.upsert(new StreamEntity(info));
}

// Update the stream progress to the full duration of the video
final StreamStateEntity entity = new StreamStateEntity(
streamId,
duration * 1000
);
streamStateTable.upsert(entity);
final var remoteInfo = getStreamInfo(info.getServiceId(), info.getUrl(), false)
.map(item ->
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item))));

// Add a history entry
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry == null) {
// never actually viewed: add history entry but with 0 views
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
} else {
return 0L;
}
})).subscribeOn(Schedulers.io());
return Single.just(info)
.filter(item -> item.getDuration() >= 0)
.map(item ->
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item)))
)
.switchIfEmpty(remoteInfo)
.flatMapCompletable(pair -> Completable.fromRunnable(() -> {
final long duration = pair.getFirst();
final long streamId = pair.getSecond();

// Update the stream progress to the full duration of the video
final var entity = new StreamStateEntity(streamId, duration * 1000);
streamStateTable.upsert(entity);

// Add a history entry
final var latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry == null) {
final var currentTime = OffsetDateTime.now(ZoneOffset.UTC);
// never actually viewed: add history entry but with 0 views
final var entry = new StreamHistoryEntity(streamId, currentTime, 0);
streamHistoryTable.insert(entry);
}
}))
.subscribeOn(Schedulers.io());
}

public Maybe<Long> onViewed(final StreamInfo info) {
@@ -221,7 +215,7 @@ public Single<Integer> deleteCompleteSearchHistory() {
public Flowable<List<String>> getRelatedSearches(final String query,
final int similarQueryLimit,
final int uniqueQueryLimit) {
return query.length() > 0
return !query.isEmpty()
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
}
@@ -236,47 +230,31 @@ private boolean isSearchHistoryEnabled() {

public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
return queueItem.getStream()
.map(info -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.flatMapMaybe(this::loadStreamState)
.filter(state -> state.isValid(queueItem.getDuration()))
.subscribeOn(Schedulers.io());
}

public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.filter(state -> state.isValid(info.getDuration()))
.flatMapMaybe(streamStateTable::getState)
.subscribeOn(Schedulers.io());
}

public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
return Completable.fromAction(() -> database.runInTransaction(() -> {
final long streamId = streamTable.upsert(new StreamEntity(info));
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
final var state = new StreamStateEntity(streamId, progressMillis);
if (state.isValid(info.getDuration())) {
streamStateTable.upsert(state);
}
})).subscribeOn(Schedulers.io());
}

public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
return Single.fromCallable(() -> {
final List<StreamEntity> entities = streamTable
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
if (entities.isEmpty()) {
return new StreamStateEntity[]{null};
}
final List<StreamStateEntity> states = streamStateTable
.getState(entities.get(0).getUid()).blockingFirst();
if (states.isEmpty()) {
return new StreamStateEntity[]{null};
}
return new StreamStateEntity[]{states.get(0)};
}).subscribeOn(Schedulers.io());
public Maybe<StreamStateEntity> loadStreamState(final InfoItem info) {
return streamTable.getStream(info.getServiceId(), info.getUrl())
.flatMap(entity -> streamStateTable.getState(entity.getUid()))
.subscribeOn(Schedulers.io());
}

public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
@@ -295,13 +273,7 @@ public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
result.add(null);
continue;
}
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
.blockingFirst();
if (states.isEmpty()) {
result.add(null);
} else {
result.add(states.get(0));
}
result.add(streamStateTable.getState(streamId).blockingGet());
}
return result;
}).subscribeOn(Schedulers.io());
Original file line number Diff line number Diff line change
@@ -129,6 +129,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
// Menu
// ////////////////////////////////////////////////////////////////////////

@Deprecated("Deprecated in Java")
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)

Original file line number Diff line number Diff line change
@@ -94,6 +94,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return object : Dialog(requireActivity(), theme) {
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (!this@FeedGroupDialog.onBackPressed()) {
super.onBackPressed()
Original file line number Diff line number Diff line change
@@ -77,11 +77,13 @@ class NotificationModeConfigFragment : Fragment() {
super.onDestroy()
}

@Deprecated("Deprecated in Java")
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.menu_notifications_channels, menu)
}

@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_toggle_all -> {
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.schabi.newpipe.ui.components.common

import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.MaterialTheme
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.theme.AppTheme

@Composable
fun NoItemsMessage(@StringRes message: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "(╯°-°)╯", fontSize = 35.sp)
Text(text = stringResource(id = message), fontSize = 24.sp)
}
}

@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun NoItemsMessagePreview() {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
NoItemsMessage(message = R.string.no_videos)
}
}
}
114 changes: 114 additions & 0 deletions app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package org.schabi.newpipe.ui.components.items

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentActivity
import androidx.preference.PreferenceManager
import androidx.window.core.layout.WindowWidthSizeClass
import my.nanihadesuka.compose.LazyColumnScrollbar
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem
import org.schabi.newpipe.ui.components.items.stream.StreamListItem
import org.schabi.newpipe.util.DependentPreferenceHelper
import org.schabi.newpipe.util.NavigationHelper

@Composable
fun ItemList(
items: List<InfoItem>,
mode: ItemViewMode = determineItemViewMode(),
listHeader: LazyListScope.() -> Unit = {}
) {
val context = LocalContext.current
val onClick = remember {
{ item: InfoItem ->
val fragmentManager = (context as FragmentActivity).supportFragmentManager
if (item is StreamInfoItem) {
NavigationHelper.openVideoDetailFragment(
context, fragmentManager, item.serviceId, item.url, item.name, null, false
)
} else if (item is PlaylistInfoItem) {
NavigationHelper.openPlaylistFragment(
fragmentManager, item.serviceId, item.url, item.name
)
}
}
}

// Handle long clicks for stream items
// TODO: Adjust the menu display depending on where it was triggered
var selectedStream by remember { mutableStateOf<StreamInfoItem?>(null) }
val onLongClick = remember {
{ stream: StreamInfoItem ->
selectedStream = stream
}
}
val onDismissPopup = remember {
{
selectedStream = null
}
}

val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context)
val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())

if (mode == ItemViewMode.GRID) {
// TODO: Implement grid layout using LazyVerticalGrid and LazyVerticalGridScrollbar.
} else {
val state = rememberLazyListState()

LazyColumnScrollbar(state = state) {
LazyColumn(modifier = nestedScrollModifier, state = state) {
listHeader()

items(items.size) {
val item = items[it]

if (item is StreamInfoItem) {
val isSelected = selectedStream == item
StreamListItem(
item, showProgress, isSelected, onClick, onLongClick, onDismissPopup
)
} else if (item is PlaylistInfoItem) {
PlaylistListItem(item, onClick)
}
}
}
}
}
}

@Composable
private fun determineItemViewMode(): ItemViewMode {
val prefValue = PreferenceManager.getDefaultSharedPreferences(LocalContext.current)
.getString(stringResource(R.string.list_view_mode_key), null)
val viewMode = prefValue?.let { ItemViewMode.valueOf(it.uppercase()) } ?: ItemViewMode.AUTO

return when (viewMode) {
ItemViewMode.AUTO -> {
// Evaluate whether to use Grid based on screen real estate.
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
ItemViewMode.GRID
} else {
ItemViewMode.LIST
}
}
else -> viewMode
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.schabi.newpipe.ui.components.items.playlist

import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.NO_SERVICE_ID

@Composable
fun PlaylistListItem(
playlist: PlaylistInfoItem,
onClick: (InfoItem) -> Unit = {},
) {
Row(
modifier = Modifier
.clickable { onClick(playlist) }
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlaylistThumbnail(
playlist = playlist,
modifier = Modifier.size(width = 140.dp, height = 78.dp)
)

Column {
Text(
text = playlist.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 2
)

Text(
text = playlist.uploaderName.orEmpty(),
style = MaterialTheme.typography.bodySmall
)
}
}
}

@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaylistListItemPreview() {
val playlist = PlaylistInfoItem(NO_SERVICE_ID, "", "Playlist")
playlist.uploaderName = "Uploader"

AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
PlaylistListItem(playlist)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.schabi.newpipe.ui.components.items.playlist

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.image.ImageStrategy

@Composable
fun PlaylistThumbnail(
playlist: PlaylistInfoItem,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
Box(contentAlignment = Alignment.BottomEnd) {
AsyncImage(
model = ImageStrategy.choosePreferredImage(playlist.thumbnails),
contentDescription = null,
placeholder = painterResource(R.drawable.placeholder_thumbnail_playlist),
error = painterResource(R.drawable.placeholder_thumbnail_playlist),
contentScale = contentScale,
modifier = modifier
)

Row(
modifier = Modifier
.padding(2.dp)
.background(Color.Black.copy(alpha = 0.5f))
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.ic_playlist_play),
contentDescription = null,
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.size(18.dp)
)

val context = LocalContext.current
Text(
text = Localization.localizeStreamCountMini(context, playlist.streamCount),
color = Color.White,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package org.schabi.newpipe.ui.components.items.stream

import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.ui.theme.AppTheme

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StreamListItem(
stream: StreamInfoItem,
showProgress: Boolean,
isSelected: Boolean,
onClick: (StreamInfoItem) -> Unit = {},
onLongClick: (StreamInfoItem) -> Unit = {},
onDismissPopup: () -> Unit = {}
) {
// Box serves as an anchor for the dropdown menu
Box(
modifier = Modifier
.combinedClickable(onLongClick = { onLongClick(stream) }, onClick = { onClick(stream) })
.fillMaxWidth()
.padding(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
StreamThumbnail(
stream = stream,
showProgress = showProgress,
modifier = Modifier.size(width = 140.dp, height = 78.dp)
)

Column {
Text(
text = stream.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 2
)

Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall)

Text(
text = getStreamInfoDetail(stream),
style = MaterialTheme.typography.bodySmall
)
}
}

StreamMenu(stream, isSelected, onDismissPopup)
}
}

@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StreamListItemPreview(
@PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamListItem(stream, showProgress = false, isSelected = false)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package org.schabi.newpipe.ui.components.items.stream

import androidx.annotation.StringRes
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import org.schabi.newpipe.R
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.download.DownloadDialog
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog
import org.schabi.newpipe.local.dialog.PlaylistDialog
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.SparseItemUtil
import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.viewmodels.StreamViewModel

@Composable
fun StreamMenu(
stream: StreamInfoItem,
expanded: Boolean,
onDismissRequest: () -> Unit
) {
val context = LocalContext.current
val streamViewModel = viewModel<StreamViewModel>()
val playerHolder = PlayerHolder.getInstance()

DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
if (playerHolder.isPlayQueueReady) {
StreamMenuItem(
text = R.string.enqueue_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.enqueueOnPlayer(context, it)
}
}
)

if (playerHolder.queuePosition < playerHolder.queueSize - 1) {
StreamMenuItem(
text = R.string.enqueue_next_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.enqueueNextOnPlayer(context, it)
}
}
)
}
}

StreamMenuItem(
text = R.string.start_here_on_background,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.playOnBackgroundPlayer(context, it, true)
}
}
)
StreamMenuItem(
text = R.string.start_here_on_popup,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.playOnPopupPlayer(context, it, true)
}
}
)
StreamMenuItem(
text = R.string.download,
onClick = {
onDismissRequest()
SparseItemUtil.fetchStreamInfoAndSaveToDatabase(
context, stream.serviceId, stream.url
) { info ->
// TODO: Use an AlertDialog composable instead.
val downloadDialog = DownloadDialog(context, info)
val fragmentManager = (context as FragmentActivity).supportFragmentManager
downloadDialog.show(fragmentManager, "downloadDialog")
}
}
)
StreamMenuItem(
text = R.string.add_to_playlist,
onClick = {
onDismissRequest()
val list = listOf(StreamEntity(stream))
PlaylistDialog.createCorrespondingDialog(context, list) { dialog ->
val tag = if (dialog is PlaylistAppendDialog) "append" else "create"
dialog.show(
(context as FragmentActivity).supportFragmentManager,
"StreamDialogEntry@${tag}_playlist"
)
}
}
)
StreamMenuItem(
text = R.string.share,
onClick = {
onDismissRequest()
ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails)
}
)
StreamMenuItem(
text = R.string.open_in_browser,
onClick = {
onDismissRequest()
ShareUtils.openUrlInBrowser(context, stream.url)
}
)
StreamMenuItem(
text = R.string.mark_as_watched,
onClick = {
onDismissRequest()
streamViewModel.markAsWatched(stream)
}
)
StreamMenuItem(
text = R.string.show_channel_details,
onClick = {
onDismissRequest()
SparseItemUtil.fetchUploaderUrlIfSparse(
context, stream.serviceId, stream.url, stream.uploaderUrl
) { url ->
NavigationHelper.openChannelFragment(context as FragmentActivity, stream, url)
}
}
)
}
}

@Composable
private fun StreamMenuItem(
@StringRes text: Int,
onClick: () -> Unit
) {
DropdownMenuItem(
text = {
Text(text = stringResource(text), color = MaterialTheme.colorScheme.onBackground)
},
onClick = onClick
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.schabi.newpipe.ui.components.items.stream

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.StreamTypeUtil
import org.schabi.newpipe.util.image.ImageStrategy
import org.schabi.newpipe.viewmodels.StreamViewModel
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

@Composable
fun StreamThumbnail(
stream: StreamInfoItem,
showProgress: Boolean,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
Column(modifier = modifier) {
Box(contentAlignment = Alignment.BottomEnd) {
AsyncImage(
model = ImageStrategy.choosePreferredImage(stream.thumbnails),
contentDescription = null,
placeholder = painterResource(R.drawable.placeholder_thumbnail_video),
error = painterResource(R.drawable.placeholder_thumbnail_video),
contentScale = contentScale,
modifier = modifier
)

val isLive = StreamTypeUtil.isLiveStream(stream.streamType)
Text(
modifier = Modifier
.padding(2.dp)
.background(if (isLive) Color.Red else Color.Black.copy(alpha = 0.5f))
.padding(2.dp),
text = if (isLive) {
stringResource(R.string.duration_live)
} else {
Localization.getDurationString(stream.duration)
},
color = Color.White,
style = MaterialTheme.typography.bodySmall
)
}

if (showProgress) {
val streamViewModel = viewModel<StreamViewModel>()
var progress by rememberSaveable { mutableLongStateOf(0L) }

LaunchedEffect(stream) {
progress = streamViewModel.getStreamState(stream)?.progressMillis ?: 0L
}

if (progress != 0L) {
LinearProgressIndicator(
modifier = Modifier.requiredHeight(2.dp),
progress = {
(progress.milliseconds / stream.duration.seconds).toFloat()
},
gapSize = 0.dp,
drawStopIndicator = {} // Hide stop indicator
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.schabi.newpipe.ui.components.items.stream

import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NO_SERVICE_ID
import java.util.concurrent.TimeUnit

fun StreamInfoItem(
serviceId: Int = NO_SERVICE_ID,
url: String = "",
name: String = "Stream",
streamType: StreamType,
uploaderName: String? = "Uploader",
uploaderUrl: String? = null,
uploaderAvatars: List<Image> = emptyList(),
duration: Long = TimeUnit.HOURS.toSeconds(1),
viewCount: Long = 10,
textualUploadDate: String = "1 month ago"
) = StreamInfoItem(serviceId, url, name, streamType).apply {
this.uploaderName = uploaderName
this.uploaderUrl = uploaderUrl
this.uploaderAvatars = uploaderAvatars
this.duration = duration
this.viewCount = viewCount
this.textualUploadDate = textualUploadDate
}

@Composable
internal fun getStreamInfoDetail(stream: StreamInfoItem): String {
val context = LocalContext.current

return rememberSaveable(stream) {
val count = stream.viewCount
val views = if (count >= 0) {
when (stream.streamType) {
StreamType.AUDIO_LIVE_STREAM -> Localization.listeningCount(context, count)
StreamType.LIVE_STREAM -> Localization.shortWatchingCount(context, count)
else -> Localization.shortViewCount(context, count)
}
} else {
""
}
val date =
Localization.relativeTimeOrTextual(context, stream.uploadDate, stream.textualUploadDate)

if (views.isEmpty()) {
date.orEmpty()
} else if (date.isNullOrEmpty()) {
views
} else {
"$views$date"
}
}
}

internal class StreamItemPreviewProvider : PreviewParameterProvider<StreamInfoItem> {
override val values = sequenceOf(
StreamInfoItem(streamType = StreamType.NONE),
StreamInfoItem(streamType = StreamType.LIVE_STREAM),
StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package org.schabi.newpipe.ui.components.video

import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.ui.components.common.NoItemsMessage
import org.schabi.newpipe.ui.components.items.ItemList
import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.NO_SERVICE_ID

@Composable
fun RelatedItems(info: StreamInfo) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(LocalContext.current)
val key = stringResource(R.string.auto_queue_key)
// TODO: AndroidX DataStore might be a better option.
var isAutoQueueEnabled by rememberSaveable {
mutableStateOf(sharedPreferences.getBoolean(key, false))
}

if (info.relatedItems.isEmpty()) {
NoItemsMessage(message = R.string.no_videos)
} else {
ItemList(
items = info.relatedItems,
mode = ItemViewMode.LIST,
listHeader = {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = stringResource(R.string.auto_queue_description))

Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.auto_queue_toggle))
Switch(
checked = isAutoQueueEnabled,
onCheckedChange = {
isAutoQueueEnabled = it
sharedPreferences.edit {
putBoolean(key, it)
}
}
)
}
}
}
}
)
}
}

@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun RelatedItemsPreview() {
val info = StreamInfo(NO_SERVICE_ID, "", "", StreamType.VIDEO_STREAM, "", "", 0)
info.relatedItems = listOf(
StreamInfoItem(streamType = StreamType.NONE),
StreamInfoItem(streamType = StreamType.LIVE_STREAM),
StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM),
)

AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
RelatedItems(info)
}
}
}
1 change: 1 addition & 0 deletions app/src/main/java/org/schabi/newpipe/util/Constants.kt
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ const val DEFAULT_THROTTLE_TIMEOUT = 120L

const val KEY_SERVICE_ID = "key_service_id"
const val KEY_URL = "key_url"
const val KEY_INFO = "info"
const val KEY_TITLE = "key_title"
const val KEY_LINK_TYPE = "key_link_type"
const val KEY_OPEN_SEARCH = "key_open_search"
Original file line number Diff line number Diff line change
@@ -472,13 +472,12 @@ public static void openChannelFragment(final FragmentManager fragmentManager,
.commit();
}

public static void openChannelFragment(@NonNull final Fragment fragment,
public static void openChannelFragment(@NonNull final FragmentActivity activity,
@NonNull final StreamInfoItem item,
final String uploaderUrl) {
// For some reason `getParentFragmentManager()` doesn't work, but this does.
openChannelFragment(
fragment.requireActivity().getSupportFragmentManager(),
item.getServiceId(), uploaderUrl, item.getUploaderName());
openChannelFragment(activity.getSupportFragmentManager(), item.getServiceId(), uploaderUrl,
item.getUploaderName());
}

/**
26 changes: 26 additions & 0 deletions app/src/main/java/org/schabi/newpipe/viewmodels/StreamViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.schabi.newpipe.viewmodels

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.rx3.awaitSingleOrNull
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.history.HistoryRecordManager

class StreamViewModel(application: Application) : AndroidViewModel(application) {
private val historyRecordManager = HistoryRecordManager(application)

suspend fun getStreamState(infoItem: InfoItem): StreamStateEntity? {
return historyRecordManager.loadStreamState(infoItem).awaitSingleOrNull()
}

fun markAsWatched(stream: StreamInfoItem) {
viewModelScope.launch {
historyRecordManager.markAsWatched(stream).await()
}
}
}
70 changes: 0 additions & 70 deletions app/src/main/res/layout/fragment_related_items.xml

This file was deleted.

33 changes: 0 additions & 33 deletions app/src/main/res/layout/related_items_header.xml

This file was deleted.

1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -855,4 +855,5 @@
<string name="show_more">Show more</string>
<string name="show_less">Show less</string>
<string name="import_settings_vulnerable_format">The settings in the export being imported use a vulnerable format that was deprecated since NewPipe 0.27.0. Make sure the export being imported is from a trusted source, and prefer using only exports obtained from NewPipe 0.27.0 or newer in the future. Support for importing settings in this vulnerable format will soon be removed completely, and then old versions of NewPipe will not be able to import settings of exports from new versions anymore.</string>
<string name="auto_queue_description">Next</string>
</resources>
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
ext.kotlin_version = '1.9.10'
ext.kotlin_version = '2.0.0'
repositories {
google()
mavenCentral()

0 comments on commit 2836191

Please sign in to comment.