Yr Crystals

This commit is contained in:
pszsh 2026-01-24 21:07:09 -08:00
commit 2bc03b0143
21 changed files with 3012 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
build/
build_android/
build_ios/
build_macos/
build_windows/
icons
*.png

216
CMakeLists.txt Normal file
View File

@ -0,0 +1,216 @@
# CMakeLists.txt
cmake_minimum_required(VERSION 3.18)
project(YrCrystals VERSION 1.0 LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
include(FetchContent)
option(BUILD_ANDROID "Build for Android" OFF)
option(BUILD_IOS "Build for iOS" OFF)
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Multimedia OpenGLWidgets)
# --- FFTW3 Configuration ---
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" AND NOT BUILD_IOS)
message(STATUS "Detected Apple Silicon Desktop. Using Homebrew FFTW3F.")
find_library(FFTW3_LIB NAMES fftw3f libfftw3f PATHS /opt/homebrew/lib NO_DEFAULT_PATH)
find_path(FFTW3_INCLUDE_DIR fftw3.h PATHS /opt/homebrew/include NO_DEFAULT_PATH)
if(NOT FFTW3_LIB OR NOT FFTW3_INCLUDE_DIR)
message(FATAL_ERROR "FFTW3F not found in /opt/homebrew. Please run: brew install fftw")
endif()
add_library(fftw3 STATIC IMPORTED)
set_target_properties(fftw3 PROPERTIES
IMPORTED_LOCATION "${FFTW3_LIB}"
INTERFACE_INCLUDE_DIRECTORIES "${FFTW3_INCLUDE_DIR}"
)
else()
message(STATUS "Building FFTW3 from source...")
set(ENABLE_FLOAT ON CACHE BOOL "Build single precision" FORCE)
set(ENABLE_SSE OFF CACHE BOOL "Disable SSE" FORCE)
set(ENABLE_SSE2 OFF CACHE BOOL "Disable SSE2" FORCE)
set(ENABLE_AVX OFF CACHE BOOL "Disable AVX" FORCE)
set(ENABLE_AVX2 OFF CACHE BOOL "Disable AVX2" FORCE)
set(ENABLE_THREADS OFF CACHE BOOL "Disable Threads" FORCE)
set(ENABLE_OPENMP OFF CACHE BOOL "Disable OpenMP" FORCE)
set(ENABLE_MPI OFF CACHE BOOL "Disable MPI" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build Static Libs" FORCE)
set(BUILD_TESTS OFF CACHE BOOL "Disable Tests" FORCE)
if(ANDROID_ABI STREQUAL "arm64-v8a")
message(STATUS "Enabling NEON for Android ARM64")
set(ENABLE_NEON ON CACHE BOOL "Enable NEON" FORCE)
elseif(BUILD_IOS)
set(ENABLE_NEON ON CACHE BOOL "Enable NEON" FORCE)
endif()
set(PATCH_CMD sed -i.bak "s/cmake_minimum_required.*/cmake_minimum_required(VERSION 3.16)/" <SOURCE_DIR>/CMakeLists.txt)
FetchContent_Declare(
fftw3_source
URL https://www.fftw.org/fftw-3.3.10.tar.gz
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
PATCH_COMMAND ${PATCH_CMD}
)
FetchContent_MakeAvailable(fftw3_source)
endif()
# --- Icon Generation ---
set(ICON_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon_source.png")
set(ICON_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_icons.sh")
set(MACOS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.icns")
set(WINDOWS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.ico")
set(IOS_ASSETS_PATH "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/ios/Assets.xcassets")
set(IOS_CONTENTS_JSON "${IOS_ASSETS_PATH}/Contents.json")
# Find ImageMagick 'magick' executable
find_program(MAGICK_EXECUTABLE NAMES magick)
if(NOT MAGICK_EXECUTABLE)
message(WARNING "ImageMagick 'magick' not found. Icons will not be generated.")
endif()
if(EXISTS "${ICON_SOURCE}" AND MAGICK_EXECUTABLE)
execute_process(COMMAND chmod +x "${ICON_SCRIPT}")
add_custom_command(
OUTPUT "${MACOS_ICON}" "${WINDOWS_ICON}" "${IOS_CONTENTS_JSON}"
COMMAND "${ICON_SCRIPT}" "${MAGICK_EXECUTABLE}"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
DEPENDS "${ICON_SOURCE}" "${ICON_SCRIPT}"
COMMENT "Generating icons from source using ${MAGICK_EXECUTABLE}..."
VERBATIM
)
add_custom_target(GenerateIcons DEPENDS "${MACOS_ICON}" "${WINDOWS_ICON}" "${IOS_CONTENTS_JSON}")
endif()
# --- Sources ---
set(PROJECT_SOURCES
src/main.cpp
src/Utils.cpp
src/Processor.cpp
src/AudioEngine.cpp
src/VisualizerWidget.cpp
src/CommonWidgets.cpp
src/PlayerControls.cpp
src/MainWindow.cpp
)
if(EXISTS "${ICON_SOURCE}")
list(APPEND PROJECT_SOURCES ${MACOS_ICON} ${WINDOWS_ICON})
endif()
set(PROJECT_HEADERS
src/Utils.h
src/Processor.h
src/AudioEngine.h
src/VisualizerWidget.h
src/CommonWidgets.h
src/PlayerControls.h
src/MainWindow.h
)
qt_add_executable(YrCrystals MANUAL_FINALIZATION ${PROJECT_SOURCES} ${PROJECT_HEADERS})
if(EXISTS "${ICON_SOURCE}" AND MAGICK_EXECUTABLE)
add_dependencies(YrCrystals GenerateIcons)
endif()
# --- Mobile Definitions ---
if(BUILD_ANDROID OR BUILD_IOS)
target_compile_definitions(YrCrystals PRIVATE IS_MOBILE)
endif()
# --- Linking ---
if(TARGET fftw3f)
set(FFTW_TARGET fftw3f)
target_include_directories(YrCrystals PRIVATE
"${fftw3_source_SOURCE_DIR}/api"
"${fftw3_source_BINARY_DIR}"
)
else()
set(FFTW_TARGET fftw3)
endif()
target_link_libraries(YrCrystals PRIVATE
Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Multimedia Qt6::OpenGLWidgets
${FFTW_TARGET}
)
if(BUILD_ANDROID)
target_link_libraries(YrCrystals PRIVATE log m)
set_property(TARGET YrCrystals PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/android")
endif()
if(BUILD_IOS)
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/ios/Info.plist")
message(FATAL_ERROR "Missing ios/Info.plist. Please create it before building.")
endif()
set_source_files_properties(src/Utils.cpp PROPERTIES LANGUAGE OBJCXX)
set_target_properties(YrCrystals PROPERTIES
MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_BUNDLE_NAME "Yr Crystals"
MACOSX_BUNDLE_GUI_IDENTIFIER "com.yrcrystals.app"
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/ios/Info.plist"
XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon"
)
if(EXISTS "${ICON_SOURCE}")
# Ensure directory exists at configure time so target_sources doesn't complain
file(MAKE_DIRECTORY "${IOS_ASSETS_PATH}")
target_sources(YrCrystals PRIVATE "${IOS_ASSETS_PATH}")
endif()
qt_import_plugins(YrCrystals
EXCLUDE
Qt6::FFmpegMediaPlugin
Qt6::QFFmpegMediaPlugin
)
target_link_libraries(YrCrystals PRIVATE
"-framework AudioToolbox"
"-framework AVFoundation"
"-framework CoreMedia"
"-framework VideoToolbox"
"-framework CoreVideo"
"-framework CoreFoundation"
"-framework UIKit"
"-framework UniformTypeIdentifiers"
"-lz"
"-lbz2"
"-liconv"
)
endif()
if(APPLE AND NOT BUILD_IOS)
set_target_properties(YrCrystals PROPERTIES
MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_BUNDLE_NAME "Yr Crystals"
MACOSX_BUNDLE_GUI_IDENTIFIER "com.yrcrystals.app"
MACOSX_BUNDLE_ICON_FILE "app_icon.icns"
RESOURCE "${MACOS_ICON}"
)
elseif(WIN32)
set_target_properties(YrCrystals PROPERTIES
WIN32_EXECUTABLE TRUE
)
endif()
qt_finalize_executable(YrCrystals)

95
Makefile Normal file
View File

@ -0,0 +1,95 @@
# Makefile
QT_ANDROID_KIT ?= $(HOME)/Qt/6.8.3/android_arm64_v8a
QT_IOS_KIT ?= $(HOME)/Qt/6.8.3/ios
QT_MACOS_PATH ?= /opt/homebrew/opt/qt@6
QT_WIN_PATH ?= C:/Qt/6.8.3/msvc2019_64
BUILD_DIR_MACOS = build_macos
BUILD_DIR_WIN = build_windows
BUILD_DIR_ANDROID = build_android
BUILD_DIR_IOS = build_ios
TARGET = YrCrystals
# Android Specifics
PKG_NAME = org.qtproject.example.YrCrystals
# CRITICAL FIX: Qt6 generates 'android-build-debug.apk' by default
APK_PATH = $(BUILD_DIR_ANDROID)/android-build/build/outputs/apk/debug/android-build-debug.apk
all: macos
desktop: macos
macos:
@echo "Building for macOS (Apple Silicon)..."
@mkdir -p $(BUILD_DIR_MACOS)
@cd $(BUILD_DIR_MACOS) && cmake .. \
-DCMAKE_PREFIX_PATH="$(QT_MACOS_PATH);/opt/homebrew" \
-DCMAKE_BUILD_TYPE=Release
@$(MAKE) -C $(BUILD_DIR_MACOS)
@echo "Build Complete. Run with: open $(BUILD_DIR_MACOS)/$(TARGET).app"
windows:
@echo "Building for Windows..."
@mkdir -p $(BUILD_DIR_WIN)
@cd $(BUILD_DIR_WIN) && cmake .. \
-DCMAKE_PREFIX_PATH="$(QT_WIN_PATH)" \
-DCMAKE_BUILD_TYPE=Release
@cmake --build $(BUILD_DIR_WIN)
android:
@if [ ! -d "$(QT_ANDROID_KIT)" ]; then echo "Error: QT_ANDROID_KIT not found at $(QT_ANDROID_KIT)"; exit 1; fi
@mkdir -p $(BUILD_DIR_ANDROID)
@cd $(BUILD_DIR_ANDROID) && cmake .. \
-DCMAKE_TOOLCHAIN_FILE=$(QT_ANDROID_KIT)/lib/cmake/Qt6/qt.toolchain.cmake \
-DQT_ANDROID_ABIS="arm64-v8a" \
-DANDROID_PLATFORM=android-24 \
-DQT_ANDROID_BUILD_ALL_ABIS=OFF \
-DBUILD_ANDROID=ON \
-DCMAKE_BUILD_TYPE=Debug
@cmake --build $(BUILD_DIR_ANDROID) --target apk
@echo "APK generated at $(APK_PATH)"
ios:
@if [ ! -d "$(QT_IOS_KIT)" ]; then echo "Error: QT_IOS_KIT not found at $(QT_IOS_KIT)"; exit 1; fi
@mkdir -p $(BUILD_DIR_IOS)
@echo "Configuring iOS CMake..."
@cd $(BUILD_DIR_IOS) && cmake .. -G Xcode \
-DCMAKE_TOOLCHAIN_FILE=$(QT_IOS_KIT)/lib/cmake/Qt6/qt.toolchain.cmake \
-DCMAKE_SYSTEM_NAME=iOS \
-DCMAKE_OSX_DEPLOYMENT_TARGET=16.0 \
-DBUILD_IOS=ON
@echo "iOS Project generated at $(BUILD_DIR_IOS)/$(TARGET).xcodeproj"
run:
@open $(BUILD_DIR_MACOS)/$(TARGET).app
# --- Android Deployment Wrappers ---
install_android: android
@echo "Installing $(APK_PATH) to device..."
@adb install -r $(APK_PATH)
run_android: install_android
@echo "Launching $(PKG_NAME)..."
@adb shell am start -n $(PKG_NAME)/org.qtproject.qt.android.bindings.QtActivity
debug_android: run_android
@echo "Attaching Logcat (Ctrl+C to exit)..."
@adb logcat -v color -s YrCrystals Qt:* DEBUG
# --- Cleaning ---
clean:
@echo "Cleaning build artifacts..."
@if [ -f "$(BUILD_DIR_MACOS)/Makefile" ]; then cmake --build $(BUILD_DIR_MACOS) --target clean; fi
@if [ -f "$(BUILD_DIR_WIN)/Makefile" ] || [ -f "$(BUILD_DIR_WIN)/build.ninja" ]; then cmake --build $(BUILD_DIR_WIN) --target clean; fi
@if [ -f "$(BUILD_DIR_ANDROID)/Makefile" ] || [ -f "$(BUILD_DIR_ANDROID)/build.ninja" ]; then cmake --build $(BUILD_DIR_ANDROID) --target clean; fi
@if [ -d "$(BUILD_DIR_IOS)/$(TARGET).xcodeproj" ]; then cmake --build $(BUILD_DIR_IOS) --target clean; fi
distclean:
@rm -rf $(BUILD_DIR_MACOS) $(BUILD_DIR_WIN) $(BUILD_DIR_ANDROID) $(BUILD_DIR_IOS)
@echo "Removed all build directories."
.PHONY: all desktop macos windows android ios run install_android run_android debug_android clean distclean

View File

@ -0,0 +1,32 @@
<?xml version="1.0"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.qtproject.example.YrCrystals"
android:versionName="1.0"
android:versionCode="1"
android:installLocation="auto">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application android:label="Yr Crystals"
android:name="org.qtproject.qt.android.bindings.QtApplication"
android:requestLegacyExternalStorage="true">
<activity android:name="org.qtproject.qt.android.bindings.QtActivity"
android:label="Yr Crystals"
android:screenOrientation="unspecified"
android:launchMode="singleTop"
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|layoutDirection|locale|fontScale|keyboard|keyboardHidden|navigation|mcc|mnc|density"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="YrCrystals"/>
</activity>
</application>
</manifest>

78
ios/Info.plist Normal file
View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Yr Crystals</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<!-- Permissions -->
<key>NSMicrophoneUsageDescription</key>
<string>This app requires audio access to visualize music.</string>
<key>NSAppleMusicUsageDescription</key>
<string>This app requires access to your music library to play tracks.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app requires access to the photo library to load album art.</string>
<key>NSCameraUsageDescription</key>
<string>This app requires camera access for visualizer input.</string>
<!-- File Access -->
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<!-- Document Types -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Audio</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.audio</string>
<string>public.mp3</string>
<string>public.mpeg-4-audio</string>
<string>public.folder</string>
<string>public.directory</string>
<string>public.content</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

135
scripts/generate_icons.sh Executable file
View File

@ -0,0 +1,135 @@
# scripts/generate_icons.sh
#!/bin/bash
# Argument 1: Path to magick executable
MAGICK_BIN="$1"
# Fallback if not provided
if [ -z "$MAGICK_BIN" ]; then
MAGICK_BIN="magick"
fi
# Assumes running from Project Root
SOURCE="assets/icon_source.png"
OUT_DIR="assets/icons"
if [ ! -f "$SOURCE" ]; then
echo "Error: Source image '$SOURCE' not found in $(pwd)"
exit 1
fi
# Verify magick works
"$MAGICK_BIN" -version >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: ImageMagick tool '$MAGICK_BIN' not found or not working."
exit 1
fi
mkdir -p "$OUT_DIR"
# macOS
ICONSET="$OUT_DIR/icon.iconset"
mkdir -p "$ICONSET"
"$MAGICK_BIN" "$SOURCE" -scale 16x16 "$ICONSET/icon_16x16.png"
"$MAGICK_BIN" "$SOURCE" -scale 32x32 "$ICONSET/icon_16x16@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 32x32 "$ICONSET/icon_32x32.png"
"$MAGICK_BIN" "$SOURCE" -scale 64x64 "$ICONSET/icon_32x32@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 128x128 "$ICONSET/icon_128x128.png"
"$MAGICK_BIN" "$SOURCE" -scale 256x256 "$ICONSET/icon_128x128@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 256x256 "$ICONSET/icon_256x256.png"
"$MAGICK_BIN" "$SOURCE" -scale 512x512 "$ICONSET/icon_256x256@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 512x512 "$ICONSET/icon_512x512.png"
"$MAGICK_BIN" "$SOURCE" -scale 1024x1024 "$ICONSET/icon_512x512@2x.png"
iconutil -c icns "$ICONSET" -o "$OUT_DIR/app_icon.icns"
rm -rf "$ICONSET"
# Windows
"$MAGICK_BIN" "$SOURCE" \
\( -clone 0 -scale 256x256 \) \
\( -clone 0 -scale 128x128 \) \
\( -clone 0 -scale 64x64 \) \
\( -clone 0 -scale 48x48 \) \
\( -clone 0 -scale 32x32 \) \
\( -clone 0 -scale 16x16 \) \
-delete 0 -alpha off -colors 256 "$OUT_DIR/app_icon.ico"
# Android
ANDROID_DIR="$OUT_DIR/android/res"
mkdir -p "$ANDROID_DIR/mipmap-mdpi"
"$MAGICK_BIN" "$SOURCE" -scale 48x48 "$ANDROID_DIR/mipmap-mdpi/ic_launcher.png"
mkdir -p "$ANDROID_DIR/mipmap-hdpi"
"$MAGICK_BIN" "$SOURCE" -scale 72x72 "$ANDROID_DIR/mipmap-hdpi/ic_launcher.png"
mkdir -p "$ANDROID_DIR/mipmap-xhdpi"
"$MAGICK_BIN" "$SOURCE" -scale 96x96 "$ANDROID_DIR/mipmap-xhdpi/ic_launcher.png"
mkdir -p "$ANDROID_DIR/mipmap-xxhdpi"
"$MAGICK_BIN" "$SOURCE" -scale 144x144 "$ANDROID_DIR/mipmap-xxhdpi/ic_launcher.png"
mkdir -p "$ANDROID_DIR/mipmap-xxxhdpi"
"$MAGICK_BIN" "$SOURCE" -scale 192x192 "$ANDROID_DIR/mipmap-xxxhdpi/ic_launcher.png"
# iOS
XCASSETS_DIR="$OUT_DIR/ios/Assets.xcassets"
IOS_DIR="$XCASSETS_DIR/AppIcon.appiconset"
mkdir -p "$IOS_DIR"
cat > "$XCASSETS_DIR/Contents.json" <<EOF
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
EOF
"$MAGICK_BIN" "$SOURCE" -scale 20x20 "$IOS_DIR/Icon-20.png"
"$MAGICK_BIN" "$SOURCE" -scale 40x40 "$IOS_DIR/Icon-20@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 60x60 "$IOS_DIR/Icon-20@3x.png"
"$MAGICK_BIN" "$SOURCE" -scale 29x29 "$IOS_DIR/Icon-29.png"
"$MAGICK_BIN" "$SOURCE" -scale 58x58 "$IOS_DIR/Icon-29@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 87x87 "$IOS_DIR/Icon-29@3x.png"
"$MAGICK_BIN" "$SOURCE" -scale 40x40 "$IOS_DIR/Icon-40.png"
"$MAGICK_BIN" "$SOURCE" -scale 80x80 "$IOS_DIR/Icon-40@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 120x120 "$IOS_DIR/Icon-40@3x.png"
"$MAGICK_BIN" "$SOURCE" -scale 120x120 "$IOS_DIR/Icon-60@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 180x180 "$IOS_DIR/Icon-60@3x.png"
"$MAGICK_BIN" "$SOURCE" -scale 76x76 "$IOS_DIR/Icon-76.png"
"$MAGICK_BIN" "$SOURCE" -scale 152x152 "$IOS_DIR/Icon-76@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 167x167 "$IOS_DIR/Icon-83.5@2x.png"
"$MAGICK_BIN" "$SOURCE" -scale 1024x1024 "$IOS_DIR/Icon-1024.png"
cat > "$IOS_DIR/Contents.json" <<EOF
{
"images" : [
{ "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-20@2x.png", "scale" : "2x" },
{ "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-20@3x.png", "scale" : "3x" },
{ "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-29.png", "scale" : "1x" },
{ "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-29@2x.png", "scale" : "2x" },
{ "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-29@3x.png", "scale" : "3x" },
{ "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-40@2x.png", "scale" : "2x" },
{ "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-40@3x.png", "scale" : "3x" },
{ "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-60@2x.png", "scale" : "2x" },
{ "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-60@3x.png", "scale" : "3x" },
{ "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-20.png", "scale" : "1x" },
{ "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-20@2x.png", "scale" : "2x" },
{ "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-29.png", "scale" : "1x" },
{ "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-29@2x.png", "scale" : "2x" },
{ "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-40.png", "scale" : "1x" },
{ "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-40@2x.png", "scale" : "2x" },
{ "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-76.png", "scale" : "1x" },
{ "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-76@2x.png", "scale" : "2x" },
{ "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "Icon-83.5@2x.png", "scale" : "2x" },
{ "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-1024.png", "scale" : "1x" }
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
EOF

241
src/AudioEngine.cpp Normal file
View File

@ -0,0 +1,241 @@
#include "AudioEngine.h"
#include <QMediaDevices>
#include <QAudioDevice>
#include <QAudioFormat>
#include <QtEndian>
#include <QUrl>
#include <QAudioBuffer>
#include <QDebug>
#include <QtGlobal>
AudioEngine::AudioEngine(QObject* parent) : QObject(parent) {
m_processors.push_back(new Processor(m_frameSize, m_sampleRate));
m_processors.push_back(new Processor(m_frameSize, m_sampleRate));
m_processTimer = new QTimer(this);
m_processTimer->setInterval(16);
connect(m_processTimer, &QTimer::timeout, this, &AudioEngine::onProcessTimer);
}
AudioEngine::~AudioEngine() {
stop();
for(auto p : m_processors) delete p;
if (m_fileSource) delete m_fileSource;
}
void AudioEngine::setNumBins(int n) {
for(auto p : m_processors) p->setNumBins(n);
}
void AudioEngine::loadTrack(const QString& filePath) {
stop();
m_pcmData.clear();
m_buffer.close();
if (m_fileSource) {
m_fileSource->close();
delete m_fileSource;
m_fileSource = nullptr;
}
if (m_decoder) delete m_decoder;
m_decoder = new QAudioDecoder(this);
QAudioFormat format;
format.setSampleRate(44100);
format.setChannelCount(2);
format.setSampleFormat(QAudioFormat::Int16);
m_decoder->setAudioFormat(format);
connect(m_decoder, &QAudioDecoder::bufferReady, this, &AudioEngine::onBufferReady);
connect(m_decoder, &QAudioDecoder::finished, this, &AudioEngine::onFinished);
connect(m_decoder, QOverload<QAudioDecoder::Error>::of(&QAudioDecoder::error), this, &AudioEngine::onError);
qDebug() << "AudioEngine: Attempting to load" << filePath;
#ifdef Q_OS_ANDROID
m_fileSource = new QFile(filePath);
if (m_fileSource->open(QIODevice::ReadOnly)) {
m_decoder->setSourceDevice(m_fileSource);
} else {
delete m_fileSource;
m_fileSource = nullptr;
// Fix: Handle content:// URIs correctly
if (filePath.startsWith("content://")) {
m_decoder->setSource(QUrl(filePath));
} else {
m_decoder->setSource(QUrl::fromLocalFile(filePath));
}
}
#else
m_decoder->setSource(QUrl::fromLocalFile(filePath));
#endif
m_decoder->start();
}
void AudioEngine::onError(QAudioDecoder::Error error) {
qWarning() << "AudioEngine: Decoder Error:" << error << m_decoder->errorString();
emit trackLoaded(false);
}
void AudioEngine::onBufferReady() {
QAudioBuffer buffer = m_decoder->read();
if (!buffer.isValid()) return;
// Fix: Explicit cast to int to avoid warning
const int frames = static_cast<int>(buffer.frameCount());
const int channels = buffer.format().channelCount();
auto sampleType = buffer.format().sampleFormat();
if (sampleType == QAudioFormat::Int16) {
const int16_t* src = buffer.constData<int16_t>();
if (!src) return;
for (int i = 0; i < frames; ++i) {
float left = 0.0f;
float right = 0.0f;
if (channels == 1) {
left = src[i] / 32768.0f;
right = left;
} else if (channels >= 2) {
left = src[i * channels] / 32768.0f;
right = src[i * channels + 1] / 32768.0f;
}
m_pcmData.append(reinterpret_cast<const char*>(&left), sizeof(float));
m_pcmData.append(reinterpret_cast<const char*>(&right), sizeof(float));
}
}
else if (sampleType == QAudioFormat::Float) {
const float* src = buffer.constData<float>();
if (!src) return;
for (int i = 0; i < frames; ++i) {
float left = 0.0f;
float right = 0.0f;
if (channels == 1) {
left = src[i];
right = left;
} else if (channels >= 2) {
left = src[i * channels];
right = src[i * channels + 1];
}
m_pcmData.append(reinterpret_cast<const char*>(&left), sizeof(float));
m_pcmData.append(reinterpret_cast<const char*>(&right), sizeof(float));
}
}
}
void AudioEngine::onFinished() {
if (m_pcmData.isEmpty()) {
emit trackLoaded(false);
return;
}
m_buffer.setData(m_pcmData);
if (!m_buffer.open(QIODevice::ReadOnly)) {
emit trackLoaded(false);
return;
}
emit trackLoaded(true);
}
void AudioEngine::play() {
if (!m_buffer.isOpen() || m_pcmData.isEmpty()) return;
if (m_sink) {
m_sink->resume();
m_processTimer->start();
return;
}
QAudioFormat format;
format.setSampleRate(44100);
format.setChannelCount(2);
format.setSampleFormat(QAudioFormat::Float);
QAudioDevice device = QMediaDevices::defaultAudioOutput();
if (device.isNull()) {
qWarning() << "AudioEngine: No audio output device found.";
return;
}
if (!device.isFormatSupported(format)) {
qWarning() << "AudioEngine: Float format not supported, using preferred format.";
format = device.preferredFormat();
}
m_sink = new QAudioSink(device, format, this);
connect(m_sink, &QAudioSink::stateChanged, this, [this](QAudio::State state){
if (state == QAudio::IdleState && m_sink->error() == QAudio::NoError) {
if (m_buffer.bytesAvailable() == 0) {
m_processTimer->stop();
emit playbackFinished();
}
}
});
m_sink->start(&m_buffer);
m_processTimer->start();
}
void AudioEngine::pause() {
if (m_sink) m_sink->suspend();
m_processTimer->stop();
}
void AudioEngine::stop() {
m_processTimer->stop();
if (m_sink) {
m_sink->stop();
delete m_sink;
m_sink = nullptr;
}
}
void AudioEngine::seek(float position) {
if (m_pcmData.isEmpty()) return;
qint64 pos = position * m_pcmData.size();
pos -= pos % 8;
if (m_buffer.isOpen()) m_buffer.seek(pos);
}
void AudioEngine::setDspParams(int frameSize, int hopSize) {
m_frameSize = frameSize;
m_hopSize = hopSize;
for(auto p : m_processors) p->setFrameSize(frameSize);
}
void AudioEngine::onProcessTimer() {
if (!m_buffer.isOpen()) return;
qint64 currentPos = m_buffer.pos();
emit positionChanged((float)currentPos / m_pcmData.size());
const float* samples = reinterpret_cast<const float*>(m_pcmData.constData());
qint64 sampleIdx = currentPos / sizeof(float);
qint64 totalSamples = m_pcmData.size() / sizeof(float);
if (sampleIdx + m_frameSize * 2 >= totalSamples) return;
std::vector<float> ch0(m_frameSize), ch1(m_frameSize);
for (int i = 0; i < m_frameSize; ++i) {
ch0[i] = samples[sampleIdx + i*2];
ch1[i] = samples[sampleIdx + i*2 + 1];
}
m_processors[0]->pushData(ch0);
m_processors[1]->pushData(ch1);
std::vector<FrameData> results;
for (auto p : m_processors) {
auto spec = p->getSpectrum();
results.push_back({spec.freqs, spec.db});
}
emit spectrumReady(results);
}

59
src/AudioEngine.h Normal file
View File

@ -0,0 +1,59 @@
// src/AudioEngine.h
#pragma once
#include <QObject>
#include <QAudioSink>
#include <QAudioDecoder>
#include <QBuffer>
#include <QFile>
#include <QTimer>
#include <vector>
#include "Processor.h"
class AudioEngine : public QObject {
Q_OBJECT
public:
AudioEngine(QObject* parent = nullptr);
~AudioEngine();
struct FrameData {
std::vector<float> freqs;
std::vector<float> db;
};
public slots:
void loadTrack(const QString& filePath);
void play();
void pause();
void stop();
void seek(float position);
void setDspParams(int frameSize, int hopSize);
void setNumBins(int n);
signals:
void playbackFinished();
void positionChanged(float pos);
void trackLoaded(bool success);
void spectrumReady(const std::vector<AudioEngine::FrameData>& data);
private slots:
void onBufferReady();
void onFinished();
void onError(QAudioDecoder::Error error);
void onProcessTimer();
private:
QAudioSink* m_sink = nullptr;
QBuffer m_buffer;
QByteArray m_pcmData;
QAudioDecoder* m_decoder = nullptr;
QFile* m_fileSource = nullptr;
QTimer* m_processTimer = nullptr;
std::vector<Processor*> m_processors;
int m_frameSize = 32768;
int m_hopSize = 1024;
int m_sampleRate = 44100;
int m_channels = 2;
};

157
src/CommonWidgets.cpp Normal file
View File

@ -0,0 +1,157 @@
#include "CommonWidgets.h"
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QPainter>
#include <QMouseEvent>
#include <QPushButton>
#include <algorithm>
#include <cmath>
PlaylistItemWidget::PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent)
: QWidget(parent)
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(5, 5, 5, 5);
layout->setSpacing(10);
m_thumb = new QLabel(this);
m_thumb->setFixedSize(50, 50);
m_thumb->setStyleSheet("background-color: #333; border-radius: 4px;");
m_thumb->setScaledContents(true);
if (!meta.art.isNull()) m_thumb->setPixmap(QPixmap::fromImage(meta.art));
layout->addWidget(m_thumb);
QVBoxLayout* textLayout = new QVBoxLayout();
textLayout->setSpacing(2);
m_title = new QLabel(meta.title, this);
m_title->setStyleSheet("color: white; font-weight: bold; font-size: 14px;");
m_artist = new QLabel(meta.artist, this);
m_artist->setStyleSheet("color: #aaa; font-size: 12px;");
textLayout->addWidget(m_title);
textLayout->addWidget(m_artist);
textLayout->addStretch();
layout->addLayout(textLayout);
layout->addStretch();
}
XYPad::XYPad(const QString& title, QWidget* parent) : QWidget(parent), m_title(title) {
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
setMinimumHeight(150);
setCursor(Qt::CrossCursor);
}
void XYPad::setFormatter(std::function<QString(float, float)> formatter) {
m_formatter = formatter;
}
void XYPad::setValues(float x, float y) {
m_x = std::clamp(x, 0.0f, 1.0f);
m_y = std::clamp(y, 0.0f, 1.0f);
update();
}
void XYPad::paintEvent(QPaintEvent*) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
p.fillRect(rect(), QColor(40, 40, 40, 200));
p.setPen(QPen(QColor(80, 80, 80), 1));
p.drawRect(rect().adjusted(0, 0, -1, -1));
p.setPen(QPen(QColor(60, 60, 60), 1, Qt::DotLine));
p.drawLine(width()/2, 0, width()/2, height());
p.drawLine(0, height()/2, width(), height()/2);
int px = m_x * width();
int py = (1.0f - m_y) * height();
p.setPen(Qt::NoPen);
p.setBrush(QColor(0, 212, 255, 180));
p.drawEllipse(QPoint(px, py), 12, 12);
p.setBrush(Qt::white);
p.drawEllipse(QPoint(px, py), 4, 4);
p.setPen(QPen(QColor(0, 212, 255, 100), 1));
p.drawLine(px, 0, px, height());
p.drawLine(0, py, width(), py);
p.setPen(Qt::white);
QFont f = font();
f.setBold(true);
f.setPointSize(10);
p.setFont(f);
QString text = m_title;
if (m_formatter) {
text += "\n" + m_formatter(m_x, m_y);
}
p.drawText(rect().adjusted(10, 10, -10, -10), Qt::AlignLeft | Qt::AlignTop, text);
}
void XYPad::mousePressEvent(QMouseEvent* event) { updateFromPos(event->pos()); }
void XYPad::mouseMoveEvent(QMouseEvent* event) { updateFromPos(event->pos()); }
void XYPad::updateFromPos(const QPoint& pos) {
m_x = std::clamp((float)pos.x() / width(), 0.0f, 1.0f);
m_y = std::clamp(1.0f - (float)pos.y() / height(), 0.0f, 1.0f);
update();
emit valuesChanged(m_x, m_y);
}
OverlayWidget::OverlayWidget(QWidget* content, QWidget* parent) : QWidget(parent), m_content(content) {
QPalette pal = palette();
pal.setColor(QPalette::Window, QColor(0, 0, 0, 100));
setAutoFillBackground(true);
setPalette(pal);
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setAlignment(Qt::AlignCenter);
layout->setContentsMargins(20, 20, 20, 20);
content->setParent(this);
content->setMaximumWidth(500);
content->setMaximumHeight(600);
layout->addWidget(content);
hide();
}
void OverlayWidget::mousePressEvent(QMouseEvent* event) {
if (!m_content->geometry().contains(event->pos())) {
hide();
}
}
void OverlayWidget::paintEvent(QPaintEvent* event) {
QPainter p(this);
p.fillRect(rect(), QColor(0, 0, 0, 100));
}
WelcomeWidget::WelcomeWidget(QWidget* parent) : QWidget(parent) {
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setAlignment(Qt::AlignCenter);
layout->setSpacing(20);
QLabel* title = new QLabel("Yr Crystals", this);
title->setStyleSheet("color: white; font-size: 32px; font-weight: bold;");
title->setAlignment(Qt::AlignCenter);
layout->addWidget(title);
QString btnStyle = "QPushButton { background-color: #333; color: white; border: 1px solid #555; border-radius: 8px; padding: 15px; font-size: 18px; } QPushButton:pressed { background-color: #555; }";
QPushButton* btnFile = new QPushButton("Open File", this);
btnFile->setStyleSheet(btnStyle);
btnFile->setFixedWidth(250);
connect(btnFile, &QPushButton::clicked, this, &WelcomeWidget::openFileClicked);
layout->addWidget(btnFile);
QPushButton* btnFolder = new QPushButton("Open Folder", this);
btnFolder->setStyleSheet(btnStyle);
btnFolder->setFixedWidth(250);
connect(btnFolder, &QPushButton::clicked, this, &WelcomeWidget::openFolderClicked);
layout->addWidget(btnFolder);
}

55
src/CommonWidgets.h Normal file
View File

@ -0,0 +1,55 @@
#pragma once
#include <QWidget>
#include <QLabel>
#include <functional>
#include "Utils.h"
class PlaylistItemWidget : public QWidget {
Q_OBJECT
public:
PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent = nullptr);
private:
QLabel* m_thumb;
QLabel* m_title;
QLabel* m_artist;
};
class XYPad : public QWidget {
Q_OBJECT
public:
XYPad(const QString& title, QWidget* parent = nullptr);
void setFormatter(std::function<QString(float, float)> formatter);
void setValues(float x, float y);
signals:
void valuesChanged(float x, float y);
protected:
void paintEvent(QPaintEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
private:
void updateFromPos(const QPoint& pos);
QString m_title;
float m_x = 0.5f;
float m_y = 0.5f;
std::function<QString(float, float)> m_formatter;
};
class OverlayWidget : public QWidget {
Q_OBJECT
public:
OverlayWidget(QWidget* content, QWidget* parent = nullptr);
protected:
void mousePressEvent(QMouseEvent* event) override;
void paintEvent(QPaintEvent* event) override;
private:
QWidget* m_content;
};
class WelcomeWidget : public QWidget {
Q_OBJECT
public:
WelcomeWidget(QWidget* parent = nullptr);
signals:
void openFileClicked();
void openFolderClicked();
};

346
src/MainWindow.cpp Normal file
View File

@ -0,0 +1,346 @@
// src/MainWindow.cpp
#include "MainWindow.h"
#include <QApplication>
#include <QHeaderView>
#include <QScrollBar>
#include <QFileInfo>
#include <QCloseEvent>
#include <QFileDialog>
#include <QScroller>
#include <QThread>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDir>
#include <QStandardPaths>
#include <QUrl>
#include <algorithm>
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
#ifndef IS_MOBILE
#define IS_MOBILE
#endif
#endif
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setWindowTitle("Yr Crystals");
resize(1280, 800);
m_stack = new QStackedWidget(this);
setCentralWidget(m_stack);
m_welcome = new WelcomeWidget(this);
connect(m_welcome, &WelcomeWidget::openFileClicked, this, &MainWindow::onOpenFile);
connect(m_welcome, &WelcomeWidget::openFolderClicked, this, &MainWindow::onOpenFolder);
m_stack->addWidget(m_welcome);
initUi();
m_engine = new AudioEngine();
QThread* audioThread = new QThread(this);
m_engine->moveToThread(audioThread);
connect(audioThread, &QThread::finished, m_engine, &QObject::deleteLater);
connect(m_engine, &AudioEngine::playbackFinished, this, &MainWindow::onTrackFinished);
connect(m_engine, &AudioEngine::trackLoaded, this, &MainWindow::onTrackLoaded);
connect(m_engine, &AudioEngine::positionChanged, m_playerPage->playback(), &PlaybackWidget::updateSeek);
connect(m_engine, &AudioEngine::spectrumReady, m_playerPage->visualizer(), &VisualizerWidget::updateData);
audioThread->start();
}
MainWindow::~MainWindow() {
if (m_engine) {
QMetaObject::invokeMethod(m_engine, "stop", Qt::BlockingQueuedConnection);
m_engine->thread()->quit();
m_engine->thread()->wait();
}
}
void MainWindow::initUi() {
m_playerPage = new PlayerPage(this);
m_playlist = new QListWidget();
m_playlist->setStyleSheet("QListWidget { background-color: #111; border: none; } QListWidget::item { border-bottom: 1px solid #222; padding: 5px; } QListWidget::item:selected { background-color: #333; }");
m_playlist->setSelectionMode(QAbstractItemView::SingleSelection);
QScroller::grabGesture(m_playlist, QScroller::LeftMouseButtonGesture);
connect(m_playlist, &QListWidget::itemDoubleClicked, this, &MainWindow::onTrackDoubleClicked);
PlaybackWidget* pb = m_playerPage->playback();
SettingsWidget* set = m_playerPage->settings();
VisualizerWidget* viz = m_playerPage->visualizer();
connect(pb, &PlaybackWidget::playClicked, this, &MainWindow::play);
connect(pb, &PlaybackWidget::pauseClicked, this, &MainWindow::pause);
connect(pb, &PlaybackWidget::nextClicked, this, &MainWindow::nextTrack);
connect(pb, &PlaybackWidget::prevClicked, this, &MainWindow::prevTrack);
connect(pb, &PlaybackWidget::seekChanged, this, &MainWindow::seek);
connect(set, &SettingsWidget::paramsChanged, viz, &VisualizerWidget::setParams);
connect(set, &SettingsWidget::dspParamsChanged, this, &MainWindow::onDspChanged);
connect(set, &SettingsWidget::binsChanged, this, &MainWindow::onBinsChanged);
connect(set, &SettingsWidget::paramsChanged, this, &MainWindow::saveSettings);
connect(set, &SettingsWidget::binsChanged, this, &MainWindow::saveSettings);
connect(m_playerPage, &PlayerPage::toggleFullScreen, this, &MainWindow::onToggleFullScreen);
#ifdef IS_MOBILE
m_mobileTabs = new QTabWidget();
m_mobileTabs->setStyleSheet("QTabWidget::pane { border: 0; } QTabBar::tab { background: #222; color: white; padding: 15px; min-width: 100px; } QTabBar::tab:selected { background: #444; }");
m_mobileTabs->addTab(m_playerPage, "Visualizer");
m_mobileTabs->addTab(m_playlist, "Playlist");
m_stack->addWidget(m_mobileTabs);
#else
m_dock = new QDockWidget("Playlist", this);
m_dock->setWidget(m_playlist);
addDockWidget(Qt::LeftDockWidgetArea, m_dock);
m_stack->addWidget(m_playerPage);
#endif
}
void MainWindow::onToggleFullScreen() {
static bool isFs = false;
isFs = !isFs;
m_playerPage->setFullScreen(isFs);
#ifdef IS_MOBILE
if (m_mobileTabs) {
QTabBar* bar = m_mobileTabs->findChild<QTabBar*>();
if (bar) bar->setVisible(!isFs);
}
#else
if (m_dock) m_dock->setVisible(!isFs);
#endif
}
void MainWindow::onOpenFile() {
m_pendingAction = PendingAction::File;
QMetaObject::invokeMethod(this, "onPermissionsResult", Qt::QueuedConnection, Q_ARG(bool, true));
}
void MainWindow::onOpenFolder() {
m_pendingAction = PendingAction::Folder;
QMetaObject::invokeMethod(this, "onPermissionsResult", Qt::QueuedConnection, Q_ARG(bool, true));
}
void MainWindow::onPermissionsResult(bool granted) {
if (!granted) return;
#ifdef Q_OS_IOS
auto callback = [this, recursive = (m_pendingAction == PendingAction::Folder)](QString path) {
if (!path.isEmpty()) loadPath(path, recursive);
};
Utils::openIosPicker(m_pendingAction == PendingAction::Folder, callback);
#else
QString initialPath;
QString filter = "Audio (*.mp3 *.m4a *.wav *.flac *.ogg)";
#ifdef Q_OS_ANDROID
initialPath = QStandardPaths::writableLocation(QStandardPaths::MusicLocation);
if (initialPath.isEmpty()) initialPath = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
#else
initialPath = QStandardPaths::writableLocation(QStandardPaths::MusicLocation);
#endif
QString path;
if (m_pendingAction == PendingAction::File) {
path = QFileDialog::getOpenFileName(nullptr, "Open Audio", initialPath, filter);
if (!path.isEmpty()) loadPath(path, false);
} else if (m_pendingAction == PendingAction::Folder) {
path = QFileDialog::getExistingDirectory(nullptr, "Open Folder", initialPath);
if (!path.isEmpty()) loadPath(path, true);
}
#endif
m_pendingAction = PendingAction::None;
}
void MainWindow::loadPath(const QString& rawPath, bool recursive) {
QString path = rawPath;
QUrl url(rawPath);
if (url.isValid() && url.isLocalFile()) {
path = url.toLocalFile();
} else if (rawPath.startsWith("file://")) {
path = QUrl::fromPercentEncoding(rawPath.toUtf8()).mid(7);
}
m_tracks.clear();
m_playlist->clear();
QFileInfo info(path);
bool isDir = info.isDir();
bool isFile = info.isFile();
if (!isDir && !isFile && QFile::exists(path)) {
if (path.endsWith(".mp3") || path.endsWith(".m4a") || path.endsWith(".wav") || path.endsWith(".flac") || path.endsWith(".ogg")) {
isFile = true;
} else {
isDir = true;
}
}
if (isDir || path.startsWith("content://")) {
m_settingsDir = path;
QStringList files = Utils::scanDirectory(path, recursive);
for (const auto& f : files) m_tracks.append({f, Utils::getMetadata(f)});
std::sort(m_tracks.begin(), m_tracks.end(), [](const TrackInfo& a, const TrackInfo& b) {
if (a.meta.album != b.meta.album) return a.meta.album < b.meta.album;
if (a.meta.trackNumber != b.meta.trackNumber) return a.meta.trackNumber < b.meta.trackNumber;
return a.meta.title < b.meta.title;
});
for (const auto& t : m_tracks) {
QListWidgetItem* item = new QListWidgetItem(m_playlist);
item->setSizeHint(QSize(0, 70));
m_playlist->addItem(item);
m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta));
}
if (!m_tracks.isEmpty()) loadIndex(0);
} else if (isFile) {
m_settingsDir = info.path();
TrackInfo t = {path, Utils::getMetadata(path)};
m_tracks.append(t);
QListWidgetItem* item = new QListWidgetItem(m_playlist);
item->setSizeHint(QSize(0, 70));
m_playlist->addItem(item);
m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta));
loadIndex(0);
}
loadSettings();
#ifdef IS_MOBILE
m_stack->setCurrentWidget(m_mobileTabs);
#else
m_stack->setCurrentWidget(m_playerPage);
#endif
}
void MainWindow::loadSettings() {
if (m_settingsDir.isEmpty()) return;
if (m_settingsDir.startsWith("content://")) return;
QFile f(QDir(m_settingsDir).filePath(".yrcrystals.json"));
if (f.open(QIODevice::ReadOnly)) {
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
QJsonObject root = doc.object();
bool glass = root["glass"].toBool(true);
bool focus = root["focus"].toBool(false);
bool trails = root["trails"].toBool(false);
bool albumColors = root["albumColors"].toBool(false);
bool shadow = root["shadow"].toBool(false);
bool mirrored = root["mirrored"].toBool(false);
int bins = root["bins"].toInt(26);
float brightness = root["brightness"].toDouble(1.0);
float entropy = root["entropy"].toDouble(1.0);
m_playerPage->settings()->setParams(glass, focus, trails, albumColors, shadow, mirrored, bins, brightness, entropy);
}
}
void MainWindow::saveSettings() {
if (m_settingsDir.isEmpty()) return;
if (m_settingsDir.startsWith("content://")) return;
SettingsWidget* s = m_playerPage->settings();
QJsonObject root;
root["glass"] = s->isGlass();
root["focus"] = s->isFocus();
root["trails"] = s->isTrails();
root["albumColors"] = s->isAlbumColors();
root["shadow"] = s->isShadow();
root["mirrored"] = s->isMirrored();
root["bins"] = s->getBins();
root["brightness"] = s->getBrightness();
root["entropy"] = s->getEntropy();
QFile f(QDir(m_settingsDir).filePath(".yrcrystals.json"));
if (f.open(QIODevice::WriteOnly)) {
f.write(QJsonDocument(root).toJson());
}
}
void MainWindow::loadIndex(int index) {
if (index < 0 || index >= m_tracks.size()) return;
m_currentIndex = index;
const auto& t = m_tracks[index];
m_playlist->setCurrentRow(index);
int bins = m_playerPage->settings()->findChild<QSlider*>()->value();
auto colors = Utils::extractAlbumColors(t.meta.art, bins);
std::vector<QColor> stdColors;
for(const auto& c : colors) stdColors.push_back(c);
m_playerPage->visualizer()->setAlbumPalette(stdColors);
QMetaObject::invokeMethod(m_engine, "loadTrack", Qt::QueuedConnection, Q_ARG(QString, t.path));
}
void MainWindow::onTrackLoaded(bool success) {
if (success) {
play();
if (m_currentIndex >= 0) {
const auto& t = m_tracks[m_currentIndex];
QString title = t.meta.title;
if (!t.meta.artist.isEmpty()) title += " - " + t.meta.artist;
setWindowTitle(title);
}
} else {
nextTrack();
}
}
void MainWindow::onTrackDoubleClicked(QListWidgetItem* item) {
loadIndex(m_playlist->row(item));
}
void MainWindow::play() {
QMetaObject::invokeMethod(m_engine, "play", Qt::QueuedConnection);
m_playerPage->playback()->setPlaying(true);
}
void MainWindow::pause() {
QMetaObject::invokeMethod(m_engine, "pause", Qt::QueuedConnection);
m_playerPage->playback()->setPlaying(false);
}
void MainWindow::nextTrack() {
int next = m_currentIndex + 1;
if (next >= static_cast<int>(m_tracks.size())) next = 0;
loadIndex(next);
}
void MainWindow::prevTrack() {
int prev = m_currentIndex - 1;
if (prev < 0) prev = static_cast<int>(m_tracks.size()) - 1;
loadIndex(prev);
}
void MainWindow::seek(float pos) {
QMetaObject::invokeMethod(m_engine, "seek", Qt::QueuedConnection, Q_ARG(float, pos));
}
void MainWindow::onTrackFinished() { nextTrack(); }
void MainWindow::updateLoop() {
}
void MainWindow::onDspChanged(int fft, int hop) {
QMetaObject::invokeMethod(m_engine, "setDspParams", Qt::QueuedConnection, Q_ARG(int, fft), Q_ARG(int, hop));
}
void MainWindow::closeEvent(QCloseEvent* event) {
event->accept();
}
void MainWindow::onBinsChanged(int n) {
QMetaObject::invokeMethod(m_engine, "setNumBins", Qt::QueuedConnection, Q_ARG(int, n));
m_playerPage->visualizer()->setNumBins(n);
if (m_currentIndex >= 0 && m_currentIndex < m_tracks.size()) {
const auto& t = m_tracks[m_currentIndex];
auto colors = Utils::extractAlbumColors(t.meta.art, n);
std::vector<QColor> stdColors;
for(const auto& c : colors) stdColors.push_back(c);
m_playerPage->visualizer()->setAlbumPalette(stdColors);
}
}

59
src/MainWindow.h Normal file
View File

@ -0,0 +1,59 @@
#pragma once
#include <QMainWindow>
#include <QDockWidget>
#include <QListWidget>
#include <QStackedWidget>
#include <QTabWidget>
#include <QTimer>
#include "AudioEngine.h"
#include "PlayerControls.h"
#include "CommonWidgets.h"
class MainWindow : public QMainWindow {
Q_OBJECT
public:
MainWindow(QWidget* parent = nullptr);
~MainWindow();
void loadPath(const QString& path, bool recursive);
protected:
void closeEvent(QCloseEvent* event) override;
private slots:
void onOpenFile();
void onOpenFolder();
void onPermissionsResult(bool granted);
void updateLoop();
void onTrackFinished();
void onTrackLoaded(bool success);
void onTrackDoubleClicked(QListWidgetItem* item);
void play();
void pause();
void nextTrack();
void prevTrack();
void seek(float pos);
void onDspChanged(int fft, int hop);
void onBinsChanged(int n);
void onToggleFullScreen();
void saveSettings();
private:
void initUi();
void loadIndex(int index);
void loadSettings();
QStackedWidget* m_stack;
WelcomeWidget* m_welcome;
PlayerPage* m_playerPage;
QDockWidget* m_dock;
QTabWidget* m_mobileTabs;
QListWidget* m_playlist;
AudioEngine* m_engine;
QTimer* m_timer;
struct TrackInfo {
QString path;
Utils::Metadata meta;
};
QVector<TrackInfo> m_tracks;
int m_currentIndex = -1;
enum class PendingAction { None, File, Folder };
PendingAction m_pendingAction = PendingAction::None;
QString m_settingsDir;
};

296
src/PlayerControls.cpp Normal file
View File

@ -0,0 +1,296 @@
// src/PlayerControls.cpp
#include "PlayerControls.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <cmath>
PlaybackWidget::PlaybackWidget(QWidget* parent) : QWidget(parent) {
setStyleSheet("background-color: rgba(0, 0, 0, 150); border-top: 1px solid #444;");
QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(10, 5, 10, 10);
m_seekSlider = new QSlider(Qt::Horizontal, this);
m_seekSlider->setRange(0, 1000);
m_seekSlider->setFixedHeight(30);
m_seekSlider->setStyleSheet(
"QSlider::handle:horizontal { background: white; width: 20px; margin: -8px 0; border-radius: 10px; }"
"QSlider::groove:horizontal { background: #444; height: 4px; }"
"QSlider::sub-page:horizontal { background: #00d4ff; }"
);
connect(m_seekSlider, &QSlider::sliderPressed, this, &PlaybackWidget::onSeekPressed);
connect(m_seekSlider, &QSlider::sliderReleased, this, &PlaybackWidget::onSeekReleased);
mainLayout->addWidget(m_seekSlider);
QHBoxLayout* rowLayout = new QHBoxLayout();
QString btnStyle = "QPushButton { background: transparent; color: white; font-size: 24px; border: 1px solid #444; border-radius: 8px; padding: 10px 20px; } QPushButton:pressed { background: #333; }";
QPushButton* btnPrev = new QPushButton("<<", this);
btnPrev->setStyleSheet(btnStyle);
connect(btnPrev, &QPushButton::clicked, this, &PlaybackWidget::prevClicked);
m_btnPlay = new QPushButton(">", this);
m_btnPlay->setStyleSheet(btnStyle);
connect(m_btnPlay, &QPushButton::clicked, this, &PlaybackWidget::onPlayToggle);
QPushButton* btnNext = new QPushButton(">>", this);
btnNext->setStyleSheet(btnStyle);
connect(btnNext, &QPushButton::clicked, this, &PlaybackWidget::nextClicked);
QPushButton* btnSettings = new QPushButton("", this);
btnSettings->setStyleSheet("QPushButton { background: transparent; color: #aaa; font-size: 24px; border: none; padding: 10px; } QPushButton:pressed { color: white; }");
connect(btnSettings, &QPushButton::clicked, this, &PlaybackWidget::settingsClicked);
rowLayout->addWidget(btnPrev);
rowLayout->addSpacing(10);
rowLayout->addWidget(m_btnPlay);
rowLayout->addSpacing(10);
rowLayout->addWidget(btnNext);
rowLayout->addStretch();
rowLayout->addWidget(btnSettings);
mainLayout->addLayout(rowLayout);
}
void PlaybackWidget::setPlaying(bool playing) {
m_isPlaying = playing;
m_btnPlay->setText(playing ? "||" : ">");
}
void PlaybackWidget::updateSeek(float pos) {
if (!m_seeking) m_seekSlider->setValue(static_cast<int>(pos * 1000));
}
void PlaybackWidget::onSeekPressed() { m_seeking = true; }
void PlaybackWidget::onSeekReleased() {
m_seeking = false;
emit seekChanged(m_seekSlider->value() / 1000.0f);
}
void PlaybackWidget::onPlayToggle() {
if (m_isPlaying) emit pauseClicked(); else emit playClicked();
}
SettingsWidget::SettingsWidget(QWidget* parent) : QWidget(parent) {
setStyleSheet("background-color: rgba(30, 30, 30, 230); border-radius: 12px; border: 1px solid #666;");
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(15, 15, 15, 15);
layout->setSpacing(15);
QHBoxLayout* header = new QHBoxLayout();
QLabel* title = new QLabel("Settings", this);
title->setStyleSheet("color: white; font-size: 18px; font-weight: bold; border: none; background: transparent;");
QPushButton* btnClose = new QPushButton("", this);
btnClose->setFixedSize(30, 30);
btnClose->setStyleSheet("QPushButton { background: #444; color: white; border-radius: 15px; border: none; font-weight: bold; } QPushButton:pressed { background: #666; }");
connect(btnClose, &QPushButton::clicked, this, &SettingsWidget::closeClicked);
header->addWidget(title);
header->addStretch();
header->addWidget(btnClose);
layout->addLayout(header);
QGridLayout* grid = new QGridLayout();
auto createCheck = [&](const QString& text, bool checked, int r, int c) {
QCheckBox* cb = new QCheckBox(text, this);
cb->setChecked(checked);
cb->setStyleSheet("QCheckBox { color: white; font-size: 14px; padding: 5px; border: none; background: transparent; } QCheckBox::indicator { width: 20px; height: 20px; }");
connect(cb, &QCheckBox::toggled, this, &SettingsWidget::emitParams);
grid->addWidget(cb, r, c);
return cb;
};
// Defaults: Only Glass checked
m_checkGlass = createCheck("Glass", true, 0, 0);
m_checkFocus = createCheck("Focus", false, 0, 1);
m_checkTrails = createCheck("Trails", false, 1, 0);
m_checkAlbumColors = createCheck("Album Colors", false, 1, 1);
m_checkShadow = createCheck("Shadow", false, 2, 0);
m_checkMirrored = createCheck("Mirrored", false, 2, 1);
layout->addLayout(grid);
// Bins Slider
QHBoxLayout* binsLayout = new QHBoxLayout();
m_lblBins = new QLabel("Bins: 26", this);
m_lblBins->setStyleSheet("color: white; font-weight: bold; border: none; background: transparent; min-width: 80px;");
m_sliderBins = new QSlider(Qt::Horizontal, this);
m_sliderBins->setRange(10, 64);
m_sliderBins->setValue(26);
m_sliderBins->setStyleSheet("QSlider::handle:horizontal { background: #aaa; width: 24px; margin: -10px 0; border-radius: 12px; } QSlider::groove:horizontal { background: #444; height: 4px; }");
connect(m_sliderBins, &QSlider::valueChanged, this, &SettingsWidget::onBinsChanged);
binsLayout->addWidget(m_lblBins);
binsLayout->addWidget(m_sliderBins);
layout->addLayout(binsLayout);
// Brightness Slider
QHBoxLayout* brightLayout = new QHBoxLayout();
m_lblBrightness = new QLabel("Bright: 100%", this);
m_lblBrightness->setStyleSheet("color: white; font-weight: bold; border: none; background: transparent; min-width: 80px;");
m_sliderBrightness = new QSlider(Qt::Horizontal, this);
m_sliderBrightness->setRange(10, 200); // 10% to 200%
m_sliderBrightness->setValue(100);
m_sliderBrightness->setStyleSheet("QSlider::handle:horizontal { background: #aaa; width: 24px; margin: -10px 0; border-radius: 12px; } QSlider::groove:horizontal { background: #444; height: 4px; }");
connect(m_sliderBrightness, &QSlider::valueChanged, this, &SettingsWidget::onBrightnessChanged);
brightLayout->addWidget(m_lblBrightness);
brightLayout->addWidget(m_sliderBrightness);
layout->addLayout(brightLayout);
// Entropy Slider
QHBoxLayout* entropyLayout = new QHBoxLayout();
m_lblEntropy = new QLabel("Entropy: 1.0", this);
m_lblEntropy->setStyleSheet("color: white; font-weight: bold; border: none; background: transparent; min-width: 80px;");
m_sliderEntropy = new QSlider(Qt::Horizontal, this);
m_sliderEntropy->setRange(0, 300); // 0.0 to 3.0
m_sliderEntropy->setValue(100);
m_sliderEntropy->setStyleSheet("QSlider::handle:horizontal { background: #aaa; width: 24px; margin: -10px 0; border-radius: 12px; } QSlider::groove:horizontal { background: #444; height: 4px; }");
connect(m_sliderEntropy, &QSlider::valueChanged, this, &SettingsWidget::onEntropyChanged);
entropyLayout->addWidget(m_lblEntropy);
entropyLayout->addWidget(m_sliderEntropy);
layout->addLayout(entropyLayout);
QHBoxLayout* padsLayout = new QHBoxLayout();
m_padDsp = new XYPad("DSP", this);
m_padDsp->setFormatter([](float x, float y) {
int fft = std::pow(2, 13 + (int)(x * 4.0f + 0.5f));
int hop = 64 + y * (8192 - 64);
return QString("FFT: %1\nHop: %2").arg(fft).arg(hop);
});
m_padDsp->setValues(0.5f, 0.118f);
connect(m_padDsp, &XYPad::valuesChanged, this, &SettingsWidget::onDspPadChanged);
padsLayout->addWidget(m_padDsp);
m_padColor = new XYPad("Color", this);
m_padColor->setFormatter([](float x, float y) {
float hue = x * 2.0f;
float cont = 0.1f + y * 2.9f;
return QString("Hue: %1\nCont: %2").arg(hue, 0, 'f', 2).arg(cont, 0, 'f', 2);
});
m_padColor->setValues(0.45f, (1.0f - 0.1f) / 2.9f);
connect(m_padColor, &XYPad::valuesChanged, this, &SettingsWidget::onColorPadChanged);
padsLayout->addWidget(m_padColor);
layout->addLayout(padsLayout);
}
void SettingsWidget::setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, int bins, float brightness, float entropy) {
bool oldState = blockSignals(true);
m_checkGlass->setChecked(glass);
m_checkFocus->setChecked(focus);
m_checkTrails->setChecked(trails);
m_checkAlbumColors->setChecked(albumColors);
m_checkShadow->setChecked(shadow);
m_checkMirrored->setChecked(mirrored);
m_sliderBins->setValue(bins);
m_lblBins->setText(QString("Bins: %1").arg(bins));
m_brightness = brightness;
int brightVal = static_cast<int>(brightness * 100.0f);
m_sliderBrightness->setValue(brightVal);
m_lblBrightness->setText(QString("Bright: %1%").arg(brightVal));
m_entropy = entropy;
int entVal = static_cast<int>(entropy * 100.0f);
m_sliderEntropy->setValue(entVal);
m_lblEntropy->setText(QString("Entropy: %1").arg(entropy, 0, 'f', 1));
blockSignals(oldState);
emitParams();
emit binsChanged(bins);
}
void SettingsWidget::emitParams() {
emit paramsChanged(
m_checkGlass->isChecked(),
m_checkFocus->isChecked(),
m_checkTrails->isChecked(),
m_checkAlbumColors->isChecked(),
m_checkShadow->isChecked(),
m_checkMirrored->isChecked(),
m_hue,
m_contrast,
m_brightness,
m_entropy
);
}
void SettingsWidget::onDspPadChanged(float x, float y) {
int power = 13 + (int)(x * 4.0f + 0.5f);
m_fft = std::pow(2, power);
m_hop = 64 + y * (8192 - 64);
emit dspParamsChanged(m_fft, m_hop);
}
void SettingsWidget::onColorPadChanged(float x, float y) {
m_hue = x * 2.0f;
m_contrast = 0.1f + y * 2.9f;
emitParams();
}
void SettingsWidget::onBinsChanged(int val) {
m_bins = val;
m_lblBins->setText(QString("Bins: %1").arg(val));
emit binsChanged(val);
}
void SettingsWidget::onBrightnessChanged(int val) {
m_brightness = val / 100.0f;
m_lblBrightness->setText(QString("Bright: %1%").arg(val));
emitParams();
}
void SettingsWidget::onEntropyChanged(int val) {
m_entropy = val / 100.0f;
m_lblEntropy->setText(QString("Entropy: %1").arg(m_entropy, 0, 'f', 1));
emitParams();
}
PlayerPage::PlayerPage(QWidget* parent) : QWidget(parent) {
m_visualizer = new VisualizerWidget(this);
m_playback = new PlaybackWidget(this);
m_settings = new SettingsWidget();
m_overlay = new OverlayWidget(m_settings, this);
connect(m_playback, &PlaybackWidget::settingsClicked, this, &PlayerPage::toggleOverlay);
connect(m_settings, &SettingsWidget::closeClicked, this, &PlayerPage::closeOverlay);
connect(m_visualizer, &VisualizerWidget::tapDetected, this, &PlayerPage::toggleFullScreen);
}
void PlayerPage::setFullScreen(bool fs) {
m_playback->setVisible(!fs);
}
void PlayerPage::toggleOverlay() {
if (m_overlay->isVisible()) m_overlay->hide();
else {
m_overlay->raise();
m_overlay->show();
}
}
void PlayerPage::closeOverlay() {
m_overlay->hide();
}
void PlayerPage::resizeEvent(QResizeEvent* event) {
int w = event->size().width();
int h = event->size().height();
m_visualizer->setGeometry(0, 0, w, h);
int pbHeight = 120;
m_playback->setGeometry(0, h - pbHeight, w, pbHeight);
m_overlay->setGeometry(0, 0, w, h);
}

