Merge pull request #11947 from Isira-Seneviratne/History-Compose

Migrate watch history to Jetpack Compose
This commit is contained in:
Isira Seneviratne
2026-07-10 05:49:58 +05:30
committed by GitHub
132 changed files with 1426 additions and 1527 deletions
+1
View File
@@ -259,6 +259,7 @@ dependencies {
implementation(libs.androidx.media)
implementation(libs.androidx.preference)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.rxjava3)
ksp(libs.androidx.room.compiler)
@@ -6,6 +6,9 @@ import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.core.Single
import java.io.IOException
import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.rx3.await
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -106,14 +109,10 @@ class FeedDAOTest {
)
}
private fun setupUnlinkDelete(time: String) {
private fun setupUnlinkDelete(time: String) = runBlocking(Dispatchers.IO) {
clearAndFillTables()
Single.fromCallable {
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
}.blockingSubscribe()
Single.fromCallable {
streamDAO.deleteOrphans()
}.blockingSubscribe()
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
streamDAO.deleteOrphans().await()
}
private fun clearAndFillTables() {
@@ -119,7 +119,7 @@ abstract class FeedDAO {
AND s.upload_date <> max_upload_date))
"""
)
abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
abstract suspend fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
@Query(
"""
@@ -17,7 +17,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
foreignKeys = [
ForeignKey(
entity = StreamEntity::class,
parentColumns = [StreamEntity.STREAM_ID],
parentColumns = ["uid"],
childColumns = [STREAM_ID],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
@@ -1,61 +1,66 @@
/*
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.database.history.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.RawQuery
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.BasicDAO
import io.reactivex.rxjava3.core.Single
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.local.history.SortKey
@Dao
abstract class StreamHistoryDAO : BasicDAO<StreamHistoryEntity> {
interface StreamHistoryDAO {
@Insert
fun insert(entity: StreamHistoryEntity): Long
@Query("SELECT * FROM stream_history")
abstract override fun getAll(): Flowable<List<StreamHistoryEntity>>
@Delete
fun delete(entity: StreamHistoryEntity)
@Query("DELETE FROM stream_history")
abstract override fun deleteAll(): Int
fun deleteAll(): Completable
override fun listByService(serviceId: Int): Flowable<List<StreamHistoryEntity>> {
throw UnsupportedOperationException()
}
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
abstract val history: Flowable<MutableList<StreamHistoryEntry>>
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
abstract val historySortedById: Flowable<MutableList<StreamHistoryEntry>>
@Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
fun getHistorySortedById(): Flowable<List<StreamHistoryEntry>>
@Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1")
abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity?
fun getLatestEntry(streamId: Long): StreamHistoryEntity?
@Query("DELETE FROM stream_history WHERE stream_id = :streamId")
abstract fun deleteStreamHistory(streamId: Long): Int
fun deleteStreamHistory(streamId: Long): Completable
// Select the latest entry and watch count for each stream id on history table
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT * FROM streams
@Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
fun getHistory(): Flowable<List<StreamHistoryEntry>>
INNER JOIN (
SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount
FROM stream_history
GROUP BY stream_id
)
ON uid = stream_id
LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
ON uid = stream_id_alias
"""
@RawQuery(
observedEntities = [StreamStatisticsEntry::class, StreamEntity::class, StreamHistoryEntity::class]
)
abstract fun getStatistics(): Flowable<MutableList<StreamStatisticsEntry>>
fun getOrderedHistoryByRaw(query: SupportSQLiteQuery): PagingSource<Int, StreamStatisticsEntry>
fun getOrderedHistory(key: SortKey): PagingSource<Int, StreamStatisticsEntry> {
val orderBy = when (key) {
SortKey.LAST_PLAYED -> "latestAccess"
SortKey.MOST_PLAYED -> "watchCount"
}
return getOrderedHistoryByRaw(
SimpleSQLiteQuery(
"""SELECT * FROM streams INNER JOIN
(SELECT stream_id, MAX(access_date) AS latestAccess,
SUM(repeat_count) AS watchCount FROM stream_history GROUP BY stream_id)
ON uid = stream_id LEFT JOIN
(SELECT stream_id AS stream_id_alias, progress_time FROM stream_state)
ON uid = stream_id_alias
ORDER BY $orderBy DESC
""".trimIndent()
)
)
}
}
@@ -12,11 +12,7 @@ import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import java.time.OffsetDateTime
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
/**
* @param streamUid the stream id this history item will refer to
@@ -24,33 +20,26 @@ import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
* @param repeatCount the total number of views this stream received
*/
@Entity(
tableName = STREAM_HISTORY_TABLE,
primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE],
indices = [Index(value = [JOIN_STREAM_ID])],
tableName = "stream_history",
primaryKeys = ["stream_id", "access_date"],
indices = [Index(value = ["stream_id"])],
foreignKeys = [
ForeignKey(
entity = StreamEntity::class,
parentColumns = arrayOf(STREAM_ID),
childColumns = arrayOf(JOIN_STREAM_ID),
parentColumns = arrayOf("uid"),
childColumns = arrayOf("stream_id"),
onDelete = CASCADE,
onUpdate = CASCADE
)
]
)
data class StreamHistoryEntity(
@ColumnInfo(name = JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamUid: Long,
@ColumnInfo(name = STREAM_ACCESS_DATE)
@ColumnInfo(name = "access_date")
var accessDate: OffsetDateTime,
@ColumnInfo(name = STREAM_REPEAT_COUNT)
@ColumnInfo(name = "repeat_count")
var repeatCount: Long
) {
companion object {
const val STREAM_HISTORY_TABLE: String = "stream_history"
const val STREAM_ACCESS_DATE: String = "access_date"
const val JOIN_STREAM_ID: String = "stream_id"
const val STREAM_REPEAT_COUNT: String = "repeat_count"
}
}
)
@@ -11,13 +11,13 @@ data class StreamHistoryEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamId: Long,
@ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
@ColumnInfo(name = "access_date")
val accessDate: OffsetDateTime,
@ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
@ColumnInfo(name = "repeat_count")
val repeatCount: Long
) {
@@ -11,7 +11,6 @@ import androidx.room.Embedded
import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
@@ -19,7 +18,7 @@ data class PlaylistStreamEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0")
@ColumnInfo(name = "progress_time", defaultValue = "0")
val progressMillis: Long,
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
@@ -37,7 +37,7 @@ import org.schabi.newpipe.database.stream.model.StreamEntity
),
ForeignKey(
entity = StreamEntity::class,
parentColumns = arrayOf(StreamEntity.STREAM_ID),
parentColumns = arrayOf("uid"),
childColumns = arrayOf(JOIN_STREAM_ID),
onDelete = CASCADE,
onUpdate = CASCADE,
@@ -11,9 +11,7 @@ import androidx.room.Embedded
import androidx.room.Ignore
import java.time.OffsetDateTime
import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
@@ -21,16 +19,16 @@ data class StreamStatisticsEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0")
@ColumnInfo(name = "progress_time", defaultValue = "0")
val progressMillis: Long,
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamId: Long,
@ColumnInfo(name = STREAM_LATEST_DATE)
@ColumnInfo(name = "latestAccess")
val latestAccessDate: OffsetDateTime,
@ColumnInfo(name = STREAM_WATCH_COUNT)
@ColumnInfo(name = "watchCount")
val watchCount: Long
) : LocalItem {
@@ -51,9 +49,4 @@ data class StreamStatisticsEntry(
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
}
}
companion object {
const val STREAM_LATEST_DATE = "latestAccess"
const val STREAM_WATCH_COUNT = "watchCount"
}
}
@@ -3,12 +3,11 @@ package org.schabi.newpipe.database.stream
import androidx.room.ColumnInfo
import androidx.room.Embedded
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
data class StreamWithState(
@Embedded
val stream: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS)
@ColumnInfo(name = "progress_time")
val stateProgressMillis: Long?
)
@@ -12,7 +12,6 @@ import io.reactivex.rxjava3.core.Maybe
import java.time.OffsetDateTime
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
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.util.StreamTypeUtil
@@ -30,9 +29,6 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
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
@Insert(onConflict = OnConflictStrategy.IGNORE)
internal abstract fun silentInsertInternal(stream: StreamEntity): Long
@@ -122,28 +118,28 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
WHERE f.stream_id = streams.uid)
"""
)
abstract fun deleteOrphans(): Int
abstract fun deleteOrphans(): Completable
/**
* Minimal entry class used when comparing/updating an existent stream.
*/
internal data class StreamCompareFeed(
@ColumnInfo(name = STREAM_ID)
@ColumnInfo(name = "uid")
var uid: Long = 0,
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
@ColumnInfo(name = "stream_type")
var streamType: StreamType,
@ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE)
@ColumnInfo(name = "textual_upload_date")
var textualUploadDate: String? = null,
@ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE)
@ColumnInfo(name = "upload_date")
var uploadDate: OffsetDateTime? = null,
@ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION)
@ColumnInfo(name = "is_upload_date_approximation")
var isUploadDateApproximation: Boolean? = null,
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
@ColumnInfo(name = "duration")
var duration: Long
)
}
@@ -11,36 +11,39 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
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.StreamStateEntity
@Dao
interface StreamStateDAO : BasicDAO<StreamStateEntity> {
interface StreamStateDAO {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(entity: StreamStateEntity): Long
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
override fun getAll(): Flowable<List<StreamStateEntity>>
@Update
fun update(entity: StreamStateEntity)
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
override fun deleteAll(): Int
@Query("SELECT * FROM stream_state")
fun getAll(): Flowable<List<StreamStateEntity>>
override fun listByService(serviceId: Int): Flowable<List<StreamStateEntity>> {
throw UnsupportedOperationException()
}
@Query("DELETE FROM stream_state")
fun deleteAll(): Completable
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
@Query("SELECT * FROM stream_state WHERE stream_id = :streamId")
fun getState(streamId: Long): Maybe<StreamStateEntity>
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
fun deleteState(streamId: Long): Int
@Query("DELETE FROM stream_state WHERE stream_id = :streamId")
fun deleteState(streamId: Long): Completable
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun silentInsertInternal(streamState: StreamStateEntity)
@Transaction
fun upsert(stream: StreamStateEntity): Long {
fun upsert(stream: StreamStateEntity) {
silentInsertInternal(stream)
return update(stream).toLong()
update(stream)
}
}
@@ -7,9 +7,6 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import java.io.Serializable
import java.time.OffsetDateTime
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL
import org.schabi.newpipe.extractor.localization.DateWrapper
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
@@ -18,50 +15,50 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem
import org.schabi.newpipe.util.image.ImageStrategy
@Entity(
tableName = STREAM_TABLE,
tableName = "streams",
indices = [
Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true)
Index(value = ["service_id", "url"], unique = true)
]
)
data class StreamEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = STREAM_ID)
@ColumnInfo(name = "uid")
var uid: Long = 0,
@ColumnInfo(name = STREAM_SERVICE_ID)
@ColumnInfo(name = "service_id")
var serviceId: Int,
@ColumnInfo(name = STREAM_URL)
@ColumnInfo(name = "url")
var url: String,
@ColumnInfo(name = STREAM_TITLE)
@ColumnInfo(name = "title")
var title: String,
@ColumnInfo(name = STREAM_TYPE)
@ColumnInfo(name = "stream_type")
var streamType: StreamType,
@ColumnInfo(name = STREAM_DURATION)
@ColumnInfo(name = "duration")
var duration: Long,
@ColumnInfo(name = STREAM_UPLOADER)
@ColumnInfo(name = "uploader")
var uploader: String,
@ColumnInfo(name = STREAM_UPLOADER_URL)
@ColumnInfo(name = "uploader_url")
var uploaderUrl: String? = null,
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
@ColumnInfo(name = "thumbnail_url")
var thumbnailUrl: String? = null,
@ColumnInfo(name = STREAM_VIEWS)
@ColumnInfo(name = "view_count")
var viewCount: Long? = null,
@ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE)
@ColumnInfo(name = "textual_upload_date")
var textualUploadDate: String? = null,
@ColumnInfo(name = STREAM_UPLOAD_DATE)
@ColumnInfo(name = "upload_date")
var uploadDate: OffsetDateTime? = null,
@ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION)
@ColumnInfo(name = "is_upload_date_approximation")
var isUploadDateApproximation: Boolean? = null
) : Serializable {
@Ignore
@@ -111,22 +108,4 @@ data class StreamEntity(
return item
}
companion object {
const val STREAM_TABLE = "streams"
const val STREAM_ID = "uid"
const val STREAM_SERVICE_ID = "service_id"
const val STREAM_URL = "url"
const val STREAM_TITLE = "title"
const val STREAM_TYPE = "stream_type"
const val STREAM_DURATION = "duration"
const val STREAM_UPLOADER = "uploader"
const val STREAM_UPLOADER_URL = "uploader_url"
const val STREAM_THUMBNAIL_URL = "thumbnail_url"
const val STREAM_VIEWS = "view_count"
const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date"
const val STREAM_UPLOAD_DATE = "upload_date"
const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation"
}
}
@@ -10,29 +10,26 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE
@Entity(
tableName = STREAM_STATE_TABLE,
primaryKeys = [JOIN_STREAM_ID],
tableName = "stream_state",
primaryKeys = ["stream_id"],
foreignKeys = [
ForeignKey(
entity = StreamEntity::class,
parentColumns = arrayOf(STREAM_ID),
childColumns = arrayOf(JOIN_STREAM_ID),
parentColumns = arrayOf("uid"),
childColumns = arrayOf("stream_id"),
onDelete = CASCADE,
onUpdate = CASCADE
)
]
)
data class StreamStateEntity(
@ColumnInfo(name = JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamUid: Long,
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
@ColumnInfo(name = "progress_time")
val progressMillis: Long
) {
/**
@@ -61,14 +58,6 @@ data class StreamStateEntity(
}
companion object {
const val STREAM_STATE_TABLE = "stream_state"
const val JOIN_STREAM_ID = "stream_id"
// This additional field is required for the SQL query because 'stream_id' is used
// for some other joins already
const val JOIN_STREAM_ID_ALIAS = "stream_id_alias"
const val STREAM_PROGRESS_MILLIS = "progress_time"
/**
* Playback state will not be saved, if playback time is less than this threshold
* (5000ms = 5s).
@@ -22,9 +22,6 @@ import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
@@ -66,10 +63,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int HEADER_TYPE = 0;
private static final int FOOTER_TYPE = 1;
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
@@ -303,14 +297,6 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
} else {
return STREAM_PLAYLIST_HOLDER_TYPE;
}
case STATISTIC_STREAM_ITEM:
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
} else {
return STREAM_STATISTICS_HOLDER_TYPE;
}
default:
Log.e(TAG, "No holder type has been considered for item: ["
+ item.getLocalItemType() + "]");
@@ -326,43 +312,36 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
Log.d(TAG, "onCreateViewHolder() called with: "
+ "parent = [" + parent + "], type = [" + type + "]");
}
switch (type) {
case HEADER_TYPE:
return new HeaderFooterHolder(headerSupplier.get());
case FOOTER_TYPE:
return new HeaderFooterHolder(footer);
case LOCAL_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE:
return new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_HOLDER_TYPE:
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
default:
return switch (type) {
case HEADER_TYPE -> new HeaderFooterHolder(headerSupplier.get());
case FOOTER_TYPE -> new HeaderFooterHolder(footer);
case LOCAL_PLAYLIST_HOLDER_TYPE ->
new LocalPlaylistItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE ->
new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE ->
new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE ->
new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE ->
new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE ->
new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE ->
new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE ->
new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE ->
new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE ->
new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_CARD_HOLDER_TYPE ->
new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
default -> {
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
return new FallbackViewHolder(new View(parent.getContext()));
}
yield new FallbackViewHolder(new View(parent.getContext()));
}
};
}
@SuppressWarnings("FinalParameters")
@@ -1,7 +1,6 @@
package org.schabi.newpipe.local.feed
import android.content.Context
import android.util.Log
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
@@ -10,6 +9,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset
import kotlinx.coroutines.rx3.await
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedEntity
@@ -110,20 +110,9 @@ class FeedDatabaseManager(context: Context) {
)
}
fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) {
suspend fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) {
feedTable.unlinkStreamsOlderThan(oldestAllowedDate)
streamTable.deleteOrphans()
}
fun clear() {
feedTable.deleteAll()
val deletedOrphans = streamTable.deleteOrphans()
if (DEBUG) {
Log.d(
this::class.java.simpleName,
"clear() → streamTable.deleteOrphans() → $deletedOrphans"
)
}
streamTable.deleteOrphans().await()
}
// /////////////////////////////////////////////////////////////////////////
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Notification
import io.reactivex.rxjava3.core.Single
@@ -15,6 +14,8 @@ import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.rx3.rxCompletable
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.NotificationMode
@@ -262,7 +263,7 @@ class FeedLoadManager(private val context: Context) {
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
* Remove streams from the database which are not linked / used by any table.
*/
private fun postProcessFeed() = Completable.fromRunnable {
private fun postProcessFeed() = rxCompletable(Dispatchers.IO) {
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
feedDatabaseManager.removeOrphansOrOlderStreams()
@@ -0,0 +1,94 @@
package org.schabi.newpipe.local.history
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.Surface
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.ui.screens.HistoryScreen
import org.schabi.newpipe.ui.theme.AppTheme
class HistoryFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
HistoryScreen()
}
}
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
val context = requireActivity()
(context as? AppCompatActivity)?.supportActionBar?.setTitle(R.string.title_activity_history)
val recordManager = HistoryRecordManager(context)
context.addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.menu_history, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_history_clear -> {
AlertDialog.Builder(context)
.setTitle(R.string.delete_view_history_alert)
.setNegativeButton(R.string.cancel) { dialog, which -> dialog.dismiss() }
.setPositiveButton(R.string.delete) { dialog, which ->
viewLifecycleOwner.lifecycleScope.launch {
launch(getExceptionHandler("Delete playback states")) {
recordManager.deleteCompleteStreamStateHistory().await()
Toast
.makeText(context, R.string.watch_history_states_deleted, Toast.LENGTH_SHORT)
.show()
}
launch(getExceptionHandler("Delete watch history")) {
recordManager.deleteWholeStreamHistory().await()
Toast.makeText(context, R.string.watch_history_deleted, Toast.LENGTH_SHORT)
.show()
}
launch(getExceptionHandler("Clear orphaned records")) {
recordManager.removeOrphanedRecords().await()
}
}
}
.show()
}
}
return true
}
},
viewLifecycleOwner
)
}
private fun getExceptionHandler(action: String) = CoroutineExceptionHandler { _, throwable ->
openActivity(requireContext(), ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, action))
}
}
@@ -151,30 +151,22 @@ public class HistoryRecordManager {
}
public Completable deleteStreamHistoryAndState(final long streamId) {
return Completable.fromAction(() -> {
streamStateTable.deleteState(streamId);
streamHistoryTable.deleteStreamHistory(streamId);
}).subscribeOn(Schedulers.io());
return streamStateTable.deleteState(streamId)
.andThen(streamHistoryTable.deleteStreamHistory(streamId));
}
public Single<Integer> deleteWholeStreamHistory() {
return Single.fromCallable(streamHistoryTable::deleteAll)
.subscribeOn(Schedulers.io());
public Completable deleteWholeStreamHistory() {
return streamHistoryTable.deleteAll().subscribeOn(Schedulers.io());
}
public Single<Integer> deleteCompleteStreamStateHistory() {
return Single.fromCallable(streamStateTable::deleteAll)
.subscribeOn(Schedulers.io());
public Completable deleteCompleteStreamStateHistory() {
return streamStateTable.deleteAll().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamHistoryEntry>> getStreamHistorySortedById() {
return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamStatisticsEntry>> getStreamStatistics() {
return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io());
}
private boolean isStreamHistoryEnabled() {
return sharedPreferences.getBoolean(streamHistoryKey, false);
}
@@ -283,8 +275,7 @@ public class HistoryRecordManager {
// Utility
///////////////////////////////////////////////////////
public Single<Integer> removeOrphanedRecords() {
return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io());
public Completable removeOrphanedRecords() {
return streamTable.deleteOrphans().subscribeOn(Schedulers.io());
}
}
@@ -0,0 +1,42 @@
package org.schabi.newpipe.local.history
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.map
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.ui.components.items.Stream
class HistoryViewModel(
application: Application,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
private val historyDao = NewPipeDatabase.getInstance(getApplication()).streamHistoryDAO()
val sortKey = savedStateHandle.getStateFlow(ORDER_KEY, SortKey.LAST_PLAYED)
val historyItems = sortKey
.flatMapLatest {
Pager(PagingConfig(pageSize = 20)) {
historyDao.getOrderedHistory(it)
}.flow
}
.map { pagingData -> pagingData.map { Stream(it) } }
.flowOn(Dispatchers.IO)
.cachedIn(viewModelScope)
fun updateOrder(sortKey: SortKey) {
savedStateHandle[ORDER_KEY] = sortKey
}
companion object {
private const val ORDER_KEY = "order"
}
}
@@ -0,0 +1,9 @@
package org.schabi.newpipe.local.history
import androidx.annotation.StringRes
import org.schabi.newpipe.R
enum class SortKey(@StringRes val title: Int) {
LAST_PLAYED(R.string.history_sort_date),
MOST_PLAYED(R.string.history_sort_views)
}
@@ -1,392 +0,0 @@
package org.schabi.newpipe.local.history;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
import com.google.android.material.snackbar.Snackbar;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
public class StatisticsPlaylistFragment
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void>
implements PlaylistControlViewHolder {
private final CompositeDisposable disposables = new CompositeDisposable();
@State
Parcelable itemsListState;
private StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED;
private StatisticPlaylistControlBinding headerBinding;
private PlaylistControlBinding playlistControlBinding;
/* Used for independent events */
private Subscription databaseSubscription;
private HistoryRecordManager recordManager;
private List<StreamStatisticsEntry> processResult(final List<StreamStatisticsEntry> results) {
final Comparator<StreamStatisticsEntry> comparator;
switch (sortMode) {
case LAST_PLAYED:
comparator = Comparator.comparing(StreamStatisticsEntry::getLatestAccessDate);
break;
case MOST_PLAYED:
comparator = Comparator.comparingLong(StreamStatisticsEntry::getWatchCount);
break;
default:
return null;
}
Collections.sort(results, comparator.reversed());
return results;
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Creation
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
recordManager = new HistoryRecordManager(getContext());
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_playlist, container, false);
}
@Override
public void onResume() {
super.onResume();
if (activity != null) {
setTitle(activity.getString(R.string.title_activity_history));
}
}
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.menu_history, menu);
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Views
///////////////////////////////////////////////////////////////////////////
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
if (!useAsFrontPage) {
setTitle(getString(R.string.title_last_played));
}
}
@Override
protected Supplier<View> getListHeaderSupplier() {
headerBinding = StatisticPlaylistControlBinding.inflate(activity.getLayoutInflater(),
itemsList, false);
playlistControlBinding = headerBinding.playlistControl;
return headerBinding::getRoot;
}
@Override
protected void initListeners() {
super.initListeners();
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
final StreamEntity item =
((StreamStatisticsEntry) selectedItem).getStreamEntity();
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
item.getServiceId(), item.getUrl(), item.getTitle(), null, false);
}
}
@Override
public void held(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
showInfoItemDialog((StreamStatisticsEntry) selectedItem);
}
}
});
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.action_history_clear) {
HistorySettingsFragment
.openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables);
} else {
return super.onOptionsItemSelected(item);
}
return true;
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Loading
///////////////////////////////////////////////////////////////////////////
@Override
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
recordManager.getStreamStatistics()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getHistoryObserver());
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Destruction
///////////////////////////////////////////////////////////////////////////
@Override
public void onPause() {
super.onPause();
itemsListState = Objects.requireNonNull(itemsList.getLayoutManager()).onSaveInstanceState();
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener();
}
headerBinding = null;
playlistControlBinding = null;
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = null;
}
@Override
public void onDestroy() {
super.onDestroy();
recordManager = null;
itemsListState = null;
}
///////////////////////////////////////////////////////////////////////////
// Statistics Loader
///////////////////////////////////////////////////////////////////////////
private Subscriber<List<StreamStatisticsEntry>> getHistoryObserver() {
return new Subscriber<List<StreamStatisticsEntry>>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = s;
databaseSubscription.request(1);
}
@Override
public void onNext(final List<StreamStatisticsEntry> streams) {
handleResult(streams);
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
}
@Override
public void onError(final Throwable exception) {
showError(
new ErrorInfo(exception, UserAction.SOMETHING_ELSE, "History Statistics"));
}
@Override
public void onComplete() {
}
};
}
@Override
public void handleResult(@NonNull final List<StreamStatisticsEntry> result) {
super.handleResult(result);
if (itemListAdapter == null) {
return;
}
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
itemListAdapter.clearStreamItemList();
if (result.isEmpty()) {
showEmptyState();
return;
}
itemListAdapter.addItems(processResult(result));
if (itemsListState != null && itemsList.getLayoutManager() != null) {
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
itemsListState = null;
}
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
headerBinding.sortButton.setOnClickListener(view -> toggleSortMode());
hideLoading();
}
///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling
///////////////////////////////////////////////////////////////////////////
@Override
protected void resetFragment() {
super.resetFragment();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
private void toggleSortMode() {
if (sortMode == StatisticSortMode.LAST_PLAYED) {
sortMode = StatisticSortMode.MOST_PLAYED;
setTitle(getString(R.string.title_most_played));
headerBinding.sortButtonIcon.setImageResource(R.drawable.ic_history);
headerBinding.sortButtonText.setText(R.string.title_last_played);
} else {
sortMode = StatisticSortMode.LAST_PLAYED;
setTitle(getString(R.string.title_last_played));
headerBinding.sortButtonIcon.setImageResource(
R.drawable.ic_filter_list);
headerBinding.sortButtonText.setText(R.string.title_most_played);
}
startLoading(true);
}
private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) {
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
}
private void showInfoItemDialog(final StreamStatisticsEntry item) {
final Context context = getContext();
final StreamInfoItem infoItem = item.toStreamInfoItem();
try {
final InfoItemDialog.Builder dialogBuilder =
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
// set entries in the middle; the others are added automatically
dialogBuilder
.addEntry(StreamDialogDefaultEntry.DELETE)
.setAction(
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteEntry(
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
.create()
.show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
}
}
private void deleteEntry(final int index) {
final LocalItem infoItem = itemListAdapter.getItemsList().get(index);
if (infoItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
final Disposable onDelete = recordManager
.deleteStreamHistoryAndState(entry.getStreamId())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
() -> {
if (getView() != null) {
Snackbar.make(getView(), R.string.one_item_deleted,
Snackbar.LENGTH_SHORT).show();
} else {
Toast.makeText(getContext(),
R.string.one_item_deleted,
Toast.LENGTH_SHORT).show();
}
},
throwable -> showSnackBarError(new ErrorInfo(throwable,
UserAction.DELETE_FROM_HISTORY, "Deleting item")));
disposables.add(onDelete);
}
}
@Override
public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}
private PlayQueue getPlayQueue(final int index) {
if (itemListAdapter == null) {
return new SinglePlayQueue(Collections.emptyList(), 0);
}
final List<LocalItem> infoItems = itemListAdapter.getItemsList();
final List<StreamInfoItem> streamInfoItems = new ArrayList<>(infoItems.size());
for (final LocalItem item : infoItems) {
if (item instanceof StreamStatisticsEntry) {
streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem());
}
}
return new SinglePlayQueue(streamInfoItems, index);
}
private enum StatisticSortMode {
LAST_PLAYED,
MOST_PLAYED,
}
}
@@ -1,13 +0,0 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder {
public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
}
}
@@ -1,13 +0,0 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalStatisticStreamGridItemHolder extends LocalStatisticStreamItemHolder {
public LocalStatisticStreamGridItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_grid_item, parent);
}
}
@@ -1,161 +0,0 @@
package org.schabi.newpipe.local.holder;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.image.CoilHelper;
import org.schabi.newpipe.views.AnimatedProgressBar;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
/*
* Created by Christian Schabesberger on 01.08.16.
* <p>
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* StreamInfoItemHolder.java is part of NewPipe.
* <p>
* NewPipe 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.
* <p>
* NewPipe 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class LocalStatisticStreamItemHolder extends LocalItemHolder {
public final ImageView itemThumbnailView;
public final TextView itemVideoTitleView;
public final TextView itemUploaderView;
public final TextView itemDurationView;
@Nullable
public final TextView itemAdditionalDetails;
private final AnimatedProgressBar itemProgressView;
public LocalStatisticStreamItemHolder(final LocalItemBuilder itemBuilder,
final ViewGroup parent) {
this(itemBuilder, R.layout.list_stream_item, parent);
}
LocalStatisticStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView);
itemUploaderView = itemView.findViewById(R.id.itemUploaderView);
itemDurationView = itemView.findViewById(R.id.itemDurationView);
itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails);
itemProgressView = itemView.findViewById(R.id.itemProgressView);
}
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
final DateTimeFormatter dateTimeFormatter) {
return Localization.concatenateStrings(
// watchCount
Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()),
dateTimeFormatter.format(entry.getLatestAccessDate()),
// serviceName
ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId()));
}
@Override
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateTimeFormatter dateTimeFormatter) {
if (!(localItem instanceof StreamStatisticsEntry)) {
return;
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
itemUploaderView.setText(item.getStreamEntity().getUploader());
if (item.getStreamEntity().getDuration() > 0) {
itemDurationView.
setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
&& item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setVisibility(View.GONE);
}
} else {
itemDurationView.setVisibility(View.GONE);
itemProgressView.setVisibility(View.GONE);
}
if (itemAdditionalDetails != null) {
itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateTimeFormatter));
}
// Default thumbnail is shown on error, while loading and if the url is empty
CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView,
item.getStreamEntity().getThumbnailUrl());
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnItemSelectedListener() != null) {
itemBuilder.getOnItemSelectedListener().selected(item);
}
});
itemView.setLongClickable(true);
itemView.setOnLongClickListener(view -> {
if (itemBuilder.getOnItemSelectedListener() != null) {
itemBuilder.getOnItemSelectedListener().held(item);
}
return true;
});
}
@Override
public void updateState(final LocalItem localItem,
final HistoryRecordManager historyRecordManager) {
if (!(localItem instanceof StreamStatisticsEntry)) {
return;
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
&& item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500);
}
} else if (itemProgressView.getVisibility() == View.VISIBLE) {
ViewUtils.animate(itemProgressView, false, 500);
}
}
}
@@ -307,10 +307,10 @@ class MediaBrowserImpl(
}
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
val history = database.streamHistoryDAO().history.firstOrError()
return history.map { items ->
items.map { this.createHistoryMediaItem(it) }
}
return database.streamHistoryDAO().getHistory().firstOrError()
.map { items ->
items.map { this.createHistoryMediaItem(it) }
}
}
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
@@ -216,7 +216,7 @@ class MediaBrowserPlaybackPreparer(
}
val streamId = path[0].toLong()
return database.streamHistoryDAO().history
return database.streamHistoryDAO().getHistory()
.firstOrError()
.map { items ->
val infoItems = items
@@ -1,314 +0,0 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
import com.grack.nanojson.JsonParserException;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.local.subscription.SubscriptionsImportExportHelper;
import org.schabi.newpipe.settings.export.BackupFileLocator;
import org.schabi.newpipe.settings.export.ImportExportManager;
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.ZipHelper;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
private static final String ZIP_MIME_TYPE = "application/zip";
private final SimpleDateFormat exportDateFormat =
new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
private ImportExportManager manager;
private String importExportDataPathKey;
private final ActivityResultLauncher<Intent> requestImportPathLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
this::requestImportPathResult);
private final ActivityResultLauncher<Intent> requestExportPathLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
this::requestExportPathResult);
private SubscriptionsImportExportHelper importExportHelper;
@Override
public void onAttach(@NonNull final Context context) {
super.onAttach(context);
importExportHelper = new SubscriptionsImportExportHelper(this);
}
@Override
public void onCreatePreferences(@Nullable final Bundle savedInstanceState,
@Nullable final String rootKey) {
manager = new ImportExportManager(new BackupFileLocator(requireContext()));
importExportDataPathKey = getString(R.string.import_export_data_path);
addPreferencesFromResourceRegistry();
final Preference importDataPreference = requirePreference(R.string.import_data);
importDataPreference.setOnPreferenceClickListener((Preference p) -> {
NoFileManagerSafeGuard.launchSafe(
requestImportPathLauncher,
StoredFileHelper.getPicker(requireContext(),
ZIP_MIME_TYPE, getImportExportDataUri()),
TAG,
getContext()
);
return true;
});
final Preference exportDataPreference = requirePreference(R.string.export_data);
exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
NoFileManagerSafeGuard.launchSafe(
requestExportPathLauncher,
StoredFileHelper.getNewPicker(requireContext(),
"NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
ZIP_MIME_TYPE, getImportExportDataUri()),
TAG,
getContext()
);
return true;
});
final Preference resetSettings = requirePreference(R.string.reset_settings);
// Resets all settings by deleting shared preference and restarting the app
// A dialogue will pop up to confirm if user intends to reset all settings
resetSettings.setOnPreferenceClickListener(preference -> {
// Show Alert Dialogue
final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setMessage(R.string.reset_all_settings);
builder.setCancelable(true);
builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
// Deletes all shared preferences xml files.
final SharedPreferences sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(requireContext());
sharedPreferences.edit().clear().apply();
// Restarts the app
if (getActivity() == null) {
return;
}
NavigationHelper.restartApp(getActivity());
});
builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> {
});
final AlertDialog alertDialog = builder.create();
alertDialog.show();
return true;
});
final Preference exportSubsPreference =
requirePreference(R.string.export_subscriptions_key);
exportSubsPreference.setOnPreferenceClickListener(reference -> {
importExportHelper.onExportSelected();
return true;
});
final Preference importSubsPreference =
requirePreference(R.string.import_subscriptions_key);
importSubsPreference.setOnPreferenceClickListener(preference -> {
importExportHelper.onImportPreviousSelected();
return true;
});
}
private void requestExportPathResult(final ActivityResult result) {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastExportDataUri = result.getData().getData();
final StoredFileHelper file = new StoredFileHelper(
requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
exportDatabase(file, lastExportDataUri);
}
}
private void requestImportPathResult(final ActivityResult result) {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastImportDataUri = result.getData().getData();
final StoredFileHelper file = new StoredFileHelper(
requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
new androidx.appcompat.app.AlertDialog.Builder(requireActivity())
.setMessage(R.string.override_current_data)
.setPositiveButton(R.string.ok, (d, id) ->
importDatabase(file, lastImportDataUri))
.setNegativeButton(R.string.cancel, (d, id) ->
d.cancel())
.show();
}
}
private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
//checkpoint before export
executor.submit(NewPipeDatabase::checkpoint).get();
final SharedPreferences preferences = PreferenceManager
.getDefaultSharedPreferences(requireContext());
manager.exportDatabase(preferences, file);
saveLastImportExportDataUri(exportDataUri); // save export path only on success
Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT)
.show();
} catch (final Exception e) {
showErrorSnackbar(e, "Exporting database and settings");
}
}
private void importDatabase(final StoredFileHelper file, final Uri importDataUri) {
// check if file is supported
if (!ZipHelper.isValidZipFile(file)) {
Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
.show();
return;
}
try {
manager.ensureDbDirectoryExists();
// replace the current database
if (!manager.extractDb(file)) {
Toast.makeText(requireContext(), R.string.could_not_import_all_files,
Toast.LENGTH_LONG)
.show();
}
// if settings file exist, ask if it should be imported.
final boolean hasJsonPrefs = manager.exportHasJsonPrefs(file);
if (hasJsonPrefs || manager.exportHasSerializedPrefs(file)) {
new androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle(R.string.import_settings)
.setMessage(hasJsonPrefs ? null : requireContext()
.getString(R.string.import_settings_vulnerable_format))
.setOnDismissListener(dialog -> finishImport(importDataUri))
.setNegativeButton(R.string.cancel, (dialog, which) -> {
dialog.dismiss();
finishImport(importDataUri);
})
.setPositiveButton(R.string.ok, (dialog, which) -> {
dialog.dismiss();
final Context context = requireContext();
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
try {
if (hasJsonPrefs) {
manager.loadJsonPrefs(file, prefs);
} else {
manager.loadSerializedPrefs(file, prefs);
}
} catch (IOException | ClassNotFoundException | JsonParserException e) {
createErrorNotification(e, "Importing preferences");
return;
}
cleanImport(context, prefs);
finishImport(importDataUri);
})
.show();
} else {
finishImport(importDataUri);
}
} catch (final Exception e) {
showErrorSnackbar(e, "Importing database and settings");
}
}
/**
* Remove settings that are not supposed to be imported on different devices
* and reset them to default values.
* @param context the context used for the import
* @param prefs the preferences used while running the import
*/
private void cleanImport(@NonNull final Context context,
@NonNull final SharedPreferences prefs) {
// Check if media tunnelling needs to be disabled automatically,
// if it was disabled automatically in the imported preferences.
final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key);
final String automaticTunnelingKey =
context.getString(R.string.disabled_media_tunneling_automatically_key);
// R.string.disable_media_tunneling_key should always be true
// if R.string.disabled_media_tunneling_automatically_key equals 1,
// but we double check here just to be sure and to avoid regressions
// caused by possible later modification of the media tunneling functionality.
// R.string.disabled_media_tunneling_automatically_key == 0:
// automatic value overridden by user in settings
// R.string.disabled_media_tunneling_automatically_key == -1: not set
final boolean wasMediaTunnelingDisabledAutomatically =
prefs.getInt(automaticTunnelingKey, -1) == 1
&& prefs.getBoolean(tunnelingKey, false);
if (wasMediaTunnelingDisabledAutomatically) {
prefs.edit()
.putInt(automaticTunnelingKey, -1)
.putBoolean(tunnelingKey, false)
.apply();
NewPipeSettings.setMediaTunneling(context);
}
}
/**
* Save import path and restart app.
*
* @param importDataUri The import path to save
*/
private void finishImport(final Uri importDataUri) {
// save import path only on success
saveLastImportExportDataUri(importDataUri);
// restart app to properly load db
NavigationHelper.restartApp(requireActivity());
}
private Uri getImportExportDataUri() {
final String path = defaultPreferences.getString(importExportDataPathKey, null);
return isBlank(path) ? null : Uri.parse(path);
}
private void saveLastImportExportDataUri(final Uri importExportDataUri) {
final SharedPreferences.Editor editor = defaultPreferences.edit()
.putString(importExportDataPathKey, importExportDataUri.toString());
editor.apply();
}
private void showErrorSnackbar(final Throwable e, final String request) {
ErrorUtil.showSnackbar(this, new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request));
}
private void createErrorNotification(final Throwable e, final String request) {
ErrorUtil.createNotification(
requireContext(),
new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request)
);
}
}
@@ -0,0 +1,259 @@
package org.schabi.newpipe.settings
import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import com.grack.nanojson.JsonParserException
import java.io.IOException
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.settings.export.BackupFileLocator
import org.schabi.newpipe.settings.export.ImportExportManager
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ZipHelper
class BackupRestoreSettingsFragment : BasePreferenceFragment() {
private val exportDateFormat = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.US)
private lateinit var manager: ImportExportManager
private val requestImportPathLauncher =
registerForActivityResult(StartActivityForResult(), this::requestImportPathResult)
private val requestExportPathLauncher =
registerForActivityResult(StartActivityForResult(), this::requestExportPathResult)
override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?
) {
val activity = requireActivity()
manager = ImportExportManager(BackupFileLocator(activity))
addPreferencesFromResourceRegistry()
requirePreference<Preference>(R.string.import_data).setOnPreferenceClickListener {
val picker = StoredFileHelper.getPicker(activity, ZIP_MIME_TYPE, importExportDataUri)
NoFileManagerSafeGuard.launchSafe(requestImportPathLauncher, picker, TAG, activity)
true
}
requirePreference<Preference>(R.string.export_data).setOnPreferenceClickListener {
val filename = "NewPipeData-${exportDateFormat.format(LocalDateTime.now())}.zip"
val picker = StoredFileHelper.getNewPicker(activity, filename, ZIP_MIME_TYPE, importExportDataUri)
NoFileManagerSafeGuard.launchSafe(requestExportPathLauncher, picker, TAG, activity)
true
}
requirePreference<Preference>(R.string.reset_settings).setOnPreferenceClickListener {
// Show Alert Dialogue
AlertDialog
.Builder(activity)
.setMessage(R.string.reset_all_settings)
.setCancelable(true)
.setPositiveButton(R.string.ok) { _, _ ->
// Deletes all shared preferences xml files.
defaultPreferences.edit { clear() }
NavigationHelper.restartApp(activity)
}.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
true
}
}
private fun requestExportPathResult(result: ActivityResult) {
val context = requireContext()
val lastExportDataUri = result.data?.data
if (result.resultCode == Activity.RESULT_OK && lastExportDataUri != null) {
// will be saved only on success
val file = StoredFileHelper(context, lastExportDataUri, ZIP_MIME_TYPE)
exportDatabase(file, lastExportDataUri)
}
}
private fun requestImportPathResult(result: ActivityResult) {
val context = requireContext()
val lastImportDataUri = result.data?.data
if (result.resultCode == Activity.RESULT_OK && lastImportDataUri != null) {
// will be saved only on success
val file = StoredFileHelper(context, lastImportDataUri, ZIP_MIME_TYPE)
AlertDialog.Builder(context)
.setMessage(R.string.override_current_data)
.setPositiveButton(R.string.ok) { _, _ -> importDatabase(file, lastImportDataUri) }
.setNegativeButton(R.string.cancel) { dialog, id -> dialog.cancel() }
.show()
}
}
private fun exportDatabase(file: StoredFileHelper, exportDataUri: Uri) {
lifecycleScope.launch(
CoroutineExceptionHandler { context, throwable ->
showErrorSnackbar(throwable, "Exporting database and settings")
}
) {
// checkpoint before export
withContext(Dispatchers.IO) {
NewPipeDatabase.checkpoint()
}
manager.exportDatabase(defaultPreferences, file)
saveLastImportExportDataUri(exportDataUri) // save export path only on success
Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT)
.show()
}
}
private fun importDatabase(file: StoredFileHelper, importDataUri: Uri) {
val context = requireContext()
// check if file is supported
if (!ZipHelper.isValidZipFile(file)) {
Toast.makeText(context, R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
.show()
return
}
try {
manager.ensureDbDirectoryExists()
// replace the current database
if (!manager.extractDb(file)) {
Toast.makeText(context, R.string.could_not_import_all_files, Toast.LENGTH_LONG)
.show()
}
// if settings file exist, ask if it should be imported.
val hasJsonPrefs = manager.exportHasJsonPrefs(file)
if (hasJsonPrefs || manager.exportHasSerializedPrefs(file)) {
AlertDialog.Builder(context)
.setTitle(R.string.import_settings)
.setMessage(if (hasJsonPrefs) null else getString(R.string.import_settings_vulnerable_format))
.setOnDismissListener { finishImport(importDataUri) }
.setNegativeButton(R.string.cancel) { dialog, which ->
dialog.dismiss()
finishImport(importDataUri)
}
.setPositiveButton(R.string.ok) { dialog, which ->
dialog.dismiss()
try {
if (hasJsonPrefs) {
manager.loadJsonPrefs(file, defaultPreferences)
} else {
manager.loadSerializedPrefs(file, defaultPreferences)
}
} catch (e: IOException) {
createErrorNotification(e, "Importing preferences")
return@setPositiveButton
} catch (e: ClassNotFoundException) {
createErrorNotification(e, "Importing preferences")
return@setPositiveButton
} catch (e: JsonParserException) {
createErrorNotification(e, "Importing preferences")
return@setPositiveButton
}
cleanImport(requireContext(), defaultPreferences)
finishImport(importDataUri)
}
.show()
} else {
finishImport(importDataUri)
}
} catch (e: Exception) {
showErrorSnackbar(e, "Importing database and settings")
}
}
/**
* Remove settings that are not supposed to be imported on different devices
* and reset them to default values.
* @param context the context used for the import
* @param prefs the preferences used while running the import
*/
private fun cleanImport(
context: Context,
prefs: SharedPreferences
) {
// Check if media tunnelling needs to be disabled automatically,
// if it was disabled automatically in the imported preferences.
val tunnelingKey = getString(R.string.disable_media_tunneling_key)
val automaticTunnelingKey = getString(R.string.disabled_media_tunneling_automatically_key)
// R.string.disable_media_tunneling_key should always be true
// if R.string.disabled_media_tunneling_automatically_key equals 1,
// but we double check here just to be sure and to avoid regressions
// caused by possible later modification of the media tunneling functionality.
// R.string.disabled_media_tunneling_automatically_key == 0:
// automatic value overridden by user in settings
// R.string.disabled_media_tunneling_automatically_key == -1: not set
val wasMediaTunnelingDisabledAutomatically =
prefs.getInt(automaticTunnelingKey, -1) == 1 &&
prefs.getBoolean(tunnelingKey, false)
if (wasMediaTunnelingDisabledAutomatically) {
prefs.edit {
putInt(automaticTunnelingKey, -1)
putBoolean(tunnelingKey, false)
}
NewPipeSettings.setMediaTunneling(context)
}
}
/**
* Save import path and restart app.
*
* @param importDataUri The import path to save
*/
private fun finishImport(importDataUri: Uri) {
// save import path only on success
saveLastImportExportDataUri(importDataUri)
// restart app to properly load db
NavigationHelper.restartApp(requireActivity())
}
private val importExportDataUri: Uri?
get() = defaultPreferences
.getString(getString(R.string.import_export_data_path), null)?.toUri()
private fun saveLastImportExportDataUri(importExportDataUri: Uri) {
defaultPreferences.edit {
putString(getString(R.string.import_export_data_path), importExportDataUri.toString())
}
}
private fun showErrorSnackbar(e: Throwable, request: String) {
showSnackbar(this, ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request))
}
private fun createErrorNotification(e: Throwable, request: String) {
createNotification(
requireContext(),
ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request)
)
}
companion object {
private const val ZIP_MIME_TYPE = "application/zip"
}
}
@@ -20,6 +20,7 @@ import org.schabi.newpipe.util.InfoCache;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.internal.functions.Functions;
public class HistorySettingsFragment extends BasePreferenceFragment {
private String cacheWipeKey;
@@ -79,8 +80,8 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
return recordManager.deleteCompleteStreamStateHistory()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> Toast.makeText(context,
R.string.watch_history_states_deleted, Toast.LENGTH_SHORT).show(),
() -> Toast.makeText(context, R.string.watch_history_states_deleted,
Toast.LENGTH_SHORT).show(),
throwable -> ErrorUtil.openActivity(context,
new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
"Delete playback states")));
@@ -91,7 +92,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
return recordManager.deleteWholeStreamHistory()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> Toast.makeText(context,
() -> Toast.makeText(context,
R.string.watch_history_deleted, Toast.LENGTH_SHORT).show(),
throwable -> ErrorUtil.openActivity(context,
new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
@@ -103,7 +104,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
return recordManager.removeOrphanedRecords()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> { },
Functions.EMPTY_ACTION,
throwable -> ErrorUtil.openActivity(context,
new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
"Clear orphaned records")));
@@ -25,7 +25,7 @@ import org.schabi.newpipe.fragments.list.kiosk.KioskFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment;
import org.schabi.newpipe.local.bookmark.BookmarkFragment;
import org.schabi.newpipe.local.feed.FeedFragment;
import org.schabi.newpipe.local.history.StatisticsPlaylistFragment;
import org.schabi.newpipe.local.history.HistoryFragment;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.local.subscription.SubscriptionFragment;
import org.schabi.newpipe.util.KioskTranslator;
@@ -305,8 +305,8 @@ public abstract class Tab {
}
@Override
public StatisticsPlaylistFragment getFragment(final Context context) {
return new StatisticsPlaylistFragment();
public Fragment getFragment(final Context context) {
return new HistoryFragment();
}
}
@@ -0,0 +1,20 @@
package org.schabi.newpipe.ui.components.common
import androidx.annotation.StringRes
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.ui.res.stringResource
@Composable
@NonRestartableComposable
fun DropdownTextMenuItem(
@StringRes text: Int,
onClick: () -> Unit
) {
DropdownMenuItem(
text = { Text(text = stringResource(text)) },
onClick = onClick
)
}
@@ -0,0 +1,52 @@
package org.schabi.newpipe.ui.components.common
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.theme.AppTheme
@Composable
fun IconButtonWithLabel(
icon: ImageVector,
@StringRes label: Int,
onClick: () -> Unit
) {
FilledTonalButton(
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 12.dp),
onClick = onClick
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = icon, contentDescription = null)
Text(text = stringResource(label))
}
}
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun IconButtonWithLabelPreview() {
AppTheme {
Surface {
IconButtonWithLabel(Icons.Default.Info, R.string.name) {}
}
}
}
@@ -0,0 +1,43 @@
package org.schabi.newpipe.ui.components.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.R
import org.schabi.newpipe.ktx.findFragmentActivity
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.util.NavigationHelper
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PlaybackControlButtons(queue: PlayQueue) {
val context = LocalContext.current
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally)) {
IconButtonWithLabel(
icon = Icons.Default.Headphones,
label = R.string.controls_background_title,
onClick = { NavigationHelper.playOnBackgroundPlayer(context, queue, false) }
)
IconButtonWithLabel(
icon = Icons.AutoMirrored.Filled.PlaylistPlay,
label = R.string.play_all,
onClick = { NavigationHelper.playOnMainPlayer(context.findFragmentActivity(), queue) }
)
IconButtonWithLabel(
icon = Icons.Default.PictureInPicture,
label = R.string.controls_popup_title,
onClick = { NavigationHelper.playOnPopupPlayer(context, queue, false) }
)
}
}
@@ -0,0 +1,26 @@
package org.schabi.newpipe.ui.components.items
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.util.NO_SERVICE_ID
sealed class Info
class Playlist(
val serviceId: Int = NO_SERVICE_ID,
val url: String = "",
val name: String = "",
val thumbnails: List<Image> = emptyList(),
val uploaderName: String = "",
val streamCount: Long = 10
) : Info() {
constructor(item: PlaylistInfoItem) : this(
item.serviceId,
item.url,
item.name,
item.thumbnails,
item.uploaderName.orEmpty(),
item.streamCount
)
}
@@ -1,44 +1,59 @@
package org.schabi.newpipe.ui.components.items
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
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.saveable.rememberSaveable
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.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.preference.PreferenceManager
import androidx.window.core.layout.WindowWidthSizeClass
import my.nanihadesuka.compose.LazyVerticalGridScrollbar
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.ktx.findFragmentActivity
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
import org.schabi.newpipe.ui.components.common.LoadingIndicator
import org.schabi.newpipe.ui.components.common.defaultThemedScrollbarSettings
import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem
import org.schabi.newpipe.ui.components.items.stream.StreamCardItem
import org.schabi.newpipe.ui.components.items.stream.StreamGridItem
import org.schabi.newpipe.ui.components.items.stream.StreamListItem
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
import org.schabi.newpipe.util.DependentPreferenceHelper
import org.schabi.newpipe.util.NavigationHelper
@Composable
fun ItemList(
items: List<InfoItem>,
items: LazyPagingItems<out Info>,
mode: ItemViewMode = determineItemViewMode(),
listHeader: LazyListScope.() -> Unit = {}
header: @Composable () -> Unit = {}
) {
val context = LocalContext.current
val onClick = remember {
{ item: InfoItem ->
{ item: Info ->
val fragmentManager = context.findFragmentActivity().supportFragmentManager
if (item is StreamInfoItem) {
if (item is Stream) {
NavigationHelper.openVideoDetailFragment(
context,
fragmentManager,
@@ -48,7 +63,7 @@ fun ItemList(
null,
false
)
} else if (item is PlaylistInfoItem) {
} else if (item is Playlist) {
NavigationHelper.openPlaylistFragment(
fragmentManager,
item.serviceId,
@@ -61,9 +76,9 @@ fun ItemList(
// Handle long clicks for stream items
// TODO: Adjust the menu display depending on where it was triggered
var selectedStream by remember { mutableStateOf<StreamInfoItem?>(null) }
var selectedStream by rememberSaveable { mutableStateOf<Stream?>(null) }
val onLongClick = remember {
{ stream: StreamInfoItem ->
{ stream: Stream ->
selectedStream = stream
}
}
@@ -76,30 +91,90 @@ fun ItemList(
val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context)
val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
if (mode == ItemViewMode.GRID) {
// TODO: Implement grid layout using LazyVerticalGrid and LazyVerticalGridScrollbar.
if (items.loadState.refresh is LoadState.NotLoading && items.itemCount == 0) {
EmptyStateComposable(
spec = EmptyStateSpec.NoVideos,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
} else if (mode == ItemViewMode.GRID) {
val state = rememberLazyGridState()
LazyVerticalGridScrollbar(state = state, settings = defaultThemedScrollbarSettings()) {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isCompact = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val minWidth = if (isCompact) 150.dp else 250.dp
LazyVerticalGrid(
modifier = nestedScrollModifier,
state = state,
columns = GridCells.Adaptive(minWidth)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
header()
}
items(items.itemCount) {
val item = items[it]
val isSelected = selectedStream == item
// TODO: Implement playlist and channel grid items.
if (item is Stream) {
StreamGridItem(
item,
showProgress,
isSelected,
isCompact,
onClick,
onLongClick,
onDismissPopup
)
} else if (item == null) { // Placeholder
LoadingIndicator(Modifier.size(minWidth, if (isCompact) 150.dp else 200.dp))
}
}
}
}
} else {
val state = rememberLazyListState()
LazyColumnThemedScrollbar(state = state) {
LazyColumn(modifier = nestedScrollModifier, state = state) {
listHeader()
item {
header()
}
items(items.size) {
items(items.itemCount) {
val item = items[it]
if (item is StreamInfoItem) {
// TODO: Implement playlist and channel items.
if (item is Stream) {
val isSelected = selectedStream == item
StreamListItem(
item,
showProgress,
isSelected,
onClick,
onLongClick,
onDismissPopup
)
} else if (item is PlaylistInfoItem) {
if (mode == ItemViewMode.CARD) {
StreamCardItem(
item,
showProgress,
isSelected,
onClick,
onLongClick,
onDismissPopup
)
} else {
StreamListItem(
item,
showProgress,
isSelected,
onClick,
onLongClick,
onDismissPopup
)
}
} else if (item is Playlist) {
PlaylistListItem(item, onClick)
} else if (item == null) { // Placeholder
LoadingIndicator(Modifier.height(80.dp))
}
}
}
@@ -0,0 +1,83 @@
package org.schabi.newpipe.ui.components.items
import android.os.Parcelable
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.concurrent.TimeUnit
import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.App
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
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 org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.image.ImageStrategy
@Parcelize
class Stream(
val serviceId: Int = NO_SERVICE_ID,
val url: String = "",
val name: String = "",
val thumbnails: List<Image> = emptyList(),
val uploaderName: String = "",
val type: StreamType,
val uploaderUrl: String? = null,
val duration: Long = TimeUnit.HOURS.toSeconds(1),
val detailText: String = "",
val streamId: Long = -1
) : Info(), Parcelable {
constructor(item: StreamInfoItem) : this(
item.serviceId, item.url, item.name, item.thumbnails, item.uploaderName.orEmpty(),
item.streamType, item.uploaderUrl, item.duration, item.detailText
)
constructor(entry: StreamStatisticsEntry) : this(
entry.streamEntity.serviceId, entry.streamEntity.url, entry.streamEntity.title,
ImageStrategy.dbUrlToImageList(entry.streamEntity.thumbnailUrl), entry.streamEntity.uploader,
entry.streamEntity.streamType, entry.streamEntity.uploaderUrl, entry.streamEntity.duration,
entry.detailText, entry.streamId
)
fun toStreamInfoItem(): StreamInfoItem {
val item = StreamInfoItem(serviceId, url, name, type)
item.duration = duration
item.uploaderName = uploaderName
item.uploaderUrl = uploaderUrl
item.thumbnails = thumbnails
return item
}
}
private val StreamInfoItem.detailText: String
get() {
val context = App.instance
val views = if (viewCount >= 0) {
when (streamType) {
StreamType.AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount)
StreamType.LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount)
else -> Localization.shortViewCount(context, viewCount)
}
} else {
""
}
val date = Localization.relativeTimeOrTextual(context, uploadDate, textualUploadDate)
return if (views.isEmpty()) {
date.orEmpty()
} else if (date.isNullOrEmpty()) {
views
} else {
"$views • $date"
}
}
private val StreamStatisticsEntry.detailText: String
get() =
Localization.concatenateStrings(
Localization.shortViewCount(App.instance, watchCount),
DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).format(latestAccessDate),
ServiceHelper.getNameOfServiceById(streamEntity.serviceId)
)
@@ -0,0 +1,70 @@
package org.schabi.newpipe.ui.components.items.common
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.Icon
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.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.util.image.ImageStrategy
@Composable
fun Thumbnail(
images: List<Image>,
imageDescription: String,
@DrawableRes imagePlaceholder: Int,
cornerBackgroundColor: Color,
cornerIcon: ImageVector?,
cornerText: String,
contentScale: ContentScale,
modifier: Modifier = Modifier
) {
Box(contentAlignment = Alignment.BottomEnd) {
AsyncImage(
model = ImageStrategy.choosePreferredImage(images),
contentDescription = imageDescription,
placeholder = painterResource(imagePlaceholder),
error = painterResource(imagePlaceholder),
contentScale = contentScale,
modifier = modifier
)
Row(
modifier = Modifier
.padding(2.dp)
.background(cornerBackgroundColor)
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (cornerIcon != null) {
Icon(
imageVector = cornerIcon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
Text(
text = cornerText,
color = Color.White,
style = MaterialTheme.typography.bodySmall
)
}
}
}
@@ -17,15 +17,14 @@ 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.components.items.Playlist
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.NO_SERVICE_ID
@Composable
fun PlaylistListItem(
playlist: PlaylistInfoItem,
onClick: (InfoItem) -> Unit = {}
playlist: Playlist,
onClick: (Playlist) -> Unit = {}
) {
Row(
modifier = Modifier
@@ -49,7 +48,7 @@ fun PlaylistListItem(
)
Text(
text = playlist.uploaderName.orEmpty(),
text = playlist.uploaderName,
style = MaterialTheme.typography.bodySmall
)
}
@@ -60,8 +59,7 @@ fun PlaylistListItem(
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaylistListItemPreview() {
val playlist = PlaylistInfoItem(NO_SERVICE_ID, "", "Playlist")
playlist.uploaderName = "Uploader"
val playlist = Playlist(NO_SERVICE_ID, "", "Playlist", uploaderName = "Uploader")
AppTheme {
Surface {
@@ -1,66 +1,32 @@
package org.schabi.newpipe.ui.components.items.playlist
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
import androidx.compose.material3.Icon
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import androidx.compose.ui.res.stringResource
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.ui.components.items.Playlist
import org.schabi.newpipe.ui.components.items.common.Thumbnail
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.image.ImageStrategy
@Composable
fun PlaylistThumbnail(
playlist: PlaylistInfoItem,
playlist: Playlist,
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
) {
Icon(
imageVector = Icons.AutoMirrored.Default.PlaylistPlay,
contentDescription = null,
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)
)
}
}
Thumbnail(
images = playlist.thumbnails,
imageDescription = stringResource(R.string.playlist_content_description, playlist.name),
imagePlaceholder = R.drawable.placeholder_thumbnail_playlist,
cornerBackgroundColor = Color.Black.copy(alpha = 0.5f),
cornerIcon = Icons.AutoMirrored.Default.PlaylistPlay,
cornerText = Localization.localizeStreamCountMini(LocalContext.current, playlist.streamCount),
contentScale = contentScale,
modifier = modifier
)
}
@@ -0,0 +1,91 @@
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.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
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.ui.components.items.Stream
import org.schabi.newpipe.ui.theme.AppTheme
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StreamCardItem(
stream: Stream,
showProgress: Boolean,
isSelected: Boolean,
onClick: (Stream) -> Unit = {},
onLongClick: (Stream) -> Unit = {},
onDismissPopup: () -> Unit = {}
) {
Box {
Column(
modifier = Modifier
.combinedClickable(
onLongClick = { onLongClick(stream) },
onClick = { onClick(stream) }
)
.padding(top = 12.dp, start = 2.dp, end = 2.dp)
) {
StreamThumbnail(
stream = stream,
showProgress = showProgress,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth
)
Column(modifier = Modifier.padding(10.dp)) {
Text(
text = stream.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 2
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stream.uploaderName,
style = MaterialTheme.typography.bodySmall
)
Text(
text = stream.detailText,
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 StreamCardItemPreview(
@PreviewParameter(StreamItemPreviewProvider::class) stream: Stream
) {
AppTheme {
Surface {
StreamCardItem(stream, showProgress = false, isSelected = false)
}
}
}
@@ -0,0 +1,81 @@
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.Box
import androidx.compose.foundation.layout.Column
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.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.DpSize
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.ui.components.items.Stream
import org.schabi.newpipe.ui.theme.AppTheme
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StreamGridItem(
stream: Stream,
showProgress: Boolean,
isSelected: Boolean = false,
isMini: Boolean = false,
onClick: (Stream) -> Unit = {},
onLongClick: (Stream) -> Unit = {},
onDismissPopup: () -> Unit = {}
) {
Box {
Column(
modifier = Modifier
.combinedClickable(
onLongClick = { onLongClick(stream) },
onClick = { onClick(stream) }
)
.padding(12.dp)
) {
val size = if (isMini) DpSize(150.dp, 85.dp) else DpSize(246.dp, 138.dp)
StreamThumbnail(
stream = stream,
showProgress = showProgress,
modifier = Modifier.size(size)
)
Text(
text = stream.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 2
)
Text(text = stream.uploaderName, style = MaterialTheme.typography.bodySmall)
Text(
text = stream.detailText,
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 StreamGridItemPreview(
@PreviewParameter(StreamItemPreviewProvider::class) stream: Stream
) {
AppTheme {
Surface {
StreamGridItem(stream, showProgress = false, isSelected = false)
}
}
}
@@ -20,17 +20,17 @@ 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.components.items.Stream
import org.schabi.newpipe.ui.theme.AppTheme
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StreamListItem(
stream: StreamInfoItem,
stream: Stream,
showProgress: Boolean,
isSelected: Boolean,
onClick: (StreamInfoItem) -> Unit = {},
onLongClick: (StreamInfoItem) -> Unit = {},
onClick: (Stream) -> Unit = {},
onLongClick: (Stream) -> Unit = {},
onDismissPopup: () -> Unit = {}
) {
// Box serves as an anchor for the dropdown menu
@@ -58,10 +58,10 @@ fun StreamListItem(
maxLines = 2
)
Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall)
Text(text = stream.uploaderName, style = MaterialTheme.typography.bodySmall)
Text(
text = getStreamInfoDetail(stream),
text = stream.detailText,
style = MaterialTheme.typography.bodySmall
)
}
@@ -75,7 +75,7 @@ fun StreamListItem(
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StreamListItemPreview(
@PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem
@PreviewParameter(StreamItemPreviewProvider::class) stream: Stream
) {
AppTheme {
Surface {
@@ -1,20 +1,18 @@
package org.schabi.newpipe.ui.components.items.stream
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.ktx.findFragmentActivity
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.ui.components.common.DropdownTextMenuItem
import org.schabi.newpipe.ui.components.items.Stream
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.SparseItemUtil
import org.schabi.newpipe.util.external_communication.ShareUtils
@@ -22,58 +20,70 @@ import org.schabi.newpipe.viewmodels.StreamViewModel
@Composable
fun StreamMenu(
stream: StreamInfoItem,
stream: Stream,
expanded: Boolean,
onDismissRequest: () -> Unit
) {
val info = stream.toStreamInfoItem()
val context = LocalContext.current
val streamViewModel = viewModel<StreamViewModel>()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
if (PlayerHolder.isPlayQueueReady) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.enqueue_stream)) },
DropdownTextMenuItem(
text = R.string.enqueue_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
SparseItemUtil.fetchItemInfoIfSparse(context, info) {
NavigationHelper.enqueueOnPlayer(context, it)
}
}
)
if (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.enqueue_next_stream)) },
DropdownTextMenuItem(
text = R.string.enqueue_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.enqueueNextOnPlayer(context, it)
SparseItemUtil.fetchItemInfoIfSparse(context, info) {
NavigationHelper.enqueueOnPlayer(context, it)
}
}
)
}
}
DropdownMenuItem(
text = { Text(text = stringResource(R.string.start_here_on_background)) },
DropdownTextMenuItem(
text = R.string.start_here_on_background,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
SparseItemUtil.fetchItemInfoIfSparse(context, info) {
NavigationHelper.playOnBackgroundPlayer(context, it, true)
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.start_here_on_popup)) },
DropdownTextMenuItem(
text = R.string.start_here_on_popup,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
SparseItemUtil.fetchItemInfoIfSparse(context, info) {
NavigationHelper.playOnPopupPlayer(context, it, true)
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download)) },
if (stream.streamId != -1L) {
DropdownTextMenuItem(
text = R.string.delete,
onClick = {
onDismissRequest()
streamViewModel.deleteStreamHistory(stream.streamId)
}
)
}
DropdownTextMenuItem(
text = R.string.download,
onClick = {
onDismissRequest()
SparseItemUtil.fetchStreamInfoAndSaveToDatabase(
@@ -88,11 +98,11 @@ fun StreamMenu(
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.add_to_playlist)) },
DropdownTextMenuItem(
text = R.string.add_to_playlist,
onClick = {
onDismissRequest()
val list = listOf(StreamEntity(stream))
val list = listOf(StreamEntity(info))
PlaylistDialog.createCorrespondingDialog(context, list) { dialog ->
val tag = if (dialog is PlaylistAppendDialog) "append" else "create"
dialog.show(
@@ -102,29 +112,29 @@ fun StreamMenu(
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.share)) },
DropdownTextMenuItem(
text = R.string.share,
onClick = {
onDismissRequest()
ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.open_in_browser)) },
DropdownTextMenuItem(
text = R.string.open_in_browser,
onClick = {
onDismissRequest()
ShareUtils.openUrlInBrowser(context, stream.url)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.mark_as_watched)) },
DropdownTextMenuItem(
text = R.string.mark_as_watched,
onClick = {
onDismissRequest()
streamViewModel.markAsWatched(stream)
streamViewModel.markAsWatched(info)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.show_channel_details)) },
DropdownTextMenuItem(
text = R.string.show_channel_details,
onClick = {
onDismissRequest()
SparseItemUtil.fetchUploaderUrlIfSparse(
@@ -134,7 +144,7 @@ fun StreamMenu(
stream.uploaderUrl
) { url ->
val activity = context.findFragmentActivity()
NavigationHelper.openChannelFragment(activity, stream, url)
NavigationHelper.openChannelFragment(activity, info, url)
}
}
)
@@ -1,24 +1,17 @@
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
@@ -26,52 +19,42 @@ import coil3.compose.AsyncImage
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.ui.components.items.Stream
import org.schabi.newpipe.ui.components.items.common.Thumbnail
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
@Composable
fun StreamThumbnail(
stream: StreamInfoItem,
stream: Stream,
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
)
}
val isLive = StreamTypeUtil.isLiveStream(stream.type)
Thumbnail(
images = stream.thumbnails,
imageDescription = stringResource(R.string.stream_content_description, stream.name),
imagePlaceholder = R.drawable.placeholder_thumbnail_video,
cornerBackgroundColor = if (isLive) Color.Red else Color.Black.copy(alpha = 0.5f),
cornerIcon = null,
cornerText = if (isLive) {
stringResource(R.string.duration_live)
} else {
Localization.getDurationString(stream.duration)
},
contentScale = contentScale,
modifier = modifier
)
if (showProgress) {
val streamViewModel = viewModel<StreamViewModel>()
var progress by rememberSaveable { mutableLongStateOf(0L) }
LaunchedEffect(stream) {
progress = streamViewModel.getStreamState(stream)?.progressMillis ?: 0L
progress = streamViewModel.getStreamState(stream.toStreamInfoItem())?.progressMillis ?: 0L
}
if (progress != 0L) {
@@ -4,7 +4,6 @@ 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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@@ -21,65 +20,67 @@ 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.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.preference.PreferenceManager
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.flowOf
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.ui.components.items.ItemList
import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
import org.schabi.newpipe.ui.components.items.Playlist
import org.schabi.newpipe.ui.components.items.Stream
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 context = LocalContext.current
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
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))
}
val displayItems = info.relatedItems.mapNotNull {
when (it) {
is StreamInfoItem -> Stream(it)
is PlaylistInfoItem -> Playlist(it)
else -> null
}
}
ItemList(
items = info.relatedItems,
items = flowOf(PagingData.from(displayItems)).collectAsLazyPagingItems(),
mode = ItemViewMode.LIST,
listHeader = {
item {
header = {
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(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(4.dp),
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)
}
Text(text = stringResource(R.string.auto_queue_toggle))
Switch(
checked = isAutoQueueEnabled,
onCheckedChange = {
isAutoQueueEnabled = it
sharedPreferences.edit {
putBoolean(key, it)
}
)
}
}
}
if (info.relatedItems.isEmpty()) {
item {
EmptyStateComposable(
spec = EmptyStateSpec.NoVideos,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
}
)
}
}
@@ -87,6 +88,27 @@ fun RelatedItems(info: StreamInfo) {
)
}
@Suppress("ktlint:standard:function-naming")
private 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
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@@ -4,10 +4,6 @@ package org.schabi.newpipe.ui.emptystate
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
@@ -22,17 +18,12 @@ fun ComposeView.setEmptyStateComposable(
setViewCompositionStrategy(strategy)
setContent {
AppTheme {
CompositionLocalProvider(
LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background)
) {
EmptyStateComposable(
spec = spec,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
}
EmptyStateComposable(
spec = spec,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
}
}
}
@@ -78,7 +78,7 @@ fun AboutScreen(padding: PaddingValues) {
@Composable
private fun AboutScreenPreview() {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
Surface {
AboutScreen(PaddingValues(8.dp))
}
}
@@ -0,0 +1,105 @@
package org.schabi.newpipe.ui.screens
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.collectAsLazyPagingItems
import org.schabi.newpipe.R
import org.schabi.newpipe.local.history.HistoryViewModel
import org.schabi.newpipe.local.history.SortKey
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.ui.components.common.PlaybackControlButtons
import org.schabi.newpipe.ui.components.items.ItemList
import org.schabi.newpipe.ui.theme.AppTheme
@Composable
fun HistoryScreen(viewModel: HistoryViewModel = viewModel()) {
val sortKey by viewModel.sortKey.collectAsStateWithLifecycle()
val historyItems = viewModel.historyItems.collectAsLazyPagingItems()
val streams = historyItems.itemSnapshotList.mapNotNull { it?.toStreamInfoItem() }
val queue = SinglePlayQueue(streams, 0)
ItemList(historyItems, header = {
HistoryHeader(
sortKey = sortKey,
onSelectSortKey = viewModel::updateOrder,
queue = queue
)
})
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun HistoryHeader(
sortKey: SortKey,
queue: PlayQueue,
onSelectSortKey: (SortKey) -> Unit
) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally)
) {
HistorySortRow(sortKey, onSelectSortKey)
PlaybackControlButtons(queue)
}
}
@Composable
private fun HistorySortRow(
sortKey: SortKey,
onSelectSortKey: (SortKey) -> Unit
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.history_sort_label))
SingleChoiceSegmentedButtonRow {
SortKey.entries.forEachIndexed { index, key ->
SegmentedButton(
selected = key == sortKey,
onClick = { onSelectSortKey(key) },
shape = SegmentedButtonDefaults
.itemShape(index = index, count = SortKey.entries.size)
) {
Text(text = stringResource(key.title))
}
}
}
}
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun HistoryHeaderPreview() {
AppTheme {
Surface {
HistoryHeader(SortKey.MOST_PLAYED, SinglePlayQueue(listOf(), 0)) {}
}
}
}
@@ -85,7 +85,10 @@ private val darkScheme = darkColorScheme(
surfaceContainerHighest = surfaceContainerHighestDark
)
private val blackScheme = darkScheme.copy(surface = Color.Black)
private val blackScheme = darkScheme.copy(
background = Color.Black,
surface = Color.Black
)
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
@@ -16,7 +16,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
@@ -55,7 +54,7 @@ import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.ktx.ContextKt;
import org.schabi.newpipe.local.bookmark.BookmarkFragment;
import org.schabi.newpipe.local.feed.FeedFragment;
import org.schabi.newpipe.local.history.StatisticsPlaylistFragment;
import org.schabi.newpipe.local.history.HistoryFragment;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.local.subscription.SubscriptionFragment;
import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment;
@@ -121,7 +120,7 @@ public final class NavigationHelper {
}
/* PLAY */
public static void playOnMainPlayer(final AppCompatActivity activity,
public static void playOnMainPlayer(final FragmentActivity activity,
@NonNull final PlayQueue playQueue) {
final PlayQueueItem item = playQueue.getItem();
if (item != null) {
@@ -560,7 +559,7 @@ public final class NavigationHelper {
public static void openStatisticFragment(final FragmentManager fragmentManager) {
defaultTransaction(fragmentManager)
.replace(R.id.fragment_holder, new StatisticsPlaylistFragment())
.replace(R.id.fragment_holder, HistoryFragment.class, null, null)
.addToBackStack(null)
.commit();
}
@@ -3,6 +3,7 @@ package org.schabi.newpipe.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.rx3.awaitSingleOrNull
@@ -23,4 +24,10 @@ class StreamViewModel(application: Application) : AndroidViewModel(application)
historyRecordManager.markAsWatched(stream).await()
}
}
fun deleteStreamHistory(streamId: Long) {
viewModelScope.launch(Dispatchers.IO) {
historyRecordManager.deleteStreamHistoryAndState(streamId).await()
}
}
}
@@ -30,7 +30,7 @@
android:layout_height="50dp"
android:layout_toRightOf="@id/sortButtonIcon"
android:gravity="left|center"
android:text="@string/title_most_played"
android:text="@string/history_sort_views"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="15sp"
android:textStyle="bold"
@@ -403,7 +403,6 @@
<string name="right_gesture_control_title">اجراء الإيماءة اليمنى</string>
<string name="settings_file_charset_title">الرموز المسموح بها في أسماء الملفات</string>
<string name="audio_track_name">%1$s %2$s</string>
<string name="title_last_played">آخر ما تم تشغيله</string>
<string name="always_use_exoplayer_set_output_surface_workaround_title">استخدم دائمًا الحل البديل لإعداد سطح إخراج فيديو ExoPlayer</string>
<string name="next_stream">البث التالي</string>
<string name="disable_media_tunneling_automatic_info">تم تعطيل نفق وسائل الإعلام عن طريق التقصير على جهازك لأن نموذج جهازك معروف بأنه لا يدعمه.</string>
@@ -492,7 +491,6 @@
<string name="search_showing_result_for">عرض نتائج ل: %s</string>
<string name="open_with">افتح باستخدام</string>
<string name="delete_item_search_history">هل تريد حذف هذا العنصر من سجل البحث؟</string>
<string name="title_most_played">الأكثر تشغيلا</string>
<string name="show_original_time_ago_title">عرض الوقت الأصلي على العناصر</string>
<string name="import_from">استعادة مِن</string>
<string name="default_resolution_title">الدقة الافتراضية</string>
-3
View File
@@ -283,8 +283,6 @@
<string name="no_streams_available_download">لا يوجد بث متاح للتنزيل</string>
<string name="one_item_deleted">تم حذف عنصر واحد.</string>
<string name="app_license">NewPipe هو برنامج مفتوح المصدر وبحقوق متروكة: يمكنك استخدام الكود ودراسته وتحسينه كما شئت. وعلى وجه التحديد يمكنك إعادة توزيعه / أو تعديله تحت شروط رخصة GNU العمومية والتي نشرتها مؤسسة البرمجيات الحرة، سواء الإصدار 3 من الرخصة، أو (باختيارك) أي إصدار أحدث.</string>
<string name="title_last_played">آخر ما تم تشغيله</string>
<string name="title_most_played">الأكثر تشغيلا</string>
<string name="override_current_data">هذا سوف يُزيل إعداداتك الحالية.</string>
<string name="preferred_open_action_settings_title">طريقة \'التشغيل\' المفضلة</string>
<string name="preferred_open_action_settings_summary">الإجراء الافتراضي عند فتح المحتوى — %s</string>
@@ -567,7 +565,6 @@
<string name="search_showing_result_for">عرض نتائج ل: %s</string>
<string name="never">أبدا</string>
<string name="wifi_only">فقط على شبكة Wi-Fi</string>
<string name="autoplay_summary">بدء التشغيل تلقائياً — %s</string>
<string name="title_activity_play_queue">تشغيل قائمة الانتظار</string>
<string name="unsupported_url_dialog_message">تعذر التعرف على الرابط. فتح باستخدام تطبيق آخر؟</string>
<string name="auto_queue_toggle">قائمة انتظار تلقائيّة</string>
-2
View File
@@ -332,8 +332,6 @@
<string name="app_license_title">NewPipe-ın Lisenziyası</string>
<string name="action_history">Tarixçə</string>
<string name="delete_item_search_history">Bu elementi axtarış tarixçəsindən silmək istəyirsiniz\?</string>
<string name="title_last_played">Son Oynadılan</string>
<string name="title_most_played">Ən Çox Oynadılan</string>
<string name="select_a_kiosk">Bölmə seç</string>
<string name="import_complete_toast">İdxal edildi</string>
<string name="no_valid_zip_file">Etibarlı ZIP faylı yoxdur</string>
@@ -407,8 +407,6 @@
<string name="channel_page_summary">Páxina d\'una canal</string>
<string name="default_kiosk_page_summary">Quioscu predetermináu</string>
<string name="kiosk_page_summary">Páxina de quioscu</string>
<string name="title_most_played">Lo más reproducío</string>
<string name="title_last_played">Lo último reproducío</string>
<string name="app_license">NewPipe ye software copyleft: pues usalu, estudialu, compartilu y ameyoralu como quieras. N\'especial, pues redistribuyilu y/o modificalu baxo los términos de la GNU General Public License según espublizó la Free Software Foundation, quier la versión 3 de la llicencia quier (na to opinión) cualesquier versión posterior.</string>
<string name="privacy_policy_encouragement">El proyeutu de NewPipe toma mui en serio la privacidá. Poro, l\'aplicación nun recueye nengún datu ensin el to consentimientu.
\nLa política de privacidá de NewPipe desplica en detalle los datos que s\'unvien y atroxen cuando unvies un informe de casque.</string>
@@ -182,8 +182,6 @@
<string name="blank_page_summary">Bo\'sh sahifa</string>
<string name="main_page_content_summary">Asosiy sahifada qanday yorliqlar ko\'rsatilgan</string>
<string name="main_page_content">Asosiy sahifaning tarkibi</string>
<string name="title_most_played">Eng ko\'p ijrolar etilganlar</string>
<string name="title_last_played">Oxirgi ijro</string>
<string name="delete_item_search_history">Ushbu narsani qidiruv tarixidan o\'chirmoqchimisiz\?</string>
<string name="action_history">Tarix</string>
<string name="title_activity_history">Tarix</string>
+1 -3
View File
@@ -229,9 +229,7 @@
<string name="title_activity_history">Гісторыя</string>
<string name="action_history">Гісторыя</string>
<string name="delete_item_search_history">Выдаліць гэты элемент з гісторыі пошуку?</string>
<string name="title_last_played">Прайгравалася нядаўна</string>
<string name="title_most_played">Прайгравалася найбольш</string>
<string name="main_page_content">Змесціва галоўнай старонкі</string>
<string name="main_page_content">Кантэнт галоўнай старонкі</string>
<string name="blank_page_summary">Пустая старонка</string>
<string name="kiosk_page_summary">Старонка кіёска</string>
<string name="channel_page_summary">Старонка канала</string>
-2
View File
@@ -220,8 +220,6 @@
<string name="title_activity_history">История</string>
<string name="action_history">История</string>
<string name="delete_item_search_history">Искате ли да изтриете този елемент от историята на търсенията?</string>
<string name="title_last_played">Последно възпроизвеждани</string>
<string name="title_most_played">Най-възпроизвеждани</string>
<string name="main_page_content">Съдържание на главната страница</string>
<string name="blank_page_summary">Празна страница</string>
<string name="kiosk_page_summary">Страница-павилион</string>
@@ -192,8 +192,6 @@
<string name="select_a_channel">একটি চ্যানেল পছন্দ করুন</string>
<string name="channel_page_summary">চ্যানেল এর পাতা</string>
<string name="blank_page_summary">খালি পাতা</string>
<string name="title_most_played">সবথেকে বেশি চালানো</string>
<string name="title_last_played">শেষ চালানো</string>
<string name="read_full_license">লাইসেন্স পড়ুন</string>
<string name="app_license_title">নিউপাইপ এর লাইসেন্স</string>
<string name="read_privacy_policy">প্রাইভেসি পলিসি পড়ুন</string>
@@ -233,8 +233,6 @@
<string name="play_queue_audio_settings">অডিও সেটিং</string>
<string name="play_queue_stream_detail">বিবরণ</string>
<string name="local">স্থানীয়</string>
<string name="title_most_played">সবথেকে বেশি চালানো</string>
<string name="title_last_played">শেষ চালানো</string>
<string name="give_back">ফিরিয়ে দিন</string>
<string name="contribution_title">যোগদান</string>
<string name="title_activity_about">নিউ পাইপ এর সম্বন্ধে</string>
-2
View File
@@ -59,8 +59,6 @@
<string name="select_a_channel">একটি চ্যানেল পছন্দ করুন</string>
<string name="channel_page_summary">চ্যানেল এর পাতা</string>
<string name="blank_page_summary">খালি পাতা</string>
<string name="title_most_played">সবথেকে বেশি চালানো</string>
<string name="title_last_played">শেষ চালানো</string>
<string name="action_history">ইতিহাস</string>
<string name="title_activity_history">ইতিহাস</string>
<string name="read_full_license">লাইসেন্স পড়ুন</string>
-2
View File
@@ -197,8 +197,6 @@
<string name="view_on_github">Visualitza a GitHub</string>
<string name="donation_title">Feu una donació</string>
<string name="website_encouragement">Per a més informació i notícies, visiteu el nostre web.</string>
<string name="title_last_played">Últimes reproduccions</string>
<string name="title_most_played">Més reproduïts</string>
<string name="kiosk_page_summary">Pàgina de quiosc</string>
<string name="channel_page_summary">Pàgina de canal</string>
<string name="select_a_kiosk">Trieu un quiosc</string>
-3
View File
@@ -99,7 +99,6 @@
<string name="app_license_title">مۆڵەتنامەی نیوپایپ</string>
<string name="show_hold_to_append_title">پیشاندانی ڕێنمایی ”داگرتن تا پاشکۆ”</string>
<string name="msg_threads">دابەشکراوەکان</string>
<string name="title_most_played">زۆرترین لێدراو</string>
<string name="unbookmark_playlist">لادانی نیشانه‌كراو</string>
<string name="tab_licenses">مۆڵەتەکان</string>
<string name="subscription_update_failed">ناتوانرێت به‌ژداریكردنه‌كه‌ نوێبكرێته‌وه‌</string>
@@ -308,7 +307,6 @@
<string name="clear_views_history_summary">مێژوو دەسڕێتەوە لەگەڵ په‌خشه‌ لێدراوه‌كان و شوێنی کارپێکەر</string>
<string name="caption_auto_generated">دانان-خۆکار</string>
<string name="one_item_deleted">١ بابەت سڕایەوە.</string>
<string name="preferred_open_action_settings_summary">کرداری بنەڕەتی لەکاتی کردنەوەی بابەتدا — %s</string>
<string name="select_a_kiosk">هکیۆسکێک دیار بکە</string>
<string name="conferences">کۆنفرانسەکان</string>
<string name="open_in_popup_mode">كردنه‌وه‌ له‌ دۆخی په‌نجه‌ره‌</string>
@@ -502,7 +500,6 @@
<string name="play_queue_audio_settings">ڕێکخستنەکانی دەنگ</string>
<string name="downloads_storage_ask_summary">پرست پێ دەکرێت بۆ شوێنی دابەزاندنی هەر بابەتێک.
\nهەڵبژێرەری فۆڵدەری سیستەم کارابکە (SAF) گەر دەتەوێت بابەتەکانت لە بیرگەی دەرەکیدا داببەزێنرێن</string>
<string name="title_last_played">دواین لێدراو</string>
<string name="could_not_setup_download_menu">ناتوانرێ لیستی دابه‌زاندن دابنرێت</string>
<string name="app_update_available_notification_title">وەشانی نوێی نیوپایپ بەردەستە!</string>
<string name="playlist_thumbnail_change_success">وێنۆچکەی خشتەلێدان گۆڕدرا.</string>
-2
View File
@@ -222,8 +222,6 @@
<string name="create">Vytvořit</string>
<string name="dismiss">Zahodit</string>
<string name="rename">Přejmenovat</string>
<string name="title_last_played">Poslední přehráno</string>
<string name="title_most_played">Nejvíce přehráno</string>
<string name="always_ask_open_action">Vždy se zeptat</string>
<string name="create_playlist">Nový playlist</string>
<string name="rename_playlist">Přejmenovat</string>
-2
View File
@@ -228,8 +228,6 @@
<string name="title_activity_history">Historik</string>
<string name="action_history">Historik</string>
<string name="delete_item_search_history">Vil du slette dette element fra søgehistorikken\?</string>
<string name="title_last_played">Sidst Afspillet</string>
<string name="title_most_played">Mest Afspillet</string>
<string name="main_page_content">Indhold af hovedside</string>
<string name="main_page_content_summary">Hvilke faner vises på hovedsiden</string>
<string name="blank_page_summary">Tom side</string>
-2
View File
@@ -217,8 +217,6 @@
<string name="detail_drag_description">Zum Neuordnen ziehen</string>
<string name="create">Erstellen</string>
<string name="rename">Umbenennen</string>
<string name="title_last_played">Zuletzt wiedergegeben</string>
<string name="title_most_played">Am häufigsten wiedergegeben</string>
<string name="always_ask_open_action">Immer fragen</string>
<string name="create_playlist">Neue Wiedergabeliste</string>
<string name="rename_playlist">Umbenennen</string>
-2
View File
@@ -225,8 +225,6 @@
<string name="app_license">Το NewPipe είναι copylelft ελεύθερο λογισμικό: Μπορείτε να το χρησιμοποιήσετε, να το μελετήσετε, να το μοιραστείτε και να το βελτιώσετε κατά βούληση. Ειδικότερα, μπορείτε να το αναδιανείμετε ή/και να το τροποποιήσετε υπό την άδεια GNU General Public Licence όπως αυτή εκδόθηκε από το Free Software Foundation, είτε υπό την έκδοση 3 της άδειας, είτε (προαιρετικά) υπό οποιαδήποτε μεταγενέστερη άδεια.</string>
<string name="read_full_license">Ανάγνωση της άδειας</string>
<string name="delete_item_search_history">Θέλετε να σβήσετε αυτό το αντικείμενο από το ιστορικό αναζήτησης;</string>
<string name="title_last_played">Τελευταία αναπαραγωγή</string>
<string name="title_most_played">Αναπαράχθηκε περισσότερο</string>
<string name="main_page_content">Περιεχόμενο της κεντρικής σελίδας</string>
<string name="blank_page_summary">Κενή σελίδα</string>
<string name="kiosk_page_summary">Σελίδα περιπτέρου</string>
-2
View File
@@ -315,8 +315,6 @@
<string name="create">Krei</string>
<string name="dismiss">Rezigni</string>
<string name="rename">Alinomi</string>
<string name="title_last_played">Lasta Ludado</string>
<string name="title_most_played">Plej ludataj filmetoj</string>
<string name="caption_none">Neniuj Subtitoloj</string>
<string name="resize_fit">Alĝustigi</string>
<string name="resize_fill">Plenigi</string>
-2
View File
@@ -222,8 +222,6 @@
<string name="create">Crear</string>
<string name="dismiss">Descartar</string>
<string name="rename">Cambiar nombre</string>
<string name="title_last_played">Última reproducción</string>
<string name="title_most_played">Más reproducido</string>
<string name="always_ask_open_action">Preguntar siempre</string>
<string name="create_playlist">Lista de reproducción nueva</string>
<string name="rename_playlist">Cambiar nombre</string>
-2
View File
@@ -210,8 +210,6 @@
<string name="title_activity_history">Ajalugu</string>
<string name="action_history">Ajalugu</string>
<string name="delete_item_search_history">Kas kustutame selle kirje otsinguajaloost\?</string>
<string name="title_last_played">Viimati esitatud</string>
<string name="title_most_played">Enim esitatud</string>
<string name="main_page_content">Avalehe sisu</string>
<string name="blank_page_summary">Tühi leht</string>
<string name="kiosk_page_summary">Kioskivaade</string>
-2
View File
@@ -238,8 +238,6 @@
<string name="dismiss">Baztertu</string>
<string name="rename">Berrizendatu</string>
<string name="one_item_deleted">Elementu 1 ezabatuta.</string>
<string name="title_last_played">Erreproduzitutako azkena</string>
<string name="title_most_played">Gehien erreproduzitutakoa</string>
<string name="export_complete_toast">Esportatuta</string>
<string name="import_complete_toast">Inportatuta</string>
<string name="no_valid_zip_file">Ez da baliozko ZIP fitxategia</string>
-2
View File
@@ -186,8 +186,6 @@
<string name="title_activity_history">تاریخچه</string>
<string name="action_history">تاریخچه</string>
<string name="delete_item_search_history">می‌خواهید این مورد را از تاریخچه جستجو پاک کنید؟</string>
<string name="title_last_played">آخرین پخش‌شده</string>
<string name="title_most_played">بیشترین پخش‌شده</string>
<string name="main_page_content">محتوای صفحه اصلی</string>
<string name="blank_page_summary">صفحهٔ خالی</string>
<string name="kiosk_page_summary">صفحهٔ کیوسک</string>
-2
View File
@@ -241,8 +241,6 @@
<string name="auto_queue_title">Lisää ehdotettu suoratoistosisältö automaattisesti soittolistaan</string>
<string name="no_streams_available_download">Suoratoistosisältöä ei saatavilla ladattavaksi</string>
<string name="app_license">NewPipe on vapaata ohjelmistoa. Voit käyttää, opiskella, jakaa ja parantaa sitä mielesi mukaan. Tarkemmin sanottuna voit jakaa sitä edelleen ja/tai muokata sitä Free Software Foundationin julkaiseman GNU General Public Licensen, version 3 tai uudemman, ehdoilla.</string>
<string name="title_last_played">Viimeksi toistettu</string>
<string name="title_most_played">Eniten toistetut</string>
<string name="export_complete_toast">Vienti valmis</string>
<string name="import_complete_toast">Tuonti valmis</string>
<string name="no_valid_zip_file">Virheellinen ZIP-tiedosto</string>
-2
View File
@@ -170,7 +170,6 @@
<string name="chapters">Mga Kabanata</string>
<string name="no_app_to_open_intent">Walang app sa device mo ang makakabukas nito</string>
<string name="featured">Tampok</string>
<string name="title_last_played">Huling Pinanood</string>
<string name="action_history">Kasaysayan</string>
<string name="no_one_listening">Walang nakikinig</string>
<string name="search_no_results">Walang nahanap</string>
@@ -181,7 +180,6 @@
<item quantity="other">Mga %s nanonood</item>
</plurals>
<string name="no_videos">Walang mga video</string>
<string name="title_most_played">Madalas na Pinanood</string>
<plurals name="videos">
<item quantity="one">%s video</item>
<item quantity="other">Mga %s na video</item>
-2
View File
@@ -221,8 +221,6 @@
<string name="create">Créer</string>
<string name="dismiss">Rejeter</string>
<string name="rename">Renommer</string>
<string name="title_last_played">Dernière lecture</string>
<string name="title_most_played">Vidéos les plus vues</string>
<string name="always_ask_open_action">Toujours demander</string>
<string name="create_playlist">Nouvelle liste de lecture</string>
<string name="rename_playlist">Renommer</string>
-2
View File
@@ -224,8 +224,6 @@
<string name="title_activity_history">Historial</string>
<string name="action_history">Historial</string>
<string name="delete_item_search_history">Desexa eliminar este elemento do historial de procura?</string>
<string name="title_last_played">Última reprodución</string>
<string name="title_most_played">Máis reproducido</string>
<string name="main_page_content">Contido da páxina principal</string>
<string name="blank_page_summary">Páxina en branco</string>
<string name="kiosk_page_summary">Páxina de quiosco</string>
-2
View File
@@ -288,8 +288,6 @@
\nמדיניות הפרטיות של NewPipe מסבירה בפרטי פרטים אילו נתונים נשלחים ומאוחסנים בעת שליחת דיווח על תקלה.</string>
<string name="read_privacy_policy">הצגת מדיניות הפרטיות</string>
<string name="app_license">NewPipe הוא יישומון חופשי בהתאם לרישיון קופילפט: מותר לך להשתמש, לחקור, לשתף ולשפר בכל דרך שנראית לך. במיוחד מותר לך להפיץ מחדש ו/או לשנות תחת תנאי הרישיון הציבורי הכללי של GNU כפי שמופץ על ידי קרן התכנה החופשית, בין אם גרסה 3 של הרישיון או (לשיקולך) כל גרסה עדכנית יותר שלו.</string>
<string name="title_last_played">התנגנו אחרונים</string>
<string name="title_most_played">הכי נצפים</string>
<string name="could_not_import_all_files">אזהרה: ייבוא חלק מהקבצים נכשל.</string>
<string name="override_current_data">פעולה זו תדרוס את ההגדרות הקיימות.</string>
<string name="import_settings">לייבא גם הגדרות\?</string>
-2
View File
@@ -207,8 +207,6 @@
<string name="give_back">वापस दो</string>
<string name="website_title">वेबसाइट</string>
<string name="website_encouragement">अधिक जानकारी और खबरों के लिए न्यूपाइप की वेबसाइट पर जाएं।</string>
<string name="title_last_played">पिछला चलाया गया</string>
<string name="title_most_played">अधिकतम चलाए गए</string>
<string name="export_complete_toast">निर्यात संपन्न हुआ</string>
<string name="import_complete_toast">आयात संपन्न हुआ</string>
<string name="no_valid_zip_file">कोई वैध ज़िप फ़ाइल नहीं है</string>
-2
View File
@@ -249,8 +249,6 @@
<string name="website_encouragement">Posjeti NewPipe web-stranicu za više informacija i vijesti.</string>
<string name="privacy_policy_title">NewPipe pravila o privatnosti</string>
<string name="read_privacy_policy">Pročitaj pravila o privatnosti</string>
<string name="title_last_played">Zadnje svirano</string>
<string name="title_most_played">Najviše reproducirano</string>
<string name="export_complete_toast">Izvezeno</string>
<string name="import_complete_toast">Uvezeno</string>
<string name="no_valid_zip_file">Nema važeće ZIP datoteke</string>
-2
View File
@@ -214,8 +214,6 @@
<string name="title_activity_history">Előzmények</string>
<string name="action_history">Előzmények</string>
<string name="delete_item_search_history">Törli ezt az elemet a keresési előzmények közül\?</string>
<string name="title_last_played">Utoljára lejátszott</string>
<string name="title_most_played">Legtöbbet lejátszott</string>
<string name="main_page_content">Főoldal tartalma</string>
<string name="blank_page_summary">Üres oldal</string>
<string name="kiosk_page_summary">Témagyűjtemények</string>
-2
View File
@@ -136,8 +136,6 @@
<string name="app_license_title">Licentia de NewPipe</string>
<string name="read_full_license">Leger le licentia</string>
<string name="action_history">Chronologia</string>
<string name="title_last_played">Ultime reproductiones</string>
<string name="title_most_played">Le plus reproducite</string>
<string name="main_page_content">Contento del pagina principal</string>
<string name="select_a_channel">Selige un canal</string>
<string name="done">Preste</string>
+1 -3
View File
@@ -186,9 +186,7 @@
<string name="give_back">Beri dukungan</string>
<string name="website_title">Situs Web</string>
<string name="website_encouragement">Kunjungi situs web NewPipe untuk melihat info dan berita lebih lanjut.</string>
<string name="delete_item_search_history">Apakah ingin hapus butir ini dari riwayat pencarian?</string>
<string name="title_last_played">Terakhir Diputar</string>
<string name="title_most_played">Sering Diputar</string>
<string name="delete_item_search_history">Apakah Anda ingin menghapus item ini dari riwayat pencarian\?</string>
<string name="main_page_content">Konten halaman utama</string>
<string name="blank_page_summary">Halaman kosong</string>
<string name="kiosk_page_summary">Halaman kiosk</string>
-2
View File
@@ -263,8 +263,6 @@
<string name="title_activity_history">Áhorf</string>
<string name="action_history">Áhorf</string>
<string name="read_full_license">Lesa leyfi</string>
<string name="title_last_played">Nýlega spilað</string>
<string name="title_most_played">Mest spilað</string>
<string name="main_page_content">Aðalsíða</string>
<string name="export_complete_toast">Flutt út</string>
<string name="import_complete_toast">Flutt inn</string>
-2
View File
@@ -222,8 +222,6 @@
<string name="create">Crea</string>
<string name="dismiss">Ignora</string>
<string name="rename">Rinomina</string>
<string name="title_last_played">Ultima riproduzione</string>
<string name="title_most_played">I più riprodotti</string>
<string name="always_ask_open_action">Chiedi ogni volta</string>
<string name="create_playlist">Nuova playlist</string>
<string name="rename_playlist">Rinomina</string>
-2
View File
@@ -265,8 +265,6 @@
<string name="privacy_policy_encouragement">NewPipe プロジェクトはあなたのプライバシーを非常に大切にしています。あなたの同意がない限り、アプリはいかなるデータも収集しません。
\nNewPipe のプライバシー・ポリシーでは、クラッシュリポート送信時にどのような種類のデータが送信・記録されるかを詳細に説明しています。</string>
<string name="app_license">NewPipe はコピーレフトなソフトウェアです。あなたは自由にそれを使用し、研究し、共有し、そして改善することができます。あなたは、GNU フリーソフトウェア財団が公開する GNU General Public ライセンス バージョン3以降の下に、自由に再配布・修正を行うことができます。</string>
<string name="title_last_played">最終再生日時</string>
<string name="title_most_played">最も再生された動画</string>
<string name="resize_zoom">拡大</string>
<string name="controls_add_to_playlist_title">プレイリスト</string>
<string name="show_hold_to_append_title">「長押しでキューに追加」のヒントを表示</string>
-2
View File
@@ -314,8 +314,6 @@
<string name="title_activity_history">ისტორია</string>
<string name="action_history">ისტორია</string>
<string name="delete_item_search_history">გსურთ წაშალოთ ეს ელემენტი ძიების ისტორიიდან\?</string>
<string name="title_last_played">ბოლოს დაუკრა</string>
<string name="title_most_played">ხშირად დაკრული</string>
<string name="main_page_content">მთავარი გვერდის შინაარსი</string>
<string name="main_page_content_summary">რა ჩანართებია ნაჩვენები მთავარ გვერდზე</string>
<string name="main_page_content_swipe_remove">გადაფურცლეთ ელემენტები მათი ამოსაშლელად</string>
-2
View File
@@ -91,7 +91,6 @@
<string name="minimize_on_exit_none_description">Ulac</string>
<string name="msg_copied">Yettwanɣel ɣef afus</string>
<string name="playlists">Tibdarin n tɣuri</string>
<string name="title_last_played">Aneggaru yettwaslekmen</string>
<string name="autoplay_title">Taɣuri tawurmant</string>
<string name="error_snackbar_action">Aneqqis</string>
<string name="download">Sader</string>
@@ -212,7 +211,6 @@
<string name="more_options">Ugar n tnefrunin</string>
<string name="share_dialog_title">Bḍu d</string>
<string name="error_details_headline">Talqayt :</string>
<string name="title_most_played">Tid yettwaɣran s waṭas</string>
<string name="tab_choose">Fren iccer</string>
<string name="metadata_language">Tutlayt</string>
<string name="unknown_content">[Arussin]</string>
-2
View File
@@ -154,8 +154,6 @@
<string name="blank_page_summary">Rûpelê Vala</string>
<string name="main_page_content_summary">Kîjan tabî di rûpelê sereke de têne nîşandin</string>
<string name="main_page_content">Naveroka rûpelê sereke</string>
<string name="title_most_played">Pir lîstin</string>
<string name="title_last_played">Lîstika dawîn</string>
<string name="delete_item_search_history">Ma hûn dixwazin vî tiştî ji dîroka lêgerînê paqij bikin\?</string>
<string name="action_history">Dîrok</string>
<string name="title_activity_history">Dîrok</string>
-2
View File
@@ -206,8 +206,6 @@
<string name="dismiss">취소</string>
<string name="rename">이름 바꾸기</string>
<string name="title_activity_recaptcha">reCAPTCHA 확인</string>
<string name="title_last_played">마지막으로 재생</string>
<string name="title_most_played">가장 많이 재생</string>
<string name="export_complete_toast">내보내기 완료</string>
<string name="import_complete_toast">가져오기 완료</string>
<string name="no_valid_zip_file">유효한 ZIP 파일 없음</string>
-2
View File
@@ -187,8 +187,6 @@
<string name="title_activity_history">مێژوو</string>
<string name="action_history">مێژوو</string>
<string name="delete_item_search_history">ئایا دەتەوێ ئەم بابەتە لە مێژووی گەڕان بسڕدرێتەوە؟</string>
<string name="title_last_played">دواین کارپێکراو</string>
<string name="title_most_played">زۆرترین کارپێکراو</string>
<string name="main_page_content">ناوەڕۆکی پەڕەی سەرەکی</string>
<string name="play_queue_remove">لادان</string>
<string name="play_queue_stream_detail">وردەکارییەکان</string>
-2
View File
@@ -211,8 +211,6 @@
<string name="create">Kurti</string>
<string name="dismiss">Nutraukti</string>
<string name="rename">Pervardyti</string>
<string name="title_last_played">Vėliausiai žiūrėta</string>
<string name="title_most_played">Dažniausiai žiūrėta</string>
<string name="export_complete_toast">Eksportavimas baigtas</string>
<string name="import_complete_toast">Importavimas baigtas</string>
<string name="no_valid_zip_file">Netinkamas ZIP failas</string>
-2
View File
@@ -46,8 +46,6 @@
<string name="blank_page_summary">Tukša lapa</string>
<string name="main_page_content_summary">Kādas cilnes rādīt galvenajā lapā</string>
<string name="main_page_content">Galvenā lapa</string>
<string name="title_most_played">Visvairāk Atskaņotais</string>
<string name="title_last_played">Pēdējais atskaņotais</string>
<string name="delete_item_search_history">Vai tiešām vēlaties izdzēst šo vaicājumu no meklēšanas vēstures?</string>
<string name="action_history">Vēsture</string>
<string name="title_activity_history">Vēsture</string>
-2
View File
@@ -206,8 +206,6 @@
<string name="title_activity_history">Историја</string>
<string name="action_history">Историја</string>
<string name="delete_item_search_history">Сакаш да го избришеш предметот од историјата?</string>
<string name="title_last_played">Последно пуштено</string>
<string name="title_most_played">Најгледани</string>
<string name="main_page_content">Содржина</string>
<string name="blank_page_summary">Празна страна</string>
<string name="kiosk_page_summary">Киоск</string>
-2
View File
@@ -97,8 +97,6 @@
<string name="blank_page_summary">ശൂന്യമായ പേജ്</string>
<string name="main_page_content_summary">പ്രധാന പേജിൽ കാണിക്കേണ്ട ടാബുകൾ</string>
<string name="main_page_content">പ്രധാന പേജ് ഉള്ളടക്കം</string>
<string name="title_most_played">ഏറ്റവും കൂടുതൽ തവണ പ്ലേ ചെയ്തത്</string>
<string name="title_last_played">അവസാനം പ്ലേ ചെയ്തത്</string>
<string name="delete_item_search_history">സെർച്ച് ചരിത്രത്തിൽനിന്ന് ഈ item നീക്കം ചെയ്യട്ടെയോ\?</string>
<string name="action_history">ചരിത്രം</string>
<string name="title_activity_history">ചരിത്രം</string>
-2
View File
@@ -231,8 +231,6 @@
<string name="title_activity_history">Sejarah</string>
<string name="action_history">Sejarah</string>
<string name="delete_item_search_history">Adakah anda mahu memadamkan item ini dari sejarah carian\?</string>
<string name="title_last_played">Terakhir dimainkan</string>
<string name="title_most_played">Kebanyakan dimainkan</string>
<string name="main_page_content">Kandungan halaman utama</string>
<string name="main_page_content_summary">Tab apa yang ditunjukkan pada halaman utama</string>
<string name="blank_page_summary">Halaman Kosong</string>
@@ -219,8 +219,6 @@
<string name="create">Opprett</string>
<string name="dismiss">Forkast</string>
<string name="rename">Gi nytt navn</string>
<string name="title_last_played">Sist spilt</string>
<string name="title_most_played">Mest spilt</string>
<string name="always_ask_open_action">Alltid spør</string>
<string name="create_playlist">Ny spilleliste</string>
<string name="rename_playlist">Gi nytt navn</string>
-2
View File
@@ -239,8 +239,6 @@
<string name="title_activity_history">इतिहास</string>
<string name="action_history">इतिहास</string>
<string name="delete_item_search_history">तपाईं खोज इतिहासबाट यो वस्तु मेटाउन चाहनुहुन्छ\?</string>
<string name="title_last_played">पछिल्लो पालि खोलिएको</string>
<string name="title_most_played">धेरै हेरिएको</string>
<string name="main_page_content">मुख्य पृष्ठको सामग्री</string>
<string name="main_page_content_summary">मुख्य पृष्ठ मा कुनकुन ट्याबहरू देखाइन्छ</string>
<string name="blank_page_summary">खाली पृष्ठ</string>

Some files were not shown because too many files have changed in this diff Show More