Merge pull request #3431 from Bnyro/subscription-groups

Subscription groups
This commit is contained in:
Bnyro 2023-03-28 18:09:39 +02:00 committed by GitHub
commit 98a094ffd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1099 additions and 25 deletions

View File

@ -0,0 +1,496 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "435fb16c318b2b9fdd0d0120931f7400",
"entities": [
{
"tableName": "watchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "watchPosition",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "searchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))",
"fields": [
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"query"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "customInstance",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "apiUrl",
"columnName": "apiUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "frontendUrl",
"columnName": "frontendUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "localSubscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))",
"fields": [
{
"fieldPath": "channelId",
"columnName": "channelId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"channelId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "playlistBookmark",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, PRIMARY KEY(`playlistId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistName",
"columnName": "playlistName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylistItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `uploader` TEXT NOT NULL, `uploadDate` TEXT, `thumbnailPath` TEXT, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailPath",
"columnName": "thumbnailPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "downloadItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `videoId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `path` TEXT NOT NULL, `url` TEXT, `format` TEXT, `quality` TEXT, `downloadSize` INTEGER NOT NULL, FOREIGN KEY(`videoId`) REFERENCES `download`(`videoId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "format",
"columnName": "format",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "quality",
"columnName": "quality",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "downloadSize",
"columnName": "downloadSize",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_downloadItem_path",
"unique": true,
"columnNames": [
"path"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloadItem_path` ON `${TABLE_NAME}` (`path`)"
}
],
"foreignKeys": [
{
"table": "download",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"videoId"
],
"referencedColumns": [
"videoId"
]
}
]
},
{
"tableName": "subscriptionGroups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `channels` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "channels",
"columnName": "channels",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '435fb16c318b2b9fdd0d0120931f7400')"
]
}
}

View File

@ -10,6 +10,7 @@ import com.github.libretube.db.dao.LocalPlaylistsDao
import com.github.libretube.db.dao.LocalSubscriptionDao import com.github.libretube.db.dao.LocalSubscriptionDao
import com.github.libretube.db.dao.PlaylistBookmarkDao import com.github.libretube.db.dao.PlaylistBookmarkDao
import com.github.libretube.db.dao.SearchHistoryDao import com.github.libretube.db.dao.SearchHistoryDao
import com.github.libretube.db.dao.SubscriptionGroupsDao
import com.github.libretube.db.dao.WatchHistoryDao import com.github.libretube.db.dao.WatchHistoryDao
import com.github.libretube.db.dao.WatchPositionDao import com.github.libretube.db.dao.WatchPositionDao
import com.github.libretube.db.obj.CustomInstance import com.github.libretube.db.obj.CustomInstance
@ -20,6 +21,7 @@ import com.github.libretube.db.obj.LocalPlaylistItem
import com.github.libretube.db.obj.LocalSubscription import com.github.libretube.db.obj.LocalSubscription
import com.github.libretube.db.obj.PlaylistBookmark import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.db.obj.WatchPosition import com.github.libretube.db.obj.WatchPosition
@ -34,13 +36,15 @@ import com.github.libretube.db.obj.WatchPosition
LocalPlaylist::class, LocalPlaylist::class,
LocalPlaylistItem::class, LocalPlaylistItem::class,
Download::class, Download::class,
DownloadItem::class DownloadItem::class,
SubscriptionGroup::class
], ],
version = 10, version = 11,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 7, to = 8), AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9), AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10) AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11)
] ]
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -84,4 +88,9 @@ abstract class AppDatabase : RoomDatabase() {
* Downloads * Downloads
*/ */
abstract fun downloadDao(): DownloadDao abstract fun downloadDao(): DownloadDao
/**
* Subscription groups
*/
abstract fun subscriptionGroupsDao(): SubscriptionGroupsDao
} }

View File

