package link.roadio.riff.player.library import link.roadio.cuetree.CatalogView import link.roadio.cuetree.Flags import link.roadio.cuetree.NodeKind import link.roadio.riff.indexer.core.media.MediaId import link.roadio.riff.player.storage.StoredContentMode import link.roadio.riff.ui.BrowserCatalog import link.roadio.riff.ui.BrowserItem import link.roadio.riff.ui.BrowserKind import link.roadio.riff.ui.BrowserMembership import link.roadio.riff.ui.BrowserRowKey import link.roadio.riff.ui.BrowserRows import link.roadio.riff.ui.BrowserTrackFact import link.roadio.riff.ui.trackReactionKey /** One mapped catalog shared by the two mode views; retained row objects are globally bounded. */ internal class CuetreeBrowseCatalog( val snapshot: CatalogView, val projection: PlayerLibraryProjection, val excluded: Set, val playback: CuetreeTrackCatalog, private val hash: (ByteArray) -> Long, ) : BrowserCatalog { private val rowCache = object : LinkedHashMap(128, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > 128 } val facts get() = playback.facts val retainedIndices get() = facts.retainedIndices val playableIndices get() = facts.playableIndices val allIds get() = facts.allIds val aliases get() = facts.aliases val albumIndices = run { snapshot.albums.indices .filter(::albumVisible) .toIntArray() } val artistIndices = run { snapshot.artists.indices .filter { facts.artistAlbums[it].any(::albumVisible) } .toIntArray() } val genreIndices = run { snapshot.genres.indices .filter { snapshot.genreDictionaryId(it) !in excluded && facts.genreAlbums[it].any(::albumVisible) }.toIntArray() } val rootFolderIndices = facts.rootFolders private val collections = java.util.concurrent.ConcurrentHashMap>() private val occurrenceCache = object : LinkedHashMap, List>(8, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry, List>) = size > 8 } fun orderedOccurrences( context: String, playableOnly: Boolean, ): List = synchronized(occurrenceCache) { occurrenceCache.getOrPut(context to playableOnly) { occurrences(context, playableOnly) } } init { playback.contextOccurrence = { context, ordinal, media -> val records = orderedOccurrences(context, true) (records.getOrNull(ordinal)?.takeIf { id(it.index) == media } ?: records.firstOrNull { id(it.index) == media })?.let { it.index to it.base } } } val root: BrowserItem by lazy { val shell = CuetreeBrowserTree.emptyRoot(projection.contentMode) // AI-INTENT: Launchers share one definition; the Books mode switch is independent // of collection membership. https://wiki.271.pics/Riff/LATEST/player-content-mode shell.copy(catalog = this, children = shell.children.map(::category)) } private val bookMode get() = projection.contentMode == StoredContentMode.AUDIOBOOK fun id(index: Int): MediaId = snapshot.nodeId(index).let { MediaId(it.hi, it.lo ?: 0L, it.lo != null) } fun isMedia(index: Int): Boolean = when (snapshot.nodeKind(index)) { NodeKind.MEDIA, NodeKind.FRAGMENT -> true NodeKind.FAILEDL1 -> snapshot.nodeFlags(index) and Flags.IDHI == 0 else -> false } fun playable(index: Int): Boolean = snapshot.nodeAvailable(index) && when (snapshot.nodeKind(index)) { NodeKind.MEDIA -> true NodeKind.FRAGMENT -> snapshot.sources(requireNotNull(snapshot.nodeParentId(index))).any(snapshot::nodeAvailable) else -> false } fun albumIndex(id: Long): Int = snapshot.albumIndex(id) fun albumIdVisible(id: Long): Boolean = albumIndex(id).takeIf { it >= 0 }?.let(::albumVisible) == true fun albumVisible(index: Int): Boolean = facts.albumVisible(index, bookMode) @Synchronized fun cached( key: String, create: () -> BrowserItem, ): BrowserItem = rowCache.getOrPut(key, create) fun rows( key: String, indices: () -> IntArray, metadata: (Int) -> BrowserRowKey, row: (Int) -> BrowserItem, ): List = collections.getOrPut(key) { object : BrowserRows() { private val records by lazy(indices) override val size get() = records.size override fun key(index: Int) = metadata(records[index]) override fun rowId(index: Int) = rowIdentity(key, records[index]) override fun findRow(id: String): BrowserItem? = rowRecord(key, id)?.let { record -> cached("$key:$record") { row(record) } } override fun get(index: Int): BrowserItem { val record = records[index] return cached("$key:$record") { row(record) } } } } private fun category(item: BrowserItem): BrowserItem = item.copy( catalog = this, enabled = when (item.id) { "category:albums" -> snapshot.albums.isNotEmpty() "category:artists" -> snapshot.artists.isNotEmpty() "category:genres" -> snapshot.genres.isNotEmpty() "category:playlists" -> false else -> item.enabled }, children = when (item.id) { "category:albums" -> rows("albums", { albumIndices }, ::albumKey, ::albumRow) "category:artists" -> rows("artists", { artistIndices }, ::artistKey, ::artistRow) "category:genres" -> rows("genres", { genreIndices }, ::genreKey, ::genreRow) "category:folders" -> rows("folders", { rootFolderIndices }, ::folderKey, ::folderRow) else -> emptyList() }, ) override fun find(id: String): BrowserItem? { if (id == "root") return root if (id.startsWith("category:")) return root.children.firstOrNull { it.id == id } val prefix = id.substringBefore(':') val value = id.substringAfter(':').toULongOrNull(16)?.toLong() return when (prefix) { "album" -> { value?.let(::albumIndex)?.takeIf { it >= 0 && albumVisible(it) }?.let(::albumRow) } "artist" -> { value?.let { key -> snapshot.artistIndex(key) }?.takeIf { it >= 0 }?.let(::artistRow) } "genre" -> { value?.let { key -> snapshot.genreIndex(key) }?.takeIf { it >= 0 }?.let(::genreRow) } "folder" -> { value?.let(::folderIndex)?.let(::folderRow) } "media-container" -> { fileIndex(id)?.let(::fileRow) } "track" -> { parseMediaId(id.substringAfter(':'))?.let(::track) } "track-row", "folder-track-row" -> { val index = id.substringAfter(':').substringBefore(':').toIntOrNull() if (index != null && index in snapshot.nodes.indices ) { trackRow( index, folder = prefix == "folder-track-row", base = id.substringAfterLast(':').toIntOrNull().takeIf { ':' in id.substringAfter(':') }, ) } else { parseMediaId(id.substringAfter(':'))?.let(::track) } } else -> { null } } } override fun preferredBinding(rowId: String): Long? { val parts = rowId.split(':') if (parts.first() !in setOf("track-row", "folder-track-row")) return null val index = parts.getOrNull(1)?.toIntOrNull()?.takeIf { it in snapshot.nodes.indices } ?: return null val base = parts.getOrNull(2)?.toIntOrNull() return if (base != null ) { playback.binding(index, base)?.source?.bindingId } else { playback .bindings(index) .firstOrNull() ?.source ?.bindingId } } fun fileId(index: Int): String = facts.containers.key(index) fun fileIndex(context: String): Int? = context .substringAfter(':') .toULongOrNull(16) ?.toLong() ?.let(facts.containers::find) override fun available(mediaId: MediaId): Boolean = snapshot.sources(link.roadio.cuetree.MediaId(mediaId.high, mediaId.low.takeIf { mediaId.lowPresent })).any(::playable) private fun trackIndex(mediaId: MediaId): Int? = snapshot .sources(link.roadio.cuetree.MediaId(mediaId.high, mediaId.low.takeIf { mediaId.lowPresent })) .filter(::isMedia) .let { it.firstOrNull(::playable) ?: it.firstOrNull() } override fun track(mediaId: MediaId): BrowserItem? = trackIndex(mediaId)?.let { trackRow(it) } override fun trackKey(mediaId: MediaId): BrowserRowKey? = trackIndex(mediaId)?.let { trackKey(it) } val retainedTracks: List get() = rows("retained", { retainedIndices }, { trackKey(it) }, { trackRow(it) }) val tracks: List get() = rows("tracks", { playableIndices }, { trackKey(it) }, { trackRow(it) }) fun bookIds(automatic: Boolean): Set = object : AbstractSet() { override fun contains(element: MediaId) = if (automatic) playback.automatic(element) else playback.book(element) override val size get() = allIds.count(::contains) override fun iterator() = allIds.asSequence().filter(::contains).iterator() } override fun tracks( contextKey: String, playableOnly: Boolean, ignoreLeadingArticles: Boolean, ) = contextTracks(contextKey, playableOnly, ignoreLeadingArticles) override fun mediaIds(contextKey: String) = contextMediaIds(contextKey) override fun entities(categoryId: String) = contextEntities(categoryId) override fun directoryPaths() = catalogDirectoryPaths() fun folderIndex(high: Long): Int? { var low = 0 var upper = snapshot.nodes.size while (low < upper) { val middle = (low + upper) ushr 1 if (snapshot.nodeId(middle).hi < high) low = middle + 1 else upper = middle } while (low < snapshot.nodes.size && snapshot.nodeId(low).hi == high) { if (snapshot.nodeKind(low) == NodeKind.DIRECTORY) return low low++ } return null } override fun trackFacts(): List = object : AbstractList() { override val size get() = playableIndices.size override fun get(index: Int): BrowserTrackFact { val media = id(playableIndices[index]) return BrowserTrackFact(media, true, trackReactionKey(playback.reaction(media))) } } } internal fun parseMediaId(hex: String): MediaId? = runCatching { require(hex.length == 16 || hex.length == 32) MediaId(hex.take(16).toULong(16).toLong(), if (hex.length == 32) hex.drop(16).toULong(16).toLong() else 0L, hex.length == 32) }.getOrNull()