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.PlaylistBookmarkDao
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.WatchPositionDao
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.PlaylistBookmark
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.WatchPosition
@ -34,13 +36,15 @@ import com.github.libretube.db.obj.WatchPosition
LocalPlaylist::class,
LocalPlaylistItem::class,
Download::class,
DownloadItem::class
DownloadItem::class,
SubscriptionGroup::class
],
version = 10,
version = 11,
autoMigrations = [
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10)
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11)
]
)
@TypeConverters(Converters::class)
@ -84,4 +88,9 @@ abstract class AppDatabase : RoomDatabase() {
* Downloads
*/
abstract fun downloadDao(): DownloadDao
/**
* Subscription groups
*/
abstract fun subscriptionGroupsDao(): SubscriptionGroupsDao
}

View File

@ -1,10 +1,13 @@
package com.github.libretube.db
import androidx.room.TypeConverter
import com.github.libretube.api.JsonHelper
import java.nio.file.Path
import java.nio.file.Paths
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toLocalDate
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
object Converters {
@TypeConverter
@ -18,4 +21,10 @@ object Converters {
@TypeConverter
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.customInstanceDao().insertAll(backupFile.customInstances.orEmpty())
Database.playlistBookmarkDao().insertAll(backupFile.playlistBookmarks.orEmpty())
Database.subscriptionGroupsDao().insertAll(backupFile.channelGroups.orEmpty())
backupFile.localPlaylists?.forEach {
Database.localPlaylistsDao().createPlaylist(it.playlist)
val playlistId = Database.localPlaylistsDao().getAll().last().playlist.id
it.videos.forEach {
it.playlistId = playlistId
Database.localPlaylistsDao().addPlaylistVideo(it)
it.videos.forEach { playlistItem ->
playlistItem.playlistId = playlistId
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.PlaylistBookmark
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.WatchPosition
import kotlinx.serialization.Serializable
@ -18,5 +19,6 @@ data class BackupFile(
var customInstances: List<CustomInstance>? = emptyList(),
var playlistBookmarks: List<PlaylistBookmark>? = 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()
})
object SubscriptionGroups : BackupOption(R.string.channel_groups, onSelected = {
it.channelGroups = Database.subscriptionGroupsDao().getAll()
})
object Preferences : BackupOption(R.string.preferences, onSelected = { file ->
file.preferences = PreferenceHelper.settings.all.map { (key, value) ->
val jsonValue = when (value) {
@ -73,6 +77,7 @@ class BackupDialog(
BackupOption.CustomInstances,
BackupOption.PlaylistBookmarks,
BackupOption.LocalPlaylists,
BackupOption.SubscriptionGroups,
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
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.children
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
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.databinding.FragmentSubscriptionsBinding
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.adapters.LegacySubscriptionAdapter
import com.github.libretube.ui.adapters.SubscriptionChannelAdapter
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.sheets.BaseBottomSheet
import com.google.android.material.chip.Chip
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class SubscriptionsFragment : Fragment() {
private lateinit var binding: FragmentSubscriptionsBinding
private val viewModel: SubscriptionsViewModel by activityViewModels()
private var channelGroups: List<SubscriptionGroup> = listOf()
private var selectedFilterGroup: Int = 0
var subscriptionsAdapter: VideosAdapter? = null
private var selectedSortOrder = PreferenceHelper.getInt(PreferenceKeys.FEED_SORT_ORDER, 0)
@ -82,14 +91,12 @@ class SubscriptionsFragment : Fragment() {
}
viewModel.videoFeed.observe(viewLifecycleOwner) {
if (!isShowingFeed()) return@observe
if (it == null) return@observe
if (!isShowingFeed() || it == null) return@observe
showFeed()
}
viewModel.subscriptions.observe(viewLifecycleOwner) {
if (isShowingFeed()) return@observe
if (it == null) return@observe
if (isShowingFeed() || it == null) return@observe
showSubscriptions()
}
@ -150,29 +157,79 @@ class SubscriptionsFragment : Fragment() {
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() {
if (viewModel.videoFeed.value == null) return
binding.subRefresh.isRefreshing = false
val feed = viewModel.videoFeed.value!!.filter {
// apply the selected filter
when (selectedFilter) {
0 -> true
1 -> !it.isShort
2 -> it.isShort
else -> throw IllegalArgumentException()
}
}.let { streams ->
runBlocking {
if (!PreferenceHelper.getBoolean(PreferenceKeys.HIDE_WATCHED_FROM_FEED, false)) {
streams
val feed = viewModel.videoFeed.value!!
.filter { streamItem ->
// filter for selected channel groups
if (selectedFilterGroup == 0) {
true
} else {
removeWatchVideosFromFeed(streams)
val channelId = streamItem.uploaderUrl.orEmpty().toID()
val group = channelGroups.getOrNull(selectedFilterGroup - 1)
group?.channels?.contains(channelId) != false
}
}
.filter {
// apply the selected filter
when (selectedFilter) {
0 -> true
1 -> !it.isShort
2 -> it.isShort
else -> throw IllegalArgumentException()
}
}.let { streams ->
runBlocking {
if (!PreferenceHelper.getBoolean(
PreferenceKeys.HIDE_WATCHED_FROM_FEED,
false
)
) {
streams
} else {
removeWatchVideosFromFeed(streams)
}
}
}
}
// sort the feed
val sortedFeed = when (selectedSortOrder) {

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>
<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
android:layout_width="match_parent"
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_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="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 -->
<string name="download_channel_name">Download Service</string>

View File

@ -250,4 +250,13 @@
</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>