diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt index f2b4aa8d..c1863c64 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt @@ -180,7 +180,7 @@ class SubscriptionGroupFragment : MainFragment() { UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${g.name}]?", null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Delete", { - StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id); + StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id, true); _didDelete = true; fragment.close(true); }, UIDialogs.ActionStyle.DANGEROUS)) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt index 71402e60..a4924838 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupListFragment.kt @@ -60,7 +60,7 @@ class SubscriptionGroupListFragment : MainFragment() { val loc = _subs.indexOf(group); _subs.remove(group); _list?.adapter?.notifyItemRangeRemoved(loc); - StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id); + StateSubscriptionGroups.instance.deleteSubscriptionGroup(group.id, true); }; it.onDragDrop.subscribe { _touchHelper?.startDrag(it); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 30d0ab30..9c926575 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -111,9 +111,12 @@ import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.db.types.DBHistory +import com.futo.platformplayer.sync.internal.GJSyncOpcodes +import com.futo.platformplayer.sync.models.SendToDevicePackage import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanNowDiffString @@ -637,6 +640,27 @@ class VideoDetailView : ConstraintLayout { StatePlayer.instance.onVideoChanging.subscribe(this) { setVideoOverview(it); }; + + var hadDevice = false; + StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session -> + val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice(); + if(hasDevice != hadDevice) { + hadDevice = hasDevice; + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateMoreButtons(); + } + } + }; + StateSync.instance.deviceRemoved.subscribe(this) { id -> + val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice(); + if(hasDevice != hadDevice) { + hadDevice = hasDevice; + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateMoreButtons(); + } + } + } + MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() }; MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() }; MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() }; @@ -878,6 +902,22 @@ class VideoDetailView : ConstraintLayout { }; _slideUpOverlay?.hide(); }, + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) { + val devices = StateSync.instance.getSessions(); + val videoToSend = video ?: return@RoundButton; + if(devices.size > 1) { + //not implemented + } + else if(devices.size == 1){ + val device = devices.first(); + UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , { + fragment.lifecycleScope.launch(Dispatchers.IO) { + device.sendJson(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds/1000).toInt())); + } + }) + } + }} else null, RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") { reloadVideo(); _slideUpOverlay?.hide(); @@ -1025,6 +1065,8 @@ class VideoDetailView : ConstraintLayout { StateApp.instance.preventPictureInPicture.remove(this); StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onVideoChanging.remove(this); + StateSync.instance.deviceUpdatedOrAdded.remove(this); + StateSync.instance.deviceRemoved.remove(this); MediaControlReceiver.onLowerVolumeReceived.remove(this); MediaControlReceiver.onPlayReceived.remove(this); MediaControlReceiver.onPauseReceived.remove(this); @@ -2860,6 +2902,7 @@ class VideoDetailView : ConstraintLayout { const val TAG_OVERLAY = "overlay"; const val TAG_LIVECHAT = "livechat"; const val TAG_OPEN = "open"; + const val TAG_SEND_TO_DEVICE = "send_to_device"; const val TAG_MORE = "MORE"; private val _buttonPinStore = FragmentedStorage.get("videoPinnedButtons"); diff --git a/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt b/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt index c46aa3b8..b5f34415 100644 --- a/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt +++ b/app/src/main/java/com/futo/platformplayer/models/SubscriptionGroup.kt @@ -1,5 +1,7 @@ package com.futo.platformplayer.models +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import java.time.OffsetDateTime import java.util.UUID @kotlinx.serialization.Serializable @@ -10,6 +12,11 @@ open class SubscriptionGroup { var urls: MutableList = mutableListOf(); var priority: Int = 99; + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var lastChange : OffsetDateTime = OffsetDateTime.MIN; + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var creationTime : OffsetDateTime = OffsetDateTime.now(); + constructor(name: String) { this.name = name; } @@ -19,6 +26,8 @@ open class SubscriptionGroup { this.image = parent.image; this.urls = parent.urls; this.priority = parent.priority; + this.lastChange = parent.lastChange; + this.creationTime = parent.creationTime; } class Selectable(parent: SubscriptionGroup, isSelected: Boolean = false): SubscriptionGroup(parent) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index d5f53af4..b5ed8ea4 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -17,10 +17,18 @@ import com.futo.platformplayer.exceptions.ReconstructionException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage +import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ReconstructStore +import com.futo.platformplayer.sync.internal.GJSyncOpcodes +import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File @@ -45,6 +53,7 @@ class StatePlaylists { val playlistStore = FragmentedStorage.storeJson("playlists") .withRestore(PlaylistBackup()) .load(); + private val _playlistRemoved = FragmentedStorage.get("playlist_removed"); val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); @@ -118,6 +127,9 @@ class StatePlaylists { return playlistStore.findItem { it.id == id }; } + fun getPlaylistRemovals(): Map { + return _playlistRemoved.all(); + } fun didPlay(playlistId: String) { val playlist = getPlaylist(playlistId); @@ -148,13 +160,15 @@ class StatePlaylists { createOrUpdatePlaylist(newPlaylist); return newPlaylist; } - fun createOrUpdatePlaylist(playlist: Playlist) { + fun createOrUpdatePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) { playlist.dateUpdate = OffsetDateTime.now(); playlistStore.saveAsync(playlist, true); if(playlist.id.isNotEmpty()) { if (StateDownloads.instance.isPlaylistCached(playlist.id)) { StateDownloads.instance.checkForOutdatedPlaylistVideos(playlist.id); } + if(isUserInteraction) + broadcastSyncPlaylist(playlist); } } fun addToPlaylist(id: String, video: IPlatformVideo) { @@ -163,14 +177,41 @@ class StatePlaylists { playlist.videos.add(SerializedPlatformVideo.fromVideo(video)); playlist.dateUpdate = OffsetDateTime.now(); playlistStore.saveAsync(playlist, true); + + broadcastSyncPlaylist(playlist); } } - fun removePlaylist(playlist: Playlist) { + private fun broadcastSyncPlaylist(playlist: Playlist){ + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncPlaylists, + SyncPlaylistsPackage(listOf(playlist), mapOf()) + ); + } + }; + } + + fun removePlaylist(playlist: Playlist, isUserInteraction: Boolean = true) { playlistStore.delete(playlist); if(StateDownloads.instance.isPlaylistCached(playlist.id)) { StateDownloads.instance.deleteCachedPlaylist(playlist.id); } + if(isUserInteraction) { + _playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now()); + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncPlaylists, + SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond()))) + ); + } + }; + } } fun createPlaylistShareUri(context: Context, playlist: Playlist): Uri { @@ -194,6 +235,16 @@ class StatePlaylists { return FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), newFile); } + + fun getSyncPlaylistsPackageString(): String{ + return Json.encodeToString( + SyncPlaylistsPackage( + getPlaylists(), + getPlaylistRemovals() + ) + ); + } + companion object { val TAG = "StatePlaylists"; private var _instance : StatePlaylists? = null; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt index f77e2fad..416d106a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt @@ -25,13 +25,20 @@ import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.states.StateHistory.Companion import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms +import com.futo.platformplayer.sync.internal.GJSyncOpcodes +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import kotlinx.coroutines.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.time.OffsetDateTime import java.util.concurrent.ExecutionException import java.util.concurrent.ForkJoinPool @@ -51,6 +58,9 @@ class StateSubscriptionGroups { .withUnique { it.id } .load(); + + private val _groupsRemoved = FragmentedStorage.get("group_removed"); + val onGroupsChanged = Event0(); fun getSubscriptionGroup(id: String): SubscriptionGroup? { @@ -59,20 +69,58 @@ class StateSubscriptionGroups { fun getSubscriptionGroups(): List { return _subGroups.getItems(); } - fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false) { + fun getSubscriptionGroupsRemovals(): Map { + return _groupsRemoved.all(); + } + fun updateSubscriptionGroup(subGroup: SubscriptionGroup, preventNotify: Boolean = false, preventSync: Boolean = false) { + subGroup.lastChange = OffsetDateTime.now(); _subGroups.save(subGroup); if(!preventNotify) onGroupsChanged.emit(); + if(!preventSync) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncSubscriptionGroups, + SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf()) + ); + } + }; + } } - fun deleteSubscriptionGroup(id: String){ + fun deleteSubscriptionGroup(id: String, isUserInteraction: Boolean = true){ val group = getSubscriptionGroup(id); if(group != null) { _subGroups.delete(group); onGroupsChanged.emit(); + + if(isUserInteraction) { + _groupsRemoved.setAndSave(id, OffsetDateTime.now()); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + if(StateSync.instance.hasAtLeastOneOnlineDevice()) { + Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})"); + StateSync.instance.broadcastJson( + GJSyncOpcodes.syncSubscriptionGroups, + SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond()))) + ); + } + }; + } } } + fun getSyncSubscriptionGroupsPackageString(): String{ + return Json.encodeToString( + SyncSubscriptionGroupsPackage( + getSubscriptionGroups(), + getSubscriptionGroupsRemovals() + ) + ); + } + + companion object { const val TAG = "StateSubscriptionGroups"; const val VERSION = 1; diff --git a/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt b/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt index a6cef5d4..fdd9eebf 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/GJSyncOpcodes.kt @@ -11,5 +11,7 @@ class GJSyncOpcodes { val syncSubscriptions: UByte = 202.toUByte(); val syncHistory: UByte = 203.toUByte(); + val syncSubscriptionGroups: UByte = 204.toUByte(); + val syncPlaylists: UByte = 205.toUByte(); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 9f6b77f5..6c46f59d 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -6,15 +6,20 @@ import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.sync.SyncSessionData import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode import com.futo.platformplayer.sync.models.SendToDevicePackage +import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -22,7 +27,9 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.nio.ByteBuffer +import java.time.Instant import java.time.OffsetDateTime +import java.time.ZoneOffset interface IAuthorizable { val isAuthorized: Boolean @@ -158,6 +165,8 @@ class SyncSession : IAuthorizable { send(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); + send(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); + send(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); if(recentHistory.size > 0) @@ -205,6 +214,67 @@ class SyncSession : IAuthorizable { } } + GJSyncOpcodes.syncSubscriptionGroups -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + var lastSubgroupChange = OffsetDateTime.MIN; + for(group in pack.groups){ + if(group.lastChange > lastSubgroupChange) + lastSubgroupChange = group.lastChange; + + val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); + + if(existing == null) + StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); + else if(existing.lastChange < group.lastChange) { + existing.name = group.name; + existing.urls = group.urls; + existing.image = group.image; + existing.priority = group.priority; + existing.lastChange = group.lastChange; + StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); + } + } + for(removal in pack.groupRemovals) { + val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.creationTime < removalTime) + StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); + } + } + + GJSyncOpcodes.syncPlaylists -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + for(playlist in pack.playlists) { + val existing = StatePlaylists.instance.getPlaylist(playlist.id); + + if(existing == null) + StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); + else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) { + existing.dateUpdate = playlist.dateUpdate; + existing.name = playlist.name; + existing.videos = playlist.videos; + existing.dateCreation = playlist.dateCreation; + existing.datePlayed = playlist.datePlayed; + StatePlaylists.instance.createOrUpdatePlaylist(existing, false); + } + } + for(removal in pack.playlistRemovals) { + val creation = StatePlaylists.instance.getPlaylist(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.dateCreation < removalTime) + StatePlaylists.instance.removePlaylist(creation, false); + + } + } + GJSyncOpcodes.syncHistory -> { val dataBody = ByteArray(data.remaining()); data.get(dataBody); @@ -242,8 +312,7 @@ class SyncSession : IAuthorizable { if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); if(sub.creationTime > removalTime) { - val newSub = - StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); + val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); added.add(newSub); } } diff --git a/app/src/main/java/com/futo/platformplayer/sync/models/SyncPlaylistsPackage.kt b/app/src/main/java/com/futo/platformplayer/sync/models/SyncPlaylistsPackage.kt new file mode 100644 index 00000000..3d40057f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/models/SyncPlaylistsPackage.kt @@ -0,0 +1,14 @@ +package com.futo.platformplayer.sync.models + +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.models.SubscriptionGroup +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime +import java.util.Dictionary + +@Serializable +class SyncPlaylistsPackage( + var playlists: List, + var playlistRemovals: Map +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/models/SyncSubscriptionGroupsPackage.kt b/app/src/main/java/com/futo/platformplayer/sync/models/SyncSubscriptionGroupsPackage.kt new file mode 100644 index 00000000..663f6a7b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/models/SyncSubscriptionGroupsPackage.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.sync.models + +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.models.SubscriptionGroup +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime +import java.util.Dictionary + +@Serializable +class SyncSubscriptionGroupsPackage( + var groups: List, + var groupRemovals: Map +) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbc73061..e2ea0e96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -608,6 +608,7 @@ Do you want to convert channel {channelName} to a playlist? Failed to convert channel Page + Sync Video Hide Hide from Home Hide Creator from Home