Initial commit: NavidromePlayer mod
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Sun Sep 27 14:41:05 MSK 2026
|
||||
gradle.version=7.4.2
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
# Navidrome Player Mod (Minecraft 1.16.5 Forge)
|
||||
|
||||
Мод для Minecraft 1.16.5 (Forge), добавляющий **блок-проигрыватель с поддержкой Subsonic / Navidrome API**.
|
||||
|
||||
---
|
||||
|
||||
### Основные возможности:
|
||||
1. **Качественный стереозвук («звук в наушниках»):**
|
||||
* Музыка играет напрямую в аудиовыход игрока без затухания и искажений ванильного 3D-звука пластинок.
|
||||
* Звук играет **везде** (на любой дистанции), пока вы не остановите плеер.
|
||||
2. **Поддержка форматов (включая FLAC):**
|
||||
* Полноценная поддержка стриминга **FLAC (Lossless)** и **MP3** без предварительной загрузки всего файла.
|
||||
* В интерфейсе отображаются бейджи формата: `[FLAC]` (голубой) и `[MP3]` (золотой).
|
||||
3. **Безопасность и приватность в мультиплеере:**
|
||||
* Блок привязывается к игроку, который его поставил (`ownerUuid`).
|
||||
* Меню выбора музыки, поиск и настройки сервера видит **только владелец блока**.
|
||||
* Другие игроки при нажатии ПКМ видят сообщение: *"Этот проигрыватель принадлежит <Имя_Игрока>. Доступ закрыт!"*.
|
||||
* Пароль и токен доступа к вашему серверу Navidrome сохраняются **только локально** на вашем клиенте (`config/navidrome_player.json`) и не передаются другим игрокам на сервере Minecraft.
|
||||
4. **Удобный и красивый интерфейс (GUI):**
|
||||
* **Поиск:** строка поиска по трекам, альбомам и исполнителям.
|
||||
* **Плейлисты:** отображение ваших плейлистов из Navidrome и треков внутри них.
|
||||
* **Альбомы:** список недавних альбомов с переходом к списку треков.
|
||||
* **Панель управления воспроизведением:**
|
||||
* Отображение текущего трека: *"Исполнитель - Название"*.
|
||||
* Прогресс трека и таймер (`01:23 / 03:45`).
|
||||
* Кнопки: `|<` (предыдущий), `Пауза` / `Играть`, `Стоп`, `>|` (следующий).
|
||||
* Интерактивный ползунок громкости `0% - 100%`.
|
||||
* **Настройки:**
|
||||
* Адрес сервера (например `http://192.168.1.100:4533` или домен `https://music.example.com`).
|
||||
* Логин и пароль.
|
||||
* Кнопка «Проверить» (выполняет ping к Subsonic API и показывает статус/версию).
|
||||
* Кнопка «Сохранить».
|
||||
|
||||
---
|
||||
|
||||
### Крафт блока:
|
||||
* В верстаке:
|
||||
* В центре — ванильный **Проигрыватель** (Jukebox).
|
||||
* Сверху, снизу, слева и справа — **Красная пыль** (Redstone Dust).
|
||||
* Или возьмите блок из вкладки «Красный камень» в креативе.
|
||||
|
||||
---
|
||||
|
||||
### Сборка и установка:
|
||||
* Готовый JAR-файл мода:
|
||||
`build/libs/navidromeplayer-1.0.0.jar`
|
||||
* Для установки:
|
||||
1. Скопируйте `navidromeplayer-1.0.0.jar` в папку `.minecraft/mods`.
|
||||
2. Убедитесь, что установлен **Minecraft Forge 1.16.5** (версия 36.2.39 или новее).
|
||||
3. Для работы на мультиплеерном сервере установите мод и на сервер, и на клиенты.
|
||||
@@ -0,0 +1,91 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url = 'https://maven.minecraftforge.net' }
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath group: 'net.minecraftforge.gradle', name: 'ForgeGradle', version: '5.1.+', changing: true
|
||||
}
|
||||
}
|
||||
apply plugin: 'net.minecraftforge.gradle'
|
||||
apply plugin: 'eclipse'
|
||||
apply plugin: 'maven-publish'
|
||||
|
||||
version = '1.0.0'
|
||||
group = 'com.rardog.navidromeplayer'
|
||||
archivesBaseName = 'navidromeplayer'
|
||||
|
||||
sourceCompatibility = targetCompatibility = compileJava.sourceCompatibility = compileJava.targetCompatibility = '1.8'
|
||||
|
||||
minecraft {
|
||||
mappings channel: 'official', version: '1.16.5'
|
||||
|
||||
runs {
|
||||
client {
|
||||
workingDirectory project.file('run')
|
||||
property 'forge.logging.markers', 'REGISTRIES'
|
||||
property 'forge.logging.console.level', 'debug'
|
||||
|
||||
mods {
|
||||
navidromeplayer {
|
||||
source sourceSets.main
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
workingDirectory project.file('run')
|
||||
property 'forge.logging.markers', 'REGISTRIES'
|
||||
property 'forge.logging.console.level', 'debug'
|
||||
|
||||
mods {
|
||||
navidromeplayer {
|
||||
source sourceSets.main
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
shade
|
||||
implementation.extendsFrom shade
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url = 'https://maven.minecraftforge.net' }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
minecraft 'net.minecraftforge:forge:1.16.5-36.2.39'
|
||||
|
||||
// External libraries for MP3 and FLAC
|
||||
shade 'javazoom:jlayer:1.0.1'
|
||||
shade 'org.jflac:jflac-codec:1.5.2'
|
||||
}
|
||||
|
||||
jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from {
|
||||
configurations.shade.collect { it.isDirectory() ? it : zipTree(it) }
|
||||
} {
|
||||
exclude 'META-INF/*.SF'
|
||||
exclude 'META-INF/*.DSA'
|
||||
exclude 'META-INF/*.RSA'
|
||||
}
|
||||
|
||||
manifest {
|
||||
attributes([
|
||||
"Specification-Title": "navidromeplayer",
|
||||
"Specification-Vendor": "rardog",
|
||||
"Specification-Version": "1",
|
||||
"Implementation-Title": project.name,
|
||||
"Implementation-Version": "${version}",
|
||||
"Implementation-Vendor" :"rardog",
|
||||
"Implementation-Timestamp": new Date().format("yyyy-MM-dd'T'HH:mm:ssZ")
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
jar.finalizedBy('reobfJar')
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"jars": []
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Java Launcher: /home/rardog/.jdks/temurin-8/bin/java
|
||||
Arguments: '--in-jar, /home/rardog/Документы/Mod/build/libs/navidromeplayer-1.0.0.jar, --out-jar, /home/rardog/Документы/Mod/build/reobfJar/output.jar, --srg-in, /home/rardog/Документы/Mod/build/createMcpToSrg/output.tsrg, --live'
|
||||
Classpath:
|
||||
- /home/rardog/.gradle/caches/forge_gradle/maven_downloader/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0-shaded.jar
|
||||
- /home/rardog/.gradle/caches/forge_gradle/minecraft_user_repo/net/minecraftforge/forge/1.16.5-36.2.39_mapped_official_1.16.5/forge-1.16.5-36.2.39_mapped_official_1.16.5.jar
|
||||
- /home/rardog/.gradle/caches/forge_gradle/minecraft_user_repo/net/minecraftforge/forge/1.16.5-36.2.39_mapped_official_1.16.5/forge-1.16.5-36.2.39_mapped_official_1.16.5-launcher.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/javazoom/jlayer/1.0.1/2bfef7a5a4c9af2184ff74b460b6d7d24349b98a/jlayer-1.0.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.jflac/jflac-codec/1.5.2/21da27e1fa32a059040c09d05e4265ac0cec95fa/jflac-codec-1.5.2.jar
|
||||
- /home/rardog/.gradle/caches/forge_gradle/minecraft_repo/versions/1.16.5/client-extra.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.google.code.findbugs/jsr305/3.0.1/f7be08ec23c21485b9b5a1cf1654c2ec8c58168d/jsr305-3.0.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecraftforge/mergetool/1.0.9/d1e1ba354a40b5703340b5cb02fdca489cff3646/mergetool-1.0.9-api.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/cpw.mods/modlauncher/8.1.3/6d0bfd78e025a87d43985e30907aa9ecbc51b2d6/modlauncher-8.1.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.ow2.asm/asm-commons/9.1/8b971b182eb5cf100b9e8d4119152d83e00e0fdd/asm-commons-9.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.ow2.asm/asm-util/9.1/36464a45d871779f3383a8a9aba2b26562a86729/asm-util-9.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.ow2.asm/asm-analysis/9.1/4f61b83b81d8b659958f4bcc48907e93ecea55a0/asm-analysis-9.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.ow2.asm/asm-tree/9.1/c333f2a855069cb8eb17a40a3eb8b1b67755d0eb/asm-tree-9.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.ow2.asm/asm/9.1/a99500cf6eea30535eeac6be73899d048f8d12a8/asm-9.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/cpw.mods/grossjava9hacks/1.3.3/e49222512ea6cabdd8b49761cef1d5a207b1f0d9/grossjava9hacks-1.3.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecraftforge/accesstransformers/3.0.1/6d23c1b9cb0607fddc38d09730796f68db96f546/accesstransformers-3.0.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.antlr/antlr4-runtime/4.9.1/428664f05d2b7f7b7610204b5aa7c1763f62011a/antlr4-runtime-4.9.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecraftforge/eventbus/4.0.0/260e34800723e4c098c4e247cf2b900535e01b6d/eventbus-4.0.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecraftforge/forgespi/3.2.0/c6ca4e4e4a0343701407c760e642537b613b543/forgespi-3.2.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecraftforge/coremods/4.0.6/d6d761379c841e2610abebcbf70ed20b65f728f0/coremods-4.0.6.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecraftforge/unsafe/0.2.0/54d7a0a5e8fdb71b973025caa46f341ae5904f39/unsafe-0.2.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.electronwill.night-config/toml/3.6.3/2b05b4d606c517da4d1a7b6d2b97d751c92887d7/toml-3.6.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.electronwill.night-config/core/3.6.3/c601bfeaeb2c0abe7aaa901b0bbe6d1beff49281/core-3.6.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.jline/jline/3.12.1/de2bd909cb9f8eaa741bd03df4a1bd3f6eb593c7/jline-3.12.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.maven/maven-artifact/3.6.3/f8ff8032903882376e8d000c51e3e16d20fc7df7/maven-artifact-3.6.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.jodah/typetools/0.8.3/98f84f353457629e81cc6827224871b1a8faa7af/typetools-0.8.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecrell/terminalconsoleappender/1.2.0/96d02cd3b384ff015a8fef4223bcb4ccf1717c95/terminalconsoleappender-1.2.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-core/2.15.0/ba55c13d7ac2fd44df9cc8074455719a33f375b9/log4j-core-2.15.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-slf4j18-impl/2.15.0/88f72ad364bfc3a7cf43186fc17212f2b4bb8d97/log4j-slf4j18-impl-2.15.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-api/2.15.0/4a5aa7e55a29391c6f66e0b259d5189aa11e45d0/log4j-api-2.15.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.sf.jopt-simple/jopt-simple/5.0.4/4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c/jopt-simple-5.0.4.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.spongepowered/mixin/0.8.4/4ec7d77d9ab32596ca0b78bb123956734767e3a/mixin-0.8.4.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.minecraftforge/nashorn-core-compat/15.1.1.1/1817deb38a5a8811148dca0d23161d92bdbd6184/nashorn-core-compat-15.1.1.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.mojang/patchy/1.3.9/eb8bb7b66fa0e2152b1b40b3856e82f7619439ee/patchy-1.3.9.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/oshi-project/oshi-core/1.1/9ddf7b048a8d701be231c0f4f95fd986198fd2d8/oshi-core-1.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.java.dev.jna/jna/4.4.0/cb208278274bf12ebdb56c61bd7407e6f774d65a/jna-4.4.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.java.dev.jna/platform/3.4.0/e3f70017be8100d3d6923f50b3d2ee17714e9c13/platform-3.4.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.ibm.icu/icu4j/66.1/72c7519b6d91f7a1f993bd44a99fe95d67211b27/icu4j-66.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.mojang/javabridge/1.0.22/6aa6453aa99a52a5cd91749da1af6ab70e082ab3/javabridge-1.0.22.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/io.netty/netty-all/4.1.25.Final/d0626cd3108294d1d58c05859add27b4ef21f83b/netty-all-4.1.25.Final.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/21.0/3a3d111be1be1b745edfa7d91678a12d7ed38709/guava-21.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.commons/commons-lang3/3.8.1/6505a72a097d9270f7a9e7bf42c4238283247755/commons-lang3-3.8.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/commons-io/commons-io/2.5/2852e6e05fbb95076fc091f6d1780f1f8fe35e0f/commons-io-2.5.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.httpcomponents/httpclient/4.3.3/18f4247ff4572a074444572cee34647c43e7c9c7/httpclient-4.3.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/commons-codec/commons-codec/1.10/4b95f4897fa13f2cd904aee711aeafc0c5295cd8/commons-codec-1.10.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.java.jinput/jinput/2.0.5/39c7796b469a600f72380316f6b1f11db6c2c7c4/jinput-2.0.5.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/net.java.jutils/jutils/1.0.0/e12fe1fda814bd348c1579329c86943d2cd3c6a6/jutils-1.0.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.mojang/brigadier/1.0.17/c6b7dc51dd44379cc751b7504816006e9be4b1e6/brigadier-1.0.17.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.mojang/datafixerupper/4.0.26/ebd6690f33871ccee9b6132c6480668ee2e35020/datafixerupper-4.0.26.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.google.code.gson/gson/2.8.0/c4ba5371a29ac9b2ad6129b1d39ea38750043eff/gson-2.8.0.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.mojang/authlib/2.1.28/ad54da276bf59983d02d5ed16fc14541354c71fd/authlib-2.1.28.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.commons/commons-compress/1.8.1/a698750c16740fd5b3871425f4cb3bbaa87f529d/commons-compress-1.8.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/commons-logging/commons-logging/1.1.3/f6f66e966c70a83ffbdb6f17a0919eaf7c8aca7f/commons-logging-1.1.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.apache.httpcomponents/httpcore/4.3.2/31fbbff1ddbf98f3aa7377c94d33b0447c646b6e/httpcore-4.3.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/it.unimi.dsi/fastutil/8.2.1/5ad88f325e424f8dbc2be5459e21ea5cab3864e9/fastutil-8.2.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.lwjgl/lwjgl-jemalloc/3.2.2/ee8e57a79300f78294576d87c4a587f8c99402e2/lwjgl-jemalloc-3.2.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.lwjgl/lwjgl-openal/3.2.2/2b772a102b0a11ee5f2109a5b136f4dc7c630827/lwjgl-openal-3.2.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.lwjgl/lwjgl-opengl/3.2.2/6ac5bb88b44c43ea195a570aab059f63da004cd8/lwjgl-opengl-3.2.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.lwjgl/lwjgl-glfw/3.2.2/d3ad4df38e400b8afba1de63f84338809399df5b/lwjgl-glfw-3.2.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.lwjgl/lwjgl-stb/3.2.2/3b8e6ebc5851dd3d17e37e5cadce2eff2a429f0f/lwjgl-stb-3.2.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.lwjgl/lwjgl-tinyfd/3.2.2/fcbe606c8f8da6f8f9a05e2c540eb1ee8632b0e9/lwjgl-tinyfd-3.2.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.lwjgl/lwjgl/3.2.2/8ad6294407e15780b43e84929c40e4c5e997972e/lwjgl-3.2.2.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/com.mojang/text2speech/1.11.3/f378f889797edd7df8d32272c06ca80a1b6b0f58/text2speech-1.11.3.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.codehaus.plexus/plexus-utils/3.2.1/13b015768e0d04849d2794e4c47eb02d01a0de32/plexus-utils-3.2.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-api/1.8.0-beta4/83b0359d847ee053d745be7ec0d8e9e8a44304b4/slf4j-api-1.8.0-beta4.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.jline/jline-reader/3.12.1/4382ab1382c7b6f379377ed5f665dc2f6e1218bc/jline-reader-3.12.1.jar
|
||||
- /home/rardog/.gradle/caches/modules-2/files-2.1/org.jline/jline-terminal/3.12.1/c777448314e050d980a6b697c140f3bfe9eb7416/jline-terminal-3.12.1.jar
|
||||
Working directory: /home/rardog/Документы/Mod/build/reobfJar
|
||||
Main class: net.md_5.specialsource.SpecialSource
|
||||
====================================
|
||||
Loading mappings
|
||||
Loading mappings... 0%
|
||||
Loading mappings... 10%
|
||||
Loading mappings... 20%
|
||||
Loading mappings... 30%
|
||||
Loading mappings... 40%
|
||||
Loading mappings... 50%
|
||||
0 packages, 5447 classes, 21481 fields, 38359 methods
|
||||
Remapping final jar
|
||||
Remapping jar... 0%
|
||||
Remapping jar... 10%
|
||||
Remapping jar... 20%
|
||||
Remapping jar... 30%
|
||||
Remapping jar... 40%
|
||||
Remapping jar... 50%
|
||||
Remapping jar... 60%
|
||||
Remapping jar... 70%
|
||||
Remapping jar... 80%
|
||||
Remapping jar... 90%
|
||||
Remapping jar... 100%
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
modLoader="javafml"
|
||||
loaderVersion="[36,)"
|
||||
license="MIT"
|
||||
|
||||
[[mods]]
|
||||
modId="navidromeplayer"
|
||||
version="1.0.0"
|
||||
displayName="Navidrome Player"
|
||||
authors="rardog"
|
||||
description='''
|
||||
Navidrome music player block for Minecraft with Subsonic API support and multiplayer security.
|
||||
Stream MP3, FLAC, and your music library directly from your personal Navidrome server!
|
||||
'''
|
||||
|
||||
[[dependencies.navidromeplayer]]
|
||||
modId="forge"
|
||||
mandatory=true
|
||||
versionRange="[36.2,)"
|
||||
ordering="NONE"
|
||||
side="BOTH"
|
||||
|
||||
[[dependencies.navidromeplayer]]
|
||||
modId="minecraft"
|
||||
mandatory=true
|
||||
versionRange="[1.16.5,1.17)"
|
||||
ordering="NONE"
|
||||
side="BOTH"
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": { "model": "navidromeplayer:block/navidrome_player" },
|
||||
"facing=south": { "model": "navidromeplayer:block/navidrome_player" },
|
||||
"facing=west": { "model": "navidromeplayer:block/navidrome_player" },
|
||||
"facing=east": { "model": "navidromeplayer:block/navidrome_player" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"block.navidromeplayer.navidrome_player": "Navidrome Player",
|
||||
"item.navidromeplayer.navidrome_player": "Navidrome Player",
|
||||
"gui.navidromeplayer.title": "Navidrome Player",
|
||||
"gui.navidromeplayer.tab.search": "Search",
|
||||
"gui.navidromeplayer.tab.playlists": "Playlists",
|
||||
"gui.navidromeplayer.tab.albums": "Albums",
|
||||
"gui.navidromeplayer.tab.settings": "Settings",
|
||||
"gui.navidromeplayer.search.btn": "Search",
|
||||
"gui.navidromeplayer.search.placeholder": "Search music...",
|
||||
"gui.navidromeplayer.settings.server_url": "Server URL:",
|
||||
"gui.navidromeplayer.settings.username": "Username:",
|
||||
"gui.navidromeplayer.settings.password": "Password:",
|
||||
"gui.navidromeplayer.settings.test": "Test",
|
||||
"gui.navidromeplayer.settings.save": "Save",
|
||||
"gui.navidromeplayer.playback.play": "Play",
|
||||
"gui.navidromeplayer.playback.pause": "Pause",
|
||||
"gui.navidromeplayer.playback.stop": "Stop",
|
||||
"gui.navidromeplayer.playback.volume": "Volume",
|
||||
"gui.navidromeplayer.playback.now_playing": "Now Playing:",
|
||||
"gui.navidromeplayer.playback.not_playing": "Not playing",
|
||||
"message.navidromeplayer.not_owner": "This player belongs to %s. Access denied!"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"block.navidromeplayer.navidrome_player": "Проигрыватель Navidrome",
|
||||
"item.navidromeplayer.navidrome_player": "Проигрыватель Navidrome",
|
||||
"gui.navidromeplayer.title": "Проигрыватель Navidrome",
|
||||
"gui.navidromeplayer.tab.search": "Поиск",
|
||||
"gui.navidromeplayer.tab.playlists": "Плейлисты",
|
||||
"gui.navidromeplayer.tab.albums": "Альбомы",
|
||||
"gui.navidromeplayer.tab.settings": "Настройки",
|
||||
"gui.navidromeplayer.search.btn": "Найти",
|
||||
"gui.navidromeplayer.search.placeholder": "Поиск треков...",
|
||||
"gui.navidromeplayer.settings.server_url": "Адрес сервера:",
|
||||
"gui.navidromeplayer.settings.username": "Логин:",
|
||||
"gui.navidromeplayer.settings.password": "Пароль:",
|
||||
"gui.navidromeplayer.settings.test": "Проверить",
|
||||
"gui.navidromeplayer.settings.save": "Сохранить",
|
||||
"gui.navidromeplayer.playback.play": "Играть",
|
||||
"gui.navidromeplayer.playback.pause": "Пауза",
|
||||
"gui.navidromeplayer.playback.stop": "Стоп",
|
||||
"gui.navidromeplayer.playback.volume": "Громкость",
|
||||
"gui.navidromeplayer.playback.now_playing": "Сейчас играет:",
|
||||
"gui.navidromeplayer.playback.not_playing": "Музыка не играет",
|
||||
"message.navidromeplayer.not_owner": "Этот проигрыватель принадлежит %s. Доступ закрыт!"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"parent": "minecraft:block/jukebox"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"parent": "minecraft:item/jukebox"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 305 B |
Binary file not shown.
|
After Width: | Height: | Size: 398 B |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"pattern": [
|
||||
" R ",
|
||||
"RJR",
|
||||
" R "
|
||||
],
|
||||
"key": {
|
||||
"J": {
|
||||
"item": "minecraft:jukebox"
|
||||
},
|
||||
"R": {
|
||||
"item": "minecraft:redstone"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"item": "navidromeplayer:navidrome_player",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Navidrome Player Resources",
|
||||
"pack_format": 6
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
Manifest-Version: 1.0
|
||||
Implementation-Title: navidromeplayer
|
||||
Implementation-Version: 1.0.0
|
||||
Specification-Vendor: rardog
|
||||
Specification-Title: navidromeplayer
|
||||
Implementation-Timestamp: 2026-09-28T13:41:30+0300
|
||||
Specification-Version: 1
|
||||
Implementation-Vendor: rardog
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx3G
|
||||
org.gradle.java.home=/home/rardog/.jdks/temurin-8
|
||||
Vendored
BIN
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'navidromeplayer'
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.rardog.navidromeplayer;
|
||||
|
||||
import com.rardog.navidromeplayer.block.ModBlocks;
|
||||
import com.rardog.navidromeplayer.block.ModItems;
|
||||
import com.rardog.navidromeplayer.network.NetworkHandler;
|
||||
import com.rardog.navidromeplayer.network.PacketBroadcastPlayback;
|
||||
import com.rardog.navidromeplayer.tileentity.ModTileEntities;
|
||||
import com.rardog.navidromeplayer.tileentity.NavidromePlayerTileEntity;
|
||||
import net.minecraft.entity.player.ServerPlayerEntity;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.IEventBus;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
|
||||
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
@Mod(NavidromePlayerMod.MOD_ID)
|
||||
public class NavidromePlayerMod {
|
||||
public static final String MOD_ID = "navidromeplayer";
|
||||
public static final Logger LOGGER = LogManager.getLogger();
|
||||
|
||||
public NavidromePlayerMod() {
|
||||
IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus();
|
||||
|
||||
ModBlocks.BLOCKS.register(modEventBus);
|
||||
ModItems.ITEMS.register(modEventBus);
|
||||
ModTileEntities.TILE_ENTITIES.register(modEventBus);
|
||||
|
||||
modEventBus.addListener(this::commonSetup);
|
||||
modEventBus.addListener(this::clientSetup);
|
||||
|
||||
MinecraftForge.EVENT_BUS.register(this);
|
||||
}
|
||||
|
||||
private void commonSetup(final FMLCommonSetupEvent event) {
|
||||
event.enqueueWork(() -> {
|
||||
NavidromePlayerTileEntity.TYPE = ModTileEntities.NAVIDROME_PLAYER.get();
|
||||
NetworkHandler.register();
|
||||
LOGGER.info("[NavidromePlayer] Initialized common setup & network protocol.");
|
||||
});
|
||||
}
|
||||
|
||||
private void clientSetup(final net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent event) {
|
||||
net.minecraftforge.fml.client.registry.ClientRegistry.bindTileEntityRenderer(
|
||||
ModTileEntities.NAVIDROME_PLAYER.get(),
|
||||
com.rardog.navidromeplayer.client.render.NavidromePlayerRenderer::new
|
||||
);
|
||||
MinecraftForge.EVENT_BUS.register(com.rardog.navidromeplayer.client.lyrics.LyricsHudOverlay.getInstance());
|
||||
MinecraftForge.EVENT_BUS.register(new com.rardog.navidromeplayer.client.command.ClientMusicCommands());
|
||||
LOGGER.info("[NavidromePlayer] Registered tile entity renderer, lyrics HUD overlay, and client music commands.");
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
if (event.getPlayer() instanceof ServerPlayerEntity) {
|
||||
ServerPlayerEntity serverPlayer = (ServerPlayerEntity) event.getPlayer();
|
||||
PacketBroadcastPlayback active = NavidromePlayerTileEntity.getActiveBroadcast();
|
||||
if (active != null) {
|
||||
NetworkHandler.sendToPlayer(serverPlayer, active);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.rardog.navidromeplayer.block;
|
||||
|
||||
import net.minecraft.block.Block;
|
||||
import net.minecraftforge.fml.RegistryObject;
|
||||
import net.minecraftforge.registries.DeferredRegister;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
|
||||
public class ModBlocks {
|
||||
public static final DeferredRegister<Block> BLOCKS = DeferredRegister.create(ForgeRegistries.BLOCKS, "navidromeplayer");
|
||||
|
||||
public static final RegistryObject<Block> NAVIDROME_PLAYER = BLOCKS.register("navidrome_player", NavidromePlayerBlock::new);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.rardog.navidromeplayer.block;
|
||||
|
||||
import net.minecraft.item.BlockItem;
|
||||
import net.minecraft.item.Item;
|
||||
import net.minecraft.item.ItemGroup;
|
||||
import net.minecraftforge.fml.RegistryObject;
|
||||
import net.minecraftforge.registries.DeferredRegister;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
|
||||
public class ModItems {
|
||||
public static final DeferredRegister<Item> ITEMS = DeferredRegister.create(ForgeRegistries.ITEMS, "navidromeplayer");
|
||||
|
||||
public static final RegistryObject<Item> NAVIDROME_PLAYER = ITEMS.register("navidrome_player",
|
||||
() -> new BlockItem(ModBlocks.NAVIDROME_PLAYER.get(), new Item.Properties().tab(ItemGroup.TAB_REDSTONE)));
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.rardog.navidromeplayer.block;
|
||||
|
||||
import com.rardog.navidromeplayer.client.ClientHooks;
|
||||
import com.rardog.navidromeplayer.tileentity.NavidromePlayerTileEntity;
|
||||
import net.minecraft.block.Block;
|
||||
import net.minecraft.block.BlockState;
|
||||
import net.minecraft.block.HorizontalBlock;
|
||||
import net.minecraft.block.SoundType;
|
||||
import net.minecraft.block.material.Material;
|
||||
import net.minecraft.entity.LivingEntity;
|
||||
import net.minecraft.entity.player.PlayerEntity;
|
||||
import net.minecraft.item.BlockItemUseContext;
|
||||
import net.minecraft.item.ItemStack;
|
||||
import net.minecraft.state.BooleanProperty;
|
||||
import net.minecraft.state.DirectionProperty;
|
||||
import net.minecraft.state.StateContainer;
|
||||
import net.minecraft.tileentity.TileEntity;
|
||||
import net.minecraft.util.ActionResultType;
|
||||
import net.minecraft.util.Hand;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.util.math.BlockPos;
|
||||
import net.minecraft.util.math.BlockRayTraceResult;
|
||||
import net.minecraft.util.text.StringTextComponent;
|
||||
import net.minecraft.util.text.TextFormatting;
|
||||
import net.minecraft.util.text.TranslationTextComponent;
|
||||
import net.minecraft.world.IBlockReader;
|
||||
import net.minecraft.world.World;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.common.ToolType;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class NavidromePlayerBlock extends Block {
|
||||
public static final DirectionProperty FACING = HorizontalBlock.FACING;
|
||||
|
||||
public NavidromePlayerBlock() {
|
||||
super(Properties.of(Material.WOOD)
|
||||
.strength(2.0f, 2.0f)
|
||||
.sound(SoundType.WOOD)
|
||||
.harvestTool(ToolType.AXE));
|
||||
this.registerDefaultState(this.stateDefinition.any()
|
||||
.setValue(FACING, net.minecraft.util.Direction.NORTH));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void createBlockStateDefinition(StateContainer.Builder<Block, BlockState> builder) {
|
||||
builder.add(FACING);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public BlockState getStateForPlacement(BlockItemUseContext context) {
|
||||
return this.defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasTileEntity(BlockState state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public TileEntity createTileEntity(BlockState state, IBlockReader world) {
|
||||
return new NavidromePlayerTileEntity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPlacedBy(World world, BlockPos pos, BlockState state, @Nullable LivingEntity placer, ItemStack stack) {
|
||||
super.setPlacedBy(world, pos, state, placer, stack);
|
||||
if (placer instanceof PlayerEntity) {
|
||||
PlayerEntity player = (PlayerEntity) placer;
|
||||
TileEntity te = world.getBlockEntity(pos);
|
||||
if (te instanceof NavidromePlayerTileEntity) {
|
||||
((NavidromePlayerTileEntity) te).setOwner(player.getUUID(), player.getGameProfile().getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ActionResultType use(BlockState state, World world, BlockPos pos, PlayerEntity player, Hand hand, BlockRayTraceResult hit) {
|
||||
TileEntity te = world.getBlockEntity(pos);
|
||||
if (te instanceof NavidromePlayerTileEntity) {
|
||||
NavidromePlayerTileEntity playerTe = (NavidromePlayerTileEntity) te;
|
||||
|
||||
if (world.isClientSide) {
|
||||
net.minecraftforge.fml.DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> ClientHooks.openScreen(pos, playerTe));
|
||||
}
|
||||
return ActionResultType.sidedSuccess(world.isClientSide);
|
||||
}
|
||||
|
||||
return ActionResultType.PASS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemove(BlockState state, World world, BlockPos pos, BlockState newState, boolean isMoving) {
|
||||
if (!state.is(newState.getBlock())) {
|
||||
if (world.isClientSide) {
|
||||
TileEntity te = world.getBlockEntity(pos);
|
||||
if (te instanceof NavidromePlayerTileEntity) {
|
||||
NavidromePlayerTileEntity playerTe = (NavidromePlayerTileEntity) te;
|
||||
net.minecraftforge.fml.DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> ClientHooks.handleBlockRemoval(playerTe));
|
||||
}
|
||||
}
|
||||
super.onRemove(state, world, pos, newState, isMoving);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void neighborChanged(BlockState state, World world, BlockPos pos, Block block, BlockPos fromPos, boolean isMoving) {
|
||||
super.neighborChanged(state, world, pos, block, fromPos, isMoving);
|
||||
if (!world.isClientSide) {
|
||||
boolean isSignal = world.hasNeighborSignal(pos);
|
||||
TileEntity te = world.getBlockEntity(pos);
|
||||
if (te instanceof NavidromePlayerTileEntity) {
|
||||
((NavidromePlayerTileEntity) te).handleNeighborSignal(isSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAnalogOutputSignal(BlockState state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAnalogOutputSignal(BlockState blockState, World world, BlockPos pos) {
|
||||
TileEntity te = world.getBlockEntity(pos);
|
||||
if (te instanceof NavidromePlayerTileEntity) {
|
||||
return ((NavidromePlayerTileEntity) te).getComparatorOutput();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public void animateTick(BlockState state, World world, BlockPos pos, java.util.Random rand) {
|
||||
TileEntity te = world.getBlockEntity(pos);
|
||||
if (te instanceof NavidromePlayerTileEntity) {
|
||||
NavidromePlayerTileEntity playerTe = (NavidromePlayerTileEntity) te;
|
||||
if (playerTe.isPlaying() && !playerTe.isPaused()) {
|
||||
if (rand.nextInt(3) == 0) {
|
||||
double x = pos.getX() + 0.5D + (rand.nextDouble() - 0.5D) * 0.4D;
|
||||
double y = pos.getY() + 1.15D;
|
||||
double z = pos.getZ() + 0.5D + (rand.nextDouble() - 0.5D) * 0.4D;
|
||||
double noteColor = rand.nextInt(25) / 24.0D;
|
||||
world.addParticle(net.minecraft.particles.ParticleTypes.NOTE, x, y, z, noteColor, 0.0D, 0.0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.rardog.navidromeplayer.client;
|
||||
|
||||
import com.rardog.navidromeplayer.client.audio.NavidromeAudioPlayer;
|
||||
import com.rardog.navidromeplayer.client.gui.NavidromePlayerScreen;
|
||||
import com.rardog.navidromeplayer.tileentity.NavidromePlayerTileEntity;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.math.BlockPos;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ClientHooks {
|
||||
public static void openScreen(BlockPos pos, NavidromePlayerTileEntity te) {
|
||||
Minecraft.getInstance().setScreen(new NavidromePlayerScreen(pos, te));
|
||||
}
|
||||
|
||||
public static void handleBlockRemoval(NavidromePlayerTileEntity playerTe) {
|
||||
if (playerTe != null && Minecraft.getInstance().player != null) {
|
||||
if (playerTe.isOwner(Minecraft.getInstance().player.getUUID())) {
|
||||
NavidromeAudioPlayer.getInstance().stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void clientTick(NavidromePlayerTileEntity te) {
|
||||
if (te != null && te.isPlaying() && !te.isPaused() && te.getLevel() != null) {
|
||||
NavidromeAudioPlayer player = NavidromeAudioPlayer.getInstance();
|
||||
float amp = player.getAudioAmplitude();
|
||||
boolean beat = player.consumeBeat();
|
||||
|
||||
te.addVinylRotation(4.0f + amp * 8.0f);
|
||||
|
||||
double posX = te.getBlockPos().getX();
|
||||
double posY = te.getBlockPos().getY();
|
||||
double posZ = te.getBlockPos().getZ();
|
||||
|
||||
if (beat) {
|
||||
int noteCount = 2 + (amp > 0.5f ? 2 : 0);
|
||||
for (int i = 0; i < noteCount; i++) {
|
||||
double x = posX + 0.5D + (te.getLevel().random.nextDouble() - 0.5D) * 0.5D;
|
||||
double y = posY + 1.15D + te.getLevel().random.nextDouble() * 0.15D;
|
||||
double z = posZ + 0.5D + (te.getLevel().random.nextDouble() - 0.5D) * 0.5D;
|
||||
double noteColor = te.getLevel().random.nextInt(25) / 24.0D;
|
||||
te.getLevel().addParticle(net.minecraft.particles.ParticleTypes.NOTE, x, y, z, noteColor, 0.0D, 0.0D);
|
||||
}
|
||||
if (amp > 0.45f) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
double x = posX + 0.5D + (te.getLevel().random.nextDouble() - 0.5D) * 0.6D;
|
||||
double y = posY + 1.05D + te.getLevel().random.nextDouble() * 0.2D;
|
||||
double z = posZ + 0.5D + (te.getLevel().random.nextDouble() - 0.5D) * 0.6D;
|
||||
te.getLevel().addParticle(net.minecraft.particles.ParticleTypes.ENCHANT, x, y, z,
|
||||
(te.getLevel().random.nextDouble() - 0.5D) * 0.2D,
|
||||
0.1D + te.getLevel().random.nextDouble() * 0.1D,
|
||||
(te.getLevel().random.nextDouble() - 0.5D) * 0.2D);
|
||||
}
|
||||
}
|
||||
} else if (te.getLevel().random.nextFloat() < (0.04f + amp * 0.16f)) {
|
||||
double x = posX + 0.5D + (te.getLevel().random.nextDouble() - 0.5D) * 0.4D;
|
||||
double y = posY + 1.15D;
|
||||
double z = posZ + 0.5D + (te.getLevel().random.nextDouble() - 0.5D) * 0.4D;
|
||||
double noteColor = te.getLevel().random.nextInt(25) / 24.0D;
|
||||
te.getLevel().addParticle(net.minecraft.particles.ParticleTypes.NOTE, x, y, z, noteColor, 0.0D, 0.0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.rardog.navidromeplayer.client;
|
||||
|
||||
import com.rardog.navidromeplayer.client.audio.NavidromeAudioPlayer;
|
||||
import com.rardog.navidromeplayer.network.PacketBroadcastPlayback;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ClientPlaybackSync {
|
||||
public static void handleBroadcast(PacketBroadcastPlayback msg) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) return;
|
||||
|
||||
// If this player is the owner who originated playback, check if server toggled pause/resume
|
||||
if (msg.ownerUuid != null && msg.ownerUuid.equals(mc.player.getUUID())) {
|
||||
NavidromeAudioPlayer localPlayer = NavidromeAudioPlayer.getInstance();
|
||||
if (msg.isPlaying) {
|
||||
if (msg.isPaused && !localPlayer.isPaused()) {
|
||||
localPlayer.pause();
|
||||
} else if (!msg.isPaused && localPlayer.isPaused()) {
|
||||
localPlayer.resume();
|
||||
}
|
||||
} else if (localPlayer.isPlaying()) {
|
||||
localPlayer.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println("[NavidromePlayer-Client] Received broadcast: playing=" + msg.isPlaying +
|
||||
", song=" + msg.songTitle + ", url=" + msg.streamUrl);
|
||||
|
||||
NavidromeAudioPlayer player = NavidromeAudioPlayer.getInstance();
|
||||
if (!msg.isPlaying) {
|
||||
player.stopRemote();
|
||||
} else {
|
||||
if (msg.isPaused) {
|
||||
player.pauseRemote();
|
||||
} else {
|
||||
player.playRemote(msg.streamUrl, msg.songId, msg.songTitle, msg.songArtist, msg.album, msg.suffix, msg.duration, msg.elapsedSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
package com.rardog.navidromeplayer.client.audio;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeApiClient;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeConfig;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeSong;
|
||||
import com.rardog.navidromeplayer.network.NetworkHandler;
|
||||
import com.rardog.navidromeplayer.network.PacketSyncPlayback;
|
||||
import javazoom.jl.decoder.Bitstream;
|
||||
import javazoom.jl.decoder.Decoder;
|
||||
import javazoom.jl.decoder.Header;
|
||||
import javazoom.jl.decoder.SampleBuffer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.math.BlockPos;
|
||||
import org.jflac.FLACDecoder;
|
||||
import org.jflac.PCMProcessor;
|
||||
import org.jflac.metadata.StreamInfo;
|
||||
import org.jflac.util.ByteData;
|
||||
|
||||
import javax.sound.sampled.*;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public class NavidromeAudioPlayer {
|
||||
private static final NavidromeAudioPlayer INSTANCE = new NavidromeAudioPlayer();
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private final AtomicLong playbackSessionId = new AtomicLong(0);
|
||||
private Thread playbackThread;
|
||||
private SourceDataLine line;
|
||||
private InputStream currentStream;
|
||||
private final AtomicBoolean isPlaying = new AtomicBoolean(false);
|
||||
private final AtomicBoolean isPaused = new AtomicBoolean(false);
|
||||
private volatile float volume = 0.8f;
|
||||
private volatile boolean isMuted = false;
|
||||
private volatile boolean isRemotePlayback = false;
|
||||
private volatile BlockPos activeBlockPos = null;
|
||||
|
||||
// Queue / Playlist
|
||||
private final List<NavidromeSong> queue = new CopyOnWriteArrayList<>();
|
||||
private volatile int currentQueueIndex = -1;
|
||||
|
||||
// Playback state
|
||||
private volatile NavidromeSong currentSong;
|
||||
private volatile int startOffsetSeconds = 0;
|
||||
private volatile long playedBytesCount = 0;
|
||||
private volatile int currentSampleRate = 44100;
|
||||
private volatile int currentChannels = 2;
|
||||
private volatile int currentBitsPerSample = 16;
|
||||
private volatile int elapsedSeconds = 0;
|
||||
|
||||
// Real-time audio analysis (amplitude & beat detection)
|
||||
private volatile float currentAudioAmplitude = 0.0f;
|
||||
private volatile boolean beatDetected = false;
|
||||
private volatile float averageEnergy = 0.1f;
|
||||
private volatile long lastBeatTimeMs = 0;
|
||||
private volatile long lastBeatPacketTimeMs = 0;
|
||||
|
||||
public float getAudioAmplitude() {
|
||||
return currentAudioAmplitude;
|
||||
}
|
||||
|
||||
public boolean consumeBeat() {
|
||||
if (beatDetected) {
|
||||
beatDetected = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public BlockPos getActiveBlockPos() {
|
||||
if (activeBlockPos != null) return activeBlockPos;
|
||||
for (com.rardog.navidromeplayer.tileentity.NavidromePlayerTileEntity te : com.rardog.navidromeplayer.tileentity.NavidromePlayerTileEntity.ACTIVE_PLAYERS) {
|
||||
if (te.isPlaying() && !te.isRemoved() && te.getBlockPos() != null) {
|
||||
return te.getBlockPos();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void analyzePcmSamples(byte[] data, int len, int bytesPerSample) {
|
||||
if (data == null || len < bytesPerSample) return;
|
||||
long sum = 0;
|
||||
int step = Math.max(bytesPerSample * 2, (len / 128) & ~(bytesPerSample - 1));
|
||||
int count = 0;
|
||||
if (bytesPerSample == 3) {
|
||||
for (int i = 0; i <= len - 3; i += step) {
|
||||
int b0 = data[i] & 0xFF;
|
||||
int b1 = data[i + 1] & 0xFF;
|
||||
int b2 = data[i + 2];
|
||||
int s24 = b0 | (b1 << 8) | (b2 << 16);
|
||||
sum += Math.abs(s24 >> 8);
|
||||
count++;
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i <= len - 2; i += step) {
|
||||
short s = (short) ((data[i] & 0xFF) | (data[i + 1] << 8));
|
||||
sum += Math.abs(s);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count == 0) return;
|
||||
float amp = Math.max(0.0f, Math.min(1.0f, (float) sum / (count * 32767.0f)));
|
||||
this.currentAudioAmplitude = 0.65f * this.currentAudioAmplitude + 0.35f * amp;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (amp > averageEnergy * 1.35f && amp > 0.08f && (now - lastBeatTimeMs >= 130)) {
|
||||
beatDetected = true;
|
||||
lastBeatTimeMs = now;
|
||||
BlockPos targetPos = getActiveBlockPos();
|
||||
if (targetPos != null && (now - lastBeatPacketTimeMs >= 160)) {
|
||||
lastBeatPacketTimeMs = now;
|
||||
int redstoneLevel = Math.min(15, Math.max(6, (int) (amp * 18.0f)));
|
||||
try {
|
||||
NetworkHandler.INSTANCE.sendToServer(new com.rardog.navidromeplayer.network.PacketBeatPulse(targetPos, redstoneLevel));
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
}
|
||||
averageEnergy = 0.95f * averageEnergy + 0.05f * amp;
|
||||
}
|
||||
|
||||
public static NavidromeAudioPlayer getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private NavidromeAudioPlayer() {
|
||||
this.volume = NavidromeConfig.get().volume;
|
||||
loadQueueFromFile();
|
||||
}
|
||||
|
||||
public synchronized void play(NavidromeSong song) {
|
||||
if (song == null) return;
|
||||
// Check if song is in queue, if not add it
|
||||
int idx = -1;
|
||||
for (int i = 0; i < queue.size(); i++) {
|
||||
if (queue.get(i).id.equals(song.id)) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx == -1) {
|
||||
queue.add(song);
|
||||
idx = queue.size() - 1;
|
||||
saveQueueToFile();
|
||||
}
|
||||
playQueueIndex(idx, 0);
|
||||
}
|
||||
|
||||
public synchronized void playQueueIndex(int index, int offsetSeconds) {
|
||||
if (index < 0 || index >= queue.size()) return;
|
||||
final long sessionId = playbackSessionId.incrementAndGet();
|
||||
stopStreamOnly();
|
||||
|
||||
this.isRemotePlayback = false;
|
||||
this.currentQueueIndex = index;
|
||||
this.currentSong = queue.get(index);
|
||||
this.startOffsetSeconds = offsetSeconds;
|
||||
this.elapsedSeconds = offsetSeconds;
|
||||
this.playedBytesCount = 0;
|
||||
this.isPlaying.set(true);
|
||||
this.isPaused.set(false);
|
||||
|
||||
syncPlaybackToServer();
|
||||
|
||||
final NavidromeSong songToPlay = this.currentSong;
|
||||
com.rardog.navidromeplayer.client.lyrics.LyricsManager.getInstance().loadLyrics(songToPlay);
|
||||
try {
|
||||
Minecraft.getInstance().execute(() -> {
|
||||
Minecraft.getInstance().getToasts().addToast(new com.rardog.navidromeplayer.client.gui.NowPlayingToast(songToPlay));
|
||||
});
|
||||
} catch (Throwable ignored) {}
|
||||
|
||||
playbackThread = new Thread(() -> {
|
||||
boolean shouldAdvance = false;
|
||||
long streamStartMs = System.currentTimeMillis();
|
||||
try {
|
||||
startStream(songToPlay, offsetSeconds);
|
||||
if (sessionId == playbackSessionId.get()) {
|
||||
// Only advance if we actually played something meaningful.
|
||||
// If the stream ended in < 2 seconds it's a bad/deleted track — don't loop.
|
||||
long playedMs = System.currentTimeMillis() - streamStartMs;
|
||||
shouldAdvance = (playedMs >= 2000);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (sessionId == playbackSessionId.get()) {
|
||||
boolean nearEnd = (songToPlay.duration > 0 && elapsedSeconds >= songToPlay.duration - 3);
|
||||
if (nearEnd || e instanceof java.io.EOFException) {
|
||||
long playedMs = System.currentTimeMillis() - streamStartMs;
|
||||
shouldAdvance = (playedMs >= 2000);
|
||||
} else if (isPlaying.get() && playbackThread == Thread.currentThread()) {
|
||||
System.err.println("[NavidromeAudioPlayer] Playback error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (sessionId == playbackSessionId.get()) {
|
||||
cleanupLine();
|
||||
if (shouldAdvance && isPlaying.get()) {
|
||||
try {
|
||||
Thread.sleep(200);
|
||||
} catch (InterruptedException ignored) {}
|
||||
|
||||
if (isPlaying.get() && sessionId == playbackSessionId.get()) {
|
||||
playNext();
|
||||
}
|
||||
} else if (!shouldAdvance && sessionId == playbackSessionId.get()) {
|
||||
// Stream failed or was too short — stop cleanly instead of looping
|
||||
isPlaying.set(false);
|
||||
syncPlaybackToServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "Navidrome-AudioPlayer-Thread");
|
||||
playbackThread.setDaemon(true);
|
||||
playbackThread.start();
|
||||
}
|
||||
|
||||
public synchronized void seek(int targetSeconds) {
|
||||
if (currentSong == null || currentQueueIndex < 0) return;
|
||||
int maxDur = currentSong.duration > 0 ? currentSong.duration : 3600;
|
||||
int clampedSeconds = Math.max(0, Math.min(targetSeconds, maxDur));
|
||||
playQueueIndex(currentQueueIndex, clampedSeconds);
|
||||
}
|
||||
|
||||
public synchronized void playNext() {
|
||||
if (queue.isEmpty()) return;
|
||||
int next = (currentQueueIndex + 1) % queue.size();
|
||||
playQueueIndex(next, 0);
|
||||
}
|
||||
|
||||
public synchronized void playPrevious() {
|
||||
if (queue.isEmpty()) return;
|
||||
// If played more than 3 seconds, restart current track first
|
||||
if (elapsedSeconds > 3) {
|
||||
seek(0);
|
||||
return;
|
||||
}
|
||||
int prev = (currentQueueIndex - 1 + queue.size()) % queue.size();
|
||||
playQueueIndex(prev, 0);
|
||||
}
|
||||
|
||||
public synchronized void addToQueue(NavidromeSong song) {
|
||||
if (song == null) return;
|
||||
// Avoid duplicate ID in queue if desired, or allow
|
||||
for (NavidromeSong s : queue) {
|
||||
if (s.id.equals(song.id)) return; // already in queue
|
||||
}
|
||||
queue.add(song);
|
||||
saveQueueToFile();
|
||||
}
|
||||
|
||||
public synchronized void removeFromQueue(int index) {
|
||||
if (index >= 0 && index < queue.size()) {
|
||||
boolean removingCurrent = (index == currentQueueIndex);
|
||||
queue.remove(index);
|
||||
if (removingCurrent) {
|
||||
if (queue.isEmpty()) {
|
||||
stop();
|
||||
} else {
|
||||
int next = index % queue.size();
|
||||
playQueueIndex(next, 0);
|
||||
}
|
||||
} else if (index < currentQueueIndex) {
|
||||
currentQueueIndex--;
|
||||
}
|
||||
saveQueueToFile();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void clearQueue() {
|
||||
stop();
|
||||
queue.clear();
|
||||
currentQueueIndex = -1;
|
||||
saveQueueToFile();
|
||||
}
|
||||
|
||||
public List<NavidromeSong> getQueue() {
|
||||
return queue;
|
||||
}
|
||||
|
||||
public int getCurrentQueueIndex() {
|
||||
return currentQueueIndex;
|
||||
}
|
||||
|
||||
private boolean startStream(NavidromeSong song, int offsetSeconds) throws Exception {
|
||||
String streamUrl = NavidromeApiClient.getStreamUrl(song.id, offsetSeconds);
|
||||
return startStreamFromUrl(streamUrl, song, offsetSeconds);
|
||||
}
|
||||
|
||||
private boolean startStreamFromUrl(String streamUrl, NavidromeSong song, int offsetSeconds) throws Exception {
|
||||
URL url = new URL(streamUrl);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("User-Agent", "NavidromePlayerMod/1.0");
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(30000);
|
||||
|
||||
int status = conn.getResponseCode();
|
||||
if (status != 200) {
|
||||
throw new RuntimeException("Stream failed with HTTP " + status);
|
||||
}
|
||||
|
||||
String contentType = conn.getContentType();
|
||||
InputStream rawIn = conn.getInputStream();
|
||||
BufferedInputStream in = new BufferedInputStream(rawIn, 131072);
|
||||
this.currentStream = in;
|
||||
|
||||
boolean isFlac = (song != null && song.isFlac()) || (contentType != null && contentType.toLowerCase().contains("flac"));
|
||||
|
||||
in.mark(16);
|
||||
byte[] magic = new byte[4];
|
||||
int read = in.read(magic);
|
||||
in.reset();
|
||||
|
||||
if (read >= 4 && magic[0] == 'f' && magic[1] == 'L' && magic[2] == 'a' && magic[3] == 'C') {
|
||||
isFlac = true;
|
||||
}
|
||||
|
||||
if (isFlac) {
|
||||
return playFlac(in, offsetSeconds);
|
||||
} else {
|
||||
return playMp3(in, offsetSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
private volatile boolean isNative24 = false;
|
||||
|
||||
public boolean isNative24BitActive() {
|
||||
return isNative24;
|
||||
}
|
||||
|
||||
public String getAudioFormatQualityString() {
|
||||
if (currentSong == null) return "";
|
||||
double khz = currentSampleRate / 1000.0;
|
||||
String khzStr = (khz == (long) khz) ? String.format("%d", (long) khz) : String.format("%.1f", khz);
|
||||
if (currentSong.isFlac()) {
|
||||
return "FLAC " + currentBitsPerSample + "/" + khzStr + (isNative24 ? " True Hi-Res" : "");
|
||||
} else {
|
||||
if (currentSong.bitRate > 0) {
|
||||
return "MP3 " + currentSong.bitRate + "k";
|
||||
}
|
||||
return "MP3 " + currentBitsPerSample + "/" + khzStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays FLAC stream with full support for true native 24-bit and 16-bit audio.
|
||||
*/
|
||||
private boolean playFlac(InputStream in, int offsetSeconds) throws Exception {
|
||||
// Capture session ID at the moment this decode starts.
|
||||
// If playbackSessionId changes (new song/stop), we must exit immediately
|
||||
// to prevent this old thread writing to the NEW audio line.
|
||||
final long mySession = playbackSessionId.get();
|
||||
|
||||
FLACDecoder flacDecoder = new FLACDecoder(in);
|
||||
final boolean[] finishedNaturally = {true};
|
||||
final long[] skippedBytes = {0};
|
||||
|
||||
flacDecoder.addPCMProcessor(new PCMProcessor() {
|
||||
@Override
|
||||
public void processStreamInfo(StreamInfo info) {
|
||||
currentSampleRate = info.getSampleRate();
|
||||
currentChannels = info.getChannels();
|
||||
currentBitsPerSample = info.getBitsPerSample();
|
||||
|
||||
try {
|
||||
openBestLine(currentSampleRate, currentChannels, currentBitsPerSample);
|
||||
} catch (Exception e) {
|
||||
System.err.println("[NavidromeAudioPlayer] Error opening audio line: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processPCM(ByteData byteData) {
|
||||
// Stop immediately if our session is no longer active —
|
||||
// prevents writing to the NEW line opened by a different thread.
|
||||
if (mySession != playbackSessionId.get() || !isPlaying.get()) {
|
||||
finishedNaturally[0] = false;
|
||||
return;
|
||||
}
|
||||
|
||||
while (isPaused.get() && isPlaying.get()) {
|
||||
try {
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
finishedNaturally[0] = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Re-check after pause loop — may have been stopped
|
||||
if (!isPlaying.get() || Thread.currentThread().isInterrupted()
|
||||
|| mySession != playbackSessionId.get()) {
|
||||
finishedNaturally[0] = false;
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] data = byteData.getData();
|
||||
int len = byteData.getLen();
|
||||
// Capture the line reference ONCE so that even if another thread
|
||||
// opens a new line, we can't accidentally write to it.
|
||||
SourceDataLine localLine = line;
|
||||
if (data == null || len <= 0 || localLine == null || !localLine.isOpen()) return;
|
||||
|
||||
if (offsetSeconds > 0) {
|
||||
int bytesPerSample = (currentBitsPerSample == 24) ? 3 : 2;
|
||||
int frameSize = currentChannels * bytesPerSample;
|
||||
long targetBytesToSkip = (long) offsetSeconds * currentSampleRate * frameSize;
|
||||
|
||||
if (skippedBytes[0] < targetBytesToSkip) {
|
||||
long remaining = targetBytesToSkip - skippedBytes[0];
|
||||
if (len <= remaining) {
|
||||
skippedBytes[0] += len;
|
||||
return; // Fast-forward skip
|
||||
} else {
|
||||
int skip = (int) remaining;
|
||||
skip = (skip / frameSize) * frameSize;
|
||||
skippedBytes[0] += skip;
|
||||
data = Arrays.copyOfRange(data, skip, len);
|
||||
len = data.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analyzePcmSamples(data, len, (currentBitsPerSample == 24) ? 3 : 2);
|
||||
|
||||
float vol = getEffectiveVolume();
|
||||
|
||||
if (currentBitsPerSample == 24) {
|
||||
if (isNative24) {
|
||||
// TRUE NATIVE 24-BIT BIT-PERFECT PLAYBACK
|
||||
if (Math.abs(vol - 1.0f) < 0.01f) {
|
||||
localLine.write(data, 0, len);
|
||||
} else {
|
||||
byte[] out24 = new byte[len];
|
||||
for (int i = 0; i <= len - 3; i += 3) {
|
||||
int b0 = data[i] & 0xFF;
|
||||
int b1 = data[i + 1] & 0xFF;
|
||||
int b2 = data[i + 2]; // sign-extended
|
||||
int sample24 = b0 | (b1 << 8) | (b2 << 16);
|
||||
int scaled = Math.round(sample24 * vol);
|
||||
scaled = Math.max(-8388608, Math.min(8388607, scaled));
|
||||
|
||||
out24[i] = (byte) (scaled & 0xFF);
|
||||
out24[i + 1] = (byte) ((scaled >> 8) & 0xFF);
|
||||
out24[i + 2] = (byte) ((scaled >> 16) & 0xFF);
|
||||
}
|
||||
localLine.write(out24, 0, len);
|
||||
}
|
||||
updatePosition(len, 3);
|
||||
} else {
|
||||
// Fallback downsample to 16-bit for hardware that doesn't support 24-bit
|
||||
int numSamples = len / 3;
|
||||
byte[] pcm16 = new byte[numSamples * 2];
|
||||
int outIdx = 0;
|
||||
for (int i = 0; i <= len - 3; i += 3) {
|
||||
int b0 = data[i] & 0xFF;
|
||||
int b1 = data[i + 1] & 0xFF;
|
||||
int b2 = data[i + 2];
|
||||
int sample24 = b0 | (b1 << 8) | (b2 << 16);
|
||||
int sample16 = sample24 >> 8;
|
||||
int scaled = Math.round(sample16 * vol);
|
||||
scaled = Math.max(-32768, Math.min(32767, scaled));
|
||||
|
||||
pcm16[outIdx++] = (byte) (scaled & 0xFF);
|
||||
pcm16[outIdx++] = (byte) ((scaled >> 8) & 0xFF);
|
||||
}
|
||||
localLine.write(pcm16, 0, outIdx);
|
||||
updatePosition(outIdx, 2);
|
||||
}
|
||||
} else {
|
||||
// Standard 16-bit PCM
|
||||
if (Math.abs(vol - 1.0f) < 0.01f) {
|
||||
localLine.write(data, 0, len);
|
||||
} else {
|
||||
byte[] pcm16 = new byte[len];
|
||||
for (int i = 0; i <= len - 2; i += 2) {
|
||||
short sample = (short) ((data[i] & 0xFF) | (data[i + 1] << 8));
|
||||
int scaled = Math.round(sample * vol);
|
||||
scaled = Math.max(-32768, Math.min(32767, scaled));
|
||||
|
||||
pcm16[i] = (byte) (scaled & 0xFF);
|
||||
pcm16[i + 1] = (byte) ((scaled >> 8) & 0xFF);
|
||||
}
|
||||
localLine.write(pcm16, 0, len);
|
||||
}
|
||||
updatePosition(len, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
flacDecoder.decode();
|
||||
return finishedNaturally[0] && isPlaying.get();
|
||||
}
|
||||
|
||||
private synchronized void openBestLine(int sampleRate, int channels, int bitsPerSample) throws LineUnavailableException {
|
||||
cleanupLine();
|
||||
|
||||
isNative24 = false;
|
||||
|
||||
// If source is 24-bit, try to open true native 24-bit line
|
||||
if (bitsPerSample == 24) {
|
||||
try {
|
||||
AudioFormat f24 = new AudioFormat(sampleRate, 24, channels, true, false);
|
||||
DataLine.Info info = new DataLine.Info(SourceDataLine.class, f24);
|
||||
if (AudioSystem.isLineSupported(info)) {
|
||||
line = (SourceDataLine) AudioSystem.getLine(info);
|
||||
line.open(f24, 196608); // ~1.1 sec buffer
|
||||
line.start();
|
||||
isNative24 = true;
|
||||
System.out.println("[NavidromeAudioPlayer] Outputting TRUE NATIVE 24-BIT FLAC (" + sampleRate + " Hz)");
|
||||
return;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[NavidromeAudioPlayer] 24-bit native line not supported, using 16-bit: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Standard 16-bit line
|
||||
AudioFormat f16 = new AudioFormat(sampleRate, 16, channels, true, false);
|
||||
DataLine.Info info = new DataLine.Info(SourceDataLine.class, f16);
|
||||
line = (SourceDataLine) AudioSystem.getLine(info);
|
||||
line.open(f16, 196608); // ~1.1 sec buffer
|
||||
line.start();
|
||||
System.out.println("[NavidromeAudioPlayer] Outputting 16-bit PCM (" + sampleRate + " Hz)");
|
||||
}
|
||||
|
||||
private boolean playMp3(InputStream in, int offsetSeconds) throws Exception {
|
||||
Bitstream bitstream = new Bitstream(in);
|
||||
Decoder decoder = new Decoder();
|
||||
Header header;
|
||||
|
||||
final long mySession = playbackSessionId.get();
|
||||
|
||||
boolean metadataInitialized = false;
|
||||
boolean lineInitialized = false;
|
||||
long targetSamplesToSkip = 0;
|
||||
long skippedSamples = 0;
|
||||
SourceDataLine localLine = null;
|
||||
|
||||
while (isPlaying.get() && !Thread.currentThread().isInterrupted()
|
||||
&& mySession == playbackSessionId.get()
|
||||
&& (header = bitstream.readFrame()) != null) {
|
||||
while (isPaused.get() && isPlaying.get()) {
|
||||
try {
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isPlaying.get() || Thread.currentThread().isInterrupted()
|
||||
|| mySession != playbackSessionId.get()) break;
|
||||
|
||||
SampleBuffer output = (SampleBuffer) decoder.decodeFrame(header, bitstream);
|
||||
if (!metadataInitialized) {
|
||||
currentSampleRate = output.getSampleFrequency();
|
||||
currentChannels = output.getChannelCount();
|
||||
currentBitsPerSample = 16;
|
||||
targetSamplesToSkip = (long) offsetSeconds * currentSampleRate * currentChannels;
|
||||
metadataInitialized = true;
|
||||
}
|
||||
|
||||
short[] samples = output.getBuffer();
|
||||
int sampleCount = output.getBufferLength();
|
||||
|
||||
// Skip frames until we reach the desired offset — do NOT open the audio line
|
||||
// until we're done skipping. Opening it early causes buffer underrun sounds
|
||||
// (hardware plays silence while we skip → clicks and pops for the listener).
|
||||
if (offsetSeconds > 0 && skippedSamples < targetSamplesToSkip) {
|
||||
long remaining = targetSamplesToSkip - skippedSamples;
|
||||
if (sampleCount <= remaining) {
|
||||
skippedSamples += sampleCount;
|
||||
bitstream.closeFrame();
|
||||
continue;
|
||||
} else {
|
||||
int skip = (int) remaining;
|
||||
skippedSamples += skip;
|
||||
short[] trimmed = new short[sampleCount - skip];
|
||||
System.arraycopy(samples, skip, trimmed, 0, trimmed.length);
|
||||
samples = trimmed;
|
||||
sampleCount = trimmed.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the audio line only now — after any skip phase is complete.
|
||||
if (!lineInitialized) {
|
||||
openLine(currentSampleRate, currentChannels, 16);
|
||||
localLine = line; // capture reference; prevents writing to a future new line
|
||||
lineInitialized = true;
|
||||
}
|
||||
|
||||
if (localLine != null && localLine.isOpen() && sampleCount > 0
|
||||
&& mySession == playbackSessionId.get()) {
|
||||
byte[] pcm = new byte[sampleCount * 2];
|
||||
float vol = getEffectiveVolume();
|
||||
for (int i = 0; i < sampleCount; i++) {
|
||||
short s = (short) Math.max(-32768, Math.min(32767, Math.round(samples[i] * vol)));
|
||||
pcm[i * 2] = (byte) (s & 0xFF);
|
||||
pcm[i * 2 + 1] = (byte) ((s >> 8) & 0xFF);
|
||||
}
|
||||
localLine.write(pcm, 0, pcm.length);
|
||||
analyzePcmSamples(pcm, pcm.length, 2);
|
||||
updatePosition(pcm.length, 2);
|
||||
}
|
||||
|
||||
bitstream.closeFrame();
|
||||
}
|
||||
|
||||
return isPlaying.get() && mySession == playbackSessionId.get();
|
||||
}
|
||||
|
||||
private synchronized void openLine(int sampleRate, int channels, int bitsPerSample) throws LineUnavailableException {
|
||||
cleanupLine();
|
||||
isNative24 = false; // always reset — openLine is for non-24-bit paths
|
||||
AudioFormat format = new AudioFormat(sampleRate, bitsPerSample, channels, true, false);
|
||||
DataLine.Info info = new DataLine.Info(SourceDataLine.class, format);
|
||||
line = (SourceDataLine) AudioSystem.getLine(info);
|
||||
line.open(format, 196608); // ~1.1 sec buffer (was 65536 = 370ms) — reduces stutter on network hiccups
|
||||
line.start();
|
||||
}
|
||||
|
||||
private void updatePosition(int writtenBytes, int bytesPerSample) {
|
||||
playedBytesCount += writtenBytes;
|
||||
int bytesPerSec = currentSampleRate * currentChannels * bytesPerSample;
|
||||
if (bytesPerSec > 0) {
|
||||
elapsedSeconds = startOffsetSeconds + (int) (playedBytesCount / bytesPerSec);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void pause() {
|
||||
if (isPlaying.get()) {
|
||||
isPaused.set(true);
|
||||
if (line != null && line.isOpen()) {
|
||||
line.stop();
|
||||
}
|
||||
syncPlaybackToServer();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void resume() {
|
||||
if (isPlaying.get() && isPaused.get()) {
|
||||
isPaused.set(false);
|
||||
if (line != null && line.isOpen()) {
|
||||
line.start();
|
||||
}
|
||||
syncPlaybackToServer();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void togglePlayPause() {
|
||||
if (isPaused.get()) {
|
||||
resume();
|
||||
} else if (isPlaying.get()) {
|
||||
pause();
|
||||
} else if (currentQueueIndex >= 0 && currentQueueIndex < queue.size()) {
|
||||
playQueueIndex(currentQueueIndex, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void stopLocalOnly() {
|
||||
isPlaying.set(false);
|
||||
isPaused.set(false);
|
||||
stopStreamOnly();
|
||||
com.rardog.navidromeplayer.client.lyrics.LyricsManager.getInstance().clear();
|
||||
currentSong = null;
|
||||
elapsedSeconds = 0;
|
||||
playedBytesCount = 0;
|
||||
startOffsetSeconds = 0;
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
stopLocalOnly();
|
||||
syncPlaybackToServer();
|
||||
}
|
||||
|
||||
public void setActiveBlockPos(BlockPos pos) {
|
||||
this.activeBlockPos = pos;
|
||||
}
|
||||
|
||||
public boolean isRemotePlayback() {
|
||||
return isRemotePlayback;
|
||||
}
|
||||
|
||||
public void setMuted(boolean muted) {
|
||||
this.isMuted = muted;
|
||||
}
|
||||
|
||||
public boolean isMuted() {
|
||||
return isMuted;
|
||||
}
|
||||
|
||||
public float getEffectiveVolume() {
|
||||
if (isMuted) return 0.0f;
|
||||
float vol = this.volume;
|
||||
try {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc != null && mc.options != null) {
|
||||
float master = mc.options.getSoundSourceVolume(net.minecraft.util.SoundCategory.MASTER);
|
||||
float music = mc.options.getSoundSourceVolume(net.minecraft.util.SoundCategory.MUSIC);
|
||||
float records = mc.options.getSoundSourceVolume(net.minecraft.util.SoundCategory.RECORDS);
|
||||
|
||||
if (master <= 0.001f) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// If user or friend adjusts "Музыка" or "Проигрыватель" in Minecraft sound settings:
|
||||
// When music is 0 (as is default when players mute vanilla background music), fallback to records.
|
||||
// If both are 0, sound is muted.
|
||||
float catVol = (music > 0.001f) ? music : records;
|
||||
vol = vol * catVol;
|
||||
}
|
||||
} catch (Throwable ignored) {}
|
||||
return Math.max(0.0f, Math.min(1.0f, vol));
|
||||
}
|
||||
|
||||
public synchronized void syncPlaybackToServer() {
|
||||
if (isRemotePlayback) return;
|
||||
try {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null || mc.getConnection() == null) {
|
||||
return;
|
||||
}
|
||||
if (currentSong != null && isPlaying.get()) {
|
||||
int offset = elapsedSeconds;
|
||||
String streamUrl = NavidromeApiClient.getStreamUrlForSharing(currentSong.id, 0);
|
||||
NetworkHandler.INSTANCE.sendToServer(new PacketSyncPlayback(
|
||||
activeBlockPos, true, isPaused.get(), streamUrl,
|
||||
currentSong.id, currentSong.title, currentSong.artist,
|
||||
currentSong.album, currentSong.suffix, currentSong.duration, offset
|
||||
));
|
||||
} else {
|
||||
NetworkHandler.INSTANCE.sendToServer(new PacketSyncPlayback(
|
||||
activeBlockPos, false, false, "", "", "", "", "", "", 0, 0
|
||||
));
|
||||
}
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
public synchronized void playRemote(String streamUrl, String songId, String title, String artist,
|
||||
String album, String suffix, int duration, int offsetSeconds) {
|
||||
if (streamUrl == null || streamUrl.trim().isEmpty()) return;
|
||||
|
||||
if (isRemotePlayback && currentSong != null && songId.equals(currentSong.id) && isPlaying.get()) {
|
||||
if (isPaused.get()) {
|
||||
isPaused.set(false);
|
||||
if (line != null && line.isOpen()) {
|
||||
line.start();
|
||||
}
|
||||
}
|
||||
if (Math.abs(elapsedSeconds - offsetSeconds) >= 2) {
|
||||
startRemotePlaybackThread(streamUrl, currentSong, offsetSeconds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRemotePlayback = true;
|
||||
this.currentSong = new NavidromeSong(songId, title, artist, album, duration, suffix);
|
||||
final NavidromeSong songToPlay = this.currentSong;
|
||||
com.rardog.navidromeplayer.client.lyrics.LyricsManager.getInstance().loadLyrics(songToPlay);
|
||||
try {
|
||||
Minecraft.getInstance().execute(() -> {
|
||||
Minecraft.getInstance().getToasts().addToast(new com.rardog.navidromeplayer.client.gui.NowPlayingToast(songToPlay));
|
||||
});
|
||||
} catch (Throwable ignored) {}
|
||||
|
||||
startRemotePlaybackThread(streamUrl, songToPlay, offsetSeconds);
|
||||
}
|
||||
|
||||
private synchronized void startRemotePlaybackThread(String streamUrl, NavidromeSong songToPlay, int offsetSeconds) {
|
||||
final long sessionId = playbackSessionId.incrementAndGet();
|
||||
stopStreamOnly();
|
||||
this.isPlaying.set(true);
|
||||
this.isPaused.set(false);
|
||||
this.startOffsetSeconds = offsetSeconds;
|
||||
this.elapsedSeconds = offsetSeconds;
|
||||
this.playedBytesCount = 0;
|
||||
|
||||
playbackThread = new Thread(() -> {
|
||||
try {
|
||||
startStreamFromUrl(streamUrl, songToPlay, offsetSeconds);
|
||||
} catch (Exception e) {
|
||||
if (sessionId == playbackSessionId.get() && isPlaying.get() && playbackThread == Thread.currentThread()) {
|
||||
System.err.println("[NavidromeAudioPlayer] Remote stream error: " + e.getMessage());
|
||||
}
|
||||
} finally {
|
||||
if (sessionId == playbackSessionId.get()) {
|
||||
cleanupLine();
|
||||
}
|
||||
}
|
||||
}, "Navidrome-RemoteAudio-Thread");
|
||||
playbackThread.setDaemon(true);
|
||||
playbackThread.start();
|
||||
}
|
||||
|
||||
public synchronized void pauseRemote() {
|
||||
if (isRemotePlayback && isPlaying.get()) {
|
||||
isPaused.set(true);
|
||||
if (line != null && line.isOpen()) {
|
||||
line.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void stopRemote() {
|
||||
if (isRemotePlayback) {
|
||||
isPlaying.set(false);
|
||||
isPaused.set(false);
|
||||
stopStreamOnly();
|
||||
com.rardog.navidromeplayer.client.lyrics.LyricsManager.getInstance().clear();
|
||||
currentSong = null;
|
||||
elapsedSeconds = 0;
|
||||
playedBytesCount = 0;
|
||||
startOffsetSeconds = 0;
|
||||
isRemotePlayback = false;
|
||||
}
|
||||
}
|
||||
|
||||
public long getElapsedMs() {
|
||||
int bytesPerSec = currentSampleRate * currentChannels * (isNative24 ? 3 : 2);
|
||||
if (bytesPerSec > 0) {
|
||||
long playedMs = (playedBytesCount * 1000L) / bytesPerSec;
|
||||
if (line != null && line.isOpen()) {
|
||||
int bufferedBytes = line.getBufferSize() - line.available();
|
||||
if (bufferedBytes > 0) {
|
||||
long latencyMs = (bufferedBytes * 1000L) / bytesPerSec;
|
||||
playedMs = Math.max(0, playedMs - latencyMs);
|
||||
}
|
||||
}
|
||||
return startOffsetSeconds * 1000L + playedMs;
|
||||
}
|
||||
return elapsedSeconds * 1000L;
|
||||
}
|
||||
|
||||
private void stopStreamOnly() {
|
||||
final InputStream streamToClose;
|
||||
final Thread oldThread;
|
||||
|
||||
synchronized (this) {
|
||||
streamToClose = currentStream;
|
||||
currentStream = null;
|
||||
oldThread = playbackThread;
|
||||
if (oldThread != null && oldThread != Thread.currentThread()) {
|
||||
oldThread.interrupt();
|
||||
}
|
||||
playbackThread = null;
|
||||
// Close the audio line immediately — this unblocks any line.write() calls
|
||||
// in the old thread, causing it to exit quickly without us holding the lock.
|
||||
cleanupLine();
|
||||
}
|
||||
|
||||
// Close stream synchronously here (not in background) so the old thread
|
||||
// gets an IOException on its next read and exits the decode loop fast.
|
||||
if (streamToClose != null) {
|
||||
try { streamToClose.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// Join OUTSIDE the synchronized block to avoid deadlock:
|
||||
// Old thread's finally calls cleanupLine() (synchronized) — if we held
|
||||
// the lock during join, old thread would wait for the lock → deadlock.
|
||||
if (oldThread != null && oldThread != Thread.currentThread()) {
|
||||
try { oldThread.join(250); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void cleanupLine() {
|
||||
if (line != null) {
|
||||
try {
|
||||
line.stop();
|
||||
line.flush();
|
||||
line.close();
|
||||
} catch (Exception ignored) {}
|
||||
line = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setVolume(float volume) {
|
||||
this.volume = Math.max(0.0f, Math.min(1.0f, volume));
|
||||
NavidromeConfig config = NavidromeConfig.get();
|
||||
config.volume = this.volume;
|
||||
config.save();
|
||||
}
|
||||
|
||||
public float getVolume() {
|
||||
return volume;
|
||||
}
|
||||
|
||||
public boolean isPlaying() {
|
||||
return isPlaying.get();
|
||||
}
|
||||
|
||||
public boolean isPaused() {
|
||||
return isPaused.get();
|
||||
}
|
||||
|
||||
public NavidromeSong getCurrentSong() {
|
||||
return currentSong;
|
||||
}
|
||||
|
||||
public int getElapsedSeconds() {
|
||||
return elapsedSeconds;
|
||||
}
|
||||
|
||||
public String getFormattedPosition() {
|
||||
int curM = elapsedSeconds / 60;
|
||||
int curS = elapsedSeconds % 60;
|
||||
if (currentSong != null && currentSong.duration > 0) {
|
||||
int totM = currentSong.duration / 60;
|
||||
int totS = currentSong.duration % 60;
|
||||
return String.format("%02d:%02d / %02d:%02d", curM, curS, totM, totS);
|
||||
}
|
||||
return String.format("%02d:%02d", curM, curS);
|
||||
}
|
||||
|
||||
public String getCurrentWorldKey() {
|
||||
try {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.hasSingleplayerServer() && mc.getSingleplayerServer() != null) {
|
||||
String name = mc.getSingleplayerServer().getWorldData().getLevelName();
|
||||
return "sp_" + name.replaceAll("[^a-zA-Z0-9_-]", "_");
|
||||
} else if (mc.getCurrentServer() != null && mc.getCurrentServer().ip != null) {
|
||||
return "mp_" + mc.getCurrentServer().ip.replaceAll("[^a-zA-Z0-9_-]", "_");
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return "global";
|
||||
}
|
||||
|
||||
public synchronized void saveQueueToFile() {
|
||||
try {
|
||||
File dir = new File(Minecraft.getInstance().gameDirectory, "config/navidrome_queues");
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
|
||||
String worldKey = getCurrentWorldKey();
|
||||
File worldFile = new File(dir, "queue_" + worldKey + ".json");
|
||||
try (FileWriter writer = new FileWriter(worldFile)) {
|
||||
GSON.toJson(queue, writer);
|
||||
}
|
||||
|
||||
File fallback = new File(new File(Minecraft.getInstance().gameDirectory, "config"), "navidrome_queue.json");
|
||||
try (FileWriter writer = new FileWriter(fallback)) {
|
||||
GSON.toJson(queue, writer);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void loadQueueForCurrentWorld() {
|
||||
try {
|
||||
File dir = new File(Minecraft.getInstance().gameDirectory, "config/navidrome_queues");
|
||||
String worldKey = getCurrentWorldKey();
|
||||
File worldFile = new File(dir, "queue_" + worldKey + ".json");
|
||||
File fallback = new File(new File(Minecraft.getInstance().gameDirectory, "config"), "navidrome_queue.json");
|
||||
File fileToLoad = worldFile.exists() ? worldFile : fallback;
|
||||
|
||||
if (fileToLoad.exists()) {
|
||||
try (FileReader reader = new FileReader(fileToLoad)) {
|
||||
List<NavidromeSong> loaded = GSON.fromJson(reader, new TypeToken<List<NavidromeSong>>(){}.getType());
|
||||
if (loaded != null) {
|
||||
queue.clear();
|
||||
queue.addAll(loaded);
|
||||
System.out.println("[NavidromeAudioPlayer] Loaded " + loaded.size() + " songs for: " + worldKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadQueueFromFile() {
|
||||
loadQueueForCurrentWorld();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.rardog.navidromeplayer.client.command;
|
||||
|
||||
import com.rardog.navidromeplayer.client.audio.NavidromeAudioPlayer;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeConfig;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeSong;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.util.text.StringTextComponent;
|
||||
import net.minecraft.util.text.TextFormatting;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.ClientChatEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ClientMusicCommands {
|
||||
|
||||
@SubscribeEvent
|
||||
public void onClientChat(ClientChatEvent event) {
|
||||
String msg = event.getMessage().trim();
|
||||
if (!msg.startsWith("/music") && !msg.startsWith("/музыка")) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.setCanceled(true);
|
||||
if (Minecraft.getInstance().gui != null && Minecraft.getInstance().gui.getChat() != null) {
|
||||
Minecraft.getInstance().gui.getChat().addRecentChat(msg);
|
||||
}
|
||||
|
||||
String[] parts = msg.split("\\s+");
|
||||
if (parts.length == 1 || "help".equalsIgnoreCase(parts[1]) || "помощь".equalsIgnoreCase(parts[1])) {
|
||||
sendMessage(TextFormatting.GOLD + "=== Управление музыкой (/music) ===");
|
||||
sendMessage(TextFormatting.YELLOW + "/music volume <0-100>" + TextFormatting.GRAY + " — изменить громкость");
|
||||
sendMessage(TextFormatting.YELLOW + "/music mute" + TextFormatting.GRAY + " — заглушить / включить звук");
|
||||
sendMessage(TextFormatting.YELLOW + "/music info" + TextFormatting.GRAY + " — текущий трек и битрейт");
|
||||
return;
|
||||
}
|
||||
|
||||
String cmd = parts[1].toLowerCase();
|
||||
NavidromeAudioPlayer player = NavidromeAudioPlayer.getInstance();
|
||||
NavidromeConfig config = NavidromeConfig.get();
|
||||
|
||||
if ("volume".equals(cmd) || "громкость".equals(cmd) || "vol".equals(cmd)) {
|
||||
if (parts.length < 3) {
|
||||
int curVol = (int) (player.getVolume() * 100);
|
||||
sendMessage(TextFormatting.YELLOW + "Текущая громкость: " + TextFormatting.GREEN + curVol + "%");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int volPercent = Integer.parseInt(parts[2]);
|
||||
volPercent = Math.max(0, Math.min(100, volPercent));
|
||||
float fVol = volPercent / 100.0f;
|
||||
player.setVolume(fVol);
|
||||
config.volume = fVol;
|
||||
config.isMuted = false;
|
||||
config.save();
|
||||
sendMessage(TextFormatting.GREEN + "Громкость музыки установлена на " + volPercent + "%");
|
||||
} catch (NumberFormatException e) {
|
||||
sendMessage(TextFormatting.RED + "Укажите число от 0 до 100, например: /music volume 50");
|
||||
}
|
||||
} else if ("mute".equals(cmd) || "заглушить".equals(cmd) || "m".equals(cmd)) {
|
||||
config.isMuted = !config.isMuted;
|
||||
config.save();
|
||||
player.setMuted(config.isMuted);
|
||||
if (config.isMuted) {
|
||||
sendMessage(TextFormatting.RED + "Звук музыки заглушен [MUTE]. Чтобы включить: /music mute");
|
||||
} else {
|
||||
int curVol = (int) (player.getVolume() * 100);
|
||||
sendMessage(TextFormatting.GREEN + "Звук музыки включен (" + curVol + "%)");
|
||||
}
|
||||
} else if ("info".equals(cmd) || "инфо".equals(cmd) || "i".equals(cmd)) {
|
||||
NavidromeSong cur = player.getCurrentSong();
|
||||
if (cur != null && player.isPlaying()) {
|
||||
String quality = player.getAudioFormatQualityString();
|
||||
sendMessage(TextFormatting.GOLD + "♫ Сейчас играет: " + TextFormatting.GREEN + cur.artist + " — " + cur.title);
|
||||
sendMessage(TextFormatting.GRAY + "Качество: " + TextFormatting.AQUA + (quality.isEmpty() ? "Аудиопоток" : quality) +
|
||||
TextFormatting.GRAY + " | Длительность: " + TextFormatting.WHITE + cur.getFormattedDuration());
|
||||
} else {
|
||||
sendMessage(TextFormatting.GRAY + "Сейчас музыка не воспроизводится.");
|
||||
}
|
||||
} else {
|
||||
sendMessage(TextFormatting.RED + "Неизвестная подкоманда. Используйте /music help");
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMessage(String text) {
|
||||
if (Minecraft.getInstance().player != null) {
|
||||
Minecraft.getInstance().player.sendMessage(new StringTextComponent(text), Util.NIL_UUID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.rardog.navidromeplayer.client.gui;
|
||||
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeApiClient;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||
import net.minecraft.client.renderer.texture.NativeImage;
|
||||
import net.minecraft.util.ResourceLocation;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class CoverArtCache {
|
||||
private static final Map<String, ResourceLocation> CACHE = new ConcurrentHashMap<>();
|
||||
private static final Set<String> LOADING = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public static ResourceLocation getCoverArt(String coverArtId) {
|
||||
if (coverArtId == null || coverArtId.isEmpty()) return null;
|
||||
ResourceLocation existing = CACHE.get(coverArtId);
|
||||
if (existing != null) return existing;
|
||||
|
||||
if (LOADING.add(coverArtId)) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
String urlStr = NavidromeApiClient.getCoverArtUrl(coverArtId);
|
||||
if (urlStr == null || urlStr.isEmpty()) return;
|
||||
|
||||
URL url = new URL(urlStr);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
conn.setConnectTimeout(8000);
|
||||
conn.setReadTimeout(8000);
|
||||
conn.setRequestProperty("User-Agent", "NavidromePlayerMod/1.0");
|
||||
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode == 200) {
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
NativeImage image = NativeImage.read(in);
|
||||
if (image != null) {
|
||||
Minecraft.getInstance().execute(() -> {
|
||||
try {
|
||||
DynamicTexture texture = new DynamicTexture(image);
|
||||
String cleanKey = coverArtId.replaceAll("[^a-zA-Z0-9_]", "_").toLowerCase();
|
||||
ResourceLocation loc = Minecraft.getInstance().getTextureManager().register("navidrome_cover_" + cleanKey, texture);
|
||||
CACHE.put(coverArtId, loc);
|
||||
} catch (Throwable t) {
|
||||
image.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.err.println("[CoverArtCache] HTTP error " + responseCode + " for cover: " + coverArtId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
System.err.println("[CoverArtCache] Failed to download cover " + coverArtId + ": " + t.getMessage());
|
||||
} finally {
|
||||
LOADING.remove(coverArtId);
|
||||
}
|
||||
}, "Navidrome-CoverDownloader").start();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.rardog.navidromeplayer.client.gui;
|
||||
|
||||
import com.mojang.blaze3d.matrix.MatrixStack;
|
||||
import com.rardog.navidromeplayer.client.audio.NavidromeAudioPlayer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.widget.Widget;
|
||||
import net.minecraft.util.math.MathHelper;
|
||||
import net.minecraft.util.text.StringTextComponent;
|
||||
|
||||
public class CustomVolumeSlider extends Widget {
|
||||
private final NavidromeAudioPlayer audioPlayer = NavidromeAudioPlayer.getInstance();
|
||||
private boolean dragging = false;
|
||||
|
||||
public CustomVolumeSlider(int x, int y, int width, int height) {
|
||||
super(x, y, width, height, new StringTextComponent("Volume"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(double mouseX, double mouseY) {
|
||||
this.dragging = true;
|
||||
setValueFromMouse(mouseX);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDrag(double mouseX, double mouseY, double dragX, double dragY) {
|
||||
if (this.dragging) {
|
||||
setValueFromMouse(mouseX);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelease(double mouseX, double mouseY) {
|
||||
this.dragging = false;
|
||||
}
|
||||
|
||||
private void setValueFromMouse(double mouseX) {
|
||||
float val = (float) (mouseX - this.x) / (float) this.width;
|
||||
val = MathHelper.clamp(val, 0.0f, 1.0f);
|
||||
audioPlayer.setVolume(val);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderButton(MatrixStack ms, int mouseX, int mouseY, float partialTicks) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
// Background
|
||||
fill(ms, this.x, this.y, this.x + this.width, this.y + this.height, 0xFF22222E);
|
||||
fill(ms, this.x + 1, this.y + 1, this.x + this.width - 1, this.y + this.height - 1, 0xFF14141A);
|
||||
|
||||
// Filled bar
|
||||
float vol = audioPlayer.getVolume();
|
||||
int fillW = (int) ((this.width - 2) * vol);
|
||||
if (fillW > 0) {
|
||||
fill(ms, this.x + 1, this.y + 1, this.x + 1 + fillW, this.y + this.height - 1, 0xFF4CAF50);
|
||||
}
|
||||
|
||||
// Thumb handle
|
||||
int thumbX = this.x + 1 + fillW;
|
||||
fill(ms, thumbX - 2, this.y - 1, thumbX + 2, this.y + this.height + 1, 0xFFFFFFFF);
|
||||
|
||||
// Text
|
||||
int percent = Math.round(vol * 100);
|
||||
String text = percent + "%";
|
||||
int textX = this.x + (this.width - mc.font.width(text)) / 2;
|
||||
int textY = this.y + (this.height - 8) / 2;
|
||||
mc.font.draw(ms, text, textX, textY, 0xFFFFFFFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
package com.rardog.navidromeplayer.client.gui;
|
||||
|
||||
import com.mojang.blaze3d.matrix.MatrixStack;
|
||||
import com.rardog.navidromeplayer.client.audio.NavidromeAudioPlayer;
|
||||
import com.rardog.navidromeplayer.client.navidrome.*;
|
||||
import com.rardog.navidromeplayer.network.NetworkHandler;
|
||||
import com.rardog.navidromeplayer.network.PacketSyncPlayback;
|
||||
import com.rardog.navidromeplayer.tileentity.NavidromePlayerTileEntity;
|
||||
import net.minecraft.client.gui.screen.Screen;
|
||||
import net.minecraft.client.gui.widget.TextFieldWidget;
|
||||
import net.minecraft.client.gui.widget.button.Button;
|
||||
import net.minecraft.util.ResourceLocation;
|
||||
import net.minecraft.util.math.BlockPos;
|
||||
import net.minecraft.util.math.MathHelper;
|
||||
import net.minecraft.util.text.StringTextComponent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class NavidromePlayerScreen extends Screen {
|
||||
public enum Tab {
|
||||
SEARCH, QUEUE, PLAYLISTS, ALBUMS, SETTINGS
|
||||
}
|
||||
|
||||
private final BlockPos pos;
|
||||
private final NavidromePlayerTileEntity tileEntity;
|
||||
private final NavidromeAudioPlayer audioPlayer = NavidromeAudioPlayer.getInstance();
|
||||
|
||||
private Tab currentTab = Tab.SEARCH;
|
||||
|
||||
// Search tab
|
||||
private TextFieldWidget searchField;
|
||||
private Button searchBtn;
|
||||
private List<NavidromeSong> currentSongList = new ArrayList<>();
|
||||
|
||||
// Playlists tab
|
||||
private List<NavidromePlaylist> playlists = new ArrayList<>();
|
||||
private NavidromePlaylist currentPlaylist = null;
|
||||
|
||||
// Albums tab
|
||||
private List<NavidromeAlbum> albums = new ArrayList<>();
|
||||
private NavidromeAlbum currentAlbum = null;
|
||||
|
||||
// Settings tab
|
||||
private TextFieldWidget urlField;
|
||||
private TextFieldWidget publicUrlField;
|
||||
private TextFieldWidget usernameField;
|
||||
private TextFieldWidget passwordField;
|
||||
private Button testBtn;
|
||||
private Button saveSettingsBtn;
|
||||
private String statusMessage = "";
|
||||
private int statusColor = 0xFFFFFFFF;
|
||||
|
||||
// Navigation buttons
|
||||
private Button tabSearchBtn;
|
||||
private Button tabQueueBtn;
|
||||
private Button tabPlaylistsBtn;
|
||||
private Button tabAlbumsBtn;
|
||||
private Button tabSettingsBtn;
|
||||
private Button backBtn;
|
||||
private Button clearQueueBtn;
|
||||
private Button filterToggleBtn;
|
||||
private Button hudPosBtn;
|
||||
private Button hudNextLineBtn;
|
||||
|
||||
// Playback control widgets
|
||||
private Button playPauseBtn;
|
||||
private Button stopBtn;
|
||||
private Button prevBtn;
|
||||
private Button nextBtn;
|
||||
private TrackSeekBar seekBar;
|
||||
private CustomVolumeSlider volumeSlider;
|
||||
|
||||
// Scrolling
|
||||
private int scrollOffset = 0;
|
||||
|
||||
// Layout
|
||||
private int guiLeft;
|
||||
private int guiTop;
|
||||
private final int guiWidth = 380;
|
||||
private final int guiHeight = 230;
|
||||
|
||||
public NavidromePlayerScreen(BlockPos pos, NavidromePlayerTileEntity tileEntity) {
|
||||
super(new StringTextComponent("Navidrome Player"));
|
||||
this.pos = pos;
|
||||
this.tileEntity = tileEntity;
|
||||
}
|
||||
|
||||
public boolean isOwner() {
|
||||
if (tileEntity == null || tileEntity.getOwnerUuid() == null) {
|
||||
return true;
|
||||
}
|
||||
if (minecraft != null && minecraft.player != null) {
|
||||
return tileEntity.isOwner(minecraft.player.getUUID());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
this.guiLeft = (this.width - this.guiWidth) / 2;
|
||||
this.guiTop = (this.height - this.guiHeight) / 2;
|
||||
audioPlayer.setActiveBlockPos(pos);
|
||||
|
||||
if (!isOwner() && (this.currentTab == Tab.SEARCH || this.currentTab == Tab.ALBUMS)) {
|
||||
this.currentTab = Tab.QUEUE;
|
||||
}
|
||||
|
||||
initNavigationButtons();
|
||||
initSearchControls();
|
||||
initSettingsControls();
|
||||
initPlaybackControls();
|
||||
|
||||
switchTab(this.currentTab);
|
||||
}
|
||||
|
||||
private void initNavigationButtons() {
|
||||
int tabY = guiTop + 7;
|
||||
int tabH = 18;
|
||||
|
||||
boolean owner = isOwner();
|
||||
|
||||
tabSearchBtn = addButton(new Button(guiLeft + 8, tabY, 44, tabH,
|
||||
new StringTextComponent(owner ? "Поиск" : "🔒"),
|
||||
b -> {
|
||||
if (isOwner()) switchTab(Tab.SEARCH);
|
||||
}));
|
||||
if (!owner) {
|
||||
tabSearchBtn.active = false;
|
||||
}
|
||||
|
||||
tabQueueBtn = addButton(new Button(guiLeft + 54, tabY, 50, tabH, new StringTextComponent("Очередь"), b -> switchTab(Tab.QUEUE)));
|
||||
|
||||
tabPlaylistsBtn = addButton(new Button(guiLeft + 106, tabY, 64, tabH, new StringTextComponent("Плейлисты"), b -> {
|
||||
currentPlaylist = null;
|
||||
switchTab(Tab.PLAYLISTS);
|
||||
loadPlaylists();
|
||||
}));
|
||||
|
||||
tabAlbumsBtn = addButton(new Button(guiLeft + 172, tabY, 50, tabH,
|
||||
new StringTextComponent(owner ? "Альбомы" : "🔒"),
|
||||
b -> {
|
||||
if (isOwner()) {
|
||||
currentAlbum = null;
|
||||
switchTab(Tab.ALBUMS);
|
||||
loadAlbums();
|
||||
}
|
||||
}));
|
||||
if (!owner) {
|
||||
tabAlbumsBtn.active = false;
|
||||
}
|
||||
|
||||
tabSettingsBtn = addButton(new Button(guiLeft + 224, tabY, 46, tabH,
|
||||
new StringTextComponent("Настр."),
|
||||
b -> switchTab(Tab.SETTINGS)));
|
||||
|
||||
clearQueueBtn = addButton(new Button(guiLeft + 272, tabY, 50, tabH, new StringTextComponent("Очистить"), b -> {
|
||||
if (isOwner()) {
|
||||
audioPlayer.clearQueue();
|
||||
scrollOffset = 0;
|
||||
}
|
||||
}));
|
||||
clearQueueBtn.visible = false;
|
||||
if (!owner) {
|
||||
clearQueueBtn.active = false;
|
||||
}
|
||||
|
||||
backBtn = addButton(new Button(guiLeft + 262, tabY, 52, tabH, new StringTextComponent("< Назад"), b -> {
|
||||
if (currentPlaylist != null) {
|
||||
currentPlaylist = null;
|
||||
scrollOffset = 0;
|
||||
} else if (currentAlbum != null) {
|
||||
currentAlbum = null;
|
||||
scrollOffset = 0;
|
||||
}
|
||||
}));
|
||||
backBtn.visible = false;
|
||||
|
||||
// FLAC filter toggle switch
|
||||
filterToggleBtn = addButton(new Button(guiLeft + 316, tabY, 56, tabH, new StringTextComponent(""), b -> {
|
||||
NavidromeConfig cfg = NavidromeConfig.get();
|
||||
cfg.onlyFlacFilter = !cfg.onlyFlacFilter;
|
||||
cfg.save();
|
||||
updateFilterButton();
|
||||
scrollOffset = 0;
|
||||
}));
|
||||
updateFilterButton();
|
||||
}
|
||||
|
||||
private void updateFilterButton() {
|
||||
if (filterToggleBtn != null) {
|
||||
boolean active = NavidromeConfig.get().onlyFlacFilter;
|
||||
filterToggleBtn.setMessage(new StringTextComponent(active ? "FLAC: ВКЛ" : "FLAC: ВСЕ"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<NavidromeSong> getFilteredList(List<NavidromeSong> list) {
|
||||
if (!NavidromeConfig.get().onlyFlacFilter || list == null) return list;
|
||||
List<NavidromeSong> filtered = new ArrayList<>();
|
||||
for (NavidromeSong s : list) {
|
||||
if (s.isFlac()) {
|
||||
filtered.add(s);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private void initSearchControls() {
|
||||
int searchY = guiTop + 30;
|
||||
searchField = new TextFieldWidget(this.font, guiLeft + 8, searchY, 280, 18, new StringTextComponent("Поиск..."));
|
||||
searchField.setMaxLength(100);
|
||||
this.children.add(searchField);
|
||||
|
||||
searchBtn = addButton(new Button(guiLeft + 294, searchY, 78, 18, new StringTextComponent("Найти"), b -> executeSearch()));
|
||||
}
|
||||
|
||||
private void initSettingsControls() {
|
||||
NavidromeConfig config = NavidromeConfig.get();
|
||||
int fieldW = 200;
|
||||
int startY = guiTop + 34;
|
||||
|
||||
urlField = new TextFieldWidget(this.font, guiLeft + 115, startY, fieldW, 16, new StringTextComponent("URL"));
|
||||
urlField.setValue(config.serverUrl);
|
||||
urlField.setMaxLength(256);
|
||||
this.children.add(urlField);
|
||||
|
||||
publicUrlField = new TextFieldWidget(this.font, guiLeft + 115, startY + 22, fieldW, 16, new StringTextComponent("ZeroTier URL"));
|
||||
publicUrlField.setValue(config.publicUrl != null ? config.publicUrl : "");
|
||||
publicUrlField.setMaxLength(256);
|
||||
this.children.add(publicUrlField);
|
||||
|
||||
usernameField = new TextFieldWidget(this.font, guiLeft + 115, startY + 44, fieldW, 16, new StringTextComponent("Username"));
|
||||
usernameField.setValue(config.username);
|
||||
usernameField.setMaxLength(64);
|
||||
this.children.add(usernameField);
|
||||
|
||||
passwordField = new TextFieldWidget(this.font, guiLeft + 115, startY + 66, fieldW, 16, new StringTextComponent("Password"));
|
||||
passwordField.setValue(config.password);
|
||||
passwordField.setMaxLength(64);
|
||||
this.children.add(passwordField);
|
||||
|
||||
testBtn = addButton(new Button(guiLeft + 115, startY + 90, 96, 18, new StringTextComponent("Проверить"), b -> testConnection()));
|
||||
saveSettingsBtn = addButton(new Button(guiLeft + 219, startY + 90, 96, 18, new StringTextComponent("Сохранить"), b -> saveSettings()));
|
||||
|
||||
int hudY = isOwner() ? (startY + 114) : (startY + 54);
|
||||
|
||||
hudPosBtn = addButton(new Button(guiLeft + 115, hudY, 96, 18,
|
||||
new StringTextComponent("Текст: " + (config.lyricsPosition.equalsIgnoreCase("TOP") ? "Сверху" : "Снизу")),
|
||||
b -> {
|
||||
config.lyricsPosition = config.lyricsPosition.equalsIgnoreCase("TOP") ? "BOTTOM" : "TOP";
|
||||
config.save();
|
||||
b.setMessage(new StringTextComponent("Текст: " + (config.lyricsPosition.equalsIgnoreCase("TOP") ? "Сверху" : "Снизу")));
|
||||
}));
|
||||
|
||||
hudNextLineBtn = addButton(new Button(guiLeft + 219, hudY, 96, 18,
|
||||
new StringTextComponent("След. стр: " + (config.showNextLyric ? "Вкл" : "Выкл")),
|
||||
b -> {
|
||||
config.showNextLyric = !config.showNextLyric;
|
||||
config.save();
|
||||
b.setMessage(new StringTextComponent("След. стр: " + (config.showNextLyric ? "Вкл" : "Выкл")));
|
||||
}));
|
||||
}
|
||||
|
||||
private void initPlaybackControls() {
|
||||
int bottomY = guiTop + guiHeight - 34;
|
||||
|
||||
prevBtn = addButton(new Button(guiLeft + 8, bottomY, 24, 20, new StringTextComponent("|<"), b -> {
|
||||
audioPlayer.playPrevious();
|
||||
syncToServer();
|
||||
}));
|
||||
playPauseBtn = addButton(new Button(guiLeft + 36, bottomY, 48, 20, new StringTextComponent(audioPlayer.isPlaying() && !audioPlayer.isPaused() ? "Пауза" : "Играть"), b -> {
|
||||
audioPlayer.togglePlayPause();
|
||||
syncToServer();
|
||||
}));
|
||||
stopBtn = addButton(new Button(guiLeft + 88, bottomY, 38, 20, new StringTextComponent("Стоп"), b -> {
|
||||
audioPlayer.stop();
|
||||
syncToServer();
|
||||
}));
|
||||
nextBtn = addButton(new Button(guiLeft + 130, bottomY, 24, 20, new StringTextComponent(">|"), b -> {
|
||||
audioPlayer.playNext();
|
||||
syncToServer();
|
||||
}));
|
||||
|
||||
// Interactive seek bar
|
||||
seekBar = addButton(new TrackSeekBar(guiLeft + 160, bottomY + 2, 140, 16));
|
||||
|
||||
// Interactive volume slider
|
||||
volumeSlider = addButton(new CustomVolumeSlider(guiLeft + 306, bottomY + 2, 66, 16));
|
||||
}
|
||||
|
||||
private void switchTab(Tab tab) {
|
||||
if (!isOwner() && (tab == Tab.SEARCH || tab == Tab.ALBUMS)) {
|
||||
tab = Tab.QUEUE;
|
||||
}
|
||||
this.currentTab = tab;
|
||||
this.scrollOffset = 0;
|
||||
|
||||
boolean isSearch = (tab == Tab.SEARCH);
|
||||
boolean isQueue = (tab == Tab.QUEUE);
|
||||
boolean isPlaylists = (tab == Tab.PLAYLISTS);
|
||||
boolean isAlbums = (tab == Tab.ALBUMS);
|
||||
boolean isSettings = (tab == Tab.SETTINGS);
|
||||
|
||||
searchField.visible = isSearch;
|
||||
searchBtn.visible = isSearch;
|
||||
|
||||
boolean owner = isOwner();
|
||||
urlField.visible = isSettings && owner;
|
||||
publicUrlField.visible = isSettings && owner;
|
||||
usernameField.visible = isSettings && owner;
|
||||
passwordField.visible = isSettings && owner;
|
||||
testBtn.visible = isSettings && owner;
|
||||
saveSettingsBtn.visible = isSettings && owner;
|
||||
|
||||
backBtn.visible = (isPlaylists && currentPlaylist != null) || (isAlbums && currentAlbum != null);
|
||||
clearQueueBtn.visible = isQueue && owner;
|
||||
filterToggleBtn.visible = !isSettings;
|
||||
hudPosBtn.visible = isSettings;
|
||||
hudNextLineBtn.visible = isSettings;
|
||||
}
|
||||
|
||||
private void executeSearch() {
|
||||
String query = searchField.getValue().trim();
|
||||
if (query.isEmpty()) return;
|
||||
|
||||
statusMessage = "Поиск...";
|
||||
statusColor = 0xFFFFFF00;
|
||||
NavidromeApiClient.search(query).thenAccept(songs -> {
|
||||
this.currentSongList = songs;
|
||||
this.scrollOffset = 0;
|
||||
if (songs.isEmpty()) {
|
||||
statusMessage = "Ничего не найдено";
|
||||
statusColor = 0xFFFF8888;
|
||||
} else {
|
||||
statusMessage = "Найдено треков: " + songs.size();
|
||||
statusColor = 0xFF88FF88;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadPlaylists() {
|
||||
statusMessage = "Загрузка плейлистов...";
|
||||
NavidromeApiClient.getPlaylists().thenAccept(list -> {
|
||||
this.playlists = list;
|
||||
this.scrollOffset = 0;
|
||||
statusMessage = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void loadAlbums() {
|
||||
statusMessage = "Загрузка альбомов...";
|
||||
NavidromeApiClient.getRecentAlbums(50).thenAccept(list -> {
|
||||
this.albums = list;
|
||||
this.scrollOffset = 0;
|
||||
statusMessage = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void testConnection() {
|
||||
saveFieldsToConfig();
|
||||
statusMessage = "Проверка...";
|
||||
statusColor = 0xFFFFFF00;
|
||||
NavidromeApiClient.ping().thenAccept(result -> {
|
||||
this.statusMessage = result;
|
||||
if (result.startsWith("Подключено")) {
|
||||
this.statusColor = 0xFF55FF55;
|
||||
} else {
|
||||
this.statusColor = 0xFFFF5555;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void saveSettings() {
|
||||
saveFieldsToConfig();
|
||||
NavidromeConfig.get().save();
|
||||
statusMessage = "Настройки сохранены!";
|
||||
statusColor = 0xFF55FF55;
|
||||
}
|
||||
|
||||
private void saveFieldsToConfig() {
|
||||
NavidromeConfig config = NavidromeConfig.get();
|
||||
config.serverUrl = urlField.getValue().trim();
|
||||
config.publicUrl = publicUrlField.getValue().trim();
|
||||
config.username = usernameField.getValue().trim();
|
||||
config.password = passwordField.getValue().trim();
|
||||
}
|
||||
|
||||
private void syncToServer() {
|
||||
audioPlayer.setActiveBlockPos(pos);
|
||||
audioPlayer.syncPlaybackToServer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
if (playPauseBtn != null) {
|
||||
playPauseBtn.setMessage(new StringTextComponent(audioPlayer.isPlaying() && !audioPlayer.isPaused() ? "Пауза" : "Играть"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(MatrixStack ms, int mouseX, int mouseY, float partialTicks) {
|
||||
this.renderBackground(ms);
|
||||
|
||||
// GUI window panel
|
||||
fill(ms, guiLeft, guiTop, guiLeft + guiWidth, guiTop + guiHeight, 0xEE1A1A22);
|
||||
// Header bar
|
||||
fill(ms, guiLeft, guiTop, guiLeft + guiWidth, guiTop + 27, 0xFF121218);
|
||||
// Bottom player bar
|
||||
fill(ms, guiLeft, guiTop + guiHeight - 48, guiLeft + guiWidth, guiTop + guiHeight, 0xFF14141C);
|
||||
// Border outline
|
||||
fill(ms, guiLeft, guiTop, guiLeft + guiWidth, guiTop + 1, 0xFF3F51B5);
|
||||
fill(ms, guiLeft, guiTop + guiHeight - 1, guiLeft + guiWidth, guiTop + guiHeight, 0xFF3F51B5);
|
||||
fill(ms, guiLeft, guiTop, guiLeft + 1, guiTop + guiHeight, 0xFF3F51B5);
|
||||
fill(ms, guiLeft + guiWidth - 1, guiTop, guiLeft + guiWidth, guiTop + guiHeight, 0xFF3F51B5);
|
||||
|
||||
// Render tab contents
|
||||
switch (currentTab) {
|
||||
case SEARCH:
|
||||
renderSongList(ms, mouseX, mouseY, getFilteredList(currentSongList), guiTop + 52, 126, true);
|
||||
break;
|
||||
case QUEUE:
|
||||
renderSongList(ms, mouseX, mouseY, getFilteredList(audioPlayer.getQueue()), guiTop + 30, 148, false);
|
||||
break;
|
||||
case PLAYLISTS:
|
||||
if (currentPlaylist == null) {
|
||||
renderPlaylistList(ms, mouseX, mouseY, guiTop + 30, 148);
|
||||
} else {
|
||||
renderSongList(ms, mouseX, mouseY, getFilteredList(currentSongList), guiTop + 30, 148, true);
|
||||
}
|
||||
break;
|
||||
case ALBUMS:
|
||||
if (currentAlbum == null) {
|
||||
renderAlbumList(ms, mouseX, mouseY, guiTop + 30, 148);
|
||||
} else {
|
||||
renderSongList(ms, mouseX, mouseY, getFilteredList(currentSongList), guiTop + 30, 148, true);
|
||||
}
|
||||
break;
|
||||
case SETTINGS:
|
||||
renderSettings(ms);
|
||||
break;
|
||||
}
|
||||
|
||||
// Render Now Playing banner above bottom bar
|
||||
renderNowPlayingBanner(ms);
|
||||
|
||||
// Render widgets (buttons, sliders, text fields)
|
||||
super.render(ms, mouseX, mouseY, partialTicks);
|
||||
if (currentTab == Tab.SEARCH && searchField.visible) {
|
||||
searchField.render(ms, mouseX, mouseY, partialTicks);
|
||||
} else if (currentTab == Tab.SETTINGS && isOwner()) {
|
||||
urlField.render(ms, mouseX, mouseY, partialTicks);
|
||||
publicUrlField.render(ms, mouseX, mouseY, partialTicks);
|
||||
usernameField.render(ms, mouseX, mouseY, partialTicks);
|
||||
passwordField.render(ms, mouseX, mouseY, partialTicks);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderNowPlayingBanner(MatrixStack ms) {
|
||||
int bannerY = guiTop + guiHeight - 46;
|
||||
NavidromeSong song = audioPlayer.getCurrentSong();
|
||||
|
||||
if (song != null && audioPlayer.isPlaying()) {
|
||||
int textX = guiLeft + 8;
|
||||
String coverId = (song.coverArt != null && !song.coverArt.isEmpty()) ? song.coverArt : song.id;
|
||||
ResourceLocation cover = CoverArtCache.getCoverArt(coverId);
|
||||
if (cover != null) {
|
||||
minecraft.getTextureManager().bind(cover);
|
||||
blit(ms, guiLeft + 8, bannerY - 3, 0, 0, 16, 16, 16, 16);
|
||||
textX = guiLeft + 28;
|
||||
}
|
||||
|
||||
String title = (audioPlayer.isPaused() ? "[Пауза] " : "♫ ") + song.artist + " - " + song.title;
|
||||
if (title.length() > 36) title = title.substring(0, 34) + "...";
|
||||
drawString(ms, this.font, title, textX, bannerY, 0xFF4CAF50);
|
||||
|
||||
if (song.isFlac()) {
|
||||
if (audioPlayer.isNative24BitActive()) {
|
||||
drawString(ms, this.font, "[FLAC 24-bit True Hi-Res]", guiLeft + guiWidth - 140, bannerY, 0xFF00FFCC);
|
||||
} else {
|
||||
drawString(ms, this.font, "[FLAC Lossless]", guiLeft + guiWidth - 90, bannerY, 0xFF00E5FF);
|
||||
}
|
||||
} else {
|
||||
drawString(ms, this.font, "[MP3]", guiLeft + guiWidth - 45, bannerY, 0xFFFFD700);
|
||||
}
|
||||
} else {
|
||||
drawString(ms, this.font, "Музыка не играет", guiLeft + 8, bannerY, 0xFF888888);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderSongList(MatrixStack ms, int mouseX, int mouseY, List<NavidromeSong> list, int listY, int listHeight, boolean showAddBtn) {
|
||||
int itemH = 22;
|
||||
int listW = guiWidth - 16;
|
||||
int startX = guiLeft + 8;
|
||||
|
||||
fill(ms, startX, listY, startX + listW, listY + listHeight, 0xFF14141C);
|
||||
|
||||
int totalH = list.size() * itemH;
|
||||
int maxScroll = Math.max(0, totalH - listHeight);
|
||||
scrollOffset = MathHelper.clamp(scrollOffset, 0, maxScroll);
|
||||
|
||||
int firstIndex = scrollOffset / itemH;
|
||||
int lastIndex = Math.min(list.size(), firstIndex + (listHeight / itemH) + 2);
|
||||
|
||||
for (int i = firstIndex; i < lastIndex; i++) {
|
||||
NavidromeSong song = list.get(i);
|
||||
int itemY = listY + (i * itemH) - scrollOffset;
|
||||
if (itemY + itemH <= listY || itemY >= listY + listHeight) continue;
|
||||
|
||||
boolean isHovered = mouseX >= startX && mouseX <= startX + listW && mouseY >= itemY && mouseY < itemY + itemH && mouseY >= listY && mouseY < listY + listHeight;
|
||||
boolean isCurrent = audioPlayer.getCurrentSong() != null && song.id.equals(audioPlayer.getCurrentSong().id);
|
||||
|
||||
int bgCol = isCurrent ? 0xFF2A4535 : (isHovered ? 0xFF262636 : ((i % 2 == 0) ? 0xFF1A1A24 : 0xFF15151E));
|
||||
fill(ms, startX, itemY, startX + listW, itemY + itemH, bgCol);
|
||||
|
||||
// Badge
|
||||
String badge = song.isFlac() ? "[FLAC]" : "[MP3]";
|
||||
int badgeColor = song.isFlac() ? 0xFF00E5FF : 0xFFFFD700;
|
||||
drawString(ms, this.font, badge, startX + 6, itemY + 6, badgeColor);
|
||||
|
||||
// Title & Artist
|
||||
String titleText = song.title;
|
||||
if (titleText.length() > 30) titleText = titleText.substring(0, 28) + "...";
|
||||
String artistText = song.artist;
|
||||
if (artistText.length() > 22) artistText = artistText.substring(0, 20) + "...";
|
||||
|
||||
drawString(ms, this.font, titleText, startX + 48, itemY + 6, isCurrent ? 0xFF55FF55 : 0xFFFFFFFF);
|
||||
drawString(ms, this.font, artistText, startX + 205, itemY + 6, 0xFFAAAAAA);
|
||||
|
||||
// Duration
|
||||
String dur = song.getFormattedDuration();
|
||||
int durX = startX + listW - (showAddBtn ? 38 : 34) - font.width(dur);
|
||||
drawString(ms, this.font, dur, durX, itemY + 6, 0xFF888888);
|
||||
|
||||
// Action button on right: [+] or [X] or [▶]
|
||||
int actBtnX = startX + listW - 24;
|
||||
boolean actHover = mouseX >= actBtnX && mouseX <= actBtnX + 18 && mouseY >= itemY + 3 && mouseY <= itemY + 19;
|
||||
fill(ms, actBtnX, itemY + 3, actBtnX + 18, itemY + 19, actHover ? 0xFF3F51B5 : 0xFF222230);
|
||||
String actSym = showAddBtn ? "+" : (isOwner() ? "x" : "▶");
|
||||
drawString(ms, this.font, actSym, actBtnX + (actSym.equals("▶") ? 5 : 6), itemY + 6, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
// Scrollbar
|
||||
if (maxScroll > 0) {
|
||||
int barX = startX + listW - 4;
|
||||
float ratio = (float) listHeight / totalH;
|
||||
int barH = Math.max(16, (int) (listHeight * ratio));
|
||||
int barY = listY + (int) ((float) scrollOffset / maxScroll * (listHeight - barH));
|
||||
fill(ms, barX, listY, barX + 3, listY + listHeight, 0xFF22222E);
|
||||
fill(ms, barX, barY, barX + 3, barY + barH, 0xFF5C6BC0);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderPlaylistList(MatrixStack ms, int mouseX, int mouseY, int listY, int listHeight) {
|
||||
int itemH = 22;
|
||||
int listW = guiWidth - 16;
|
||||
int startX = guiLeft + 8;
|
||||
|
||||
fill(ms, startX, listY, startX + listW, listY + listHeight, 0xFF14141C);
|
||||
|
||||
if (playlists.isEmpty()) {
|
||||
drawCenteredString(ms, this.font, "В Navidrome пока нет созданных плейлистов", guiLeft + guiWidth / 2, listY + 54, 0xFFAAAAAA);
|
||||
drawCenteredString(ms, this.font, "Создайте плейлист в веб-интерфейсе сервера, и он появится здесь", guiLeft + guiWidth / 2, listY + 72, 0xFF666666);
|
||||
return;
|
||||
}
|
||||
|
||||
int totalH = playlists.size() * itemH;
|
||||
int maxScroll = Math.max(0, totalH - listHeight);
|
||||
scrollOffset = MathHelper.clamp(scrollOffset, 0, maxScroll);
|
||||
|
||||
int firstIndex = scrollOffset / itemH;
|
||||
int lastIndex = Math.min(playlists.size(), firstIndex + (listHeight / itemH) + 2);
|
||||
|
||||
for (int i = firstIndex; i < lastIndex; i++) {
|
||||
NavidromePlaylist pl = playlists.get(i);
|
||||
int itemY = listY + (i * itemH) - scrollOffset;
|
||||
if (itemY + itemH <= listY || itemY >= listY + listHeight) continue;
|
||||
|
||||
boolean isHovered = mouseX >= startX && mouseX <= startX + listW && mouseY >= itemY && mouseY < itemY + itemH && mouseY >= listY && mouseY < listY + listHeight;
|
||||
fill(ms, startX, itemY, startX + listW, itemY + itemH, isHovered ? 0xFF262636 : ((i % 2 == 0) ? 0xFF1A1A24 : 0xFF15151E));
|
||||
|
||||
drawString(ms, this.font, "♫ " + pl.name, startX + 8, itemY + 6, 0xFFFFFFFF);
|
||||
String count = pl.songCount + " треков";
|
||||
drawString(ms, this.font, count, startX + listW - font.width(count) - 10, itemY + 6, 0xFFAAAAAA);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderAlbumList(MatrixStack ms, int mouseX, int mouseY, int listY, int listHeight) {
|
||||
int itemH = 22;
|
||||
int listW = guiWidth - 16;
|
||||
int startX = guiLeft + 8;
|
||||
|
||||
fill(ms, startX, listY, startX + listW, listY + listHeight, 0xFF14141C);
|
||||
|
||||
int totalH = albums.size() * itemH;
|
||||
int maxScroll = Math.max(0, totalH - listHeight);
|
||||
scrollOffset = MathHelper.clamp(scrollOffset, 0, maxScroll);
|
||||
|
||||
int firstIndex = scrollOffset / itemH;
|
||||
int lastIndex = Math.min(albums.size(), firstIndex + (listHeight / itemH) + 2);
|
||||
|
||||
for (int i = firstIndex; i < lastIndex; i++) {
|
||||
NavidromeAlbum album = albums.get(i);
|
||||
int itemY = listY + (i * itemH) - scrollOffset;
|
||||
if (itemY + itemH <= listY || itemY >= listY + listHeight) continue;
|
||||
|
||||
boolean isHovered = mouseX >= startX && mouseX <= startX + listW && mouseY >= itemY && mouseY < itemY + itemH && mouseY >= listY && mouseY < listY + listHeight;
|
||||
fill(ms, startX, itemY, startX + listW, itemY + itemH, isHovered ? 0xFF262636 : ((i % 2 == 0) ? 0xFF1A1A24 : 0xFF15151E));
|
||||
|
||||
drawString(ms, this.font, "◎ " + album.name, startX + 8, itemY + 6, 0xFFFFFFFF);
|
||||
String info = (album.artist != null ? album.artist : "") + (album.year > 0 ? " (" + album.year + ")" : "");
|
||||
drawString(ms, this.font, info, startX + 180, itemY + 6, 0xFFAAAAAA);
|
||||
String count = album.songCount + " тр.";
|
||||
drawString(ms, this.font, count, startX + listW - font.width(count) - 10, itemY + 6, 0xFF888888);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderSettings(MatrixStack ms) {
|
||||
int startY = guiTop + 34;
|
||||
if (isOwner()) {
|
||||
drawString(ms, this.font, "Адрес сервера:", guiLeft + 16, startY + 4, 0xFFFFFFFF);
|
||||
drawString(ms, this.font, "ZeroTier URL:", guiLeft + 16, startY + 26, 0xFF80D8FF);
|
||||
drawString(ms, this.font, "Логин:", guiLeft + 16, startY + 48, 0xFFFFFFFF);
|
||||
drawString(ms, this.font, "Пароль:", guiLeft + 16, startY + 70, 0xFFFFFFFF);
|
||||
drawString(ms, this.font, "Караоке HUD:", guiLeft + 16, startY + 118, 0xFF00E5FF);
|
||||
|
||||
if (!statusMessage.isEmpty()) {
|
||||
drawCenteredString(ms, this.font, statusMessage, guiLeft + guiWidth / 2, startY + 138, statusColor);
|
||||
}
|
||||
} else {
|
||||
fill(ms, guiLeft + 16, startY, guiLeft + guiWidth - 16, startY + 42, 0xFF191924);
|
||||
fill(ms, guiLeft + 16, startY, guiLeft + guiWidth - 16, startY + 1, 0xFF3F51B5);
|
||||
|
||||
drawString(ms, this.font, "🔒 Параметры сервера и пароли скрыты владельцем", guiLeft + 24, startY + 8, 0xFFFFCC00);
|
||||
drawString(ms, this.font, "Вы можете настроить отображение текста песен (караоке) для себя:", guiLeft + 24, startY + 24, 0xFFAAAAAA);
|
||||
|
||||
drawString(ms, this.font, "Караоке HUD:", guiLeft + 16, startY + 58, 0xFF00E5FF);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button == 0) {
|
||||
if (currentTab == Tab.SEARCH) {
|
||||
checkSongListClick(mouseX, mouseY, currentSongList, guiTop + 52, 126, true);
|
||||
} else if (currentTab == Tab.QUEUE) {
|
||||
checkSongListClick(mouseX, mouseY, audioPlayer.getQueue(), guiTop + 30, 148, false);
|
||||
} else if (currentTab == Tab.PLAYLISTS) {
|
||||
if (currentPlaylist == null) {
|
||||
checkPlaylistClick(mouseX, mouseY, guiTop + 30, 148);
|
||||
} else {
|
||||
checkSongListClick(mouseX, mouseY, currentSongList, guiTop + 30, 148, true);
|
||||
}
|
||||
} else if (currentTab == Tab.ALBUMS) {
|
||||
if (currentAlbum == null) {
|
||||
checkAlbumClick(mouseX, mouseY, guiTop + 30, 148);
|
||||
} else {
|
||||
checkSongListClick(mouseX, mouseY, currentSongList, guiTop + 30, 148, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
private void checkSongListClick(double mouseX, double mouseY, List<NavidromeSong> rawList, int listY, int listHeight, boolean isSearchOrServer) {
|
||||
if (rawList == null) return;
|
||||
List<NavidromeSong> list = getFilteredList(rawList);
|
||||
int itemH = 22;
|
||||
int listW = guiWidth - 16;
|
||||
int startX = guiLeft + 8;
|
||||
|
||||
if (mouseX >= startX && mouseX <= startX + listW && mouseY >= listY && mouseY < listY + listHeight) {
|
||||
int relativeY = (int) (mouseY - listY) + scrollOffset;
|
||||
int index = relativeY / itemH;
|
||||
if (index >= 0 && index < list.size()) {
|
||||
NavidromeSong song = list.get(index);
|
||||
int actBtnX = startX + listW - 24;
|
||||
if (mouseX >= actBtnX && mouseX <= actBtnX + 18) {
|
||||
// Action button clicked
|
||||
if (isSearchOrServer) {
|
||||
// [+] Add to queue
|
||||
audioPlayer.addToQueue(song);
|
||||
} else if (isOwner()) {
|
||||
// [X] Remove from queue (owner only)
|
||||
int realIdx = rawList.indexOf(song);
|
||||
if (realIdx >= 0) {
|
||||
audioPlayer.removeFromQueue(realIdx);
|
||||
}
|
||||
} else {
|
||||
// [▶] Play from queue (guests)
|
||||
int realIdx = rawList.indexOf(song);
|
||||
if (realIdx >= 0) {
|
||||
audioPlayer.playQueueIndex(realIdx, 0);
|
||||
syncToServer();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Row clicked: play song
|
||||
if (isSearchOrServer) {
|
||||
audioPlayer.play(song);
|
||||
} else {
|
||||
int realIdx = rawList.indexOf(song);
|
||||
if (realIdx >= 0) {
|
||||
audioPlayer.playQueueIndex(realIdx, 0);
|
||||
}
|
||||
}
|
||||
syncToServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkPlaylistClick(double mouseX, double mouseY, int listY, int listHeight) {
|
||||
int itemH = 22;
|
||||
int listW = guiWidth - 16;
|
||||
int startX = guiLeft + 8;
|
||||
|
||||
if (mouseX >= startX && mouseX <= startX + listW && mouseY >= listY && mouseY < listY + listHeight) {
|
||||
int relativeY = (int) (mouseY - listY) + scrollOffset;
|
||||
int index = relativeY / itemH;
|
||||
if (index >= 0 && index < playlists.size()) {
|
||||
currentPlaylist = playlists.get(index);
|
||||
backBtn.visible = true;
|
||||
statusMessage = "Загрузка треков...";
|
||||
NavidromeApiClient.getPlaylistSongs(currentPlaylist.id).thenAccept(songs -> {
|
||||
this.currentSongList = songs;
|
||||
this.scrollOffset = 0;
|
||||
statusMessage = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAlbumClick(double mouseX, double mouseY, int listY, int listHeight) {
|
||||
int itemH = 22;
|
||||
int listW = guiWidth - 16;
|
||||
int startX = guiLeft + 8;
|
||||
|
||||
if (mouseX >= startX && mouseX <= startX + listW && mouseY >= listY && mouseY < listY + listHeight) {
|
||||
int relativeY = (int) (mouseY - listY) + scrollOffset;
|
||||
int index = relativeY / itemH;
|
||||
if (index >= 0 && index < albums.size()) {
|
||||
currentAlbum = albums.get(index);
|
||||
backBtn.visible = true;
|
||||
statusMessage = "Загрузка треков...";
|
||||
NavidromeApiClient.getAlbumSongs(currentAlbum.id).thenAccept(songs -> {
|
||||
this.currentSongList = songs;
|
||||
this.scrollOffset = 0;
|
||||
statusMessage = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
|
||||
scrollOffset = Math.max(0, scrollOffset - (int) (delta * 22));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.rardog.navidromeplayer.client.gui;
|
||||
|
||||
import com.mojang.blaze3d.matrix.MatrixStack;
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeSong;
|
||||
import net.minecraft.client.gui.AbstractGui;
|
||||
import net.minecraft.client.gui.toasts.IToast;
|
||||
import net.minecraft.client.gui.toasts.ToastGui;
|
||||
import net.minecraft.util.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class NowPlayingToast implements IToast {
|
||||
private final NavidromeSong song;
|
||||
|
||||
public NowPlayingToast(NavidromeSong song) {
|
||||
this.song = song;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Visibility render(MatrixStack ms, ToastGui toastGui, long delta) {
|
||||
toastGui.getMinecraft().getTextureManager().bind(TEXTURE);
|
||||
RenderSystem.color4f(1.0F, 1.0F, 1.0F, 1.0F);
|
||||
toastGui.blit(ms, 0, 0, 0, 0, this.width(), this.height());
|
||||
|
||||
if (song == null) {
|
||||
return Visibility.HIDE;
|
||||
}
|
||||
|
||||
String coverId = (song.coverArt != null && !song.coverArt.isEmpty()) ? song.coverArt : song.id;
|
||||
ResourceLocation cover = CoverArtCache.getCoverArt(coverId);
|
||||
|
||||
int iconX = 6;
|
||||
int iconY = 6;
|
||||
int iconSize = 20;
|
||||
|
||||
if (cover != null) {
|
||||
toastGui.getMinecraft().getTextureManager().bind(cover);
|
||||
AbstractGui.blit(ms, iconX, iconY, 0.0F, 0.0F, iconSize, iconSize, iconSize, iconSize);
|
||||
} else {
|
||||
AbstractGui.fill(ms, iconX, iconY, iconX + iconSize, iconY + iconSize, 0xFF1E2235);
|
||||
AbstractGui.fill(ms, iconX, iconY, iconX + iconSize, iconY + 1, 0xFF00E5FF);
|
||||
toastGui.getMinecraft().font.draw(ms, "♫", iconX + 6, iconY + 6, 0xFF00E5FF);
|
||||
}
|
||||
|
||||
String header = "♫ Сейчас играет";
|
||||
toastGui.getMinecraft().font.draw(ms, header, 30, 7, 0xFF500050);
|
||||
|
||||
String title = song.artist + " — " + song.title;
|
||||
net.minecraft.client.gui.FontRenderer font = toastGui.getMinecraft().font;
|
||||
if (font.width(title) > 122) {
|
||||
while (font.width(title + "…") > 122 && title.length() > 1) {
|
||||
title = title.substring(0, title.length() - 1);
|
||||
}
|
||||
title = title + "…";
|
||||
}
|
||||
font.draw(ms, title, 30, 18, 0xFF000000);
|
||||
|
||||
return delta >= 4500L ? Visibility.HIDE : Visibility.SHOW;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.rardog.navidromeplayer.client.gui;
|
||||
|
||||
import com.mojang.blaze3d.matrix.MatrixStack;
|
||||
import com.rardog.navidromeplayer.client.audio.NavidromeAudioPlayer;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeSong;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.widget.Widget;
|
||||
import net.minecraft.util.math.MathHelper;
|
||||
import net.minecraft.util.text.StringTextComponent;
|
||||
|
||||
public class TrackSeekBar extends Widget {
|
||||
private final NavidromeAudioPlayer audioPlayer = NavidromeAudioPlayer.getInstance();
|
||||
private boolean dragging = false;
|
||||
private float scrubRatio = 0.0f;
|
||||
|
||||
public TrackSeekBar(int x, int y, int width, int height) {
|
||||
super(x, y, width, height, new StringTextComponent("Seek"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(double mouseX, double mouseY) {
|
||||
this.dragging = true;
|
||||
updateScrubFromMouse(mouseX);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDrag(double mouseX, double mouseY, double dragX, double dragY) {
|
||||
if (this.dragging) {
|
||||
updateScrubFromMouse(mouseX);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelease(double mouseX, double mouseY) {
|
||||
if (this.dragging) {
|
||||
this.dragging = false;
|
||||
updateScrubFromMouse(mouseX);
|
||||
NavidromeSong song = audioPlayer.getCurrentSong();
|
||||
if (song != null && song.duration > 0) {
|
||||
int targetSec = (int) (scrubRatio * song.duration);
|
||||
audioPlayer.seek(targetSec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateScrubFromMouse(double mouseX) {
|
||||
float ratio = (float) (mouseX - this.x) / (float) this.width;
|
||||
this.scrubRatio = MathHelper.clamp(ratio, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderButton(MatrixStack ms, int mouseX, int mouseY, float partialTicks) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
NavidromeSong song = audioPlayer.getCurrentSong();
|
||||
|
||||
// Background
|
||||
fill(ms, this.x, this.y, this.x + this.width, this.y + this.height, 0xFF22222E);
|
||||
fill(ms, this.x + 1, this.y + 1, this.x + this.width - 1, this.y + this.height - 1, 0xFF121218);
|
||||
|
||||
float progress = 0.0f;
|
||||
int curSec = 0;
|
||||
int totSec = 0;
|
||||
|
||||
if (song != null && song.duration > 0) {
|
||||
totSec = song.duration;
|
||||
if (this.dragging) {
|
||||
progress = scrubRatio;
|
||||
curSec = (int) (scrubRatio * totSec);
|
||||
} else {
|
||||
curSec = audioPlayer.getElapsedSeconds();
|
||||
progress = MathHelper.clamp((float) curSec / (float) totSec, 0.0f, 1.0f);
|
||||
}
|
||||
} else if (audioPlayer.isPlaying()) {
|
||||
curSec = audioPlayer.getElapsedSeconds();
|
||||
}
|
||||
|
||||
// Filled progress bar
|
||||
int fillW = (int) ((this.width - 2) * progress);
|
||||
if (fillW > 0) {
|
||||
int barColor = dragging ? 0xFF00E5FF : 0xFF3F51B5;
|
||||
fill(ms, this.x + 1, this.y + 1, this.x + 1 + fillW, this.y + this.height - 1, barColor);
|
||||
}
|
||||
|
||||
// Thumb handle
|
||||
if (song != null) {
|
||||
int thumbX = this.x + 1 + fillW;
|
||||
fill(ms, thumbX - 2, this.y - 2, thumbX + 2, this.y + this.height + 2, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
// Time label text (e.g. 01:23 / 04:15)
|
||||
String timeStr;
|
||||
if (totSec > 0) {
|
||||
timeStr = String.format("%02d:%02d / %02d:%02d", curSec / 60, curSec % 60, totSec / 60, totSec % 60);
|
||||
} else {
|
||||
timeStr = String.format("%02d:%02d", curSec / 60, curSec % 60);
|
||||
}
|
||||
int textX = this.x + (this.width - mc.font.width(timeStr)) / 2;
|
||||
int textY = this.y + (this.height - 8) / 2;
|
||||
mc.font.draw(ms, timeStr, textX, textY, 0xFFFFFFFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.rardog.navidromeplayer.client.lyrics;
|
||||
|
||||
import com.mojang.blaze3d.matrix.MatrixStack;
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.rardog.navidromeplayer.client.audio.NavidromeAudioPlayer;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeSong;
|
||||
import net.minecraft.client.MainWindow;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.AbstractGui;
|
||||
import net.minecraft.util.math.MathHelper;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderGameOverlayEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class LyricsHudOverlay extends AbstractGui {
|
||||
private static final LyricsHudOverlay INSTANCE = new LyricsHudOverlay();
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
private final NavidromeAudioPlayer audioPlayer = NavidromeAudioPlayer.getInstance();
|
||||
private final LyricsManager lyricsManager = LyricsManager.getInstance();
|
||||
|
||||
private String previousLine = "";
|
||||
private String currentLine = "";
|
||||
private long transitionStartTime = 0;
|
||||
private static final float TRANSITION_DURATION_MS = 350.0f;
|
||||
|
||||
// Track announcement banner fallback
|
||||
private String lastAnnouncedSongId = null;
|
||||
private long announcementStartTime = 0;
|
||||
private static final long ANNOUNCEMENT_DURATION_MS = 6000L;
|
||||
|
||||
public static LyricsHudOverlay getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private LyricsHudOverlay() {}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onClientLoggedIn(net.minecraftforge.client.event.ClientPlayerNetworkEvent.LoggedInEvent event) {
|
||||
audioPlayer.loadQueueForCurrentWorld();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onClientLoggedOut(net.minecraftforge.client.event.ClientPlayerNetworkEvent.LoggedOutEvent event) {
|
||||
audioPlayer.stopLocalOnly();
|
||||
lyricsManager.clear();
|
||||
currentLine = "";
|
||||
previousLine = "";
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onWorldUnload(net.minecraftforge.event.world.WorldEvent.Unload event) {
|
||||
if (event.getWorld() != null && event.getWorld().isClientSide()) {
|
||||
audioPlayer.stopLocalOnly();
|
||||
lyricsManager.clear();
|
||||
currentLine = "";
|
||||
previousLine = "";
|
||||
}
|
||||
}
|
||||
|
||||
private int syncTickCounter = 0;
|
||||
|
||||
@SubscribeEvent
|
||||
public void onClientTick(net.minecraftforge.event.TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != net.minecraftforge.event.TickEvent.Phase.END) return;
|
||||
if (mc.player == null) return;
|
||||
|
||||
syncTickCounter++;
|
||||
if (syncTickCounter >= 40) {
|
||||
syncTickCounter = 0;
|
||||
if (audioPlayer.isPlaying() && !audioPlayer.isPaused() && !audioPlayer.isRemotePlayback()) {
|
||||
audioPlayer.syncPlaybackToServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onRenderOverlay(RenderGameOverlayEvent.Post event) {
|
||||
if (event.getType() != RenderGameOverlayEvent.ElementType.ALL) return;
|
||||
if (mc.player == null || mc.options.hideGui) return;
|
||||
if (!audioPlayer.isPlaying()) return;
|
||||
|
||||
NavidromeSong song = audioPlayer.getCurrentSong();
|
||||
if (song == null) return;
|
||||
|
||||
MainWindow window = mc.getWindow();
|
||||
int screenWidth = window.getGuiScaledWidth();
|
||||
int screenHeight = window.getGuiScaledHeight();
|
||||
MatrixStack ms = event.getMatrixStack();
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long posMs = audioPlayer.getElapsedMs();
|
||||
|
||||
// Check if track has lyrics
|
||||
if (lyricsManager.hasLyrics()) {
|
||||
LyricsManager.LyricsLine line = lyricsManager.getCurrentLine(posMs);
|
||||
String newText = (line != null && !line.text.isEmpty()) ? line.text : "";
|
||||
|
||||
if (!newText.equals(currentLine)) {
|
||||
previousLine = currentLine;
|
||||
currentLine = newText;
|
||||
transitionStartTime = now;
|
||||
}
|
||||
|
||||
renderLyricsTransition(ms, screenWidth, screenHeight, now, posMs);
|
||||
} else {
|
||||
// No synced lyrics: show "Now Playing" notification banner for a few seconds
|
||||
if (!song.id.equals(lastAnnouncedSongId)) {
|
||||
lastAnnouncedSongId = song.id;
|
||||
announcementStartTime = now;
|
||||
}
|
||||
|
||||
long elapsedAnnounce = now - announcementStartTime;
|
||||
if (elapsedAnnounce < ANNOUNCEMENT_DURATION_MS) {
|
||||
renderAnnouncement(ms, screenWidth, screenHeight, song, elapsedAnnounce);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderLyricsTransition(MatrixStack ms, int screenWidth, int screenHeight, long now, long posMs) {
|
||||
if (currentLine.isEmpty() && previousLine.isEmpty()) return;
|
||||
|
||||
com.rardog.navidromeplayer.client.navidrome.NavidromeConfig config = com.rardog.navidromeplayer.client.navidrome.NavidromeConfig.get();
|
||||
boolean isTop = "TOP".equalsIgnoreCase(config.lyricsPosition);
|
||||
int centerX = screenWidth / 2;
|
||||
int baseY = isTop ? 28 : (screenHeight - 68);
|
||||
|
||||
float progress = (now - transitionStartTime) / TRANSITION_DURATION_MS;
|
||||
progress = MathHelper.clamp(progress, 0.0f, 1.0f);
|
||||
|
||||
int curIdx = lyricsManager.getCurrentLineIndex(posMs);
|
||||
|
||||
// 1. Previous line (subtle above)
|
||||
if (!previousLine.isEmpty() && progress < 1.0f) {
|
||||
float alphaOut = (1.0f - progress) * 0.65f;
|
||||
int yOut = baseY - 15 - (int) (progress * 4);
|
||||
renderLineText(ms, centerX, yOut, previousLine, alphaOut, 0x88CCEE, false);
|
||||
} else if (config.showNextLyric && curIdx > 0 && progress >= 1.0f && !currentLine.isEmpty()) {
|
||||
LyricsManager.LyricsLine prevObj = lyricsManager.getLineAt(curIdx - 1);
|
||||
if (prevObj != null && !prevObj.text.isEmpty()) {
|
||||
renderLineText(ms, centerX, baseY - 15, prevObj.text, 0.35f, 0xAAAAAA, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Current line (main active lyric)
|
||||
if (!currentLine.isEmpty()) {
|
||||
float alphaIn = progress;
|
||||
int yIn = baseY + (int) ((1.0f - progress) * 4);
|
||||
renderLineText(ms, centerX, yIn, currentLine, alphaIn, 0xFFFFFF, true);
|
||||
}
|
||||
|
||||
// 3. Next line preview (subtle below)
|
||||
if (config.showNextLyric && curIdx >= 0 && !currentLine.isEmpty()) {
|
||||
LyricsManager.LyricsLine nextObj = lyricsManager.getLineAt(curIdx + 1);
|
||||
if (nextObj != null && !nextObj.text.isEmpty()) {
|
||||
renderLineText(ms, centerX, baseY + 15, nextObj.text, 0.40f, 0x99BBDD, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderAnnouncement(MatrixStack ms, int screenWidth, int screenHeight, NavidromeSong song, long elapsed) {
|
||||
float alpha = 1.0f;
|
||||
if (elapsed < 500) {
|
||||
alpha = elapsed / 500.0f;
|
||||
} else if (elapsed > ANNOUNCEMENT_DURATION_MS - 1000) {
|
||||
alpha = (ANNOUNCEMENT_DURATION_MS - elapsed) / 1000.0f;
|
||||
}
|
||||
alpha = MathHelper.clamp(alpha, 0.0f, 1.0f);
|
||||
|
||||
com.rardog.navidromeplayer.client.navidrome.NavidromeConfig config = com.rardog.navidromeplayer.client.navidrome.NavidromeConfig.get();
|
||||
boolean isTop = "TOP".equalsIgnoreCase(config.lyricsPosition);
|
||||
int centerX = screenWidth / 2;
|
||||
int y = isTop ? 28 : (screenHeight - 68);
|
||||
|
||||
String quality = audioPlayer.getAudioFormatQualityString();
|
||||
String text = "♫ " + song.artist + " — " + song.title;
|
||||
if (!quality.isEmpty()) {
|
||||
text += " [" + quality + "]";
|
||||
}
|
||||
renderLineText(ms, centerX, y, text, alpha, 0x00FFCC, true);
|
||||
}
|
||||
|
||||
private void renderLineText(MatrixStack ms, int centerX, int y, String text, float alpha, int baseRgb, boolean isHighlight) {
|
||||
if (alpha <= 0.01f || text.isEmpty()) return;
|
||||
|
||||
int textW = mc.font.width(text);
|
||||
int halfW = textW / 2;
|
||||
|
||||
// Draw sleek pill background
|
||||
int bgAlpha = (int) (alpha * (isHighlight ? 170 : 110));
|
||||
int bgColor = (bgAlpha << 24) | 0x101018;
|
||||
fill(ms, centerX - halfW - 8, y - 3, centerX + halfW + 8, y + 11, bgColor);
|
||||
|
||||
if (isHighlight) {
|
||||
// Border accents for highlighted main line
|
||||
int borderAlpha = (int) (alpha * 130);
|
||||
int borderColor = (borderAlpha << 24) | 0x3F51B5;
|
||||
fill(ms, centerX - halfW - 8, y - 4, centerX + halfW + 8, y - 3, borderColor);
|
||||
fill(ms, centerX - halfW - 8, y + 11, centerX + halfW + 8, y + 12, borderColor);
|
||||
}
|
||||
|
||||
// Draw text with shadow and alpha
|
||||
int textAlpha = (int) (alpha * 255);
|
||||
int color = (textAlpha << 24) | (baseRgb & 0x00FFFFFF);
|
||||
|
||||
RenderSystem.enableBlend();
|
||||
RenderSystem.defaultBlendFunc();
|
||||
mc.font.drawShadow(ms, text, centerX - halfW, y, color);
|
||||
RenderSystem.disableBlend();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package com.rardog.navidromeplayer.client.lyrics;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeConfig;
|
||||
import com.rardog.navidromeplayer.client.navidrome.NavidromeSong;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class LyricsManager {
|
||||
private static final LyricsManager INSTANCE = new LyricsManager();
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final Pattern LRC_PATTERN = Pattern.compile("\\[(\\d{1,2}):(\\d{2})(?:[\\.:](\\d{1,3}))?\\](.*)");
|
||||
private static final Pattern LRC_OFFSET_PATTERN = Pattern.compile("^\\[offset:\\s*([+-]?\\d+)\\]", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern LRC_META_PATTERN = Pattern.compile("^\\[(?:ar|al|ti|by|length|re|ve|offset|hash|au):[^\\]]*\\]", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern ENHANCED_WORD_TAG = Pattern.compile("<\\d{1,2}:\\d{2}(?:[\\.:]\\d{1,3})?>");
|
||||
|
||||
private static final Pattern YAML_TEXT_PATTERN = Pattern.compile("^(?:-\\s*)?text:\\s*['\"]?(.*?)['\"]?$");
|
||||
private static final Pattern YAML_START_PATTERN = Pattern.compile("^(?:-\\s*)?(?:start_ms|startTimeMs|time_ms|start):\\s*(\\d+)");
|
||||
private static final Pattern YAML_END_PATTERN = Pattern.compile("^(?:-\\s*)?(?:end_ms|endTimeMs|end):\\s*(\\d+)");
|
||||
|
||||
public static class LyricsLine {
|
||||
public final long timeMs;
|
||||
public final long endTimeMs;
|
||||
public final String text;
|
||||
|
||||
public LyricsLine(long timeMs, String text) {
|
||||
this(timeMs, 0, text);
|
||||
}
|
||||
|
||||
public LyricsLine(long timeMs, long endTimeMs, String text) {
|
||||
this.timeMs = timeMs;
|
||||
this.endTimeMs = endTimeMs;
|
||||
this.text = text != null ? text.trim() : "";
|
||||
}
|
||||
}
|
||||
|
||||
private final List<LyricsLine> currentLyrics = new ArrayList<>();
|
||||
private String currentSongId = null;
|
||||
private boolean hasLyrics = false;
|
||||
|
||||
public static LyricsManager getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private LyricsManager() {}
|
||||
|
||||
public synchronized void loadLyrics(NavidromeSong song) {
|
||||
if (song == null) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (song.id.equals(currentSongId)) return;
|
||||
this.currentSongId = song.id;
|
||||
this.hasLyrics = false;
|
||||
this.currentLyrics.clear();
|
||||
|
||||
CompletableFuture.runAsync(() -> {
|
||||
List<LyricsLine> loaded = fetchLyrics(song);
|
||||
synchronized (this) {
|
||||
if (song.id.equals(currentSongId)) {
|
||||
currentLyrics.clear();
|
||||
if (loaded != null && !loaded.isEmpty()) {
|
||||
currentLyrics.addAll(loaded);
|
||||
hasLyrics = true;
|
||||
System.out.println("[LyricsManager] Loaded " + loaded.size() + " synced lyric lines for: " + song.title);
|
||||
} else {
|
||||
hasLyrics = false;
|
||||
System.out.println("[LyricsManager] No lyrics found for: " + song.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized void clear() {
|
||||
currentSongId = null;
|
||||
hasLyrics = false;
|
||||
currentLyrics.clear();
|
||||
}
|
||||
|
||||
public synchronized boolean hasLyrics() {
|
||||
return hasLyrics && !currentLyrics.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized LyricsLine getCurrentLine(long positionMs) {
|
||||
if (!hasLyrics || currentLyrics.isEmpty()) return null;
|
||||
|
||||
LyricsLine best = null;
|
||||
for (int i = 0; i < currentLyrics.size(); i++) {
|
||||
LyricsLine line = currentLyrics.get(i);
|
||||
if (line.timeMs <= positionMs) {
|
||||
if (line.endTimeMs > line.timeMs) {
|
||||
if (positionMs <= line.endTimeMs + 400) {
|
||||
best = line;
|
||||
} else {
|
||||
best = null; // Line finished and passed end time
|
||||
}
|
||||
} else {
|
||||
long nextTimeMs = (i + 1 < currentLyrics.size()) ? currentLyrics.get(i + 1).timeMs : (line.timeMs + 7000L);
|
||||
long maxDuration = Math.min(nextTimeMs, line.timeMs + 7500L);
|
||||
if (positionMs <= maxDuration) {
|
||||
best = line;
|
||||
} else {
|
||||
best = null; // Gap before next line
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public synchronized int getCurrentLineIndex(long positionMs) {
|
||||
if (!hasLyrics || currentLyrics.isEmpty()) return -1;
|
||||
int bestIdx = -1;
|
||||
for (int i = 0; i < currentLyrics.size(); i++) {
|
||||
LyricsLine line = currentLyrics.get(i);
|
||||
if (line.timeMs <= positionMs) {
|
||||
if (line.endTimeMs > line.timeMs) {
|
||||
if (positionMs <= line.endTimeMs + 400) {
|
||||
bestIdx = i;
|
||||
} else {
|
||||
bestIdx = -1;
|
||||
}
|
||||
} else {
|
||||
long nextTimeMs = (i + 1 < currentLyrics.size()) ? currentLyrics.get(i + 1).timeMs : (line.timeMs + 7000L);
|
||||
long maxDuration = Math.min(nextTimeMs, line.timeMs + 7500L);
|
||||
if (positionMs <= maxDuration) {
|
||||
bestIdx = i;
|
||||
} else {
|
||||
bestIdx = -1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
public synchronized LyricsLine getLineAt(int index) {
|
||||
if (!hasLyrics || index < 0 || index >= currentLyrics.size()) return null;
|
||||
return currentLyrics.get(index);
|
||||
}
|
||||
|
||||
private List<LyricsLine> fetchLyrics(NavidromeSong song) {
|
||||
NavidromeConfig config = NavidromeConfig.get();
|
||||
String baseUrl = config.getCleanUrl();
|
||||
|
||||
// 1. Try Navidrome server first (OpenSubsonic getLyricsBySongId)
|
||||
if (!baseUrl.isEmpty()) {
|
||||
try {
|
||||
String urlStr = baseUrl + "/rest/getLyricsBySongId.view?id=" + NavidromeConfig.urlEncode(song.id) +
|
||||
"&" + config.getAuthQueryParams();
|
||||
JsonElement elem = fetchJson(urlStr);
|
||||
if (elem != null && elem.isJsonObject()) {
|
||||
JsonObject root = elem.getAsJsonObject();
|
||||
if (root.has("subsonic-response")) {
|
||||
JsonObject resp = root.getAsJsonObject("subsonic-response");
|
||||
List<LyricsLine> list = parseNavidromeLyrics(resp, true);
|
||||
if (list != null && !list.isEmpty()) {
|
||||
System.out.println("[LyricsManager] Using Navidrome local synced lyrics for: " + song.title);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// 2. Query LRCLIB (open-source synced lyrics database)
|
||||
try {
|
||||
List<LyricsLine> lrclibList = fetchFromLrclib(song);
|
||||
if (lrclibList != null && !lrclibList.isEmpty()) {
|
||||
System.out.println("[LyricsManager] Using LRCLIB millisecond-synced lyrics for: " + song.title);
|
||||
return lrclibList;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("[LyricsManager] LRCLIB query failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 3. Fallback: try Navidrome standard getLyrics.view or unsynced
|
||||
if (!baseUrl.isEmpty()) {
|
||||
try {
|
||||
String urlStr = baseUrl + "/rest/getLyricsBySongId.view?id=" + NavidromeConfig.urlEncode(song.id) +
|
||||
"&" + config.getAuthQueryParams();
|
||||
JsonElement elem = fetchJson(urlStr);
|
||||
if (elem != null && elem.isJsonObject()) {
|
||||
JsonObject resp = elem.getAsJsonObject().getAsJsonObject("subsonic-response");
|
||||
if (resp != null) {
|
||||
List<LyricsLine> list = parseNavidromeLyrics(resp, false);
|
||||
if (list != null && !list.isEmpty()) {
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
try {
|
||||
String urlStr = baseUrl + "/rest/getLyrics.view?artist=" + NavidromeConfig.urlEncode(song.artist) +
|
||||
"&title=" + NavidromeConfig.urlEncode(song.title) + "&" + config.getAuthQueryParams();
|
||||
JsonElement elem = fetchJson(urlStr);
|
||||
if (elem != null && elem.isJsonObject()) {
|
||||
JsonObject resp = elem.getAsJsonObject().getAsJsonObject("subsonic-response");
|
||||
if (resp != null && resp.has("lyrics")) {
|
||||
JsonObject lyr = resp.getAsJsonObject("lyrics");
|
||||
String text = "";
|
||||
if (lyr.has("value")) text = lyr.get("value").getAsString();
|
||||
else if (lyr.has("content")) text = lyr.get("content").getAsString();
|
||||
else if (lyr.has("text")) text = lyr.get("text").getAsString();
|
||||
|
||||
List<LyricsLine> parsed = parseLrcContent(text);
|
||||
if (!parsed.isEmpty()) return parsed;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private List<LyricsLine> fetchFromLrclib(NavidromeSong song) {
|
||||
String cleanArt = cleanArtist(song.artist);
|
||||
String cleanTit = cleanTitle(song.title);
|
||||
int dur = song.duration;
|
||||
|
||||
// Attempt 1: Exact /api/get with clean artist and title
|
||||
try {
|
||||
String url1 = "https://lrclib.net/api/get?artist_name=" + NavidromeConfig.urlEncode(cleanArt) +
|
||||
"&track_name=" + NavidromeConfig.urlEncode(cleanTit) +
|
||||
(dur > 0 ? "&duration=" + dur : "");
|
||||
JsonElement elem = fetchJson(url1);
|
||||
if (elem != null && elem.isJsonObject()) {
|
||||
JsonObject data = elem.getAsJsonObject();
|
||||
if (data.has("syncedLyrics") && !data.get("syncedLyrics").isJsonNull()) {
|
||||
String lrc = data.get("syncedLyrics").getAsString();
|
||||
List<LyricsLine> list = parseLrcContent(lrc);
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// Attempt 2: Exact /api/get without duration (in case duration varies slightly)
|
||||
if (dur > 0) {
|
||||
try {
|
||||
String url2 = "https://lrclib.net/api/get?artist_name=" + NavidromeConfig.urlEncode(cleanArt) +
|
||||
"&track_name=" + NavidromeConfig.urlEncode(cleanTit);
|
||||
JsonElement elem = fetchJson(url2);
|
||||
if (elem != null && elem.isJsonObject()) {
|
||||
JsonObject data = elem.getAsJsonObject();
|
||||
if (data.has("syncedLyrics") && !data.get("syncedLyrics").isJsonNull()) {
|
||||
String lrc = data.get("syncedLyrics").getAsString();
|
||||
List<LyricsLine> list = parseLrcContent(lrc);
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// Attempt 3: Flexible search /api/search?q=
|
||||
try {
|
||||
String query = (cleanArt + " " + cleanTit).trim();
|
||||
String searchUrl = "https://lrclib.net/api/search?q=" + NavidromeConfig.urlEncode(query);
|
||||
JsonElement elem = fetchJson(searchUrl);
|
||||
if (elem != null && elem.isJsonArray()) {
|
||||
JsonArray items = elem.getAsJsonArray();
|
||||
JsonObject bestItem = null;
|
||||
int bestDiff = Integer.MAX_VALUE;
|
||||
|
||||
for (JsonElement itemElem : items) {
|
||||
if (!itemElem.isJsonObject()) continue;
|
||||
JsonObject item = itemElem.getAsJsonObject();
|
||||
if (item.has("syncedLyrics") && !item.get("syncedLyrics").isJsonNull()) {
|
||||
String syn = item.get("syncedLyrics").getAsString().trim();
|
||||
if (!syn.isEmpty()) {
|
||||
int itemDur = item.has("duration") ? item.get("duration").getAsInt() : 0;
|
||||
int diff = (dur > 0 && itemDur > 0) ? Math.abs(dur - itemDur) : 0;
|
||||
if (bestItem == null || diff < bestDiff) {
|
||||
bestItem = item;
|
||||
bestDiff = diff;
|
||||
if (diff <= 3) break; // Close enough match
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestItem != null) {
|
||||
String lrc = bestItem.get("syncedLyrics").getAsString();
|
||||
List<LyricsLine> list = parseLrcContent(lrc);
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private List<LyricsLine> parseNavidromeLyrics(JsonObject resp, boolean syncedOnly) {
|
||||
if (!resp.has("lyricsList") || !resp.getAsJsonObject("lyricsList").has("structuredLyrics")) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
JsonArray arr = resp.getAsJsonObject("lyricsList").getAsJsonArray("structuredLyrics");
|
||||
for (JsonElement el : arr) {
|
||||
if (!el.isJsonObject()) continue;
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
if (!o.has("line")) continue;
|
||||
JsonArray linesArr = o.getAsJsonArray("line");
|
||||
|
||||
List<String> rawValues = new ArrayList<>();
|
||||
List<LyricsLine> directSynced = new ArrayList<>();
|
||||
boolean hasNativeTimestamps = false;
|
||||
|
||||
for (JsonElement l : linesArr) {
|
||||
if (!l.isJsonObject()) continue;
|
||||
JsonObject obj = l.getAsJsonObject();
|
||||
String val = obj.has("value") ? obj.get("value").getAsString() : "";
|
||||
rawValues.add(val);
|
||||
|
||||
long start = -1;
|
||||
if (obj.has("start")) start = obj.get("start").getAsLong();
|
||||
else if (obj.has("time")) start = obj.get("time").getAsLong();
|
||||
|
||||
if (start >= 0) {
|
||||
hasNativeTimestamps = true;
|
||||
String cleanVal = cleanLyricText(val);
|
||||
if (!cleanVal.isEmpty()) {
|
||||
directSynced.add(new LyricsLine(start, cleanVal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 1: Native Subsonic timestamps
|
||||
if (hasNativeTimestamps && !directSynced.isEmpty()) {
|
||||
Collections.sort(directSynced, Comparator.comparingLong(a -> a.timeMs));
|
||||
return directSynced;
|
||||
}
|
||||
|
||||
// Check 2: Spotify YAML format in raw values (e.g. start_ms: 12340, text: ...)
|
||||
List<LyricsLine> yamlParsed = parseSpotifyYaml(rawValues);
|
||||
if (!yamlParsed.isEmpty()) {
|
||||
return yamlParsed;
|
||||
}
|
||||
|
||||
// Check 3: Standard LRC embedded in lines
|
||||
List<LyricsLine> lrcParsed = parseLrcFromLines(rawValues);
|
||||
if (!lrcParsed.isEmpty()) {
|
||||
return lrcParsed;
|
||||
}
|
||||
|
||||
// If syncedOnly is false, plain lyrics fallback
|
||||
if (!syncedOnly) {
|
||||
List<LyricsLine> plainFiltered = parseCleanPlainLines(rawValues);
|
||||
if (!plainFiltered.isEmpty()) {
|
||||
return plainFiltered;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private List<LyricsLine> parseSpotifyYaml(List<String> rawValues) {
|
||||
List<LyricsLine> results = new ArrayList<>();
|
||||
String curText = null;
|
||||
long curStart = -1;
|
||||
long curEnd = -1;
|
||||
|
||||
for (String val : rawValues) {
|
||||
if (val == null) continue;
|
||||
String trimmed = val.trim();
|
||||
if (trimmed.isEmpty()) continue;
|
||||
|
||||
Matcher mText = YAML_TEXT_PATTERN.matcher(trimmed);
|
||||
if (mText.find()) {
|
||||
curText = mText.group(1).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
Matcher mStart = YAML_START_PATTERN.matcher(trimmed);
|
||||
if (mStart.find()) {
|
||||
try {
|
||||
curStart = Long.parseLong(mStart.group(1));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
continue;
|
||||
}
|
||||
|
||||
Matcher mEnd = YAML_END_PATTERN.matcher(trimmed);
|
||||
if (mEnd.find()) {
|
||||
try {
|
||||
curEnd = Long.parseLong(mEnd.group(1));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
if (curText != null && !curText.isEmpty() && curStart >= 0) {
|
||||
String clean = cleanLyricText(curText);
|
||||
if (!clean.isEmpty()) {
|
||||
results.add(new LyricsLine(curStart, curEnd, clean));
|
||||
}
|
||||
}
|
||||
curText = null;
|
||||
curStart = -1;
|
||||
curEnd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Catch last trailing line if end_ms was missing
|
||||
if (curText != null && !curText.isEmpty() && curStart >= 0) {
|
||||
String clean = cleanLyricText(curText);
|
||||
if (!clean.isEmpty()) {
|
||||
results.add(new LyricsLine(curStart, curEnd, clean));
|
||||
}
|
||||
}
|
||||
|
||||
if (!results.isEmpty()) {
|
||||
Collections.sort(results, Comparator.comparingLong(a -> a.timeMs));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<LyricsLine> parseLrcContent(String content) {
|
||||
if (content == null || content.isEmpty()) return Collections.emptyList();
|
||||
String[] lines = content.split("\r?\n");
|
||||
List<String> list = new ArrayList<>(lines.length);
|
||||
for (String line : lines) {
|
||||
list.add(line);
|
||||
}
|
||||
return parseLrcFromLines(list);
|
||||
}
|
||||
|
||||
private List<LyricsLine> parseLrcFromLines(List<String> rawLines) {
|
||||
List<LyricsLine> list = new ArrayList<>();
|
||||
long offsetMs = 0;
|
||||
|
||||
for (String line : rawLines) {
|
||||
if (line == null) continue;
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
// Offset tag check
|
||||
Matcher mOff = LRC_OFFSET_PATTERN.matcher(line);
|
||||
if (mOff.find()) {
|
||||
try {
|
||||
offsetMs = Long.parseLong(mOff.group(1));
|
||||
} catch (Exception ignored) {}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Metadata tag check: [ar:...], [ti:...], etc.
|
||||
if (LRC_META_PATTERN.matcher(line).find()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract all timestamps on this line
|
||||
Matcher matcher = LRC_PATTERN.matcher(line);
|
||||
List<Long> timesOnLine = new ArrayList<>();
|
||||
String text = line;
|
||||
|
||||
while (matcher.find()) {
|
||||
long min = Long.parseLong(matcher.group(1));
|
||||
long sec = Long.parseLong(matcher.group(2));
|
||||
long ms = 0;
|
||||
String msStr = matcher.group(3);
|
||||
if (msStr != null) {
|
||||
if (msStr.length() == 1) ms = Long.parseLong(msStr) * 100;
|
||||
else if (msStr.length() == 2) ms = Long.parseLong(msStr) * 10;
|
||||
else ms = Long.parseLong(msStr.substring(0, 3));
|
||||
}
|
||||
timesOnLine.add(min * 60000 + sec * 1000 + ms);
|
||||
text = matcher.group(4);
|
||||
}
|
||||
|
||||
if (!timesOnLine.isEmpty()) {
|
||||
String clean = cleanLyricText(text);
|
||||
if (!clean.isEmpty()) {
|
||||
for (long t : timesOnLine) {
|
||||
list.add(new LyricsLine(Math.max(0, t + offsetMs), clean));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!list.isEmpty()) {
|
||||
Collections.sort(list, Comparator.comparingLong(a -> a.timeMs));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<LyricsLine> parseCleanPlainLines(List<String> rawLines) {
|
||||
List<String> clean = new ArrayList<>();
|
||||
for (String line : rawLines) {
|
||||
if (line == null) continue;
|
||||
String t = line.trim();
|
||||
if (t.isEmpty()) continue;
|
||||
// Ignore metadata tags, yaml headers, instrumental
|
||||
if (t.startsWith("version:") || t.startsWith("metadata:") || t.startsWith("title:") ||
|
||||
t.startsWith("artist:") || t.startsWith("album:") || t.startsWith("plain:") ||
|
||||
t.equalsIgnoreCase("Instrumental")) {
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith("start_ms:") || t.startsWith("end_ms:") || t.startsWith("- text:")) {
|
||||
continue;
|
||||
}
|
||||
clean.add(cleanLyricText(t));
|
||||
}
|
||||
if (clean.isEmpty()) return Collections.emptyList();
|
||||
|
||||
List<LyricsLine> result = new ArrayList<>();
|
||||
long step = 4000L;
|
||||
for (int i = 0; i < clean.size(); i++) {
|
||||
result.add(new LyricsLine(i * step, clean.get(i)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String cleanLyricText(String text) {
|
||||
if (text == null) return "";
|
||||
// Strip enhanced word sync tags <00:12.34>
|
||||
String s = ENHANCED_WORD_TAG.matcher(text).replaceAll("");
|
||||
// Strip residual yaml quotes
|
||||
if (s.startsWith("'") && s.endsWith("'") && s.length() >= 2) {
|
||||
s = s.substring(1, s.length() - 1);
|
||||
} else if (s.startsWith("\"") && s.endsWith("\"") && s.length() >= 2) {
|
||||
s = s.substring(1, s.length() - 1);
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
public static String cleanArtist(String artist) {
|
||||
if (artist == null) return "";
|
||||
String s = artist;
|
||||
s = s.replace("•", " ");
|
||||
s = s.replaceAll("(?i)\\s+(feat\\.|ft\\.|featuring)\\s+.*", "");
|
||||
s = s.replaceAll("(?i)/.*", "");
|
||||
s = s.replaceAll("(?i),.*", "");
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
public static String cleanTitle(String title) {
|
||||
if (title == null) return "";
|
||||
String s = title;
|
||||
s = s.replaceAll("(?i)\\s*\\(feat\\..*?\\)", "");
|
||||
s = s.replaceAll("(?i)\\s*\\[feat\\..*?\\]", "");
|
||||
s = s.replaceAll("(?i)\\s*feat\\..*", "");
|
||||
s = s.replaceAll("(?i)\\s*ft\\..*", "");
|
||||
s = s.replaceAll("(?i)\\s*\\(remaster.*?\\)", "");
|
||||
s = s.replaceAll("(?i)\\s*\\[remaster.*?\\]", "");
|
||||
s = s.replaceAll("(?i)\\s*-.*remaster.*", "");
|
||||
s = s.replaceAll("(?i)\\s*-.*studio recording.*", "");
|
||||
s = s.replaceAll("(?i)\\s*\\(official.*\\)", "");
|
||||
s = s.replaceAll("(?i)\\s*\\[official.*\\]", "");
|
||||
s = s.replaceAll("(?i)\\s*\\(deluxe.*?\\)", "");
|
||||
s = s.replaceAll("(?i)\\s*\\[deluxe.*?\\]", "");
|
||||
s = s.replaceAll("(?i)\\s*\\(cover.*?\\)", "");
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
private static JsonElement fetchJson(String urlString) {
|
||||
try {
|
||||
URL url = new URL(urlString);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(6000);
|
||||
conn.setReadTimeout(6000);
|
||||
conn.setRequestProperty("User-Agent", "NavidromePlayerMod/1.0 (Minecraft Mod)");
|
||||
|
||||
if (conn.getResponseCode() != 200) return null;
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
return GSON.fromJson(reader, JsonElement.class);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user