diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0f8a76be0a..4b97cc6d45 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -54,6 +54,11 @@
android:label="@string/settings"
android:screenOrientation="locked" />
+
+
NewPipeMediaServiceRepository()
- PlayerHelper.localStreamExtraction -> LocalStreamsExtractionPipedMediaServiceRepository()
else -> PipedMediaServiceRepository()
}
}
diff --git a/app/src/main/java/com/github/libretube/api/PipedAuthApi.kt b/app/src/main/java/com/github/libretube/api/PipedAuthApi.kt
index d6cacbfc25..9b1fe12eec 100644
--- a/app/src/main/java/com/github/libretube/api/PipedAuthApi.kt
+++ b/app/src/main/java/com/github/libretube/api/PipedAuthApi.kt
@@ -13,7 +13,6 @@ import com.github.libretube.api.obj.Subscription
import com.github.libretube.api.obj.Token
import retrofit2.http.Body
import retrofit2.http.GET
-import retrofit2.http.Header
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
@@ -29,7 +28,6 @@ interface PipedAuthApi {
@POST("user/delete")
suspend fun deleteAccount(
- @Header("Authorization") token: String,
@Body password: DeleteUserRequest
)
@@ -45,11 +43,10 @@ interface PipedAuthApi {
@GET("subscribed")
suspend fun isSubscribed(
@Query("channelId") channelId: String,
- @Header("Authorization") token: String
): Subscribed
@GET("subscriptions")
- suspend fun subscriptions(@Header("Authorization") token: String): List
+ suspend fun subscriptions(): List
@GET("subscriptions/unauthenticated")
suspend fun unauthenticatedSubscriptions(
@@ -61,65 +58,55 @@ interface PipedAuthApi {
@POST("subscribe")
suspend fun subscribe(
- @Header("Authorization") token: String,
@Body subscribe: Subscribe
): Message
@POST("unsubscribe")
suspend fun unsubscribe(
- @Header("Authorization") token: String,
@Body subscribe: Subscribe
): Message
@POST("import")
suspend fun importSubscriptions(
@Query("override") override: Boolean,
- @Header("Authorization") token: String,
@Body channels: List
): Message
@POST("import/playlist")
suspend fun clonePlaylist(
- @Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): EditPlaylistBody
@GET("user/playlists")
- suspend fun getUserPlaylists(@Header("Authorization") token: String): List
+ suspend fun getUserPlaylists(): List
@POST("user/playlists/rename")
suspend fun renamePlaylist(
- @Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message
@PATCH("user/playlists/description")
suspend fun changePlaylistDescription(
- @Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message
@POST("user/playlists/delete")
suspend fun deletePlaylist(
- @Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message
@POST("user/playlists/create")
suspend fun createPlaylist(
- @Header("Authorization") token: String,
@Body name: Playlists
): EditPlaylistBody
@POST("user/playlists/add")
suspend fun addToPlaylist(
- @Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message
@POST("user/playlists/remove")
suspend fun removeFromPlaylist(
- @Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message
diff --git a/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt b/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt
index eb3e0ea974..ca4d298350 100644
--- a/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt
+++ b/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt
@@ -1,6 +1,5 @@
package com.github.libretube.api
-import androidx.core.text.isDigitsOnly
import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem
@@ -8,29 +7,21 @@ import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.enums.PlaylistType
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.PipedImportPlaylist
-import com.github.libretube.repo.LocalPlaylistsRepository
-import com.github.libretube.repo.PipedPlaylistRepository
-import com.github.libretube.repo.PlaylistRepository
+import com.github.libretube.repo.UserDataRepository
+import com.github.libretube.repo.UserDataRepositoryHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
object PlaylistsHelper {
- private val pipedPlaylistRegex =
- "[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}".toRegex()
const val MAX_CONCURRENT_IMPORT_CALLS = 5
- private val token get() = PreferenceHelper.getToken()
- val loggedIn: Boolean get() = token.isNotEmpty()
- private val playlistsRepository: PlaylistRepository
- get() = when {
- loggedIn -> PipedPlaylistRepository()
- else -> LocalPlaylistsRepository()
- }
+ @Suppress("DEPRECATION")
+ private val userDataRepository: UserDataRepository get() = UserDataRepositoryHelper.userDataRepository
suspend fun getPlaylists(): List = withContext(Dispatchers.IO) {
- val playlists = playlistsRepository.getPlaylists()
+ val playlists = userDataRepository.getPlaylists()
sortPlaylists(playlists)
}
@@ -48,56 +39,46 @@ object PlaylistsHelper {
}
}
- suspend fun getPlaylist(playlistId: String): Playlist {
+ suspend fun getPlaylist(playlistId: String, playlistType: PlaylistType): Playlist {
// load locally stored playlists with the auth api
- return when (getPlaylistType(playlistId)) {
+ return when (playlistType) {
PlaylistType.PUBLIC -> MediaServiceRepository.instance.getPlaylist(playlistId)
- else -> playlistsRepository.getPlaylist(playlistId)
+ else -> userDataRepository.getPlaylist(playlistId)
}
}
suspend fun getAllPlaylistsWithVideos(playlistIds: List? = null): List {
return withContext(Dispatchers.IO) {
(playlistIds ?: getPlaylists().map { it.id!! })
- .map { async { getPlaylist(it) } }
+ .map { async { getPlaylist(it, getPlaylistType(it)) } }
.awaitAll()
}
}
suspend fun createPlaylist(playlistName: String) =
- playlistsRepository.createPlaylist(playlistName)
+ userDataRepository.createPlaylist(playlistName)
suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem) =
withContext(Dispatchers.IO) {
- playlistsRepository.addToPlaylist(playlistId, *videos)
+ userDataRepository.addToPlaylist(playlistId, *videos)
}
suspend fun renamePlaylist(playlistId: String, newName: String) =
- playlistsRepository.renamePlaylist(playlistId, newName)
+ userDataRepository.renamePlaylist(playlistId, newName)
suspend fun changePlaylistDescription(playlistId: String, newDescription: String) =
- playlistsRepository.changePlaylistDescription(playlistId, newDescription)
+ userDataRepository.changePlaylistDescription(playlistId, newDescription)
- suspend fun removeFromPlaylist(playlistId: String, index: Int) =
- playlistsRepository.removeFromPlaylist(playlistId, index)
+ suspend fun removeFromPlaylist(playlistId: String, videoId: String, index: Int) =
+ userDataRepository.removeFromPlaylist(playlistId, videoId, index)
suspend fun importPlaylists(playlists: List) =
- playlistsRepository.importPlaylists(playlists)
-
- suspend fun clonePlaylist(playlistId: String) = playlistsRepository.clonePlaylist(playlistId)
- suspend fun deletePlaylist(playlistId: String) = playlistsRepository.deletePlaylist(playlistId)
+ userDataRepository.importPlaylists(playlists)
- fun getPrivatePlaylistType(): PlaylistType {
- return if (loggedIn) PlaylistType.PRIVATE else PlaylistType.LOCAL
- }
+ suspend fun clonePlaylist(playlistId: String) = userDataRepository.clonePlaylist(playlistId)
+ suspend fun deletePlaylist(playlistId: String) = userDataRepository.deletePlaylist(playlistId)
- fun getPlaylistType(playlistId: String): PlaylistType {
- return if (playlistId.isDigitsOnly()) {
- PlaylistType.LOCAL
- } else if (playlistId.matches(pipedPlaylistRegex)) {
- PlaylistType.PRIVATE
- } else {
- PlaylistType.PUBLIC
- }
- }
+ // TODO: remove this and pass the type information down instead
+ private fun isYouTubePlaylist(playlistId: String) = playlistId.startsWith("PL") && playlistId.length == 34
+ fun getPlaylistType(playlistId: String) = if (isYouTubePlaylist(playlistId)) PlaylistType.PUBLIC else PlaylistType.PRIVATE
}
diff --git a/app/src/main/java/com/github/libretube/api/RetrofitInstance.kt b/app/src/main/java/com/github/libretube/api/RetrofitInstance.kt
index 18b01c08d1..ff605ed3d2 100644
--- a/app/src/main/java/com/github/libretube/api/RetrofitInstance.kt
+++ b/app/src/main/java/com/github/libretube/api/RetrofitInstance.kt
@@ -1,6 +1,7 @@
package com.github.libretube.api
import com.github.libretube.BuildConfig
+import com.github.libretube.api.ltsync.LibreTubeSyncServerApi
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.helpers.PreferenceHelper
import okhttp3.MediaType.Companion.toMediaType
@@ -10,43 +11,68 @@ import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.create
+typealias HeadersAccessor = () -> Map
+
object RetrofitInstance {
const val PIPED_API_URL = "https://pipedapi.kavin.rocks"
+ private const val LIBRETUBE_SYNC_SERVER_URL = "https://sync.libretube.dev"
- val authUrl
- get() = if (
- PreferenceHelper.getBoolean(
- PreferenceKeys.AUTH_INSTANCE_TOGGLE,
- false
- )
- ) {
- PreferenceHelper.getString(
- PreferenceKeys.AUTH_INSTANCE,
- PIPED_API_URL
- )
- } else {
- PipedMediaServiceRepository.apiUrl
- }
+ val pipedAuthUrl
+ get() = PreferenceHelper.getString(
+ PreferenceKeys.AUTH_INSTANCE,
+ PIPED_API_URL
+ )
+
+ private val libretubeSyncServerUrl
+ get() = PreferenceHelper.getString(
+ PreferenceKeys.LIBRETUBE_SYNC_SERVER_URL,
+ LIBRETUBE_SYNC_SERVER_URL
+ )
val apiLazyMgr = resettableManager()
val kotlinxConverterFactory = JsonHelper.json
.asConverterFactory("application/json".toMediaType())
- val httpClient by lazy { buildClient() }
+ val pipedAuthApi by resettableLazy(apiLazyMgr) {
+ buildRetrofitInstance(
+ pipedAuthUrl,
+ headersAccessor = { mapOf("Authorization" to PreferenceHelper.getToken()) }
+ )
+ }
- val authApi by resettableLazy(apiLazyMgr) {
- buildRetrofitInstance(authUrl)
+ val libretubeSyncServerApi by resettableLazy(apiLazyMgr) {
+ buildRetrofitInstance(
+ libretubeSyncServerUrl,
+ headersAccessor = { mapOf("Authorization" to PreferenceHelper.getToken()) }
+ )
}
// the url provided here isn't actually used anywhere in the external api
val externalApi = buildRetrofitInstance(PIPED_API_URL)
- private fun buildClient(): OkHttpClient {
+ /**
+ * Build a new [OkHttpClient] with logging support.
+ *
+ * @param headersAccessor Method that returns a list of headers to inject into each request. Can
+ * e.g. be used for injecting authorization tokens to the client.
+ */
+ fun buildClient(headersAccessor: HeadersAccessor = { mapOf() }): OkHttpClient {
val httpClient = OkHttpClient().newBuilder()
+ // add provided headers to all requests
+ httpClient.addInterceptor { interceptorChain ->
+ val request = interceptorChain.request()
+ .newBuilder()
+ for ((key, value) in headersAccessor()) {
+ request.addHeader(key, value)
+ }
+
+ interceptorChain.proceed(request.build())
+ }
+
if (BuildConfig.DEBUG) {
val loggingInterceptor = HttpLoggingInterceptor().apply {
- level = HttpLoggingInterceptor.Level.BASIC
+ level = HttpLoggingInterceptor.Level.HEADERS
}
httpClient.addInterceptor(loggingInterceptor)
@@ -55,9 +81,12 @@ object RetrofitInstance {
return httpClient.build()
}
- inline fun buildRetrofitInstance(apiUrl: String): T = Retrofit.Builder()
+ inline fun buildRetrofitInstance(
+ apiUrl: String,
+ noinline headersAccessor: HeadersAccessor = { mapOf() }
+ ): T = Retrofit.Builder()
.baseUrl(apiUrl)
- .client(httpClient)
+ .client(buildClient(headersAccessor))
.addConverterFactory(kotlinxConverterFactory)
.build()
.create()
diff --git a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt
index cec4ca04fc..3756352f8a 100644
--- a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt
+++ b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt
@@ -1,62 +1,37 @@
package com.github.libretube.api
import com.github.libretube.api.obj.Subscription
-import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.obj.SubscriptionsFeedItem
-import com.github.libretube.helpers.PreferenceHelper
-import com.github.libretube.repo.AccountSubscriptionsRepository
import com.github.libretube.repo.FeedProgress
import com.github.libretube.repo.FeedRepository
-import com.github.libretube.repo.LocalFeedRepository
-import com.github.libretube.repo.LocalSubscriptionsRepository
-import com.github.libretube.repo.PipedAccountFeedRepository
-import com.github.libretube.repo.PipedLocalSubscriptionsRepository
-import com.github.libretube.repo.PipedNoAccountFeedRepository
-import com.github.libretube.repo.SubscriptionsRepository
+import com.github.libretube.repo.UserDataRepository
+import com.github.libretube.repo.UserDataRepositoryHelper
object SubscriptionHelper {
- /**
- * The maximum number of channel IDs that can be passed via a GET request for fetching
- * the subscriptions list and the feed
- */
- const val GET_SUBSCRIPTIONS_LIMIT = 100
-
- private val localFeedExtraction
- get() = PreferenceHelper.getBoolean(
- PreferenceKeys.LOCAL_FEED_EXTRACTION,
- true
- )
- private val token get() = PreferenceHelper.getToken()
- private val subscriptionsRepository: SubscriptionsRepository
- get() = when {
- token.isNotEmpty() -> AccountSubscriptionsRepository()
- localFeedExtraction -> LocalSubscriptionsRepository()
- else -> PipedLocalSubscriptionsRepository()
- }
+ @Suppress("DEPRECATION")
+ private val userDataRepository: UserDataRepository
+ get() = UserDataRepositoryHelper.userDataRepository
+ @Suppress("DEPRECATION")
private val feedRepository: FeedRepository
- get() = when {
- localFeedExtraction -> LocalFeedRepository()
- token.isNotEmpty() -> PipedAccountFeedRepository()
- else -> PipedNoAccountFeedRepository()
- }
+ get() = UserDataRepositoryHelper.feedRepository
suspend fun subscribe(
channelId: String, name: String, uploaderAvatar: String?, verified: Boolean
- ) = subscriptionsRepository.subscribe(channelId, name, uploaderAvatar, verified)
+ ) = userDataRepository.subscribe(channelId, name, uploaderAvatar, verified)
suspend fun unsubscribe(channelId: String) {
- subscriptionsRepository.unsubscribe(channelId)
+ userDataRepository.unsubscribe(channelId)
// remove videos from (local) feed
feedRepository.removeChannel(channelId)
}
- suspend fun isSubscribed(channelId: String) = subscriptionsRepository.isSubscribed(channelId)
+ suspend fun isSubscribed(channelId: String) = userDataRepository.isSubscribed(channelId)
suspend fun importSubscriptions(newChannels: List) =
- subscriptionsRepository.importSubscriptions(newChannels)
+ userDataRepository.importSubscriptions(newChannels)
suspend fun getSubscriptions() =
- subscriptionsRepository.getSubscriptions().sortedBy { it.name.lowercase() }
+ userDataRepository.getSubscriptions().sortedBy { it.name.lowercase() }
- suspend fun getSubscriptionChannelIds() = subscriptionsRepository.getSubscriptionChannelIds()
+ suspend fun getSubscriptionChannelIds() = userDataRepository.getSubscriptionChannelIds()
suspend fun getFeed(forceRefresh: Boolean, onProgressUpdate: (FeedProgress) -> Unit = {}) =
feedRepository.getFeed(forceRefresh, onProgressUpdate)
@@ -64,5 +39,5 @@ object SubscriptionHelper {
feedRepository.submitFeedItemChange(feedItem)
suspend fun submitSubscriptionChannelInfosChanged(subscriptions: List) =
- subscriptionsRepository.submitSubscriptionChannelInfosChanged(subscriptions)
+ userDataRepository.submitSubscriptionChannelInfosChanged(subscriptions)
}
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/LibreTubeSyncServerApi.kt b/app/src/main/java/com/github/libretube/api/ltsync/LibreTubeSyncServerApi.kt
new file mode 100644
index 0000000000..fdf842591c
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/LibreTubeSyncServerApi.kt
@@ -0,0 +1,136 @@
+package com.github.libretube.api.ltsync
+
+import com.github.libretube.api.ltsync.obj.Channel
+import com.github.libretube.api.ltsync.obj.CreatePlaylist
+import com.github.libretube.api.ltsync.obj.CreateVideo
+import com.github.libretube.api.ltsync.obj.DeleteUser
+import com.github.libretube.api.ltsync.obj.ExtendedPublicPlaylist
+import com.github.libretube.api.ltsync.obj.ExtendedSubscriptionGroup
+import com.github.libretube.api.ltsync.obj.ExtendedWatchHistoryItem
+import com.github.libretube.api.ltsync.obj.LoginResponse
+import com.github.libretube.api.ltsync.obj.LoginUser
+import com.github.libretube.api.ltsync.obj.Playlist
+import com.github.libretube.api.ltsync.obj.PlaylistResponse
+import com.github.libretube.api.ltsync.obj.RegisterUser
+import com.github.libretube.api.ltsync.obj.SubscriptionGroup
+import com.github.libretube.api.ltsync.obj.WatchHistoryItem
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.HTTP
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+// Generated based on the server's OpenAPI spec, using https://openapi-generator.tech/docs/generators/kotlin/
+// generate -i api-spec.yaml -g kotlin -o outdir --library jvm-retrofit2 --additional-properties=serializationLibrary=kotlinx_serialization
+
+interface LibreTubeSyncServerApi {
+ // workaround for https://stackoverflow.com/questions/37942474/delete-method-is-not-supportingnon-body-http-method-cannot-contain-body-or-t
+ @HTTP(method = "DELETE", path ="v1/account/delete", hasBody = true)
+ suspend fun deleteAccount(@Body deleteUser: DeleteUser)
+
+ @POST("v1/account/login")
+ suspend fun loginAccount(@Body loginUser: LoginUser): LoginResponse
+
+ @POST("v1/account/register")
+ suspend fun registerAccount(@Body registerUser: RegisterUser): LoginResponse
+
+
+
+ @DELETE("v1/playlists/{playlist_id}")
+ suspend fun deletePlaylist(@Path("playlist_id") playlistId: String)
+
+ @GET("v1/playlists/{playlist_id}")
+ suspend fun getPlaylist(@Path("playlist_id") playlistId: String): PlaylistResponse
+
+ @GET("v1/playlists/")
+ suspend fun getPlaylists(): List
+
+ @POST("v1/playlists/{playlist_id}/videos")
+ suspend fun addToPlaylist(@Path("playlist_id") playlistId: String, @Body createVideo: List)
+
+ @POST("v1/playlists/")
+ suspend fun createPlaylist(@Body createPlaylist: CreatePlaylist): Playlist
+
+ @DELETE("v1/playlists/{playlist_id}/videos/{video_id}")
+ suspend fun removeFromPlaylist(@Path("playlist_id") playlistId: String, @Path("video_id") videoId: String)
+
+ @PATCH("v1/playlists/{playlist_id}")
+ suspend fun updatePlaylist(@Path("playlist_id") playlistId: String, @Body createPlaylist: CreatePlaylist): Playlist
+
+
+
+ @GET("v1/subscriptions/")
+ suspend fun getSubscriptions(): List
+
+ @GET("v1/subscriptions/{channel_id}")
+ suspend fun getSubscription(@Path("channel_id") channelId: String): Channel
+
+ @PUT("v1/subscriptions/")
+ suspend fun subscribe(@Body channel: Channel)
+
+ @DELETE("v1/subscriptions/{channel_id}")
+ suspend fun unsubscribe(@Path("channel_id") channelId: String)
+
+
+
+ @POST("v1/subscriptions/groups/")
+ suspend fun createSubscriptionGroup(@Body subscriptionGroup: SubscriptionGroup): SubscriptionGroup
+
+ @DELETE("v1/subscriptions/groups/{subscription_group_id}")
+ suspend fun deleteSubscriptionGroup(@Path("subscription_group_id") subscriptionGroupId: String)
+
+ @GET("v1/subscriptions/groups/{subscription_group_id}")
+ suspend fun getSubscriptionGroup(@Path("subscription_group_id") subscriptionGroupId: String): ExtendedSubscriptionGroup
+
+ @GET("v1/subscriptions/groups/")
+ suspend fun getSubscriptionGroups(): List
+
+ @PATCH("v1/subscriptions/groups/{subscription_group_id}")
+ suspend fun updateSubscriptionGroup(@Path("subscription_group_id") subscriptionGroupId: String, @Body subscriptionGroup: SubscriptionGroup): SubscriptionGroup
+
+ @DELETE("v1/subscriptions/groups/{subscription_group_id}/channels/{channel_id}")
+ suspend fun removeFromSubscriptionGroup(@Path("subscription_group_id") subscriptionGroupId: String, @Path("channel_id") channelId: String)
+
+ @PUT("v1/subscriptions/groups/{subscription_group_id}/channels/{channel_id}")
+ suspend fun addToSubscriptionGroup(@Path("subscription_group_id") subscriptionGroupId: String, @Path("channel_id") channelId: String)
+
+
+
+ @PUT("v1/watch_history/")
+ suspend fun addToWatchHistory(@Body extendedWatchHistoryItem: ExtendedWatchHistoryItem): ExtendedWatchHistoryItem
+
+ @PATCH("v1/watch_history/{video_id}")
+ suspend fun updateWatchHistoryEntry(@Path("video_id") videoId: String, @Body watchHistoryItem: WatchHistoryItem)
+
+ @GET("v1/watch_history/{video_id}")
+ suspend fun getFromWatchHistory(@Path("video_id") videoId: String): ExtendedWatchHistoryItem
+
+ @GET("v1/watch_history/")
+ suspend fun getWatchHistory(
+ @Query("page") page: Int,
+ ): List
+
+ @DELETE("v1/watch_history/{video_id}")
+ suspend fun removeFromWatchHistory(@Path("video_id") videoId: String)
+
+ @DELETE("v1/watch_history/")
+ suspend fun clearWatchHistory()
+
+
+
+ @GET("v1/playlist_bookmarks/")
+ suspend fun getPlaylistBookmarks(): List
+
+ @GET("v1/playlist_bookmarks/{public_playlist_id}")
+ suspend fun getPlaylistBookmark(@Path("public_playlist_id") publicPlaylistId: String): ExtendedPublicPlaylist
+
+ @POST("v1/playlist_bookmarks/")
+ suspend fun createPlaylistBookmark(@Body extendedPublicPlaylist: ExtendedPublicPlaylist): ExtendedPublicPlaylist
+
+ @DELETE("v1/playlist_bookmarks/{public_playlist_id}")
+ suspend fun deletePlaylistBookmark(@Path("public_playlist_id") publicPlaylistId: String)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/Channel.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/Channel.kt
new file mode 100644
index 0000000000..da580275f2
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/Channel.kt
@@ -0,0 +1,22 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class Channel(
+ @SerialName(value = "avatar")
+ val avatar: String,
+
+ @SerialName(value = "id")
+ val id: String,
+
+ @SerialName(value = "name")
+ val name: String,
+
+ @SerialName(value = "verified")
+ val verified: Boolean
+
+) {
+}
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/CreatePlaylist.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/CreatePlaylist.kt
new file mode 100644
index 0000000000..b740c46bfe
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/CreatePlaylist.kt
@@ -0,0 +1,23 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class CreatePlaylist (
+
+ @SerialName(value = "description")
+ val description: kotlin.String,
+
+ @SerialName(value = "title")
+ val title: kotlin.String,
+
+ @SerialName(value = "thumbnail_url")
+ val thumbnailUrl: kotlin.String? = null
+
+) {
+
+
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/CreateVideo.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/CreateVideo.kt
new file mode 100644
index 0000000000..4f7233e51a
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/CreateVideo.kt
@@ -0,0 +1,42 @@
+package com.github.libretube.api.ltsync.obj
+
+import com.github.libretube.api.obj.StreamItem
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class CreateVideo(
+ @SerialName(value = "duration")
+ val duration: Int,
+
+ @SerialName(value = "id")
+ val id: String,
+
+ @SerialName(value = "thumbnail_url")
+ val thumbnailUrl: String,
+
+ @SerialName(value = "title")
+ val title: String,
+
+ /* Upload date as UNIX timestamp (millis). */
+ @SerialName(value = "upload_date")
+ val uploadDate: Long,
+
+ @SerialName(value = "uploader")
+ val uploader: Channel
+) {
+ fun toStreamItem(): StreamItem = StreamItem(
+ url = id,
+ title = title,
+ type = StreamItem.TYPE_STREAM,
+ uploaded = uploadDate,
+ thumbnail = thumbnailUrl,
+ duration = duration.toLong(),
+ uploaderUrl = uploader.id,
+ uploaderName = uploader.name,
+ uploaderVerified = uploader.verified,
+ uploaderAvatar = uploader.avatar,
+ )
+
+}
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/DeleteUser.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/DeleteUser.kt
new file mode 100644
index 0000000000..6dcfcbd8f4
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/DeleteUser.kt
@@ -0,0 +1,13 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class DeleteUser (
+ @SerialName(value = "password")
+ val password: String
+) {
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedPlaylist.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedPlaylist.kt
new file mode 100644
index 0000000000..0a102c0365
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedPlaylist.kt
@@ -0,0 +1,23 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ExtendedPlaylist (
+ @SerialName(value = "description")
+ val description: String,
+
+ @SerialName(value = "id")
+ val id: String,
+
+ @SerialName(value = "title")
+ val title: String,
+
+ @SerialName(value = "thumbnail_url")
+ val thumbnailUrl: String? = null,
+
+ @SerialName(value = "video_count")
+ val videoCount: Long? = null
+)
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedPublicPlaylist.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedPublicPlaylist.kt
new file mode 100644
index 0000000000..49ff5e5ae5
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedPublicPlaylist.kt
@@ -0,0 +1,34 @@
+package com.github.libretube.api.ltsync.obj
+
+
+import com.github.libretube.db.obj.PlaylistBookmark
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * Public (API) view of a read-only playlist (e.g. from YouTube).
+ *
+ * @param playlist
+ * @param uploader
+ */
+@Serializable
+data class ExtendedPublicPlaylist (
+ @SerialName(value = "playlist")
+ val playlist: ExtendedPlaylist,
+
+ @SerialName(value = "uploader")
+ val uploader: Channel
+) {
+ fun toPlaylistBookmark(): PlaylistBookmark {
+ return PlaylistBookmark(
+ playlistId = playlist.id,
+ playlistName = playlist.title,
+ thumbnailUrl = playlist.thumbnailUrl,
+ uploader = uploader.name,
+ uploaderUrl = uploader.id,
+ uploaderAvatar = uploader.avatar,
+ videos = playlist.videoCount?.toInt() ?: -1
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedSubscriptionGroup.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedSubscriptionGroup.kt
new file mode 100644
index 0000000000..9ccdc97fa6
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedSubscriptionGroup.kt
@@ -0,0 +1,12 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ExtendedSubscriptionGroup (
+ @SerialName(value = "channels")
+ val channels: List,
+ @SerialName(value = "group")
+ val group: SubscriptionGroup
+)
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedWatchHistoryItem.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedWatchHistoryItem.kt
new file mode 100644
index 0000000000..f1db221315
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/ExtendedWatchHistoryItem.kt
@@ -0,0 +1,17 @@
+package com.github.libretube.api.ltsync.obj
+
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ExtendedWatchHistoryItem (
+
+ @SerialName(value = "metadata")
+ val metadata: WatchHistoryItem,
+
+ @SerialName(value = "video")
+ val video: CreateVideo
+
+) {
+}
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/LoginResponse.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/LoginResponse.kt
new file mode 100644
index 0000000000..411f10f94c
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/LoginResponse.kt
@@ -0,0 +1,13 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class LoginResponse (
+ @SerialName(value = "jwt")
+ val jwt: String
+) {
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/LoginUser.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/LoginUser.kt
new file mode 100644
index 0000000000..e1857e0c09
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/LoginUser.kt
@@ -0,0 +1,16 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class LoginUser (
+ @SerialName(value = "name")
+ val name: String,
+
+ @SerialName(value = "password")
+ val password: String
+) {
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/Playlist.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/Playlist.kt
new file mode 100644
index 0000000000..791c690675
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/Playlist.kt
@@ -0,0 +1,34 @@
+package com.github.libretube.api.ltsync.obj
+
+import com.github.libretube.api.obj.Playlists
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class Playlist(
+ @SerialName(value = "description")
+ val description: String,
+
+ @SerialName(value = "id")
+ val id: String,
+
+ @SerialName(value = "title")
+ val title: String,
+
+ @SerialName(value = "thumbnail_url")
+ val thumbnailUrl: String? = null,
+
+ @SerialName(value = "video_count")
+ val videoCount: Long = 0,
+) {
+ fun toPipedPlaylists(): Playlists = Playlists(
+ id = id,
+ name = title,
+ shortDescription = description,
+ thumbnail = thumbnailUrl,
+ videos = videoCount
+ )
+
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/PlaylistResponse.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/PlaylistResponse.kt
new file mode 100644
index 0000000000..0dd279f830
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/PlaylistResponse.kt
@@ -0,0 +1,23 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class PlaylistResponse (
+ @SerialName(value = "playlist")
+ val playlist: Playlist,
+
+ @SerialName(value = "videos")
+ val videos: List
+) {
+ fun toPipedPlaylist(): com.github.libretube.api.obj.Playlist = com.github.libretube.api.obj.Playlist(
+ name = playlist.title,
+ description = playlist.description,
+ thumbnailUrl = playlist.thumbnailUrl,
+ videos = playlist.videoCount.toInt(),
+ relatedStreams = videos.map { it.toStreamItem() }
+ )
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/RegisterUser.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/RegisterUser.kt
new file mode 100644
index 0000000000..b0cc28bbfd
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/RegisterUser.kt
@@ -0,0 +1,16 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class RegisterUser (
+ @SerialName(value = "name")
+ val name: String,
+
+ @SerialName(value = "password")
+ val password: String
+) {
+}
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/SubscriptionGroup.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/SubscriptionGroup.kt
new file mode 100644
index 0000000000..9f8fdd5c23
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/SubscriptionGroup.kt
@@ -0,0 +1,14 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class SubscriptionGroup(
+ @SerialName(value = "id")
+ val id: String,
+ @SerialName(value = "title")
+ val title: String
+)
+
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/WatchHistoryItem.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/WatchHistoryItem.kt
new file mode 100644
index 0000000000..ec56bad0ac
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/WatchHistoryItem.kt
@@ -0,0 +1,18 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class WatchHistoryItem (
+ /* Date as UNIX timestamp (millis). */
+ @SerialName(value = "added_date")
+ val addedDate: kotlin.Long,
+
+ @Contextual @SerialName(value = "watched_state")
+ val watchedState: WatchedState,
+
+ @SerialName(value = "position_millis")
+ val positionMillis: kotlin.Int? = null
+)
diff --git a/app/src/main/java/com/github/libretube/api/ltsync/obj/WatchedState.kt b/app/src/main/java/com/github/libretube/api/ltsync/obj/WatchedState.kt
new file mode 100644
index 0000000000..7555dc977d
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/ltsync/obj/WatchedState.kt
@@ -0,0 +1,20 @@
+package com.github.libretube.api.ltsync.obj
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class WatchedState {
+
+ @SerialName(value = "planned")
+ Planned,
+
+ @SerialName(value = "watching")
+ Watching,
+
+ @SerialName(value = "completed")
+ Completed,
+
+ @SerialName(value = "dropped")
+ Dropped
+}
diff --git a/app/src/main/java/com/github/libretube/api/obj/WatchHistoryEntry.kt b/app/src/main/java/com/github/libretube/api/obj/WatchHistoryEntry.kt
new file mode 100644
index 0000000000..26b7379bcb
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/api/obj/WatchHistoryEntry.kt
@@ -0,0 +1,13 @@
+package com.github.libretube.api.obj
+
+data class WatchHistoryEntryMetadata(
+ val videoId: String,
+ val addedDate: Long,
+ val finished: Boolean,
+ val positionMillis: Long? = null,
+)
+
+data class WatchHistoryEntry(
+ val metadata: WatchHistoryEntryMetadata,
+ val video: StreamItem
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt
index 32c54219e6..7c47867711 100644
--- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt
+++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt
@@ -34,10 +34,11 @@ object PreferenceKeys {
const val SEARCH_SUGGESTIONS = "search_suggestions"
// Instance
+ const val YOUTUBE_DATA_SOURCE = "youtube_data_source"
+ const val SYNC_SERVER_TYPE = "sync_server"
+ const val LIBRETUBE_SYNC_SERVER_URL = "libretube_sync_server_url"
const val FETCH_INSTANCE = "selectInstance"
const val AUTH_INSTANCE = "selectAuthInstance"
- const val AUTH_INSTANCE_TOGGLE = "auth_instance_toggle"
- const val CUSTOM_INSTANCE = "customInstance"
const val LOGIN_REGISTER = "login_register"
const val LOGOUT = "logout"
const val DELETE_ACCOUNT = "delete_account"
@@ -105,7 +106,6 @@ object PreferenceKeys {
const val SHOW_UPCOMING_IN_FEED = "show_upcoming_in_feed"
const val SELECTED_FEED_FILTERS = "filter_feed"
const val FEED_SORT_ORDER = "sort_oder_feed"
- const val LOCAL_FEED_EXTRACTION = "local_feed_extraction"
const val LAST_LOCAL_FEED_REFRESH_TIMESTAMP_MILLIS = "last_feed_refresh_timestamp_millis"
// Advanced
@@ -118,12 +118,9 @@ object PreferenceKeys {
const val CLEAR_WATCH_POSITIONS = "clear_watch_positions"
const val SHARE_WITH_TIME_CODE = "share_with_time_code"
const val SELECTED_SHARE_HOST = "selected_share_host"
- const val CLEAR_BOOKMARKS = "clear_bookmarks"
const val MAX_CONCURRENT_DOWNLOADS = "max_parallel_downloads"
const val EXTERNAL_DOWNLOAD_PROVIDER = "external_download_provider"
- const val FULL_LOCAL_MODE = "full_local_mode"
const val LOCAL_RYD = "local_return_youtube_dislikes"
- const val LOCAL_STREAM_EXTRACTION = "local_stream_extraction"
const val INCLUDE_TIMESTAMP_IN_BACKUP_FILENAME = "include_timestamp_in_filename"
// History
@@ -139,6 +136,7 @@ object PreferenceKeys {
const val SELECTED_DOWNLOAD_PLAYLIST_SORT_TYPE = "selected_download_playlist_sort_type"
const val LAST_SHOWN_INFO_MESSAGE_VERSION_CODE = "last_shown_info_message_version"
const val PREFERENCE_VERSION = "PREFERENCE_VERSION"
+ const val WELCOME_ACTIVITY_FINISHED = "WELCOME_ACTIVITY_FINISHED"
// use the helper methods at PreferenceHelper to access these
const val LAST_USER_SEEN_FEED_TIME = "last_watched_feed_time"
diff --git a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt
index 4685710ec9..a7813c9cf9 100644
--- a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt
+++ b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt
@@ -1,16 +1,20 @@
package com.github.libretube.db
+import android.util.Log
import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.api.obj.WatchHistoryEntry
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.SearchHistoryItem
-import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.enums.ContentFilter
+import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
+import kotlin.time.Clock
object DatabaseHelper {
private const val MAX_SEARCH_HISTORY_SIZE = 20
@@ -21,39 +25,6 @@ object DatabaseHelper {
// can only mark as watched if at least 75% watched
private const val RELATIVE_WATCHED_THRESHOLD = 0.75f
- suspend fun addToWatchHistory(watchHistoryItem: WatchHistoryItem) =
- withContext(Dispatchers.IO) {
- Database.watchHistoryDao().insert(watchHistoryItem)
- val maxHistorySize = PreferenceHelper.getString(
- PreferenceKeys.WATCH_HISTORY_SIZE,
- "100"
- )
- if (maxHistorySize == "unlimited") {
- return@withContext
- }
-
- // delete the first watch history entry if the limit is reached
- val historySize = Database.watchHistoryDao().getSize()
- if (historySize > maxHistorySize.toInt()) {
- Database.watchHistoryDao().delete(Database.watchHistoryDao().getOldest())
- }
- }
-
- suspend fun getWatchHistoryPage(page: Int, pageSize: Int): List {
- val watchHistoryDao = Database.watchHistoryDao()
- val historySize = watchHistoryDao.getSize()
-
- if (historySize < pageSize * (page - 1)) return emptyList()
-
- val offset = historySize - (pageSize * page)
- val limit = if (offset < 0) {
- offset + pageSize
- } else {
- pageSize
- }
- return watchHistoryDao.getN(limit, maxOf(offset, 0)).reversed()
- }
-
suspend fun addToSearchHistory(searchHistoryItem: SearchHistoryItem) {
Database.searchHistoryDao().insert(searchHistoryItem)
@@ -68,10 +39,25 @@ object DatabaseHelper {
}
}
- suspend fun getWatchPosition(videoId: String) = Database.watchPositionDao().findById(videoId)?.position
-
- fun getWatchPositionBlocking(videoId: String): Long? = runBlocking(Dispatchers.IO) {
- getWatchPosition(videoId)
+ suspend fun getWatchPosition(videoId: String) = runCatching {
+ UserDataRepositoryHelper.userDataRepository
+ .getFromWatchHistory(videoId)
+ }.getOrNull()?.metadata?.positionMillis
+
+ suspend fun addToWatchHistory(video: StreamItem) = try {
+ UserDataRepositoryHelper.userDataRepository.addToWatchHistory(
+ WatchHistoryEntry(
+ metadata = WatchHistoryEntryMetadata(
+ videoId = video.url!!.toID(),
+ addedDate = Clock.System.now().toEpochMilliseconds(),
+ finished = false,
+ positionMillis = null
+ ),
+ video = video
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(TAG(), e.toString())
}
suspend fun isVideoWatched(videoId: String, duration: Long): Boolean =
@@ -99,10 +85,13 @@ object DatabaseHelper {
* @param unfinished If true, only returns unfinished videos. If false, only returns finished videos.
*/
suspend fun filterByWatchStatus(
- watchHistoryItem: WatchHistoryItem,
+ watchHistoryItem: WatchHistoryEntry,
unfinished: Boolean = true
): Boolean {
- return unfinished xor isVideoWatched(watchHistoryItem.videoId, watchHistoryItem.duration ?: 0)
+ return unfinished xor isVideoWatched(
+ watchHistoryItem.metadata.videoId,
+ watchHistoryItem.video.duration ?: 0
+ )
}
suspend fun filterByStreamTypeAndWatchPosition(
diff --git a/app/src/main/java/com/github/libretube/db/dao/SubscriptionGroupsDao.kt b/app/src/main/java/com/github/libretube/db/dao/SubscriptionGroupsDao.kt
index 0db9887fde..4fc5a015a8 100644
--- a/app/src/main/java/com/github/libretube/db/dao/SubscriptionGroupsDao.kt
+++ b/app/src/main/java/com/github/libretube/db/dao/SubscriptionGroupsDao.kt
@@ -12,6 +12,9 @@ interface SubscriptionGroupsDao {
@Query("SELECT * FROM subscriptionGroups ORDER BY `index` ASC")
suspend fun getAll(): List
+ @Query("SELECT * FROM subscriptionGroups WHERE name = :name")
+ suspend fun getByName(name: String): SubscriptionGroup?
+
@Query("SELECT EXISTS(SELECT * FROM subscriptionGroups WHERE name = :name)")
suspend fun exists(name: String): Boolean
@@ -22,7 +25,7 @@ interface SubscriptionGroupsDao {
suspend fun insertAll(subscriptionGroups: List)
@Update
- suspend fun updateAll(subscriptionGroups: List)
+ suspend fun updateGroup(subscriptionGroup: SubscriptionGroup)
@Query("DELETE FROM subscriptionGroups WHERE name = :name")
suspend fun deleteGroup(name: String)
diff --git a/app/src/main/java/com/github/libretube/db/obj/SubscriptionGroup.kt b/app/src/main/java/com/github/libretube/db/obj/SubscriptionGroup.kt
index f56050cdf8..0340de9d35 100644
--- a/app/src/main/java/com/github/libretube/db/obj/SubscriptionGroup.kt
+++ b/app/src/main/java/com/github/libretube/db/obj/SubscriptionGroup.kt
@@ -1,6 +1,7 @@
package com.github.libretube.db.obj
import androidx.room.Entity
+import androidx.room.Ignore
import androidx.room.PrimaryKey
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
@@ -16,5 +17,16 @@ data class SubscriptionGroup(
@JsonNames("groupName", "name")
var name: String,
var channels: List = listOf(),
- var index: Int = 0
-)
+ var index: Int = 0,
+
+ @Ignore
+ var id: String = "",
+) {
+ constructor(name: String) : this(name = name, channels = emptyList())
+
+ init {
+ // in LibreTube, we identify channel groups by their name
+ // so for compatibility with other APIs, the ID is set to the name of the group
+ if (id.isEmpty()) id = name
+ }
+}
diff --git a/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt b/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt
index 93b3e68c97..7277351390 100644
--- a/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt
+++ b/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt
@@ -4,6 +4,10 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.api.obj.WatchHistoryEntry
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
+import com.github.libretube.db.DatabaseHelper
+import com.github.libretube.db.DatabaseHolder
import com.github.libretube.extensions.toMillis
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
@@ -20,6 +24,8 @@ data class WatchHistoryItem(
@ColumnInfo var thumbnailUrl: String? = null,
@ColumnInfo val duration: Long? = null,
@ColumnInfo val isShort: Boolean = false
+
+ // TODO: store date when the video was added to the history
) {
val isLive get() = (duration == null) || (duration <= 0L)
@@ -36,4 +42,21 @@ data class WatchHistoryItem(
duration = duration,
isShort = isShort
)
+
+ suspend fun toWatchHistoryEntry(): WatchHistoryEntry {
+ val watchPosition = DatabaseHolder.Database.watchPositionDao().findById(videoId)
+ val isWatched = watchPosition?.position?.let {
+ DatabaseHelper.isVideoWatched(it, duration)
+ } ?: false
+
+ return WatchHistoryEntry(
+ metadata = WatchHistoryEntryMetadata(
+ videoId = videoId,
+ finished = isWatched,
+ addedDate = -1,
+ positionMillis = watchPosition?.position,
+ ),
+ video = toStreamItem()
+ )
+ }
}
diff --git a/app/src/main/java/com/github/libretube/enums/PlaylistType.kt b/app/src/main/java/com/github/libretube/enums/PlaylistType.kt
index 852a1cda9e..6e0f7da3d6 100644
--- a/app/src/main/java/com/github/libretube/enums/PlaylistType.kt
+++ b/app/src/main/java/com/github/libretube/enums/PlaylistType.kt
@@ -4,11 +4,6 @@ enum class PlaylistType {
/**
* Local playlist
*/
- LOCAL,
-
- /**
- * Piped playlist
- */
PRIVATE,
/**
diff --git a/app/src/main/java/com/github/libretube/enums/SyncServerType.kt b/app/src/main/java/com/github/libretube/enums/SyncServerType.kt
new file mode 100644
index 0000000000..dabf9d36de
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/enums/SyncServerType.kt
@@ -0,0 +1,8 @@
+package com.github.libretube.enums
+
+// order and names must be kept in sync with WelcomeViewModel.kt and array.xml!
+enum class SyncServerType {
+ NONE,
+ LIBRETUBE,
+ PIPED
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt b/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt
index 8639b504d1..76e576286c 100644
--- a/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt
+++ b/app/src/main/java/com/github/libretube/helpers/DownloadHelper.kt
@@ -120,7 +120,7 @@ object DownloadHelper {
} else {
CoroutineScope(Dispatchers.IO).launch {
val playlistVideoIds = try {
- PlaylistsHelper.getPlaylist(playlistId)
+ PlaylistsHelper.getPlaylist(playlistId, PlaylistsHelper.getPlaylistType(playlistId))
} catch (e: Exception) {
context.toastFromMainDispatcher(R.string.unknown_error)
return@launch
diff --git a/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt b/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt
index 0504e7fd72..52bb174359 100644
--- a/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt
+++ b/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt
@@ -10,6 +10,7 @@ import com.github.libretube.api.JsonHelper
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.db.DatabaseHelper
+import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.enums.ImportFormat
import com.github.libretube.extensions.TAG
@@ -356,7 +357,7 @@ object ImportHelper {
}
for (video in videos) {
- DatabaseHelper.addToWatchHistory(video)
+ DatabaseHolder.Database.watchHistoryDao().insert(video)
}
if (videos.isEmpty()) {
diff --git a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt
index f8b4cddd90..15406a3584 100644
--- a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt
+++ b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt
@@ -7,6 +7,7 @@ import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Looper
import android.util.Base64
+import android.util.Log
import android.view.accessibility.CaptioningManager
import androidx.annotation.OptIn
import androidx.annotation.StringRes
@@ -39,14 +40,16 @@ import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
import com.github.libretube.constants.PreferenceKeys
-import com.github.libretube.db.DatabaseHolder
-import com.github.libretube.db.obj.WatchPosition
+import com.github.libretube.db.DatabaseHelper
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.SbSkipOptions
+import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.seekBy
import com.github.libretube.extensions.togglePlayPauseState
import com.github.libretube.obj.VideoStats
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.util.TextUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -54,6 +57,7 @@ import kotlinx.coroutines.launch
import java.util.Locale
import kotlin.math.max
import kotlin.math.roundToInt
+import kotlin.time.Clock
object PlayerHelper {
private const val ACTION_MEDIA_CONTROL = "media_control"
@@ -61,7 +65,6 @@ object PlayerHelper {
const val SPONSOR_HIGHLIGHT_CATEGORY = "poi_highlight"
const val ROLE_FLAG_AUTO_GEN_SUBTITLE = C.ROLE_FLAG_SUPPLEMENTARY
private const val MINIMUM_BUFFER_DURATION = 1000 * 10 // exo default is 50s
- const val WATCH_POSITION_TIMER_DELAY_MS = 1000L
/**
* Playback speed while the fast forward action is active (triggered by a long press on the player)
@@ -353,16 +356,10 @@ object PlayerHelper {
)
val fullLocalMode: Boolean
- get() = PreferenceHelper.getBoolean(
- PreferenceKeys.FULL_LOCAL_MODE,
- false
- )
-
- val localStreamExtraction: Boolean
- get() = PreferenceHelper.getBoolean(
- PreferenceKeys.LOCAL_STREAM_EXTRACTION,
- true
- )
+ get() = PreferenceHelper.getString(
+ PreferenceKeys.YOUTUBE_DATA_SOURCE,
+ "local"
+ ) == "local"
val localRYD: Boolean
get() = PreferenceHelper.getBoolean(
@@ -466,6 +463,7 @@ object PlayerHelper {
listOf(rewindAction, playPauseAction, forwardAction)
}
}
+
@OptIn(UnstableApi::class)
private fun createRendererFactory(context: Context): DefaultRenderersFactory {
val renderersFactory = object : DefaultRenderersFactory(context) {
@@ -483,6 +481,7 @@ object PlayerHelper {
}
return renderersFactory
}
+
/**
* Create a basic player, that is used for all types of playback situations inside the app
*/
@@ -872,9 +871,22 @@ object PlayerHelper {
return
}
- val watchPosition = WatchPosition(videoId, player.currentPosition)
+ val watchHistoryEntry = WatchHistoryEntryMetadata(
+ videoId = videoId,
+ finished = DatabaseHelper.isVideoWatched(
+ player.currentPosition,
+ player.duration.div(1000)
+ ),
+ addedDate = Clock.System.now().toEpochMilliseconds(),
+ positionMillis = player.currentPosition
+ )
CoroutineScope(Dispatchers.IO).launch {
- DatabaseHolder.Database.watchPositionDao().insert(watchPosition)
+ try {
+ UserDataRepositoryHelper.userDataRepository
+ .updateWatchHistoryEntry(watchHistoryEntry)
+ } catch (e: Exception) {
+ Log.e(TAG(), e.toString())
+ }
}
}
diff --git a/app/src/main/java/com/github/libretube/helpers/PreferenceHelper.kt b/app/src/main/java/com/github/libretube/helpers/PreferenceHelper.kt
index 3961098c67..5c26ada582 100644
--- a/app/src/main/java/com/github/libretube/helpers/PreferenceHelper.kt
+++ b/app/src/main/java/com/github/libretube/helpers/PreferenceHelper.kt
@@ -10,7 +10,6 @@ import com.github.libretube.R
import com.github.libretube.api.TrendingCategory
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.enums.SbSkipOptions
-import com.github.libretube.extensions.round
import com.github.libretube.helpers.LocaleHelper.getDetectedCountry
import kotlin.math.roundToInt
@@ -147,6 +146,19 @@ object PreferenceHelper {
PreferenceMigration(6, 7) {
remove("disable_video_image_proxy")
},
+ PreferenceMigration(7, 8) {
+ remove("local_stream_extraction")
+
+ val usesFullLocalMode = getBoolean("full_local_mode", true)
+ putString(PreferenceKeys.YOUTUBE_DATA_SOURCE, if (usesFullLocalMode) "local" else "piped")
+ remove("full_local_mode")
+
+ val usesPipedAuth = getBoolean("auth_instance_toggle", false)
+ if (usesPipedAuth) putString(PreferenceKeys.SYNC_SERVER_TYPE, "piped")
+ remove("auth_instance_toggle")
+
+ remove("local_feed_extraction") // local feed extraction is default now unless Piped is used
+ },
)
/**
diff --git a/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt
deleted file mode 100644
index 6743d032f5..0000000000
--- a/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.github.libretube.repo
-
-import com.github.libretube.api.RetrofitInstance
-import com.github.libretube.api.obj.Subscribe
-import com.github.libretube.api.obj.Subscription
-import com.github.libretube.extensions.toID
-import com.github.libretube.helpers.PreferenceHelper
-
-class AccountSubscriptionsRepository : SubscriptionsRepository {
- private val token get() = PreferenceHelper.getToken()
-
- override suspend fun subscribe(
- channelId: String, name: String, uploaderAvatar: String?, verified: Boolean
- ) {
- runCatching {
- RetrofitInstance.authApi.subscribe(token, Subscribe(channelId))
- }
- }
-
- override suspend fun unsubscribe(channelId: String) {
- runCatching {
- RetrofitInstance.authApi.unsubscribe(token, Subscribe(channelId))
- }
- }
-
- override suspend fun isSubscribed(channelId: String): Boolean? {
- return runCatching {
- RetrofitInstance.authApi.isSubscribed(channelId, token)
- }.getOrNull()?.subscribed
- }
-
- override suspend fun importSubscriptions(newChannels: List) {
- RetrofitInstance.authApi.importSubscriptions(false, token, newChannels)
- }
-
- override suspend fun getSubscriptions(): List {
- return RetrofitInstance.authApi.subscriptions(token)
- }
-
- override suspend fun getSubscriptionChannelIds(): List {
- return getSubscriptions().map { it.url.toID() }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/LibreTubeSyncServerUserDataRepository.kt b/app/src/main/java/com/github/libretube/repo/LibreTubeSyncServerUserDataRepository.kt
new file mode 100644
index 0000000000..e28ddfb790
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/repo/LibreTubeSyncServerUserDataRepository.kt
@@ -0,0 +1,396 @@
+package com.github.libretube.repo
+
+import com.github.libretube.api.RetrofitInstance
+import com.github.libretube.api.ltsync.obj.Channel
+import com.github.libretube.api.ltsync.obj.CreatePlaylist
+import com.github.libretube.api.ltsync.obj.CreateVideo
+import com.github.libretube.api.ltsync.obj.DeleteUser
+import com.github.libretube.api.ltsync.obj.ExtendedPlaylist
+import com.github.libretube.api.ltsync.obj.ExtendedPublicPlaylist
+import com.github.libretube.api.ltsync.obj.ExtendedSubscriptionGroup
+import com.github.libretube.api.ltsync.obj.ExtendedWatchHistoryItem
+import com.github.libretube.api.ltsync.obj.LoginUser
+import com.github.libretube.api.ltsync.obj.RegisterUser
+import com.github.libretube.api.ltsync.obj.WatchHistoryItem
+import com.github.libretube.api.ltsync.obj.WatchedState
+import com.github.libretube.api.obj.Playlist
+import com.github.libretube.api.obj.Playlists
+import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.api.obj.Subscription
+import com.github.libretube.api.obj.WatchHistoryEntry
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
+import com.github.libretube.db.obj.PlaylistBookmark
+import com.github.libretube.db.obj.SubscriptionGroup
+import com.github.libretube.extensions.toID
+import retrofit2.HttpException
+
+class LibreTubeSyncServerUserDataRepository : UserDataRepository {
+ override var requiresLogin: Boolean = true
+
+ private val api get() = RetrofitInstance.libretubeSyncServerApi
+
+ private suspend fun tryHttpOrRaiseError(block: suspend () -> T): T {
+ try {
+ return block()
+ } catch (e: HttpException) {
+ val message = e.response()?.errorBody()?.string()
+ throw Exception(message.orEmpty().ifEmpty { e.message })
+ }
+ }
+
+ override suspend fun login(username: String, password: String): String {
+ return tryHttpOrRaiseError {
+ api.loginAccount(
+ LoginUser(
+ username,
+ password
+ )
+ ).jwt
+ }
+ }
+
+ override suspend fun register(username: String, password: String): String {
+ return tryHttpOrRaiseError {
+ api.registerAccount(
+ RegisterUser(
+ username,
+ password
+ )
+ ).jwt
+ }
+ }
+
+ override suspend fun deleteAccount(password: String) {
+ tryHttpOrRaiseError {
+ api.deleteAccount(
+ DeleteUser(password)
+ )
+ }
+ }
+
+ override suspend fun subscribe(
+ channelId: String,
+ name: String,
+ uploaderAvatar: String?,
+ verified: Boolean
+ ) {
+ tryHttpOrRaiseError {
+ api.subscribe(
+ Channel(
+ id = channelId,
+ name = name,
+ avatar = uploaderAvatar.orEmpty(),
+ verified = verified
+ )
+ )
+ }
+ }
+
+ override suspend fun unsubscribe(channelId: String) {
+ tryHttpOrRaiseError { api.unsubscribe(channelId) }
+ }
+
+ override suspend fun isSubscribed(channelId: String): Boolean? {
+ try {
+ // is subscribed if we can successfully load the subscription data
+ api.getSubscription(channelId)
+ return true
+ } catch (e: HttpException) {
+ if (e.response()?.code() == 404) return false
+ }
+
+ return null
+ }
+
+ override suspend fun getSubscriptions(): List {
+ return tryHttpOrRaiseError { api.getSubscriptions() }.map { channel ->
+ Subscription(
+ url = channel.id,
+ name = channel.name,
+ avatar = channel.avatar,
+ verified = channel.verified
+ )
+ }
+ }
+
+ override suspend fun getSubscriptionChannelIds(): List {
+ return tryHttpOrRaiseError { api.getSubscriptions() }.map { it.id }
+ }
+
+
+ override suspend fun getPlaylist(playlistId: String): Playlist {
+ return tryHttpOrRaiseError { api.getPlaylist(playlistId).toPipedPlaylist() }
+ }
+
+ override suspend fun getPlaylists(): List {
+ return tryHttpOrRaiseError { api.getPlaylists().map { it.toPipedPlaylists() } }
+ }
+
+ private fun StreamItem.toCreateVideo(): CreateVideo = CreateVideo(
+ id = url!!.toID(),
+ title = title.orEmpty(),
+ thumbnailUrl = thumbnail.orEmpty(),
+ duration = duration?.toInt() ?: -1,
+ uploadDate = uploaded,
+ uploader = Channel(
+ id = uploaderUrl!!.toID(),
+ name = uploaderName.orEmpty(),
+ avatar = uploaderAvatar.orEmpty(),
+ verified = uploaderVerified == true
+ )
+ )
+
+ override suspend fun addToPlaylist(
+ playlistId: String,
+ vararg videos: StreamItem
+ ): Boolean {
+ tryHttpOrRaiseError {
+ api.addToPlaylist(playlistId, videos.map { it.toCreateVideo() })
+ }
+
+ return true
+ }
+
+ override suspend fun renamePlaylist(
+ playlistId: String,
+ newName: String
+ ): Boolean {
+ val playlist = tryHttpOrRaiseError { getPlaylist(playlistId).copy(name = newName) }
+
+ return runCatching { updatePlaylist(playlistId, playlist) }.isSuccess
+ }
+
+ override suspend fun changePlaylistDescription(
+ playlistId: String,
+ newDescription: String
+ ): Boolean {
+ val playlist =
+ tryHttpOrRaiseError { getPlaylist(playlistId).copy(description = newDescription) }
+
+ return runCatching { updatePlaylist(playlistId, playlist) }.isSuccess
+ }
+
+ private fun Playlist.toCreatePlaylist(): CreatePlaylist = CreatePlaylist(
+ title = name.orEmpty(),
+ description = description.orEmpty(),
+ thumbnailUrl = thumbnailUrl
+ )
+
+ private suspend fun updatePlaylist(playlistId: String, playlist: Playlist) {
+ tryHttpOrRaiseError { api.updatePlaylist(playlistId, playlist.toCreatePlaylist()) }
+ }
+
+ override suspend fun removeFromPlaylist(
+ playlistId: String,
+ videoId: String,
+ index: Int
+ ): Boolean {
+ return runCatching { api.removeFromPlaylist(playlistId, videoId) }.isSuccess
+ }
+
+ override suspend fun createPlaylist(playlistName: String): String {
+ return tryHttpOrRaiseError {
+ api.createPlaylist(
+ CreatePlaylist(
+ title = playlistName,
+ description = "",
+ thumbnailUrl = null
+ )
+ ).id
+ }
+ }
+
+ override suspend fun deletePlaylist(playlistId: String): Boolean {
+ return runCatching { api.deletePlaylist(playlistId) }.isSuccess
+ }
+
+ override suspend fun createSubscriptionGroup(name: String): String {
+ return tryHttpOrRaiseError {
+ api.createSubscriptionGroup(
+ com.github.libretube.api.ltsync.obj.SubscriptionGroup(
+ "",
+ name
+ )
+ ).id
+ }
+ }
+
+ override suspend fun renameSubscriptionGroup(
+ subscriptionGroupId: String,
+ newName: String
+ ) {
+ tryHttpOrRaiseError {
+ api.updateSubscriptionGroup(
+ subscriptionGroupId, com.github.libretube.api.ltsync.obj.SubscriptionGroup(
+ subscriptionGroupId, title = newName
+ )
+ )
+ }
+ }
+
+ override suspend fun deleteSubscriptionGroup(subscriptionGroupId: String) {
+ tryHttpOrRaiseError {
+ api.deleteSubscriptionGroup(subscriptionGroupId)
+ }
+ }
+
+ private fun ExtendedSubscriptionGroup.toSubscriptionGroup(): SubscriptionGroup {
+ return SubscriptionGroup(
+ id = group.id,
+ name = group.title,
+ channels = channels.map { it.id }
+ )
+ }
+
+ override suspend fun getSubscriptionGroup(subscriptionGroupId: String): SubscriptionGroup {
+ return tryHttpOrRaiseError {
+ api.getSubscriptionGroup(subscriptionGroupId).toSubscriptionGroup()
+ }
+ }
+
+ override suspend fun getSubscriptionGroups(): List {
+ return tryHttpOrRaiseError {
+ api.getSubscriptionGroups().map { it.toSubscriptionGroup() }
+ }
+ }
+
+ override suspend fun addToSubscriptionGroup(
+ subscriptionGroupId: String,
+ channelId: String
+ ) {
+ tryHttpOrRaiseError {
+ api.addToSubscriptionGroup(subscriptionGroupId, channelId)
+ }
+ }
+
+ override suspend fun removeFromSubscriptionGroup(
+ subscriptionGroupId: String,
+ channelId: String
+ ) {
+ tryHttpOrRaiseError {
+ api.removeFromSubscriptionGroup(subscriptionGroupId, channelId)
+ }
+ }
+
+ private fun WatchHistoryItem.toWatchHistoryEntryMetadata(videoId: String): WatchHistoryEntryMetadata {
+ return WatchHistoryEntryMetadata(
+ videoId = videoId,
+ addedDate = addedDate,
+ finished = watchedState == WatchedState.Completed,
+ positionMillis = positionMillis?.toLong()
+ )
+ }
+
+ private fun ExtendedWatchHistoryItem.toWatchHistoryEntry(): WatchHistoryEntry {
+ return WatchHistoryEntry(
+ metadata = metadata.toWatchHistoryEntryMetadata(video.id),
+ video = video.toStreamItem()
+ )
+ }
+
+ private fun WatchHistoryEntryMetadata.toWatchHistoryItem(): WatchHistoryItem {
+ return WatchHistoryItem(
+ addedDate = addedDate,
+ watchedState = if (finished) WatchedState.Completed else WatchedState.Watching,
+ positionMillis = positionMillis?.toInt()
+ )
+ }
+
+ override suspend fun getWatchHistory(page: Int): List {
+ return tryHttpOrRaiseError {
+ api.getWatchHistory(page).map { it.toWatchHistoryEntry() }
+ }
+ }
+
+ override suspend fun getFromWatchHistory(videoId: String): WatchHistoryEntry? {
+ return tryHttpOrRaiseError {
+ try {
+ api.getFromWatchHistory(videoId).toWatchHistoryEntry()
+ } catch (e: HttpException) {
+ // if we get 404, the video is not in the watch history
+ if (e.code() == 404) return@tryHttpOrRaiseError null
+ else throw e
+ }
+ }
+ }
+
+ override suspend fun clearWatchHistory() {
+ tryHttpOrRaiseError {
+ api.clearWatchHistory()
+ }
+ }
+
+ override suspend fun addToWatchHistory(watchHistoryEntry: WatchHistoryEntry) {
+ val video = watchHistoryEntry.video.toCreateVideo()
+ val metadata = watchHistoryEntry.metadata.toWatchHistoryItem()
+
+ tryHttpOrRaiseError {
+ api.addToWatchHistory(
+ ExtendedWatchHistoryItem(metadata, video)
+ )
+ }
+ }
+
+ override suspend fun updateWatchHistoryEntry(metadata: WatchHistoryEntryMetadata) {
+ tryHttpOrRaiseError {
+ api.updateWatchHistoryEntry(metadata.videoId, metadata.toWatchHistoryItem())
+ }
+ }
+
+ override suspend fun removeFromWatchHistory(videoId: String) {
+ tryHttpOrRaiseError {
+ api.removeFromWatchHistory(videoId)
+ }
+ }
+
+ override suspend fun getPlaylistBookmarks(): List {
+ return tryHttpOrRaiseError {
+ api.getPlaylistBookmarks().map {
+ it.toPlaylistBookmark()
+ }
+ }
+ }
+
+ override suspend fun getPlaylistBookmark(playlistId: String): PlaylistBookmark? {
+ return tryHttpOrRaiseError {
+ try {
+ api.getPlaylistBookmark(playlistId).toPlaylistBookmark()
+ } catch (e: HttpException) {
+ // The API returns 404 Not Found if the playlist is not bookmarked
+ if (e.code() == 404) return@tryHttpOrRaiseError null
+ else throw e
+ }
+ }
+ }
+
+ private fun PlaylistBookmark.toExtendedPublicPlaylist(): ExtendedPublicPlaylist =
+ ExtendedPublicPlaylist(
+ playlist = ExtendedPlaylist(
+ id = playlistId,
+ title = playlistName.orEmpty(),
+ description = "", // TODO: also store and support playlist descriptions
+ thumbnailUrl = thumbnailUrl,
+ videoCount = videos.toLong()
+ ),
+ uploader = Channel(
+ id = uploaderUrl.orEmpty(),
+ avatar = uploaderAvatar.orEmpty(),
+ name = uploader.orEmpty(),
+ verified = false
+ )
+ )
+
+ override suspend fun createPlaylistBookmark(playlist: PlaylistBookmark) {
+ tryHttpOrRaiseError {
+ api.createPlaylistBookmark(
+ playlist.toExtendedPublicPlaylist()
+ )
+ }
+ }
+
+ override suspend fun deletePlaylistBookmark(playlistId: String) {
+ tryHttpOrRaiseError {
+ api.deletePlaylistBookmark(playlistId)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt
deleted file mode 100644
index f79d99de5f..0000000000
--- a/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package com.github.libretube.repo
-
-import com.github.libretube.api.MediaServiceRepository
-import com.github.libretube.api.PlaylistsHelper
-import com.github.libretube.api.PlaylistsHelper.MAX_CONCURRENT_IMPORT_CALLS
-import com.github.libretube.api.obj.Playlist
-import com.github.libretube.api.obj.Playlists
-import com.github.libretube.api.obj.StreamItem
-import com.github.libretube.db.DatabaseHolder
-import com.github.libretube.db.obj.LocalPlaylist
-import com.github.libretube.extensions.parallelMap
-import com.github.libretube.obj.PipedImportPlaylist
-
-class LocalPlaylistsRepository: PlaylistRepository {
- override suspend fun getPlaylist(playlistId: String): Playlist {
- val relation = DatabaseHolder.Database.localPlaylistsDao().getAll()
- .first { it.playlist.id.toString() == playlistId }
-
- return Playlist(
- name = relation.playlist.name,
- description = relation.playlist.description,
- thumbnailUrl = relation.playlist.thumbnailUrl,
- videos = relation.videos.size,
- relatedStreams = relation.videos.map { it.toStreamItem() }
- )
- }
-
- override suspend fun getPlaylists(): List {
- return DatabaseHolder.Database.localPlaylistsDao().getAll()
- .map {
- Playlists(
- id = it.playlist.id.toString(),
- name = it.playlist.name,
- shortDescription = it.playlist.description,
- thumbnail = it.playlist.thumbnailUrl,
- videos = it.videos.size.toLong()
- )
- }
- }
-
- override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
- val localPlaylist = DatabaseHolder.Database.localPlaylistsDao().getAll()
- .first { it.playlist.id.toString() == playlistId }
-
- for (video in videos) {
- val localPlaylistItem = video.toLocalPlaylistItem(playlistId)
-
- val existingVideo = DatabaseHolder.Database.localPlaylistsDao()
- .getPlaylistVideo(playlistId, localPlaylistItem.videoId)
- if (existingVideo != null) {
- // update existing video metadata
- localPlaylistItem.id = existingVideo.id
- DatabaseHolder.Database.localPlaylistsDao().updatePlaylistVideo(localPlaylistItem)
- continue
- }
-
- // add the new video to the database
- DatabaseHolder.Database.localPlaylistsDao().addPlaylistVideo(localPlaylistItem)
-
- val playlist = localPlaylist.playlist
- if (playlist.thumbnailUrl.isEmpty()) {
- // set the new playlist thumbnail URL
- localPlaylistItem.thumbnailUrl?.let {
- playlist.thumbnailUrl = it
- DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist)
- }
- }
- }
-
- return true
- }
-
- override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
- val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll()
- .first { it.playlist.id.toString() == playlistId }.playlist
- playlist.name = newName
- DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist)
-
- return true
- }
-
- override suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean {
- val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll()
- .first { it.playlist.id.toString() == playlistId }.playlist
- playlist.description = newDescription
- DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist)
-
- return true
- }
-
- override suspend fun clonePlaylist(playlistId: String): String {
- val playlist = MediaServiceRepository.instance.getPlaylist(playlistId)
- val newPlaylist = createPlaylist(playlist.name ?: "Unknown name")
-
- PlaylistsHelper.addToPlaylist(newPlaylist, *playlist.relatedStreams.toTypedArray())
-
- var nextPage = playlist.nextpage
- while (nextPage != null) {
- nextPage = runCatching {
- MediaServiceRepository.instance.getPlaylistNextPage(playlistId, nextPage!!).apply {
- PlaylistsHelper.addToPlaylist(newPlaylist, *relatedStreams.toTypedArray())
- }.nextpage
- }.getOrNull()
- }
-
- return playlistId
- }
-
- override suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean {
- val transaction = DatabaseHolder.Database.localPlaylistsDao().getAll()
- .first { it.playlist.id.toString() == playlistId }
- DatabaseHolder.Database.localPlaylistsDao().removePlaylistVideo(
- transaction.videos[index]
- )
- // set a new playlist thumbnail if the first video got removed
- if (index == 0) {
- transaction.playlist.thumbnailUrl =
- transaction.videos.getOrNull(1)?.thumbnailUrl.orEmpty()
- }
- DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(transaction.playlist)
-
- return true
- }
-
- override suspend fun importPlaylists(playlists: List) {
- for (playlist in playlists) {
- val playlistId = createPlaylist(playlist.name!!)
-
- // if not logged in, all video information needs to become fetched manually
- // Only do so with `MAX_CONCURRENT_IMPORT_CALLS` videos at once to prevent performance issues
- for (videoIdList in playlist.videos.chunked(MAX_CONCURRENT_IMPORT_CALLS)) {
- val streams = videoIdList.parallelMap {
- runCatching { MediaServiceRepository.instance.getStreams(it) }
- .getOrNull()
- ?.toStreamItem(it)
- }.filterNotNull()
-
- PlaylistsHelper.addToPlaylist(playlistId, *streams.toTypedArray())
- }
- }
- }
-
- override suspend fun createPlaylist(playlistName: String): String {
- val playlist = LocalPlaylist(name = playlistName, thumbnailUrl = "")
- return DatabaseHolder.Database.localPlaylistsDao().createPlaylist(playlist).toString()
- }
-
- override suspend fun deletePlaylist(playlistId: String): Boolean {
- DatabaseHolder.Database.localPlaylistsDao().deletePlaylistById(playlistId)
- DatabaseHolder.Database.localPlaylistsDao().deletePlaylistItemsByPlaylistId(playlistId)
-
- return true
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt
deleted file mode 100644
index 0737290d15..0000000000
--- a/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-package com.github.libretube.repo
-
-import android.util.Log
-import com.github.libretube.api.obj.Subscription
-import com.github.libretube.db.DatabaseHolder.Database
-import com.github.libretube.db.obj.LocalSubscription
-import com.github.libretube.extensions.TAG
-import com.github.libretube.extensions.parallelMap
-import com.github.libretube.repo.LocalFeedRepository.Companion.CHANNEL_BATCH_DELAY
-import com.github.libretube.repo.LocalFeedRepository.Companion.CHANNEL_BATCH_SIZE
-import com.github.libretube.repo.LocalFeedRepository.Companion.CHANNEL_CHUNK_SIZE
-import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
-import kotlinx.coroutines.delay
-import org.schabi.newpipe.extractor.channel.ChannelInfo
-import java.util.concurrent.atomic.AtomicInteger
-
-class LocalSubscriptionsRepository : SubscriptionsRepository {
- override suspend fun subscribe(
- channelId: String, name: String, uploaderAvatar: String?, verified: Boolean
- ) {
- val localSubscription = LocalSubscription(
- channelId = channelId,
- name = name,
- avatar = uploaderAvatar,
- verified = verified
- )
-
- Database.localSubscriptionDao().insert(localSubscription)
- }
-
- override suspend fun unsubscribe(channelId: String) {
- Database.localSubscriptionDao().deleteById(channelId)
- }
-
- override suspend fun isSubscribed(channelId: String): Boolean {
- return Database.localSubscriptionDao().includes(channelId)
- }
-
- override suspend fun importSubscriptions(newChannels: List) {
- val subscribedChannels = getSubscriptionChannelIds()
-
- val newFiltered = newChannels.filter { !subscribedChannels.contains(it) }
-
- val failedChannels = mutableListOf()
-
- val channelExtractionCount = AtomicInteger()
- for (chunk in newFiltered.chunked(CHANNEL_CHUNK_SIZE)) {
- // avoid being rate-limited by adding random delays between requests
- val count = channelExtractionCount.get();
- if (count >= CHANNEL_BATCH_SIZE) {
- // add a delay after each BATCH_SIZE amount of fully-fetched channels
- delay(CHANNEL_BATCH_DELAY.random())
- channelExtractionCount.set(0)
- }
-
- chunk.parallelMap { channelId ->
- try {
- val channelUrl = "$YOUTUBE_FRONTEND_URL/channel/${channelId}"
- val channelInfo = ChannelInfo.getInfo(channelUrl)
-
- val avatarUrl = channelInfo.avatars.maxByOrNull { it.height }?.url
- subscribe(channelId, channelInfo.name, avatarUrl, channelInfo.isVerified)
- } catch (e: Exception) {
- Log.e(TAG(), e.toString())
- failedChannels.add(channelId)
- }
- }
- }
-
- if (!failedChannels.isEmpty()) {
- throw Exception("Failed to import ${failedChannels.joinToString(", ")}")
- }
- }
-
- override suspend fun getSubscriptions(): List {
- // load all channels that have not been fetched yet
- val unfinished = Database.localSubscriptionDao().getChannelsWithoutMetaInfo()
- runCatching {
- importSubscriptions(unfinished.map { it.channelId })
- }
-
- return Database.localSubscriptionDao().getAll().map {
- Subscription(
- url = it.channelId,
- name = it.name.orEmpty(),
- avatar = it.avatar,
- verified = it.verified
- )
- }
- }
-
- override suspend fun getSubscriptionChannelIds(): List {
- return Database.localSubscriptionDao().getAll().map { it.channelId }
- }
-
- override suspend fun submitSubscriptionChannelInfosChanged(subscriptions: List) {
- Database.localSubscriptionDao().updateAll(subscriptions.map {
- LocalSubscription(
- it.url,
- it.name,
- it.avatar,
- it.verified
- )
- })
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/LocalUserDataRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalUserDataRepository.kt
new file mode 100644
index 0000000000..f85f7cbf07
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/repo/LocalUserDataRepository.kt
@@ -0,0 +1,306 @@
+package com.github.libretube.repo
+
+import com.github.libretube.api.obj.Playlist
+import com.github.libretube.api.obj.Playlists
+import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.api.obj.Subscription
+import com.github.libretube.api.obj.WatchHistoryEntry
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
+import com.github.libretube.constants.PreferenceKeys
+import com.github.libretube.db.DatabaseHolder.Database
+import com.github.libretube.db.obj.LocalPlaylist
+import com.github.libretube.db.obj.LocalSubscription
+import com.github.libretube.db.obj.PlaylistBookmark
+import com.github.libretube.db.obj.SubscriptionGroup
+import com.github.libretube.db.obj.WatchPosition
+import com.github.libretube.helpers.PreferenceHelper
+
+class LocalUserDataRepository : UserDataRepository {
+ override var requiresLogin: Boolean = false
+
+ private val WATCH_HISTORY_PAGE_SIZE = 30
+
+ override suspend fun getPlaylist(playlistId: String): Playlist {
+ val relation = Database.localPlaylistsDao().getAll()
+ .first { it.playlist.id.toString() == playlistId }
+
+ return Playlist(
+ name = relation.playlist.name,
+ description = relation.playlist.description,
+ thumbnailUrl = relation.playlist.thumbnailUrl,
+ videos = relation.videos.size,
+ relatedStreams = relation.videos.map { it.toStreamItem() }
+ )
+ }
+
+ override suspend fun getPlaylists(): List {
+ return Database.localPlaylistsDao().getAll()
+ .map {
+ Playlists(
+ id = it.playlist.id.toString(),
+ name = it.playlist.name,
+ shortDescription = it.playlist.description,
+ thumbnail = it.playlist.thumbnailUrl,
+ videos = it.videos.size.toLong()
+ )
+ }
+ }
+
+ override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
+ val localPlaylist = Database.localPlaylistsDao().getAll()
+ .first { it.playlist.id.toString() == playlistId }
+
+ for (video in videos) {
+ val localPlaylistItem = video.toLocalPlaylistItem(playlistId)
+
+ val existingVideo = Database.localPlaylistsDao()
+ .getPlaylistVideo(playlistId, localPlaylistItem.videoId)
+ if (existingVideo != null) {
+ // update existing video metadata
+ localPlaylistItem.id = existingVideo.id
+ Database.localPlaylistsDao().updatePlaylistVideo(localPlaylistItem)
+ continue
+ }
+
+ // add the new video to the database
+ Database.localPlaylistsDao().addPlaylistVideo(localPlaylistItem)
+
+ val playlist = localPlaylist.playlist
+ if (playlist.thumbnailUrl.isEmpty()) {
+ // set the new playlist thumbnail URL
+ localPlaylistItem.thumbnailUrl?.let {
+ playlist.thumbnailUrl = it
+ Database.localPlaylistsDao().updatePlaylist(playlist)
+ }
+ }
+ }
+
+ return true
+ }
+
+ override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
+ val playlist = Database.localPlaylistsDao().getAll()
+ .first { it.playlist.id.toString() == playlistId }.playlist
+ playlist.name = newName
+ Database.localPlaylistsDao().updatePlaylist(playlist)
+
+ return true
+ }
+
+ override suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean {
+ val playlist = Database.localPlaylistsDao().getAll()
+ .first { it.playlist.id.toString() == playlistId }.playlist
+ playlist.description = newDescription
+ Database.localPlaylistsDao().updatePlaylist(playlist)
+
+ return true
+ }
+
+ override suspend fun removeFromPlaylist(playlistId: String, videoId: String, index: Int): Boolean {
+ val transaction = Database.localPlaylistsDao().getAll()
+ .first { it.playlist.id.toString() == playlistId }
+ Database.localPlaylistsDao().removePlaylistVideo(
+ transaction.videos[index]
+ )
+ // set a new playlist thumbnail if the first video got removed
+ if (index == 0) {
+ transaction.playlist.thumbnailUrl =
+ transaction.videos.getOrNull(1)?.thumbnailUrl.orEmpty()
+ }
+ Database.localPlaylistsDao().updatePlaylist(transaction.playlist)
+
+ return true
+ }
+
+ override suspend fun createPlaylist(playlistName: String): String {
+ val playlist = LocalPlaylist(name = playlistName, thumbnailUrl = "")
+ return Database.localPlaylistsDao().createPlaylist(playlist).toString()
+ }
+
+ override suspend fun deletePlaylist(playlistId: String): Boolean {
+ Database.localPlaylistsDao().deletePlaylistById(playlistId)
+ Database.localPlaylistsDao().deletePlaylistItemsByPlaylistId(playlistId)
+
+ return true
+ }
+
+ override suspend fun subscribe(
+ channelId: String, name: String, uploaderAvatar: String?, verified: Boolean
+ ) {
+ val localSubscription = LocalSubscription(
+ channelId = channelId,
+ name = name,
+ avatar = uploaderAvatar,
+ verified = verified
+ )
+
+ Database.localSubscriptionDao().insert(localSubscription)
+ }
+
+ override suspend fun unsubscribe(channelId: String) {
+ Database.localSubscriptionDao().deleteById(channelId)
+ }
+
+ override suspend fun isSubscribed(channelId: String): Boolean {
+ return Database.localSubscriptionDao().includes(channelId)
+ }
+
+ override suspend fun getSubscriptions(): List {
+ // load all channels that have not been fetched yet
+ val unfinished = Database.localSubscriptionDao().getChannelsWithoutMetaInfo()
+ runCatching {
+ importSubscriptions(unfinished.map { it.channelId })
+ }
+
+ return Database.localSubscriptionDao().getAll().map {
+ Subscription(
+ url = it.channelId,
+ name = it.name.orEmpty(),
+ avatar = it.avatar,
+ verified = it.verified
+ )
+ }
+ }
+
+ override suspend fun getSubscriptionChannelIds(): List {
+ return Database.localSubscriptionDao().getAll().map { it.channelId }
+ }
+
+ override suspend fun submitSubscriptionChannelInfosChanged(subscriptions: List) {
+ Database.localSubscriptionDao().updateAll(subscriptions.map {
+ LocalSubscription(
+ it.url,
+ it.name,
+ it.avatar,
+ it.verified
+ )
+ })
+ }
+
+ override suspend fun createSubscriptionGroup(name: String): String {
+ Database.subscriptionGroupsDao()
+ .createGroup(SubscriptionGroup(name = name))
+
+ // name is the ID in our database modeling
+ return name
+ }
+
+ override suspend fun renameSubscriptionGroup(
+ subscriptionGroupId: String,
+ newName: String
+ ) {
+ val updatedGroup = Database.subscriptionGroupsDao()
+ .getByName(subscriptionGroupId)!!.copy(name = newName)
+
+ // delete the old version of the group first before updating it, as the name is the
+ // primary key
+ Database.subscriptionGroupsDao().deleteGroup(subscriptionGroupId)
+ Database.subscriptionGroupsDao().createGroup(updatedGroup)
+ }
+
+ override suspend fun deleteSubscriptionGroup(subscriptionGroupId: String) {
+ Database.subscriptionGroupsDao()
+ .deleteGroup(subscriptionGroupId)
+ }
+
+ override suspend fun getSubscriptionGroup(subscriptionGroupId: String): SubscriptionGroup {
+ return Database.subscriptionGroupsDao().getByName(subscriptionGroupId)!!
+ }
+
+ override suspend fun getSubscriptionGroups(): List {
+ return Database.subscriptionGroupsDao().getAll()
+ .sortedBy { it.index }
+ }
+
+ override suspend fun addToSubscriptionGroup(
+ subscriptionGroupId: String,
+ channelId: String
+ ) {
+ val group = Database.subscriptionGroupsDao().getByName(subscriptionGroupId)!!
+ group.channels = group.channels.plus(channelId).distinct()
+ Database.subscriptionGroupsDao().updateGroup(group)
+ }
+
+ override suspend fun removeFromSubscriptionGroup(
+ subscriptionGroupId: String,
+ channelId: String
+ ) {
+ val group = Database.subscriptionGroupsDao().getByName(subscriptionGroupId)!!
+ group.channels = group.channels.filter { it != channelId }
+ Database.subscriptionGroupsDao().updateGroup(group)
+ }
+
+ override suspend fun addToWatchHistory(watchHistoryEntry: WatchHistoryEntry) {
+ val watchHistoryItem = watchHistoryEntry.video.toWatchHistoryItem(watchHistoryEntry.metadata.videoId)
+ Database.watchHistoryDao().insert(watchHistoryItem)
+
+ // TODO: remove this preference in the future
+ val maxHistorySize = PreferenceHelper.getString(
+ PreferenceKeys.WATCH_HISTORY_SIZE,
+ "100"
+ )
+ if (maxHistorySize == "unlimited") {
+ return
+ }
+
+ // delete the first watch history entry if the limit is reached
+ val historySize = Database.watchHistoryDao().getSize()
+ if (historySize > maxHistorySize.toInt()) {
+ Database.watchHistoryDao().delete(Database.watchHistoryDao().getOldest())
+ }
+
+ // create watch position
+ updateWatchHistoryEntry(watchHistoryEntry.metadata)
+ }
+
+ override suspend fun updateWatchHistoryEntry(metadata: WatchHistoryEntryMetadata) {
+ metadata.positionMillis?.let {
+ Database.watchPositionDao().insert(WatchPosition(videoId = metadata.videoId, position = it))
+ }
+ }
+
+ override suspend fun removeFromWatchHistory(videoId: String) {
+ Database.watchHistoryDao().deleteByVideoId(videoId)
+ Database.watchPositionDao().deleteByVideoId(videoId)
+ }
+
+ override suspend fun getWatchHistory(page: Int): List {
+ val watchHistoryDao = Database.watchHistoryDao()
+ val historySize = watchHistoryDao.getSize()
+
+ if (historySize < WATCH_HISTORY_PAGE_SIZE * (page - 1)) return emptyList()
+
+ val offset = historySize - (WATCH_HISTORY_PAGE_SIZE * page)
+ val limit = if (offset < 0) {
+ offset + WATCH_HISTORY_PAGE_SIZE
+ } else {
+ WATCH_HISTORY_PAGE_SIZE
+ }
+ return watchHistoryDao.getN(limit, maxOf(offset, 0)).reversed()
+ .map { it.toWatchHistoryEntry() }
+ }
+
+ override suspend fun getFromWatchHistory(videoId: String): WatchHistoryEntry? {
+ return Database.watchHistoryDao().findById(videoId)?.toWatchHistoryEntry()
+ }
+
+ override suspend fun clearWatchHistory() {
+ Database.watchHistoryDao().deleteAll()
+ }
+
+ override suspend fun getPlaylistBookmarks(): List {
+ return Database.playlistBookmarkDao().getAll()
+ }
+
+ override suspend fun getPlaylistBookmark(playlistId: String): PlaylistBookmark? {
+ return Database.playlistBookmarkDao().findById(playlistId)
+ }
+
+ override suspend fun createPlaylistBookmark(playlist: PlaylistBookmark) {
+ Database.playlistBookmarkDao().insert(playlist)
+ }
+
+ override suspend fun deletePlaylistBookmark(playlistId: String) {
+ Database.playlistBookmarkDao().deleteById(playlistId)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt
index 7dd04a6d89..a03520961a 100644
--- a/app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt
+++ b/app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt
@@ -11,6 +11,6 @@ class PipedAccountFeedRepository : FeedRepository {
): List {
val token = PreferenceHelper.getToken()
- return RetrofitInstance.authApi.getFeed(token)
+ return RetrofitInstance.pipedAuthApi.getFeed(token)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/PipedLocalSubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedLocalSubscriptionsRepository.kt
deleted file mode 100644
index d7053e1970..0000000000
--- a/app/src/main/java/com/github/libretube/repo/PipedLocalSubscriptionsRepository.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.github.libretube.repo
-
-import com.github.libretube.api.RetrofitInstance
-import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT
-import com.github.libretube.api.obj.Subscription
-import com.github.libretube.db.DatabaseHolder.Database
-import com.github.libretube.db.obj.LocalSubscription
-
-class PipedLocalSubscriptionsRepository : SubscriptionsRepository {
- override suspend fun subscribe(
- channelId: String, name: String, uploaderAvatar: String?, verified: Boolean
- ) {
- // further meta info is not needed when using Piped local subscriptions
- Database.localSubscriptionDao().insert(LocalSubscription(channelId))
- }
-
- override suspend fun importSubscriptions(newChannels: List) {
- // further meta info is not needed when using Piped local subscriptions
- Database.localSubscriptionDao().insertAll(newChannels.map { LocalSubscription(it) })
- }
-
- override suspend fun isSubscribed(channelId: String): Boolean {
- return Database.localSubscriptionDao().includes(channelId)
- }
-
- override suspend fun unsubscribe(channelId: String) {
- Database.localSubscriptionDao().deleteById(channelId)
- }
-
- override suspend fun getSubscriptions(): List {
- val channelIds = getSubscriptionChannelIds()
-
- return when {
- channelIds.size > GET_SUBSCRIPTIONS_LIMIT -> RetrofitInstance.authApi.unauthenticatedSubscriptions(
- channelIds
- )
-
- else -> RetrofitInstance.authApi.unauthenticatedSubscriptions(
- channelIds.joinToString(",")
- )
- }
- }
-
- override suspend fun getSubscriptionChannelIds(): List {
- return Database.localSubscriptionDao().getAll().map { it.channelId }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt
deleted file mode 100644
index 5fc75cae8a..0000000000
--- a/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.github.libretube.repo
-
-import com.github.libretube.api.RetrofitInstance
-import com.github.libretube.api.SubscriptionHelper
-import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT
-import com.github.libretube.api.obj.StreamItem
-
-class PipedNoAccountFeedRepository : FeedRepository {
- override suspend fun getFeed(
- forceRefresh: Boolean,
- onProgressUpdate: (FeedProgress) -> Unit
- ): List {
- val channelIds = SubscriptionHelper.getSubscriptionChannelIds()
-
- return when {
- channelIds.size > GET_SUBSCRIPTIONS_LIMIT ->
- RetrofitInstance.authApi
- .getUnauthenticatedFeed(channelIds)
-
- else -> RetrofitInstance.authApi.getUnauthenticatedFeed(
- channelIds.joinToString(",")
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/PipedPlaylistRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedPlaylistRepository.kt
deleted file mode 100644
index 346bc87536..0000000000
--- a/app/src/main/java/com/github/libretube/repo/PipedPlaylistRepository.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-package com.github.libretube.repo
-
-import com.github.libretube.api.PlaylistsHelper
-import com.github.libretube.api.RetrofitInstance
-import com.github.libretube.api.obj.EditPlaylistBody
-import com.github.libretube.api.obj.Message
-import com.github.libretube.api.obj.Playlist
-import com.github.libretube.api.obj.Playlists
-import com.github.libretube.api.obj.StreamItem
-import com.github.libretube.extensions.toID
-import com.github.libretube.helpers.PreferenceHelper
-import com.github.libretube.obj.PipedImportPlaylist
-
-class PipedPlaylistRepository: PlaylistRepository {
- private fun Message.isOk() = this.message == "ok"
- private val token get() = PreferenceHelper.getToken()
-
- override suspend fun getPlaylist(playlistId: String): Playlist {
- return RetrofitInstance.authApi.getPlaylist(playlistId)
- }
-
- override suspend fun getPlaylists(): List {
- return RetrofitInstance.authApi.getUserPlaylists(token)
- }
-
- override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
- val playlist = EditPlaylistBody(playlistId, videoIds = videos.map { it.url!!.toID() })
-
- return RetrofitInstance.authApi.addToPlaylist(token, playlist).isOk()
- }
-
- override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
- val playlist = EditPlaylistBody(playlistId, newName = newName)
-
- return RetrofitInstance.authApi.renamePlaylist(token, playlist).isOk()
- }
-
- override suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean {
- val playlist = EditPlaylistBody(playlistId, description = newDescription)
-
- return RetrofitInstance.authApi.changePlaylistDescription(token, playlist).isOk()
- }
-
- override suspend fun clonePlaylist(playlistId: String): String? {
- return RetrofitInstance.authApi.clonePlaylist(
- token,
- EditPlaylistBody(playlistId)
- ).playlistId
- }
-
- override suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean {
- return RetrofitInstance.authApi.removeFromPlaylist(
- PreferenceHelper.getToken(),
- EditPlaylistBody(playlistId = playlistId, index = index)
- ).isOk()
- }
-
- override suspend fun importPlaylists(playlists: List) {
- for (playlist in playlists) {
- val playlistId = PlaylistsHelper.createPlaylist(playlist.name!!) ?: return
- val streams = playlist.videos.map { StreamItem(url = it) }
- PlaylistsHelper.addToPlaylist(playlistId, *streams.toTypedArray())
- }
- }
-
- override suspend fun createPlaylist(playlistName: String): String? {
- return RetrofitInstance.authApi.createPlaylist(
- token,
- Playlists(name = playlistName)
- ).playlistId
- }
-
- override suspend fun deletePlaylist(playlistId: String): Boolean {
- return runCatching {
- RetrofitInstance.authApi.deletePlaylist(
- PreferenceHelper.getToken(),
- EditPlaylistBody(playlistId)
- ).isOk()
- }.getOrDefault(false)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/PipedUserDataRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedUserDataRepository.kt
new file mode 100644
index 0000000000..c190aeb22e
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/repo/PipedUserDataRepository.kt
@@ -0,0 +1,210 @@
+package com.github.libretube.repo
+
+import com.github.libretube.LibreTubeApp
+import com.github.libretube.R
+import com.github.libretube.api.JsonHelper
+import com.github.libretube.api.PlaylistsHelper
+import com.github.libretube.api.RetrofitInstance
+import com.github.libretube.api.obj.DeleteUserRequest
+import com.github.libretube.api.obj.EditPlaylistBody
+import com.github.libretube.api.obj.Login
+import com.github.libretube.api.obj.Message
+import com.github.libretube.api.obj.Playlist
+import com.github.libretube.api.obj.Playlists
+import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.api.obj.Subscribe
+import com.github.libretube.api.obj.Subscription
+import com.github.libretube.api.obj.Token
+import com.github.libretube.api.obj.WatchHistoryEntry
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
+import com.github.libretube.db.obj.PlaylistBookmark
+import com.github.libretube.extensions.toID
+import com.github.libretube.obj.PipedImportPlaylist
+import retrofit2.HttpException
+
+class PipedUserDataRepository : UserDataRepository {
+ override var requiresLogin: Boolean = true
+
+ private fun Message.isOk() = this.message == "ok"
+
+ private val localRepositoryDelegate = LocalUserDataRepository()
+
+ override suspend fun register(username: String, password: String): String {
+ return RetrofitInstance.pipedAuthApi.register(Login(username, password)).token!!
+ }
+
+ override suspend fun login(username: String, password: String): String {
+ val token = try {
+ RetrofitInstance.pipedAuthApi.login(Login(username, password))
+ } catch (e: HttpException) {
+ // properly forward the error message
+ val errorMessage = e.response()?.errorBody()?.string()?.runCatching {
+ JsonHelper.json.decodeFromString(this).error
+ }?.getOrNull() ?: LibreTubeApp.instance.getString(R.string.server_error)
+ throw Exception(errorMessage)
+ }
+
+ if (token.error != null) throw Exception(token.error)
+ return token.token!!
+ }
+
+ override suspend fun deleteAccount(password: String) {
+ RetrofitInstance.pipedAuthApi.deleteAccount(DeleteUserRequest(password))
+ }
+
+ override suspend fun getPlaylist(playlistId: String): Playlist {
+ return RetrofitInstance.pipedAuthApi.getPlaylist(playlistId)
+ }
+
+ override suspend fun getPlaylists(): List {
+ return RetrofitInstance.pipedAuthApi.getUserPlaylists()
+ }
+
+ override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
+ val playlist = EditPlaylistBody(playlistId, videoIds = videos.map { it.url!!.toID() })
+
+ return RetrofitInstance.pipedAuthApi.addToPlaylist(playlist).isOk()
+ }
+
+ override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
+ val playlist = EditPlaylistBody(playlistId, newName = newName)
+
+ return RetrofitInstance.pipedAuthApi.renamePlaylist(playlist).isOk()
+ }
+
+ override suspend fun changePlaylistDescription(
+ playlistId: String,
+ newDescription: String
+ ): Boolean {
+ val playlist = EditPlaylistBody(playlistId, description = newDescription)
+
+ return RetrofitInstance.pipedAuthApi.changePlaylistDescription(playlist).isOk()
+ }
+
+ override suspend fun clonePlaylist(playlistId: String): String? {
+ return RetrofitInstance.pipedAuthApi.clonePlaylist(
+ EditPlaylistBody(playlistId)
+ ).playlistId
+ }
+
+ override suspend fun removeFromPlaylist(
+ playlistId: String,
+ videoId: String,
+ index: Int
+ ): Boolean {
+ return RetrofitInstance.pipedAuthApi.removeFromPlaylist(
+ EditPlaylistBody(playlistId = playlistId, index = index)
+ ).isOk()
+ }
+
+ override suspend fun importPlaylists(playlists: List) {
+ for (playlist in playlists) {
+ val playlistId = PlaylistsHelper.createPlaylist(playlist.name!!) ?: return
+ val streams = playlist.videos.map { StreamItem(url = it) }
+ PlaylistsHelper.addToPlaylist(playlistId, *streams.toTypedArray())
+ }
+ }
+
+ override suspend fun createPlaylist(playlistName: String): String? {
+ return RetrofitInstance.pipedAuthApi.createPlaylist(
+ Playlists(name = playlistName)
+ ).playlistId
+ }
+
+ override suspend fun deletePlaylist(playlistId: String): Boolean {
+ return runCatching {
+ RetrofitInstance.pipedAuthApi.deletePlaylist(
+ EditPlaylistBody(playlistId)
+ ).isOk()
+ }.getOrDefault(false)
+ }
+
+ override suspend fun subscribe(
+ channelId: String, name: String, uploaderAvatar: String?, verified: Boolean
+ ) {
+ runCatching {
+ RetrofitInstance.pipedAuthApi.subscribe(Subscribe(channelId))
+ }
+ }
+
+ override suspend fun unsubscribe(channelId: String) {
+ runCatching {
+ RetrofitInstance.pipedAuthApi.unsubscribe(Subscribe(channelId))
+ }
+ }
+
+ override suspend fun isSubscribed(channelId: String): Boolean? {
+ return runCatching {
+ RetrofitInstance.pipedAuthApi.isSubscribed(channelId)
+ }.getOrNull()?.subscribed
+ }
+
+ override suspend fun importSubscriptions(newChannels: List) {
+ RetrofitInstance.pipedAuthApi.importSubscriptions(false, newChannels)
+ }
+
+ override suspend fun getSubscriptions(): List {
+ return RetrofitInstance.pipedAuthApi.subscriptions()
+ }
+
+ override suspend fun getSubscriptionChannelIds(): List {
+ return getSubscriptions().map { it.url.toID() }
+ }
+
+ //
+ // Piped doesn't support any of the functionalities below, so we handle them locally instead
+ //
+
+ override suspend fun createSubscriptionGroup(name: String) =
+ localRepositoryDelegate.createSubscriptionGroup(name)
+
+ override suspend fun renameSubscriptionGroup(
+ subscriptionGroupId: String,
+ newName: String
+ ) = localRepositoryDelegate.renameSubscriptionGroup(subscriptionGroupId, newName)
+
+ override suspend fun deleteSubscriptionGroup(subscriptionGroupId: String) =
+ localRepositoryDelegate.deleteSubscriptionGroup(subscriptionGroupId)
+
+ override suspend fun getSubscriptionGroup(subscriptionGroupId: String) =
+ localRepositoryDelegate.getSubscriptionGroup(subscriptionGroupId)
+
+ override suspend fun getSubscriptionGroups() = localRepositoryDelegate.getSubscriptionGroups()
+
+ override suspend fun addToSubscriptionGroup(
+ subscriptionGroupId: String,
+ channelId: String
+ ) = localRepositoryDelegate.addToSubscriptionGroup(subscriptionGroupId, channelId)
+
+ override suspend fun removeFromSubscriptionGroup(
+ subscriptionGroupId: String,
+ channelId: String
+ ) = localRepositoryDelegate.removeFromSubscriptionGroup(subscriptionGroupId, channelId)
+
+ override suspend fun addToWatchHistory(watchHistoryEntry: WatchHistoryEntry) =
+ localRepositoryDelegate.addToWatchHistory(watchHistoryEntry)
+
+ override suspend fun updateWatchHistoryEntry(metadata: WatchHistoryEntryMetadata) =
+ localRepositoryDelegate.updateWatchHistoryEntry(metadata)
+
+ override suspend fun removeFromWatchHistory(videoId: String) =
+ localRepositoryDelegate.removeFromWatchHistory(videoId)
+
+ override suspend fun getWatchHistory(page: Int) = localRepositoryDelegate.getWatchHistory(page)
+
+ override suspend fun getFromWatchHistory(videoId: String) =
+ localRepositoryDelegate.getFromWatchHistory(videoId)
+
+ override suspend fun clearWatchHistory() = localRepositoryDelegate.clearWatchHistory()
+
+ override suspend fun getPlaylistBookmarks() = localRepositoryDelegate.getPlaylistBookmarks()
+
+ override suspend fun getPlaylistBookmark(playlistId: String) =
+ localRepositoryDelegate.getPlaylistBookmark(playlistId)
+
+ override suspend fun createPlaylistBookmark(playlist: PlaylistBookmark) =
+ localRepositoryDelegate.createPlaylistBookmark(playlist)
+
+ override suspend fun deletePlaylistBookmark(playlistId: String) =
+ localRepositoryDelegate.deletePlaylistBookmark(playlistId)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/PlaylistRepository.kt b/app/src/main/java/com/github/libretube/repo/PlaylistRepository.kt
deleted file mode 100644
index 0c99457ad4..0000000000
--- a/app/src/main/java/com/github/libretube/repo/PlaylistRepository.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.github.libretube.repo
-
-import com.github.libretube.api.obj.Playlist
-import com.github.libretube.api.obj.Playlists
-import com.github.libretube.api.obj.StreamItem
-import com.github.libretube.obj.PipedImportPlaylist
-
-interface PlaylistRepository {
- suspend fun getPlaylist(playlistId: String): Playlist
- suspend fun getPlaylists(): List
- suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean
- suspend fun renamePlaylist(playlistId: String, newName: String): Boolean
- suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean
- suspend fun clonePlaylist(playlistId: String): String?
- suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean
- suspend fun importPlaylists(playlists: List)
- suspend fun createPlaylist(playlistName: String): String?
- suspend fun deletePlaylist(playlistId: String): Boolean
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt
deleted file mode 100644
index fecbc81200..0000000000
--- a/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.github.libretube.repo
-
-import com.github.libretube.api.obj.Subscription
-
-interface SubscriptionsRepository {
- suspend fun subscribe(channelId: String, name: String, uploaderAvatar: String?, verified: Boolean)
- suspend fun unsubscribe(channelId: String)
- suspend fun isSubscribed(channelId: String): Boolean?
- suspend fun importSubscriptions(newChannels: List)
- suspend fun getSubscriptions(): List
- suspend fun getSubscriptionChannelIds(): List
- suspend fun submitSubscriptionChannelInfosChanged(subscriptions: List) {}
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/UserDataRepository.kt b/app/src/main/java/com/github/libretube/repo/UserDataRepository.kt
new file mode 100644
index 0000000000..f560d346e1
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/repo/UserDataRepository.kt
@@ -0,0 +1,143 @@
+package com.github.libretube.repo
+
+import android.util.Log
+import com.github.libretube.api.MediaServiceRepository
+import com.github.libretube.api.PlaylistsHelper.MAX_CONCURRENT_IMPORT_CALLS
+import com.github.libretube.api.obj.Playlist
+import com.github.libretube.api.obj.Playlists
+import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.api.obj.Subscription
+import com.github.libretube.api.obj.WatchHistoryEntry
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
+import com.github.libretube.db.obj.PlaylistBookmark
+import com.github.libretube.db.obj.SubscriptionGroup
+import com.github.libretube.extensions.TAG
+import com.github.libretube.extensions.parallelMap
+import com.github.libretube.obj.PipedImportPlaylist
+import com.github.libretube.repo.LocalFeedRepository.Companion.CHANNEL_BATCH_DELAY
+import com.github.libretube.repo.LocalFeedRepository.Companion.CHANNEL_BATCH_SIZE
+import com.github.libretube.repo.LocalFeedRepository.Companion.CHANNEL_CHUNK_SIZE
+import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
+import kotlinx.coroutines.delay
+import org.schabi.newpipe.extractor.channel.ChannelInfo
+import java.util.concurrent.atomic.AtomicInteger
+
+interface UserDataRepository {
+ var requiresLogin: Boolean
+
+ suspend fun login(username: String, password: String): String = ""
+ suspend fun register(username: String, password: String): String = ""
+ suspend fun deleteAccount(password: String) = Unit
+
+ suspend fun subscribe(channelId: String, name: String, uploaderAvatar: String?, verified: Boolean)
+ suspend fun unsubscribe(channelId: String)
+ // TODO: isSubscribed shouldn't be able to return null?
+ suspend fun isSubscribed(channelId: String): Boolean?
+ suspend fun getSubscriptions(): List
+ suspend fun getSubscriptionChannelIds(): List
+ suspend fun submitSubscriptionChannelInfosChanged(subscriptions: List) {}
+
+ suspend fun getPlaylist(playlistId: String): Playlist
+ suspend fun getPlaylists(): List
+ suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean
+ suspend fun renamePlaylist(playlistId: String, newName: String): Boolean
+ suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean
+ suspend fun removeFromPlaylist(playlistId: String, videoId: String, index: Int): Boolean
+ suspend fun createPlaylist(playlistName: String): String?
+ suspend fun deletePlaylist(playlistId: String): Boolean
+
+ suspend fun createSubscriptionGroup(name: String): String
+ suspend fun renameSubscriptionGroup(subscriptionGroupId: String, newName: String)
+ suspend fun deleteSubscriptionGroup(subscriptionGroupId: String)
+ suspend fun getSubscriptionGroup(subscriptionGroupId: String): SubscriptionGroup
+ suspend fun getSubscriptionGroups(): List
+ suspend fun addToSubscriptionGroup(subscriptionGroupId: String, channelId: String)
+ suspend fun removeFromSubscriptionGroup(subscriptionGroupId: String, channelId: String)
+
+ suspend fun addToWatchHistory(watchHistoryEntry: WatchHistoryEntry)
+ suspend fun updateWatchHistoryEntry(metadata: WatchHistoryEntryMetadata)
+ suspend fun removeFromWatchHistory(videoId: String)
+ suspend fun getWatchHistory(page: Int): List
+ suspend fun getFromWatchHistory(videoId: String): WatchHistoryEntry?
+ suspend fun clearWatchHistory()
+
+ suspend fun getPlaylistBookmarks(): List
+ suspend fun getPlaylistBookmark(playlistId: String): PlaylistBookmark?
+ suspend fun createPlaylistBookmark(playlist: PlaylistBookmark)
+ suspend fun deletePlaylistBookmark(playlistId: String)
+
+ // The following methods can be overriden to offload the work to the server, but in most cases
+ // the default implementation should work out just fine.
+
+ suspend fun importSubscriptions(newChannels: List) {
+ val subscribedChannels = getSubscriptionChannelIds()
+
+ val newFiltered = newChannels.filter { !subscribedChannels.contains(it) }
+
+ val failedChannels = mutableListOf()
+
+ val channelExtractionCount = AtomicInteger()
+ for (chunk in newFiltered.chunked(CHANNEL_CHUNK_SIZE)) {
+ // avoid being rate-limited by adding random delays between requests
+ val count = channelExtractionCount.get()
+ if (count >= CHANNEL_BATCH_SIZE) {
+ // add a delay after each BATCH_SIZE amount of fully-fetched channels
+ delay(CHANNEL_BATCH_DELAY.random())
+ channelExtractionCount.set(0)
+ }
+
+ chunk.parallelMap { channelId ->
+ try {
+ val channelUrl = "$YOUTUBE_FRONTEND_URL/channel/${channelId}"
+ val channelInfo = ChannelInfo.getInfo(channelUrl)
+
+ val avatarUrl = channelInfo.avatars.maxByOrNull { it.height }?.url
+ subscribe(channelId, channelInfo.name, avatarUrl, channelInfo.isVerified)
+ } catch (e: Exception) {
+ Log.e(TAG(), e.toString())
+ failedChannels.add(channelId)
+ }
+ }
+ }
+
+ if (!failedChannels.isEmpty()) {
+ throw Exception("Failed to import ${failedChannels.joinToString(", ")}")
+ }
+ }
+
+ suspend fun clonePlaylist(playlistId: String): String? {
+ val playlist = MediaServiceRepository.instance.getPlaylist(playlistId)
+ val newPlaylist = createPlaylist(playlist.name ?: "Unknown name") ?: return null
+
+ addToPlaylist(newPlaylist, *playlist.relatedStreams.toTypedArray())
+
+ var nextPage = playlist.nextpage
+ while (nextPage != null) {
+ nextPage = runCatching {
+ MediaServiceRepository.instance.getPlaylistNextPage(playlistId, nextPage).apply {
+ addToPlaylist(newPlaylist, *relatedStreams.toTypedArray())
+ }.nextpage
+ }.getOrNull()
+ }
+
+ return playlistId
+ }
+
+ suspend fun importPlaylists(playlists: List) {
+ for (playlist in playlists) {
+ val playlistId = createPlaylist(playlist.name!!) ?: throw Exception("failed to create playlist")
+
+ // if not logged in, all video information needs to become fetched manually
+ // Only do so with `MAX_CONCURRENT_IMPORT_CALLS` videos at once to prevent performance issues
+ for (videoIdList in playlist.videos.chunked(MAX_CONCURRENT_IMPORT_CALLS)) {
+ val streams = videoIdList.parallelMap {
+ runCatching { MediaServiceRepository.instance.getStreams(it) }
+ .getOrNull()
+ ?.toStreamItem(it)
+ }.filterNotNull()
+
+ addToPlaylist(playlistId, *streams.toTypedArray())
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/repo/UserDataRepositoryHelper.kt b/app/src/main/java/com/github/libretube/repo/UserDataRepositoryHelper.kt
new file mode 100644
index 0000000000..1f20c352de
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/repo/UserDataRepositoryHelper.kt
@@ -0,0 +1,30 @@
+package com.github.libretube.repo
+
+import com.github.libretube.constants.PreferenceKeys
+import com.github.libretube.enums.SyncServerType
+import com.github.libretube.helpers.PreferenceHelper
+
+object UserDataRepositoryHelper {
+ private val loggedIn get() = PreferenceHelper.getToken().isNotBlank()
+ val syncServerType: SyncServerType
+ get() = when (PreferenceHelper.getString(PreferenceKeys.SYNC_SERVER_TYPE, "none")) {
+ "piped" -> SyncServerType.PIPED
+ "libretube" -> SyncServerType.LIBRETUBE
+ else -> SyncServerType.NONE
+ }
+
+ @Deprecated("DO NOT use this directly, use the wrappers from PlaylistHelper and SubscriptionHelper instead!")
+ val userDataRepository: UserDataRepository
+ get() = when (syncServerType) {
+ SyncServerType.PIPED -> PipedUserDataRepository()
+ SyncServerType.LIBRETUBE -> LibreTubeSyncServerUserDataRepository()
+ else -> LocalUserDataRepository()
+ }
+
+ @Deprecated("DO NOT use this directly, use the wrappers from SubscriptionHelper instead!")
+ val feedRepository: FeedRepository
+ get() = when (syncServerType to loggedIn) {
+ SyncServerType.PIPED to true -> PipedAccountFeedRepository()
+ else -> LocalFeedRepository()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt
index 1c9d3f0be4..b7cc9a944b 100644
--- a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt
+++ b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt
@@ -40,7 +40,6 @@ import com.github.libretube.helpers.PlayerHelper.getCurrentSegment
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.util.DefaultTrackSelectorWithAudioQualitySupport
import com.github.libretube.util.NowPlayingNotification
-import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.PlayingQueueMode
import com.google.common.util.concurrent.ListenableFuture
@@ -64,11 +63,6 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
val handler = Handler(Looper.getMainLooper())
- private val watchPositionTimer = PauseableTimer(
- onTick = ::saveWatchPosition,
- delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
- )
-
// SponsorBlock Segment data
private var sponsorBlockAutoSkip = true
protected val sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
@@ -86,10 +80,8 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
super.onIsPlayingChanged(isPlaying)
// Start or pause watch position timer
- if (isPlaying) {
- watchPositionTimer.resume()
- } else {
- watchPositionTimer.pause()
+ if (!isPlaying) {
+ saveWatchPosition()
}
}
@@ -460,7 +452,6 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
saveWatchPosition()
notificationProvider = null
- watchPositionTimer.destroy()
handler.removeCallbacksAndMessages(null)
diff --git a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt
index 61731196ba..b3a25fa3e2 100644
--- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt
+++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt
@@ -63,10 +63,9 @@ open class OfflinePlayerService : AbstractPlayerService() {
// add video to watch history when playback starts
if (playbackState == Player.STATE_READY && PlayerHelper.watchHistoryEnabled) {
scope.launch(Dispatchers.IO) {
- val watchHistoryItem =
- downloadWithItems?.download?.toStreamItem()?.toWatchHistoryItem(videoId)
- if (watchHistoryItem != null) {
- DatabaseHelper.addToWatchHistory(watchHistoryItem)
+ val video = downloadWithItems?.download?.toStreamItem()
+ if (video != null) {
+ DatabaseHelper.addToWatchHistory(video)
}
}
}
diff --git a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt
index 158ef29217..85b4584368 100644
--- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt
+++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt
@@ -28,6 +28,7 @@ import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.getSubtitleRoleFlags
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.parcelable.PlayerData
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.util.DeArrowUtil
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.YoutubeHlsPlaylistParser
@@ -81,9 +82,8 @@ open class OnlinePlayerService : AbstractPlayerService() {
if (PlayerHelper.watchHistoryEnabled) {
scope.launch(Dispatchers.IO) {
streams?.let { streams ->
- val watchHistoryItem =
- streams.toStreamItem(videoId).toWatchHistoryItem(videoId)
- DatabaseHelper.addToWatchHistory(watchHistoryItem)
+ val video = streams.toStreamItem(videoId)
+ DatabaseHelper.addToWatchHistory(video)
}
}
}
@@ -131,7 +131,7 @@ open class OnlinePlayerService : AbstractPlayerService() {
MediaServiceRepository.instance.getStreams(videoId).let {
DeArrowUtil.deArrowStreams(it, videoId)
}
- } catch (e: Exception) {
+ } catch (e: Exception) {
Log.e(TAG(), e.stackTraceToString())
toastFromMainDispatcher(e.localizedMessage.orEmpty())
return@withContext null
@@ -170,8 +170,12 @@ open class OnlinePlayerService : AbstractPlayerService() {
if (seekToPositionMs != 0L) {
exoPlayer?.seekTo(seekToPositionMs)
} else if (watchPositionsEnabled) {
- DatabaseHelper.getWatchPositionBlocking(videoId)?.let {
- if (!DatabaseHelper.isVideoWatched(it, streams?.duration)) exoPlayer?.seekTo(it)
+ CoroutineScope(Dispatchers.IO).launch {
+ UserDataRepositoryHelper.userDataRepository.getFromWatchHistory(videoId)?.metadata?.positionMillis?.let {
+ if (!DatabaseHelper.isVideoWatched(it, streams?.duration)) {
+ withContext(Dispatchers.Main) { exoPlayer?.seekTo(it) }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt b/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt
index 34364d7232..a17690e26c 100644
--- a/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt
+++ b/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt
@@ -77,7 +77,7 @@ class PlaylistDownloadEnqueueService : LifecycleService() {
nManager.notify(NotificationId.ENQUEUE_PLAYLIST_DOWNLOAD.id, buildNotification())
lifecycleScope.launch(Dispatchers.IO) {
- if (playlistType != PlaylistType.PUBLIC) {
+ if (playlistType == PlaylistType.PRIVATE) {
enqueuePrivatePlaylist()
} else {
enqueuePublicPlaylist()
@@ -100,7 +100,7 @@ class PlaylistDownloadEnqueueService : LifecycleService() {
private suspend fun enqueuePrivatePlaylist() {
val playlist = try {
- PlaylistsHelper.getPlaylist(playlistId)
+ PlaylistsHelper.getPlaylist(playlistId, PlaylistType.PRIVATE)
} catch (e: Exception) {
toastFromMainDispatcher(e.localizedMessage.orEmpty())
stopSelf()
diff --git a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt
index 604f11c360..3bde2e3f81 100644
--- a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt
+++ b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt
@@ -104,6 +104,14 @@ class MainActivity : AbstractPlayerHostActivity() {
installSplashScreen()
super.onCreate(savedInstanceState)
+ // show welcome activity on first app startup (or after the update to prospectively v32)
+ if (!PreferenceHelper.getBoolean(PreferenceKeys.WELCOME_ACTIVITY_FINISHED, false)) {
+ val welcomeIntent = Intent(this, WelcomeActivity::class.java)
+ startActivity(welcomeIntent)
+ finish()
+ return
+ }
+
// show noInternet Activity if no internet available on app startup
if (!NetworkHelper.isNetworkAvailable(this)) {
val noInternetIntent = Intent(this, NoInternetActivity::class.java)
diff --git a/app/src/main/java/com/github/libretube/ui/activities/WelcomeActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/WelcomeActivity.kt
new file mode 100644
index 0000000000..569f071a40
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/ui/activities/WelcomeActivity.kt
@@ -0,0 +1,137 @@
+package com.github.libretube.ui.activities
+
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.core.os.bundleOf
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import com.github.libretube.R
+import com.github.libretube.constants.IntentData
+import com.github.libretube.constants.PreferenceKeys
+import com.github.libretube.databinding.ActivityWelcomeBinding
+import com.github.libretube.enums.SyncServerType
+import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
+import com.github.libretube.ui.base.BaseActivity
+import com.github.libretube.ui.dialogs.LoginDialog
+import com.github.libretube.ui.dialogs.SelectInstanceDialog
+import com.github.libretube.ui.models.WelcomeViewModel
+import com.github.libretube.ui.preferences.BackupRestoreSettings
+import com.github.libretube.ui.preferences.InstanceSettings.Companion.INSTANCE_DIALOG_REQUEST_KEY
+
+class WelcomeActivity : BaseActivity() {
+ private val viewModel by viewModels { WelcomeViewModel.Factory }
+
+ private val restoreFilePicker =
+ registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri == null) return@registerForActivityResult
+ viewModel.restoreAdvancedBackup(this, uri)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val binding = ActivityWelcomeBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ binding.restore.setOnClickListener {
+ restoreFilePicker.launch(BackupRestoreSettings.JSON)
+ }
+
+ binding.okay.setOnClickListener {
+ viewModel.refreshAndNavigate()
+ }
+
+ supportFragmentManager.setFragmentResultListener(
+ INSTANCE_DIALOG_REQUEST_KEY,
+ this
+ ) { _, bundle ->
+ val loggedIn = bundle.getBoolean(IntentData.loginTask)
+ if (loggedIn) viewModel.setLoggedIn(true)
+ }
+ binding.login.setOnClickListener {
+ LoginDialog().show(supportFragmentManager, null)
+ }
+
+ supportFragmentManager.setFragmentResultListener(
+ SelectInstanceDialog.SELECT_INSTANCE_RESULT_KEY,
+ this
+ ) { _, bundle ->
+ val apiUrl =
+ bundle.getString(SelectInstanceDialog.SELECT_INSTANCE_CURRENT_INSTANCE_API_URL_EXTRA)
+ if (apiUrl != null) {
+ PreferenceHelper.setToken("")
+ PreferenceHelper.putString(PreferenceKeys.AUTH_INSTANCE, apiUrl)
+ viewModel.setPipedInstance(apiUrl)
+ }
+ }
+ binding.selectInstance.setOnClickListener {
+ SelectInstanceDialog()
+ .apply {
+ val selectedInstance = this@WelcomeActivity.viewModel.uiState.value?.selectedPipedInstance
+ arguments = bundleOf(
+ SelectInstanceDialog.SELECT_INSTANCE_TITLE_EXTRA to this@WelcomeActivity.getString(R.string.auth_instance),
+ SelectInstanceDialog.SELECT_INSTANCE_CURRENT_INSTANCE_API_URL_EXTRA to selectedInstance
+ )
+ }
+ .show(supportFragmentManager, null)
+ }
+
+ binding.syncTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (isChecked) {
+ val index = binding.syncTypeGroup.children.indexOfFirst { it.id == checkedId }
+ val syncServerType = SyncServerType.entries[index]
+ viewModel.setSyncServerType(syncServerType)
+ }
+ }
+
+ // HACK: check if user already used app before by checking if they already loaded the subscriptions feed
+ val userAlreadyUsedAppBefore = PreferenceHelper.getLastCheckedFeedTime(false) != 0L
+
+ viewModel.uiState.observe(this) { (syncServerType, loggedIn, pipedInstance, navigateToMain) ->
+ binding.okay.isEnabled = syncServerType == SyncServerType.NONE || loggedIn
+
+ binding.selectInstance.isVisible = syncServerType == SyncServerType.PIPED
+ binding.login.isVisible = syncServerType != SyncServerType.NONE
+ binding.login.isEnabled =
+ syncServerType == SyncServerType.LIBRETUBE || (syncServerType == SyncServerType.PIPED && pipedInstance != null)
+
+ binding.switchSyncTypeWarning.isVisible = syncServerType != SyncServerType.NONE && userAlreadyUsedAppBefore
+
+ binding.infoText.text = when (syncServerType) {
+ SyncServerType.NONE -> getString(R.string.sync_type_summary_none)
+ SyncServerType.LIBRETUBE -> getString(
+ R.string.sync_type_summary_libretube,
+ "https://github.com/libre-tube/sync-server"
+ )
+
+ SyncServerType.PIPED -> getString(
+ R.string.sync_type_summary_piped,
+ "https://github.com/TeamPiped/piped"
+ )
+ }
+
+ navigateToMain?.let {
+ PreferenceHelper.putBoolean(PreferenceKeys.WELCOME_ACTIVITY_FINISHED, true)
+
+ val mainActivityIntent = Intent(this, MainActivity::class.java)
+ startActivity(mainActivityIntent)
+ finish()
+ viewModel.onNavigated()
+ }
+ }
+
+ // set initially displayed option from settings
+ val selected = UserDataRepositoryHelper.syncServerType
+ binding.syncTypeGroup.check(binding.syncTypeGroup.children.toList()[selected.ordinal].id)
+ // trigger initial change of sync server so that the info text is updated (see above)
+ viewModel.setSyncServerType(selected)
+ }
+
+ override fun requestOrientationChange() {
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt
index aff8eab69d..9e7ba907d7 100644
--- a/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt
+++ b/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt
@@ -2,16 +2,16 @@ package com.github.libretube.ui.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.ListAdapter
import com.github.libretube.databinding.BottomSheetItemBinding
import com.github.libretube.obj.BottomSheetItem
+import com.github.libretube.ui.adapters.callbacks.DiffUtilItemCallback
import com.github.libretube.ui.extensions.setDrawables
import com.github.libretube.ui.viewholders.BottomSheetViewHolder
class BottomSheetAdapter(
- private val items: List,
private val listener: (index: Int) -> Unit
-) : RecyclerView.Adapter() {
+) : ListAdapter(DiffUtilItemCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BottomSheetViewHolder {
val binding = BottomSheetItemBinding.inflate(
LayoutInflater.from(parent.context),
@@ -22,7 +22,7 @@ class BottomSheetAdapter(
}
override fun onBindViewHolder(holder: BottomSheetViewHolder, position: Int) {
- val item = items[position]
+ val item = getItem(position)!!
holder.binding.root.apply {
val current = item.getCurrent()
text = if (current != null) "${item.title} ($current)" else item.title
@@ -35,6 +35,4 @@ class BottomSheetAdapter(
}
}
}
-
- override fun getItemCount() = items.size
}
diff --git a/app/src/main/java/com/github/libretube/ui/adapters/CarouselPlaylistAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/CarouselPlaylistAdapter.kt
index 26ed45a3f3..5883ab76a0 100644
--- a/app/src/main/java/com/github/libretube/ui/adapters/CarouselPlaylistAdapter.kt
+++ b/app/src/main/java/com/github/libretube/ui/adapters/CarouselPlaylistAdapter.kt
@@ -4,9 +4,9 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.ListAdapter
-import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.CarouselPlaylistThumbnailBinding
+import com.github.libretube.enums.PlaylistType
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.ui.adapters.callbacks.DiffUtilItemCallback
@@ -20,7 +20,7 @@ data class CarouselPlaylist(
val thumbnail: String?
)
-class CarouselPlaylistAdapter : ListAdapter(
+class CarouselPlaylistAdapter(private val playlistType: PlaylistType) : ListAdapter(
DiffUtilItemCallback()
) {
override fun onCreateViewHolder(
@@ -41,9 +41,8 @@ class CarouselPlaylistAdapter : ListAdapter
CoroutineScope(Dispatchers.IO).launch {
- DatabaseHolder.Database.subscriptionGroupsDao()
- .deleteGroup(groups[position].name)
+ UserDataRepositoryHelper.userDataRepository
+ .deleteSubscriptionGroup(groups[position].id)
groups.removeAt(position)
viewModel.groups.postValue(groups)
diff --git a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt
index 84c05dceb2..7c6d3053db 100644
--- a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt
+++ b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt
@@ -6,10 +6,13 @@ import androidx.core.os.bundleOf
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.ListAdapter
+import com.github.libretube.api.obj.WatchHistoryEntry
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.VideoRowBinding
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.WatchHistoryItem
+import com.github.libretube.extensions.toID
+import com.github.libretube.extensions.toLocalDate
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.ui.adapters.callbacks.DiffUtilItemCallback
@@ -25,7 +28,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class WatchHistoryAdapter :
- ListAdapter(DiffUtilItemCallback()) {
+ ListAdapter(DiffUtilItemCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WatchHistoryViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
@@ -34,13 +37,13 @@ class WatchHistoryAdapter :
}
override fun onBindViewHolder(holder: WatchHistoryViewHolder, position: Int) {
- val video = getItem(holder.bindingAdapterPosition)
+ val video = getItem(holder.bindingAdapterPosition).video
holder.binding.apply {
videoTitle.text = video.title
- channelName.text = video.uploader
+ channelName.text = video.uploaderName
videoInfo.text =
- video.uploadDate?.takeIf { !video.isLive }?.let { TextUtils.localizeDate(it) }
- ImageHelper.loadImage(video.thumbnailUrl, thumbnail)
+ video.uploaded.toLocalDate().takeIf { !video.isLive }?.let { TextUtils.localizeDate(it) }
+ ImageHelper.loadImage(video.thumbnail, thumbnail)
if (video.duration != null) {
// we pass in 0 for the uploadDate, as a future video cannot be watched already
@@ -60,7 +63,7 @@ class WatchHistoryAdapter :
}
root.setOnClickListener {
- NavigationHelper.navigateVideo(root.context, video.videoId)
+ NavigationHelper.navigateVideo(root.context, video.url?.toID())
}
val activity = (root.context as BaseActivity)
@@ -73,19 +76,19 @@ class WatchHistoryAdapter :
notifyItemChanged(position)
}
val sheet = VideoOptionsBottomSheet()
- sheet.arguments = bundleOf(IntentData.streamItem to video.toStreamItem())
+ sheet.arguments = bundleOf(IntentData.streamItem to video)
sheet.show(fragmentManager, WatchHistoryAdapter::class.java.name)
true
}
if (video.duration != null) watchProgress.setWatchProgressLength(
- video.videoId,
+ video.url!!.toID(),
video.duration
)
CoroutineScope(Dispatchers.IO).launch {
val isDownloaded =
- DatabaseHolder.Database.downloadDao().exists(video.videoId)
+ DatabaseHolder.Database.downloadDao().exists(video.url!!.toID())
withContext(Dispatchers.Main) {
downloadBadge.isVisible = isDownloaded
diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/DeleteAccountDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/DeleteAccountDialog.kt
index 02e4056860..28ad3075fd 100644
--- a/app/src/main/java/com/github/libretube/ui/dialogs/DeleteAccountDialog.kt
+++ b/app/src/main/java/com/github/libretube/ui/dialogs/DeleteAccountDialog.kt
@@ -10,12 +10,11 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import com.github.libretube.R
-import com.github.libretube.api.RetrofitInstance
-import com.github.libretube.api.obj.DeleteUserRequest
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DialogDeleteAccountBinding
import com.github.libretube.extensions.TAG
-import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.extensions.toastFromMainDispatcher
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.preferences.InstanceSettings
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
@@ -28,6 +27,7 @@ class DeleteAccountDialog : DialogFragment() {
return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root)
+ .setTitle(R.string.deleteAccount)
.setPositiveButton(R.string.deleteAccount, null)
.setNegativeButton(R.string.cancel, null)
.show()
@@ -50,15 +50,16 @@ class DeleteAccountDialog : DialogFragment() {
}
private suspend fun deleteAccount(password: String) {
- val token = PreferenceHelper.getToken()
+ val context = requireContext()
try {
withContext(Dispatchers.IO) {
- RetrofitInstance.authApi.deleteAccount(token, DeleteUserRequest(password))
+ @Suppress("DEPRECATION")
+ UserDataRepositoryHelper.userDataRepository.deleteAccount(password)
}
} catch (e: Exception) {
Log.e(TAG(), e.toString())
- Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show()
+ context.toastFromMainDispatcher(e.message.orEmpty())
return
}
Toast.makeText(context, R.string.success, Toast.LENGTH_SHORT).show()
diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/LoginDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/LoginDialog.kt
index 99a6a917c8..34cefaa4f2 100644
--- a/app/src/main/java/com/github/libretube/ui/dialogs/LoginDialog.kt
+++ b/app/src/main/java/com/github/libretube/ui/dialogs/LoginDialog.kt
@@ -3,7 +3,6 @@ package com.github.libretube.ui.dialogs
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
-import android.util.Log
import android.util.Patterns
import android.widget.Toast
import androidx.core.os.bundleOf
@@ -11,21 +10,16 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import com.github.libretube.R
-import com.github.libretube.api.JsonHelper
-import com.github.libretube.api.RetrofitInstance
-import com.github.libretube.api.obj.Login
-import com.github.libretube.api.obj.Token
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DialogLoginBinding
-import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.preferences.InstanceSettings.Companion.INSTANCE_DIALOG_REQUEST_KEY
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import retrofit2.HttpException
class LoginDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@@ -64,38 +58,24 @@ class LoginDialog : DialogFragment() {
}
private fun signIn(username: String, password: String, createNewAccount: Boolean = false) {
- val login = Login(username, password)
lifecycleScope.launch(Dispatchers.IO) {
- val response = try {
+ @Suppress("DEPRECATION") val token = try {
if (createNewAccount) {
- RetrofitInstance.authApi.register(login)
+ UserDataRepositoryHelper.userDataRepository.register(username, password)
} else {
- RetrofitInstance.authApi.login(login)
+ UserDataRepositoryHelper.userDataRepository.login(username, password)
}
- } catch (e: HttpException) {
- val errorMessage = e.response()?.errorBody()?.string()?.runCatching {
- JsonHelper.json.decodeFromString(this).error
- }?.getOrNull() ?: context?.getString(R.string.server_error).orEmpty()
- context?.toastFromMainDispatcher(errorMessage)
- return@launch
} catch (e: Exception) {
- Log.e(TAG(), e.toString())
- context?.toastFromMainDispatcher(e.localizedMessage.orEmpty())
- return@launch
- }
-
- if (response.error != null) {
- context?.toastFromMainDispatcher(response.error)
+ context?.toastFromMainDispatcher(e.message.orEmpty())
return@launch
}
- if (response.token == null) return@launch
context?.toastFromMainDispatcher(
if (createNewAccount) R.string.registered else R.string.loggedIn
)
- PreferenceHelper.setToken(response.token)
- PreferenceHelper.setUsername(login.username)
+ PreferenceHelper.setToken(token)
+ PreferenceHelper.setUsername(username)
withContext(Dispatchers.Main) {
setFragmentResult(
diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/SelectInstanceDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/SelectInstanceDialog.kt
new file mode 100644
index 0000000000..38b9becc4e
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/ui/dialogs/SelectInstanceDialog.kt
@@ -0,0 +1,60 @@
+package com.github.libretube.ui.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.setFragmentResult
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.github.libretube.R
+import com.github.libretube.databinding.SimpleOptionsRecyclerBinding
+import com.github.libretube.ui.adapters.InstancesAdapter
+import com.github.libretube.ui.models.InstancesModel
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.launch
+
+class SelectInstanceDialog : DialogFragment() {
+ val viewModel: InstancesModel by activityViewModels()
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val title = requireArguments().getString(SELECT_INSTANCE_TITLE_EXTRA)!!
+ var selectedInstanceUrl =
+ requireArguments().getString(SELECT_INSTANCE_CURRENT_INSTANCE_API_URL_EXTRA)
+
+ val binding = SimpleOptionsRecyclerBinding.inflate(layoutInflater)
+ binding.optionsRecycler.layoutManager = LinearLayoutManager(context)
+
+ lifecycleScope.launch {
+ viewModel.instances.collect { instances ->
+ val selectedIndex = instances.indexOfFirst { it.apiUrl == selectedInstanceUrl }
+ val adapter = InstancesAdapter(selectedIndex) {
+ selectedInstanceUrl = instances[it].apiUrl
+ }
+ adapter.submitList(instances)
+ binding.optionsRecycler.adapter = adapter
+ }
+ }
+
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(title)
+ .setView(binding.root)
+ .setNeutralButton(R.string.addInstance) { _, _ ->
+ CreateCustomInstanceDialog().show(requireActivity().supportFragmentManager, null)
+ }
+ .setPositiveButton(R.string.okay) { _, _ ->
+ setFragmentResult(
+ SELECT_INSTANCE_RESULT_KEY,
+ bundleOf(SELECT_INSTANCE_CURRENT_INSTANCE_API_URL_EXTRA to selectedInstanceUrl)
+ )
+ }
+ .show()
+ }
+
+ companion object {
+ const val SELECT_INSTANCE_CURRENT_INSTANCE_API_URL_EXTRA = "current_instance"
+ const val SELECT_INSTANCE_TITLE_EXTRA = "title"
+ const val SELECT_INSTANCE_RESULT_KEY = "instance_select_result"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt
index eae105540d..9ed90d8f8a 100644
--- a/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt
+++ b/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt
@@ -8,6 +8,7 @@ import android.widget.RadioButton
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
import com.github.libretube.R
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
@@ -21,9 +22,12 @@ import com.github.libretube.extensions.serializable
import com.github.libretube.helpers.ClipboardHelper
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.ShareData
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
class ShareDialog : DialogFragment() {
private lateinit var id: String
@@ -103,8 +107,19 @@ class ShareDialog : DialogFragment() {
binding.timeStamp.addTextChangedListener {
binding.linkPreview.text = generateLinkText(binding, customInstances)
}
- val timeStamp =
- shareData.currentPosition ?: DatabaseHelper.getWatchPositionBlocking(id)?.div(1000)
+ val timeStamp = shareData.currentPosition
+ if (timeStamp == null) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ runCatching {
+ val timeStamp = UserDataRepositoryHelper.userDataRepository.getFromWatchHistory(id)
+ ?.metadata?.positionMillis?.div(10000) ?: return@runCatching
+ withContext(Dispatchers.Main) {
+ binding.timeStamp.setText(timeStamp.toString())
+ }
+ }
+ }
+ }
+
binding.timeStamp.setText((timeStamp ?: 0L).toString())
if (binding.timeCodeSwitch.isChecked) {
binding.timeStampInputLayout.isVisible = true
diff --git a/app/src/main/java/com/github/libretube/ui/extensions/SetWatchProgressLength.kt b/app/src/main/java/com/github/libretube/ui/extensions/SetWatchProgressLength.kt
index 9800c8ccf5..3c2d9e3adc 100644
--- a/app/src/main/java/com/github/libretube/ui/extensions/SetWatchProgressLength.kt
+++ b/app/src/main/java/com/github/libretube/ui/extensions/SetWatchProgressLength.kt
@@ -11,6 +11,10 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.helpers.ThemeHelper
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
/**
* Shows the already watched time under the video
@@ -18,15 +22,7 @@ import com.github.libretube.helpers.ThemeHelper
* @param duration The duration of the video in seconds
*/
fun View.setWatchProgressLength(videoId: String, duration: Long) {
- val progress = DatabaseHelper.getWatchPositionBlocking(videoId)?.div(1000)
- if (progress == null || progress == 0L) {
- isGone = true
- return
- }
-
- updateLayoutParams {
- matchConstraintPercentWidth = progress.toFloat()/ duration.toFloat()
- }
+ isGone = true
var backgroundColor = ThemeHelper.getThemeColor(
context,
@@ -46,6 +42,16 @@ fun View.setWatchProgressLength(videoId: String, duration: Long) {
}
}
+ CoroutineScope(Dispatchers.IO).launch {
+ val progress = DatabaseHelper.getWatchPosition(videoId)?.div(1000)
- isVisible = true
+ if (progress != null && progress == 0L) {
+ withContext(Dispatchers.Main) {
+ updateLayoutParams {
+ matchConstraintPercentWidth = progress.toFloat()/ duration.toFloat()
+ }
+ isVisible = true
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/github/libretube/ui/extensions/SetupSubscriptionButton.kt b/app/src/main/java/com/github/libretube/ui/extensions/SetupSubscriptionButton.kt
index a9fc70a360..1611824cec 100644
--- a/app/src/main/java/com/github/libretube/ui/extensions/SetupSubscriptionButton.kt
+++ b/app/src/main/java/com/github/libretube/ui/extensions/SetupSubscriptionButton.kt
@@ -5,6 +5,7 @@ import androidx.core.view.isVisible
import com.github.libretube.R
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.constants.PreferenceKeys
+import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PreferenceHelper
import com.google.android.material.button.MaterialButton
import com.google.android.material.snackbar.Snackbar
@@ -52,15 +53,20 @@ fun TextView.setupSubscriptionButton(
val setSubscriptionState : (Boolean) -> Unit = { subscribe ->
CoroutineScope(Dispatchers.IO).launch {
- if (subscribe)
- SubscriptionHelper.subscribe(
- channelId,
- channelName,
- channelAvatar,
- channelVerified
- )
- else
- SubscriptionHelper.unsubscribe(channelId)
+ try {
+ if (subscribe)
+ SubscriptionHelper.subscribe(
+ channelId,
+ channelName,
+ channelAvatar,
+ channelVerified
+ )
+ else
+ SubscriptionHelper.unsubscribe(channelId)
+ } catch (e: Exception) {
+ context.toastFromMainDispatcher(e.message.orEmpty())
+ return@launch
+ }
}
subscribed = subscribe
diff --git a/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt
index 3615d277c4..9b634d4dee 100644
--- a/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt
+++ b/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt
@@ -18,6 +18,7 @@ import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.constants.PreferenceKeys.HOME_TAB_CONTENT
import com.github.libretube.databinding.FragmentHomeBinding
import com.github.libretube.db.obj.PlaylistBookmark
+import com.github.libretube.enums.PlaylistType
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.activities.SettingsActivity
import com.github.libretube.ui.adapters.CarouselPlaylist
@@ -44,8 +45,8 @@ class HomeFragment : Fragment(R.layout.fragment_home) {
private val trendingAdapter = VideoCardsAdapter()
private val feedAdapter = VideoCardsAdapter(columnWidthDp = 250f)
private val watchingAdapter = VideoCardsAdapter(columnWidthDp = 250f)
- private val bookmarkAdapter = CarouselPlaylistAdapter()
- private val playlistAdapter = CarouselPlaylistAdapter()
+ private val bookmarkAdapter = CarouselPlaylistAdapter(PlaylistType.PUBLIC)
+ private val playlistAdapter = CarouselPlaylistAdapter(PlaylistType.PRIVATE)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = FragmentHomeBinding.bind(view)
diff --git a/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt
index 5984832521..468048234e 100644
--- a/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt
+++ b/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt
@@ -21,12 +21,13 @@ import com.github.libretube.api.obj.Playlists
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentLibraryBinding
-import com.github.libretube.db.DatabaseHolder
+import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.ceilHalf
import com.github.libretube.extensions.dpToPx
import com.github.libretube.helpers.NavBarHelper
import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.adapters.PlaylistBookmarkAdapter
import com.github.libretube.ui.adapters.PlaylistsAdapter
import com.github.libretube.ui.base.DynamicLayoutManagerFragment
@@ -44,7 +45,7 @@ class LibraryFragment : DynamicLayoutManagerFragment(R.layout.fragment_library)
private val commonPlayerViewModel: CommonPlayerViewModel by activityViewModels()
- private val playlistsAdapter = PlaylistsAdapter(PlaylistsHelper.getPrivatePlaylistType())
+ private val playlistsAdapter = PlaylistsAdapter(PlaylistType.PRIVATE)
private val playlistBookmarkAdapter = PlaylistBookmarkAdapter()
override fun setLayoutManagers(gridItems: Int) {
@@ -145,7 +146,7 @@ class LibraryFragment : DynamicLayoutManagerFragment(R.layout.fragment_library)
private fun initBookmarks() {
lifecycleScope.launch {
val bookmarks = withContext(Dispatchers.IO) {
- DatabaseHolder.Database.playlistBookmarkDao().getAll()
+ UserDataRepositoryHelper.userDataRepository.getPlaylistBookmarks()
}
val binding = _binding ?: return@launch
diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt
index 2d033314ea..552622a76e 100644
--- a/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt
+++ b/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt
@@ -38,6 +38,7 @@ import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.adapters.PlaylistAdapter
import com.github.libretube.ui.adapters.PlaylistItem
import com.github.libretube.ui.base.BaseActivity
@@ -54,7 +55,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
-import org.schabi.newpipe.extractor.timeago.patterns.it
class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist) {
private var _binding: FragmentPlaylistBinding? = null
@@ -100,10 +100,14 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist
binding.playlistProgress.isVisible = true
- isBookmarked = runBlocking(Dispatchers.IO) {
- DatabaseHolder.Database.playlistBookmarkDao().includes(playlistId)
+ lifecycleScope.launch(Dispatchers.IO) {
+ isBookmarked =
+ UserDataRepositoryHelper.userDataRepository.getPlaylistBookmark(playlistId) != null
+
+ withContext(Dispatchers.Main) {
+ updateBookmarkRes()
+ }
}
- updateBookmarkRes()
commonPlayerViewModel.isMiniPlayerVisible.observe(viewLifecycleOwner) {
binding.playlistRecView.updatePadding(bottom = if (it) 64f.dpToPx() else 0)
@@ -135,7 +139,7 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist
lifecycleScope.launch {
val response = try {
withContext(Dispatchers.IO) {
- PlaylistsHelper.getPlaylist(playlistId)
+ PlaylistsHelper.getPlaylist(playlistId, playlistType)
}
} catch (e: Exception) {
Log.e(TAG(), e.toString())
@@ -185,7 +189,8 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist
)
}
- binding.playlistInfo.text = getChannelAndVideoString(response, playlistFeed.size)
+ binding.playlistInfo.text =
+ getChannelAndVideoString(response, playlistFeed.size)
}
})
@@ -266,11 +271,12 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist
updateBookmarkRes()
lifecycleScope.launch(Dispatchers.IO) {
if (!isBookmarked) {
- DatabaseHolder.Database.playlistBookmarkDao()
- .deleteById(playlistId)
+ UserDataRepositoryHelper.userDataRepository
+ .deletePlaylistBookmark(playlistId)
} else {
- DatabaseHolder.Database.playlistBookmarkDao()
- .insert(response.toPlaylistBookmark(playlistId))
+ val bookmark = response.toPlaylistBookmark(playlistId)
+ UserDataRepositoryHelper.userDataRepository
+ .createPlaylistBookmark(bookmark)
}
}
}
@@ -339,14 +345,14 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist
withContext(Dispatchers.IO) {
// update the playlist thumbnail and title if bookmarked
val playlistBookmark =
- DatabaseHolder.Database.playlistBookmarkDao().findById(playlistId)
+ UserDataRepositoryHelper.userDataRepository.getPlaylistBookmark(playlistId)
?: return@withContext
if (playlistBookmark.thumbnailUrl != playlist.thumbnailUrl ||
playlistBookmark.playlistName != playlist.name ||
playlistBookmark.videos != playlist.videos
) {
- DatabaseHolder.Database.playlistBookmarkDao()
- .update(playlist.toPlaylistBookmark(playlistBookmark.playlistId))
+ val bookmark = playlist.toPlaylistBookmark(playlistBookmark.playlistId)
+ UserDataRepositoryHelper.userDataRepository.createPlaylistBookmark(bookmark)
}
}
}
@@ -402,7 +408,11 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist
// try to remove the video from the playlist and show an undo snackbar if successful
lifecycleScope.launch(Dispatchers.IO) {
try {
- PlaylistsHelper.removeFromPlaylist(playlistId, originalPlaylistPosition)
+ PlaylistsHelper.removeFromPlaylist(
+ playlistId,
+ video.url!!.toID(),
+ originalPlaylistPosition
+ )
val shortTitle = TextUtils.limitTextToLength(video.title.orEmpty(), 50)
val snackBarText = getString(
@@ -462,7 +472,11 @@ class PlaylistFragment : DynamicLayoutManagerFragment(R.layout.fragment_playlist
*
* I.e., this method adds the given offset to all videos with an originalPlaylistIndex > modifiedPosition.
*/
- private fun fixItemIndices(items: List, modifiedPosition: Int, offset: Int): List {
+ private fun fixItemIndices(
+ items: List,
+ modifiedPosition: Int,
+ offset: Int
+ ): List {
return items.map {
if (it.originalPlaylistIndex > modifiedPosition) {
it.copy(originalPlaylistIndex = it.originalPlaylistIndex + offset)
diff --git a/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt
index d41ed66adb..1b61af8252 100644
--- a/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt
+++ b/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt
@@ -18,11 +18,11 @@ import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentSubscriptionsBinding
import com.github.libretube.db.DatabaseHelper
-import com.github.libretube.db.DatabaseHolder
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.SelectableOption
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.adapters.VideoCardsAdapter
import com.github.libretube.ui.base.DynamicLayoutManagerFragment
import com.github.libretube.ui.models.EditChannelGroupsModel
@@ -185,8 +185,7 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment(R.layout.fragment_sub
})
lifecycleScope.launch(Dispatchers.IO) {
- val groups = DatabaseHolder.Database.subscriptionGroupsDao().getAll()
- .sortedBy { it.index }
+ val groups = UserDataRepositoryHelper.userDataRepository.getSubscriptionGroups()
channelGroupsModel.groups.postValue(groups)
}
}
diff --git a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt
index 2df3251593..2e7445b33a 100644
--- a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt
+++ b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt
@@ -22,7 +22,9 @@ import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.extensions.ceilHalf
import com.github.libretube.extensions.dpToPx
import com.github.libretube.extensions.setOnDismissListener
+import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.NavigationHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.adapters.WatchHistoryAdapter
import com.github.libretube.ui.base.DynamicLayoutManagerFragment
import com.github.libretube.ui.extensions.addOnBottomReachedListener
@@ -108,9 +110,10 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc
binding.statusFilterChips.isGone = true
lifecycleScope.launch(Dispatchers.IO) {
- Database.withTransaction {
- Database.watchHistoryDao().deleteAll()
- if (selected[0]) Database.watchPositionDao().deleteAll()
+ try {
+ UserDataRepositoryHelper.userDataRepository.clearWatchHistory()
+ } catch (e: Exception) {
+ context?.toastFromMainDispatcher(e.message.orEmpty())
}
}
}
@@ -134,11 +137,11 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc
if (history.isEmpty()) return@setOnClickListener
PlayingQueue.add(
- *history.reversed().map(WatchHistoryItem::toStreamItem).toTypedArray()
+ *history.reversed().map{ it.video }.toTypedArray()
)
NavigationHelper.navigateVideo(
requireContext(),
- history.last().videoId,
+ history.last().metadata.videoId,
keepQueue = true
)
}
@@ -150,6 +153,7 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc
binding.playAll.isVisible = history.isNotEmpty()
watchHistoryAdapter.submitList(history)
+ binding.clear.isEnabled = history.isNotEmpty()
}
viewModel.fetchNextPage()
@@ -157,15 +161,6 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc
binding.watchHistoryRecView.addOnBottomReachedListener {
viewModel.fetchNextPage()
}
-
- CoroutineScope(Dispatchers.IO).launch {
- val hasItems = Database.watchHistoryDao().getSize() != 0
-
- withContext(Dispatchers.Main) {
- binding.clear.isEnabled = hasItems
- }
- }
-
}
override fun onConfigurationChanged(newConfig: Configuration) {
diff --git a/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt
index 791d3aba4e..623ef867e8 100644
--- a/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt
+++ b/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt
@@ -12,12 +12,12 @@ import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHelper
-import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.extensions.runSafely
import com.github.libretube.extensions.updateIfChanged
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
@@ -105,7 +105,7 @@ class HomeViewModel : ViewModel() {
private suspend fun loadBookmarks() {
runSafely(
onSuccess = { newBookmarks -> bookmarks.updateIfChanged(newBookmarks) },
- ioBlock = { DatabaseHolder.Database.playlistBookmarkDao().getAll() }
+ ioBlock = { UserDataRepositoryHelper.userDataRepository.getPlaylistBookmarks() }
)
}
@@ -125,10 +125,12 @@ class HomeViewModel : ViewModel() {
}
private suspend fun loadWatchingFromDB(): List {
- val videos = DatabaseHelper.getWatchHistoryPage(1, 20)
+ val videos = runCatching {
+ UserDataRepositoryHelper.userDataRepository.getWatchHistory(1)
+ }.getOrElse { emptyList() }
return DatabaseHelper
- .filterUnwatched(videos.map { it.toStreamItem() })
+ .filterUnwatched(videos.map { it.video })
}
private suspend fun tryLoadFeed(subscriptionsViewModel: SubscriptionsViewModel): List {
diff --git a/app/src/main/java/com/github/libretube/ui/models/InstancesModel.kt b/app/src/main/java/com/github/libretube/ui/models/InstancesModel.kt
index b8ce21d919..112ef78e1d 100644
--- a/app/src/main/java/com/github/libretube/ui/models/InstancesModel.kt
+++ b/app/src/main/java/com/github/libretube/ui/models/InstancesModel.kt
@@ -2,6 +2,8 @@ package com.github.libretube.ui.models
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.github.libretube.api.PipedMediaServiceRepository
+import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.PipedInstance
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.CustomInstance
@@ -9,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.MalformedURLException
@@ -16,6 +19,22 @@ class InstancesModel : ViewModel() {
val customInstances = Database.customInstanceDao().getAllFlow()
.flowOn(Dispatchers.IO)
+ val instances = customInstances.map { instances ->
+ val instances = instances.map { PipedInstance(it.name, it.apiUrl) }.toMutableList()
+
+ // add the currently used instances to the list if they're currently down / not part
+ // of the public instances list
+ for (apiUrl in listOf(PipedMediaServiceRepository.apiUrl, RetrofitInstance.pipedAuthUrl)) {
+ if (instances.none { it.apiUrl == apiUrl }) {
+ val origin = apiUrl.toHttpUrl().host
+ instances.add(PipedInstance(origin, apiUrl, isCurrentlyDown = true))
+ }
+ }
+ instances.sortBy { it.name }
+
+ instances
+ }
+
fun addCustomInstance(
apiUrlInput: String,
instanceNameInput: String?,
diff --git a/app/src/main/java/com/github/libretube/ui/models/WatchHistoryModel.kt b/app/src/main/java/com/github/libretube/ui/models/WatchHistoryModel.kt
index 2192729f71..e67488a0fa 100644
--- a/app/src/main/java/com/github/libretube/ui/models/WatchHistoryModel.kt
+++ b/app/src/main/java/com/github/libretube/ui/models/WatchHistoryModel.kt
@@ -5,11 +5,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
+import com.github.libretube.api.obj.WatchHistoryEntry
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHelper
-import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -19,7 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class WatchHistoryModel : ViewModel() {
- private val watchHistory = MutableLiveData>()
+ private val watchHistory = MutableLiveData>()
private var currentPage = 1
private var isLoading = false
@@ -40,7 +41,7 @@ class WatchHistoryModel : ViewModel() {
selectedStatus.value = value
}
- private suspend fun WatchHistoryItem.shouldIncludeByFilters(): Boolean {
+ private suspend fun WatchHistoryEntry.shouldIncludeByFilters(): Boolean {
// no watch position filter
if (selectedStatusFilter == 0) return true
@@ -56,7 +57,12 @@ class WatchHistoryModel : ViewModel() {
isLoading = true
val newHistory = withContext(Dispatchers.IO) {
- DatabaseHelper.getWatchHistoryPage(currentPage, HISTORY_PAGE_SIZE)
+ try {
+ UserDataRepositoryHelper.userDataRepository.getWatchHistory(currentPage)
+ } catch (e: Exception) {
+ // TODO: proper error handling
+ return@withContext emptyList()
+ }
}
isLoading = false
@@ -69,16 +75,14 @@ class WatchHistoryModel : ViewModel() {
)
}
- fun removeFromHistory(watchHistoryItem: WatchHistoryItem) =
+ fun removeFromHistory(watchHistoryEntry: WatchHistoryEntry) =
viewModelScope.launch(Dispatchers.IO) {
- DatabaseHolder.Database.watchHistoryDao().delete(watchHistoryItem)
+ runCatching {
+ UserDataRepositoryHelper.userDataRepository.removeFromWatchHistory(watchHistoryEntry.metadata.videoId)
- watchHistory.postValue(
- watchHistory.value.orEmpty().filter { it != watchHistoryItem }
- )
+ watchHistory.postValue(
+ watchHistory.value.orEmpty().filter { it != watchHistoryEntry }
+ )
+ }
}
-
- companion object {
- private const val HISTORY_PAGE_SIZE = 10
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/ui/models/WelcomeViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/WelcomeViewModel.kt
new file mode 100644
index 0000000000..220e403f43
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/ui/models/WelcomeViewModel.kt
@@ -0,0 +1,90 @@
+package com.github.libretube.ui.models
+
+import android.content.Context
+import android.net.Uri
+import android.os.Parcelable
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.createSavedStateHandle
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.github.libretube.constants.PreferenceKeys
+import com.github.libretube.enums.SyncServerType
+import com.github.libretube.helpers.BackupHelper
+import com.github.libretube.helpers.PreferenceHelper
+import com.github.libretube.repo.UserDataRepositoryHelper
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+
+class WelcomeViewModel(
+ private val savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+
+ private val _uiState = savedStateHandle.getStateFlow(
+ UI_STATE, UiState()
+ )
+ val uiState = _uiState.asLiveData()
+
+ fun setSyncServerType(syncServerType: SyncServerType) {
+ savedStateHandle[UI_STATE] =
+ _uiState.value.copy(syncServerType = syncServerType, loggedIn = false)
+ PreferenceHelper.putString(
+ PreferenceKeys.SYNC_SERVER_TYPE,
+ _uiState.value.syncServerType.name.lowercase()
+ )
+ }
+
+ fun setLoggedIn(loggedIn: Boolean) {
+ savedStateHandle[UI_STATE] = _uiState.value.copy(loggedIn = loggedIn)
+ }
+
+ fun setPipedInstance(instanceApiUrl: String) {
+ savedStateHandle[UI_STATE] = _uiState.value.copy(selectedPipedInstance = instanceApiUrl, loggedIn = false)
+ }
+
+ fun restoreAdvancedBackup(context: Context, uri: Uri) {
+ viewModelScope.launch {
+ BackupHelper.restoreAdvancedBackup(context, uri)
+
+ // only skip the welcome activity if the restored backup contains an instance
+ val instancePref = PreferenceHelper.getString(PreferenceKeys.FETCH_INSTANCE, "")
+ if (instancePref.isNotEmpty()) {
+ refreshAndNavigate()
+ }
+ }
+ }
+
+ fun refreshAndNavigate() {
+ savedStateHandle[UI_STATE] = _uiState.value.copy(navigateToMain = Unit)
+ }
+
+ fun onNavigated() {
+ savedStateHandle[UI_STATE] = _uiState.value.copy(navigateToMain = null)
+ }
+
+ @Parcelize
+ data class UiState(
+ val syncServerType: SyncServerType = UserDataRepositoryHelper.syncServerType,
+ val loggedIn: Boolean = false,
+ val selectedPipedInstance: String? = PreferenceHelper.getString(
+ PreferenceKeys.AUTH_INSTANCE,
+ ""
+ ).ifBlank { null },
+ val navigateToMain: Unit? = null,
+ ) : Parcelable
+
+ companion object {
+ private const val UI_STATE = "ui_state"
+
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ WelcomeViewModel(
+ savedStateHandle = createSavedStateHandle(),
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt
index 5800919fe0..6f8fb5c4f4 100644
--- a/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt
+++ b/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt
@@ -24,32 +24,6 @@ class HistorySettings : BasePreferenceFragment() {
}
true
}
-
- // clear watch history and positions
- val clearWatchHistory = findPreference(PreferenceKeys.CLEAR_WATCH_HISTORY)
- clearWatchHistory?.setOnPreferenceClickListener {
- showClearDialog(R.string.clear_history) {
- Database.watchHistoryDao().deleteAll()
- }
- true
- }
-
- // clear watch positions
- val clearWatchPositions = findPreference(PreferenceKeys.CLEAR_WATCH_POSITIONS)
- clearWatchPositions?.setOnPreferenceClickListener {
- showClearDialog(R.string.reset_watch_positions) {
- Database.watchPositionDao().deleteAll()
- }
- true
- }
-
- val resetBookmarks = findPreference(PreferenceKeys.CLEAR_BOOKMARKS)
- resetBookmarks?.setOnPreferenceClickListener {
- showClearDialog(R.string.clear_bookmarks) {
- Database.playlistBookmarkDao().deleteAll()
- }
- true
- }
}
private fun showClearDialog(title: Int, actionOnConfirm: suspend () -> Unit) {
diff --git a/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt
index d46fa4598e..c847ecc4b7 100644
--- a/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt
+++ b/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt
@@ -1,76 +1,49 @@
package com.github.libretube.ui.preferences
import android.os.Bundle
-import android.view.LayoutInflater
import android.widget.Toast
import androidx.core.app.ActivityCompat
+import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.lifecycleScope
+import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
+import androidx.preference.PreferenceCategory
import androidx.preference.SwitchPreferenceCompat
-import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
-import com.github.libretube.api.PipedMediaServiceRepository
import com.github.libretube.api.RetrofitInstance
-import com.github.libretube.api.obj.PipedInstance
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
-import com.github.libretube.databinding.SimpleOptionsRecyclerBinding
+import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.helpers.PreferenceHelper
-import com.github.libretube.ui.adapters.InstancesAdapter
import com.github.libretube.ui.base.BasePreferenceFragment
-import com.github.libretube.ui.dialogs.CreateCustomInstanceDialog
-import com.github.libretube.ui.dialogs.CustomInstancesListDialog
import com.github.libretube.ui.dialogs.DeleteAccountDialog
import com.github.libretube.ui.dialogs.LoginDialog
import com.github.libretube.ui.dialogs.LogoutDialog
+import com.github.libretube.ui.dialogs.SelectInstanceDialog
import com.github.libretube.ui.models.InstancesModel
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.common.collect.ImmutableList
-import kotlinx.coroutines.launch
-import okhttp3.HttpUrl.Companion.toHttpUrl
+import com.github.libretube.ui.views.ButtonGroupPreference
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
class InstanceSettings : BasePreferenceFragment() {
- private val token get() = PreferenceHelper.getToken()
- private var instances = mutableListOf()
private val customInstancesModel: InstancesModel by activityViewModels()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.instance_settings, rootKey)
val instancePref = findPreference(PreferenceKeys.FETCH_INSTANCE)!!
- val authInstanceToggle = findPreference(
- PreferenceKeys.AUTH_INSTANCE_TOGGLE
- )!!
val authInstance = findPreference(PreferenceKeys.AUTH_INSTANCE)!!
- val instancePrefs = listOf(instancePref, authInstance)
-
- lifecycleScope.launch {
- customInstancesModel.customInstances.collect { updatedInstances ->
- instances =
- updatedInstances.map { PipedInstance(it.name, it.apiUrl) }.toMutableList()
- // update the instances to also show custom ones
- initInstancesPref(instancePrefs)
- }
- }
- authInstance.setOnPreferenceChangeListener { _, _ ->
- RetrofitInstance.apiLazyMgr.reset()
- logoutAndUpdateUI()
- true
+ for (instancePref in arrayOf(instancePref, authInstance)) {
+ instancePref.summaryProvider =
+ Preference.SummaryProvider { preference ->
+ preference.value
+ }
}
- authInstanceToggle.setOnPreferenceChangeListener { _, _ ->
+ authInstance.setOnPreferenceChangeListener { _, _ ->
RetrofitInstance.apiLazyMgr.reset()
- logoutAndUpdateUI()
- true
- }
-
- val customInstance = findPreference(PreferenceKeys.CUSTOM_INSTANCE)
- customInstance?.setOnPreferenceClickListener {
- CustomInstancesListDialog()
- .show(childFragmentManager, CreateCustomInstanceDialog::class.java.name)
+ logoutAndUpdateUI(true)
true
}
@@ -78,10 +51,6 @@ class InstanceSettings : BasePreferenceFragment() {
val logout = findPreference(PreferenceKeys.LOGOUT)
val deleteAccount = findPreference(PreferenceKeys.DELETE_ACCOUNT)
- login?.isVisible = token.isEmpty()
- logout?.isVisible = token.isNotEmpty()
- deleteAccount?.isEnabled = token.isNotEmpty()
-
childFragmentManager.setFragmentResultListener(
INSTANCE_DIALOG_REQUEST_KEY,
this
@@ -89,11 +58,9 @@ class InstanceSettings : BasePreferenceFragment() {
val isLoggedIn = resultBundle.getBoolean(IntentData.loginTask)
val isLoggedOut = resultBundle.getBoolean(IntentData.logoutTask)
if (isLoggedIn) {
- login?.isVisible = false
- logout?.isVisible = true
- deleteAccount?.isEnabled = true
+ toggleAuthAccountActionsUI(true)
} else if (isLoggedOut) {
- logoutAndUpdateUI()
+ logoutAndUpdateUI(true)
}
}
@@ -113,43 +80,42 @@ class InstanceSettings : BasePreferenceFragment() {
true
}
- val fullLocalMode = findPreference(PreferenceKeys.FULL_LOCAL_MODE)!!
+ val youTubeDataSource = findPreference(PreferenceKeys.YOUTUBE_DATA_SOURCE)!!
val localReturnYouTubeDislike = findPreference(PreferenceKeys.LOCAL_RYD)!!
- localReturnYouTubeDislike.isEnabled = fullLocalMode.isEnabled
- fullLocalMode.setOnPreferenceChangeListener { _, newValue ->
- localReturnYouTubeDislike.isEnabled = newValue == true
+ val instanceCategory = findPreference("instance_category")!!
+
+ localReturnYouTubeDislike.isVisible = youTubeDataSource.value != "piped"
+ instanceCategory.isVisible = youTubeDataSource.value == "piped"
+ youTubeDataSource.setOnPreferenceChangeListener { _, newValue ->
+ localReturnYouTubeDislike.isVisible = newValue != "piped"
+ instanceCategory.isVisible = newValue == "piped"
- // when the full local mode gets enabled, the fetch instance is no longer used and replaced
- // fully by local extraction. thus, the user has to be logged out from the fetch instance
- if (newValue == true && !authInstanceToggle.isChecked) logoutAndUpdateUI()
true
}
- }
- private fun initInstancesPref(instancePrefs: List) = runCatching {
- // add the currently used instances to the list if they're currently down / not part
- // of the public instances list
- for (apiUrl in listOf(PipedMediaServiceRepository.apiUrl, RetrofitInstance.authUrl)) {
- if (instances.none { it.apiUrl == apiUrl }) {
- val origin = apiUrl.toHttpUrl().host
- instances.add(PipedInstance(origin, apiUrl, isCurrentlyDown = true))
- }
- }
+ val syncServerType = findPreference(PreferenceKeys.SYNC_SERVER_TYPE)!!
+ val libretubeSyncServerInstance = findPreference(PreferenceKeys.LIBRETUBE_SYNC_SERVER_URL)!!
- instances.sortBy { it.name }
+ authInstance.isVisible = syncServerType.value == "piped"
+ libretubeSyncServerInstance.isVisible = syncServerType.value == "libretube"
+ toggleAuthAccountActionsUI(syncServerType.value != "none")
+ syncServerType.setOnPreferenceChangeListener { _, newValue ->
+ authInstance.isVisible = newValue == "piped"
+ libretubeSyncServerInstance.isVisible = newValue == "libretube"
- // If any preference dialog is visible in this fragment, it's one of the instance selection
- // dialogs. In order to prevent UX issues, we don't update the instances list then.
- if (isDialogVisible) return@runCatching
+ logoutAndUpdateUI(newValue != "none")
+ true
+ }
- for (instancePref in instancePrefs) {
- // add custom instances to the list preference
- instancePref.entries = instances.map { it.name }.toTypedArray()
- instancePref.entryValues = instances.map { it.apiUrl }.toTypedArray()
- instancePref.summaryProvider =
- Preference.SummaryProvider { preference ->
- preference.entry
- }
+ libretubeSyncServerInstance.setOnPreferenceChangeListener { _, newValue ->
+ // validate that the input is an actual URL
+ if (newValue.toString().toHttpUrlOrNull() == null) {
+ context?.toastFromMainThread(R.string.invalid_url)
+ return@setOnPreferenceChangeListener false
+ }
+
+ logoutAndUpdateUI(true)
+ true
}
}
@@ -166,47 +132,43 @@ class InstanceSettings : BasePreferenceFragment() {
}
private fun showInstanceSelectionDialog(preference: ListPreference) {
- var selectedInstance = preference.value
- val selectedIndex = instances.indexOfFirst { it.apiUrl == selectedInstance }
-
- val layoutInflater = LayoutInflater.from(context)
- val binding = SimpleOptionsRecyclerBinding.inflate(layoutInflater)
- binding.optionsRecycler.layoutManager = LinearLayoutManager(context)
-
- val instances = ImmutableList.copyOf(this.instances)
- binding.optionsRecycler.adapter = InstancesAdapter(selectedIndex) {
- selectedInstance = instances[it].apiUrl
- }.also { it.submitList(instances) }
-
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(preference.title)
- .setView(binding.root)
- .setNeutralButton(R.string.addInstance) { _, _ ->
- CreateCustomInstanceDialog().show(childFragmentManager, null)
+ SelectInstanceDialog()
+ .apply {
+ arguments = bundleOf(
+ SelectInstanceDialog.SELECT_INSTANCE_TITLE_EXTRA to getString(R.string.auth_instance),
+ SelectInstanceDialog.SELECT_INSTANCE_CURRENT_INSTANCE_API_URL_EXTRA to preference.value
+ )
}
- .setPositiveButton(R.string.okay) { _, _ ->
- preference.value = selectedInstance
- resetForNewInstance()
- }
- .show()
+ .show(childFragmentManager, null)
+ childFragmentManager.setFragmentResultListener(
+ SelectInstanceDialog.SELECT_INSTANCE_RESULT_KEY,
+ this
+ ) { _, bundle ->
+ val apiUrl =
+ bundle.getString(SelectInstanceDialog.SELECT_INSTANCE_CURRENT_INSTANCE_API_URL_EXTRA)
+ preference.value = apiUrl
+ resetForNewInstance()
+ childFragmentManager.clearFragmentResultListener(SelectInstanceDialog.SELECT_INSTANCE_RESULT_KEY)
+ }
}
- private fun logoutAndUpdateUI() {
- PreferenceHelper.setToken("")
- Toast.makeText(context, getString(R.string.loggedout), Toast.LENGTH_SHORT).show()
- findPreference(PreferenceKeys.LOGIN_REGISTER)?.isVisible = true
- findPreference(PreferenceKeys.LOGOUT)?.isVisible = false
- findPreference(PreferenceKeys.DELETE_ACCOUNT)?.isEnabled = false
- }
+ private fun toggleAuthAccountActionsUI(hasAuthSupport: Boolean) {
+ val loggedIn = PreferenceHelper.getToken().isNotBlank()
- private fun resetForNewInstance() {
- val authInstanceToggle = findPreference(
- PreferenceKeys.AUTH_INSTANCE_TOGGLE
- )!!
+ findPreference(PreferenceKeys.LOGIN_REGISTER)?.isVisible = !loggedIn && hasAuthSupport
+ findPreference(PreferenceKeys.LOGOUT)?.isVisible = loggedIn && hasAuthSupport
+ findPreference(PreferenceKeys.DELETE_ACCOUNT)?.isVisible = loggedIn && hasAuthSupport
+ }
- if (!authInstanceToggle.isChecked) {
- logoutAndUpdateUI()
+ private fun logoutAndUpdateUI(hasAuthSupport: Boolean) {
+ if (PreferenceHelper.getToken().isNotBlank()) {
+ Toast.makeText(context, getString(R.string.loggedout), Toast.LENGTH_SHORT).show()
+ PreferenceHelper.setToken("")
}
+ toggleAuthAccountActionsUI(hasAuthSupport)
+ }
+
+ private fun resetForNewInstance() {
RetrofitInstance.apiLazyMgr.reset()
ActivityCompat.recreate(requireActivity())
}
diff --git a/app/src/main/java/com/github/libretube/ui/sheets/AddChannelToGroupSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/AddChannelToGroupSheet.kt
index 48207b31a4..aa3a1df39a 100644
--- a/app/src/main/java/com/github/libretube/ui/sheets/AddChannelToGroupSheet.kt
+++ b/app/src/main/java/com/github/libretube/ui/sheets/AddChannelToGroupSheet.kt
@@ -6,7 +6,8 @@ import androidx.lifecycle.lifecycleScope
import com.github.libretube.R
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DialogAddChannelToGroupBinding
-import com.github.libretube.db.DatabaseHolder
+import com.github.libretube.db.obj.SubscriptionGroup
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.adapters.AddChannelToGroupAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -36,17 +37,18 @@ class AddChannelToGroupSheet : ExpandedBottomSheet(R.layout.dialog_add_channel_t
}
lifecycleScope.launch(Dispatchers.IO) {
- val subGroupsDao = DatabaseHolder.Database.subscriptionGroupsDao()
- val subscriptionGroups = subGroupsDao.getAll().sortedBy { it.index }.toMutableList()
+ val initialSubscriptionGroups = UserDataRepositoryHelper.userDataRepository
+ .getSubscriptionGroups().sortedBy { it.index }
+ val modifiableGroups = initialSubscriptionGroups.toMutableList()
withContext(Dispatchers.Main) {
- addToGroupAdapter.submitList(subscriptionGroups)
+ addToGroupAdapter.submitList(initialSubscriptionGroups)
binding.okay.setOnClickListener {
requireDialog().hide()
lifecycleScope.launch(Dispatchers.IO) {
- subGroupsDao.updateAll(subscriptionGroups)
+ applyGroupsDiff(initialSubscriptionGroups, modifiableGroups.toList())
withContext(Dispatchers.Main) {
dialog?.dismiss()
@@ -56,4 +58,42 @@ class AddChannelToGroupSheet : ExpandedBottomSheet(R.layout.dialog_add_channel_t
}
}
}
+
+ companion object {
+ suspend fun applyGroupsDiff(
+ initialChannelGroups: List,
+ modifiedChannelGroups: List
+ ) {
+ // TODO: very ugly, refactor the UI part of this to tell what changed
+ // so that we don't need to diff manually
+
+ for ((modifiedGroup, initialGroup) in modifiedChannelGroups.associateWith { mod ->
+ initialChannelGroups.find { mod.id == it.id }
+ }) {
+ if (initialGroup?.channels != modifiedGroup.channels) {
+ // search for channels that were remove from the group
+ for (initialChannelId in initialGroup?.channels.orEmpty()) {
+ if (initialChannelId !in modifiedGroup.channels) {
+ UserDataRepositoryHelper.userDataRepository
+ .removeFromSubscriptionGroup(
+ modifiedGroup.id,
+ initialChannelId
+ )
+ }
+ }
+
+ // search for channels that were added to the group
+ for (modifiedChannelId in modifiedGroup.channels) {
+ if (modifiedChannelId !in initialGroup?.channels.orEmpty()) {
+ UserDataRepositoryHelper.userDataRepository
+ .addToSubscriptionGroup(
+ modifiedGroup.id,
+ modifiedChannelId
+ )
+ }
+ }
+ }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/github/libretube/ui/sheets/BaseBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/BaseBottomSheet.kt
index c54a770476..b94c5421ae 100644
--- a/app/src/main/java/com/github/libretube/ui/sheets/BaseBottomSheet.kt
+++ b/app/src/main/java/com/github/libretube/ui/sheets/BaseBottomSheet.kt
@@ -1,7 +1,6 @@
package com.github.libretube.ui.sheets
import android.os.Bundle
-import android.util.Log
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import androidx.annotation.LayoutRes
@@ -14,8 +13,8 @@ import com.github.libretube.databinding.BottomSheetBinding
import com.github.libretube.extensions.dpToPx
import com.github.libretube.obj.BottomSheetItem
import com.github.libretube.ui.adapters.BottomSheetAdapter
-import kotlinx.coroutines.launch
import com.github.libretube.ui.extensions.onSystemInsets
+import kotlinx.coroutines.launch
open class BaseBottomSheet(@LayoutRes layoutResId: Int = R.layout.bottom_sheet) : ExpandedBottomSheet(layoutResId) {
@@ -24,6 +23,8 @@ open class BaseBottomSheet(@LayoutRes layoutResId: Int = R.layout.bottom_sheet)
private lateinit var items: List
private lateinit var listener: (index: Int) -> Unit
+ private lateinit var adapter: BottomSheetAdapter
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = BottomSheetBinding.bind(view)
@@ -39,7 +40,10 @@ open class BaseBottomSheet(@LayoutRes layoutResId: Int = R.layout.bottom_sheet)
}
binding.optionsRecycler.layoutManager = LinearLayoutManager(requireContext())
- binding.optionsRecycler.adapter = BottomSheetAdapter(items, listener)
+
+ val adapter = BottomSheetAdapter(listener)
+ adapter.submitList(items)
+ binding.optionsRecycler.adapter = adapter
// add bottom padding to the list, to ensure that the last item is not overlapped by the system bars
binding.optionsRecycler.onSystemInsets { v, systemInsets ->
@@ -55,6 +59,10 @@ open class BaseBottomSheet(@LayoutRes layoutResId: Int = R.layout.bottom_sheet)
fun setItems(items: List, listener: (suspend (index: Int) -> Unit)?) = apply {
this.items = items
+
+ // if the caller calls `setItems` while the sheet is already visible, update the existing list
+ if (::adapter.isInitialized) adapter.submitList(items)
+
this.listener = { index ->
lifecycleScope.launch {
dialog?.hide()
diff --git a/app/src/main/java/com/github/libretube/ui/sheets/ChannelGroupsSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/ChannelGroupsSheet.kt
index 398f348576..2609cb95c9 100644
--- a/app/src/main/java/com/github/libretube/ui/sheets/ChannelGroupsSheet.kt
+++ b/app/src/main/java/com/github/libretube/ui/sheets/ChannelGroupsSheet.kt
@@ -8,12 +8,12 @@ import androidx.lifecycle.lifecycleScope
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.extensions.move
import com.github.libretube.extensions.setOnDraggedListener
import com.github.libretube.ui.adapters.SubscriptionGroupsAdapter
import com.github.libretube.ui.models.EditChannelGroupsModel
+import com.github.libretube.ui.sheets.AddChannelToGroupSheet.Companion.applyGroupsDiff
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -42,11 +42,12 @@ class ChannelGroupsSheet : ExpandedBottomSheet(R.layout.dialog_subscription_grou
}
binding.confirm.setOnClickListener {
+ val groupsBeforeChange = channelGroupsModel.groups.value.orEmpty()
+
channelGroupsModel.groups.value = adapter.groups
channelGroupsModel.groups.value?.forEachIndexed { index, group -> group.index = index }
CoroutineScope(Dispatchers.IO).launch {
- DatabaseHolder.Database.subscriptionGroupsDao()
- .updateAll(channelGroupsModel.groups.value.orEmpty())
+ applyGroupsDiff(groupsBeforeChange, adapter.groups)
}
dismiss()
}
diff --git a/app/src/main/java/com/github/libretube/ui/sheets/EditChannelGroupSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/EditChannelGroupSheet.kt
index 71a4e4ae65..99124a63a7 100644
--- a/app/src/main/java/com/github/libretube/ui/sheets/EditChannelGroupSheet.kt
+++ b/app/src/main/java/com/github/libretube/ui/sheets/EditChannelGroupSheet.kt
@@ -13,16 +13,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
import com.github.libretube.api.obj.Subscription
import com.github.libretube.databinding.DialogEditChannelGroupBinding
-import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.SubscriptionGroup
+import com.github.libretube.extensions.toastFromMainDispatcher
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.adapters.SubscriptionGroupChannelsAdapter
import com.github.libretube.ui.models.EditChannelGroupsModel
import com.github.libretube.ui.models.SubscriptionsViewModel
+import com.github.libretube.ui.sheets.AddChannelToGroupSheet.Companion.applyGroupsDiff
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
class EditChannelGroupSheet : ExpandedBottomSheet(R.layout.dialog_edit_channel_group) {
private var _binding: DialogEditChannelGroupBinding? = null
@@ -90,16 +92,22 @@ class EditChannelGroupSheet : ExpandedBottomSheet(R.layout.dialog_edit_channel_g
}
private fun saveGroup(group: SubscriptionGroup, oldGroupName: String) {
- // delete the old instance if the group already existed and add the updated/new one
- channelGroupsModel.groups.value = channelGroupsModel.groups.value
- ?.filter { it.name != oldGroupName }
- ?.plus(group)
+ val groupsBeforeChange = channelGroupsModel.groups.value.orEmpty()
CoroutineScope(Dispatchers.IO).launch {
- // delete the old version of the group first before updating it, as the name is the
- // primary key
- DatabaseHolder.Database.subscriptionGroupsDao().deleteGroup(oldGroupName)
- DatabaseHolder.Database.subscriptionGroupsDao().createGroup(group)
+ try {
+ group.id = UserDataRepositoryHelper.userDataRepository.createSubscriptionGroup(group.name)
+ withContext(Dispatchers.Main) {
+ channelGroupsModel.groups.value = channelGroupsModel.groups.value
+ ?.filter { it.name != oldGroupName }
+ ?.plus(group)
+ }
+
+ val groupsAfterChange = channelGroupsModel.groups.value.orEmpty()
+ applyGroupsDiff(groupsBeforeChange, groupsAfterChange)
+ } catch (e: Exception) {
+ context?.toastFromMainDispatcher(e.message.orEmpty())
+ }
}
}
@@ -131,12 +139,13 @@ class EditChannelGroupSheet : ExpandedBottomSheet(R.layout.dialog_edit_channel_g
return getString(R.string.group_name_error_empty)
}
- val groupExists = runBlocking(Dispatchers.IO) {
- DatabaseHolder.Database.subscriptionGroupsDao().exists(name)
- }
- if (groupExists && channelGroupsModel.groupToEdit?.name != name) {
- return getString(R.string.group_name_error_exists)
- }
+ // TODO: either remove this check or figure out how to support this for LibreTube sync server
+// val groupExists = runBlocking(Dispatchers.IO) {
+// DatabaseHolder.Database.subscriptionGroupsDao().exists(name)
+// }
+// if (groupExists && channelGroupsModel.groupToEdit?.name != name) {
+// return getString(R.string.group_name_error_exists)
+// }
return null
}
diff --git a/app/src/main/java/com/github/libretube/ui/sheets/PlayingQueueSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/PlayingQueueSheet.kt
index ff07a8a90d..b8ac372e48 100644
--- a/app/src/main/java/com/github/libretube/ui/sheets/PlayingQueueSheet.kt
+++ b/app/src/main/java/com/github/libretube/ui/sheets/PlayingQueueSheet.kt
@@ -8,6 +8,8 @@ import androidx.fragment.app.setFragmentResult
import androidx.media3.common.Player
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
+import com.github.libretube.api.obj.WatchHistoryEntry
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.QueueBottomSheetBinding
import com.github.libretube.db.DatabaseHelper
@@ -15,6 +17,7 @@ import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.WatchPosition
import com.github.libretube.extensions.setActionListener
import com.github.libretube.extensions.toID
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.adapters.PlayingQueueAdapter
import com.github.libretube.ui.dialogs.AddToPlaylistDialog
import com.github.libretube.util.PlayingQueue
@@ -23,6 +26,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlin.time.Clock
class PlayingQueueSheet : ExpandedBottomSheet(R.layout.queue_bottom_sheet) {
private var _binding: QueueBottomSheetBinding? = null
@@ -166,8 +170,19 @@ class PlayingQueueSheet : ExpandedBottomSheet(R.layout.queue_bottom_sheet) {
PlayingQueue.getStreams().forEach {
val videoId = it.url.orEmpty().toID()
val duration = it.duration ?: 0
- val watchPosition = WatchPosition(videoId, duration * 1000)
- DatabaseHolder.Database.watchPositionDao().insert(watchPosition)
+ val watchHistoryEntry = WatchHistoryEntry(
+ video = it,
+ metadata = WatchHistoryEntryMetadata(
+ videoId = videoId,
+ finished = true,
+ positionMillis = duration * 1000,
+ addedDate = Clock.System.now().toEpochMilliseconds()
+ )
+ )
+ runCatching {
+ UserDataRepositoryHelper.userDataRepository
+ .addToWatchHistory(watchHistoryEntry)
+ }
}
}
}
@@ -175,8 +190,11 @@ class PlayingQueueSheet : ExpandedBottomSheet(R.layout.queue_bottom_sheet) {
1 -> {
CoroutineScope(Dispatchers.IO).launch {
PlayingQueue.getStreams().forEach {
- DatabaseHolder.Database.watchPositionDao()
- .deleteByVideoId(it.url.orEmpty().toID())
+ val videoId = it.url.orEmpty().toID()
+ runCatching {
+ UserDataRepositoryHelper.userDataRepository
+ .removeFromWatchHistory(videoId)
+ }
}
}
}
diff --git a/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt
index 6125198f88..0c57a9b041 100644
--- a/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt
+++ b/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt
@@ -1,12 +1,13 @@
package com.github.libretube.ui.sheets
import android.os.Bundle
+import androidx.annotation.StringRes
import androidx.core.os.bundleOf
+import androidx.lifecycle.lifecycleScope
import com.github.libretube.R
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.constants.IntentData
-import com.github.libretube.db.DatabaseHolder
import com.github.libretube.enums.ImportFormat
import com.github.libretube.enums.PlaylistType
import com.github.libretube.enums.ShareObjectType
@@ -17,6 +18,7 @@ import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.helpers.ContextHelper
import com.github.libretube.helpers.DownloadHelper
import com.github.libretube.obj.ShareData
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.DeletePlaylistDialog
@@ -26,7 +28,7 @@ import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.preferences.BackupRestoreSettings
import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PlaylistOptionsBottomSheet : BaseBottomSheet() {
@@ -36,34 +38,22 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
private var exportFormat: ImportFormat = ImportFormat.NEWPIPE
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- arguments?.let {
- playlistName = it.getString(IntentData.playlistName)!!
- playlistId = it.getString(IntentData.playlistId)!!
- playlistType = it.serializable(IntentData.playlistType)!!
- }
-
- setTitle(playlistName)
-
+ private fun buildOptionsList(isBookmarked: Boolean?): List {
// options for the dialog
val optionsList = mutableListOf(R.string.playOnBackground, R.string.download)
if (PlayingQueue.isNotEmpty()) optionsList.add(R.string.add_to_queue)
- val isBookmarked = runBlocking(Dispatchers.IO) {
- DatabaseHolder.Database.playlistBookmarkDao().includes(playlistId)
- }
-
if (playlistType == PlaylistType.PUBLIC) {
optionsList.add(R.string.share)
optionsList.add(R.string.clonePlaylist)
// only add the bookmark option to the playlist if public
- optionsList.add(
- if (isBookmarked) R.string.remove_bookmark else R.string.add_to_bookmarks
- )
+ if (isBookmarked != null) {
+ optionsList.add(
+ if (isBookmarked) R.string.remove_bookmark else R.string.add_to_bookmarks
+ )
+ }
} else {
optionsList.add(R.string.export_playlist)
optionsList.add(R.string.renamePlaylist)
@@ -71,118 +61,156 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
optionsList.add(R.string.deletePlaylist)
}
- setSimpleItems(optionsList.map { getString(it) }) { which ->
- val mFragmentManager = (context as BaseActivity).supportFragmentManager
-
- when (optionsList[which]) {
- // play the playlist in the background
- R.string.playOnBackground -> {
- val playlist = withContext(Dispatchers.IO) {
- runCatching { PlaylistsHelper.getPlaylist(playlistId) }
- }.getOrElse {
- context?.toastFromMainDispatcher(R.string.error)
- return@setSimpleItems
- }
+ return optionsList
+ }
- playlist.relatedStreams.firstOrNull()?.let {
- BackgroundHelper.playOnBackground(
- requireContext(),
- it.url!!.toID(),
- playlistId = playlistId
- )
- }
+ private suspend fun onOptionSelected(@StringRes stringResId: Int, isBookmarked: Boolean) {
+ val mFragmentManager = (context as BaseActivity).supportFragmentManager
+
+ when (stringResId) {
+ // play the playlist in the background
+ R.string.playOnBackground -> {
+ val playlist = withContext(Dispatchers.IO) {
+ runCatching { PlaylistsHelper.getPlaylist(playlistId, playlistType) }
+ }.getOrElse {
+ context?.toastFromMainDispatcher(R.string.error)
+ return
}
- R.string.add_to_queue -> {
- PlayingQueue.insertPlaylist(playlistId, null)
- }
- // Clone the playlist to the users Piped account
- R.string.clonePlaylist -> {
- val context = requireContext()
- val playlistId = withContext(Dispatchers.IO) {
- runCatching {
- PlaylistsHelper.clonePlaylist(playlistId)
- }.getOrNull()
- }
- context.toastFromMainDispatcher(
- if (playlistId != null) R.string.playlistCloned else R.string.server_error
- )
- }
- // share the playlist
- R.string.share -> {
- val newShareDialog = ShareDialog()
- newShareDialog.arguments = bundleOf(
- IntentData.id to playlistId,
- IntentData.shareObjectType to ShareObjectType.PLAYLIST,
- IntentData.shareData to ShareData(currentPlaylist = playlistName)
+ playlist.relatedStreams.firstOrNull()?.let {
+ BackgroundHelper.playOnBackground(
+ requireContext(),
+ it.url!!.toID(),
+ playlistId = playlistId
)
- // using parentFragmentManager, childFragmentManager doesn't work here
- newShareDialog.show(parentFragmentManager, ShareDialog::class.java.name)
}
+ }
- R.string.deletePlaylist -> {
- val newDeletePlaylistDialog = DeletePlaylistDialog()
- newDeletePlaylistDialog.arguments = bundleOf(
- IntentData.playlistId to playlistId
- )
- newDeletePlaylistDialog.show(mFragmentManager, null)
+ R.string.add_to_queue -> {
+ PlayingQueue.insertPlaylist(playlistId, playlistType, null)
+ }
+ // Clone the playlist to the users Piped account
+ R.string.clonePlaylist -> {
+ val context = requireContext()
+ val playlistId = withContext(Dispatchers.IO) {
+ runCatching {
+ PlaylistsHelper.clonePlaylist(playlistId)
+ }.getOrNull()
}
+ context.toastFromMainDispatcher(
+ if (playlistId != null) R.string.playlistCloned else R.string.server_error
+ )
+ }
+ // share the playlist
+ R.string.share -> {
+ val newShareDialog = ShareDialog()
+ newShareDialog.arguments = bundleOf(
+ IntentData.id to playlistId,
+ IntentData.shareObjectType to ShareObjectType.PLAYLIST,
+ IntentData.shareData to ShareData(currentPlaylist = playlistName)
+ )
+ // using parentFragmentManager, childFragmentManager doesn't work here
+ newShareDialog.show(parentFragmentManager, ShareDialog::class.java.name)
+ }
- R.string.renamePlaylist -> {
- val newRenamePlaylistDialog = RenamePlaylistDialog()
- newRenamePlaylistDialog.arguments = bundleOf(
- IntentData.playlistId to playlistId,
- IntentData.playlistName to playlistName
- )
- newRenamePlaylistDialog.show(mFragmentManager, null)
- }
+ R.string.deletePlaylist -> {
+ val newDeletePlaylistDialog = DeletePlaylistDialog()
+ newDeletePlaylistDialog.arguments = bundleOf(
+ IntentData.playlistId to playlistId
+ )
+ newDeletePlaylistDialog.show(mFragmentManager, null)
+ }
- R.string.change_playlist_description -> {
- val newPlaylistDescriptionDialog = PlaylistDescriptionDialog()
- newPlaylistDescriptionDialog.arguments = bundleOf(
- IntentData.playlistId to playlistId,
- IntentData.playlistDescription to ""
- )
- newPlaylistDescriptionDialog.show(mFragmentManager, null)
- }
+ R.string.renamePlaylist -> {
+ val newRenamePlaylistDialog = RenamePlaylistDialog()
+ newRenamePlaylistDialog.arguments = bundleOf(
+ IntentData.playlistId to playlistId,
+ IntentData.playlistName to playlistName
+ )
+ newRenamePlaylistDialog.show(mFragmentManager, null)
+ }
- R.string.download -> {
- DownloadHelper.startDownloadPlaylistDialog(
- requireContext(),
- mFragmentManager,
- playlistId,
- playlistName,
- playlistType
- )
+ R.string.change_playlist_description -> {
+ val newPlaylistDescriptionDialog = PlaylistDescriptionDialog()
+ newPlaylistDescriptionDialog.arguments = bundleOf(
+ IntentData.playlistId to playlistId,
+ IntentData.playlistDescription to ""
+ )
+ newPlaylistDescriptionDialog.show(mFragmentManager, null)
+ }
+
+ R.string.download -> {
+ DownloadHelper.startDownloadPlaylistDialog(
+ requireContext(),
+ mFragmentManager,
+ playlistId,
+ playlistName,
+ playlistType
+ )
+ }
+
+ R.string.export_playlist -> {
+ val context = requireContext()
+
+ BackupRestoreSettings.createImportFormatDialog(
+ context,
+ R.string.export_playlist,
+ BackupRestoreSettings.exportPlaylistFormatList + listOf(ImportFormat.URLSORIDS)
+ ) { format, includeTimestamp ->
+ exportFormat = format
+ ContextHelper.unwrapActivity(context)
+ .startPlaylistExport(
+ playlistId,
+ playlistName,
+ exportFormat,
+ includeTimestamp
+ )
}
+ }
- R.string.export_playlist -> {
- val context = requireContext()
-
- BackupRestoreSettings.createImportFormatDialog(
- context,
- R.string.export_playlist,
- BackupRestoreSettings.exportPlaylistFormatList + listOf(ImportFormat.URLSORIDS)
- ) { format, includeTimestamp ->
- exportFormat = format
- ContextHelper.unwrapActivity(context)
- .startPlaylistExport(playlistId, playlistName, exportFormat, includeTimestamp)
+ else -> {
+ withContext(Dispatchers.IO) {
+ if (isBookmarked) {
+ UserDataRepositoryHelper.userDataRepository.deletePlaylistBookmark(
+ playlistId
+ )
+ } else {
+ val bookmark = runCatching {
+ MediaServiceRepository.instance.getPlaylist(playlistId)
+ }.getOrElse { return@withContext }.toPlaylistBookmark(playlistId)
+ UserDataRepositoryHelper.userDataRepository.createPlaylistBookmark(bookmark)
}
}
+ }
+ }
+ }
- else -> {
- withContext(Dispatchers.IO) {
- if (isBookmarked) {
- DatabaseHolder.Database.playlistBookmarkDao().deleteById(playlistId)
- } else {
- val bookmark = try {
- MediaServiceRepository.instance.getPlaylist(playlistId)
- } catch (e: Exception) {
- return@withContext
- }.toPlaylistBookmark(playlistId)
- DatabaseHolder.Database.playlistBookmarkDao().insert(bookmark)
- }
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ arguments?.let {
+ playlistName = it.getString(IntentData.playlistName)!!
+ playlistId = it.getString(IntentData.playlistId)!!
+ playlistType = it.serializable(IntentData.playlistType)!!
+ }
+
+ setTitle(playlistName)
+
+ val optionsList = buildOptionsList(null)
+ setSimpleItems(optionsList.map { getString(it) }) { which ->
+ onOptionSelected(optionsList[which], false)
+ }
+
+ // we have to update the options list as soon as we know whether the playlist is bookmarked
+ lifecycleScope.launch(Dispatchers.IO) {
+ val isBookmarked = runCatching {
+ UserDataRepositoryHelper.userDataRepository.getPlaylistBookmark(playlistId)
+ }.getOrNull() != null
+
+ withContext(Dispatchers.Main) {
+ val optionsList = buildOptionsList(isBookmarked)
+ setSimpleItems(optionsList.map { getString(it) }) { which ->
+ onOptionSelected(optionsList[which], isBookmarked)
}
}
}
diff --git a/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt
index bef9170067..baa3f70d06 100644
--- a/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt
+++ b/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt
@@ -1,11 +1,14 @@
package com.github.libretube.ui.sheets
import android.os.Bundle
+import androidx.annotation.StringRes
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.NavHostFragment
import com.github.libretube.R
import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.api.obj.WatchHistoryEntryMetadata
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHelper
@@ -14,11 +17,13 @@ import com.github.libretube.db.obj.WatchPosition
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.toID
+import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.DownloadHelper
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.ShareData
+import com.github.libretube.repo.UserDataRepositoryHelper
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.dialogs.AddToPlaylistDialog
import com.github.libretube.ui.dialogs.ShareDialog
@@ -26,7 +31,7 @@ import com.github.libretube.ui.fragments.SubscriptionsFragment
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.PlayingQueueMode
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
@@ -37,107 +42,127 @@ import kotlinx.coroutines.withContext
class VideoOptionsBottomSheet : BaseBottomSheet() {
private lateinit var streamItem: StreamItem
- override fun onCreate(savedInstanceState: Bundle?) {
- streamItem = arguments?.parcelable(IntentData.streamItem)!!
- val playlistId = arguments?.getString(IntentData.playlistId)
+ private suspend fun onOptionSelect(@StringRes option: Int, videoId: String, playlistId: String?) {
+ when (option) {
+ // Start the background mode
+ R.string.playOnBackground -> {
+ NavigationHelper.navigateVideo(
+ requireContext(),
+ videoId = videoId,
+ playlistId = playlistId,
+ audioOnlyPlayerRequested = true
+ )
+ }
+ // Add Video to Playlist Dialog
+ R.string.addToPlaylist -> {
+ AddToPlaylistDialog().apply {
+ arguments = bundleOf(IntentData.videoInfo to streamItem)
+ }.show(
+ parentFragmentManager,
+ AddToPlaylistDialog::class.java.name
+ )
+ }
- val videoId = streamItem.url?.toID() ?: return
+ R.string.download -> {
+ DownloadHelper.startDownloadDialog(
+ requireContext(),
+ parentFragmentManager,
+ videoId
+ )
+ }
- setTitle(streamItem.title)
+ R.string.share -> {
+ val bundle = bundleOf(
+ IntentData.id to videoId,
+ IntentData.shareObjectType to ShareObjectType.VIDEO,
+ IntentData.shareData to ShareData(currentVideo = streamItem.title)
+ )
+ val newShareDialog = ShareDialog()
+ newShareDialog.arguments = bundle
+ // using parentFragmentManager is important here
+ newShareDialog.show(parentFragmentManager, ShareDialog::class.java.name)
+ }
- val optionsList = mutableListOf()
- // these options are only available for other videos than the currently playing one
- if (PlayingQueue.getCurrent()?.url?.toID() != videoId) {
- optionsList += getOptionsForNotActivePlayback(videoId)
- }
+ R.string.play_next -> {
+ PlayingQueue.addAsNext(streamItem)
+ }
- optionsList += listOf(R.string.addToPlaylist, R.string.download, R.string.share)
- if (streamItem.isLive) optionsList.remove(R.string.download)
+ R.string.add_to_queue -> {
+ PlayingQueue.add(streamItem)
+ }
- setSimpleItems(optionsList.map { getString(it) }) { which ->
- when (optionsList[which]) {
- // Start the background mode
- R.string.playOnBackground -> {
- NavigationHelper.navigateVideo(
- requireContext(),
- videoId = videoId,
- playlistId = playlistId,
- audioOnlyPlayerRequested = true
- )
+ R.string.mark_as_watched -> {
+ val watchPosition = WatchHistoryEntryMetadata(
+ videoId = videoId,
+ addedDate = -1,
+ finished = true,
+ positionMillis = Long.MAX_VALUE
+ )
+ withContext(Dispatchers.IO) {
+ UserDataRepositoryHelper.userDataRepository
+ .updateWatchHistoryEntry(watchPosition)
+
+ if (PlayerHelper.watchHistoryEnabled) {
+ DatabaseHelper.addToWatchHistory(streamItem)
+ }
}
- // Add Video to Playlist Dialog
- R.string.addToPlaylist -> {
- AddToPlaylistDialog().apply {
- arguments = bundleOf(IntentData.videoInfo to streamItem)
- }.show(
- parentFragmentManager,
- AddToPlaylistDialog::class.java.name
- )
+ if (PreferenceHelper.getBoolean(PreferenceKeys.HIDE_WATCHED_FROM_FEED, false)) {
+ // get the host fragment containing the current fragment
+ val navHostFragment = (context as MainActivity).supportFragmentManager
+ .findFragmentById(R.id.fragment) as NavHostFragment?
+ // get the current fragment
+ val fragment = navHostFragment?.childFragmentManager?.fragments
+ ?.firstOrNull() as? SubscriptionsFragment
+ fragment?.removeItem(videoId)
}
+ setFragmentResult(VIDEO_OPTIONS_SHEET_REQUEST_KEY, bundleOf())
+ }
- R.string.download -> {
- DownloadHelper.startDownloadDialog(
- requireContext(),
- parentFragmentManager,
- videoId
- )
+ R.string.mark_as_unwatched -> {
+ withContext(Dispatchers.IO) {
+ try {
+ UserDataRepositoryHelper.userDataRepository.removeFromWatchHistory(
+ videoId
+ )
+ } catch (e: Exception) {
+ context?.toastFromMainDispatcher(e.message.orEmpty())
+ }
}
+ setFragmentResult(VIDEO_OPTIONS_SHEET_REQUEST_KEY, bundleOf())
+ }
+ }
+ }
- R.string.share -> {
- val bundle = bundleOf(
- IntentData.id to videoId,
- IntentData.shareObjectType to ShareObjectType.VIDEO,
- IntentData.shareData to ShareData(currentVideo = streamItem.title)
- )
- val newShareDialog = ShareDialog()
- newShareDialog.arguments = bundle
- // using parentFragmentManager is important here
- newShareDialog.show(parentFragmentManager, ShareDialog::class.java.name)
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ streamItem = arguments?.parcelable(IntentData.streamItem)!!
+ val playlistId = arguments?.getString(IntentData.playlistId)
- R.string.play_next -> {
- PlayingQueue.addAsNext(streamItem)
- }
+ val videoId = streamItem.url?.toID() ?: return
- R.string.add_to_queue -> {
- PlayingQueue.add(streamItem)
- }
+ setTitle(streamItem.title)
- R.string.mark_as_watched -> {
- val watchPosition = WatchPosition(videoId, Long.MAX_VALUE)
- withContext(Dispatchers.IO) {
- DatabaseHolder.Database.watchPositionDao().insert(watchPosition)
+ val optionsList = mutableListOf(R.string.addToPlaylist, R.string.download, R.string.share)
+ if (streamItem.isLive) optionsList.remove(R.string.download)
- if (PlayerHelper.watchHistoryEnabled) {
- DatabaseHelper.addToWatchHistory(streamItem.toWatchHistoryItem(videoId))
- }
- }
- if (PreferenceHelper.getBoolean(PreferenceKeys.HIDE_WATCHED_FROM_FEED, false)) {
- // get the host fragment containing the current fragment
- val navHostFragment = (context as MainActivity).supportFragmentManager
- .findFragmentById(R.id.fragment) as NavHostFragment?
- // get the current fragment
- val fragment = navHostFragment?.childFragmentManager?.fragments
- ?.firstOrNull() as? SubscriptionsFragment
- fragment?.removeItem(videoId)
- }
- setFragmentResult(VIDEO_OPTIONS_SHEET_REQUEST_KEY, bundleOf())
- }
+ // these options are only available for other videos than the currently playing one
+ if (PlayingQueue.getCurrent()?.url?.toID() != videoId) {
+ getOptionsForNotActivePlayback(videoId) { addedOptions ->
+ val optionsList = optionsList + addedOptions
- R.string.mark_as_unwatched -> {
- withContext(Dispatchers.IO) {
- DatabaseHolder.Database.watchPositionDao().deleteByVideoId(videoId)
- DatabaseHolder.Database.watchHistoryDao().deleteByVideoId(videoId)
- }
- setFragmentResult(VIDEO_OPTIONS_SHEET_REQUEST_KEY, bundleOf())
+ setSimpleItems(optionsList.map { getString(it) }) { which ->
+ onOptionSelect(optionsList[which], videoId, playlistId)
}
}
+ } else {
+ setSimpleItems(optionsList.map { getString(it) }) { which ->
+ onOptionSelect(optionsList[which], videoId, playlistId)
+ }
}
super.onCreate(savedInstanceState)
}
- private fun getOptionsForNotActivePlayback(videoId: String): List {
+ private fun getOptionsForNotActivePlayback(videoId: String, onOptionsList: (List) -> Unit) {
// List that stores the different menu options. In the future could be add more options here.
val optionsList = mutableListOf(R.string.playOnBackground)
@@ -149,22 +174,25 @@ class VideoOptionsBottomSheet : BaseBottomSheet() {
// show the mark as watched or unwatched option if watch positions are enabled
if (PlayerHelper.watchPositionsAny || PlayerHelper.watchHistoryEnabled) {
- val watchHistoryEntry = runBlocking(Dispatchers.IO) {
- DatabaseHolder.Database.watchHistoryDao().findById(videoId)
- }
+ lifecycleScope.launch(Dispatchers.IO) {
+ val watchHistoryEntry =
+ UserDataRepositoryHelper.userDataRepository.getFromWatchHistory(videoId)
- val position = DatabaseHelper.getWatchPositionBlocking(videoId) ?: 0
- val isCompleted = DatabaseHelper.isVideoWatched(position, streamItem.duration ?: 0)
- if (position != 0L || watchHistoryEntry != null) {
- optionsList += R.string.mark_as_unwatched
- }
+ if (watchHistoryEntry != null) {
+ optionsList += R.string.mark_as_unwatched
+ }
- if (!isCompleted || watchHistoryEntry == null) {
- optionsList += R.string.mark_as_watched
+ if (watchHistoryEntry == null) {
+ optionsList += R.string.mark_as_watched
+ }
+
+ withContext(Dispatchers.Main) {
+ onOptionsList(optionsList)
+ }
}
}
- return optionsList
+ onOptionsList(optionsList)
}
companion object {
diff --git a/app/src/main/java/com/github/libretube/ui/views/ButtonGroupPreference.kt b/app/src/main/java/com/github/libretube/ui/views/ButtonGroupPreference.kt
new file mode 100644
index 0000000000..bd1bf5819f
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/ui/views/ButtonGroupPreference.kt
@@ -0,0 +1,88 @@
+package com.github.libretube.ui.views
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.util.AttributeSet
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.content.withStyledAttributes
+import androidx.core.view.children
+import androidx.preference.Preference
+import androidx.preference.PreferenceViewHolder
+import com.github.libretube.R
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.button.MaterialButtonToggleGroup
+
+class ButtonGroupPreference(context: Context, val attrs: AttributeSet?): Preference(context, attrs) {
+ private lateinit var entries: Array
+ private lateinit var entryValues: Array
+
+ init {
+ layoutResource = R.layout.preference_button_group
+
+
+ // obtain custom style attributes
+ context.withStyledAttributes(attrs, R.styleable.ButtonGroupPreference, 0, 0) {
+ entries = getTextArray(R.styleable.ButtonGroupPreference_optionEntries)!!
+ entryValues = getTextArray(R.styleable.ButtonGroupPreference_optionValues)!!
+
+ assert(entries.size == entryValues.size)
+ }
+ }
+
+ private var defaultValue = ""
+ val value get() = getPersistedString(defaultValue)!!
+
+ override fun onBindViewHolder(holder: PreferenceViewHolder) {
+ super.onBindViewHolder(holder)
+ val view = holder.itemView
+
+ // default preference stuff
+ view.findViewById(android.R.id.title).text = title
+ view.findViewById(android.R.id.icon).setImageDrawable(icon)
+
+ val buttonGroup = view.findViewById(R.id.button_group)
+ buttonGroup.removeAllViews()
+
+ // otherwise the buttonGroup.check below triggers the listener
+ buttonGroup.clearOnButtonCheckedListeners()
+
+ // add one button for each option to the view
+ for ((title, v) in entries.zip(entryValues)) {
+ // custom button styling is tricky, see https://stackoverflow.com/questions/60590968/set-materialbutton-style-to-textbutton-programmatically
+ val button = MaterialButton(context, null, R.attr.tonalButtonStyleCustomAttr).also {
+ it.id = View.generateViewId()
+ it.layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ it.text = title
+ }
+ buttonGroup.addView(button)
+
+ // calling MaterialButton#setChecked breaks everything, we have to call
+ // MaterialButtonGroup#check instead so that the button group works properly
+ if (value == v) buttonGroup.check(button.id)
+ }
+
+ buttonGroup.addOnButtonCheckedListener { _, id, isChecked ->
+ if (!isChecked) return@addOnButtonCheckedListener
+
+ val i = buttonGroup.children.indexOfFirst { it.id == id }
+ val newValue = entryValues[i].toString()
+
+ val confirmed = onPreferenceChangeListener?.onPreferenceChange(this, newValue) != false
+ if (confirmed) {
+ persistString(newValue)
+ }
+ }
+ }
+
+ override fun onGetDefaultValue(ta: TypedArray, index: Int): Any {
+ // Get the default value from the XML attribute, if specified
+ defaultValue = ta.getString(index).orEmpty()
+ return defaultValue
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/libretube/ui/views/SbSpinnerPreference.kt b/app/src/main/java/com/github/libretube/ui/views/SbSpinnerPreference.kt
index 46d2033810..45f59f2d8a 100644
--- a/app/src/main/java/com/github/libretube/ui/views/SbSpinnerPreference.kt
+++ b/app/src/main/java/com/github/libretube/ui/views/SbSpinnerPreference.kt
@@ -11,7 +11,6 @@ import android.widget.TextView
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.github.libretube.R
-import com.github.libretube.helpers.PreferenceHelper
class SbSpinnerPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs) {
private lateinit var adapter: ArrayAdapter
@@ -40,7 +39,7 @@ class SbSpinnerPreference(context: Context, attrs: AttributeSet) : Preference(co
spinner.onItemSelectedListener = null
// Set the initial selected item
- PreferenceHelper.getString(key, initialValue).let { selectedItem ->
+ getPersistedString(initialValue).let { selectedItem ->
val position = getEntryValues().indexOf(selectedItem)
spinner.setSelection(position)
}
diff --git a/app/src/main/java/com/github/libretube/util/PauseableTimer.kt b/app/src/main/java/com/github/libretube/util/PauseableTimer.kt
deleted file mode 100644
index e90644f9e0..0000000000
--- a/app/src/main/java/com/github/libretube/util/PauseableTimer.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.github.libretube.util
-
-import android.os.Handler
-import android.os.Looper
-import java.util.Timer
-import java.util.TimerTask
-
-class PauseableTimer(
- private val onTick: () -> Unit,
- private val delayMillis: Long = 1000L
-) {
- val handler: Handler = Handler(Looper.getMainLooper())
-
- var timer: Timer? = null
-
- init {
- resume()
- }
-
- fun resume() {
- if (timer == null) timer = Timer()
-
- timer?.scheduleAtFixedRate(
- object : TimerTask() {
- override fun run() {
- handler.post(onTick)
- }
- },
- delayMillis,
- delayMillis
- )
- }
-
- fun pause() {
- timer?.cancel()
- timer = null
- }
-
- fun destroy() {
- timer?.cancel()
- timer = null
- }
-}
diff --git a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt
index 3e97a82e8e..b65174f422 100644
--- a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt
+++ b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt
@@ -4,6 +4,7 @@ import androidx.media3.common.Player
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.obj.StreamItem
+import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.move
import com.github.libretube.extensions.runCatchingIO
import com.github.libretube.extensions.toID
@@ -188,8 +189,8 @@ object PlayingQueue {
}
}
- fun insertPlaylist(playlistId: String, newCurrentStream: StreamItem?) = runCatchingIO {
- val playlist = PlaylistsHelper.getPlaylist(playlistId)
+ fun insertPlaylist(playlistId: String, playlistType: PlaylistType, newCurrentStream: StreamItem?) = runCatchingIO {
+ val playlist = PlaylistsHelper.getPlaylist(playlistId, playlistType)
val isMainList = newCurrentStream != null
addToQueueAsync(playlist.relatedStreams, newCurrentStream, isMainList)
if (playlist.nextpage == null) return@runCatchingIO
@@ -229,7 +230,8 @@ object PlayingQueue {
updateCurrent(streamItem)
if (playlistId != null) {
- insertPlaylist(playlistId, streamItem)
+ val playlistType = PlaylistsHelper.getPlaylistType(playlistId)
+ insertPlaylist(playlistId, playlistType, streamItem)
} else if (channelId != null) {
insertChannel(channelId, streamItem)
} else if (relatedStreams.isNotEmpty()) {
diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml
new file mode 100644
index 0000000000..bacab16f6b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_warning.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_welcome.xml b/app/src/main/res/layout/activity_welcome.xml
new file mode 100644
index 0000000000..a591413c45
--- /dev/null
+++ b/app/src/main/res/layout/activity_welcome.xml
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/preference_button_group.xml b/app/src/main/res/layout/preference_button_group.xml
new file mode 100644
index 0000000000..78b0ef48b6
--- /dev/null
+++ b/app/src/main/res/layout/preference_button_group.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index be522cfc22..6d1c68d480 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -16,6 +16,9 @@
- @style/Widget.Material3.LoadingIndicator.Contained
- @style/AlertDialogTheme
+
+
+ - @style/Widget.Material3Expressive.Button.TonalButton