109
src/PlayerControls.h Normal file
View File

@ -0,0 +1,109 @@
// src/PlayerControls.h
#pragma once
#include <QWidget>
#include <QSlider>
#include <QPushButton>
#include <QCheckBox>
#include <QLabel>
#include "VisualizerWidget.h"
#include "CommonWidgets.h"
class PlaybackWidget : public QWidget {
Q_OBJECT
public:
PlaybackWidget(QWidget* parent = nullptr);
void setPlaying(bool playing);
void updateSeek(float pos);
signals:
void playClicked();
void pauseClicked();
void nextClicked();
void prevClicked();
void seekChanged(float pos);
void settingsClicked();
private slots:
void onSeekPressed();
void onSeekReleased();
void onPlayToggle();
private:
QSlider* m_seekSlider;
bool m_seeking = false;
bool m_isPlaying = false;
QPushButton* m_btnPlay;
};
class SettingsWidget : public QWidget {
Q_OBJECT
public:
SettingsWidget(QWidget* parent = nullptr);
bool isGlass() const { return m_checkGlass->isChecked(); }
bool isFocus() const { return m_checkFocus->isChecked(); }
bool isTrails() const { return m_checkTrails->isChecked(); }
bool isAlbumColors() const { return m_checkAlbumColors->isChecked(); }
bool isShadow() const { return m_checkShadow->isChecked(); }
bool isMirrored() const { return m_checkMirrored->isChecked(); }
int getBins() const { return m_sliderBins->value(); }
float getBrightness() const { return m_brightness; }
float getEntropy() const { return m_entropy; }
void setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, int bins, float brightness, float entropy);
signals:
void paramsChanged(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, float hue, float contrast, float brightness, float entropy);
void dspParamsChanged(int fft, int hop);
void binsChanged(int n);
void closeClicked();
private slots:
void emitParams();
void onDspPadChanged(float x, float y);
void onColorPadChanged(float x, float y);
void onBinsChanged(int val);
void onBrightnessChanged(int val);
void onEntropyChanged(int val);
private:
QCheckBox* m_checkGlass;
QCheckBox* m_checkFocus;
QCheckBox* m_checkTrails;
QCheckBox* m_checkAlbumColors;
QCheckBox* m_checkShadow;
QCheckBox* m_checkMirrored;
XYPad* m_padDsp;
XYPad* m_padColor;
QSlider* m_sliderBins;
QLabel* m_lblBins;
QSlider* m_sliderBrightness;
QLabel* m_lblBrightness;
QSlider* m_sliderEntropy;
QLabel* m_lblEntropy;
float m_hue = 0.9f;
float m_contrast = 1.0f;
float m_brightness = 1.0f;
float m_entropy = 1.0f;
int m_fft = 32768;
int m_hop = 1024;
int m_bins = 26;
};
class PlayerPage : public QWidget {
Q_OBJECT
public:
PlayerPage(QWidget* parent = nullptr);
VisualizerWidget* visualizer() { return m_visualizer; }
PlaybackWidget* playback() { return m_playback; }
SettingsWidget* settings() { return m_settings; }
void setFullScreen(bool fs);
signals:
void toggleFullScreen();
protected:
void resizeEvent(QResizeEvent* event) override;
private slots:
void toggleOverlay();
void closeOverlay();
private:
VisualizerWidget* m_visualizer;
PlaybackWidget* m_playback;
SettingsWidget* m_settings;
OverlayWidget* m_overlay;
};