@ -1,10 +1,13 @@
package com.github.libretube.db package com.github.libretube.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.github.libretube.api.JsonHelper
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.toLocalDate import kotlinx.datetime.toLocalDate
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
object Converters { object Converters {
@TypeConverter @TypeConverter
@ -18,4 +21,10 @@ object Converters {
@TypeConverter @TypeConverter
fun stringToPath(string: String?) = string?.let { Paths.get(it) } fun stringToPath(string: String?) = string?.let { Paths.get(it) }
@TypeConverter
fun stringListToJson(value: List<String>) = JsonHelper.json.encodeToString(value)
@TypeConverter
fun jsonToStringList(value: String) = JsonHelper.json.decodeFromString<List<String>>(value)
} }

View File

@ -0,0 +1,22 @@
package com.github.libretube.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.libretube.db.obj.SubscriptionGroup
@Dao()
interface SubscriptionGroupsDao {
@Query("SELECT * FROM subscriptionGroups")
suspend fun getAll(): List<SubscriptionGroup>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun createGroup(subscriptionGroup: SubscriptionGroup)
@Insert
suspend fun insertAll(subscriptionGroups: List<SubscriptionGroup>)
@Query("DELETE FROM subscriptionGroups WHERE name = :name")
suspend fun deleteGroup(name: String)
}

View File

@ -0,0 +1,12 @@
package com.github.libretube.db.obj
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
@Serializable
@Entity(tableName = "subscriptionGroups")
data class SubscriptionGroup(
@PrimaryKey var name: String,
val channels: MutableList<String>
)

View File

@ -52,13 +52,14 @@ object BackupHelper {
Database.localSubscriptionDao().insertAll(backupFile.localSubscriptions.orEmpty()) Database.localSubscriptionDao().insertAll(backupFile.localSubscriptions.orEmpty())
Database.customInstanceDao().insertAll(backupFile.customInstances.orEmpty()) Database.customInstanceDao().insertAll(backupFile.customInstances.orEmpty())
Database.playlistBookmarkDao().insertAll(backupFile.playlistBookmarks.orEmpty()) Database.playlistBookmarkDao().insertAll(backupFile.playlistBookmarks.orEmpty())
Database.subscriptionGroupsDao().insertAll(backupFile.channelGroups.orEmpty())
backupFile.localPlaylists?.forEach { backupFile.localPlaylists?.forEach {
Database.localPlaylistsDao().createPlaylist(it.playlist) Database.localPlaylistsDao().createPlaylist(it.playlist)
val playlistId = Database.localPlaylistsDao().getAll().last().playlist.id val playlistId = Database.localPlaylistsDao().getAll().last().playlist.id
it.videos.forEach { it.videos.forEach { playlistItem ->
it.playlistId = playlistId playlistItem.playlistId = playlistId
Database.localPlaylistsDao().addPlaylistVideo(it) Database.localPlaylistsDao().addPlaylistVideo(playlistItem)
} }
} }

View File

@ -5,6 +5,7 @@ import com.github.libretube.db.obj.LocalPlaylistWithVideos
import com.github.libretube.db.obj.LocalSubscription import com.github.libretube.db.obj.LocalSubscription
import com.github.libretube.db.obj.PlaylistBookmark import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.db.obj.WatchPosition import com.github.libretube.db.obj.WatchPosition
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -18,5 +19,6 @@ data class BackupFile(
var customInstances: List<CustomInstance>? = emptyList(), var customInstances: List<CustomInstance>? = emptyList(),
var playlistBookmarks: List<PlaylistBookmark>? = emptyList(), var playlistBookmarks: List<PlaylistBookmark>? = emptyList(),
var localPlaylists: List<LocalPlaylistWithVideos>? = emptyList(), var localPlaylists: List<LocalPlaylistWithVideos>? = emptyList(),
var preferences: List<PreferenceItem>? = emptyList() var preferences: List<PreferenceItem>? = emptyList(),
var channelGroups: List<SubscriptionGroup>? = emptyList()
) )

View File

@ -0,0 +1,42 @@
package com.github.libretube.ui.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.api.obj.Subscription
import com.github.libretube.databinding.SubscriptionGroupChannelRowBinding
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.ui.viewholders.SubscriptionGroupChannelRowViewHolder
class SubscriptionGroupChannelsAdapter(
private val channels: List<Subscription>,
private val group: SubscriptionGroup,
private val onGroupChanged: (SubscriptionGroup) -> Unit
) : RecyclerView.Adapter<SubscriptionGroupChannelRowViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): SubscriptionGroupChannelRowViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = SubscriptionGroupChannelRowBinding.inflate(layoutInflater, parent, false)
return SubscriptionGroupChannelRowViewHolder(binding)
}
override fun getItemCount() = channels.size
override fun onBindViewHolder(holder: SubscriptionGroupChannelRowViewHolder, position: Int) {
val channel = channels[position]
val channelId = channel.url.toID()
holder.binding.apply {
subscriptionChannelName.text = channel.name
ImageHelper.loadImage(channel.avatar, subscriptionChannelImage)
channelIncluded.isChecked = group.channels.contains(channelId)
channelIncluded.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) group.channels.add(channelId) else group.channels.remove(channelId)
onGroupChanged(group)
}
}
}
}

