Subscription groups

This commit is contained in:
Bnyro 2023-03-28 12:16:47 +02:00
parent 4470e4f757
commit a2117bd74b
20 changed files with 1066 additions and 21 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,23 @@
package com.github.libretube.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
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)
@Update
suspend fun updateGroup(subscriptionGroup: SubscriptionGroup)
@Query("DELETE FROM subscriptionGroups WHERE name = :name")
suspend fun deleteGroup(name: String)
}

View File

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

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,61 @@
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.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.remove(subscriptionGroup)
runBlocking {
DatabaseHolder.Database.subscriptionGroupsDao().deleteGroup(
subscriptionGroup.name
)
}
notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount)
}
editGroup.setOnClickListener {
EditChannelGroupDialog(subscriptionGroup) {
groups[position] = it
runBlocking {
DatabaseHolder.Database.subscriptionGroupsDao().updateGroup(it)
}
notifyItemChanged(position)
onGroupsChanged(groups)
}.show(parentFragmentManager, null)
}
}
}
fun insertItem(subscriptionsGroup: SubscriptionGroup) {
groups.add(subscriptionsGroup)
notifyItemInserted(itemCount - 1)
}
}

View File

@ -0,0 +1,46 @@
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.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 {
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

@ -5,10 +5,12 @@ 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 +18,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 +90,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 +156,73 @@ class SubscriptionsFragment : Fragment() {
binding.subRefresh.isRefreshing = false
}
}
lifecycleScope.launch {
initChannelGroups()
}
}
private suspend fun initChannelGroups() {
channelGroups = DatabaseHolder.Database.subscriptionGroupsDao().getAll()
binding.chipAll.isSelected = true
binding.channelGroups.children.forEachIndexed { index, view ->
if (index != 0) binding.channelGroups.removeView(view)
}
channelGroups.forEachIndexed { index, group ->
val chip = Chip(context, null, R.style.ElevatedFilterChip).apply {
id = View.generateViewId()
isCheckable = true
isClickable = true
text = group.name
setOnClickListener {
selectedFilterGroup = index + 1 // since the first one is "All"
showFeed()
}
}
binding.channelGroups.addView(chip)
}
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()
channelGroups.getOrNull(selectedFilterGroup + 1)?.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,58 @@
<?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_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

@ -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>