154
src/Processor.cpp Normal file
View File

@ -0,0 +1,154 @@
// src/Processor.cpp
#include "Processor.h"
#include <cmath>
#include <algorithm>
#include <cstring>
const double PI = 3.14159265358979323846;
Processor::Processor(int frameSize, int sampleRate)
: m_frameSize(0), m_sampleRate(sampleRate),
m_in(nullptr), m_out(nullptr), m_plan(nullptr)
{
setFrameSize(frameSize);
setNumBins(26);
}
Processor::~Processor() {
if (m_plan) fftwf_destroy_plan(m_plan);
if (m_in) fftwf_free(m_in);
if (m_out) fftwf_free(m_out);
}
void Processor::setNumBins(int n) {
m_customBins.clear();
m_freqsConst.clear();
m_history.clear(); // Clear history on bin change to avoid size mismatch
float minFreq = 40.0f;
float maxFreq = 11000.0f;
for (int i = 0; i <= n; ++i) {
float f = minFreq * std::pow(maxFreq / minFreq, (float)i / n);
m_customBins.push_back(f);
}
m_freqsConst.push_back(10.0f);
for (size_t i = 0; i < m_customBins.size() - 1; ++i) {
m_freqsConst.push_back((m_customBins[i] + m_customBins[i+1]) / 2.0f);
}
}
void Processor::setFrameSize(int size) {
if (m_frameSize == size) return;
if (m_plan) fftwf_destroy_plan(m_plan);
if (m_in) fftwf_free(m_in);
if (m_out) fftwf_free(m_out);
m_frameSize = size;
m_in = (float*)fftwf_malloc(sizeof(float) * m_frameSize);
m_out = (fftwf_complex*)fftwf_malloc(sizeof(fftwf_complex) * (m_frameSize / 2 + 1));
m_plan = fftwf_plan_dft_r2c_1d(m_frameSize, m_in, m_out, FFTW_ESTIMATE);
m_window.resize(m_frameSize);
// Blackman-Harris window for excellent side-lobe suppression (reduces spectral leakage/noise)
for (int i = 0; i < m_frameSize; ++i) {
float a0 = 0.35875f;
float a1 = 0.48829f;
float a2 = 0.14128f;
float a3 = 0.01168f;
m_window[i] = a0 - a1 * std::cos(2.0f * PI * i / (m_frameSize - 1))
+ a2 * std::cos(4.0f * PI * i / (m_frameSize - 1))
- a3 * std::cos(6.0f * PI * i / (m_frameSize - 1));
}
m_buffer.assign(m_frameSize, 0.0f);
m_history.clear();
}
void Processor::pushData(const std::vector<float>& data) {
if (data.size() == m_frameSize) {
std::copy(data.begin(), data.end(), m_buffer.begin());
} else if (data.size() < m_frameSize) {
std::copy(m_buffer.begin() + data.size(), m_buffer.end(), m_buffer.begin());
std::copy(data.begin(), data.end(), m_buffer.end() - data.size());
}
}
float Processor::getInterpolatedDb(const std::vector<float>& freqs, const std::vector<float>& db, float targetFreq) {
auto it = std::lower_bound(freqs.begin(), freqs.end(), targetFreq);
if (it == freqs.begin()) return db[0];
if (it == freqs.end()) return db.back();
size_t idxUpper = std::distance(freqs.begin(), it);
size_t idxLower = idxUpper - 1;
float f0 = freqs[idxLower];
float f1 = freqs[idxUpper];
float d0 = db[idxLower];
float d1 = db[idxUpper];
float t = (targetFreq - f0) / (f1 - f0);
return d0 + t * (d1 - d0);
}
Processor::Spectrum Processor::getSpectrum() {
// 1. Windowing
for (int i = 0; i < m_frameSize; ++i) {
m_in[i] = m_buffer[i] * m_window[i];
}
// 2. FFT
fftwf_execute(m_plan);
// 3. Compute Magnitude (dB)
int bins = m_frameSize / 2 + 1;
std::vector<float> freqsFull(bins);
std::vector<float> dbFull(bins);
for (int i = 0; i < bins; ++i) {
float re = m_out[i][0];
float im = m_out[i][1];
float mag = 2.0f * std::sqrt(re*re + im*im) / m_frameSize;
dbFull[i] = 20.0f * std::log10(std::max(mag, 1e-12f));
freqsFull[i] = i * (float)m_sampleRate / m_frameSize;
}
// 4. Map to Custom Bins (Log Scale)
std::vector<float> currentDb(m_freqsConst.size());
for (size_t i = 0; i < m_freqsConst.size(); ++i) {
float val = getInterpolatedDb(freqsFull, dbFull, m_freqsConst[i]);
if (val < -100.0f) val = -100.0f;
currentDb[i] = val;
}
// 5. Moving Average Filter
// CRITICAL CHANGE: Reduced smoothing to 1 (effectively off) to allow
// the VisualizerWidget to detect sharp transients (Flux) accurately.
// The Visualizer will handle its own aesthetic smoothing.
m_smoothingLength = 1;
m_history.push_back(currentDb);
if (m_history.size() > m_smoothingLength) {
m_history.pop_front();
}
std::vector<float> averagedDb(currentDb.size(), 0.0f);
if (!m_history.empty()) {
for (const auto& vec : m_history) {
for (size_t i = 0; i < vec.size(); ++i) {
averagedDb[i] += vec[i];
}
}
float factor = 1.0f / m_history.size();
for (float& v : averagedDb) {
v *= factor;
}
}
return {m_freqsConst, averagedDb};
}

