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