add support for bookmarking playlists

This commit is contained in:
Bnyro
2022-11-18 18:01:11 +01:00
parent 56d0d6f1b3
commit 48951f13c3
16 changed files with 628 additions and 14 deletions

View File

@ -18,6 +18,12 @@ android {
multiDexEnabled true
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
resValue "string", "app_name", "LibreTube"
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
}
buildFeatures {

View File

@ -0,0 +1,174 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "c9803a67ce206dbda6e44ed761f80136",
"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": {
"columnNames": [
"videoId"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"videoId"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"query"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"name"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"channelId"
],
"autoGenerate": false
},
"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, 'c9803a67ce206dbda6e44ed761f80136')"
]
}
}

View File

@ -0,0 +1,224 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "eb8d0ff1131448df6216b549bbfa7c21",
"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": {
"columnNames": [
"videoId"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"videoId"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"query"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"name"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"channelId"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"playlistId"
],
"autoGenerate": false
},
"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, 'eb8d0ff1131448df6216b549bbfa7c21')"
]
}
}

View File

@ -1,14 +1,17 @@
package com.github.libretube.db
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import com.github.libretube.db.dao.CustomInstanceDao
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.WatchHistoryDao
import com.github.libretube.db.dao.WatchPositionDao
import com.github.libretube.db.obj.CustomInstance
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.WatchHistoryItem
import com.github.libretube.db.obj.WatchPosition
@ -19,9 +22,13 @@ import com.github.libretube.db.obj.WatchPosition
WatchPosition::class,
SearchHistoryItem::class,
CustomInstance::class,
LocalSubscription::class
LocalSubscription::class,
PlaylistBookmark::class
],
version = 7
version = 8,
autoMigrations = [
AutoMigration(from = 7, to = 8)
]
)
abstract class AppDatabase : RoomDatabase() {
/**
@ -48,4 +55,9 @@ abstract class AppDatabase : RoomDatabase() {
* Local Subscriptions
*/
abstract fun localSubscriptionDao(): LocalSubscriptionDao
/**
* Bookmarked Playlists
*/
abstract fun playlistBookmarkDao(): PlaylistBookmarkDao
}

View File

@ -0,0 +1,29 @@
package com.github.libretube.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.libretube.db.obj.PlaylistBookmark
@Dao
interface PlaylistBookmarkDao {
@Query("SELECT * FROM playlistBookmark")
fun getAll(): List<PlaylistBookmark>
@Query("SELECT * FROM playlistBookmark WHERE playlistId LIKE :playlistId LIMIT 1")
fun findById(playlistId: String): PlaylistBookmark
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(vararg bookmarks: PlaylistBookmark)
@Delete
fun delete(playlistBookmark: PlaylistBookmark)
@Query("SELECT EXISTS(SELECT * FROM playlistBookmark WHERE playlistId= :playlistId)")
fun includes(playlistId: String): Boolean
@Query("DELETE FROM playlistBookmark")
fun deleteAll()
}

View File

@ -0,0 +1,16 @@
package com.github.libretube.db.obj
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "playlistBookmark")
data class PlaylistBookmark(
@PrimaryKey
val playlistId: String = "",
val playlistName: String? = null,
var thumbnailUrl: String? = null,
var uploader: String? = null,
var uploaderUrl: String? = null,
var uploaderAvatar: String? = null
)

View File

@ -0,0 +1,31 @@
package com.github.libretube.ui.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.PlaylistBookmarkRowBinding
import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.ui.viewholders.PlaylistBookmarkViewHolder
import com.github.libretube.util.ImageHelper
class PlaylistBookmarksAdapter(
private val bookmarks: List<PlaylistBookmark>
) : RecyclerView.Adapter<PlaylistBookmarkViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlaylistBookmarkViewHolder {
val binding = PlaylistBookmarkRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return PlaylistBookmarkViewHolder(binding)
}
override fun getItemCount(): Int {
return bookmarks.size
}
override fun onBindViewHolder(holder: PlaylistBookmarkViewHolder, position: Int) {
val bookmark = bookmarks[position]
holder.binding.apply {
ImageHelper.loadImage(bookmark.thumbnailUrl, thumbnail)
playlistName.text = bookmark.playlistName
uploaderName.text = bookmark.uploader
}
}
}

View File