45
src/Processor.h Normal file
View File

@ -0,0 +1,45 @@
// src/Processor.h
#pragma once
#include <vector>
#include <deque>
#include <fftw3.h>
class Processor {
public:
Processor(int frameSize, int sampleRate);
~Processor();
void setFrameSize(int size);
void setNumBins(int n);
void pushData(const std::vector<float>& data);
struct Spectrum {
std::vector<float> freqs;
std::vector<float> db;
};
Spectrum getSpectrum();
private:
int m_frameSize;
int m_sampleRate;
float* m_in;
fftwf_complex* m_out;
fftwf_plan m_plan;
std::vector<float> m_window;
// Buffer for the current audio frame
std::vector<float> m_buffer;
// Mapping & Smoothing
std::vector<float> m_customBins;
std::vector<float> m_freqsConst;
// Moving Average History
std::deque<std::vector<float>> m_history;
size_t m_smoothingLength = 3; // Number of frames to average
float getInterpolatedDb(const std::vector<float>& freqs, const std::vector<float>& db, float targetFreq);
};

413
src/Utils.cpp Normal file
View File

@ -0,0 +1,413 @@
// src/Utils.cpp
#include "Utils.h"
#include <QProcess>
#include <QFileInfo>
#include <QDir>
#include <QDirIterator>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDebug>
#include <cmath>
#ifdef Q_OS_ANDROID
#include <QCoreApplication>
#include <QJniObject>
#include <QJniEnvironment>
#include <QtCore/qnativeinterface.h>
// Helper to scan Android Content URIs (Tree)
void scanAndroidTree(const QJniObject& context, const QJniObject& treeUri, const QJniObject& parentDocId, QStringList& results, bool recursive) {
QJniEnvironment env;
QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;");
if (env.checkAndClearExceptions()) return;
QJniObject childrenUri = QJniObject::callStaticObjectMethod(
"android/provider/DocumentsContract", "buildChildDocumentsUriUsingTree",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;",
treeUri.object(), parentDocId.object()
);
if (env.checkAndClearExceptions()) return;
QJniObject cursor = contentResolver.callObjectMethod(
"query",
"(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;",
childrenUri.object(),
nullptr, nullptr, nullptr, nullptr
);
if (env.checkAndClearExceptions()) {
qWarning() << "JNI: SecurityException or other error querying children of" << parentDocId.toString();
return;
}
if (!cursor.isValid()) return;
jint colDocId = cursor.callMethod<jint>("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("document_id").object<jstring>());
jint colMime = cursor.callMethod<jint>("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("mime_type").object<jstring>());
while (cursor.callMethod<jboolean>("moveToNext")) {
if (env.checkAndClearExceptions()) break;
QString mime = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colMime).toString();
QString docId = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colDocId).toString();
if (mime == "vnd.android.document/directory") {
if (recursive) {
scanAndroidTree(context, treeUri, QJniObject::fromString(docId), results, true);
}
} else if (mime.startsWith("audio/") || mime == "application/ogg" || mime == "audio/x-wav") {
QJniObject fileUri = QJniObject::callStaticObjectMethod(
"android/provider/DocumentsContract", "buildDocumentUriUsingTree",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;",
treeUri.object(), QJniObject::fromString(docId).object<jstring>()
);
if (fileUri.isValid()) {
results << fileUri.toString();
}
}
}
cursor.callMethod<void>("close");
env.checkAndClearExceptions();
}
Utils::Metadata getMetadataAndroid(const QString &path) {
Utils::Metadata meta;
meta.title = QFileInfo(path).fileName(); // Fallback
QJniObject retriever("android/media/MediaMetadataRetriever");
if (!retriever.isValid()) return meta;
QJniObject context = QNativeInterface::QAndroidApplication::context();
QJniEnvironment env;
try {
if (path.startsWith("content://")) {
QJniObject uri = QJniObject::callStaticObjectMethod(
"android/net/Uri",
"parse",
"(Ljava/lang/String;)Landroid/net/Uri;",
QJniObject::fromString(path).object<jstring>()
);
retriever.callMethod<void>(
"setDataSource",
"(Landroid/content/Context;Landroid/net/Uri;)V",
context.object(),
uri.object()
);
} else {
retriever.callMethod<void>(
"setDataSource",
"(Ljava/lang/String;)V",
QJniObject::fromString(path).object<jstring>()
);
}
} catch (...) {
qWarning() << "JNI: Failed to set data source for" << path;
env.checkAndClearExceptions();
return meta;
}
if (env.checkAndClearExceptions()) return meta;
auto extract = [&](int key) -> QString {
QJniObject val = retriever.callObjectMethod(
"extractMetadata",
"(I)Ljava/lang/String;",
key
);
if (env.checkAndClearExceptions()) return QString();
return val.isValid() ? val.toString() : QString();
};
// METADATA_KEY_TITLE = 7
QString t = extract(7);
if (!t.isEmpty()) meta.title = t;
// METADATA_KEY_ARTIST = 2
QString a = extract(2);
if (!a.isEmpty()) meta.artist = a;
// METADATA_KEY_ALBUM = 1
QString al = extract(1);
if (!al.isEmpty()) meta.album = al;
// METADATA_KEY_CD_TRACK_NUMBER = 0
QString tr = extract(0);
if (!tr.isEmpty()) meta.trackNumber = tr.split('/').first().toInt();
QJniObject artObj = retriever.callObjectMethod("getEmbeddedPicture", "()[B");
if (!env.checkAndClearExceptions() && artObj.isValid()) {
jbyteArray jBa = artObj.object<jbyteArray>();
if (jBa) {
int len = env->GetArrayLength(jBa);
QByteArray ba;
ba.resize(len);
env->GetByteArrayRegion(jBa, 0, len, reinterpret_cast<jbyte*>(ba.data()));
meta.art.loadFromData(ba);
}
}
retriever.callMethod<void>("release");
env.checkAndClearExceptions();
return meta;
}
#endif
#ifdef Q_OS_IOS
#include <UIKit/UIKit.h>
#include <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#include <AVFoundation/AVFoundation.h>
// Native iOS Metadata Extraction
Utils::Metadata getMetadataIOS(const QString &path) {
Utils::Metadata meta;
meta.title = QFileInfo(path).fileName();
NSURL *url = [NSURL fileURLWithPath:path.toNSString()];
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
// Note: In a real app, we should load values asynchronously, but for this simple player
// we access commonMetadata directly which might block slightly or return cached data.
// For local files, it's usually fast enough.
NSArray<AVMetadataItem *> *metadata = [asset commonMetadata];
for (AVMetadataItem *item in metadata) {
if (item.value == nil) continue;
if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle]) {
meta.title = QString::fromNSString((NSString *)item.value);
} else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtist]) {
meta.artist = QString::fromNSString((NSString *)item.value);
} else if ([item.commonKey isEqualToString:AVMetadataCommonKeyAlbumName]) {
meta.album = QString::fromNSString((NSString *)item.value);
} else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtwork]) {
if ([item.value isKindOfClass:[NSData class]]) {
NSData *data = (NSData *)item.value;
meta.art.loadFromData(QByteArray::fromRawData((const char *)data.bytes, data.length));
}
}
}
return meta;
}
// Native iOS File Picker Delegate
@interface FilePickerDelegate : NSObject <UIDocumentPickerDelegate>
@property (nonatomic, assign) std::function<void(QString)> callback;
@property (nonatomic, assign) bool isFolder;
@end
@implementation FilePickerDelegate
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
if (urls.count > 0) {
NSURL *url = urls.firstObject;
// If it's a folder, we must start accessing the security scoped resource.
// We intentionally do NOT stop accessing it to ensure the app can read it later.
// The OS will clean up when the app terminates.
if (self.isFolder) {
[url startAccessingSecurityScopedResource];
}
if (self.callback) {
self.callback(QString::fromNSString(url.absoluteString));
}
}
}
- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {
// Do nothing
}
@end
static FilePickerDelegate* g_pickerDelegate = nil;
namespace Utils {
void openIosPicker(bool folder, std::function<void(QString)> callback) {
if (!g_pickerDelegate) {
g_pickerDelegate = [[FilePickerDelegate alloc] init];
}
g_pickerDelegate.callback = callback;
g_pickerDelegate.isFolder = folder;
UIDocumentPickerViewController *picker = nil;
// Use modern API (iOS 14+)
if (folder) {
// Open folder in place (asCopy: NO)
picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO];
} else {
// Import file (asCopy: YES) - copies to app sandbox
picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeAudio] asCopy:YES];
}
picker.delegate = g_pickerDelegate;
picker.allowsMultipleSelection = NO;
// Find Root VC (Scene-aware)
UIViewController *root = nil;
for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
if (scene.activationState == UISceneActivationStateForegroundActive && [scene isKindOfClass:[UIWindowScene class]]) {
root = ((UIWindowScene *)scene).windows.firstObject.rootViewController;
if (root) break;
}
}
// Fallback
if (!root) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
root = [UIApplication sharedApplication].keyWindow.rootViewController;
#pragma clang diagnostic pop
}
if (root) {
[root presentViewController:picker animated:YES completion:nil];
} else {
qWarning() << "iOS: Could not find root view controller to present picker.";
}
}
}
#endif
namespace Utils {
bool checkDependencies() {
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
return true;
#else
QProcess p;
p.start("ffmpeg", {"-version"});
return p.waitForFinished() && p.exitCode() == 0;
#endif
}
QString convertToWav(const QString &inputPath) {
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
return inputPath;
#else
QString wavPath = inputPath + ".temp.wav";
if (QFile::exists(wavPath)) QFile::remove(wavPath);
QProcess p;
p.start("ffmpeg", {"-y", "-i", inputPath, "-vn", "-loglevel", "error", "-f", "wav", wavPath});
if (p.waitForFinished() && p.exitCode() == 0) {
return wavPath;
}
return QString();
#endif
}
Metadata getMetadata(const QString &filePath) {
#ifdef Q_OS_ANDROID
return getMetadataAndroid(filePath);
#elif defined(Q_OS_IOS)
return getMetadataIOS(filePath);
#else
Metadata meta;
meta.title = QFileInfo(filePath).fileName();
QProcess p;
p.start("ffprobe", {"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath});
if (p.waitForFinished()) {
QJsonDocument doc = QJsonDocument::fromJson(p.readAllStandardOutput());
QJsonObject root = doc.object();
QJsonObject format = root["format"].toObject();
QJsonObject tags = format["tags"].toObject();
if (tags.contains("title")) meta.title = tags["title"].toString();
if (tags.contains("artist")) meta.artist = tags["artist"].toString();
if (tags.contains("album")) meta.album = tags["album"].toString();
if (tags.contains("track")) {
meta.trackNumber = tags["track"].toString().split('/').first().toInt();
}
}
QProcess pArt;
pArt.start("ffmpeg", {"-y", "-i", filePath, "-an", "-vcodec", "png", "-f", "image2pipe", "-"});
if (pArt.waitForFinished()) {
QByteArray data = pArt.readAllStandardOutput();
if (!data.isEmpty()) meta.art.loadFromData(data);
}
return meta;
#endif
}
QVector<QColor> extractAlbumColors(const QImage &art, int numBins) {
QVector<QColor> palette(numBins, QColor(127, 127, 127));
if (art.isNull()) return palette;
QImage scaled = art.scaled(numBins, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
for (int x = 0; x < numBins; ++x) {
float maxVibrancy = -1.0f;
QColor bestColor = QColor(127, 127, 127);
for (int y = 0; y < scaled.height(); ++y) {
QColor c = scaled.pixelColor(x, y);
float s = c.hsvSaturationF();
float v = c.valueF();
if (s * v > maxVibrancy) {
maxVibrancy = s * v;
bestColor = c;
}
}
palette[x] = bestColor;
}
return palette;
}
QStringList scanDirectory(const QString &path, bool recursive) {
#ifdef Q_OS_ANDROID
if (path.startsWith("content://")) {
QStringList results;
QJniEnvironment env;
QJniObject uri = QJniObject::callStaticObjectMethod(
"android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;",
QJniObject::fromString(path).object<jstring>()
);
if (!uri.isValid()) return results;
QJniObject context = QNativeInterface::QAndroidApplication::context();
QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;");
// Try to take persistable permission (best effort)
contentResolver.callMethod<void>(
"takePersistableUriPermission",
"(Landroid/net/Uri;I)V",
uri.object(),
1
);
if (env.checkAndClearExceptions()) {
qWarning() << "JNI: Failed to take persistable URI permission for" << path;
}
QJniObject docId = QJniObject::callStaticObjectMethod(
"android/provider/DocumentsContract", "getTreeDocumentId",
"(Landroid/net/Uri;)Ljava/lang/String;", uri.object()
);
if (env.checkAndClearExceptions() || !docId.isValid()) {
qWarning() << "JNI: Failed to get Tree Document ID for" << path;
return results;
}
scanAndroidTree(context, uri, docId, results, recursive);
return results;
}
#endif
QStringList files;
QStringList filters = {"*.mp3", "*.m4a", "*.wav", "*.flac", "*.ogg", "*.aiff"};
QDirIterator::IteratorFlag flag = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags;
QDirIterator it(path, filters, QDir::Files, flag);
while (it.hasNext()) files << it.next();
return files;
}
void requestAndroidPermissions(std::function<void(bool)> callback) {
callback(true);
}
}

32
src/Utils.h Normal file
View File

@ -0,0 +1,32 @@
// src/Utils.h
#pragma once
#include <QString>
#include <QImage>
#include <QVector>
#include <QColor>
#include <QStringList>
#include <functional>
namespace Utils {
bool checkDependencies();
QString convertToWav(const QString &inputPath);
struct Metadata {
QString title;
QString artist;
QString album;
int trackNumber = 0;
QImage art;
};
Metadata getMetadata(const QString &filePath);
QVector<QColor> extractAlbumColors(const QImage &art, int numBins);
QStringList scanDirectory(const QString &path, bool recursive);
void requestAndroidPermissions(std::function<void(bool)> callback);
#ifdef Q_OS_IOS
void openIosPicker(bool folder, std::function<void(QString)> callback);
#endif
}

358
src/VisualizerWidget.cpp Normal file
View File

@ -0,0 +1,358 @@
// src/VisualizerWidget.cpp
#include "VisualizerWidget.h"
#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
#include <QApplication>
#include <QLinearGradient>
#include <cmath>
#include <algorithm>
#include <numeric>
VisualizerWidget::VisualizerWidget(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_OpaquePaintEvent);
setNumBins(26);
}
void VisualizerWidget::mouseReleaseEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) {
emit tapDetected();
}
QWidget::mouseReleaseEvent(event);
}
void VisualizerWidget::setNumBins(int n) {
m_customBins.clear();
float minFreq = 40.0f;
float maxFreq = 11000.0f;
for (int i = 0; i <= n; ++i) {
float f = minFreq * std::pow(maxFreq / minFreq, (float)i / n);
m_customBins.push_back(f);
}
m_channels.clear();
}
void VisualizerWidget::setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, float hue, float contrast, float brightness, float entropy) {
m_glass = glass;
m_focus = focus;
m_trailsEnabled = trails;
m_useAlbumColors = albumColors;
m_shadowMode = shadow;
m_mirrored = mirrored;
m_hueFactor = hue;
m_contrast = contrast;
m_brightness = brightness;
m_entropyStrength = entropy;
update();
}
void VisualizerWidget::setAlbumPalette(const std::vector<QColor>& palette) {
m_albumPalette.clear();
int targetLen = static_cast<int>(m_customBins.size()) - 1;
if (palette.empty()) return;
for (int i = 0; i < targetLen; ++i) {
int idx = (i * static_cast<int>(palette.size() - 1)) / (targetLen - 1);
m_albumPalette.push_back(palette[idx]);
}
}
float VisualizerWidget::getX(float freq) {
float logMin = std::log10(20.0f);
float logMax = std::log10(20000.0f);
if (freq <= 0) return 0;
return (std::log10(std::max(freq, 1e-9f)) - logMin) / (logMax - logMin);
}
QColor VisualizerWidget::applyModifiers(QColor c) {
float h, s, l;
c.getHslF(&h, &s, &l);
s = std::clamp(s * m_hueFactor, 0.0f, 1.0f);
float v = c.valueF();
v = std::clamp(v * (m_contrast * 0.5f + 0.5f), 0.0f, 1.0f);
return QColor::fromHsvF(c.hsvHueF(), s, v);
}
float VisualizerWidget::calculateEntropy(const std::deque<float>& history) {
if (history.size() < 2) return 0.0f;
float sum = std::accumulate(history.begin(), history.end(), 0.0f);
float mean = sum / history.size();
float sqSum = 0.0f;
for (float v : history) sqSum += (v - mean) * (v - mean);
// Normalize: 10dB std dev is considered "Max Chaos"
return std::clamp(std::sqrt(sqSum / history.size()) / 10.0f, 0.0f, 1.0f);
}
void VisualizerWidget::updateData(const std::vector<AudioEngine::FrameData>& data) {
if (QApplication::applicationState() != Qt::ApplicationActive || !isVisible()) return;
m_data = data;
if (m_channels.size() != data.size()) m_channels.resize(data.size());
for (size_t ch = 0; ch < data.size(); ++ch) {
const auto& db = data[ch].db;
size_t numBins = db.size();
auto& bins = m_channels[ch].bins;
if (bins.size() != numBins) bins.resize(numBins);
for (size_t i = 0; i < numBins; ++i) {
auto& bin = bins[i];
float rawVal = db[i];
// 1. Update History & Calculate Entropy
bin.history.push_back(rawVal);
if (bin.history.size() > 15) bin.history.pop_front();
float entropy = calculateEntropy(bin.history);
float order = 1.0f - entropy;
// 2. Calculate Responsiveness (The Physics Core)
float p = 3.0f * m_entropyStrength;
float responsiveness = 0.02f + (0.98f * std::pow(order, p));
// 3. Update Visual Bar Height (Slew Limiting)
bin.visualDb = (bin.visualDb * (1.0f - responsiveness)) + (rawVal * responsiveness);
// 4. Trail Physics
bin.trailLife = std::max(bin.trailLife - 0.02f, 0.0f);
float flux = rawVal - bin.lastRawDb;
bin.lastRawDb = rawVal;
if (flux > 0) {
// Impact Multiplier: Boosts the "Kick" of ordered signals
float impactMultiplier = 1.0f + (3.0f * order * m_entropyStrength);
float jumpTarget = bin.visualDb + (flux * impactMultiplier);
if (jumpTarget > bin.trailDb) {
bin.trailDb = jumpTarget;
bin.trailLife = 1.0f;
// Thickness based on order
bin.trailThickness = 1.0f + (order * 4.0f);
}
}
if (bin.trailDb < bin.visualDb) bin.trailDb = bin.visualDb;
}
}
update();
}
void VisualizerWidget::paintEvent(QPaintEvent*) {
if (QApplication::applicationState() != Qt::ApplicationActive || !isVisible()) return;
QPainter p(this);
p.fillRect(rect(), Qt::black);
p.setRenderHint(QPainter::Antialiasing);
if (m_data.empty()) return;
int w = width();
int h = height();
if (m_mirrored) {
int hw = w / 2;
int hh = h / 2;
// Top-Left
p.save();
drawContent(p, hw, hh);
p.restore();
// Top-Right (Mirror X)
p.save();
p.translate(w, 0);
p.scale(-1, 1);
drawContent(p, hw, hh);
p.restore();
// Bottom-Left (Mirror Y)
p.save();
p.translate(0, h);
p.scale(1, -1);
drawContent(p, hw, hh);
p.restore();
// Bottom-Right (Mirror XY)
p.save();
p.translate(w, h);
p.scale(-1, -1);
drawContent(p, hw, hh);
p.restore();
} else {
drawContent(p, w, h);
}
}
void VisualizerWidget::drawContent(QPainter& p, int w, int h) {
auto getScreenY = [&](float normY) {
float screenH = normY * h;
return m_shadowMode ? screenH : h - screenH;
};
// --- Unified Glass Color Logic ---
QColor unifiedColor = Qt::white;
if (m_glass && !m_data.empty()) {
size_t midIdx = m_data[0].freqs.size() / 2;
float frameMidFreq = (midIdx < m_data[0].freqs.size()) ? m_data[0].freqs[midIdx] : 1000.0f;
float sumDb = 0;
for(float v : m_data[0].db) sumDb += v;
float frameMeanDb = m_data[0].db.empty() ? -80.0f : sumDb / m_data[0].db.size();
float logMin = std::log10(20.0f);
float logMax = std::log10(20000.0f);
float frameFreqNorm = (std::log10(std::max(frameMidFreq, 1e-9f)) - logMin) / (logMax - logMin);
float frameAmpNorm = std::clamp((frameMeanDb + 80.0f) / 80.0f, 0.0f, 1.0f);
float frameAmpWeight = 1.0f / std::pow(frameFreqNorm + 1e-4f, 5.0f);
frameAmpWeight = std::clamp(frameAmpWeight * 2.0f, 0.5f, 6.0f);
float frameHue = std::fmod(frameFreqNorm + frameAmpNorm * frameAmpWeight * m_hueFactor, 1.0f);
if (m_mirrored) frameHue = 1.0f - frameHue; // Invert hue for mirrored mode
if (frameHue < 0) frameHue += 1.0f;
unifiedColor = QColor::fromHsvF(frameHue, 1.0, 1.0);
}
// --- Draw Trails First (Behind) ---
if (m_trailsEnabled) {
for (size_t ch = 0; ch < m_channels.size(); ++ch) {
const auto& freqs = m_data[ch].freqs;
const auto& bins = m_channels[ch].bins;
if (bins.empty()) continue;
float xOffset = (ch == 1 && m_data.size() > 1) ? 1.002f : 1.0f;
for (size_t i = 0; i < freqs.size() - 1; ++i) {
const auto& b = bins[i];
const auto& bNext = bins[i+1];
if (b.trailLife < 0.01f) continue;
float saturation = 1.0f - std::sqrt(b.trailLife);
float alpha = b.trailLife * 0.6f;
QColor c;
if (m_useAlbumColors && !m_albumPalette.empty()) {
int palIdx = i;
if (m_mirrored) palIdx = m_albumPalette.size() - 1 - i;
palIdx = std::clamp(palIdx, 0, (int)m_albumPalette.size() - 1);
QColor base = m_albumPalette[palIdx];
base = applyModifiers(base);
float h_val, s, v, a;
base.getHsvF(&h_val, &s, &v, &a);
c = QColor::fromHsvF(h_val, s * saturation, v, alpha);
} else {
float hue = (float)i / freqs.size();
if (m_mirrored) hue = 1.0f - hue;
c = QColor::fromHsvF(hue, saturation, 1.0f, alpha);
}
float x1 = getX(freqs[i] * xOffset) * w;
float x2 = getX(freqs[i+1] * xOffset) * w;
float y1 = getScreenY((b.trailDb + 80.0f) / 80.0f);
float y2 = getScreenY((bNext.trailDb + 80.0f) / 80.0f);
p.setPen(QPen(c, b.trailThickness));
p.drawLine(QPointF(x1, y1), QPointF(x2, y2));
}
}
}
// --- Draw Bars (Trapezoids) ---
for (size_t ch = 0; ch < m_channels.size(); ++ch) {
const auto& freqs = m_data[ch].freqs;
const auto& bins = m_channels[ch].bins;
if (bins.empty()) continue;
float xOffset = (ch == 1 && m_data.size() > 1) ? 1.005f : 1.0f;
for (size_t i = 0; i < freqs.size() - 1; ++i) {
const auto& b = bins[i];
const auto& bNext = bins[i+1];
QColor binColor;
if (m_useAlbumColors && !m_albumPalette.empty()) {
int palIdx = i;
if (m_mirrored) palIdx = m_albumPalette.size() - 1 - i;
palIdx = std::clamp(palIdx, 0, (int)m_albumPalette.size() - 1);
binColor = m_albumPalette[palIdx];
binColor = applyModifiers(binColor);
} else {
float hue = (float)i / (freqs.size() - 1);
if (m_mirrored) hue = 1.0f - hue;
binColor = QColor::fromHsvF(hue, 1.0f, 1.0f);
}
// Use visualDb (Physics processed)
float meanDb = (b.visualDb + bNext.visualDb) / 2.0f;
float intensity = std::clamp((meanDb + 80.0f) / 80.0f, 0.0f, 1.0f);
float h_val, s, v, a;
binColor.getHsvF(&h_val, &s, &v, &a);
float brightness = (0.4f + 0.6f * intensity) * m_brightness;
v = std::clamp(v * brightness, 0.0f, 1.0f);
QColor dynamicBinColor = QColor::fromHsvF(h_val, s, v);
QColor fillColor, lineColor;
if (m_glass) {
float uh, us, uv, ua;
unifiedColor.getHsvF(&uh, &us, &uv, &ua);
float uBrightness = (0.4f + 0.6f * intensity) * m_brightness;
fillColor = QColor::fromHsvF(uh, us, std::clamp(uv * uBrightness, 0.0f, 1.0f));
lineColor = dynamicBinColor;
} else {
fillColor = dynamicBinColor;
lineColor = dynamicBinColor;
}
float alpha = 0.4f + (intensity - 0.5f) * m_contrast;
alpha = std::clamp(alpha, 0.0f, 1.0f);
fillColor.setAlphaF(alpha);
lineColor.setAlphaF(0.9f);
if (ch == 1 && m_data.size() > 1) {
int r,g,b_val,a_val;
fillColor.getRgb(&r,&g,&b_val,&a_val);
fillColor.setRgb(std::max(0, r-40), std::max(0, g-40), std::min(255, b_val+40), a_val);
lineColor.getRgb(&r,&g,&b_val,&a_val);
lineColor.setRgb(std::max(0, r-40), std::max(0, g-40), std::min(255, b_val+40), a_val);
}
float x1 = getX(freqs[i] * xOffset) * w;
float x2 = getX(freqs[i+1] * xOffset) * w;
float barH1 = std::clamp((b.visualDb + 80.0f) / 80.0f * h, 0.0f, (float)h);
float barH2 = std::clamp((bNext.visualDb + 80.0f) / 80.0f * h, 0.0f, (float)h);
float y1, y2, anchorY;
if (m_shadowMode) {
anchorY = 0;
y1 = barH1;
y2 = barH2;
} else {
anchorY = h;
y1 = h - barH1;
y2 = h - barH2;
}
QPainterPath fillPath;
fillPath.moveTo(x1, anchorY);
fillPath.lineTo(x1, y1);
fillPath.lineTo(x2, y2);
fillPath.lineTo(x2, anchorY);
fillPath.closeSubpath();
p.fillPath(fillPath, fillColor);
p.setPen(QPen(lineColor, 1));
p.drawLine(QPointF(x1, anchorY), QPointF(x1, y1));
if (i == freqs.size() - 2) {
p.drawLine(QPointF(x2, anchorY), QPointF(x2, y2));
}
}
}
}

