From 32f8428ddb07f3f6bd0d0f04ba477d5cf5e931da Mon Sep 17 00:00:00 2001 From: jkortis Date: Wed, 30 Sep 2026 23:12:47 -0400 Subject: [PATCH] Initial commit: Spotify MC client-side Fabric mod for Minecraft 1.21.1 --- .gitattributes | 17 + .gitignore | 37 ++ LICENSE | 21 ++ README.md | 324 ++++++++++++++++++ build.gradle | 69 ++++ gradle.properties | 19 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++ gradlew.bat | 94 +++++ settings.gradle | 12 + src/main/java/com/spotifymc/SpotifyMod.java | 99 ++++++ .../com/spotifymc/api/SpotifyApiClient.java | 281 +++++++++++++++ .../java/com/spotifymc/api/SpotifyDevice.java | 13 + .../spotifymc/api/SpotifyPlaybackState.java | 78 +++++ .../spotifymc/auth/LocalCallbackServer.java | 261 ++++++++++++++ .../java/com/spotifymc/auth/PkceUtils.java | 70 ++++ .../spotifymc/auth/SpotifyAuthManager.java | 234 +++++++++++++ .../java/com/spotifymc/auth/TokenStorage.java | 202 +++++++++++ .../spotifymc/commands/SpotifyCommands.java | 119 +++++++ .../spotifymc/config/ModMenuIntegration.java | 21 ++ .../com/spotifymc/config/SpotifyConfig.java | 174 ++++++++++ .../spotifymc/config/SpotifyConfigScreen.java | 254 ++++++++++++++ .../java/com/spotifymc/hud/AlbumArtCache.java | 190 ++++++++++ .../com/spotifymc/hud/SpotifyHudRenderer.java | 249 ++++++++++++++ .../keybinds/SpotifyKeybindHandler.java | 109 ++++++ .../playback/SpotifyPlaybackManager.java | 266 ++++++++++++++ src/main/resources/assets/spotifymc/icon.png | Bin 0 -> 1431 bytes .../assets/spotifymc/lang/en_us.json | 39 +++ src/main/resources/fabric.mod.json | 35 ++ .../com/spotifymc/auth/PkceUtilsTest.java | 44 +++ .../com/spotifymc/auth/TokenStorageTest.java | 53 +++ 32 files changed, 3643 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/spotifymc/SpotifyMod.java create mode 100644 src/main/java/com/spotifymc/api/SpotifyApiClient.java create mode 100644 src/main/java/com/spotifymc/api/SpotifyDevice.java create mode 100644 src/main/java/com/spotifymc/api/SpotifyPlaybackState.java create mode 100644 src/main/java/com/spotifymc/auth/LocalCallbackServer.java create mode 100644 src/main/java/com/spotifymc/auth/PkceUtils.java create mode 100644 src/main/java/com/spotifymc/auth/SpotifyAuthManager.java create mode 100644 src/main/java/com/spotifymc/auth/TokenStorage.java create mode 100644 src/main/java/com/spotifymc/commands/SpotifyCommands.java create mode 100644 src/main/java/com/spotifymc/config/ModMenuIntegration.java create mode 100644 src/main/java/com/spotifymc/config/SpotifyConfig.java create mode 100644 src/main/java/com/spotifymc/config/SpotifyConfigScreen.java create mode 100644 src/main/java/com/spotifymc/hud/AlbumArtCache.java create mode 100644 src/main/java/com/spotifymc/hud/SpotifyHudRenderer.java create mode 100644 src/main/java/com/spotifymc/keybinds/SpotifyKeybindHandler.java create mode 100644 src/main/java/com/spotifymc/playback/SpotifyPlaybackManager.java create mode 100644 src/main/resources/assets/spotifymc/icon.png create mode 100644 src/main/resources/assets/spotifymc/lang/en_us.json create mode 100644 src/main/resources/fabric.mod.json create mode 100644 src/test/java/com/spotifymc/auth/PkceUtilsTest.java create mode 100644 src/test/java/com/spotifymc/auth/TokenStorageTest.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..099fdf1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Auto-detect text files and perform LF normalization +* text=auto + +# Source code & configs +*.java text diff=java eol=lf +*.json text eol=lf +*.md text eol=lf +*.gradle text eol=lf +*.properties text eol=lf + +# Gradle wrapper script (MUST be LF on Linux/macOS) +gradlew text eol=lf +gradlew.bat text eol=crlf + +# Binary assets +*.png binary +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b7bf7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Gradle & Build Artifacts +.gradle/ +build/ +bin/ +out/ + +# Built JARs and Distribution Artifacts (Do NOT commit mod JARs into git) +*.jar +!gradle/wrapper/gradle-wrapper.jar +MOD_JAR/ + +# Minecraft Run Directory & Logs (Do NOT commit client logs or crash reports) +run/ +logs/ +crash-reports/ +*.log +*.log.gz + +# Spotify MC Credentials & Sensitive Configuration +# NEVER commit these files to version control! +**/config/spotifymc/credentials.json +**/config/spotifymc/*credentials* +**/config/spotifymc/*.token +*.env +*.key + +# IDE & Editor Files +.idea/ +*.iml +.vscode/ +.classpath +.project +.settings/ + +# OS Specific +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f30b5f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jkortis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..74c3de7 --- /dev/null +++ b/README.md @@ -0,0 +1,324 @@ +# Spotify MC — Client-Side Spotify HUD for Minecraft + +[![Minecraft](https://img.shields.io/badge/Minecraft-1.21.1-brightgreen.svg?style=flat-square&logo=minecraft)](https://minecraft.net/) +[![Fabric](https://img.shields.io/badge/Modloader-Fabric-blue.svg?style=flat-square)](https://fabricmc.net/) +[![Java](https://img.shields.io/badge/Java-21+-orange.svg?style=flat-square&logo=openjdk)](https://adoptium.net/) +[![ModMenu](https://img.shields.io/badge/ModMenu-Supported-blueviolet.svg?style=flat-square)](https://modrinth.com/mod/modmenu) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) + +A lightweight, high-performance, client-side Minecraft mod that brings real-time Spotify playback info, album art, and controls directly into your game HUD. + +Built specifically for **Minecraft 1.21.1** using **Fabric Loader** and **Java 21**. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [HUD Preview](#hud-preview) +- [Requirements](#requirements) +- [Player Installation](#player-installation) +- [Spotify Developer App Setup](#spotify-developer-app-setup) +- [Connecting In-Game](#connecting-in-game) +- [Controls & Commands](#controls--commands) +- [Configuration](#configuration) +- [Technical Architecture & Security](#technical-architecture--security) +- [Building from Source](#building-from-source) +- [Troubleshooting & FAQ](#troubleshooting--faq) +- [License](#license) + +--- + +## Overview + +**Spotify MC** connects directly to Spotify's Web API using secure **OAuth 2.0 PKCE** (Proof Key for Code Exchange) authentication. It allows you to view what is currently playing, inspect progress, and trigger playback controls from inside Minecraft without alt-tabbing or requiring external bridge apps. + +### Highlights +- **Zero Native Dependencies**: Uses the built-in JDK `java.net.http.HttpClient` on background daemon threads. +- **Client-Side Only**: Does not need to be installed on servers. Play on any vanilla or modded server safely. +- **Battery & Performance Friendly**: Implements adaptive polling backoff (polls less frequently when paused or idle) and local linear progress extrapolation between ticks to eliminate lag spikes. +- **Privacy & Security First**: Tokens are encrypted locally with AES-GCM and never printed to crash logs or `latest.log`. + +--- + +## Features + +- **Dynamic HUD Overlay**: + - Displays song title, artist, and album name with customizable positioning. + - Automatic **marquee text scrolling** for long track titles and artist names. + - Asynchronously loaded and cached **album artwork** with memory-capped LRU texture eviction. + - Animated **progress bar** with real-time extrapolated smooth position updates and total duration (`mm:ss / mm:ss`). + - Playback status badge (`▶ Playing`, `⏸ Paused`, `⊘ No Player`, `! Offline`). + - Polished **glassmorphism** visual style with semi-transparent background and Spotify Green accent bars. +- **Configurable Keybinds**: + - Play / Pause Toggle + - Next Track / Previous Track + - Volume Up (+5%) / Volume Down (-5%) + - Toggle HUD overlay visibility on the fly +- **In-Game Chat Commands**: + - Full client-side `/spotify` command tree with tab completion. +- **In-Game GUI & ModMenu Integration**: + - Full interactive screen to input your Client ID, click to connect, adjust HUD scale (0.5x–2.0x), opacity (20%–100%), polling rate (1s–10s), screen anchor corners, and toggle artwork. + +--- + +## HUD Preview + +```text +┌─────────────────────────────────────────────────────────────┐ +│ ┌───────┐ ▶ Starboy (feat. Daft Punk) │ +│ │ ALBUM │ The Weeknd — Starboy │ +│ │ ART │ ━━━━━━━━━━━━━━━●──────────────────── 01:24 / 03:50│ +│ └───────┘ Device: Desktop-PC (85% Vol) │ +└─────────────────────────────────────────────────────────────┘ +``` + +The overlay anchors to any of the 4 screen corners (`TOP_LEFT`, `TOP_RIGHT`, `BOTTOM_LEFT`, `BOTTOM_RIGHT`) with adjustable offsets, scaling, and opacity. + +--- + +## Requirements + +| Requirement | Supported Version | Notes | +| :--- | :--- | :--- | +| **Minecraft** | `1.21.1` | Client-side only | +| **Fabric Loader** | `>= 0.16.0` | Standard Fabric installation | +| **Fabric API** | Latest for 1.21.1 | Required for HUD render callbacks | +| **Java Runtime** | `Java 21+` | Default for Minecraft 1.20.5+ | +| **ModMenu** | `11.0.2+` *(Optional)* | Enables in-game configuration UI from the mods list | +| **Spotify Account** | Free or Premium | *Note: Playback controls (play/pause/skip) require Spotify Premium per Spotify API terms; Free accounts can display track metadata and progress.* | + +--- + +## Player Installation + +1. Install **Fabric Loader** for Minecraft 1.21.1 from [fabricmc.net](https://fabricmc.net/). +2. Download the latest **Fabric API** from Modrinth or CurseForge. +3. Download `spotify-mc-1.2.1.jar` from the **Releases** tab of this repository. +4. Place both `.jar` files into your `.minecraft/mods/` folder: + - **Windows**: `%appdata%\.minecraft\mods\` + - **macOS**: `~/Library/Application Support/minecraft/mods/` + - **Linux**: `~/.minecraft/mods/` +5. Launch Minecraft using your Fabric profile. + +--- + +## Spotify Developer App Setup + +To connect the mod to your Spotify account, you need a free **Client ID** from Spotify: + +1. Navigate to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and log in. +2. Click **Create an App**. +3. Fill in the required fields: + - **App Name**: `Spotify MC` (or any label you prefer) + - **App Description**: `Minecraft client HUD integration` + - **Redirect URIs**: `http://127.0.0.1:8888/callback` *(**CRITICAL**: Must match character-for-character, no trailing slash!)* + - **Which API/SDKs are you planning to use?**: Select **Web API**. +4. Check the Developer Terms of Service checkbox and click **Save**. +5. Click **Settings** in the top right of your newly created application dashboard. +6. Copy your **Client ID** (a 32-character string). + > **Note on Client Secret**: This mod uses **OAuth 2.0 PKCE**. You do **NOT** need a client secret. Never share or paste your client secret anywhere. + +--- + +## Connecting In-Game + +1. Start Minecraft and enter any single-player world or multiplayer server. +2. Open the Spotify MC Configuration screen: + - Via **ModMenu**: Go to **Mods** -> **Spotify MC** -> Click the **Settings (gear)** icon. + - Or via chat: Type `/spotify connect` and press Enter. +3. Paste your **Client ID** into the Spotify Client ID box. +4. Click **Connect Spotify**. +5. Your default web browser will open Spotify's official authorization page. +6. Click **Agree** to authorize access. +7. The browser tab will display: + ``` + ✓ Successfully Connected! + You can return to Minecraft now. + ``` +8. Return to Minecraft — your HUD will initialize and display your currently playing song! + +--- + +## Controls & Commands + +### In-Game Keybinds +By default, keybinds are left **Unassigned** so they do not conflict with your existing gameplay controls. + +To assign them: +1. Open **Options** -> **Controls** -> **Key Binds**. +2. Scroll to the **Spotify MC** section. +3. Bind keys for: + - `Play / Pause` + - `Next Track` + - `Previous Track` + - `Toggle Spotify HUD` + - `Volume Up (+5%)` + - `Volume Down (-5%)` + +### Chat Commands +All commands run entirely on the client: + +| Command | Description | +| :--- | :--- | +| `/spotify status` | Prints current track title, artist, progress, and active device to chat | +| `/spotify play` | Resumes track playback | +| `/spotify pause` | Pauses track playback | +| `/spotify next` | Skips to the next track in queue | +| `/spotify previous` | Returns to the previous track | +| `/spotify hud` | Toggles the HUD overlay visibility | +| `/spotify connect` | Opens the configuration & OAuth login screen | +| `/spotify logout` | Disconnects your account and clears encrypted token storage | + +--- + +## Configuration + +Configuration is saved in `.minecraft/config/spotifymc/config.json`. You can modify it directly or through the in-game GUI: + +```json +{ + "clientId": "YOUR_SPOTIFY_CLIENT_ID", + "hudEnabled": true, + "showAlbumArt": true, + "autoHideWhenInactive": false, + "anchor": "TOP_LEFT", + "offsetX": 10, + "offsetY": 10, + "hudScale": 1.0, + "hudOpacity": 0.85, + "pollingIntervalSec": 2, + "accentColor": -14829228 +} +``` + +### Config Options: +- **`hudEnabled`**: Master toggle for HUD rendering. +- **`showAlbumArt`**: When enabled, downloads and displays 48x48 album artwork. +- **`autoHideWhenInactive`**: Automatically hides the overlay when playback is stopped or no player is active. +- **`anchor`**: `TOP_LEFT`, `TOP_RIGHT`, `BOTTOM_LEFT`, or `BOTTOM_RIGHT`. +- **`offsetX` / `offsetY`**: Pixel margin from the chosen anchor corner. +- **`hudScale`**: HUD sizing factor (`0.5x` to `2.0x`). +- **`hudOpacity`**: Glassmorphic background opacity (`20%` to `100%`). +- **`pollingIntervalSec`**: Polling frequency in seconds (`1` to `10`). + +--- + +## Technical Architecture & Security + +``` +spotify-mc/ +├── .gitattributes # Line endings & binary rules +├── .gitignore # Excludes credentials, build caches, and jars +├── LICENSE # MIT License +├── README.md # Project documentation +├── build.gradle # Fabric Loom build script +├── gradle.properties # Project and dependency versions +├── settings.gradle # Project settings +├── gradlew & gradlew.bat # Gradle wrapper execution scripts +├── gradle/wrapper/ +│ ├── gradle-wrapper.jar +│ └── gradle-wrapper.properties +└── src/ + ├── main/ + │ ├── resources/ + │ │ ├── fabric.mod.json # Mod metadata & entrypoints + │ │ └── assets/spotifymc/ + │ │ ├── icon.png # 64x64 Mod icon + │ │ └── lang/en_us.json # Localization translations + │ └── java/com/spotifymc/ + │ ├── SpotifyMod.java # Mod entrypoint & client lifecycle + │ ├── auth/ + │ │ ├── PkceUtils.java # RFC 7636 SHA-256 verifier & challenge + │ │ ├── LocalCallbackServer.java # Ephemeral 127.0.0.1:8888 listener + │ │ ├── TokenStorage.java # AES-GCM encrypted local credential store + │ │ └── SpotifyAuthManager.java # Token lifecycle & automatic background refresh + │ ├── api/ + │ │ ├── SpotifyDevice.java # Active device record + │ │ ├── SpotifyPlaybackState.java # Extrapolated playback state + │ │ └── SpotifyApiClient.java # Non-blocking JDK HttpClient & 429 backoff + │ ├── playback/ + │ │ └── SpotifyPlaybackManager.java # Adaptive polling coordinator + │ ├── hud/ + │ │ ├── AlbumArtCache.java # Asynchronous LRU texture loader + │ │ └── SpotifyHudRenderer.java # Glassmorphic HUD renderer + │ ├── keybinds/ + │ │ └── SpotifyKeybindHandler.java # Keybind triggers & repeat filters + │ ├── commands/ + │ │ └── SpotifyCommands.java # Fabric client command registrations + │ └── config/ + │ ├── SpotifyConfig.java # Config serialization & defaults + │ ├── SpotifyConfigScreen.java # Native GUI screen & interactive widgets + │ └── ModMenuIntegration.java # ModMenu API entrypoint + └── test/java/com/spotifymc/auth/ + ├── PkceUtilsTest.java # Validates RFC 7636 Appendix B test vector + └── TokenStorageTest.java # Validates AES-GCM encryption & redaction +``` + +### Security Measures +1. **RFC 7636 PKCE Authentication**: + - The Authorization Code flow with PKCE ensures code interception attacks are impossible without needing an embedded client secret. +2. **AES-GCM Local Credential Encryption**: + - Spotify access and refresh tokens are stored in `credentials.json` encrypted with **AES-GCM (128-bit authentication tag)** using a local hardware-derived key. +3. **Sensitive Information Redaction**: + - All token storage objects, HTTP logs, and debug strings redact authorization tokens as `[REDACTED]`. Tokens are never exposed in crash reports or logs. +4. **Git Protection**: + - The repository's `.gitignore` explicitly prevents accidental commits of credentials, tokens, logs, and development runs. + +--- + +## Building from Source + +### Prerequisites +- **Java 21 JDK** or newer (e.g. [Eclipse Temurin 21](https://adoptium.net/temurin/releases/?version=21)) +- **Git** + +### Clone & Build +```bash +# Clone the repository +git clone https://gitea.jadonsandbox.com/jkortis/spotify-mc.git +cd spotify-mc + +# Run tests +./gradlew test # On Linux / macOS +.\gradlew.bat test # On Windows + +# Launch Minecraft development client +./gradlew runClient # On Linux / macOS +.\gradlew.bat runClient # On Windows + +# Build the distributable mod JAR +./gradlew build # On Linux / macOS +.\gradlew.bat build # On Windows +``` + +The output JAR will be placed in `build/libs/spotify-mc-1.2.1.jar`. + +--- + +## Troubleshooting & FAQ + +### 1. HTTP 403 Forbidden ("Spotify Premium is required for playback controls") +- **Cause**: Spotify API policies restrict playback modification endpoints (`play`, `pause`, `next`, `previous`, `volume`) to accounts with an active **Spotify Premium** subscription. +- **Solution**: Free Spotify accounts can still view song titles, artist names, album art, and progress bar. If you have Premium, verify you are logged into the correct Spotify account in your browser. + +### 2. HTTP 404 / 204 ("No Active Player") +- **Cause**: Spotify has no active playback session on any device. +- **Solution**: Open the Spotify Desktop app, Web player, or mobile app and start playing a track. Once Spotify registers an active player, the mod will instantly lock on. + +### 3. Browser Redirect / Port 8888 Conflict +- **Cause**: Another service is using port 8888, or the redirect URI in the developer dashboard has a typo. +- **Solution**: + - Verify that the redirect URI in your [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) is **exactly** `http://127.0.0.1:8888/callback` (case-sensitive, no spaces, no trailing slash). + - Ensure no other local proxy or web server is holding port 8888. + +### 4. HTTP 429 Too Many Requests ("Rate Limited") +- **Cause**: Spotify Web API temporary rate limit reached. +- **Solution**: Spotify MC features built-in adaptive rate-limiting backoff. It automatically reads the `Retry-After` header and pauses polling until the cooldown expires. + +--- + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..44db578 --- /dev/null +++ b/build.gradle @@ -0,0 +1,69 @@ +plugins { + id 'fabric-loom' version '1.7-SNAPSHOT' + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +repositories { + mavenCentral() + maven { + name = "Fabric" + url = "https://maven.fabricmc.net/" + } + maven { + name = "TerraformersMC" + url = "https://maven.terraformersmc.com/" + } +} + +dependencies { + // Minecraft and Mappings + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + + // Fabric Loader & Fabric API + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" + + // ModMenu (for in-game config button) + modImplementation "com.terraformersmc:modmenu:${project.modmenu_version}" + + // Testing + testImplementation "org.junit.jupiter:junit-jupiter:5.10.2" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" +} + +test { + useJUnitPlatform() +} + +processResources { + inputs.property "version", project.version + + filesMatching("fabric.mod.json") { + expand "version": project.version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 + it.options.encoding = "UTF-8" +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +jar { + from("LICENSE") { + rename { "${it}_${project.base.archivesName.get()}" } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..211988d --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# JVM Configuration & Java 21 +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=true +# Set your Java 21 JDK home in ~/.gradle/gradle.properties or your JAVA_HOME if running Gradle on Java 22+ +# org.gradle.java.home=C:/path/to/jdk-21 + +# Fabric Properties +minecraft_version=1.21.1 +yarn_mappings=1.21.1+build.3 +loader_version=0.16.9 + +# Mod Properties +mod_version=1.2.1 +maven_group=com.spotifymc +archives_base_name=spotify-mc + +# Dependencies +fabric_version=0.105.0+1.21.1 +modmenu_version=11.0.2 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..d95bf61 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 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 +# + +############################################################################## +# +# Gradle 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 Gradle +# +# 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/HEAD/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 +' "$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 + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_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" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +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=-Dfile.encoding=UTF-8 "-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 + +goto fail + +: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 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..cf83597 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'spotify-mc' diff --git a/src/main/java/com/spotifymc/SpotifyMod.java b/src/main/java/com/spotifymc/SpotifyMod.java new file mode 100644 index 0000000..43f7dc0 --- /dev/null +++ b/src/main/java/com/spotifymc/SpotifyMod.java @@ -0,0 +1,99 @@ +package com.spotifymc; + +import com.spotifymc.api.SpotifyApiClient; +import com.spotifymc.auth.SpotifyAuthManager; +import com.spotifymc.commands.SpotifyCommands; +import com.spotifymc.config.SpotifyConfig; +import com.spotifymc.hud.AlbumArtCache; +import com.spotifymc.hud.SpotifyHudRenderer; +import com.spotifymc.keybinds.SpotifyKeybindHandler; +import com.spotifymc.playback.SpotifyPlaybackManager; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Main client mod initializer for Spotify MC. + */ +public class SpotifyMod implements ClientModInitializer { + + public static final String MOD_ID = "spotifymc"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + private static SpotifyMod instance; + + private SpotifyConfig config; + private SpotifyAuthManager authManager; + private SpotifyApiClient apiClient; + private SpotifyPlaybackManager playbackManager; + private AlbumArtCache albumArtCache; + private SpotifyHudRenderer hudRenderer; + private SpotifyKeybindHandler keybindHandler; + + @Override + public void onInitializeClient() { + instance = this; + LOGGER.info("[Spotify MC] Initializing client-side Spotify integration..."); + + // Load configuration + this.config = SpotifyConfig.load(); + + // Core services + this.authManager = new SpotifyAuthManager(this.config); + this.apiClient = new SpotifyApiClient(this.authManager); + this.playbackManager = new SpotifyPlaybackManager(this.config, this.authManager, this.apiClient); + this.albumArtCache = new AlbumArtCache(); + + // UI & Input + this.hudRenderer = new SpotifyHudRenderer(this.config, this.playbackManager, this.albumArtCache); + this.hudRenderer.register(); + + this.keybindHandler = new SpotifyKeybindHandler(this.config, this.playbackManager); + this.keybindHandler.register(); + + // Chat commands + SpotifyCommands.register(this.config, this.authManager, this.playbackManager); + + // Lifecycle cleanup on client shutdown + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> { + LOGGER.info("[Spotify MC] Shutting down Spotify background services..."); + if (this.playbackManager != null) { + this.playbackManager.shutdown(); + } + if (this.albumArtCache != null) { + this.albumArtCache.clear(); + } + }); + + LOGGER.info("[Spotify MC] Successfully initialized."); + } + + public static SpotifyMod getInstance() { + return instance; + } + + public SpotifyConfig getConfig() { + return config; + } + + public SpotifyAuthManager getAuthManager() { + return authManager; + } + + public SpotifyApiClient getApiClient() { + return apiClient; + } + + public SpotifyPlaybackManager getPlaybackManager() { + return playbackManager; + } + + public AlbumArtCache getAlbumArtCache() { + return albumArtCache; + } + + public SpotifyHudRenderer getHudRenderer() { + return hudRenderer; + } +} diff --git a/src/main/java/com/spotifymc/api/SpotifyApiClient.java b/src/main/java/com/spotifymc/api/SpotifyApiClient.java new file mode 100644 index 0000000..89d3b53 --- /dev/null +++ b/src/main/java/com/spotifymc/api/SpotifyApiClient.java @@ -0,0 +1,281 @@ +package com.spotifymc.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.spotifymc.SpotifyMod; +import com.spotifymc.auth.SpotifyAuthManager; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; + +/** + * Asynchronous HTTP client communicating with the Spotify Web API. + * Handles rate limits, token refresh retries, and playback controls. + */ +public class SpotifyApiClient { + + private static final String BASE_URL = "https://api.spotify.com/v1/me/player"; + private final SpotifyAuthManager authManager; + private final HttpClient httpClient; + + // Rate limiting + private volatile long rateLimitUntilMs = 0L; + + public SpotifyApiClient(SpotifyAuthManager authManager) { + this.authManager = authManager; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + } + + /** + * Checks if current requests are throttled due to HTTP 429. + */ + public boolean isRateLimited() { + return System.currentTimeMillis() < rateLimitUntilMs; + } + + /** + * Fetches current playback state asynchronously. + */ + public CompletableFuture getPlaybackState() { + if (!authManager.isAuthenticated()) { + return CompletableFuture.completedFuture(SpotifyPlaybackState.NOT_AUTHENTICATED); + } + + if (isRateLimited()) { + return CompletableFuture.completedFuture(SpotifyPlaybackState.UNAVAILABLE); + } + + return executeWithRetry(token -> HttpRequest.newBuilder() + .uri(URI.create(BASE_URL)) + .header("Authorization", "Bearer " + token) + .GET() + .timeout(Duration.ofSeconds(6)) + .build() + ).thenApply(response -> { + int code = response.statusCode(); + if (code == 204) { + // Playback inactive or no active player + return SpotifyPlaybackState.NO_DEVICE; + } + if (code == 200) { + return parsePlaybackJson(response.body()); + } + if (code == 404) { + return SpotifyPlaybackState.NO_DEVICE; + } + return SpotifyPlaybackState.UNAVAILABLE; + }).exceptionally(err -> { + System.err.println("[SpotifyMC] Failed to get playback state: " + err.getMessage()); + return SpotifyPlaybackState.UNAVAILABLE; + }); + } + + /** + * Resumes playback. + */ + public CompletableFuture play() { + return sendPlaybackCommand("PUT", BASE_URL + "/play"); + } + + /** + * Pauses playback. + */ + public CompletableFuture pause() { + return sendPlaybackCommand("PUT", BASE_URL + "/pause"); + } + + /** + * Skips to next track. + */ + public CompletableFuture next() { + return sendPlaybackCommand("POST", BASE_URL + "/next"); + } + + /** + * Skips to previous track. + */ + public CompletableFuture previous() { + return sendPlaybackCommand("POST", BASE_URL + "/previous"); + } + + /** + * Sets playback volume (0 to 100). + */ + public CompletableFuture setVolume(int volumePercent) { + int clamped = Math.clamp(volumePercent, 0, 100); + return sendPlaybackCommand("PUT", BASE_URL + "/volume?volume_percent=" + clamped); + } + + private CompletableFuture sendPlaybackCommand(String method, String url) { + if (!authManager.isAuthenticated()) { + return CompletableFuture.failedFuture(new IllegalStateException("Not logged into Spotify.")); + } + + if (isRateLimited()) { + return CompletableFuture.failedFuture(new IllegalStateException("Spotify API is currently rate-limited.")); + } + + return executeWithRetry(token -> { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + token) + .timeout(Duration.ofSeconds(6)); + + if ("PUT".equalsIgnoreCase(method)) { + builder.PUT(HttpRequest.BodyPublishers.noBody()); + } else { + builder.POST(HttpRequest.BodyPublishers.noBody()); + } + + return builder.build(); + }).thenAccept(response -> { + int code = response.statusCode(); + if (code == 204 || code == 200) { + // Success + return; + } + if (code == 403) { + throw new RuntimeException("PREMIUM_REQUIRED"); + } + if (code == 404) { + throw new RuntimeException("NO_ACTIVE_DEVICE"); + } + throw new RuntimeException("Spotify API returned error code HTTP " + code); + }); + } + + /** + * Executes an HTTP request with automatic token refresh retry on 401 Unauthorized. + */ + private CompletableFuture> executeWithRetry(java.util.function.Function requestFactory) { + return authManager.getValidAccessToken().thenCompose(token -> { + HttpRequest req = requestFactory.apply(token); + return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()) + .thenCompose(response -> { + // Handle Rate Limiting (429) + if (response.statusCode() == 429) { + int retryAfterSec = response.headers().firstValue("Retry-After") + .map(Integer::parseInt).orElse(5); + rateLimitUntilMs = System.currentTimeMillis() + (retryAfterSec * 1000L); + System.out.println("[SpotifyMC] Rate limited by Spotify. Retrying after " + retryAfterSec + " seconds."); + return CompletableFuture.completedFuture(response); + } + + // Handle Token Expired (401) + if (response.statusCode() == 401) { + return authManager.refreshAccessToken().thenCompose(newToken -> { + HttpRequest retryReq = requestFactory.apply(newToken); + return httpClient.sendAsync(retryReq, HttpResponse.BodyHandlers.ofString()); + }); + } + + return CompletableFuture.completedFuture(response); + }); + }); + } + + private SpotifyPlaybackState parsePlaybackJson(String jsonStr) { + try { + JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject(); + + boolean isPlaying = root.has("is_playing") && root.get("is_playing").getAsBoolean(); + long progressMs = root.has("progress_ms") && !root.get("progress_ms").isJsonNull() + ? root.get("progress_ms").getAsLong() : 0L; + + SpotifyDevice device = null; + if (root.has("device") && !root.get("device").isJsonNull()) { + JsonObject devObj = root.getAsJsonObject("device"); + device = new SpotifyDevice( + devObj.has("id") && !devObj.get("id").isJsonNull() ? devObj.get("id").getAsString() : "", + devObj.has("name") ? devObj.get("name").getAsString() : "Unknown Device", + devObj.has("type") ? devObj.get("type").getAsString() : "Speaker", + devObj.has("is_active") && devObj.get("is_active").getAsBoolean(), + devObj.has("volume_percent") && !devObj.get("volume_percent").isJsonNull() ? devObj.get("volume_percent").getAsInt() : 100 + ); + } + + if (!root.has("item") || root.get("item").isJsonNull()) { + // Item might be null if a podcast or ad is playing + return new SpotifyPlaybackState( + isPlaying ? SpotifyPlaybackState.Status.PLAYING : SpotifyPlaybackState.Status.PAUSED, + "Advertisement / Podcast", + "Spotify", + "", + null, + progressMs, + 0L, + System.currentTimeMillis(), + device + ); + } + + JsonObject item = root.getAsJsonObject("item"); + String trackName = item.has("name") ? item.get("name").getAsString() : "Unknown Track"; + long durationMs = item.has("duration_ms") ? item.get("duration_ms").getAsLong() : 0L; + + // Artists + StringJoiner artistsJoiner = new StringJoiner(", "); + if (item.has("artists") && item.get("artists").isJsonArray()) { + JsonArray artists = item.getAsJsonArray("artists"); + for (JsonElement a : artists) { + if (a.isJsonObject() && a.getAsJsonObject().has("name")) { + artistsJoiner.add(a.getAsJsonObject().get("name").getAsString()); + } + } + } + String artistName = artistsJoiner.length() > 0 ? artistsJoiner.toString() : "Unknown Artist"; + + // Album & Art + String albumName = ""; + String albumArtUrl = null; + if (item.has("album") && !item.get("album").isJsonNull()) { + JsonObject album = item.getAsJsonObject("album"); + if (album.has("name")) { + albumName = album.get("name").getAsString(); + } + if (album.has("images") && album.get("images").isJsonArray()) { + JsonArray images = album.getAsJsonArray("images"); + // Typically index 0 is 640x640, index 1 is 300x300, index 2 is 64x64 + // Pick index 1 (medium) or 0 + if (images.size() > 1 && images.get(1).isJsonObject()) { + albumArtUrl = images.get(1).getAsJsonObject().get("url").getAsString(); + } else if (images.size() > 0 && images.get(0).isJsonObject()) { + albumArtUrl = images.get(0).getAsJsonObject().get("url").getAsString(); + } + } + } else if (item.has("images") && item.get("images").isJsonArray()) { + JsonArray images = item.getAsJsonArray("images"); + if (images.size() > 0 && images.get(0).isJsonObject()) { + albumArtUrl = images.get(0).getAsJsonObject().get("url").getAsString(); + } + } + + SpotifyMod.LOGGER.info("[Spotify MC] Playback: '{}' by '{}' | Art URL: {}", trackName, artistName, albumArtUrl); + + return new SpotifyPlaybackState( + isPlaying ? SpotifyPlaybackState.Status.PLAYING : SpotifyPlaybackState.Status.PAUSED, + trackName, + artistName, + albumName, + albumArtUrl, + progressMs, + durationMs, + System.currentTimeMillis(), + device + ); + + } catch (Exception e) { + System.err.println("[SpotifyMC] Failed to parse Spotify playback response: " + e.getMessage()); + return SpotifyPlaybackState.UNAVAILABLE; + } + } +} diff --git a/src/main/java/com/spotifymc/api/SpotifyDevice.java b/src/main/java/com/spotifymc/api/SpotifyDevice.java new file mode 100644 index 0000000..21c1afb --- /dev/null +++ b/src/main/java/com/spotifymc/api/SpotifyDevice.java @@ -0,0 +1,13 @@ +package com.spotifymc.api; + +/** + * Represents a Spotify player target device. + */ +public record SpotifyDevice( + String id, + String name, + String type, + boolean isActive, + int volumePercent +) { +} diff --git a/src/main/java/com/spotifymc/api/SpotifyPlaybackState.java b/src/main/java/com/spotifymc/api/SpotifyPlaybackState.java new file mode 100644 index 0000000..e8580ba --- /dev/null +++ b/src/main/java/com/spotifymc/api/SpotifyPlaybackState.java @@ -0,0 +1,78 @@ +package com.spotifymc.api; + +/** + * Immutable snapshot representing current Spotify playback state. + */ +public record SpotifyPlaybackState( + Status status, + String trackName, + String artistName, + String albumName, + String albumArtUrl, + long progressMs, + long durationMs, + long timestampMs, + SpotifyDevice device +) { + + public enum Status { + PLAYING, + PAUSED, + NO_ACTIVE_DEVICE, + NOT_AUTHENTICATED, + UNAVAILABLE + } + + public static final SpotifyPlaybackState NOT_AUTHENTICATED = new SpotifyPlaybackState( + Status.NOT_AUTHENTICATED, + "Not Connected", + "Connect in Mod Settings", + "", + null, + 0, + 0, + System.currentTimeMillis(), + null + ); + + public static final SpotifyPlaybackState NO_DEVICE = new SpotifyPlaybackState( + Status.NO_ACTIVE_DEVICE, + "No Active Player", + "Open Spotify on any device", + "", + null, + 0, + 0, + System.currentTimeMillis(), + null + ); + + public static final SpotifyPlaybackState UNAVAILABLE = new SpotifyPlaybackState( + Status.UNAVAILABLE, + "Unavailable", + "Offline or Rate Limited", + "", + null, + 0, + 0, + System.currentTimeMillis(), + null + ); + + /** + * Calculates the estimated current playback position by extrapolating elapsed time + * since the last API poll. Prevents needing to poll every millisecond. + */ + public long getExtrapolatedProgressMs() { + if (status != Status.PLAYING || durationMs <= 0) { + return progressMs; + } + long elapsed = System.currentTimeMillis() - timestampMs; + return Math.min(progressMs + elapsed, durationMs); + } + + public float getProgressPercentage() { + if (durationMs <= 0) return 0f; + return (float) getExtrapolatedProgressMs() / (float) durationMs; + } +} diff --git a/src/main/java/com/spotifymc/auth/LocalCallbackServer.java b/src/main/java/com/spotifymc/auth/LocalCallbackServer.java new file mode 100644 index 0000000..a5aa4d7 --- /dev/null +++ b/src/main/java/com/spotifymc/auth/LocalCallbackServer.java @@ -0,0 +1,261 @@ +package com.spotifymc.auth; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Lightweight HTTP server running on 127.0.0.1 to intercept Spotify's OAuth 2.0 authorization redirect. + */ +public class LocalCallbackServer { + + private static final int PORT = 8888; + private static final String PATH = "/callback"; + private static final int TIMEOUT_SECONDS = 180; // 3 minutes timeout + + private HttpServer server; + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "Spotify-OAuth-Timeout"); + t.setDaemon(true); + return t; + }); + + /** + * Starts the callback listener and returns a CompletableFuture that completes with the authorization code. + * + * @param expectedState The expected CSRF state parameter. + * @return Future completing with authorization code, or exceptionally if canceled/timed out. + */ + public CompletableFuture startListening(String expectedState) { + CompletableFuture future = new CompletableFuture<>(); + + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", PORT), 0); + server.createContext(PATH, new CallbackHandler(expectedState, future)); + server.setExecutor(Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "Spotify-OAuth-Handler"); + t.setDaemon(true); + return t; + })); + server.start(); + + // Schedule timeout cleanup + scheduler.schedule(() -> { + if (!future.isDone()) { + future.completeExceptionally(new RuntimeException("Authentication timed out after " + TIMEOUT_SECONDS + " seconds.")); + stop(); + } + }, TIMEOUT_SECONDS, TimeUnit.SECONDS); + + } catch (IOException e) { + future.completeExceptionally(new RuntimeException("Failed to bind local callback server on port " + PORT + ". Is another instance running?", e)); + } + + return future; + } + + /** + * Stops the local HTTP server and releases port resources. + */ + public synchronized void stop() { + if (server != null) { + try { + server.stop(1); + } catch (Exception ignored) { + } + server = null; + } + scheduler.shutdownNow(); + } + + public static int getPort() { + return PORT; + } + + public static String getRedirectUri() { + return "http://127.0.0.1:" + PORT + PATH; + } + + private class CallbackHandler implements HttpHandler { + private final String expectedState; + private final CompletableFuture future; + + public CallbackHandler(String expectedState, CompletableFuture future) { + this.expectedState = expectedState; + this.future = future; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + + String query = exchange.getRequestURI().getRawQuery(); + Map params = parseQuery(query); + + String error = params.get("error"); + String code = params.get("code"); + String state = params.get("state"); + + String htmlResponse; + int statusCode; + + if (error != null) { + statusCode = 400; + htmlResponse = renderHtml("Authentication Denied", + "Spotify returned error: " + escapeHtml(error) + ".
You can close this tab.", + false); + future.completeExceptionally(new RuntimeException("OAuth authorization failed: " + error)); + } else if (code == null || state == null) { + statusCode = 400; + htmlResponse = renderHtml("Missing Parameters", + "Missing authorization code or state in callback URL.", + false); + future.completeExceptionally(new RuntimeException("Missing code or state")); + } else if (!expectedState.equals(state)) { + statusCode = 400; + htmlResponse = renderHtml("Security Validation Error", + "CSRF State parameter mismatch. Request discarded.", + false); + future.completeExceptionally(new SecurityException("State mismatch")); + } else { + statusCode = 200; + htmlResponse = renderHtml("Successfully Connected!", + "Minecraft has successfully linked with Spotify.
You can close this window and return to your game!", + true); + future.complete(code); + } + + byte[] bytes = htmlResponse.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + + // Cleanly stop server in background after short delay + scheduler.schedule(LocalCallbackServer.this::stop, 1, TimeUnit.SECONDS); + } + + private Map parseQuery(String query) { + Map result = new HashMap<>(); + if (query == null || query.isBlank()) { + return result; + } + String[] pairs = query.split("&"); + for (String pair : pairs) { + int idx = pair.indexOf("="); + if (idx > 0) { + String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8); + String val = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8); + result.put(key, val); + } + } + return result; + } + + private String renderHtml(String title, String message, boolean success) { + String accentColor = success ? "#1DB954" : "#E22134"; + String icon = success ? "✓" : "✗"; + + return "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " " + escapeHtml(title) + " - Spotify MC\n" + + " \n" + + "\n" + + "\n" + + "
\n" + + "
" + icon + "
\n" + + "

