diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/AdhocPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/AdhocPager.kt new file mode 100644 index 00000000..7e1554f7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/AdhocPager.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.structures + +class AdhocPager: IPager { + private var _page = 0; + private val _nextPage: (Int) -> List; + private var _currentResults: List = listOf(); + private var _hasMore = true; + + constructor(nextPage: (Int) -> List, initialResults: List? = null){ + _nextPage = nextPage; + if(initialResults != null) + _currentResults = initialResults; + else + nextPage(); + } + + override fun hasMorePages(): Boolean { + return _hasMore; + } + + override fun nextPage() { + val newResults = _nextPage(++_page); + if(newResults.isEmpty()) + _hasMore = false; + _currentResults = newResults; + } + + override fun getResults(): List { + return _currentResults; + } +} \ No newline at end of file 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 5a366550..66a8685a 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 @@ -75,6 +75,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.states.* import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage +import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.views.MonetizationView import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout import com.futo.platformplayer.views.casting.CastView @@ -125,6 +126,7 @@ class VideoDetailView : ConstraintLayout { var video: IPlatformVideoDetails? = null private set; private var _playbackTracker: IPlaybackTracker? = null; + private var _historyIndex: DBHistory.Index? = null; val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url; @@ -772,6 +774,15 @@ class VideoDetailView : ConstraintLayout { } } } + suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){ + val current = _historyIndex; + if(current == null || current.url != video.url) { + val index = StatePlaylists.instance.getHistoryByVideo(video, true); + _historyIndex = index; + return@withContext index; + } + return@withContext current; + } //Lifecycle @@ -1248,24 +1259,30 @@ class VideoDetailView : ConstraintLayout { updateQueueState(); - _historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, false, (toResume.toFloat() / 1000.0f).toLong()); - Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); - if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { - _layoutResume.visibility = View.VISIBLE; - _textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"; + fragment.lifecycleScope.launch(Dispatchers.IO) { + val historyItem = getHistoryIndex(videoDetail); - _jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - delay(8000); + withContext(Dispatchers.Main) { + _historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong()); + Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); + if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { + _layoutResume.visibility = View.VISIBLE; + _textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"; + + _jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + delay(8000); + _layoutResume.visibility = View.GONE; + _textResume.text = ""; + } catch (e: Throwable) { + Logger.e(TAG, "Failed to set resume changes.", e); + } + } + } else { _layoutResume.visibility = View.GONE; _textResume.text = ""; - } catch (e: Throwable) { - Logger.e(TAG, "Failed to set resume changes.", e); } } - } else { - _layoutResume.visibility = View.GONE; - _textResume.text = ""; } @@ -1568,7 +1585,7 @@ class VideoDetailView : ConstraintLayout { if(localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video", - *localVideoSources.stream() + *localVideoSources .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it, { handleSelectVideoTrack(it) }); @@ -1576,7 +1593,7 @@ class VideoDetailView : ConstraintLayout { else null, if(localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio", - *localAudioSource.stream() + *localAudioSource .map { SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, { handleSelectAudioTrack(it) }); @@ -1592,7 +1609,7 @@ class VideoDetailView : ConstraintLayout { else null, if(liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video", - *liveStreamVideoFormats.stream() + *liveStreamVideoFormats .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it, { _player.selectVideoTrack(it.height) }); @@ -1600,7 +1617,7 @@ class VideoDetailView : ConstraintLayout { else null, if(liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio", - *liveStreamAudioFormats.stream() + *liveStreamAudioFormats .map { SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it, { _player.selectAudioTrack(it.bitrate) }); @@ -1609,7 +1626,7 @@ class VideoDetailView : ConstraintLayout { if(bestVideoSources.isNotEmpty()) SlideUpMenuGroup(this.context, context.getString(R.string.video), "video", - *bestVideoSources.stream() + *bestVideoSources .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it, { handleSelectVideoTrack(it) }); @@ -1617,7 +1634,7 @@ class VideoDetailView : ConstraintLayout { else null, if(bestAudioSources.isNotEmpty()) SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio", - *bestAudioSources.stream() + *bestAudioSources .map { SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, { handleSelectAudioTrack(it) }); @@ -2049,7 +2066,10 @@ class VideoDetailView : ConstraintLayout { val v = video ?: return; val currentTime = System.currentTimeMillis(); if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { - StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); + fragment.lifecycleScope.launch(Dispatchers.IO) { + val history = getHistoryIndex(v); + StatePlaylists.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); + } _lastPositionSaveTime = currentTime; } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 8a098137..08e02159 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -59,20 +59,6 @@ import kotlin.system.measureTimeMillis class StateApp { val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active - /* - private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay"); - - fun getExternalRootDirectory(): File? { - if(!externalRootDirectory.exists()) { - val result = externalRootDirectory.mkdirs(); - if(!result) - return null; - return externalRootDirectory; - } - else - return externalRootDirectory; - }*/ - fun getExternalGeneralDirectory(context: Context): DocumentFile? { val generalUri = Settings.instance.storage.getStorageGeneralUri(); if(isValidStorageUri(context, generalUri)) @@ -539,8 +525,14 @@ class StateApp { StateAnnouncement.instance.registerDidYouKnow(); Logger.i(TAG, "MainApp Started: Finished"); + StatePlaylists.instance.toMigrateCheck(); - if(true) { + StatePlaylists.instance._historyDBStore.deleteAll(); + if(StatePlaylists.instance.shouldMigrateLegacyHistory()) + StatePlaylists.instance.migrateLegacyHistory(); + + + if(false) { Logger.i(TAG, "TEST:--------(200)---------"); testHistoryDB(200); Logger.i(TAG, "TEST:--------(1000)---------"); 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 6a309ad7..43ff1bed 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException @@ -58,6 +59,7 @@ class StatePlaylists { val historyIndex: ConcurrentMap = ConcurrentHashMap(); val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) .withIndex({ it.url }, historyIndex) + .withUnique({ it.url }, historyIndex) .load(); val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); @@ -69,6 +71,137 @@ class StatePlaylists { return listOf(playlistStore, _watchlistStore, _historyStore); } + fun shouldMigrateLegacyHistory(): Boolean { + return _historyDBStore.count() == 0 && _historyStore.count() > 0; + } + fun migrateLegacyHistory() { + Logger.i(TAG, "Migrating legacy history"); + _historyDBStore.deleteAll(); + val allHistory = _historyStore.getItems(); + Logger.i(TAG, "Migrating legacy history (${allHistory.size}) items"); + for(item in allHistory) { + _historyDBStore.insert(item); + } + } + + + fun getHistoryPosition(url: String): Long { + return historyIndex[url]?.position ?: 0; + } + fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long { + val pos = if(position < 0) 0 else position; + if(index.obj == null) throw IllegalStateException("Can only update history with a deserialized db item"); + val historyVideo = index.obj!!; + + val positionBefore = historyVideo.position; + if (updateExisting) { + var shouldUpdate = false; + if (positionBefore < 30) { + shouldUpdate = true; + } else { + if (position > 30) { + shouldUpdate = true; + } + } + + if (shouldUpdate) { + + //A unrecovered item + if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) + historyVideo.video = SerializedPlatformVideo.fromVideo(liveObj); + + historyVideo.position = pos; + historyVideo.date = OffsetDateTime.now(); + _historyDBStore.update(index.id!!, historyVideo); + onHistoricVideoChanged.emit(liveObj, pos); + } + + return positionBefore; + } + + return positionBefore; + } + /* + fun updateHistoryPosition(video: IPlatformVideo, updateExisting: Boolean, position: Long = -1L): Long { + val pos = if(position < 0) 0 else position; + val historyVideo = _historyStore.findItem { it.video.url == video.url }; + if (historyVideo != null) { + val positionBefore = historyVideo.position; + if (updateExisting) { + var shouldUpdate = false; + if (positionBefore < 30) { + shouldUpdate = true; + } else { + if (position > 30) { + shouldUpdate = true; + } + } + + if (shouldUpdate) { + + //A unrecovered item + if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) + historyVideo.video = SerializedPlatformVideo.fromVideo(video); + + historyVideo.position = pos; + historyVideo.date = OffsetDateTime.now(); + _historyStore.saveAsync(historyVideo); + onHistoricVideoChanged.emit(video, pos); + } + } + + return positionBefore; + } else { + val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), pos, OffsetDateTime.now()); + _historyStore.saveAsync(newHistItem); + return 0; + } + } +*/ + fun getHistory() : List { + return _historyDBStore.getAllObjects(); + //return _historyStore.getItems().sortedByDescending { it.date }; + } + fun getHistoryPager(): IPager { + return _historyDBStore.getObjectPager(); + } + fun getHistoryIndexByUrl(url: String): DBHistory.Index? { + return historyIndex[url]; + } + fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false): DBHistory.Index { + val existing = historyIndex[video.url]; + if(existing != null) + return _historyDBStore.get(existing.id!!); + else { + val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, OffsetDateTime.now()); + val id = _historyDBStore.insert(newHistItem); + return _historyDBStore.get(id); + } + } + + fun removeHistory(url: String) { + val hist = getHistoryIndexByUrl(url); + if(hist != null) + _historyDBStore.delete(hist.id!!); + /* + val hist = _historyStore.findItem { it.video.url == url }; + if(hist != null) + _historyStore.delete(hist);*/ + } + + fun removeHistoryRange(minutesToDelete: Long) { + val now = OffsetDateTime.now().toEpochSecond(); + val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.date) < minutesToDelete * 60 }; + for(item in toDelete) + _historyDBStore.delete(item); + /* + val now = OffsetDateTime.now(); + val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete }; + + for(item in toDelete) + _historyStore.delete(item);*/ + } + fun getWatchLater() : List { synchronized(_watchlistStore) { return _watchlistStore.getItems(); @@ -109,6 +242,7 @@ class StatePlaylists { return playlistStore.findItem { it.id == id }; } + fun didPlay(playlistId: String) { val playlist = getPlaylist(playlistId); if(playlist != null) { @@ -117,66 +251,6 @@ class StatePlaylists { } } - fun getHistoryPosition(url: String): Long { - val histVideo = _historyStore.findItem { it.video.url == url }; - if(histVideo != null) - return histVideo.position; - return 0; - } - fun updateHistoryPosition(video: IPlatformVideo, updateExisting: Boolean, position: Long = -1L): Long { - val pos = if(position < 0) 0 else position; - val historyVideo = _historyStore.findItem { it.video.url == video.url }; - if (historyVideo != null) { - val positionBefore = historyVideo.position; - if (updateExisting) { - var shouldUpdate = false; - if (positionBefore < 30) { - shouldUpdate = true; - } else { - if (position > 30) { - shouldUpdate = true; - } - } - - if (shouldUpdate) { - - //A unrecovered item - if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) - historyVideo.video = SerializedPlatformVideo.fromVideo(video); - - historyVideo.position = pos; - historyVideo.date = OffsetDateTime.now(); - _historyStore.saveAsync(historyVideo); - onHistoricVideoChanged.emit(video, pos); - } - } - - return positionBefore; - } else { - val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), pos, OffsetDateTime.now()); - _historyStore.saveAsync(newHistItem); - return 0; - } - } - - fun getHistory() : List { - return _historyStore.getItems().sortedByDescending { it.date }; - } - - fun removeHistory(url: String) { - val hist = _historyStore.findItem { it.video.url == url }; - if(hist != null) - _historyStore.delete(hist); - } - - fun removeHistoryRange(minutesToDelete: Long) { - val now = OffsetDateTime.now(); - val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete }; - - for(item in toDelete) - _historyStore.delete(item); - } - suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist { val channel = StatePlatform.instance.getChannel(channelUrl).await(); return createPlaylistFromChannel(channel, onPage); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ColumnIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnIndex.kt new file mode 100644 index 00000000..de234590 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnIndex.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.stores.db + +import androidx.room.ColumnInfo + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ColumnIndex(val name: String = ColumnInfo.INHERIT_FIELD_NAME) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ColumnOrdered.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnOrdered.kt new file mode 100644 index 00000000..c1b19df6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnOrdered.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.stores.db + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ColumnOrdered(val priority: Int, val descending: Boolean = false); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt index 7800d649..ce3c9c70 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt @@ -3,14 +3,13 @@ package com.futo.platformplayer.stores.db import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.stores.db.types.DBHistory +import kotlin.reflect.KClass abstract class ManagedDBDescriptor, D: ManagedDBDatabase, DA: ManagedDBDAOBase> { - abstract fun dbClass(): Class; + abstract val table_name: String; + abstract fun dbClass(): KClass; abstract fun create(obj: T): I; - open val ordered: String? = null; - - open fun sqlIndexOnly(tableName: String): SimpleSQLiteQuery? = null; - open fun sqlPage(tableName: String, page: Int, length: Int): SimpleSQLiteQuery? = null; + abstract fun indexClass(): KClass; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt index 78a30abf..b111ff59 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt @@ -5,10 +5,13 @@ import androidx.room.Ignore import androidx.room.PrimaryKey import com.futo.platformplayer.api.media.Serializer -interface ManagedDBIndex { - var id: Long? - var serialized: ByteArray? +open class ManagedDBIndex { + @ColumnIndex + @PrimaryKey(true) + open var id: Long? = null; + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var serialized: ByteArray? = null; - @get:Ignore - var obj: T?; + @Ignore + var obj: T? = null; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 98eda24f..e2665eac 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -1,15 +1,26 @@ package com.futo.platformplayer.stores.db +import androidx.room.ColumnInfo import androidx.room.Room import androidx.sqlite.db.SimpleSQLiteQuery +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.assume +import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer import kotlinx.serialization.KSerializer +import java.lang.reflect.Field +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import kotlin.reflect.KClass import kotlin.reflect.KType +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.javaField class ManagedDBStore, T, D: ManagedDBDatabase, DA: ManagedDBDAOBase> { private val _class: KType; @@ -22,16 +33,25 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA private var _dbDescriptor: ManagedDBDescriptor; + private val _columnInfo: List; + + private val _sqlGet: (Long)-> SimpleSQLiteQuery; + private val _sqlGetAll: (LongArray)-> SimpleSQLiteQuery; private val _sqlAll: SimpleSQLiteQuery; + private val _sqlCount: SimpleSQLiteQuery; private val _sqlDeleteAll: SimpleSQLiteQuery; + private val _sqlDeleteById: (Long) -> SimpleSQLiteQuery; private var _sqlIndexed: SimpleSQLiteQuery? = null; + private var _sqlPage: ((Int, Int) -> SimpleSQLiteQuery)? = null; val className: String? get() = _class.classifier?.assume>()?.simpleName; val name: String; private val _indexes: ArrayListAny, ConcurrentMap>> = arrayListOf(); + private val _indexCollection = ConcurrentHashMap(); + private var _withUnique: Pair<(I)->Any, ConcurrentMap>? = null; constructor(name: String, descriptor: ManagedDBDescriptor, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { _dbDescriptor = descriptor; @@ -43,23 +63,52 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA }; _serializer = serializer; _class = clazz; + _columnInfo = _dbDescriptor.indexClass().memberProperties + .filter { it.hasAnnotation() && it.name != "serialized" } + .map { ColumnMetadata(it.javaField!!, it.findAnnotation()!!, it.findAnnotation()) }; - _sqlAll = SimpleSQLiteQuery("SELECT * FROM $_name" + if(descriptor.ordered.isNullOrEmpty()) "" else " ${descriptor.ordered}"); - _sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${_name}"); - _sqlIndexed = descriptor.sqlIndexOnly(_name); + val indexColumnNames = _columnInfo.map { it.name }; + + val orderedColumns = _columnInfo.filter { it.ordered != null }.sortedBy { it.ordered!!.priority }; + val orderSQL = if(orderedColumns.size > 0) + " ORDER BY " + orderedColumns.map { "${it.name} ${if(it.ordered!!.descending) "DESC" else "ASC"}" }.joinToString(", "); + else ""; + + _sqlGet = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id = ?", arrayOf(it)) }; + _sqlGetAll = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id IN (?)", arrayOf(it)) }; + _sqlAll = SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} ${orderSQL}"); + _sqlCount = SimpleSQLiteQuery("SELECT COUNT(id) FROM ${_dbDescriptor.table_name}"); + _sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${_dbDescriptor.table_name}"); + _sqlDeleteById = { id -> SimpleSQLiteQuery("DELETE FROM ${_dbDescriptor.table_name} WHERE id = :id", arrayOf(id)) }; + _sqlIndexed = SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${_dbDescriptor.table_name}"); + + if(orderedColumns.size > 0) { + _sqlPage = { page, length -> + SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} ${orderSQL} LIMIT ? OFFSET ?", arrayOf(length, page * length)); + } + } } - fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap): ManagedDBStore { + fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap, withUnique: Boolean = false): ManagedDBStore { if(_sqlIndexed == null) throw IllegalStateException("Can only create indexes if sqlIndexOnly is implemented"); _indexes.add(Pair(keySelector, indexContainer)); + if(withUnique) + withUnique(keySelector, indexContainer); + + return this; + } + fun withUnique(keySelector: (I)->Any, indexContainer: ConcurrentMap): ManagedDBStore { + if(_withUnique != null) + throw IllegalStateException("Only 1 unique property is allowed"); + _withUnique = Pair(keySelector, indexContainer); + return this; } - fun load(): ManagedDBStore { - _db = Room.databaseBuilder(StateApp.instance.context, _dbDescriptor.dbClass(), _name) + _db = Room.databaseBuilder(StateApp.instance.context, _dbDescriptor.dbClass().java, _name) .fallbackToDestructiveMigration() .allowMainThreadQueries() .build() @@ -73,18 +122,41 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return this; } - fun insert(obj: T) { + fun getUnique(obj: I): I? { + if(_withUnique == null) + throw IllegalStateException("Unique is not configured for ${name}"); + val key = _withUnique!!.first.invoke(obj); + return _withUnique!!.second[key]; + } + fun isUnique(obj: I): Boolean { + if(_withUnique == null) + throw IllegalStateException("Unique is not configured for ${name}"); + val key = _withUnique!!.first.invoke(obj); + return !_withUnique!!.second.containsKey(key); + } + + fun count(): Int { + return dbDaoBase.action(_sqlCount); + } + + fun insert(obj: T): Long { val newIndex = _dbDescriptor.create(obj); + val unique = getUnique(newIndex); + if(unique != null) + return unique.id!!; + newIndex.serialized = serialize(obj); newIndex.id = dbDaoBase.insert(newIndex); newIndex.serialized = null; + if(!_indexes.isEmpty()) { for (index in _indexes) { val key = index.first(newIndex); index.second.put(key, newIndex); } } + return newIndex.id!!; } fun update(id: Long, obj: T) { val newIndex = _dbDescriptor.create(obj); @@ -109,45 +181,72 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun getAllObjects(): List = convertObjects(getAll()); fun getAll(): List { - return dbDaoBase.getMultiple(_sqlAll); + return deserializeIndexes(dbDaoBase.getMultiple(_sqlAll)); } - fun getObject(id: Long) = convertObject(get(id)); + fun getObject(id: Long) = get(id).obj!!; fun get(id: Long): I { - return dbDaoBase.get(SimpleSQLiteQuery("SELECT * FROM $_name WHERE id = ?", arrayOf(id))); + return deserializeIndex(dbDaoBase.get(_sqlGet(id))); } - fun getAllObjects(vararg id: Long): List = convertObjects(getAll(*id)); + fun getAllObjects(vararg id: Long): List = getAll(*id).map { it.obj!! }; fun getAll(vararg id: Long): List { - return dbDaoBase.getMultiple(SimpleSQLiteQuery("SELECT * FROM $_name WHERE id IN (?)", arrayOf(id))); + return deserializeIndexes(dbDaoBase.getMultiple(_sqlGetAll(id))); } - fun getPageObjects(page: Int, length: Int): List = convertObjects(getPage(page, length)); + fun getObjectPage(page: Int, length: Int): List = convertObjects(getPage(page, length)); + fun getObjectPager(pageLength: Int = 20): IPager { + return AdhocPager({ + getObjectPage(it - 1, pageLength); + }); + } fun getPage(page: Int, length: Int): List { - val query = _dbDescriptor.sqlPage(_name, page, length) ?: throw IllegalStateException("Paged db not setup for ${_name}"); + if(_sqlPage == null) + throw IllegalStateException("DB Store [${name}] does not have ordered fields to provide pages"); + val query = _sqlPage!!(page, length) ?: throw IllegalStateException("Paged db not setup for ${_name}"); return dbDaoBase.getMultiple(query); } + fun getPager(pageLength: Int = 20): IPager { + return AdhocPager({ + getPage(it - 1, pageLength); + }); + } + fun delete(item: I) { dbDaoBase.delete(item); for(index in _indexes) index.second.remove(index.first(item)); } + fun delete(id: Long) { + dbDaoBase.action(_sqlDeleteById(id)); + for(index in _indexes) + index.second.values.removeIf { it.id == id } + } fun deleteAll() { dbDaoBase.action(_sqlDeleteAll); + _indexCollection.clear(); for(index in _indexes) index.second.clear(); } - - fun convertObject(index: ManagedDBIndex): T? { - return index.serialized?.let { - _serializer.deserialize(_class, it); - }; + fun convertObject(index: I): T? { + return index.obj ?: deserializeIndex(index).obj; } - fun convertObjects(indexes: List>): List { - return indexes.mapNotNull { convertObject(it) }; + fun convertObjects(indexes: List): List { + return indexes.mapNotNull { it.obj ?: convertObject(it) }; + } + fun deserializeIndex(index: I): I { + if(index.serialized == null) throw IllegalStateException("Cannot deserialize index-only items from [${name}]"); + val obj = _serializer.deserialize(_class, index.serialized!!); + index.obj = obj; + return index; + } + fun deserializeIndexes(indexes: List): List { + for(index in indexes) + deserializeIndex(index); + return indexes; } fun serialize(obj: T): ByteArray { @@ -158,4 +257,12 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA inline fun , D: ManagedDBDatabase, DA: ManagedDBDAOBase> create(name: String, descriptor: ManagedDBDescriptor, serializer: KSerializer? = null) = ManagedDBStore(name, descriptor, kotlin.reflect.typeOf(), JsonStoreSerializer.create(serializer)); } + + class ColumnMetadata( + val field: Field, + val info: ColumnIndex, + val ordered: ColumnOrdered? + ) { + val name get() = if(info.name == ColumnInfo.INHERIT_FIELD_NAME) field.name else info.name; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt index d6463ec2..77256688 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt @@ -15,11 +15,6 @@ class DBChannelCache { class Index: ManagedDBIndex { @PrimaryKey(true) override var id: Long? = null; - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) - override var serialized: ByteArray? = null; - - @Ignore - override var obj: SerializedPlatformContent? = null; var feedType: String? = null; var channelUrl: String? = null; diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt index 2b0d3301..89cd8fbc 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt @@ -10,11 +10,14 @@ import androidx.room.Query import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.stores.db.ColumnIndex +import com.futo.platformplayer.stores.db.ColumnOrdered import com.futo.platformplayer.stores.db.ManagedDBDAOBase import com.futo.platformplayer.stores.db.ManagedDBDatabase import com.futo.platformplayer.stores.db.ManagedDBDescriptor import com.futo.platformplayer.stores.db.ManagedDBIndex import com.futo.platformplayer.stores.db.ManagedDBStore +import kotlin.reflect.KClass import kotlin.reflect.KType class DBHistory { @@ -22,6 +25,7 @@ class DBHistory { const val TABLE_NAME = "history"; } + //These classes solely exist for bounding generics for type erasure @Dao interface DBDAO: ManagedDBDAOBase {} @Database(entities = [Index::class], version = 2) @@ -30,26 +34,25 @@ class DBHistory { } class Descriptor: ManagedDBDescriptor() { + override val table_name: String = TABLE_NAME; override fun create(obj: HistoryVideo): Index = Index(obj); - override fun dbClass(): Class = DB::class.java; - - //Optional - override fun sqlIndexOnly(tableName: String): SimpleSQLiteQuery = SimpleSQLiteQuery("SELECT id, url, position, date FROM $TABLE_NAME"); - override fun sqlPage(tableName: String, page: Int, length: Int): SimpleSQLiteQuery = SimpleSQLiteQuery("SELECT * FROM $TABLE_NAME ORDER BY date DESC, id DESC LIMIT ? OFFSET ?", arrayOf(length, page * length)); + override fun dbClass(): KClass = DB::class; + override fun indexClass(): KClass = Index::class; } @Entity(TABLE_NAME) class Index: ManagedDBIndex { @PrimaryKey(true) + @ColumnOrdered(1) + @ColumnIndex override var id: Long? = null; - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) - override var serialized: ByteArray? = null; - - @Ignore - override var obj: HistoryVideo? = null; + @ColumnIndex var url: String; + @ColumnIndex var position: Long; + @ColumnIndex + @ColumnOrdered(0, true) var date: Long; constructor() { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt index 72d81241..6970d82a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt @@ -6,7 +6,11 @@ import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlaylists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch class HistoryListAdapter : RecyclerView.Adapter { private lateinit var _filteredVideos: MutableList; @@ -18,16 +22,18 @@ class HistoryListAdapter : RecyclerView.Adapter { updateFilteredVideos(); StatePlaylists.instance.onHistoricVideoChanged.subscribe(this) { video, position -> - val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url }; - if (index == -1) { - return@subscribe; - } + StateApp.instance.scope.launch(Dispatchers.Main) { + val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url }; + if (index == -1) { + return@launch; + } - _filteredVideos[index].position = position; - if (index < _filteredVideos.size - 2) { - notifyItemRangeChanged(index, 2); - } else { - notifyItemChanged(index); + _filteredVideos[index].position = position; + if (index < _filteredVideos.size - 2) { + notifyItemRangeChanged(index, 2); + } else { + notifyItemChanged(index); + } } }; }