View File

@ -0,0 +1,67 @@
package com.github.libretube.ui.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.SubscriptionGroupRowBinding
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.ui.dialogs.EditChannelGroupDialog
import com.github.libretube.ui.viewholders.SubscriptionGroupsViewHolder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
class SubscriptionGroupsAdapter(
private val groups: MutableList<SubscriptionGroup>,
private val parentFragmentManager: FragmentManager,
private val onGroupsChanged: (List<SubscriptionGroup>) -> Unit
) : RecyclerView.Adapter<SubscriptionGroupsViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): SubscriptionGroupsViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = SubscriptionGroupRowBinding.inflate(layoutInflater, parent, false)
return SubscriptionGroupsViewHolder(binding)
}
override fun getItemCount() = groups.size
override fun onBindViewHolder(holder: SubscriptionGroupsViewHolder, position: Int) {
val subscriptionGroup = groups[position]
holder.binding.apply {
groupName.text = subscriptionGroup.name
deleteGroup.setOnClickListener {
groups.removeAt(position)
runBlocking(Dispatchers.IO) {
DatabaseHolder.Database.subscriptionGroupsDao().deleteGroup(
subscriptionGroup.name
)
}
onGroupsChanged(groups)
notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount)
}
editGroup.setOnClickListener {
EditChannelGroupDialog(subscriptionGroup) {
groups[position] = it
runBlocking(Dispatchers.IO) {
// delete the old one as it might have a different name
DatabaseHolder.Database.subscriptionGroupsDao().deleteGroup(
groupName.text.toString()
)
DatabaseHolder.Database.subscriptionGroupsDao().createGroup(it)
}
notifyItemChanged(position)
onGroupsChanged(groups)
}.show(parentFragmentManager, null)
}
}
}
fun insertItem(subscriptionsGroup: SubscriptionGroup) {
groups.add(subscriptionsGroup)
notifyItemInserted(itemCount - 1)
}
}

View File

