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