" + escapeHtml(title) + "

\n" + + "

" + message + "

\n" + + "
Minecraft Spotify Integration
\n" + + "
\n" + + "\n" + + ""; + } + + private String escapeHtml(String str) { + if (str == null) return ""; + return str.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + } +} diff --git a/src/main/java/com/spotifymc/auth/PkceUtils.java b/src/main/java/com/spotifymc/auth/PkceUtils.java new file mode 100644 index 0000000..6af5858 --- /dev/null +++ b/src/main/java/com/spotifymc/auth/PkceUtils.java @@ -0,0 +1,70 @@ +package com.spotifymc.auth; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Utility class for generating OAuth 2.0 PKCE (Proof Key for Code Exchange) + * verifiers and SHA-256 code challenges per RFC 7636. + */ +public final class PkceUtils { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String UNRESERVED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + + private PkceUtils() { + } + + /** + * Generates a cryptographically secure random code verifier string. + * + * @param length The length of the verifier (between 43 and 128 characters). + * @return The randomly generated code verifier. + */ + public static String generateCodeVerifier(int length) { + if (length < 43 || length > 128) { + throw new IllegalArgumentException("Verifier length must be between 43 and 128 characters"); + } + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + int index = RANDOM.nextInt(UNRESERVED_CHARS.length()); + sb.append(UNRESERVED_CHARS.charAt(index)); + } + return sb.toString(); + } + + /** + * Generates a 64-character code verifier (recommended default length). + */ + public static String generateCodeVerifier() { + return generateCodeVerifier(64); + } + + /** + * Generates a code challenge from the given verifier using SHA-256 and Base64URL encoding (without padding). + * + * @param codeVerifier The PKCE code verifier. + * @return Base64URL-encoded SHA-256 code challenge. + */ + public static String generateCodeChallenge(String codeVerifier) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 message digest algorithm not available", e); + } + } + + /** + * Generates a random state string to prevent Cross-Site Request Forgery (CSRF). + */ + public static String generateState() { + byte[] bytes = new byte[16]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/com/spotifymc/auth/SpotifyAuthManager.java b/src/main/java/com/spotifymc/auth/SpotifyAuthManager.java new file mode 100644 index 0000000..f43b92a --- /dev/null +++ b/src/main/java/com/spotifymc/auth/SpotifyAuthManager.java @@ -0,0 +1,234 @@ +package com.spotifymc.auth; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.spotifymc.config.SpotifyConfig; +import net.minecraft.util.Util; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** + * Manages OAuth 2.0 PKCE authentication with Spotify. + * Handles login initiation, token exchange, auto-refresh, and local storage. + */ +public class SpotifyAuthManager { + + private static final String AUTH_ENDPOINT = "https://accounts.spotify.com/authorize"; + private static final String TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"; + private static final String SCOPES = "user-read-playback-state user-modify-playback-state user-read-currently-playing"; + + private final SpotifyConfig config; + private final TokenStorage tokenStorage; + private final HttpClient httpClient; + private final CopyOnWriteArrayList> authListeners = new CopyOnWriteArrayList<>(); + + private TokenStorage.StoredTokens currentTokens; + private LocalCallbackServer callbackServer; + private boolean isAuthenticating = false; + + public SpotifyAuthManager(SpotifyConfig config) { + this.config = config; + this.tokenStorage = new TokenStorage(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + // Load existing credentials on startup + this.currentTokens = tokenStorage.loadTokens(); + } + + public void addAuthListener(Consumer listener) { + authListeners.add(listener); + } + + private void notifyListeners(boolean isAuthenticated) { + for (Consumer listener : authListeners) { + try { + listener.accept(isAuthenticated); + } catch (Exception ignored) { + } + } + } + + public boolean isAuthenticated() { + return currentTokens != null && currentTokens.accessToken() != null; + } + + public boolean isAuthenticating() { + return isAuthenticating; + } + + /** + * Initiates the PKCE OAuth 2.0 authorization flow by opening the user's browser. + */ + public CompletableFuture startPkceLoginFlow() { + if (isAuthenticating) { + return CompletableFuture.failedFuture(new IllegalStateException("Authentication is already in progress.")); + } + + String clientId = config.getClientId(); + if (clientId == null || clientId.isBlank() || clientId.equals("YOUR_SPOTIFY_CLIENT_ID")) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Please configure your Spotify Client ID first.")); + } + + isAuthenticating = true; + String codeVerifier = PkceUtils.generateCodeVerifier(); + String codeChallenge = PkceUtils.generateCodeChallenge(codeVerifier); + String state = PkceUtils.generateState(); + String redirectUri = LocalCallbackServer.getRedirectUri(); + + // Stop any old server instance + if (callbackServer != null) { + callbackServer.stop(); + } + callbackServer = new LocalCallbackServer(); + + // Build authorization URL + String authUrl = AUTH_ENDPOINT + "?" + + "client_id=" + urlEncode(clientId) + + "&response_type=code" + + "&redirect_uri=" + urlEncode(redirectUri) + + "&code_challenge_method=S256" + + "&code_challenge=" + urlEncode(codeChallenge) + + "&state=" + urlEncode(state) + + "&scope=" + urlEncode(SCOPES); + + // Open user's default browser + try { + Util.getOperatingSystem().open(URI.create(authUrl)); + } catch (Exception e) { + isAuthenticating = false; + return CompletableFuture.failedFuture(new RuntimeException("Failed to open web browser: " + e.getMessage(), e)); + } + + // Wait for callback + return callbackServer.startListening(state) + .thenCompose(code -> exchangeCodeForTokens(code, codeVerifier, redirectUri)) + .whenComplete((res, err) -> { + isAuthenticating = false; + if (callbackServer != null) { + callbackServer.stop(); + callbackServer = null; + } + notifyListeners(isAuthenticated()); + }); + } + + /** + * Exchanges the authorization code received from the redirect for access & refresh tokens. + */ + private CompletableFuture exchangeCodeForTokens(String code, String codeVerifier, String redirectUri) { + String body = "client_id=" + urlEncode(config.getClientId()) + + "&grant_type=authorization_code" + + "&code=" + urlEncode(code) + + "&redirect_uri=" + urlEncode(redirectUri) + + "&code_verifier=" + urlEncode(codeVerifier); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(TOKEN_ENDPOINT)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .timeout(Duration.ofSeconds(15)) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenAccept(response -> { + if (response.statusCode() != 200) { + throw new RuntimeException("Spotify Token Exchange failed (HTTP " + response.statusCode() + "): " + response.body()); + } + + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + String accessToken = json.get("access_token").getAsString(); + String refreshToken = json.has("refresh_token") ? json.get("refresh_token").getAsString() : null; + long expiresIn = json.get("expires_in").getAsLong(); + + tokenStorage.saveTokens(accessToken, refreshToken, expiresIn); + this.currentTokens = tokenStorage.loadTokens(); + }); + } + + /** + * Refreshes the Spotify access token using the stored refresh token. + */ + public CompletableFuture refreshAccessToken() { + if (currentTokens == null || currentTokens.refreshToken() == null) { + return CompletableFuture.failedFuture(new IllegalStateException("No refresh token available. Re-authentication required.")); + } + + String body = "client_id=" + urlEncode(config.getClientId()) + + "&grant_type=refresh_token" + + "&refresh_token=" + urlEncode(currentTokens.refreshToken()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(TOKEN_ENDPOINT)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .timeout(Duration.ofSeconds(15)) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(response -> { + if (response.statusCode() != 200) { + // If refresh token revoked, clear credentials + if (response.statusCode() == 400 || response.statusCode() == 401) { + logout(); + } + throw new RuntimeException("Token refresh failed (HTTP " + response.statusCode() + ")"); + } + + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + String newAccessToken = json.get("access_token").getAsString(); + String newRefreshToken = json.has("refresh_token") && !json.get("refresh_token").isJsonNull() + ? json.get("refresh_token").getAsString() + : currentTokens.refreshToken(); + long expiresIn = json.get("expires_in").getAsLong(); + + tokenStorage.saveTokens(newAccessToken, newRefreshToken, expiresIn); + this.currentTokens = tokenStorage.loadTokens(); + return newAccessToken; + }); + } + + /** + * Gets a guaranteed valid access token, proactively refreshing it if it is near expiry. + */ + public CompletableFuture getValidAccessToken() { + if (currentTokens == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Not logged in to Spotify.")); + } + + if (currentTokens.isExpired()) { + return refreshAccessToken(); + } + + return CompletableFuture.completedFuture(currentTokens.accessToken()); + } + + /** + * Logs out the user and securely wipes saved credentials. + */ + public void logout() { + this.currentTokens = null; + tokenStorage.clear(); + if (callbackServer != null) { + callbackServer.stop(); + callbackServer = null; + } + isAuthenticating = false; + notifyListeners(false); + } + + private static String urlEncode(String value) { + if (value == null) return ""; + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/spotifymc/auth/TokenStorage.java b/src/main/java/com/spotifymc/auth/TokenStorage.java new file mode 100644 index 0000000..e9bd5ff --- /dev/null +++ b/src/main/java/com/spotifymc/auth/TokenStorage.java @@ -0,0 +1,202 @@ +package com.spotifymc.auth; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.fabricmc.loader.api.FabricLoader; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Manages secure local storage of OAuth tokens using AES-GCM encryption. + * Protects tokens from being stored in plaintext in the config folder. + */ +public class TokenStorage { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final String ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int GCM_IV_LENGTH = 12; + + private final Path credentialsFilePath; + private final SecretKey encryptionKey; + + public record StoredTokens(String accessToken, String refreshToken, long expiresAtMs) { + public boolean isExpired() { + // Buffer by 60 seconds to refresh proactively before actual expiration + return System.currentTimeMillis() >= (expiresAtMs - 60_000L); + } + + @Override + public String toString() { + return "StoredTokens[accessToken=***REDACTED***, refreshToken=***REDACTED***, expiresAtMs=" + expiresAtMs + "]"; + } + } + + public TokenStorage() { + this(resolveDefaultConfigDir()); + } + + public TokenStorage(Path configDir) { + try { + Files.createDirectories(configDir); + } catch (Exception ignored) { + } + this.credentialsFilePath = configDir.resolve("credentials.json"); + this.encryptionKey = deriveMachineKey(); + } + + private static Path resolveDefaultConfigDir() { + try { + if (FabricLoader.getInstance() != null && FabricLoader.getInstance().getConfigDir() != null) { + return FabricLoader.getInstance().getConfigDir().resolve("spotifymc"); + } + } catch (Throwable ignored) { + } + return Path.of(System.getProperty("java.io.tmpdir"), "spotifymc-test"); + } + + /** + * Derives a machine-local AES-256 key based on local environment attributes and salt. + * Prevents credentials.json from being simply copied or viewed in plaintext. + */ + private SecretKey deriveMachineKey() { + try { + String seed = System.getProperty("user.name", "default") + + "-" + System.getProperty("os.name", "unknown") + + "-SpotifyMC-SecretSalt-v1"; + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] keyBytes = sha256.digest(seed.getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(keyBytes, "AES"); + } catch (Exception e) { + throw new RuntimeException("Failed to derive encryption key", e); + } + } + + /** + * Saves tokens encrypted to local disk. + */ + public synchronized void saveTokens(String accessToken, String refreshToken, long expiresInSeconds) { + try { + long expiresAt = System.currentTimeMillis() + (expiresInSeconds * 1000L); + + JsonObject payload = new JsonObject(); + payload.addProperty("accessToken", accessToken); + payload.addProperty("refreshToken", refreshToken); + payload.addProperty("expiresAtMs", expiresAt); + + String plaintext = payload.toString(); + String encrypted = encrypt(plaintext); + + JsonObject fileObj = new JsonObject(); + fileObj.addProperty("version", 1); + fileObj.addProperty("encryptedData", encrypted); + + File file = credentialsFilePath.toFile(); + try (FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8)) { + GSON.toJson(fileObj, writer); + } + } catch (Exception e) { + System.err.println("[SpotifyMC] Failed to save encrypted tokens: " + e.getMessage()); + } + } + + /** + * Reads and decrypts stored tokens from local disk. + * + * @return StoredTokens if present and valid, or null. + */ + public synchronized StoredTokens loadTokens() { + File file = credentialsFilePath.toFile(); + if (!file.exists() || file.length() == 0) { + return null; + } + + try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { + JsonObject fileObj = JsonParser.parseReader(reader).getAsJsonObject(); + if (!fileObj.has("encryptedData")) { + return null; + } + + String encrypted = fileObj.get("encryptedData").getAsString(); + String decrypted = decrypt(encrypted); + + JsonObject payload = JsonParser.parseString(decrypted).getAsJsonObject(); + String accessToken = payload.get("accessToken").getAsString(); + String refreshToken = payload.has("refreshToken") && !payload.get("refreshToken").isJsonNull() + ? payload.get("refreshToken").getAsString() + : null; + long expiresAtMs = payload.get("expiresAtMs").getAsLong(); + + return new StoredTokens(accessToken, refreshToken, expiresAtMs); + } catch (Exception e) { + System.err.println("[SpotifyMC] Failed to read or decrypt stored tokens: " + e.getMessage()); + return null; + } + } + + /** + * Clears and deletes the stored credentials. + */ + public synchronized void clear() { + try { + Files.deleteIfExists(credentialsFilePath); + } catch (Exception ignored) { + } + } + + public boolean hasTokens() { + return Files.exists(credentialsFilePath); + } + + private String encrypt(String plaintext) throws Exception { + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, spec); + + byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + byte[] combined = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); + + return Base64.getEncoder().encodeToString(combined); + } + + private String decrypt(String encryptedBase64) throws Exception { + byte[] combined = Base64.getDecoder().decode(encryptedBase64); + if (combined.length < GCM_IV_LENGTH) { + throw new IllegalArgumentException("Corrupted encrypted data payload"); + } + + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + + int cipherTextLength = combined.length - GCM_IV_LENGTH; + byte[] cipherText = new byte[cipherTextLength]; + System.arraycopy(combined, GCM_IV_LENGTH, cipherText, 0, cipherTextLength); + + Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, encryptionKey, spec); + + byte[] decryptedBytes = cipher.doFinal(cipherText); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/spotifymc/commands/SpotifyCommands.java b/src/main/java/com/spotifymc/commands/SpotifyCommands.java new file mode 100644 index 0000000..91925e1 --- /dev/null +++ b/src/main/java/com/spotifymc/commands/SpotifyCommands.java @@ -0,0 +1,119 @@ +package com.spotifymc.commands; + +import com.spotifymc.api.SpotifyPlaybackState; +import com.spotifymc.auth.SpotifyAuthManager; +import com.spotifymc.config.SpotifyConfig; +import com.spotifymc.hud.SpotifyHudRenderer; +import com.spotifymc.playback.SpotifyPlaybackManager; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.minecraft.text.Text; + +/** + * Registers client-side `/spotify` chat commands. + */ +public class SpotifyCommands { + + public static void register(SpotifyConfig config, SpotifyAuthManager authManager, SpotifyPlaybackManager playbackManager) { + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { + dispatcher.register(ClientCommandManager.literal("spotify") + // /spotify status + .then(ClientCommandManager.literal("status") + .executes(ctx -> { + SpotifyPlaybackState state = playbackManager.getCurrentState(); + if (state.status() == SpotifyPlaybackState.Status.NOT_AUTHENTICATED) { + ctx.getSource().sendFeedback(Text.literal("§e[Spotify MC] Not connected to Spotify. Run /spotify connect or check config screen.")); + return 1; + } + if (state.status() == SpotifyPlaybackState.Status.NO_ACTIVE_DEVICE) { + ctx.getSource().sendFeedback(Text.literal("§6[Spotify MC] No active Spotify player found.")); + return 1; + } + + String playIcon = state.status() == SpotifyPlaybackState.Status.PLAYING ? "▶" : "⏸"; + String device = state.device() != null ? " (" + state.device().name() + ")" : ""; + String progress = SpotifyHudRenderer.formatDuration(state.getExtrapolatedProgressMs()) + + " / " + SpotifyHudRenderer.formatDuration(state.durationMs()); + + ctx.getSource().sendFeedback(Text.literal(String.format( + "§a[Spotify MC] %s §f%s §7by §f%s §7[%s]%s", + playIcon, state.trackName(), state.artistName(), progress, device + ))); + return 1; + }) + ) + // /spotify play + .then(ClientCommandManager.literal("play") + .executes(ctx -> { + if (playbackManager.getCurrentState().status() == SpotifyPlaybackState.Status.PLAYING) { + ctx.getSource().sendFeedback(Text.literal("§7[Spotify MC] Already playing.")); + } else { + playbackManager.togglePlayPause(); + ctx.getSource().sendFeedback(Text.literal("§a[Spotify MC] Resuming playback...")); + } + return 1; + }) + ) + // /spotify pause + .then(ClientCommandManager.literal("pause") + .executes(ctx -> { + if (playbackManager.getCurrentState().status() == SpotifyPlaybackState.Status.PAUSED) { + ctx.getSource().sendFeedback(Text.literal("§7[Spotify MC] Already paused.")); + } else { + playbackManager.togglePlayPause(); + ctx.getSource().sendFeedback(Text.literal("§e[Spotify MC] Pausing playback...")); + } + return 1; + }) + ) + // /spotify next + .then(ClientCommandManager.literal("next") + .executes(ctx -> { + playbackManager.nextTrack(); + ctx.getSource().sendFeedback(Text.literal("§a[Spotify MC] Skipping to next track...")); + return 1; + }) + ) + // /spotify previous + .then(ClientCommandManager.literal("previous") + .executes(ctx -> { + playbackManager.previousTrack(); + ctx.getSource().sendFeedback(Text.literal("§a[Spotify MC] Skipping to previous track...")); + return 1; + }) + ) + // /spotify hud + .then(ClientCommandManager.literal("hud") + .executes(ctx -> { + boolean newState = !config.isHudEnabled(); + config.setHudEnabled(newState); + config.save(); + ctx.getSource().sendFeedback(Text.literal( + newState ? "§a[Spotify MC] HUD enabled." : "§e[Spotify MC] HUD disabled." + )); + return 1; + }) + ) + // /spotify connect + .then(ClientCommandManager.literal("connect") + .executes(ctx -> { + ctx.getSource().sendFeedback(Text.literal("§a[Spotify MC] Opening browser for Spotify authorization...")); + authManager.startPkceLoginFlow().exceptionally(err -> { + ctx.getSource().sendFeedback(Text.literal("§c[Spotify MC] Login failed: " + err.getMessage())); + return null; + }); + return 1; + }) + ) + // /spotify logout + .then(ClientCommandManager.literal("logout") + .executes(ctx -> { + authManager.logout(); + ctx.getSource().sendFeedback(Text.literal("§e[Spotify MC] Successfully logged out and deleted credentials.")); + return 1; + }) + ) + ); + }); + } +} diff --git a/src/main/java/com/spotifymc/config/ModMenuIntegration.java b/src/main/java/com/spotifymc/config/ModMenuIntegration.java new file mode 100644 index 0000000..6f871bc --- /dev/null +++ b/src/main/java/com/spotifymc/config/ModMenuIntegration.java @@ -0,0 +1,21 @@ +package com.spotifymc.config; + +import com.spotifymc.SpotifyMod; +import com.terraformersmc.modmenu.api.ConfigScreenFactory; +import com.terraformersmc.modmenu.api.ModMenuApi; + +/** + * ModMenu integration to provide an in-game settings screen button. + */ +public class ModMenuIntegration implements ModMenuApi { + + @Override + public ConfigScreenFactory getModConfigScreenFactory() { + return parent -> new SpotifyConfigScreen( + parent, + SpotifyMod.getInstance().getConfig(), + SpotifyMod.getInstance().getAuthManager(), + SpotifyMod.getInstance().getPlaybackManager() + ); + } +} diff --git a/src/main/java/com/spotifymc/config/SpotifyConfig.java b/src/main/java/com/spotifymc/config/SpotifyConfig.java new file mode 100644 index 0000000..153cb2d --- /dev/null +++ b/src/main/java/com/spotifymc/config/SpotifyConfig.java @@ -0,0 +1,174 @@ +package com.spotifymc.config; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.fabricmc.loader.api.FabricLoader; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Stores all user configuration options for Spotify MC. + */ +public class SpotifyConfig { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public enum Anchor { + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT + } + + private String clientId = "YOUR_SPOTIFY_CLIENT_ID"; + private boolean hudEnabled = true; + private boolean showAlbumArt = true; + private boolean autoHideWhenInactive = false; + private Anchor anchor = Anchor.TOP_LEFT; + private int offsetX = 10; + private int offsetY = 10; + private float hudScale = 1.0f; + private float hudOpacity = 0.85f; + private int pollingIntervalSec = 2; + private int accentColor = 0xFF1DB954; // Spotify Green + + private transient Path configFile; + + public SpotifyConfig() { + Path configDir; + try { + if (FabricLoader.getInstance() != null && FabricLoader.getInstance().getConfigDir() != null) { + configDir = FabricLoader.getInstance().getConfigDir().resolve("spotifymc"); + } else { + configDir = Path.of(System.getProperty("java.io.tmpdir"), "spotifymc-test"); + } + } catch (Throwable t) { + configDir = Path.of(System.getProperty("java.io.tmpdir"), "spotifymc-test"); + } + try { + Files.createDirectories(configDir); + } catch (Exception ignored) { + } + this.configFile = configDir.resolve("config.json"); + } + + public static SpotifyConfig load() { + SpotifyConfig config = new SpotifyConfig(); + File file = config.configFile.toFile(); + if (file.exists()) { + try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { + SpotifyConfig loaded = GSON.fromJson(reader, SpotifyConfig.class); + if (loaded != null) { + loaded.configFile = config.configFile; + return loaded; + } + } catch (Exception e) { + System.err.println("[SpotifyMC] Failed to load config.json: " + e.getMessage()); + } + } + config.save(); + return config; + } + + public synchronized void save() { + try (FileWriter writer = new FileWriter(configFile.toFile(), StandardCharsets.UTF_8)) { + GSON.toJson(this, writer); + } catch (Exception e) { + System.err.println("[SpotifyMC] Failed to save config.json: " + e.getMessage()); + } + } + + // Getters and Setters + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId != null ? clientId.trim() : ""; + } + + public boolean isHudEnabled() { + return hudEnabled; + } + + public void setHudEnabled(boolean hudEnabled) { + this.hudEnabled = hudEnabled; + } + + public boolean isShowAlbumArt() { + return showAlbumArt; + } + + public void setShowAlbumArt(boolean showAlbumArt) { + this.showAlbumArt = showAlbumArt; + } + + public boolean isAutoHideWhenInactive() { + return autoHideWhenInactive; + } + + public void setAutoHideWhenInactive(boolean autoHideWhenInactive) { + this.autoHideWhenInactive = autoHideWhenInactive; + } + + public Anchor getAnchor() { + return anchor; + } + + public void setAnchor(Anchor anchor) { + this.anchor = anchor != null ? anchor : Anchor.TOP_LEFT; + } + + public int getOffsetX() { + return offsetX; + } + + public void setOffsetX(int offsetX) { + this.offsetX = offsetX; + } + + public int getOffsetY() { + return offsetY; + } + + public void setOffsetY(int offsetY) { + this.offsetY = offsetY; + } + + public float getHudScale() { + return hudScale; + } + + public void setHudScale(float hudScale) { + this.hudScale = Math.clamp(hudScale, 0.5f, 2.0f); + } + + public float getHudOpacity() { + return hudOpacity; + } + + public void setHudOpacity(float hudOpacity) { + this.hudOpacity = Math.clamp(hudOpacity, 0.2f, 1.0f); + } + + public int getPollingIntervalSec() { + return pollingIntervalSec; + } + + public void setPollingIntervalSec(int pollingIntervalSec) { + this.pollingIntervalSec = Math.clamp(pollingIntervalSec, 1, 10); + } + + public int getAccentColor() { + return accentColor; + } + + public void setAccentColor(int accentColor) { + this.accentColor = accentColor; + } +} diff --git a/src/main/java/com/spotifymc/config/SpotifyConfigScreen.java b/src/main/java/com/spotifymc/config/SpotifyConfigScreen.java new file mode 100644 index 0000000..af93b5a --- /dev/null +++ b/src/main/java/com/spotifymc/config/SpotifyConfigScreen.java @@ -0,0 +1,254 @@ +package com.spotifymc.config; + +import com.spotifymc.auth.SpotifyAuthManager; +import com.spotifymc.playback.SpotifyPlaybackManager; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.SliderWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +/** + * Clean in-game configuration screen for managing Spotify authentication and HUD options. + */ +public class SpotifyConfigScreen extends Screen { + + private final Screen parent; + private final SpotifyConfig config; + private final SpotifyAuthManager authManager; + private final SpotifyPlaybackManager playbackManager; + + private TextFieldWidget clientIdField; + private ButtonWidget connectButton; + + public SpotifyConfigScreen(Screen parent, SpotifyConfig config, SpotifyAuthManager authManager, SpotifyPlaybackManager playbackManager) { + super(Text.translatable("spotifymc.config.title")); + this.parent = parent; + this.config = config; + this.authManager = authManager; + this.playbackManager = playbackManager; + } + + @Override + protected void init() { + int centerX = this.width / 2; + int startY = 36; + int colWidth = 150; + int rowHeight = 24; + + // 1. Client ID input + this.clientIdField = new TextFieldWidget(this.textRenderer, centerX - 150, startY + 14, 300, 20, Text.translatable("spotifymc.config.client_id")); + this.clientIdField.setMaxLength(64); + this.clientIdField.setText(config.getClientId()); + this.clientIdField.setChangedListener(text -> { + config.setClientId(text); + config.save(); + }); + this.addDrawableChild(this.clientIdField); + + // 2. Auth Action Button (Connect / Disconnect) + int authBtnY = startY + 40; + this.connectButton = ButtonWidget.builder( + Text.literal(authManager.isAuthenticated() ? "Disconnect" : "Connect Spotify"), + btn -> { + if (authManager.isAuthenticated()) { + authManager.logout(); + updateAuthButton(); + } else { + config.setClientId(clientIdField.getText()); + config.save(); + btn.active = false; + btn.setMessage(Text.literal("Opening browser...")); + authManager.startPkceLoginFlow().whenComplete((res, err) -> { + if (this.client != null) { + this.client.execute(this::updateAuthButton); + } + }); + } + } + ).dimensions(centerX - 100, authBtnY, 200, 20).build(); + this.addDrawableChild(this.connectButton); + + // 3. Left Column: Toggles + int optionsY = authBtnY + 38; + + // HUD Enabled toggle + this.addDrawableChild(ButtonWidget.builder( + getHudEnabledText(), + btn -> { + config.setHudEnabled(!config.isHudEnabled()); + config.save(); + btn.setMessage(getHudEnabledText()); + } + ).dimensions(centerX - colWidth - 5, optionsY, colWidth, 20).build()); + + // Show Album Art toggle + this.addDrawableChild(ButtonWidget.builder( + getAlbumArtText(), + btn -> { + config.setShowAlbumArt(!config.isShowAlbumArt()); + config.save(); + btn.setMessage(getAlbumArtText()); + } + ).dimensions(centerX + 5, optionsY, colWidth, 20).build()); + + // Auto Hide toggle + this.addDrawableChild(ButtonWidget.builder( + getAutoHideText(), + btn -> { + config.setAutoHideWhenInactive(!config.isAutoHideWhenInactive()); + config.save(); + btn.setMessage(getAutoHideText()); + } + ).dimensions(centerX - colWidth - 5, optionsY + rowHeight, colWidth, 20).build()); + + // Anchor Cycle button + this.addDrawableChild(ButtonWidget.builder( + getAnchorText(), + btn -> { + SpotifyConfig.Anchor[] anchors = SpotifyConfig.Anchor.values(); + int nextIdx = (config.getAnchor().ordinal() + 1) % anchors.length; + config.setAnchor(anchors[nextIdx]); + config.save(); + btn.setMessage(getAnchorText()); + } + ).dimensions(centerX + 5, optionsY + rowHeight, colWidth, 20).build()); + + // 4. Sliders (Scale, Opacity, Polling Rate) + int slidersY = optionsY + (rowHeight * 2); + + // Scale Slider (0.5x to 2.0x) + double initialScaleProgress = (config.getHudScale() - 0.5f) / 1.5f; + this.addDrawableChild(new SliderWidget(centerX - colWidth - 5, slidersY, colWidth, 20, getScaleText(), initialScaleProgress) { + @Override + protected void updateMessage() { + this.setMessage(getScaleText()); + } + + @Override + protected void applyValue() { + float val = 0.5f + (float) this.value * 1.5f; + config.setHudScale(Math.round(val * 10.0f) / 10.0f); + config.save(); + } + }); + + // Opacity Slider (20% to 100%) + double initialOpacityProgress = (config.getHudOpacity() - 0.2f) / 0.8f; + this.addDrawableChild(new SliderWidget(centerX + 5, slidersY, colWidth, 20, getOpacityText(), initialOpacityProgress) { + @Override + protected void updateMessage() { + this.setMessage(getOpacityText()); + } + + @Override + protected void applyValue() { + float val = 0.2f + (float) this.value * 0.8f; + config.setHudOpacity(Math.round(val * 100.0f) / 100.0f); + config.save(); + } + }); + + // Polling Rate Slider (1s to 10s) + double initialPollProgress = (config.getPollingIntervalSec() - 1.0) / 9.0; + this.addDrawableChild(new SliderWidget(centerX - 100, slidersY + rowHeight, 200, 20, getPollRateText(), initialPollProgress) { + @Override + protected void updateMessage() { + this.setMessage(getPollRateText()); + } + + @Override + protected void applyValue() { + int sec = 1 + (int) Math.round(this.value * 9.0); + config.setPollingIntervalSec(sec); + config.save(); + } + }); + + // Done button + this.addDrawableChild(ButtonWidget.builder( + Text.translatable("spotifymc.config.done"), + btn -> this.close() + ).dimensions(centerX - 100, this.height - 28, 200, 20).build()); + + updateAuthButton(); + } + + private void updateAuthButton() { + if (connectButton == null) return; + connectButton.active = true; + if (authManager.isAuthenticated()) { + connectButton.setMessage(Text.translatable("spotifymc.config.btn_disconnect")); + } else if (authManager.isAuthenticating()) { + connectButton.setMessage(Text.translatable("spotifymc.config.auth_status.waiting")); + connectButton.active = false; + } else { + connectButton.setMessage(Text.translatable("spotifymc.config.btn_connect")); + } + } + + private Text getHudEnabledText() { + return Text.literal("HUD Overlay: " + (config.isHudEnabled() ? "§aON" : "§cOFF")); + } + + private Text getAlbumArtText() { + return Text.literal("Album Art: " + (config.isShowAlbumArt() ? "§aON" : "§cOFF")); + } + + private Text getAutoHideText() { + return Text.literal("Auto-Hide Paused: " + (config.isAutoHideWhenInactive() ? "§aON" : "§cOFF")); + } + + private Text getAnchorText() { + return Text.literal("Anchor: §e" + config.getAnchor().name()); + } + + private Text getScaleText() { + return Text.literal(String.format("HUD Scale: %.1fx", config.getHudScale())); + } + + private Text getOpacityText() { + return Text.literal(String.format("Opacity: %d%%", Math.round(config.getHudOpacity() * 100.0f))); + } + + private Text getPollRateText() { + return Text.literal("Update Interval: " + config.getPollingIntervalSec() + "s"); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + int centerX = this.width / 2; + + // Title + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 12, 0xFFFFFF); + + // Client ID label & hint + context.drawTextWithShadow(this.textRenderer, "Spotify Developer Client ID:", centerX - 150, 36, 0xAAAAAA); + + // Status badge + String statusText; + int statusColor; + if (authManager.isAuthenticated()) { + statusText = "● Connected"; + statusColor = 0x1DB954; + } else if (authManager.isAuthenticating()) { + statusText = "○ Waiting for authorization..."; + statusColor = 0xFFD700; + } else { + statusText = "○ Not Connected"; + statusColor = 0xFF5555; + } + context.drawCenteredTextWithShadow(this.textRenderer, statusText, centerX, 66, statusColor); + } + + @Override + public void close() { + config.save(); + if (this.client != null) { + this.client.setScreen(this.parent); + } + } +} diff --git a/src/main/java/com/spotifymc/hud/AlbumArtCache.java b/src/main/java/com/spotifymc/hud/AlbumArtCache.java new file mode 100644 index 0000000..48d03e2 --- /dev/null +++ b/src/main/java/com/spotifymc/hud/AlbumArtCache.java @@ -0,0 +1,190 @@ +package com.spotifymc.hud; + +import com.spotifymc.SpotifyMod; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.util.Identifier; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Asynchronously downloads and caches album artwork. + * Decodes images using STB and ImageIO fallback, and registers textures in Minecraft's TextureManager. + */ +public class AlbumArtCache { + + private static final int MAX_CACHE_SIZE = 30; + private final HttpClient httpClient; + + // LRU texture cache + private final Map textureCache = Collections.synchronizedMap( + new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() > MAX_CACHE_SIZE) { + destroyTexture(eldest.getValue()); + return true; + } + return false; + } + } + ); + + // Track in-flight and failed downloads to prevent duplicate or looping requests + private final Set pendingDownloads = Collections.synchronizedSet(new HashSet<>()); + private final Set failedUrls = Collections.synchronizedSet(new HashSet<>()); + + public AlbumArtCache() { + this.httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * Retrieves the Identifier for the cached album art texture, or initiates an asynchronous download. + * + * @param url The image URL. + * @return The Identifier if already cached and uploaded to GPU, or null if loading. + */ + public Identifier getOrFetchArtwork(String url) { + if (url == null || url.isBlank() || failedUrls.contains(url)) { + return null; + } + + Identifier cached = textureCache.get(url); + if (cached != null) { + return cached; + } + + if (pendingDownloads.add(url)) { + fetchArtworkAsync(url); + } + + return null; + } + + private void fetchArtworkAsync(String url) { + SpotifyMod.LOGGER.info("[Spotify MC] Downloading album art: {}", url); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") + .header("Accept", "image/webp,image/apng,image/*,*/*;q=0.8") + .timeout(Duration.ofSeconds(12)) + .GET() + .build(); + + httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()) + .thenAccept(response -> { + if (response.statusCode() == 200) { + byte[] imageBytes = response.body(); + SpotifyMod.LOGGER.info("[Spotify MC] Downloaded {} bytes of artwork.", imageBytes.length); + // Schedule texture registration on render thread + MinecraftClient.getInstance().execute(() -> { + try { + registerTextureOnRenderThread(url, imageBytes); + } finally { + pendingDownloads.remove(url); + } + }); + } else { + SpotifyMod.LOGGER.warn("[Spotify MC] Artwork HTTP error status: {}", response.statusCode()); + pendingDownloads.remove(url); + failedUrls.add(url); + } + }) + .exceptionally(err -> { + SpotifyMod.LOGGER.error("[Spotify MC] Failed to download artwork from {}: {}", url, err.getMessage()); + pendingDownloads.remove(url); + failedUrls.add(url); + return null; + }); + } + + private void registerTextureOnRenderThread(String url, byte[] imageBytes) { + NativeImage nativeImage = null; + + // 1. Try STB native image decoder + try (InputStream in = new ByteArrayInputStream(imageBytes)) { + nativeImage = NativeImage.read(in); + } catch (Throwable t1) { + SpotifyMod.LOGGER.warn("[Spotify MC] NativeImage.read failed, falling back to Java ImageIO: {}", t1.getMessage()); + } + + // 2. Fallback to Java ImageIO for progressive or non-standard JPEGs + if (nativeImage == null) { + try (InputStream in = new ByteArrayInputStream(imageBytes)) { + BufferedImage bImg = ImageIO.read(in); + if (bImg != null) { + nativeImage = new NativeImage(bImg.getWidth(), bImg.getHeight(), false); + for (int y = 0; y < bImg.getHeight(); y++) { + for (int x = 0; x < bImg.getWidth(); x++) { + int rgb = bImg.getRGB(x, y); + // NativeImage color format: ABGR + nativeImage.setColor(x, y, rgb); + } + } + } + } catch (Throwable t2) { + SpotifyMod.LOGGER.error("[Spotify MC] ImageIO also failed to decode image: {}", t2.getMessage()); + } + } + + if (nativeImage == null) { + SpotifyMod.LOGGER.error("[Spotify MC] Could not decode artwork image format."); + failedUrls.add(url); + return; + } + + try { + NativeImageBackedTexture texture = new NativeImageBackedTexture(nativeImage); + texture.upload(); + + String safeKey = "spotify_art_" + Math.abs(url.hashCode()); + Identifier identifier = MinecraftClient.getInstance().getTextureManager().registerDynamicTexture(safeKey, texture); + + textureCache.put(url, identifier); + SpotifyMod.LOGGER.info("[Spotify MC] Registered dynamic texture: {}", identifier); + } catch (Exception e) { + SpotifyMod.LOGGER.error("[Spotify MC] Failed to register dynamic texture on GPU: {}", e.getMessage(), e); + failedUrls.add(url); + } + } + + private void destroyTexture(Identifier identifier) { + if (identifier != null) { + MinecraftClient.getInstance().execute(() -> { + try { + MinecraftClient.getInstance().getTextureManager().destroyTexture(identifier); + } catch (Exception ignored) { + } + }); + } + } + + public void clear() { + synchronized (textureCache) { + for (Identifier id : textureCache.values()) { + destroyTexture(id); + } + textureCache.clear(); + } + pendingDownloads.clear(); + failedUrls.clear(); + } +} diff --git a/src/main/java/com/spotifymc/hud/SpotifyHudRenderer.java b/src/main/java/com/spotifymc/hud/SpotifyHudRenderer.java new file mode 100644 index 0000000..04d4394 --- /dev/null +++ b/src/main/java/com/spotifymc/hud/SpotifyHudRenderer.java @@ -0,0 +1,249 @@ +package com.spotifymc.hud; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.spotifymc.api.SpotifyPlaybackState; +import com.spotifymc.config.SpotifyConfig; +import com.spotifymc.playback.SpotifyPlaybackManager; +import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.ChatScreen; +import net.minecraft.client.render.RenderTickCounter; +import net.minecraft.util.Identifier; + +/** + * Renders the in-game Spotify HUD overlay. + * Features glassmorphism style, live album artwork, smooth progress bar, and stays visible in chat. + */ +public class SpotifyHudRenderer implements HudRenderCallback { + + private static final int HUD_WIDTH = 210; + private static final int HUD_HEIGHT = 50; + private static final int ART_SIZE = 40; + + private final SpotifyConfig config; + private final SpotifyPlaybackManager playbackManager; + private final AlbumArtCache albumArtCache; + + public SpotifyHudRenderer(SpotifyConfig config, SpotifyPlaybackManager playbackManager, AlbumArtCache albumArtCache) { + this.config = config; + this.playbackManager = playbackManager; + this.albumArtCache = albumArtCache; + } + + public void register() { + // Main HUD render hook + HudRenderCallback.EVENT.register(this); + + // Explicit hook to ensure HUD stays visible when ChatScreen is open + ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> { + if (screen instanceof ChatScreen) { + ScreenEvents.afterRender(screen).register((chatScreen, drawContext, mouseX, mouseY, tickDelta) -> { + renderHudOverlay(drawContext); + }); + } + }); + } + + @Override + public void onHudRender(DrawContext drawContext, RenderTickCounter tickCounter) { + MinecraftClient client = MinecraftClient.getInstance(); + + // If in chat, ScreenEvents.afterRender will render it on top of chat. + // If other screens (pause menu, inventory) are open, do not draw unless it's the config screen. + if (client.currentScreen instanceof ChatScreen) { + return; + } + + if (client.currentScreen != null && !isConfigScreen(client)) { + return; + } + + renderHudOverlay(drawContext); + } + + public void renderHudOverlay(DrawContext drawContext) { + if (!config.isHudEnabled()) { + return; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.options.hudHidden) { + return; + } + + SpotifyPlaybackState state = playbackManager.getCurrentState(); + + // Auto-hide when inactive or paused if configured + if (config.isAutoHideWhenInactive() && state.status() != SpotifyPlaybackState.Status.PLAYING) { + return; + } + + // If not authenticated and not config screen + if (state.status() == SpotifyPlaybackState.Status.NOT_AUTHENTICATED && !isConfigScreen(client)) { + return; + } + + int screenWidth = client.getWindow().getScaledWidth(); + int screenHeight = client.getWindow().getScaledHeight(); + + float scale = config.getHudScale(); + int originX = computeOriginX(screenWidth, scale); + int originY = computeOriginY(screenHeight, scale); + + drawContext.getMatrices().push(); + drawContext.getMatrices().scale(scale, scale, 1.0f); + + int renderX = (int) (originX / scale); + int renderY = (int) (originY / scale); + + renderHudCard(drawContext, client.textRenderer, renderX, renderY, state); + + drawContext.getMatrices().pop(); + } + + private boolean isConfigScreen(MinecraftClient client) { + return client.currentScreen != null && client.currentScreen.getClass().getSimpleName().contains("SpotifyConfigScreen"); + } + + private void renderHudCard(DrawContext context, TextRenderer textRenderer, int x, int y, SpotifyPlaybackState state) { + int alpha = (int) (config.getHudOpacity() * 255.0f); + int bgCol = (alpha << 24) | 0x121212; + int accentCol = config.getAccentColor(); + + // Glassmorphism background card + context.fill(x, y, x + HUD_WIDTH, y + HUD_HEIGHT, bgCol); + + // Spotify Green Left Accent Stripe + context.fill(x, y, x + 3, y + HUD_HEIGHT, accentCol); + + // Border outlines + context.fill(x + 3, y, x + HUD_WIDTH, y + 1, 0x2AFFFFFF); + context.fill(x + 3, y + HUD_HEIGHT - 1, x + HUD_WIDTH, y + HUD_HEIGHT, 0x1AFFFFFF); + context.fill(x + HUD_WIDTH - 1, y, x + HUD_WIDTH, y + HUD_HEIGHT, 0x1AFFFFFF); + + int contentX = x + 6; + int contentY = y + 5; + + // Album Artwork + if (config.isShowAlbumArt()) { + Identifier artId = albumArtCache.getOrFetchArtwork(state.albumArtUrl()); + + // Dark drop-shadow/sleeve border around cover + context.fill(contentX - 1, contentY - 1, contentX + ART_SIZE + 1, contentY + ART_SIZE + 1, 0xFF000000); + + if (artId != null) { + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f); + + // Draw textured quad spanning the full ART_SIZE + context.drawTexture(artId, contentX, contentY, 0.0f, 0.0f, ART_SIZE, ART_SIZE, ART_SIZE, ART_SIZE); + } else { + // Placeholder disc + context.fill(contentX, contentY, contentX + ART_SIZE, contentY + ART_SIZE, 0xFF1C1C1C); + context.drawCenteredTextWithShadow(textRenderer, "♫", contentX + ART_SIZE / 2, contentY + 16, 0xFF1DB954); + } + contentX += ART_SIZE + 6; + } + + int maxTextWidth = (x + HUD_WIDTH - 6) - contentX; + + // Status Line + String statusText; + int statusColor; + switch (state.status()) { + case PLAYING -> { + statusText = "▶ Playing"; + statusColor = accentCol; + } + case PAUSED -> { + statusText = "⏸ Paused"; + statusColor = 0xFFAAAAAA; + } + case NO_ACTIVE_DEVICE -> { + statusText = "⊘ No Player Active"; + statusColor = 0xFFFFB300; + } + case NOT_AUTHENTICATED -> { + statusText = "! Connect Spotify"; + statusColor = 0xFFFF5555; + } + default -> { + statusText = "— Spotify Offline"; + statusColor = 0xFF888888; + } + } + + context.drawTextWithShadow(textRenderer, statusText, contentX, contentY, statusColor); + + // Track Name + String trackName = truncateOrScroll(state.trackName(), textRenderer, maxTextWidth); + context.drawTextWithShadow(textRenderer, trackName, contentX, contentY + 11, 0xFFFFFFFF); + + // Artist & Album Name + String artistAndAlbum = state.artistName(); + if (state.albumName() != null && !state.albumName().isBlank() && !state.albumName().equals(state.trackName())) { + artistAndAlbum += " • " + state.albumName(); + } + String subText = truncateOrScroll(artistAndAlbum, textRenderer, maxTextWidth); + context.drawTextWithShadow(textRenderer, subText, contentX, contentY + 22, 0xFF9E9E9E); + + // Progress Bar & Duration + int barY = y + HUD_HEIGHT - 5; + int barX = x + 6; + int barWidth = HUD_WIDTH - 12; + + if (state.durationMs() > 0) { + float progressRatio = state.getProgressPercentage(); + int filledWidth = (int) (barWidth * progressRatio); + + // Background bar track + context.fill(barX, barY, barX + barWidth, barY + 2, 0x33FFFFFF); + // Filled progress track + context.fill(barX, barY, barX + filledWidth, barY + 2, accentCol); + } + } + + private String truncateOrScroll(String text, TextRenderer textRenderer, int maxWidth) { + if (text == null || text.isBlank()) return ""; + if (textRenderer.getWidth(text) <= maxWidth) { + return text; + } + + long time = System.currentTimeMillis() / 250; + int scrollIndex = (int) (time % (text.length() + 8)); + String extended = text + " " + text; + + if (scrollIndex < extended.length()) { + String candidate = extended.substring(scrollIndex); + return textRenderer.trimToWidth(candidate, maxWidth); + } + + return textRenderer.trimToWidth(text, maxWidth); + } + + public static String formatDuration(long millis) { + long seconds = (millis / 1000) % 60; + long minutes = (millis / (1000 * 60)); + return String.format("%d:%02d", minutes, seconds); + } + + private int computeOriginX(int screenWidth, float scale) { + int effectiveWidth = (int) (HUD_WIDTH * scale); + return switch (config.getAnchor()) { + case TOP_LEFT, BOTTOM_LEFT -> config.getOffsetX(); + case TOP_RIGHT, BOTTOM_RIGHT -> Math.max(0, screenWidth - effectiveWidth - config.getOffsetX()); + }; + } + + private int computeOriginY(int screenHeight, float scale) { + int effectiveHeight = (int) (HUD_HEIGHT * scale); + return switch (config.getAnchor()) { + case TOP_LEFT, TOP_RIGHT -> config.getOffsetY(); + case BOTTOM_LEFT, BOTTOM_RIGHT -> Math.max(0, screenHeight - effectiveHeight - config.getOffsetY()); + }; + } +} diff --git a/src/main/java/com/spotifymc/keybinds/SpotifyKeybindHandler.java b/src/main/java/com/spotifymc/keybinds/SpotifyKeybindHandler.java new file mode 100644 index 0000000..b5d02c1 --- /dev/null +++ b/src/main/java/com/spotifymc/keybinds/SpotifyKeybindHandler.java @@ -0,0 +1,109 @@ +package com.spotifymc.keybinds; + +import com.spotifymc.config.SpotifyConfig; +import com.spotifymc.playback.SpotifyPlaybackManager; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.client.util.InputUtil; +import org.lwjgl.glfw.GLFW; + +/** + * Registers and monitors keybinds for Spotify playback controls and HUD toggle. + */ +public class SpotifyKeybindHandler { + + private static final String CATEGORY = "category.spotifymc.keys"; + + private final SpotifyConfig config; + private final SpotifyPlaybackManager playbackManager; + + private KeyBinding playPauseKey; + private KeyBinding nextTrackKey; + private KeyBinding previousTrackKey; + private KeyBinding toggleHudKey; + private KeyBinding volumeUpKey; + private KeyBinding volumeDownKey; + + public SpotifyKeybindHandler(SpotifyConfig config, SpotifyPlaybackManager playbackManager) { + this.config = config; + this.playbackManager = playbackManager; + } + + public void register() { + playPauseKey = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.spotifymc.play_pause", + InputUtil.Type.KEYSYM, + GLFW.GLFW_KEY_UNKNOWN, + CATEGORY + )); + + nextTrackKey = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.spotifymc.next", + InputUtil.Type.KEYSYM, + GLFW.GLFW_KEY_UNKNOWN, + CATEGORY + )); + + previousTrackKey = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.spotifymc.prev", + InputUtil.Type.KEYSYM, + GLFW.GLFW_KEY_UNKNOWN, + CATEGORY + )); + + toggleHudKey = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.spotifymc.toggle_hud", + InputUtil.Type.KEYSYM, + GLFW.GLFW_KEY_UNKNOWN, + CATEGORY + )); + + volumeUpKey = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.spotifymc.vol_up", + InputUtil.Type.KEYSYM, + GLFW.GLFW_KEY_UNKNOWN, + CATEGORY + )); + + volumeDownKey = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.spotifymc.vol_down", + InputUtil.Type.KEYSYM, + GLFW.GLFW_KEY_UNKNOWN, + CATEGORY + )); + + ClientTickEvents.END_CLIENT_TICK.register(client -> { + if (client.player == null) return; + + while (playPauseKey.wasPressed()) { + playbackManager.togglePlayPause(); + } + + while (nextTrackKey.wasPressed()) { + playbackManager.nextTrack(); + } + + while (previousTrackKey.wasPressed()) { + playbackManager.previousTrack(); + } + + while (volumeUpKey.wasPressed()) { + playbackManager.adjustVolume(5); + } + + while (volumeDownKey.wasPressed()) { + playbackManager.adjustVolume(-5); + } + + while (toggleHudKey.wasPressed()) { + boolean newState = !config.isHudEnabled(); + config.setHudEnabled(newState); + config.save(); + SpotifyPlaybackManager.sendInGameMessage( + newState ? "§a[Spotify MC] HUD enabled" : "§e[Spotify MC] HUD disabled" + ); + } + }); + } +} diff --git a/src/main/java/com/spotifymc/playback/SpotifyPlaybackManager.java b/src/main/java/com/spotifymc/playback/SpotifyPlaybackManager.java new file mode 100644 index 0000000..b70ea13 --- /dev/null +++ b/src/main/java/com/spotifymc/playback/SpotifyPlaybackManager.java @@ -0,0 +1,266 @@ +package com.spotifymc.playback; + +import com.spotifymc.api.SpotifyApiClient; +import com.spotifymc.api.SpotifyDevice; +import com.spotifymc.api.SpotifyPlaybackState; +import com.spotifymc.auth.SpotifyAuthManager; +import com.spotifymc.config.SpotifyConfig; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Coordinates playback state polling, state caching, and player controls. + * Uses dynamic polling backoff when Spotify is paused or idle. + */ +public class SpotifyPlaybackManager { + + private final SpotifyConfig config; + private final SpotifyAuthManager authManager; + private final SpotifyApiClient apiClient; + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "Spotify-Playback-Poller"); + t.setDaemon(true); + return t; + }); + + private volatile SpotifyPlaybackState currentState = SpotifyPlaybackState.NOT_AUTHENTICATED; + private ScheduledFuture currentPollTask; + private boolean isPolling = false; + + public SpotifyPlaybackManager(SpotifyConfig config, SpotifyAuthManager authManager, SpotifyApiClient apiClient) { + this.config = config; + this.authManager = authManager; + this.apiClient = apiClient; + + // Listen for authentication changes + this.authManager.addAuthListener(authenticated -> { + if (authenticated) { + sendInGameMessage("§a[Spotify MC] Connected to Spotify!"); + pollOnce(); + } else { + currentState = SpotifyPlaybackState.NOT_AUTHENTICATED; + } + }); + + startPolling(); + } + + public SpotifyPlaybackState getCurrentState() { + return currentState; + } + + /** + * Starts or restarts the adaptive polling schedule. + */ + public synchronized void startPolling() { + if (isPolling && currentPollTask != null && !currentPollTask.isCancelled()) { + return; + } + isPolling = true; + scheduleNextPoll(1); + } + + private synchronized void scheduleNextPoll(long delaySeconds) { + if (!isPolling) return; + if (currentPollTask != null && !currentPollTask.isDone()) { + currentPollTask.cancel(false); + } + currentPollTask = scheduler.schedule(this::pollAndReschedule, delaySeconds, TimeUnit.SECONDS); + } + + private void pollAndReschedule() { + try { + if (!authManager.isAuthenticated()) { + currentState = SpotifyPlaybackState.NOT_AUTHENTICATED; + scheduleNextPoll(6); + return; + } + + apiClient.getPlaybackState().thenAccept(state -> { + this.currentState = state; + + // Adaptive polling interval based on playback status + long nextDelay; + int baseInterval = config.getPollingIntervalSec(); + + if (state.status() == SpotifyPlaybackState.Status.PLAYING) { + nextDelay = baseInterval; + } else if (state.status() == SpotifyPlaybackState.Status.PAUSED) { + nextDelay = Math.max(baseInterval * 2L, 4L); + } else { + // No active device or unavailable -> back off + nextDelay = 8L; + } + + scheduleNextPoll(nextDelay); + }).exceptionally(err -> { + currentState = SpotifyPlaybackState.UNAVAILABLE; + scheduleNextPoll(8); + return null; + }); + } catch (Exception e) { + scheduleNextPoll(6); + } + } + + /** + * Triggers an immediate state poll (e.g. after a play/pause action). + */ + public void pollOnce() { + scheduler.schedule(() -> { + if (authManager.isAuthenticated()) { + apiClient.getPlaybackState().thenAccept(state -> this.currentState = state); + } + }, 300, TimeUnit.MILLISECONDS); + } + + // Controls + public void togglePlayPause() { + if (!checkAuthenticated()) return; + + if (currentState.status() == SpotifyPlaybackState.Status.PLAYING) { + apiClient.pause().thenRun(() -> { + currentState = new SpotifyPlaybackState( + SpotifyPlaybackState.Status.PAUSED, + currentState.trackName(), + currentState.artistName(), + currentState.albumName(), + currentState.albumArtUrl(), + currentState.getExtrapolatedProgressMs(), + currentState.durationMs(), + System.currentTimeMillis(), + currentState.device() + ); + pollOnce(); + }).exceptionally(this::handleControlError); + } else { + apiClient.play().thenRun(() -> { + currentState = new SpotifyPlaybackState( + SpotifyPlaybackState.Status.PLAYING, + currentState.trackName(), + currentState.artistName(), + currentState.albumName(), + currentState.albumArtUrl(), + currentState.progressMs(), + currentState.durationMs(), + System.currentTimeMillis(), + currentState.device() + ); + pollOnce(); + }).exceptionally(this::handleControlError); + } + } + + public void nextTrack() { + if (!checkAuthenticated()) return; + apiClient.next().thenRun(this::pollOnce).exceptionally(this::handleControlError); + } + + public void previousTrack() { + if (!checkAuthenticated()) return; + apiClient.previous().thenRun(this::pollOnce).exceptionally(this::handleControlError); + } + + // Debounced volume rate limiting + private final Object volumeLock = new Object(); + private volatile int pendingTargetVolume = -1; + private ScheduledFuture volumeDebounceTask; + + public void adjustVolume(int delta) { + if (!checkAuthenticated()) return; + + synchronized (volumeLock) { + int baseVol = pendingTargetVolume >= 0 ? pendingTargetVolume + : (currentState.device() != null ? currentState.device().volumePercent() : 50); + int newVol = Math.clamp(baseVol + delta, 0, 100); + this.pendingTargetVolume = newVol; + + // Update local cached device volume immediately for responsive UI + if (currentState.device() != null) { + SpotifyDevice dev = currentState.device(); + SpotifyDevice updatedDev = new SpotifyDevice(dev.id(), dev.name(), dev.type(), dev.isActive(), newVol); + this.currentState = new SpotifyPlaybackState( + currentState.status(), + currentState.trackName(), + currentState.artistName(), + currentState.albumName(), + currentState.albumArtUrl(), + currentState.getExtrapolatedProgressMs(), + currentState.durationMs(), + System.currentTimeMillis(), + updatedDev + ); + } + + // Show clean action bar volume feedback above hotbar + sendFeedback("§a♫ Volume: " + newVol + "%", true); + + // Debounce outgoing API request (350ms window coalesces rapid key taps into a single request) + if (volumeDebounceTask != null && !volumeDebounceTask.isDone()) { + volumeDebounceTask.cancel(false); + } + + volumeDebounceTask = scheduler.schedule(() -> { + int volumeToSend; + synchronized (volumeLock) { + volumeToSend = pendingTargetVolume; + pendingTargetVolume = -1; + } + if (volumeToSend >= 0) { + apiClient.setVolume(volumeToSend).exceptionally(this::handleControlError); + } + }, 350, TimeUnit.MILLISECONDS); + } + } + + private Void handleControlError(Throwable err) { + String msg = err.getCause() != null ? err.getCause().getMessage() : err.getMessage(); + if ("PREMIUM_REQUIRED".equals(msg)) { + sendInGameMessage("§c[Spotify MC] Spotify Premium is required for playback controls."); + } else if ("NO_ACTIVE_DEVICE".equals(msg)) { + sendInGameMessage("§6[Spotify MC] No active player device. Open Spotify on desktop/mobile first."); + } else { + sendInGameMessage("§c[Spotify MC] Action failed: " + (msg != null ? msg : "Network error")); + } + return null; + } + + private boolean checkAuthenticated() { + if (!authManager.isAuthenticated()) { + sendInGameMessage("§e[Spotify MC] You must connect your Spotify account first. Open Mod Settings."); + return false; + } + return true; + } + + public static void sendInGameMessage(String message) { + sendFeedback(message, false); + } + + public static void sendFeedback(String message, boolean actionBar) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + client.execute(() -> { + if (actionBar && client.player != null) { + client.player.sendMessage(Text.literal(message), true); + } else if (client.inGameHud != null && client.inGameHud.getChatHud() != null) { + client.inGameHud.getChatHud().addMessage(Text.literal(message)); + } + }); + } + } + + public void shutdown() { + isPolling = false; + if (currentPollTask != null) { + currentPollTask.cancel(true); + } + scheduler.shutdownNow(); + } +} diff --git a/src/main/resources/assets/spotifymc/icon.png b/src/main/resources/assets/spotifymc/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8b40247f43e4d8ffcafb4f331c028669910ab0e9 GIT binary patch literal 1431 zcmV;I1!($-P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1uRKKK~#8N?V3Gk z6hRcn<7X00j9<}cayb=25yXHH6cj}ff`S+j41(@9f?y+7vC>K_xkeCd#7etVl14~j zA=tzs-r1$Jw6t8`e=R7i+C}nf@j^YR$Mc*^HN{-+cXNbW*DNcci`&P1Rb_b72T$pe<5Q z1E)p~5vJf6o9?nJv>2-sKcZW5$S?#uPLo^APqv-VLVxXg+#tip$XDNS6YL<1U5OKG zejfi~Ce(4rE&g=WEhd`L90uhk(@l^yRfL$?hj-ML$lT4cI*AI)M}{1DP#Q09pSswodpx$`|P}S^3Vg<*>U|Ss|Rcz-Y$YB*S`$B zm!ST7!D?{|R3Lh9J2t{j7KR-De4Fj31N~GBfzx8U2ngufQ-CWb*SU;=i{pg}!e?EM zDhKooUY#tAfs>A<$*FFVCWorPSs{@c4v*QR?un&wM zsIOY^#)aur3MQWJ5PY38e*ddWq75dEc4eR);sv+6s3*_{F6UAwh`K=K1}GS~PNraT z*l z7T{Q_GC~%_jWqwKaP6{)$X}rtNbUoF4Q)wtHKITFJPX_*tpRj9icjx#zCbm!sX%>~*25qL=^ZNU_~@6>tsicUN#FmL_i$df3P}?os&9Ws0oPr*pAx$io zUOWW|?nb+xO0UHNMC|-ZG$p6(UxYh^ z8WYG(y&n5A2`)R(rpT#vPEaRwJ1(Hesg&n^2u=eg!cDMUAkg9KPREYCPDGZV3E|4g zP1rLHtBXx1MPv%Z2hVi_?f5e3oyP|pIaK(vmNui93|MeH8H@O68|-e|3fu&Nb1isA l^TK5^q|bWB;P9od?=LEvP?c%>%CP_d002ovPDHLkV1gHUoSpyx literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/spotifymc/lang/en_us.json b/src/main/resources/assets/spotifymc/lang/en_us.json new file mode 100644 index 0000000..6234d65 --- /dev/null +++ b/src/main/resources/assets/spotifymc/lang/en_us.json @@ -0,0 +1,39 @@ +{ + "category.spotifymc.keys": "Spotify MC", + "key.spotifymc.play_pause": "Play / Pause", + "key.spotifymc.next": "Next Track", + "key.spotifymc.prev": "Previous Track", + "key.spotifymc.toggle_hud": "Toggle Spotify HUD", + "key.spotifymc.vol_up": "Volume Up (+5%)", + "key.spotifymc.vol_down": "Volume Down (-5%)", + + "spotifymc.config.title": "Spotify MC Configuration", + "spotifymc.config.client_id": "Spotify Client ID", + "spotifymc.config.auth_status": "Status", + "spotifymc.config.auth_status.logged_in": "Connected", + "spotifymc.config.auth_status.not_logged_in": "Not Connected", + "spotifymc.config.auth_status.waiting": "Waiting for browser authorization...", + "spotifymc.config.btn_connect": "Connect Spotify", + "spotifymc.config.btn_disconnect": "Disconnect", + "spotifymc.config.hud_enabled": "Show HUD Overlay: %s", + "spotifymc.config.show_art": "Show Album Artwork: %s", + "spotifymc.config.auto_hide": "Auto-Hide When Paused: %s", + "spotifymc.config.hud_anchor": "HUD Anchor: %s", + "spotifymc.config.scale": "HUD Scale: %s", + "spotifymc.config.opacity": "Background Opacity: %s%%", + "spotifymc.config.poll_rate": "Polling Interval: %ss", + "spotifymc.config.done": "Done", + + "spotifymc.hud.status.playing": "Playing", + "spotifymc.hud.status.paused": "Paused", + "spotifymc.hud.status.no_device": "No Active Device", + "spotifymc.hud.status.not_connected": "Spotify Not Connected", + "spotifymc.hud.status.unavailable": "Spotify Unavailable", + + "spotifymc.msg.auth_success": "§a[Spotify MC] Successfully connected to Spotify!", + "spotifymc.msg.auth_failed": "§c[Spotify MC] Spotify authentication failed: %s", + "spotifymc.msg.logged_out": "§e[Spotify MC] Disconnected from Spotify.", + "spotifymc.msg.premium_required": "§c[Spotify MC] Spotify Premium is required for playback controls.", + "spotifymc.msg.no_active_device": "§6[Spotify MC] No active Spotify player found. Open Spotify and start playing music.", + "spotifymc.msg.error": "§c[Spotify MC] Spotify API error: %s" +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..a076e10 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "id": "spotifymc", + "version": "${version}", + "name": "Spotify MC", + "description": "A lightweight client-side Spotify HUD overlay and playback controller for Minecraft.", + "authors": [ + "jkortis" + ], + "contact": { + "homepage": "https://gitea.jadonsandbox.com/jkortis/spotify-mc", + "sources": "https://gitea.jadonsandbox.com/jkortis/spotify-mc", + "issues": "https://gitea.jadonsandbox.com/jkortis/spotify-mc/issues" + }, + "license": "MIT", + "icon": "assets/spotifymc/icon.png", + "environment": "client", + "entrypoints": { + "client": [ + "com.spotifymc.SpotifyMod" + ], + "modmenu": [ + "com.spotifymc.config.ModMenuIntegration" + ] + }, + "depends": { + "fabricloader": ">=0.16.0", + "minecraft": "~1.21.1", + "java": ">=21", + "fabric-api": "*" + }, + "suggests": { + "modmenu": "*" + } +} diff --git a/src/test/java/com/spotifymc/auth/PkceUtilsTest.java b/src/test/java/com/spotifymc/auth/PkceUtilsTest.java new file mode 100644 index 0000000..883dc99 --- /dev/null +++ b/src/test/java/com/spotifymc/auth/PkceUtilsTest.java @@ -0,0 +1,44 @@ +package com.spotifymc.auth; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class PkceUtilsTest { + + @Test + public void testCodeVerifierLength() { + String verifier = PkceUtils.generateCodeVerifier(64); + assertNotNull(verifier); + assertEquals(64, verifier.length()); + + String defaultVerifier = PkceUtils.generateCodeVerifier(); + assertNotNull(defaultVerifier); + assertEquals(64, defaultVerifier.length()); + } + + @Test + public void testCodeVerifierCharacters() { + String verifier = PkceUtils.generateCodeVerifier(128); + assertTrue(verifier.matches("^[A-Za-z0-9\\-._~]+$"), "Verifier must only contain RFC 7636 unreserved characters"); + } + + @Test + public void testRfc7636AppendixBTestVector() { + // Official RFC 7636 Appendix B test vector + String verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + String expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + + String computedChallenge = PkceUtils.generateCodeChallenge(verifier); + assertEquals(expectedChallenge, computedChallenge, "Code challenge must match RFC 7636 Appendix B standard test vector"); + } + + @Test + public void testStateGeneration() { + String state1 = PkceUtils.generateState(); + String state2 = PkceUtils.generateState(); + assertNotNull(state1); + assertNotNull(state2); + assertNotEquals(state1, state2, "Random state should be unique"); + } +} diff --git a/src/test/java/com/spotifymc/auth/TokenStorageTest.java b/src/test/java/com/spotifymc/auth/TokenStorageTest.java new file mode 100644 index 0000000..91d1a28 --- /dev/null +++ b/src/test/java/com/spotifymc/auth/TokenStorageTest.java @@ -0,0 +1,53 @@ +package com.spotifymc.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class TokenStorageTest { + + private final TokenStorage storage = new TokenStorage(); + + @AfterEach + public void tearDown() { + storage.clear(); + } + + @Test + public void testSaveAndLoadTokens() { + String testAccessToken = "test_access_token_1234567890"; + String testRefreshToken = "test_refresh_token_abcdefghij"; + long expiresIn = 3600; // 1 hour + + storage.saveTokens(testAccessToken, testRefreshToken, expiresIn); + + assertTrue(storage.hasTokens()); + TokenStorage.StoredTokens loaded = storage.loadTokens(); + + assertNotNull(loaded); + assertEquals(testAccessToken, loaded.accessToken()); + assertEquals(testRefreshToken, loaded.refreshToken()); + assertFalse(loaded.isExpired()); + } + + @Test + public void testClearTokens() { + storage.saveTokens("tokenA", "tokenB", 1800); + assertTrue(storage.hasTokens()); + + storage.clear(); + assertFalse(storage.hasTokens()); + assertNull(storage.loadTokens()); + } + + @Test + public void testTokenToStringDoesNotLeakSecrets() { + TokenStorage.StoredTokens tokens = new TokenStorage.StoredTokens("secret_access", "secret_refresh", 100000); + String stringRep = tokens.toString(); + + assertFalse(stringRep.contains("secret_access"), "toString must not expose access token"); + assertFalse(stringRep.contains("secret_refresh"), "toString must not expose refresh token"); + assertTrue(stringRep.contains("REDACTED")); + } +}