@ -51,6 +51,10 @@ class BackupDialog(
it.localPlaylists = Database.localPlaylistsDao().getAll() it.localPlaylists = Database.localPlaylistsDao().getAll()
}) })
object SubscriptionGroups : BackupOption(R.string.channel_groups, onSelected = {
it.channelGroups = Database.subscriptionGroupsDao().getAll()
})
object Preferences : BackupOption(R.string.preferences, onSelected = { file -> object Preferences : BackupOption(R.string.preferences, onSelected = { file ->
file.preferences = PreferenceHelper.settings.all.map { (key, value) -> file.preferences = PreferenceHelper.settings.all.map { (key, value) ->
val jsonValue = when (value) { val jsonValue = when (value) {
@ -73,6 +77,7 @@ class BackupDialog(
BackupOption.CustomInstances, BackupOption.CustomInstances,
BackupOption.PlaylistBookmarks, BackupOption.PlaylistBookmarks,
BackupOption.LocalPlaylists, BackupOption.LocalPlaylists,
BackupOption.SubscriptionGroups,
BackupOption.Preferences BackupOption.Preferences
) )

View File

@ -0,0 +1,47 @@
package com.github.libretube.ui.dialogs
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
import com.github.libretube.databinding.DialogSubscriptionGroupsBinding
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.ui.adapters.SubscriptionGroupsAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
class ChannelGroupsDialog(
private val groups: MutableList<SubscriptionGroup>,
private val onGroupsChanged: (List<SubscriptionGroup>) -> Unit
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = DialogSubscriptionGroupsBinding.inflate(layoutInflater)
binding.groupsRV.layoutManager = LinearLayoutManager(context)
val adapter = SubscriptionGroupsAdapter(
groups.toMutableList(),
parentFragmentManager,
onGroupsChanged
)
binding.groupsRV.adapter = adapter
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.channel_groups)
.setView(binding.root)
.setPositiveButton(R.string.okay, null)
.setNeutralButton(R.string.new_group) { _, _ ->
EditChannelGroupDialog(SubscriptionGroup("", mutableListOf())) {
runBlocking(Dispatchers.IO) {
DatabaseHolder.Database.subscriptionGroupsDao().createGroup(it)
}
groups.add(it)
adapter.insertItem(it)
onGroupsChanged(groups)
}.show(parentFragmentManager, null)
}
.create()
}
}

View File

@ -0,0 +1,80 @@
package com.github.libretube.ui.dialogs
import android.app.Dialog
import android.os.Bundle
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.obj.Subscription
import com.github.libretube.databinding.DialogEditChannelGroupBinding
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.ui.adapters.SubscriptionGroupChannelsAdapter
import com.github.libretube.ui.models.SubscriptionsViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class EditChannelGroupDialog(
private var group: SubscriptionGroup,
private val onGroupChanged: (SubscriptionGroup) -> Unit
) : DialogFragment() {
private val subscriptionsModel: SubscriptionsViewModel by activityViewModels()
private lateinit var binding: DialogEditChannelGroupBinding
private var channels: List<Subscription> = listOf()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = DialogEditChannelGroupBinding.inflate(layoutInflater)
binding.groupName.setText(group.name)
binding.channelsRV.layoutManager = LinearLayoutManager(context)
fetchSubscriptions()
binding.searchInput.addTextChangedListener {
showChannels(channels, it?.toString())
}
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.edit_group)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.okay) { _, _ ->
group.name = binding.groupName.text.toString()
if (group.name.isBlank()) return@setPositiveButton
onGroupChanged(group)
}
.setView(binding.root)
.create()
}
private fun fetchSubscriptions() {
subscriptionsModel.subscriptions.value?.let {
channels = it
showChannels(it, null)
return
}
lifecycleScope.launch(Dispatchers.IO) {
channels = runCatching {
SubscriptionHelper.getSubscriptions()
}.getOrNull().orEmpty()
withContext(Dispatchers.Main) {
showChannels(channels, null)
}
}
}
private fun showChannels(channels: List<Subscription>, query: String?) {
binding.channelsRV.adapter = SubscriptionGroupChannelsAdapter(
channels.filter { query == null || it.name.lowercase().contains(query.lowercase()) },
group
) {
group = it
}
binding.subscriptionsContainer.isVisible = true
binding.progress.isVisible = false
}
}

View File

@ -1,14 +1,17 @@
package com.github.libretube.ui.fragments package com.github.libretube.ui.fragments
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.core.view.children
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R import com.github.libretube.R
@ -16,19 +19,25 @@ import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentSubscriptionsBinding import com.github.libretube.databinding.FragmentSubscriptionsBinding
import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.adapters.LegacySubscriptionAdapter import com.github.libretube.ui.adapters.LegacySubscriptionAdapter
import com.github.libretube.ui.adapters.SubscriptionChannelAdapter import com.github.libretube.ui.adapters.SubscriptionChannelAdapter
import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.dialogs.ChannelGroupsDialog
import com.github.libretube.ui.models.SubscriptionsViewModel import com.github.libretube.ui.models.SubscriptionsViewModel
import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.sheets.BaseBottomSheet
import com.google.android.material.chip.Chip
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class SubscriptionsFragment : Fragment() { class SubscriptionsFragment : Fragment() {
private lateinit var binding: FragmentSubscriptionsBinding private lateinit var binding: FragmentSubscriptionsBinding
private val viewModel: SubscriptionsViewModel by activityViewModels() private val viewModel: SubscriptionsViewModel by activityViewModels()
private var channelGroups: List<SubscriptionGroup> = listOf()
private var selectedFilterGroup: Int = 0
var subscriptionsAdapter: VideosAdapter? = null var subscriptionsAdapter: VideosAdapter? = null
private var selectedSortOrder = PreferenceHelper.getInt(PreferenceKeys.FEED_SORT_ORDER, 0) private var selectedSortOrder = PreferenceHelper.getInt(PreferenceKeys.FEED_SORT_ORDER, 0)
@ -82,14 +91,12 @@ class SubscriptionsFragment : Fragment() {
} }
viewModel.videoFeed.observe(viewLifecycleOwner) { viewModel.videoFeed.observe(viewLifecycleOwner) {
if (!isShowingFeed()) return@observe if (!isShowingFeed() || it == null) return@observe
if (it == null) return@observe
showFeed() showFeed()
} }
viewModel.subscriptions.observe(viewLifecycleOwner) { viewModel.subscriptions.observe(viewLifecycleOwner) {
if (isShowingFeed()) return@observe if (isShowingFeed() || it == null) return@observe
if (it == null) return@observe
showSubscriptions() showSubscriptions()
} }
@ -150,13 +157,59 @@ class SubscriptionsFragment : Fragment() {
binding.subRefresh.isRefreshing = false binding.subRefresh.isRefreshing = false
} }
} }
lifecycleScope.launch {
initChannelGroups()
}
}
@SuppressLint("InflateParams")
private suspend fun initChannelGroups() {
channelGroups = DatabaseHolder.Database.subscriptionGroupsDao().getAll()
binding.chipAll.isChecked = true
binding.channelGroups.removeAllViews()
binding.channelGroups.addView(binding.chipAll)
channelGroups.forEach { group ->
val chip = layoutInflater.inflate(R.layout.filter_chip, null) as Chip
chip.apply {
id = View.generateViewId()
isCheckable = true
text = group.name
}
binding.channelGroups.addView(chip)
}
binding.channelGroups.setOnCheckedStateChangeListener { group, checkedIds ->
selectedFilterGroup = group.children.indexOfFirst { it.id == checkedIds.first() }
showFeed()
}
binding.editGroups.setOnClickListener {
ChannelGroupsDialog(channelGroups.toMutableList()) {
lifecycleScope.launch { initChannelGroups() }
}.show(childFragmentManager, null)
}
} }
private fun showFeed() { private fun showFeed() {
if (viewModel.videoFeed.value == null) return if (viewModel.videoFeed.value == null) return
binding.subRefresh.isRefreshing = false binding.subRefresh.isRefreshing = false
val feed = viewModel.videoFeed.value!!.filter { val feed = viewModel.videoFeed.value!!
.filter { streamItem ->
// filter for selected channel groups
if (selectedFilterGroup == 0) {
true
} else {
val channelId = streamItem.uploaderUrl.orEmpty().toID()
val group = channelGroups.getOrNull(selectedFilterGroup - 1)
group?.channels?.contains(channelId) != false
}
}
.filter {
// apply the selected filter // apply the selected filter
when (selectedFilter) { when (selectedFilter) {
0 -> true 0 -> true
@ -166,7 +219,11 @@ class SubscriptionsFragment : Fragment() {
} }
}.let { streams -> }.let { streams ->
runBlocking { runBlocking {
if (!PreferenceHelper.getBoolean(PreferenceKeys.HIDE_WATCHED_FROM_FEED, false)) { if (!PreferenceHelper.getBoolean(
PreferenceKeys.HIDE_WATCHED_FROM_FEED,
false
)
) {
streams streams
} else { } else {
removeWatchVideosFromFeed(streams) removeWatchVideosFromFeed(streams)

View File

@ -0,0 +1,8 @@
package com.github.libretube.ui.viewholders
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.SubscriptionGroupChannelRowBinding
class SubscriptionGroupChannelRowViewHolder(
val binding: SubscriptionGroupChannelRowBinding
) : RecyclerView.ViewHolder(binding.root)

View File

@ -0,0 +1,8 @@
package com.github.libretube.ui.viewholders
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.SubscriptionGroupRowBinding
class SubscriptionGroupsViewHolder(
val binding: SubscriptionGroupRowBinding
) : RecyclerView.ViewHolder(binding.root)

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginHorizontal="20dp"
android:hint="@string/group_name"
app:startIconDrawable="@drawable/ic_edit">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/group_name"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/subscriptions_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:hint="@string/search_hint"
app:startIconDrawable="@drawable/ic_search">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/search_input"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/channelsRV"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp" />
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginVertical="200dp" />
</LinearLayout>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/groupsRV"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp" />
</LinearLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.chip.Chip xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/Widget.Material3.Chip.Filter.Elevated"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

View File

@ -131,6 +131,47 @@
</FrameLayout> </FrameLayout>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="3dp"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.chip.ChipGroup
android:id="@+id/channel_groups"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:checkedChip="@id/chip_all"
android:layout_marginStart="5dp"
app:selectionRequired="true"
app:singleLine="true"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@+id/chip_all"
style="@style/ElevatedFilterChip"
android:text="@string/all" />
</com.google.android.material.chip.ChipGroup>
<ImageView
android:id="@+id/edit_groups"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_edit" />
</LinearLayout>
</HorizontalScrollView>
<RelativeLayout <RelativeLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:paddingHorizontal="20dp" >
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/subscription_channel_image"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
android:layout_marginStart="8dp"
app:shapeAppearance="@style/CircleImageView" />
<TextView
android:id="@+id/subscription_channel_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
tools:text="Channel Name" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/channel_included"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="25dp"
android:paddingVertical="10dp">
<TextView
android:id="@+id/group_name"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="10dp"
android:layout_weight="1" />
<ImageView
android:id="@+id/edit_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_edit" />
<ImageView
android:id="@+id/delete_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_delete" />
</LinearLayout>

View File

@ -456,6 +456,10 @@
<string name="disable_proxy">Disable Piped proxy</string> <string name="disable_proxy">Disable Piped proxy</string>
<string name="disable_proxy_summary">Load videos and images directly from YouTube\'s servers. Only enable the option if you use a VPN anyways! Note that this might not work with content from YT music.</string> <string name="disable_proxy_summary">Load videos and images directly from YouTube\'s servers. Only enable the option if you use a VPN anyways! Note that this might not work with content from YT music.</string>
<string name="auto_fullscreen_shorts">Auto fullscreen on short videos</string> <string name="auto_fullscreen_shorts">Auto fullscreen on short videos</string>
<string name="channel_groups">Channel groups</string>
<string name="new_group">New</string>
<string name="group_name">Group name</string>
<string name="edit_group">Edit group</string>
<!-- Notification channel strings --> <!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string> <string name="download_channel_name">Download Service</string>

View File

@ -250,4 +250,13 @@
</style> </style>
<style name="ElevatedFilterChip" parent="Widget.Material3.Chip.Filter.Elevated">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginStart">5dp</item>
<item name="android:layout_marginEnd">5dp</item>
</style>
</resources> </resources>