64
src/VisualizerWidget.h Normal file
View File

@ -0,0 +1,64 @@
// src/VisualizerWidget.h
#pragma once
#include <QWidget>
#include <QMouseEvent>
#include <vector>
#include <deque>
#include <QPointF>
#include "AudioEngine.h"
class VisualizerWidget : public QWidget {
Q_OBJECT
public:
VisualizerWidget(QWidget* parent = nullptr);
void updateData(const std::vector<AudioEngine::FrameData>& data);
void setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, float hue, float contrast, float brightness, float entropy);
void setAlbumPalette(const std::vector<QColor>& palette);
void setNumBins(int n);
signals:
void tapDetected();
protected:
void paintEvent(QPaintEvent* e) override;
void mouseReleaseEvent(QMouseEvent* event) override;
private:
void drawContent(QPainter& p, int w, int h);
struct BinState {
std::deque<float> history; // For entropy calculation
float visualDb = -100.0f; // The dampened/processed value for display
float lastRawDb = -100.0f; // To calculate flux
// Trail Physics
float trailDb = -100.0f;
float trailLife = 0.0f;
float trailThickness = 2.0f;
};
struct ChannelState {
std::vector<BinState> bins;
};
std::vector<AudioEngine::FrameData> m_data;
std::vector<ChannelState> m_channels;
std::vector<QColor> m_albumPalette;
std::vector<float> m_customBins;
bool m_glass = true;
bool m_focus = false;
bool m_trailsEnabled = false;
bool m_useAlbumColors = false;
bool m_shadowMode = false;
bool m_mirrored = false;
float m_hueFactor = 0.9f;
float m_contrast = 1.0f;
float m_brightness = 1.0f;
float m_entropyStrength = 1.0f;
float getX(float freq);
QColor applyModifiers(QColor c);
float calculateEntropy(const std::deque<float>& history);
};

