mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-10-12 02:31:49 +00:00
Merge pull request #11947 from Isira-Seneviratne/History-Compose
Migrate watch history to Jetpack Compose
This commit is contained in:
@@ -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)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
-161
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -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 {
|
||||
|
||||
+14
-48
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+19
-36
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user