@ -13,7 +13,10 @@ import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.databinding.FragmentHomeBinding
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.extensions.awaitQuery
import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.ui.adapters.PlaylistBookmarksAdapter
import com.github.libretube.ui.adapters.PlaylistsAdapter
import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.base.BaseFragment
@ -111,6 +114,18 @@ class HomeFragment : BaseFragment() {
})
}
}
runOrError {
val bookmarkedPlaylists = awaitQuery {
DatabaseHolder.Database.playlistBookmarkDao().getAll()
}
if (bookmarkedPlaylists.isEmpty()) return@runOrError
runOnUiThread {
makeVisible(binding.bookmarksRV, binding.bookmarksRV)
binding.playlistsRV.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
binding.playlistsRV.adapter = PlaylistBookmarksAdapter(bookmarkedPlaylists)
}
}
}
private fun runOrError(action: suspend () -> Unit) {

View File

@ -14,13 +14,14 @@ import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.FragmentPlaylistBinding
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.awaitQuery
import com.github.libretube.extensions.query
import com.github.libretube.extensions.toID
import com.github.libretube.obj.ShareData
import com.github.libretube.ui.adapters.PlaylistAdapter
import com.github.libretube.ui.base.BaseFragment
import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet
import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper
@ -37,6 +38,7 @@ class PlaylistFragment : BaseFragment() {
private var nextPage: String? = null
private var playlistAdapter: PlaylistAdapter? = null
private var isLoading = true
private var isBookmarked = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -62,11 +64,24 @@ class PlaylistFragment : BaseFragment() {
binding.playlistRecView.layoutManager = LinearLayoutManager(context)
binding.playlistProgress.visibility = View.VISIBLE
isBookmarked = awaitQuery {
DatabaseHolder.Database.playlistBookmarkDao().includes(playlistId!!)
}
updateBookmarkRes()
fetchPlaylist()
}
private fun updateBookmarkRes() {
binding.bookmark.setIconResource(
if (isBookmarked) R.drawable.ic_bookmark else R.drawable.ic_bookmark_outlined
)
}
@SuppressLint("SetTextI18n")
private fun fetchPlaylist() {
binding.playlistScrollview.visibility = View.GONE
lifecycleScope.launchWhenCreated {
val response = try {
// load locally stored playlists with the auth api
@ -83,6 +98,7 @@ class PlaylistFragment : BaseFragment() {
Log.e(TAG(), "HttpException, unexpected response")
return@launchWhenCreated
}
binding.playlistScrollview.visibility = View.VISIBLE
nextPage = response.nextpage
playlistName = response.name
isLoading = false
@ -114,12 +130,27 @@ class PlaylistFragment : BaseFragment() {
)
}
binding.share.setOnClickListener {
ShareDialog(
playlistId!!,
ShareObjectType.PLAYLIST,
ShareData(currentPlaylist = response.name)
).show(childFragmentManager, null)
binding.bookmark.setOnClickListener {
query {
if (isBookmarked) {
DatabaseHolder.Database.playlistBookmarkDao().delete(
DatabaseHolder.Database.playlistBookmarkDao().findById(playlistId!!)
)
} else {
DatabaseHolder.Database.playlistBookmarkDao().insertAll(
PlaylistBookmark(
playlistId = playlistId!!,
playlistName = response.name,
thumbnailUrl = response.thumbnailUrl,
uploader = response.uploader,
uploaderAvatar = response.uploaderAvatar,
uploaderUrl = response.uploaderUrl
)
)
}
}
isBookmarked = !isBookmarked
updateBookmarkRes()
}
playlistAdapter = PlaylistAdapter(

View File

@ -0,0 +1,8 @@
package com.github.libretube.ui.viewholders
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.PlaylistBookmarkRowBinding
class PlaylistBookmarkViewHolder(
val binding: PlaylistBookmarkRowBinding
) : 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="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

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="M17,3L7,3c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3L19,5c0,-1.1 -0.9,-2 -2,-2zM17,18l-5,-2.18L7,18L7,5h10v13z" />
</vector>

View File

@ -59,6 +59,18 @@
</RelativeLayout>
<TextView
android:id="@+id/bookmarksTV"
style="@style/HomeCategoryTitle"
android:text="@string/playlists" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/bookmarksRV"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:visibility="gone" />
<TextView
android:id="@+id/playlistsTV"
style="@style/HomeCategoryTitle"

View File

@ -88,14 +88,14 @@
app:icon="@drawable/ic_playlist" />
<com.google.android.material.button.MaterialButton
android:id="@+id/share"
android:id="@+id/bookmark"
style="@style/Widget.Material3.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_weight="1"
android:text="@string/share"
app:icon="@drawable/ic_share_outlined" />
android:text="@string/bookmark"
app:icon="@drawable/ic_bookmark_outlined" />
</LinearLayout>

View File

@ -0,0 +1,33 @@
<?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="250dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_marginVertical="10dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.ExtraLarge"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
android:id="@+id/playlistName"
android:textSize="18sp"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/uploaderName"
android:textSize="16sp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -381,6 +381,9 @@
<string name="trends">Trends</string>
<string name="featured">Featured</string>
<string name="trending">What\'s trending now</string>
<string name="bookmarks">Bookmarks</string>
<string name="bookmark">Bookmark</string>
<!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string>
<string name="download_channel_description">Shows a notification when downloading media.</string>