Yr Crystals
This commit is contained in:
commit
2bc03b0143
|
|
@ -0,0 +1,7 @@
|
||||||
|
build/
|
||||||
|
build_android/
|
||||||
|
build_ios/
|
||||||
|
build_macos/
|
||||||
|
build_windows/
|
||||||
|
icons
|
||||||
|
*.png
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue