From fcbab104346972098fb1c72f086c8a6e76ae33c1 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 30 Sep 2024 16:39:10 +0200 Subject: [PATCH] Sync implementation for subscriptions, tracking subscription removals, add fcast link to cast tutorial description. --- .../platformplayer/activities/MainActivity.kt | 8 +- .../mainactivity/main/TutorialFragment.kt | 2 +- .../platformplayer/models/Subscription.kt | 5 + .../futo/platformplayer/states/StateBackup.kt | 11 ++ .../states/StateSubscriptions.kt | 78 +++++++++++- .../futo/platformplayer/states/StateSync.kt | 34 ++++++ .../stores/StringDateMapStorage.kt | 65 ++++++++++ .../platformplayer/stores/v2/ManagedStore.kt | 8 ++ .../sync/internal/SyncSession.kt | 113 ++++++++++++++++-- .../views/adapters/SubscriptionAdapter.kt | 11 +- .../views/subscriptions/SubscribeButton.kt | 2 +- app/src/main/res/drawable/ic_sad.png | Bin 0 -> 2903 bytes 12 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/stores/StringDateMapStorage.kt create mode 100644 app/src/main/res/drawable/ic_sad.png diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index bd998d38..e4efd6c6 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -70,6 +70,7 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache +import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup @@ -731,7 +732,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } - suspend fun handleUrl(url: String): Boolean { + suspend fun handleUrl(url: String, position: Int = 0): Boolean { Logger.i(TAG, "handleUrl(url=$url)") return withContext(Dispatchers.IO) { @@ -739,7 +740,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (StatePlatform.instance.hasEnabledVideoClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found video client"); lifecycleScope.launch(Dispatchers.Main) { - navigate(_fragVideoDetail, url); + if(position > 0) + navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); + else + navigate(_fragVideoDetail, url); _fragVideoDetail.maximizeVideoDetail(true); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt index 5139c0f8..9fddac25 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt @@ -200,7 +200,7 @@ class TutorialFragment : MainFragment() { TutorialVideo( uuid = "94d36959-e3fc-4c24-a988-89147067a179", name = "Casting", - description = "Learn about casting in Grayjay. How do I show video on my TV?", + description = "Learn about casting in Grayjay. How do I show video on my TV?\nhttps://fcast.org/", thumbnailUrl = "https://releases.grayjay.app/tutorials/how-to-cast.jpg", videoUrl = "https://releases.grayjay.app/tutorials/how-to-cast.mp4", duration = 79 diff --git a/app/src/main/java/com/futo/platformplayer/models/Subscription.kt b/app/src/main/java/com/futo/platformplayer/models/Subscription.kt index 8ec98efc..e0c12463 100644 --- a/app/src/main/java/com/futo/platformplayer/models/Subscription.kt +++ b/app/src/main/java/com/futo/platformplayer/models/Subscription.kt @@ -15,6 +15,9 @@ import java.time.OffsetDateTime class Subscription { var channel: SerializedChannel; + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var creationTime: OffsetDateTime = OffsetDateTime.MIN; + //Settings var doNotifications: Boolean = false; var doFetchLive: Boolean = false; @@ -55,6 +58,8 @@ class Subscription { constructor(channel : SerializedChannel) { this.channel = channel; + if(this.creationTime == OffsetDateTime.MIN) + this.creationTime = OffsetDateTime.now(); } fun isChannel(url: String): Boolean { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt index bd428c6d..dcebaf95 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt @@ -35,6 +35,7 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.FileNotFoundException +import java.io.InputStream import java.time.OffsetDateTime import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -509,6 +510,16 @@ class StateBackup { } companion object { + fun fromZipBytes(str: ByteArrayInputStream): ExportStructure { + var zip: ZipInputStream? = null; + try { + zip = ZipInputStream(str); + return fromZip(zip); + } + finally { + zip?.close(); + } + } fun fromZip(zipStream: ZipInputStream): ExportStructure { var entry: ZipEntry? diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index da54578d..8e69d487 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -18,13 +18,22 @@ import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringDateMapStorage +import com.futo.platformplayer.stores.StringStringMapStorage 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.SyncSubscriptionsPackage +import com.google.gson.JsonSerializer import kotlinx.coroutines.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.LocalDateTime import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.concurrent.ForkJoinPool import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -45,6 +54,9 @@ class StateSubscriptions { private val _subscriptionOthers = FragmentedStorage.storeJson("subscriptions_others") .withUnique { it.channel.url } .load(); + private val _subscriptionsRemoved = FragmentedStorage.get("subscriptions_removed"); + + private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency()); private val _legacySubscriptions = FragmentedStorage.get(); @@ -222,18 +234,67 @@ class StateSubscriptions { fun getSubscriptions(): List { return _subscriptions.getItems(); } + fun getSubscriptionRemovals(): Map { + return _subscriptionsRemoved.all(); + } + fun getSubscriptionRemovalTime(url: String): OffsetDateTime{ + return _subscriptionsRemoved.get(url) ?: OffsetDateTime.MIN; + } - fun addSubscription(channel : IPlatformChannel) : Subscription { + fun addSubscription(channel : IPlatformChannel, creationDate: OffsetDateTime? = null) : Subscription { val subObj = Subscription(SerializedChannel.fromChannel(channel)); + if(creationDate != null) + subObj.creationTime = creationDate; _subscriptions.save(subObj); onSubscriptionsChanged.emit(getSubscriptions(), true); + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + StateSync.instance.broadcast( + GJSyncOpcodes.syncSubscriptions, Json.encodeToString( + SyncSubscriptionsPackage( + listOf(subObj), + mapOf() + ) + ) + ); + } + catch(ex: Exception) { + Logger.w(TAG, "Failed to send subs changes to sync clients", ex); + } + } + return subObj; } - fun removeSubscription(url: String) : Subscription? { + + fun applySubscriptionRemovals(removals: Map): List { + val removed = mutableListOf() + val subs = getSubscriptions().associate { Pair(it.channel.url.lowercase(), it) }; + for(removal in removals) { + if(subs.containsKey(removal.key.lowercase())) { + val sub = subs[removal.key.lowercase()]; + val datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(removal.value, 0, ZoneOffset.UTC), ZoneOffset.UTC); + if(datetime > sub!!.creationTime) + { + removeSubscription(sub.channel.url); + removed.add(sub); + } + } + } + _subscriptionsRemoved.setAllAndSave(removals) { key, value, oldValue -> + return@setAllAndSave oldValue == null || value > oldValue; + } + return removed; + } + + + fun removeSubscription(url: String, isUserAction: Boolean = false) : Subscription? { var sub : Subscription? = getSubscription(url); if(sub != null) { _subscriptions.delete(sub); onSubscriptionsChanged.emit(getSubscriptions(), false); + if(isUserAction) + _subscriptionsRemoved.setAndSave(sub.channel.url, OffsetDateTime.now()); } return sub; } @@ -328,6 +389,9 @@ class StateSubscriptions { fun toMigrateCheck(): List> { return listOf(_subscriptions); } + fun getUnderlyingSubscriptionsStore(): ManagedStore { + return _subscriptions; + } //Old migrate fun shouldMigrate(): Boolean { @@ -346,6 +410,16 @@ class StateSubscriptions { _legacySubscriptions.delete(); } + + fun getSyncSubscriptionsPackageString(): String{ + return Json.encodeToString( + SyncSubscriptionsPackage( + getSubscriptions(), + getSubscriptionRemovals() + ) + ); + } + companion object { const val TAG = "StateSubscriptions"; const val VERSION = 1; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 9b805aad..e0753dc5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -20,6 +20,7 @@ import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringStorage +import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.internal.SyncDeviceInfo import com.futo.platformplayer.sync.internal.SyncKeyPair import com.futo.platformplayer.sync.internal.SyncSession @@ -37,6 +38,7 @@ import java.net.ServerSocket import java.net.Socket import java.util.Base64 import java.util.Locale +import kotlin.system.measureTimeMillis class StateSync { private val _authorizedDevices = FragmentedStorage.get("authorized_devices") @@ -182,6 +184,11 @@ class StateSync { _sessions[publicKey] } } + fun getSessions(): List { + return synchronized(_sessions) { + return _sessions.values.toList() + }; + } private fun handleServiceUpdated(services: List) { if (!Settings.instance.synchronization.connectDiscovered) { @@ -260,6 +267,8 @@ class StateSync { _authorizedDevices.addDistinct(remotePublicKey) _authorizedDevices.save() deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!) + + checkForSync(it); }, onUnauthorized = { unauthorize(remotePublicKey) @@ -334,6 +343,31 @@ class StateSync { }) } + fun broadcast(opcode: UByte, data: String) { + broadcast(opcode, data.toByteArray(Charsets.UTF_8)); + } + fun broadcast(opcode: UByte, data: ByteArray) { + for(session in getSessions()) { + try { + if (session.isAuthorized && session.connected) { + session.send(opcode, data); + } + } + catch(ex: Exception) { + Logger.w(TAG, "Failed to broadcast ${opcode} to ${session.remotePublicKey}: ${ex.message}}", ex); + } + } + } + + fun checkForSync(session: SyncSession) { + val time = measureTimeMillis { + //val export = StateBackup.export(); + //session.send(GJSyncOpcodes.syncExport, export.asZip()); + session.send(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); + } + Logger.i(TAG, "Generated and sent sync export in ${time}ms"); + } + fun stop() { _started = false _serviceDiscoverer.stop() diff --git a/app/src/main/java/com/futo/platformplayer/stores/StringDateMapStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/StringDateMapStorage.kt new file mode 100644 index 00000000..f7b92e6e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/StringDateMapStorage.kt @@ -0,0 +1,65 @@ +package com.futo.platformplayer.stores + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.Dictionary +import java.util.concurrent.ConcurrentHashMap + +@kotlinx.serialization.Serializable +class StringDateMapStorage : FragmentedStorageFileJson() { + var map: HashMap = hashMapOf() + + override fun encode(): String { + synchronized(map) { + return Json.encodeToString(this); + } + } + + fun get(key: String): OffsetDateTime? { + synchronized(map) { + val v = map[key]; + if (v == null) + return null; + return OffsetDateTime.of( + LocalDateTime.ofEpochSecond(v, 0, ZoneOffset.UTC), + ZoneOffset.UTC + ); + } + } + fun has(key: String): Boolean { + return map.contains(key); + } + + fun all(): Map{ + synchronized(map) { + return map.toMap(); + } + } + + fun setAllAndSave(newValues: Map, condition: ((String, Long, Long?) -> Boolean)? = null) { + synchronized(map){ + for(kv in newValues){ + if(condition == null || condition(kv.key, kv.value, map.get(kv.key))) + map.set(kv.key, kv.value); + } + } + } + fun setAndSave(key: String, value: OffsetDateTime): OffsetDateTime { + synchronized(map) { + map[key] = value.toEpochSecond(); + save() + return value + } + } + + fun setAndSaveBlocking(key: String, value: OffsetDateTime): OffsetDateTime { + synchronized(map) { + map[key] = value.toEpochSecond(); + saveBlocking() + return value + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt b/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt index 96733484..cb10cd20 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt @@ -273,6 +273,14 @@ class ManagedStore{ save(obj, withReconstruction, onlyExisting); } + suspend fun fromReconstruction(reconstruction: String, cache: ImportCache? = null): T { + if(_reconstructStore == null) + throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type"); + + val id = UUID.randomUUID().toString(); + return _reconstructStore!!.toObjectWithHeader(id, reconstruction, ReconstructStore.Builder(), cache); + } + suspend fun createFromReconstruction(reconstruction: String, builder: ReconstructStore.Builder, cache: ImportCache? = null): String { if(_reconstructStore == null) throw IllegalStateException("Can't reconstruct as no reconstruction is implemented for this type"); 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 7f52b564..b0b54df8 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 @@ -2,12 +2,20 @@ package com.futo.platformplayer.sync.internal import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode +import com.futo.platformplayer.sync.models.SendToDevicePackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import java.io.ByteArrayInputStream import java.nio.ByteBuffer interface IAuthorizable { @@ -117,18 +125,105 @@ class SyncSession : IAuthorizable { Logger.i(TAG, "Received ${opcode} (${data.remaining()} bytes)") //TODO: Abstract this out - when(opcode) { - GJSyncOpcodes.sendToDevices -> { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - val context = StateApp.instance.contextOrNull; - if(context != null && context is MainActivity) { - val url = String(data.array(), Charsets.UTF_8); - UIDialogs.appToast("Received url from device [${socketSession.remotePublicKey}]:\n{$url}"); - context.handleUrl(url); + try { + when (opcode) { + GJSyncOpcodes.sendToDevices -> { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + val context = StateApp.instance.contextOrNull; + if (context != null && context is MainActivity) { + val dataBody = ByteArray(data.remaining()); + val remainder = data.remaining(); + data.get(dataBody, 0, remainder); + val json = String(dataBody, Charsets.UTF_8); + val obj = Json.decodeFromString(json); + UIDialogs.appToast("Received url from device [${socketSession.remotePublicKey}]:\n{${obj.url}"); + context.handleUrl(obj.url, obj.position); + } + }; + } + + GJSyncOpcodes.syncExport -> { + val dataBody = ByteArray(data.remaining()); + val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining()); + try { + val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStr); + for (store in exportStruct.stores) { + if (store.key.equals("subscriptions", true)) { + val subStore = + StateSubscriptions.instance.getUnderlyingSubscriptionsStore(); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val pack = SyncSubscriptionsPackage( + store.value.map { + subStore.fromReconstruction(it, exportStruct.cache) + }, + StateSubscriptions.instance.getSubscriptionRemovals() + ); + handleSyncSubscriptionPackage(this@SyncSession, pack); + } + } + } + } finally { + bytesStr.close(); } - }; + } + + GJSyncOpcodes.syncSubscriptions -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val subPackage = Serializer.json.decodeFromString(json); + handleSyncSubscriptionPackage(this, subPackage); + } } } + catch(ex: Exception) { + Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex); + } + } + + private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { + val added = mutableListOf() + for(sub in pack.subscriptions) { + 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); + added.add(newSub); + } + } + } + if(added.size > 3) + UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); + else if(added.size > 0) + UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + + added.map { it.channel.name }.joinToString("\n")); + + + if(pack.subscriptions != null && pack.subscriptions.size > 0) { + for (subRemoved in pack.subscriptionRemovals) { + val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); + if(removed.size > 3) + UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); + else if(removed.size > 0) + UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + + removed.map { it.channel.name }.joinToString("\n")); + + } + } + } + + + fun send(opcode: UByte, data: String) { + send(opcode, data.toByteArray(Charsets.UTF_8)); + } + fun send(opcode: UByte, data: ByteArray) { + val sock = _socketSessions.firstOrNull(); + if(sock != null){ + sock.send(opcode, ByteBuffer.wrap(data)); + } + else + throw IllegalStateException("Session has no active sockets"); } private companion object { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt index 034b6e66..e3644cc3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt @@ -1,12 +1,16 @@ package com.futo.platformplayer.views.adapters +import android.os.Looper import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateSubscriptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class SubscriptionAdapter : RecyclerView.Adapter { private lateinit var _sortedDataset: List; @@ -30,7 +34,10 @@ class SubscriptionAdapter : RecyclerView.Adapter { _inflater = inflater; _confirmationMessage = confirmationMessage; - StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> updateDataset(); } + StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } + else + updateDataset(); } updateDataset(); } @@ -43,7 +50,7 @@ class SubscriptionAdapter : RecyclerView.Adapter { holder.onTrash.subscribe { val sub = holder.subscription ?: return@subscribe; UIDialogs.showConfirmationDialog(_inflater.context, _confirmationMessage, { - StateSubscriptions.instance.removeSubscription(sub.channel.url); + StateSubscriptions.instance.removeSubscription(sub.channel.url, true); }); }; holder.onSettings.subscribe { diff --git a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscribeButton.kt b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscribeButton.kt index 86b575b1..dbb9bfb6 100644 --- a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscribeButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscribeButton.kt @@ -79,7 +79,7 @@ class SubscribeButton : LinearLayout { } private fun handleUnSubscribe(url: String) { setIsLoading(false); - val removed = StateSubscriptions.instance.removeSubscription(url); + val removed = StateSubscriptions.instance.removeSubscription(url, true); if (removed != null) UIDialogs.toast(context, context.getString(R.string.unsubscribed_from) + removed.channel.name); setIsSubscribed(false); diff --git a/app/src/main/res/drawable/ic_sad.png b/app/src/main/res/drawable/ic_sad.png new file mode 100644 index 0000000000000000000000000000000000000000..46937131269c699545901e3cbf56745277565dbf GIT binary patch literal 2903 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