diff --git a/build.gradle.kts b/build.gradle.kts index cf9c399..be6cd7f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,7 +51,7 @@ repositories { dependencies { implementation(kotlin("reflect")) - implementation("com.github.Xerus2000.util", "javafx", "259dad93192584306dbf951721d3cf8e6bd25d1b") + implementation("com.github.defvs.util", "javafx", "36699cd09dfa0c8fd3991ea12533f06af5d51ec0") implementation("org.controlsfx", "controlsfx", "8.40.+") implementation("ch.qos.logback", "logback-classic", "1.2.+") diff --git a/src/main/xerus/monstercat/MonsterUtilities.kt b/src/main/xerus/monstercat/MonsterUtilities.kt index e197bef..c812682 100644 --- a/src/main/xerus/monstercat/MonsterUtilities.kt +++ b/src/main/xerus/monstercat/MonsterUtilities.kt @@ -37,11 +37,7 @@ import xerus.monstercat.api.Covers import xerus.monstercat.api.DiscordRPC import xerus.monstercat.api.Player import xerus.monstercat.downloader.TabDownloader -import xerus.monstercat.tabs.BaseTab -import xerus.monstercat.tabs.TabCatalog -import xerus.monstercat.tabs.TabGenres -import xerus.monstercat.tabs.TabSettings -import xerus.monstercat.tabs.TabSound +import xerus.monstercat.tabs.* import java.io.File import java.net.URL import java.net.UnknownHostException @@ -105,6 +101,7 @@ class MonsterUtilities(checkForUpdate: Boolean): JFXMessageDisplay { addTab(TabCatalog::class) addTab(TabGenres::class) addTab(TabDownloader::class) + addTab(TabReleases::class) addTab(TabSound::class) addTab(TabSettings::class) if(currentVersion != Settings.LASTVERSION.get()) { diff --git a/src/main/xerus/monstercat/api/Cache.kt b/src/main/xerus/monstercat/api/Cache.kt index c2043f4..b152e07 100644 --- a/src/main/xerus/monstercat/api/Cache.kt +++ b/src/main/xerus/monstercat/api/Cache.kt @@ -18,7 +18,7 @@ import xerus.monstercat.downloader.CONNECTSID import xerus.monstercat.globalDispatcher import java.io.File -private const val cacheVersion = 5 +private const val cacheVersion = 6 object Cache: Refresher() { private val logger = KotlinLogging.logger { } diff --git a/src/main/xerus/monstercat/api/Covers.kt b/src/main/xerus/monstercat/api/Covers.kt index 54d695b..b6fc78a 100644 --- a/src/main/xerus/monstercat/api/Covers.kt +++ b/src/main/xerus/monstercat/api/Covers.kt @@ -29,7 +29,7 @@ object Covers { fun getCoverImage(coverUrl: String, size: Int = 1024, invalidate: Boolean = false): Image = getCover(coverUrl, 1024, invalidate).use { createImage(it, size) } - private fun createImage(content: InputStream, size: Number) = + fun createImage(content: InputStream, size: Number) = Image(content, size.toDouble(), size.toDouble(), false, false) /** @@ -55,6 +55,16 @@ object Covers { return coverFile.inputStream() } + fun getCachedCover(coverUrl: String, cachedSize: Int, imageSize: Int = cachedSize): Image? { + val coverFile = coverCacheFile(coverUrl, cachedSize) + return try { + val imageStream = coverFile.inputStream() + createImage(imageStream, imageSize) + } catch(e: Exception) { + null + } + } + /** Fetches the given [coverUrl] with an [APIConnection] in the requested [size]. * @param coverUrl the base url to fetch the cover * @param size the size of the cover to be fetched from the api, with all powers of 2 being available. diff --git a/src/main/xerus/monstercat/api/response/Release.kt b/src/main/xerus/monstercat/api/response/Release.kt index 21d8876..a93c2a6 100644 --- a/src/main/xerus/monstercat/api/response/Release.kt +++ b/src/main/xerus/monstercat/api/response/Release.kt @@ -15,6 +15,7 @@ data class Release( @Key var renderedArtists: String = "", @Key override var title: String = "", @Key var coverUrl: String = "", + @Key("inEarlyAccess") var earlyAccess: Boolean = false, @Key var downloadable: Boolean = false): MusicItem() { @Key var isCollection: Boolean = false diff --git a/src/main/xerus/monstercat/tabs/BaseTab.kt b/src/main/xerus/monstercat/tabs/BaseTab.kt index 3438ee1..b23e122 100644 --- a/src/main/xerus/monstercat/tabs/BaseTab.kt +++ b/src/main/xerus/monstercat/tabs/BaseTab.kt @@ -2,6 +2,7 @@ package xerus.monstercat.tabs import javafx.scene.control.Control import javafx.scene.layout.Pane +import javafx.scene.layout.StackPane import javafx.scene.layout.VBox import mu.KotlinLogging import org.controlsfx.validation.decoration.GraphicValidationDecoration @@ -26,3 +27,11 @@ abstract class VTab : VBox(), BaseTab { styleClass.add("vtab") } } + +abstract class StackTab : StackPane(), BaseTab { + protected val logger = KotlinLogging.logger(javaClass.name) + + init { + styleClass.add("vtab") + } +} \ No newline at end of file diff --git a/src/main/xerus/monstercat/tabs/TabReleases.kt b/src/main/xerus/monstercat/tabs/TabReleases.kt new file mode 100644 index 0000000..b8c0945 --- /dev/null +++ b/src/main/xerus/monstercat/tabs/TabReleases.kt @@ -0,0 +1,251 @@ +package xerus.monstercat.tabs + +import javafx.animation.FadeTransition +import javafx.beans.property.SimpleBooleanProperty +import javafx.collections.FXCollections +import javafx.geometry.Insets +import javafx.geometry.Orientation +import javafx.geometry.Pos +import javafx.scene.Group +import javafx.scene.control.* +import javafx.scene.effect.GaussianBlur +import javafx.scene.image.Image +import javafx.scene.image.ImageView +import javafx.scene.layout.* +import javafx.scene.paint.Color +import javafx.scene.text.Font +import javafx.util.Duration +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.controlsfx.control.GridCell +import org.controlsfx.control.GridView +import xerus.ktutil.javafx.* +import xerus.ktutil.javafx.properties.SimpleObservable +import xerus.ktutil.javafx.properties.addListener +import xerus.ktutil.javafx.properties.listen +import xerus.ktutil.nullIfEmpty +import xerus.monstercat.api.Cache +import xerus.monstercat.api.Covers +import xerus.monstercat.api.Player +import xerus.monstercat.api.response.Release +import xerus.monstercat.api.response.Track +import xerus.monstercat.monsterUtilities + + +class TabReleases: StackTab() { + private var cols = SimpleObservable(2) + val cellSize: Double + get() = monsterUtilities.window.width / cols.value - 16.0 * (cols.value + 1) + + private val releases = FXCollections.observableArrayList() + + private val gridView = GridView().apply { + setCellFactory { + ReleaseGridCell(this@TabReleases).apply { setOnMouseClicked { showRelease(this.item) } } + } + horizontalCellSpacing = 16.0 + verticalCellSpacing = 16.0 + + fun setCellSize() { + cellWidth = cellSize + cellHeight = cellSize + } + setCellSize() + cols.listen { setCellSize() } + monsterUtilities.window.widthProperty().listen { setCellSize() } + } + + private val listView = ListView().apply { + setCellFactory { + ReleaseListCell().apply { setOnMouseClicked { showRelease(this.item) } } + } + } + + private val blurLowRes = SimpleBooleanProperty(false) + + init { + gridView.items = releases + listView.items = releases + GlobalScope.launch { + val releases = Cache.getReleases() + onFx { this@TabReleases.releases.setAll(releases) } + } + val gridEditor = HBox(0.0, + createButton("-") { cols.value = (cols.value - 1).coerceAtLeast(2) }, + createButton("+") { cols.value = (cols.value + 1).coerceAtMost(4) }, + CheckBox("Blur low-res").bind(blurLowRes).tooltip("May affect performance while loading covers, but looks less pixelated") + ) + val colEditor = Group( + VBox( + CheckBox("Grid View").apply { isSelected = false }.onClick { + gridEditor.isDisable = !isSelected + val tab = this@TabReleases + tab.children.removeAll(listView, gridView) + if(isSelected) { + tab.children.add(0, gridView) + }else{ + tab.children.add(0, listView) + } + }, + gridEditor + ).apply { + background = Background(BackgroundFill(Color(0.0, 0.0, 0.0, 0.7), CornerRadii(8.0), Insets.EMPTY)) + padding = Insets(8.0) + } + ) + + val placeholder = Group(HBox(ImageView(Image("img/loading-16.gif")), Label("Loading Releases..."))) + add(placeholder) + setAlignment(placeholder, Pos.CENTER) + + releases.listen { + onFx { + if(!it.isNullOrEmpty()) { + add(listView) + add(colEditor) + setAlignment(colEditor, Pos.BOTTOM_LEFT) + children.remove(placeholder) + } else { + children.removeAll(listView, gridView, colEditor) + add(placeholder) + setAlignment(placeholder, Pos.CENTER) + } + } + } + + } + + private fun showRelease(release: Release) { + val parent = VBox() + + val tracks = FXCollections.observableArrayList(release.tracks) + val tracksView = ListView(tracks).apply { setCellFactory { TrackListCell() } } + + val infoHeader = HBox(16.0, + ImageView(Covers.getThumbnailImage(release.coverUrl, 256)).apply { + effect = GaussianBlur(10.0) + val cachedCover = Covers.getCachedCover(release.coverUrl, 256, 256) + if(cachedCover != null) { + image = cachedCover + effect = null + } else { + image = Covers.getThumbnailImage(release.coverUrl, 256) + GlobalScope.launch { + val image = Covers.getCover(release.coverUrl, 256).use { Covers.createImage(it, 256) } + onFx { this@apply.image = image; effect = null } + } + } + setOnMouseClicked { monsterUtilities.viewCover(release.coverUrl) } + }, + Separator(Orientation.VERTICAL) + ) + infoHeader.fill(VBox( + Label(release.title).apply { style += "-fx-font-size: 32px; -fx-font-weight: bold;" }, + Label(release.renderedArtists.nullIfEmpty()?.let { "by $it" } + ?: "Various Artists").apply { style += "-fx-font-size: 24px;" }, + HBox(Label(release.releaseDate), Label("${release.tracks.size} tracks")).apply { style += "-fx-font-size: 16px;" }, + HBox( + buttonWithId("play") { Player.play(release) }, + buttonWithId("satin-add") { /* TODO : Playlist add when merged */ }, // TODO : Add icon + buttonWithId("satin-open") { /* TODO : Tick in Downloader tab */ }.tooltip("Show in downloader") // TODO : Save icon + ).id("controls").apply { fill(pos = 0) } + ).apply { fill(pos = 3) }) + + parent.style += "-fx-background-color: -fx-background;" + parent.add(infoHeader) + parent.add(Separator(Orientation.HORIZONTAL)) + parent.fill(tracksView) + parent.addButton("Back") { + FadeTransition(Duration(300.0), parent).apply { + fromValue = 1.0 + toValue = 0.0 + setOnFinished { children.remove(parent) } + }.play() + }.apply { isCancelButton = true } + + FadeTransition(Duration(300.0), parent).apply { + fromValue = 0.0 + toValue = 1.0 + }.play() + add(parent).toFront() + } + + class ReleaseGridCell(private val context: TabReleases): GridCell() { + override fun updateItem(item: Release?, empty: Boolean) { + super.updateItem(item, empty) + if(empty || item == null) { + graphic = null + } else { + val lowRes = SimpleBooleanProperty(true) + val cover = ImageView() + + val cachedCover = Covers.getCachedCover(item.coverUrl, 256, 256) + if(cachedCover != null) { + cover.image = cachedCover + lowRes.value = false + } else { + cover.image = Covers.getThumbnailImage(item.coverUrl, 256) + lowRes.value = true + GlobalScope.launch { + val image = Covers.getCover(item.coverUrl, 256).use { Covers.createImage(it, 256) } + lowRes.value = false + onFx { cover.image = image } + } + } + + cover.fitHeight = context.cellSize + cover.fitWidth = context.cellSize + context.cols.listen { + cover.fitHeight = context.cellSize + cover.fitWidth = context.cellSize + } + + cover.effect = if(context.blurLowRes.value && lowRes.value) GaussianBlur(5.0) else null + arrayOf(context.blurLowRes, lowRes).addListener { + cover.effect = if(context.blurLowRes.value && lowRes.value) GaussianBlur(5.0) else null + } + + graphic = StackPane(cover, + Label(item.toString()).apply { + background = Background(BackgroundFill(Color(0.0, 0.0, 0.0, 0.7), CornerRadii(8.0), Insets.EMPTY)) + padding = Insets(8.0) + translateY = -16.0 + } + ).apply { + alignment = Pos.BOTTOM_CENTER; tooltip = Tooltip(item.toString()) + if(item.earlyAccess) style += "-fx-border-color: gold; -fx-border-width: 4px; -fx-border-radius: 8px" + } + } + } + } + + class ReleaseListCell: ListCell() { + override fun updateItem(item: Release?, empty: Boolean) { + super.updateItem(item, empty) + if(empty || item == null) { + graphic = null + } else { + val cover = ImageView(Covers.getThumbnailImage(item.coverUrl, 256)).apply { fitHeight = 64.0; fitWidth = 64.0; } + graphic = HBox(cover, Label(item.toString()).apply { font = Font(14.0); padding = Insets(16.0) }) + } + } + } + + class TrackListCell: ListCell() { + override fun updateItem(item: Track?, empty: Boolean) { + super.updateItem(item, empty) + if(empty || item == null) graphic = null + else { + val parent = HBox() + parent.add(HBox( + buttonWithId("play") { Player.playTrack(item) }, + buttonWithId("satin-add") { /* TODO : Add to playlist once the branch is merged */ }, // TODO : Add icon + buttonWithId("satin-open") { /* TODO : Tick in Downloader tab */ }.tooltip("Show in downloader") // TODO: Save icon + ).id("controls").apply { alignment = Pos.CENTER_LEFT }) + parent.fill(HBox(Label(item.toString()) /* TODO : Unlicensable alert */), 0) + + graphic = parent + } + } + } +} \ No newline at end of file