61
src/main.cpp Normal file
View File

@ -0,0 +1,61 @@
#include <QApplication>
#include <QCommandLineParser>
#include "MainWindow.h"
#include "AudioEngine.h"
#ifdef Q_OS_ANDROID
#include <android/log.h>
void androidMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
android_LogPriority priority = ANDROID_LOG_DEBUG;
switch (type) {
case QtDebugMsg: priority = ANDROID_LOG_DEBUG; break;
case QtInfoMsg: priority = ANDROID_LOG_INFO; break;
case QtWarningMsg: priority = ANDROID_LOG_WARN; break;
case QtCriticalMsg: priority = ANDROID_LOG_ERROR; break;
case QtFatalMsg: priority = ANDROID_LOG_FATAL; break;
}
__android_log_print(priority, "YrCrystals", "%s", qPrintable(msg));
}
#endif
int main(int argc, char *argv[]) {
#ifdef Q_OS_ANDROID
qInstallMessageHandler(androidMessageHandler);
#endif
QApplication app(argc, argv);
QApplication::setApplicationName("Yr Crystals");
QApplication::setApplicationVersion("1.0");
qRegisterMetaType<std::vector<AudioEngine::FrameData>>("std::vector<AudioEngine::FrameData>");
QPalette p = app.palette();
p.setColor(QPalette::Window, Qt::black);
p.setColor(QPalette::WindowText, Qt::white);
app.setPalette(p);
QCommandLineParser parser;
parser.setApplicationDescription("Yr Crystals Player");
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption recursiveOption(QStringList() << "r" << "recursive", "Recursively scan directory.");
parser.addOption(recursiveOption);
parser.addPositionalArgument("source", "Audio file or directory to play.");
parser.process(app);
QStringList args = parser.positionalArguments();
QString inputPath;
if (!args.isEmpty()) inputPath = args.first();
bool recursive = parser.isSet(recursiveOption);
MainWindow w;
w.show();
if (!inputPath.isEmpty()) {
w.loadPath(inputPath, recursive);
}
return app.exec();
}