├── .clang-format ├── .github └── workflows │ ├── coverage.yml │ ├── lin.yml │ ├── mac.yml │ └── win.yml ├── .gitignore ├── CMakeLists.txt ├── LICENSE ├── README.md ├── cmake ├── ClangFormat.cmake └── CodeCoverage.cmake ├── examples ├── rain_and_thunder │ ├── 2519_rain.mp3 │ ├── 2520_rain.mp3 │ ├── 2522_rain.mp3 │ ├── 2524_thunder.mp3 │ ├── 2525_thunder.mp3 │ ├── 2528_thunder.mp3 │ ├── README │ └── rain_and_thunder.json └── river │ ├── 615519_river.mp3 │ ├── 620193_river.mp3 │ ├── 620288_river.mp3 │ ├── README │ └── river.json ├── linux ├── CMakeLists.txt ├── applications │ └── soundscape.desktop ├── icons │ └── hicolor │ │ └── scalable │ │ └── apps │ │ └── soundscape.svg └── metainfo │ └── soundscape.metainfo.xml.in ├── screenshots ├── overview.png ├── pause-tracks.png ├── playback.png ├── transition.png └── volume.png ├── src ├── CMakeLists.txt ├── IconLabel.cpp ├── IconLabel.h ├── JsonRW.cpp ├── JsonRW.h ├── Main.cpp ├── MainWindow.cpp ├── MainWindow.h ├── Player.cpp ├── Player.h ├── PositionLabel.cpp ├── PositionLabel.h ├── PositionSlider.cpp ├── PositionSlider.h ├── Status.cpp ├── Status.h ├── Track.cpp ├── Track.h ├── TrackControls.cpp ├── TrackControls.h ├── TrackSettings.cpp ├── TrackSettings.h ├── Transition.cpp ├── Transition.h ├── TransitionIcon.cpp ├── TransitionIcon.h ├── Version.h.in ├── Volume.cpp ├── Volume.h ├── icons │ ├── cross-fade.svg │ ├── fade-gap.svg │ ├── fade-in-label.svg │ ├── fade-out-in.svg │ ├── fade-out-label.svg │ ├── gap-label.svg │ ├── icon.ico │ ├── icon.svg │ ├── position-label.svg │ ├── switch-off.svg │ ├── switch-on.svg │ ├── switch-paused.svg │ └── win32.rc ├── styles │ ├── status.css │ ├── track-controls.css │ └── transition-icon.css └── translations │ ├── .gitattributes │ ├── soundscape_de.ts │ ├── soundscape_it.ts │ └── soundscape_ru.ts └── tests ├── CMakeLists.txt ├── TestJsonRW.cpp ├── TestMainWindow.cpp ├── TestPlayer.cpp ├── TestPositionLabel.cpp ├── TestTrack.cpp ├── TestTrackControls.cpp ├── TestTrackSettings.cpp ├── TestTransition.cpp └── media ├── sound_0000.wav ├── sound_0100.wav ├── sound_XXXX.wav └── video_0100.webm /.clang-format: -------------------------------------------------------------------------------- 1 | # https://clang.llvm.org/docs/ClangFormatStyleOptions.html 2 | 3 | Language: Cpp 4 | 5 | BreakBeforeBraces: Custom 6 | BraceWrapping: 7 | AfterCaseLabel: true 8 | AfterClass: true 9 | AfterControlStatement: Always 10 | AfterEnum: true 11 | AfterFunction: true 12 | AfterNamespace: true 13 | AfterStruct: true 14 | AfterUnion: true 15 | BeforeCatch: true 16 | BeforeElse: true 17 | BeforeLambdaBody: false 18 | BeforeWhile: true 19 | SplitEmptyFunction: false 20 | SplitEmptyNamespace: false 21 | SplitEmptyRecord: false 22 | 23 | AlignAfterOpenBracket: Align 24 | #AlignConsecutiveAssignments: AcrossComments 25 | #AlignConsecutiveDeclarations: AcrossComments 26 | AlignOperands: true 27 | AlignTrailingComments: true 28 | AllowShortBlocksOnASingleLine: Always 29 | AllowShortCaseLabelsOnASingleLine: true 30 | AllowShortEnumsOnASingleLine: true 31 | AllowShortFunctionsOnASingleLine: All 32 | AllowShortIfStatementsOnASingleLine: AllIfsAndElse 33 | AllowShortLambdasOnASingleLine: All 34 | AllowShortLoopsOnASingleLine: true 35 | AlwaysBreakTemplateDeclarations: No 36 | 37 | BreakBeforeBinaryOperators: None 38 | BreakConstructorInitializers: AfterColon 39 | 40 | ColumnLimit: 0 41 | 42 | FixNamespaceComments: true 43 | 44 | IncludeBlocks: Regroup 45 | IncludeCategories: 46 | # put third party headers before standard headers 47 | - Regex: "<.*\\.(h|hpp|hxx)>" 48 | Priority: 100 49 | # match standard headers 50 | - Regex: "<[a-z_]+>" 51 | Priority: 1000 52 | 53 | IndentCaseLabels: true 54 | IndentWidth: 2 55 | 56 | #PackConstructorInitializers: CurrentLine 57 | PointerAlignment: Left 58 | 59 | #QualifierAlignment: Left 60 | 61 | ReferenceAlignment: Left 62 | 63 | SpaceBeforeRangeBasedForLoopColon: true 64 | 65 | Standard: Latest 66 | 67 | UseTab: Never 68 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | name: Coverage 5 | 6 | on: [push, pull_request] 7 | 8 | permissions: 9 | contents: write 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | linux: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - name: build dependencies 23 | run: | 24 | sudo apt-get -qq update 25 | sudo apt-get install --assume-yes gcovr 26 | sudo apt-get install --assume-yes gstreamer1.0-plugins-good 27 | sudo apt-get install --assume-yes libgl-dev 28 | sudo apt-get install --assume-yes pulseaudio 29 | sudo apt-get install --assume-yes qt6-base-dev 30 | sudo apt-get install --assume-yes qt6-l10n-tools 31 | sudo apt-get install --assume-yes qt6-multimedia-dev 32 | sudo apt-get install --assume-yes qt6-tools-dev 33 | sudo apt-get install --assume-yes qt6-tools-dev-tools 34 | sudo apt-get install --assume-yes xvfb 35 | - name: configure 36 | run: | 37 | BUILD_DIR=$HOME/build 38 | echo "BUILD_DIR=$BUILD_DIR" >>$GITHUB_ENV 39 | cmake -DWITH_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug -B $BUILD_DIR 40 | - name: build 41 | run: | 42 | cmake --build $BUILD_DIR --target all 43 | - name: test 44 | run: | 45 | systemctl --user start pulseaudio 46 | xvfb-run ctest --output-on-failure --test-dir $BUILD_DIR/tests --repeat until-pass:5 47 | cmake --build $BUILD_DIR --target coveralls 48 | - name: coveralls 49 | uses: coverallsapp/github-action@v2 50 | with: 51 | file: ${{ env.BUILD_DIR }}/coverage.json 52 | -------------------------------------------------------------------------------- /.github/workflows/lin.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | name: lin 5 | 6 | on: [push, pull_request] 7 | 8 | permissions: 9 | contents: write 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | linux: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | qt-version: ['6.8.1'] 22 | build-type: [Release] 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: install aqt 28 | run: | 29 | pip3 install aqtinstall 30 | - name: install Qt 31 | run: | 32 | O_DIR=$HOME/Qt 33 | echo "O_DIR=$O_DIR" >>$GITHUB_ENV 34 | aqt install-qt linux desktop ${{ matrix.qt-version }} -m qtmultimedia -O $O_DIR 35 | - name: Qt dir 36 | run: | 37 | Qt6_QMAKE=`find $O_DIR -name "qmake6*"` 38 | Qt6_BINDIR=`dirname $Qt6_QMAKE` 39 | Qt6_DIR=`dirname $Qt6_BINDIR` 40 | QT_PLUGIN_PATH=$Qt6_DIR/plugins 41 | QML2_IMPORT_PATH=$Qt6_DIR/qml 42 | echo "Qt6_DIR=$Qt6_DIR" >>$GITHUB_ENV 43 | echo "Qt6_BINDIR=$Qt6_BINDIR" >>$GITHUB_ENV 44 | echo "QT_PLUGIN_PATH=$QT_PLUGIN_PATH" >>$GITHUB_ENV 45 | echo "QML2_IMPORT_PATH=$QML2_IMPORT_PATH" >>$GITHUB_ENV 46 | - name: build dependencies 47 | run: | 48 | sudo apt-get -qq update 49 | sudo apt-get install --assume-yes libgl1-mesa-dev 50 | sudo apt-get install --assume-yes libpulse0 51 | sudo apt-get install --assume-yes libqt5gui5 52 | sudo apt-get install --assume-yes libxcb-cursor0 53 | sudo apt-get install --assume-yes pulseaudio 54 | sudo apt-get install --assume-yes xvfb 55 | - name: configure 56 | run: | 57 | BUILD_DIR=build 58 | echo "BUILD_DIR=$BUILD_DIR" >>$GITHUB_ENV 59 | cmake -DWITH_DEPLOY_SCRIPT=ON -DCMAKE_PREFIX_PATH=$Qt6_DIR -DCMAKE_BUILD_TYPE=${{ matrix.build-type }} -B $BUILD_DIR 60 | - name: build 61 | run: | 62 | cmake --build $BUILD_DIR --target package 63 | - name: upload artefacts 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: linux 67 | path: ${{ env.BUILD_DIR }}/soundscape-* 68 | - name: test 69 | run: | 70 | systemctl --user start pulseaudio 71 | xvfb-run ctest --output-on-failure --test-dir $BUILD_DIR/tests --repeat until-pass:5 72 | - name: release 73 | uses: softprops/action-gh-release@v1 74 | if: startsWith(github.ref, 'refs/tags/') 75 | with: 76 | tag_name: ${{ github.ref_name }} 77 | name: ${{ github.ref_name }} 78 | draft: false 79 | prerelease: false 80 | files: ${{ env.BUILD_DIR }}/soundscape-* 81 | -------------------------------------------------------------------------------- /.github/workflows/mac.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | name: mac 5 | 6 | on: [push, pull_request] 7 | 8 | permissions: 9 | contents: write 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | macos: 17 | runs-on: macos-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | qt-version: ['6.8.1'] 22 | build-type: [Release] 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: install aqt 28 | run: | 29 | pipx install aqtinstall 30 | - name: install build tools 31 | run: | 32 | brew install librsvg 33 | - name: install Qt 34 | run: | 35 | O_DIR=$HOME/Qt 36 | echo "O_DIR=$O_DIR" >>$GITHUB_ENV 37 | aqt install-qt mac desktop ${{ matrix.qt-version }} -m qtmultimedia -O $O_DIR 38 | - name: Qt dir 39 | run: | 40 | Qt6_QMAKE=`find $O_DIR -name "qmake6*"` 41 | Qt6_BINDIR=`dirname $Qt6_QMAKE` 42 | Qt6_DIR=`dirname $Qt6_BINDIR` 43 | QT_PLUGIN_PATH=$Qt6_DIR/plugins 44 | QML2_IMPORT_PATH=$Qt6_DIR/qml 45 | echo "Qt6_DIR=$Qt6_DIR" >>$GITHUB_ENV 46 | echo "Qt6_BINDIR=$Qt6_BINDIR" >>$GITHUB_ENV 47 | echo "QT_PLUGIN_PATH=$QT_PLUGIN_PATH" >>$GITHUB_ENV 48 | echo "QML2_IMPORT_PATH=$QML2_IMPORT_PATH" >>$GITHUB_ENV 49 | - name: configure 50 | run: | 51 | BUILD_DIR=build 52 | echo "BUILD_DIR=$BUILD_DIR" >>$GITHUB_ENV 53 | cmake -DWITH_DEPLOY_SCRIPT=ON -DCMAKE_PREFIX_PATH=$Qt6_DIR -DCMAKE_BUILD_TYPE=${{ matrix.build-type }} -B $BUILD_DIR 54 | - name: build 55 | run: | 56 | cmake --build $BUILD_DIR --target package 57 | - name: upload artefacts 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: macos 61 | path: ${{ env.BUILD_DIR }}/soundscape-* 62 | - name: test 63 | run: | 64 | ctest --output-on-failure --test-dir $BUILD_DIR/tests --repeat until-pass:5 65 | - name: release 66 | uses: softprops/action-gh-release@v1 67 | if: startsWith(github.ref, 'refs/tags/') 68 | with: 69 | tag_name: ${{ github.ref_name }} 70 | name: ${{ github.ref_name }} 71 | draft: false 72 | prerelease: false 73 | files: ${{ env.BUILD_DIR }}/soundscape-* 74 | -------------------------------------------------------------------------------- /.github/workflows/win.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | name: win 5 | 6 | on: [push, pull_request] 7 | 8 | permissions: 9 | contents: write 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | windows: 17 | runs-on: windows-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | qt-version: ['6.8.1'] 22 | qt-arch: [win64_mingw] 23 | build-type: [Release] 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | - name: install aqt 29 | run: | 30 | pip3 install aqtinstall 31 | - name: install build tools 32 | run: | 33 | O_DIR=$HOME/Qt 34 | echo "O_DIR=$O_DIR" >>$GITHUB_ENV 35 | aqt install-tool windows desktop tools_cmake -O $O_DIR 36 | CMake_BIN=$O_DIR/Tools/CMake_64/bin 37 | echo "CMake_BIN=$CMake_BIN" >>$GITHUB_ENV 38 | aqt install-tool windows desktop tools_ninja -O $O_DIR 39 | Ninja_BIN=$O_DIR/Tools/Ninja 40 | echo "Ninja_BIN=$Ninja_BIN" >>$GITHUB_ENV 41 | - name: install Qt 42 | run: | 43 | aqt install-qt windows desktop ${{ matrix.qt-version }} ${{ matrix.qt-arch }} -m qtmultimedia -O $O_DIR 44 | - name: Qt dir 45 | run: | 46 | Qt6_QMAKE=`find $O_DIR -name "qmake6*"` 47 | Qt6_BINDIR=`dirname $Qt6_QMAKE` 48 | Qt6_DIR=`dirname $Qt6_BINDIR` 49 | QT_PLUGIN_PATH=$Qt6_DIR/plugins 50 | QML2_IMPORT_PATH=$Qt6_DIR/qml 51 | echo "Qt6_DIR=$Qt6_DIR" >>$GITHUB_ENV 52 | echo "Qt6_BINDIR=$Qt6_BINDIR" >>$GITHUB_ENV 53 | echo "QT_PLUGIN_PATH=$QT_PLUGIN_PATH" >>$GITHUB_ENV 54 | echo "QML2_IMPORT_PATH=$QML2_IMPORT_PATH" >>$GITHUB_ENV 55 | - name: install MinGW 56 | run: | 57 | aqt install-tool windows desktop tools_mingw1310 -O $O_DIR 58 | MinGW_BIN=$O_DIR/Tools/mingw1310_64/bin 59 | echo "MinGW_BIN=$MinGW_BIN" >>$GITHUB_ENV 60 | - name: configure 61 | run: | 62 | PATH=$MinGW_BIN:$CMake_BIN:$Ninja_BIN:$PATH 63 | BUILD_DIR=build 64 | echo "BUILD_DIR=$BUILD_DIR" >>$GITHUB_ENV 65 | cmake -DWITH_DEPLOY_SCRIPT=ON -DCMAKE_PREFIX_PATH=$Qt6_DIR -DCMAKE_BUILD_TYPE=${{ matrix.build-type }} -GNinja -B $BUILD_DIR 66 | - name: build 67 | run: | 68 | PATH=$MinGW_BIN:$CMake_BIN:$Ninja_BIN:$PATH 69 | cmake --build $BUILD_DIR --target package 70 | - name: upload artefacts 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: windows 74 | path: ${{ env.BUILD_DIR }}/soundscape-* 75 | - name: setup audio 76 | uses: LABSN/sound-ci-helpers@v1 77 | - name: test 78 | run: | 79 | PATH=$Qt6_BINDIR:$MinGW_BIN:$PATH 80 | ctest --output-on-failure --test-dir $BUILD_DIR/tests --repeat until-pass:5 81 | - name: release 82 | uses: softprops/action-gh-release@v1 83 | if: startsWith(github.ref, 'refs/tags/') 84 | with: 85 | tag_name: ${{ github.ref_name }} 86 | name: ${{ github.ref_name }} 87 | draft: false 88 | prerelease: false 89 | files: ${{ env.BUILD_DIR }}/soundscape-* 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build*/ 2 | CMakeLists.txt.* 3 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | cmake_minimum_required(VERSION 3.16) 5 | 6 | project(soundscape LANGUAGES CXX) 7 | 8 | set(CMAKE_CXX_STANDARD 17) 9 | set(CMAKE_CXX_STANDARD_REQUIRED ON) 10 | 11 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 12 | 13 | option(WITH_CLANG_FORMAT "Enable clang-format check." OFF) 14 | option(WITH_DEPLOY_SCRIPT "Enable Qt deploy script." OFF) 15 | 16 | list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake") 17 | include(ClangFormat) 18 | include(CodeCoverage) 19 | 20 | set(WEB_SITE "https://github.com/ddanilov/soundscape") 21 | 22 | set(APP_TITLE "Soundscape") 23 | if(CMAKE_SYSTEM_NAME MATCHES "Darwin") 24 | set(APP_NAME ${APP_TITLE}) 25 | else() 26 | string(TOLOWER ${APP_TITLE} APP_NAME) 27 | endif() 28 | message(STATUS "application name: ${APP_NAME}") 29 | 30 | if(NOT DEFINED APP_VERSION) 31 | find_package(Git) 32 | if(Git_FOUND) 33 | execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --always 34 | WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} 35 | OUTPUT_VARIABLE APP_VERSION) 36 | elseif() 37 | set(APP_VERSION "unknown") 38 | endif() 39 | endif() 40 | string(STRIP ${APP_VERSION} APP_VERSION) 41 | message(STATUS "version: ${APP_VERSION}") 42 | 43 | set(APP_LIB ${APP_NAME}-lib) 44 | set(APP_EXE ${APP_NAME}) 45 | 46 | find_package(Qt6 6.2 COMPONENTS 47 | LinguistTools 48 | Multimedia 49 | Test 50 | Widgets 51 | REQUIRED) 52 | message(STATUS "Using Qt ${Qt6_VERSION} from ${Qt6_DIR}") 53 | 54 | if(Qt6_VERSION VERSION_GREATER_EQUAL 6.3) 55 | qt_standard_project_setup() 56 | else() 57 | set(CMAKE_AUTOMOC ON) 58 | set(CMAKE_AUTOUIC ON) 59 | include(GNUInstallDirs) 60 | endif() 61 | 62 | add_subdirectory(src) 63 | add_subdirectory(tests) 64 | 65 | if(CMAKE_SYSTEM_NAME MATCHES "Linux") 66 | add_subdirectory(linux) 67 | endif() 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Soundscape 2 | 3 | [![GitHub License](https://img.shields.io/github/license/ddanilov/soundscape?color=green)](https://www.gnu.org/licenses/gpl-3.0.html) 4 | [![GitHub release](https://img.shields.io/github/release/ddanilov/soundscape)](https://github.com/ddanilov/soundscape/releases/) 5 | [![Coverage Status](https://coveralls.io/repos/github/ddanilov/soundscape/badge.svg)](https://coveralls.io/github/ddanilov/soundscape) 6 | [![Flathub Downloads](https://img.shields.io/flathub/downloads/io.github.ddanilov.soundscape)](https://flathub.org/apps/io.github.ddanilov.soundscape) 7 | [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/ddanilov/soundscape/total)](https://github.com/ddanilov/soundscape/releases/) 8 | [![GitHub Downloads (all assets, latest release)](https://img.shields.io/github/downloads/ddanilov/soundscape/latest/total)](https://github.com/ddanilov/soundscape/releases/latest) 9 | 10 | [**Soundscape**](https://github.com/ddanilov/soundscape) is an open-source 11 | system-tray resident desktop application for playing a mix of sounds, e.g. 12 | natural sounds by animals or wind and water. 13 | 14 | ![](screenshots/overview.png) 15 | 16 | ## Usage 17 | 18 | The application comes with two example soundscapes. Use mouse right-click in the 19 | main window or on the tray icon to access application menu where you can add or 20 | remove soundtracks and change their settings. 21 | 22 | For each track you can change its volume, loop transition and playback state. 23 | 24 | ### Volume 25 | 26 | ![](screenshots/volume.png) 27 | 28 | ### Loop transition 29 | 30 | ![](screenshots/transition.png) 31 | 32 | ### Playback 33 | 34 | ![](screenshots/playback.png) 35 | 36 | From the application menu you can pause and resume all tracks at once. 37 | 38 | ![](screenshots/pause-tracks.png) 39 | 40 | On Linux and Windows, use the `Quit` item from the application menu to finish 41 | the application. Pressing the close window button will just minimize the 42 | application to the tray. 43 | 44 | Command line options: 45 | 46 | * `--load ` load track list from a file on start, 47 | * `--minimize` minimize window to tray on start, 48 | * `--disable-tray` disable tray icon. 49 | 50 | [Freesound](https://freesound.org/) is a good source of sounds for your own 51 | soundscapes. 52 | 53 | ## Installation 54 | 55 | ### Linux distributions 56 | 57 | Packages for some Linux distributions are available. 58 | 59 | **Ubuntu** users can install the application from PPA repository 60 | `ppa:ddanilov/soundscape`, see 61 | for 62 | details. 63 | 64 | **Debian**, **openSUSE** and **Fedora** packages can be installed from 65 | download page of `home:danilov:soundscape` OBS project, see 66 | . 67 | 68 | **Flatpak** package is available from **Flathub** app store at 69 | 70 | 71 | ### Windows 72 | 73 | You can install the application using **Windows Package Manager** 74 | 75 | winget search soundscape 76 | winget install Danilov.Soundscape 77 | 78 | ### Prebuilt binaries 79 | 80 | Prebuilt binaries for Windows, macOS and Linux are available from the 81 | [Releases page](https://github.com/ddanilov/soundscape/releases). 82 | 83 | On Linux you may need to install additional packages in order to run the 84 | prebuilt binary. Most likely XCB util-cursor module is missing. On Debian-based 85 | systems you can install it with 86 | 87 | sudo apt-get install libxcb-cursor0 88 | 89 | on openSUSE with 90 | 91 | sudo zypper install libxcb-cursor0 92 | 93 | and on RedHat-based systems with 94 | 95 | sudo dnf install xcb-util-cursor 96 | 97 | ## License 98 | 99 | This program is free software: you can redistribute it and/or modify 100 | it under the terms of the GNU General Public License version 3 as 101 | published by the Free Software Foundation. 102 | 103 | This program is distributed in the hope that it will be useful, but 104 | WITHOUT ANY WARRANTY; without even the implied warranty of 105 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 106 | General Public License for more details. 107 | 108 | You should have received a copy of the GNU General Public License 109 | along with this program. If not, see . 110 | -------------------------------------------------------------------------------- /cmake/ClangFormat.cmake: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | # let's use CPPLINT property to run clang-format check 5 | find_program(CLANG_FORMAT clang-format) 6 | if(WITH_CLANG_FORMAT AND CLANG_FORMAT) 7 | message(STATUS "Setup clang-format check") 8 | set(CMAKE_CXX_CPPLINT "${CLANG_FORMAT};--dry-run;--Werror") 9 | endif() 10 | -------------------------------------------------------------------------------- /cmake/CodeCoverage.cmake: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | option(WITH_COVERAGE "Enable code coverage reports for tests." OFF) 5 | 6 | if(WITH_COVERAGE) 7 | find_program(GCOVR gcovr REQUIRED) 8 | 9 | if (NOT (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_BUILD_TYPE STREQUAL "Debug")) 10 | message(SEND_ERROR "can't prepare coverage") 11 | endif() 12 | 13 | add_compile_options(--coverage) 14 | add_link_options(--coverage) 15 | 16 | set(CTEST_COVERAGE_DIR "coverage") 17 | message(STATUS "coverage report base: ${PROJECT_BINARY_DIR}/${CTEST_COVERAGE_DIR}") 18 | 19 | set(EXCLUDE_PATTERN ".*tests") 20 | 21 | add_custom_target(coverage 22 | COMMAND ${CMAKE_COMMAND} -E make_directory ${PROJECT_BINARY_DIR}/${CTEST_COVERAGE_DIR} 23 | COMMAND gcovr 24 | --html --html-details 25 | --exclude "${EXCLUDE_PATTERN}" 26 | --output ${CTEST_COVERAGE_DIR}/index.html 27 | --root ${PROJECT_SOURCE_DIR} 28 | --object-directory ${PROJECT_BINARY_DIR} 29 | WORKING_DIRECTORY ${PROJECT_BINARY_DIR}) 30 | 31 | add_custom_target(coveralls 32 | COMMAND gcovr 33 | --coveralls coverage.json 34 | --exclude "${EXCLUDE_PATTERN}" 35 | --root ${PROJECT_SOURCE_DIR} 36 | --object-directory ${PROJECT_BINARY_DIR} 37 | WORKING_DIRECTORY ${PROJECT_BINARY_DIR}) 38 | 39 | endif() 40 | -------------------------------------------------------------------------------- /examples/rain_and_thunder/2519_rain.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/rain_and_thunder/2519_rain.mp3 -------------------------------------------------------------------------------- /examples/rain_and_thunder/2520_rain.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/rain_and_thunder/2520_rain.mp3 -------------------------------------------------------------------------------- /examples/rain_and_thunder/2522_rain.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/rain_and_thunder/2522_rain.mp3 -------------------------------------------------------------------------------- /examples/rain_and_thunder/2524_thunder.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/rain_and_thunder/2524_thunder.mp3 -------------------------------------------------------------------------------- /examples/rain_and_thunder/2525_thunder.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/rain_and_thunder/2525_thunder.mp3 -------------------------------------------------------------------------------- /examples/rain_and_thunder/2528_thunder.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/rain_and_thunder/2528_thunder.mp3 -------------------------------------------------------------------------------- /examples/rain_and_thunder/README: -------------------------------------------------------------------------------- 1 | This soundscape mix uses sounds from Freesound pack 2 | Storm https://freesound.org/people/RHumphries/packs/147/ 3 | by user RHumphries https://freesound.org/people/RHumphries/ 4 | licensed under Attribution 3.0 http://creativecommons.org/licenses/by/3.0/ 5 | 6 | * 2519__RHumphries__rbh_rain_01.wav 7 | url: https://freesound.org/s/2519/ 8 | license: Attribution 3.0 9 | 10 | * 2520__RHumphries__rbh_rain_02.wav 11 | url: https://freesound.org/s/2520/ 12 | license: Attribution 3.0 13 | 14 | * 2522__RHumphries__rbh_rain_04.wav 15 | url: https://freesound.org/s/2522/ 16 | license: Attribution 3.0 17 | 18 | * 2524__RHumphries__rbh_thunder_02.wav 19 | url: https://freesound.org/s/2524/ 20 | license: Attribution 3.0 21 | 22 | * 2525__RHumphries__rbh_thunder_03.wav 23 | url: https://freesound.org/s/2525/ 24 | license: Attribution 3.0 25 | 26 | * 2528__RHumphries__rbh_thunder_06.wav 27 | url: https://freesound.org/s/2528/ 28 | license: Attribution 3.0 29 | -------------------------------------------------------------------------------- /examples/rain_and_thunder/rain_and_thunder.json: -------------------------------------------------------------------------------- 1 | { 2 | "tracks": [ 3 | { 4 | "fadeInDuration": 2000, 5 | "fadeOutDuration": 2000, 6 | "fileName": "2519_rain.mp3", 7 | "gap": 0, 8 | "gapMax": 15, 9 | "playing": true, 10 | "randomGap": true, 11 | "transition": 2, 12 | "volume": 0.2 13 | }, 14 | { 15 | "fadeInDuration": 2000, 16 | "fadeOutDuration": 2000, 17 | "fileName": "2520_rain.mp3", 18 | "gap": 0, 19 | "gapMax": 15, 20 | "playing": true, 21 | "randomGap": true, 22 | "transition": 2, 23 | "volume": 0.2 24 | }, 25 | { 26 | "fadeInDuration": 2000, 27 | "fadeOutDuration": 2000, 28 | "fileName": "2522_rain.mp3", 29 | "gap": 0, 30 | "gapMax": 15, 31 | "playing": true, 32 | "randomGap": true, 33 | "transition": 2, 34 | "volume": 0.2 35 | }, 36 | { 37 | "fadeInDuration": 1000, 38 | "fadeOutDuration": 1000, 39 | "fileName": "2524_thunder.mp3", 40 | "gap": 30, 41 | "gapMax": 300, 42 | "playing": true, 43 | "randomGap": true, 44 | "transition": 2, 45 | "volume": 0.5 46 | }, 47 | { 48 | "fadeInDuration": 1000, 49 | "fadeOutDuration": 1000, 50 | "fileName": "2525_thunder.mp3", 51 | "gap": 30, 52 | "gapMax": 300, 53 | "playing": true, 54 | "randomGap": true, 55 | "transition": 2, 56 | "volume": 0.5 57 | }, 58 | { 59 | "fadeInDuration": 1000, 60 | "fadeOutDuration": 5000, 61 | "fileName": "2528_thunder.mp3", 62 | "gap": 300, 63 | "gapMax": 900, 64 | "playing": true, 65 | "randomGap": true, 66 | "transition": 2, 67 | "volume": 0.5 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /examples/river/615519_river.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/river/615519_river.mp3 -------------------------------------------------------------------------------- /examples/river/620193_river.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/river/620193_river.mp3 -------------------------------------------------------------------------------- /examples/river/620288_river.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/examples/river/620288_river.mp3 -------------------------------------------------------------------------------- /examples/river/README: -------------------------------------------------------------------------------- 1 | This soundscape mix uses sounds from Freesound pack 2 | https://freesound.org/people/klankbeeld/packs/33832/ 3 | by user klankbeeld https://freesound.org/people/klankbeeld/ 4 | licensed under Attribution http://creativecommons.org/licenses/by/3.0/ 5 | 6 | * 615519__klankbeeld__summer-river-709am-nl-210718-0304.wav 7 | url: https://freesound.org/s/615519/ 8 | license: Attribution 9 | 10 | * 620193__klankbeeld__waves-river-11-34am-210921-0315.wav 11 | url: https://freesound.org/s/620193/ 12 | license: Attribution 13 | 14 | * 620288__klankbeeld__waves-river-11-36am-210921-0315.wav 15 | url: https://freesound.org/s/620288/ 16 | license: Attribution 17 | -------------------------------------------------------------------------------- /examples/river/river.json: -------------------------------------------------------------------------------- 1 | { 2 | "tracks": [ 3 | { 4 | "fadeInDuration": 5000, 5 | "fadeOutDuration": 5000, 6 | "fileName": "615519_river.mp3", 7 | "gap": 30, 8 | "gapMax": 60, 9 | "playing": true, 10 | "randomGap": true, 11 | "transition": 2, 12 | "volume": 0.5 13 | }, 14 | { 15 | "fadeInDuration": 5000, 16 | "fadeOutDuration": 5000, 17 | "fileName": "620193_river.mp3", 18 | "gap": 10, 19 | "gapMax": 300, 20 | "playing": true, 21 | "randomGap": false, 22 | "transition": 1, 23 | "volume": 0.5 24 | }, 25 | { 26 | "fadeInDuration": 5000, 27 | "fadeOutDuration": 5000, 28 | "fileName": "620288_river.mp3", 29 | "gap": 10, 30 | "gapMax": 300, 31 | "playing": true, 32 | "randomGap": false, 33 | "transition": 1, 34 | "volume": 0.5 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /linux/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | install(DIRECTORY "applications" TYPE DATA) 2 | install(DIRECTORY "icons" TYPE DATA) 3 | 4 | string(TIMESTAMP BUILD_DATE "%Y-%m-%d" UTC) 5 | configure_file(metainfo/soundscape.metainfo.xml.in metainfo/soundscape.metainfo.xml) 6 | install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/metainfo" TYPE DATA) 7 | -------------------------------------------------------------------------------- /linux/applications/soundscape.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Soundscape 4 | Icon=soundscape 5 | Exec=soundscape 6 | Terminal=false 7 | Categories=Audio;Player;AudioVideo; 8 | Keywords=Sound;Audio;Player; 9 | X-Desktop-File-Install-Version=0.26 10 | -------------------------------------------------------------------------------- /linux/icons/hicolor/scalable/apps/soundscape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 16 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /linux/metainfo/soundscape.metainfo.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | io.github.ddanilov.soundscape 4 | 5 | Soundscape 6 | Desktop soundscape application 7 | 8 | @WEB_SITE@ 9 | Denis Danilov 10 | 11 | CC0-1.0 12 | GPL-3.0-only AND CC-BY-3.0 13 | 14 | 15 |

16 | Soundscape is an open-source system-tray resident desktop application for playing a mix of sounds, e.g. natural sounds by animals or wind and water. 17 |

18 |
19 | 20 | 21 | Audio 22 | 23 | 24 | 25 | ambient 26 | audio 27 | foss 28 | meditation 29 | nature 30 | noise 31 | open-source 32 | qt 33 | sound 34 | soundscape 35 | system-tray 36 | 37 | 38 | soundscape.desktop 39 | 40 | 41 | 42 | https://raw.githubusercontent.com/ddanilov/soundscape/main/screenshots/overview.png 43 | 44 | 45 | https://raw.githubusercontent.com/ddanilov/soundscape/main/screenshots/volume.png 46 | 47 | 48 | https://raw.githubusercontent.com/ddanilov/soundscape/main/screenshots/transition.png 49 | 50 | 51 | https://raw.githubusercontent.com/ddanilov/soundscape/main/screenshots/playback.png 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 | -------------------------------------------------------------------------------- /screenshots/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/screenshots/overview.png -------------------------------------------------------------------------------- /screenshots/pause-tracks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/screenshots/pause-tracks.png -------------------------------------------------------------------------------- /screenshots/playback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/screenshots/playback.png -------------------------------------------------------------------------------- /screenshots/transition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/screenshots/transition.png -------------------------------------------------------------------------------- /screenshots/volume.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/screenshots/volume.png -------------------------------------------------------------------------------- /src/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2024 Denis Danilov and contributors 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | set(HEADERS 5 | IconLabel.h 6 | JsonRW.h 7 | MainWindow.h 8 | Player.h 9 | PositionLabel.h 10 | PositionSlider.h 11 | Status.h 12 | Track.h 13 | TrackControls.h 14 | TrackSettings.h 15 | Transition.h 16 | TransitionIcon.h 17 | Volume.h 18 | # 19 | Version.h.in 20 | ) 21 | 22 | set(SOURCES 23 | IconLabel.cpp 24 | JsonRW.cpp 25 | Main.cpp 26 | MainWindow.cpp 27 | Player.cpp 28 | PositionLabel.cpp 29 | PositionSlider.cpp 30 | Status.cpp 31 | Track.cpp 32 | TrackControls.cpp 33 | TrackSettings.cpp 34 | Transition.cpp 35 | TransitionIcon.cpp 36 | Volume.cpp 37 | ) 38 | 39 | qt_add_library(${APP_LIB} STATIC 40 | ${HEADERS} 41 | ${SOURCES} 42 | ) 43 | target_link_libraries(${APP_LIB} PUBLIC Qt::Multimedia Qt::Widgets) 44 | 45 | qt_add_translations(${APP_LIB} TS_FILES 46 | translations/soundscape_de.ts 47 | translations/soundscape_it.ts 48 | translations/soundscape_ru.ts 49 | ) 50 | 51 | message(STATUS "system name: ${CMAKE_SYSTEM_NAME}") 52 | 53 | if(CMAKE_SYSTEM_NAME MATCHES "Darwin") 54 | set(ICON_NAME "icon") 55 | set(ICONSET_DIR "${ICON_NAME}.iconset") 56 | set(ICON_TMP "macos_icon.svg") 57 | # brew install librsvg 58 | find_program(RSVG_CONVERT rsvg-convert HINTS /usr/local/bin/ REQUIRED) 59 | add_custom_command( 60 | OUTPUT icon.icns 61 | COMMAND ${CMAKE_COMMAND} -E make_directory ${ICONSET_DIR} 62 | COMMAND cat ${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg | sed -e 's/r="16"/r="12"/' > ${ICON_TMP} 63 | COMMAND ${RSVG_CONVERT} ${ICON_TMP} --width=16 --height=16 --output=${ICONSET_DIR}/icon_16x16.png 64 | COMMAND ${RSVG_CONVERT} ${ICON_TMP} --width=32 --height=32 --output=${ICONSET_DIR}/icon_32x32.png 65 | COMMAND ${RSVG_CONVERT} ${ICON_TMP} --width=64 --height=64 --output=${ICONSET_DIR}/icon_64x64.png 66 | COMMAND ${RSVG_CONVERT} ${ICON_TMP} --width=128 --height=128 --output=${ICONSET_DIR}/icon_128x128.png 67 | COMMAND ${RSVG_CONVERT} ${ICON_TMP} --width=256 --height=256 --output=${ICONSET_DIR}/icon_256x256.png 68 | COMMAND ${RSVG_CONVERT} ${ICON_TMP} --width=512 --height=512 --output=${ICONSET_DIR}/icon_512x512.png 69 | COMMAND ${RSVG_CONVERT} ${ICON_TMP} --width=1024 --height=1024 --output=${ICONSET_DIR}/icon_1024x1024.png 70 | COMMAND iconutil -c icns ${ICONSET_DIR} 71 | DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/icons/icon.svg 72 | ) 73 | 74 | set(APP_ICON "${ICON_NAME}.icns") 75 | set(MACOSX_BUNDLE_ICON_FILE ${APP_ICON}) 76 | set_source_files_properties(${APP_ICON} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") 77 | endif() 78 | 79 | set(RESOURCES icons/win32.rc) 80 | qt_add_executable(${APP_EXE} WIN32 MACOSX_BUNDLE ${RESOURCES} ${APP_ICON}) 81 | target_link_libraries(${APP_EXE} PRIVATE ${APP_LIB}) 82 | add_dependencies(${APP_EXE} update_translations) 83 | 84 | qt_add_resources(${APP_EXE} icons_resources 85 | PREFIX "/icons" 86 | BASE "icons" 87 | FILES 88 | icons/cross-fade.svg 89 | icons/fade-gap.svg 90 | icons/fade-in-label.svg 91 | icons/fade-out-in.svg 92 | icons/fade-out-label.svg 93 | icons/gap-label.svg 94 | icons/icon.svg 95 | icons/position-label.svg 96 | icons/switch-off.svg 97 | icons/switch-on.svg 98 | icons/switch-paused.svg 99 | ) 100 | 101 | qt_add_resources(${APP_EXE} styles_resources 102 | PREFIX "/styles" 103 | BASE "styles" 104 | FILES 105 | styles/status.css 106 | styles/track-controls.css 107 | styles/transition-icon.css 108 | ) 109 | 110 | install(TARGETS ${APP_EXE} RUNTIME BUNDLE DESTINATION ".") 111 | 112 | set(CMAKE_INSTALL_DATADIR "${CMAKE_INSTALL_DATADIR}/${APP_NAME}") 113 | 114 | if(WITH_DEPLOY_SCRIPT) 115 | set(CMAKE_INSTALL_DATADIR ".") 116 | if(CMAKE_SYSTEM_NAME MATCHES "Darwin") 117 | set(CMAKE_INSTALL_DATADIR "${APP_NAME}.app/Contents/Resources") 118 | endif() 119 | set(CMAKE_INSTALL_DOCDIR ${CMAKE_INSTALL_DATADIR}) 120 | 121 | qt_generate_deploy_app_script(TARGET ${APP_EXE} OUTPUT_SCRIPT deploy_script) 122 | install(SCRIPT ${deploy_script}) 123 | 124 | include(InstallRequiredSystemLibraries) 125 | 126 | set(CPACK_PACKAGE_VERSION_MAJOR "${APP_VERSION}") 127 | set(CPACK_PACKAGE_VERSION_MINOR "") 128 | set(CPACK_PACKAGE_VERSION_PATCH "") 129 | 130 | if(CMAKE_SYSTEM_NAME MATCHES "Windows") 131 | set(CPACK_GENERATOR "ZIP") 132 | find_program(MAKENSIS_EXE makensis.exe PATHS "$ENV{PROGRAMFILES}/NSIS" "$ENV{PROGRAMFILES\(X86\)}/NSIS") 133 | if(MAKENSIS_EXE) 134 | list(APPEND CPACK_GENERATOR "NSIS") 135 | set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE") 136 | set(CPACK_PACKAGE_INSTALL_DIRECTORY ${APP_TITLE}) 137 | set(CPACK_NSIS_DISPLAY_NAME "${APP_TITLE}") 138 | set(CPACK_NSIS_PACKAGE_NAME "${APP_TITLE}") 139 | set(CPACK_NSIS_MENU_LINKS 140 | "bin/${APP_EXE}.exe" "${APP_TITLE}" 141 | "${WEB_SITE}" "${APP_TITLE} Web Site") 142 | set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) 143 | set(CPACK_NSIS_MANIFEST_DPI_AWARE ON) 144 | endif() 145 | elseif(CMAKE_SYSTEM_NAME MATCHES "Darwin") 146 | set(CPACK_GENERATOR "DragNDrop") 147 | elseif(CMAKE_SYSTEM_NAME MATCHES "Linux") 148 | set(CPACK_GENERATOR "TGZ") 149 | endif() 150 | 151 | include(CPack) 152 | endif() 153 | 154 | message(STATUS "data dir: ${CMAKE_INSTALL_DATADIR}") 155 | configure_file(Version.h.in ${CMAKE_CURRENT_BINARY_DIR}/Version.h) 156 | target_include_directories(${APP_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") 157 | 158 | install(DIRECTORY "${PROJECT_SOURCE_DIR}/examples" TYPE DATA) 159 | install(FILES 160 | "${PROJECT_SOURCE_DIR}/README.md" 161 | "${PROJECT_SOURCE_DIR}/LICENSE" 162 | TYPE DOC) 163 | install(DIRECTORY "${PROJECT_SOURCE_DIR}/screenshots" TYPE DOC) 164 | -------------------------------------------------------------------------------- /src/IconLabel.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "IconLabel.h" 5 | 6 | #include 7 | 8 | IconLabel::IconLabel(const QString& icon_file, QWidget* parent) : 9 | QLabel(parent) 10 | { 11 | const auto icon = QIcon(icon_file); 12 | const auto p = fontInfo().pixelSize(); 13 | const auto h = static_cast(1.5 * p); 14 | const auto w = 2 * h; 15 | setPixmap(icon.pixmap(w, h)); 16 | } 17 | -------------------------------------------------------------------------------- /src/IconLabel.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | 8 | class IconLabel : public QLabel 9 | { 10 | Q_OBJECT 11 | 12 | public: 13 | explicit IconLabel(const QString& icon_file, QWidget* parent = nullptr); 14 | }; 15 | -------------------------------------------------------------------------------- /src/JsonRW.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "JsonRW.h" 5 | 6 | std::optional JsonRW::readString(const char* tag, const QJsonObject& json) 7 | { 8 | const bool check = json.contains(tag) && json[tag].isString(); 9 | if (check) { return json[tag].toString(); } 10 | return std::nullopt; 11 | } 12 | 13 | std::optional JsonRW::readDouble(const char* tag, const QJsonObject& json) 14 | { 15 | const bool check = json.contains(tag) && json[tag].isDouble(); 16 | if (check) { return json[tag].toDouble(); } 17 | return std::nullopt; 18 | } 19 | 20 | std::optional JsonRW::readBool(const char* tag, const QJsonObject& json) 21 | { 22 | const bool check = json.contains(tag) && json[tag].isBool(); 23 | if (check) { return json[tag].toBool(); } 24 | return std::nullopt; 25 | } 26 | 27 | std::optional JsonRW::readInteger(const char* tag, const QJsonObject& json) 28 | { 29 | const bool check = json.contains(tag) && json[tag].isDouble(); 30 | if (check) { return json[tag].toInteger(); } 31 | return std::nullopt; 32 | } 33 | -------------------------------------------------------------------------------- /src/JsonRW.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | struct JsonRW 11 | { 12 | constexpr static const char* TracksTag = "tracks"; 13 | constexpr static const char* FileNameTag = "fileName"; 14 | constexpr static const char* VolumeTag = "volume"; 15 | constexpr static const char* PlayingTag = "playing"; 16 | constexpr static const char* FadeInDurationTag = "fadeInDuration"; 17 | constexpr static const char* FadeOutDurationTag = "fadeOutDuration"; 18 | constexpr static const char* TransitionTag = "transition"; 19 | constexpr static const char* GapTag = "gap"; 20 | constexpr static const char* GapMaxTag = "gapMax"; 21 | constexpr static const char* RandomGapTag = "randomGap"; 22 | 23 | static std::optional readString(const char* tag, const QJsonObject& json); 24 | static std::optional readDouble(const char* tag, const QJsonObject& json); 25 | static std::optional readBool(const char* tag, const QJsonObject& json); 26 | static std::optional readInteger(const char* tag, const QJsonObject& json); 27 | }; 28 | -------------------------------------------------------------------------------- /src/Main.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "MainWindow.h" 5 | #include "Version.h" 6 | 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | int main(int argc, char* argv[]) 14 | { 15 | QApplication a(argc, argv); 16 | QApplication::setApplicationName(APP_TITLE); 17 | QApplication::setApplicationVersion(APP_VERSION); 18 | 19 | QList translators; 20 | auto loadTranslation = [&translators, &a](const auto& name, const auto& path) { 21 | auto* translator = translators.emplace_back(new QTranslator(&a)); 22 | if (translator->load(QLocale::system(), name, "_", path)) 23 | { 24 | QApplication::installTranslator(translator); 25 | } 26 | }; 27 | loadTranslation("soundscape", ":/i18n/"); 28 | loadTranslation("qtbase", QLibraryInfo::path(QLibraryInfo::TranslationsPath)); 29 | loadTranslation("qtmultimedia", QLibraryInfo::path(QLibraryInfo::TranslationsPath)); 30 | 31 | QCommandLineParser parser; 32 | parser.addHelpOption(); 33 | parser.addVersionOption(); 34 | 35 | const QCommandLineOption load_option("load", 36 | QCoreApplication::translate("Help", "Load track list from file."), 37 | QCoreApplication::translate("Help", "path to file")); 38 | parser.addOption(load_option); 39 | 40 | const QCommandLineOption minimize_option("minimize", QCoreApplication::translate("Help", "Minimize window to tray.")); 41 | parser.addOption(minimize_option); 42 | 43 | #if defined(Q_OS_MACOS) 44 | const QCommandLineOption tray_option("enable-tray", QCoreApplication::translate("Help", "Enable tray icon.")); 45 | #else 46 | const QCommandLineOption tray_option("disable-tray", QCoreApplication::translate("Help", "Disable tray icon.")); 47 | #endif 48 | parser.addOption(tray_option); 49 | 50 | parser.process(a); 51 | 52 | const QString file_name = parser.value(load_option); 53 | const bool minimize = parser.isSet(minimize_option); 54 | #if defined(Q_OS_MACOS) 55 | const bool disable_tray = !parser.isSet(tray_option); 56 | #else 57 | const bool disable_tray = parser.isSet(tray_option); 58 | #endif 59 | 60 | MainWindow w(disable_tray); 61 | w.start(file_name, minimize); 62 | 63 | return QApplication::exec(); 64 | } 65 | -------------------------------------------------------------------------------- /src/MainWindow.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "MainWindow.h" 5 | 6 | #include "JsonRW.h" 7 | #include "Track.h" 8 | #include "TrackControls.h" 9 | #include "Version.h" 10 | 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | 21 | MainWindow::MainWindow(const bool disable_tray, QWidget* parent) : 22 | QMainWindow(parent), 23 | m_tray_available(disable_tray ? false : QSystemTrayIcon::isSystemTrayAvailable()), 24 | m_tray_icon(new QSystemTrayIcon(this)), 25 | m_tray_menu(new QMenu(this)), 26 | m_mouse_menu(new QMenu(this)), 27 | m_widget(new QWidget(this)), 28 | m_box_layout(new QVBoxLayout(m_widget)), 29 | m_menu_info(new QLabel(this)) 30 | { 31 | setupTrayIcon(); 32 | 33 | setWindowTitle(APP_TITLE); 34 | 35 | addPauseResumeItemsToMenu(m_mouse_menu); 36 | addTrackItemsToMenu(m_mouse_menu); 37 | addQuitItemToMenu(m_mouse_menu); 38 | 39 | setCentralWidget(m_widget); 40 | 41 | m_box_layout->setContentsMargins(0, 0, 0, 0); 42 | m_box_layout->setAlignment(Qt::AlignTop); 43 | 44 | m_menu_info->setTextFormat(Qt::PlainText); 45 | m_menu_info->setText(tr("Use mouse right-click\n" 46 | "to access application menu")); 47 | m_box_layout->addWidget(m_menu_info, 0, Qt::AlignCenter); 48 | 49 | #if defined(Q_OS_MACOS) 50 | auto* menu_bar = new QMenuBar(nullptr); 51 | auto* about_menu = menu_bar->addMenu(tr("About")); 52 | auto* about_action = about_menu->addAction(tr("About")); 53 | connect(about_action, &QAction::triggered, this, &MainWindow::showAbout); 54 | #endif 55 | } 56 | 57 | void MainWindow::start(const QString& file_name, const bool hidden) 58 | { 59 | if (m_tray_available && hidden) 60 | { 61 | hide(); 62 | } 63 | else 64 | { 65 | show(); 66 | } 67 | 68 | if (!file_name.isEmpty()) 69 | { 70 | QFile file(file_name); 71 | loadTracksFromJson(file); 72 | } 73 | } 74 | 75 | void MainWindow::addPauseResumeItemsToMenu(QMenu* menu) const 76 | { 77 | auto* pause_tracks = menu->addAction(tr("Pause playing tracks")); 78 | connect(pause_tracks, &QAction::triggered, this, &MainWindow::pausePlayingTracks); 79 | 80 | auto* resume_tracks = menu->addAction(tr("Resume paused tracks")); 81 | connect(resume_tracks, &QAction::triggered, this, &MainWindow::resumePausedTracks); 82 | } 83 | 84 | void MainWindow::addTrackItemsToMenu(QMenu* menu) const 85 | { 86 | auto* add_track = menu->addAction(tr("Add track")); 87 | connect(add_track, &QAction::triggered, this, &MainWindow::addTrack); 88 | 89 | auto* save_track_list = menu->addAction(tr("Save track list")); 90 | connect(save_track_list, &QAction::triggered, this, &MainWindow::saveTrackList); 91 | 92 | auto* load_track_list = menu->addAction(tr("Load track list")); 93 | connect(load_track_list, &QAction::triggered, this, &MainWindow::loadTrackList); 94 | 95 | auto* example_menu = new QMenu(tr("Examples")); 96 | menu->addMenu(example_menu); 97 | 98 | auto* example_rain = example_menu->addAction(tr("Rain and Thunder")); 99 | connect(example_rain, &QAction::triggered, this, &MainWindow::loadExampleRain); 100 | 101 | auto* example_river = example_menu->addAction(tr("River")); 102 | connect(example_river, &QAction::triggered, this, &MainWindow::loadExampleRiver); 103 | } 104 | 105 | void MainWindow::addQuitItemToMenu(QMenu* menu) const 106 | { 107 | menu->addSeparator(); 108 | 109 | #if !defined(Q_OS_MACOS) 110 | auto* about_app = menu->addAction(tr("About")); 111 | connect(about_app, &QAction::triggered, this, &MainWindow::showAbout); 112 | #endif 113 | 114 | auto* quit_app = menu->addAction(tr("Quit")); 115 | connect(quit_app, &QAction::triggered, this, &MainWindow::quit); 116 | } 117 | 118 | void MainWindow::trayIconAction(QSystemTrayIcon::ActivationReason /*reason*/) 119 | { 120 | m_tray_icon->contextMenu()->exec(QCursor::pos()); 121 | } 122 | 123 | void MainWindow::addTrack() 124 | { 125 | QString file_name = QFileDialog::getOpenFileName(); 126 | if (!file_name.isEmpty()) 127 | { 128 | addTrackFromMedia(file_name); 129 | } 130 | } 131 | 132 | void MainWindow::saveTrackList() 133 | { 134 | QString file_name = QFileDialog::getSaveFileName(); 135 | if (!file_name.isEmpty()) 136 | { 137 | QFile file(file_name); 138 | saveTracksToJson(file); 139 | } 140 | } 141 | 142 | void MainWindow::loadTrackList() 143 | { 144 | QString file_name = QFileDialog::getOpenFileName(); 145 | if (!file_name.isEmpty()) 146 | { 147 | QFile file(file_name); 148 | loadTracksFromJson(file); 149 | } 150 | } 151 | 152 | void MainWindow::loadExample(const QString& name) 153 | { 154 | const auto& app_dir = QDir(QCoreApplication::applicationDirPath()); 155 | QString file_name("../"); 156 | #if defined Q_OS_MACOS 157 | file_name.append("Resources"); 158 | #else 159 | file_name.append(CMAKE_INSTALL_DATADIR); 160 | #endif 161 | file_name.append("/examples/"); 162 | file_name.append(name); 163 | file_name = QDir::cleanPath(app_dir.absoluteFilePath(file_name)); 164 | QFile file(file_name); 165 | loadTracksFromJson(file); 166 | } 167 | 168 | void MainWindow::loadExampleRain() 169 | { 170 | loadExample("rain_and_thunder/rain_and_thunder.json"); 171 | } 172 | 173 | void MainWindow::loadExampleRiver() 174 | { 175 | loadExample("river/river.json"); 176 | } 177 | 178 | void MainWindow::moveTrackUp(const QString& id) 179 | { 180 | auto* track = m_widget->findChild(id); 181 | if (track == nullptr) { return; } 182 | 183 | if (const auto new_index = m_box_layout->indexOf(track) - 1; 184 | new_index > 0) // menu info at index 0 185 | { 186 | m_box_layout->removeWidget(track); 187 | m_box_layout->insertWidget(new_index, track); 188 | } 189 | } 190 | 191 | void MainWindow::moveTrackDown(const QString& id) 192 | { 193 | auto* track = m_widget->findChild(id); 194 | if (track == nullptr) { return; } 195 | 196 | if (const auto new_index = m_box_layout->indexOf(track) + 1; 197 | new_index < m_box_layout->count()) 198 | { 199 | m_box_layout->removeWidget(track); 200 | m_box_layout->insertWidget(new_index, track); 201 | } 202 | } 203 | 204 | void MainWindow::removeTrack(const QString& id) 205 | { 206 | auto* track = m_widget->findChild(id); 207 | if (track == nullptr) { return; } 208 | 209 | m_box_layout->removeWidget(track); 210 | track->deleteLater(); 211 | 212 | if (m_box_layout->count() == 1) 213 | { 214 | m_menu_info->show(); 215 | } 216 | } 217 | 218 | void MainWindow::pausePlayingTracks() 219 | { 220 | for (auto* track_control : m_widget->findChildren()) 221 | { 222 | track_control->pausePlaying(); 223 | } 224 | } 225 | 226 | void MainWindow::resumePausedTracks() 227 | { 228 | for (auto* track_control : m_widget->findChildren()) 229 | { 230 | track_control->resumePaused(); 231 | } 232 | } 233 | 234 | void MainWindow::closeEvent(QCloseEvent* event) 235 | { 236 | if (m_quit || !m_tray_available) 237 | { 238 | for (auto* track_control : m_widget->findChildren()) 239 | { 240 | track_control->track()->pause(); 241 | } 242 | event->accept(); 243 | return; 244 | } 245 | 246 | windowHide(); 247 | event->ignore(); 248 | } 249 | 250 | void MainWindow::mousePressEvent(QMouseEvent* event) 251 | { 252 | if (event->button() == Qt::RightButton) 253 | { 254 | m_mouse_menu->exec(QCursor::pos()); 255 | } 256 | } 257 | 258 | void MainWindow::quit() 259 | { 260 | m_quit = true; 261 | QApplication::quit(); 262 | } 263 | 264 | void MainWindow::setupTrayIcon() 265 | { 266 | if (!m_tray_available) { return; } 267 | 268 | Qt::WindowFlags flags = Qt::CustomizeWindowHint | 269 | Qt::WindowMaximizeButtonHint | 270 | Qt::WindowCloseButtonHint; 271 | setWindowFlags(flags); 272 | 273 | auto* show_window = m_tray_menu->addAction(tr("Show window")); 274 | connect(show_window, &QAction::triggered, this, &MainWindow::windowShow); 275 | 276 | addPauseResumeItemsToMenu(m_tray_menu); 277 | addQuitItemToMenu(m_tray_menu); 278 | 279 | m_tray_icon->setContextMenu(m_tray_menu); 280 | connect(m_tray_icon, &QSystemTrayIcon::activated, this, &MainWindow::trayIconAction); 281 | 282 | const QIcon& icon = QIcon(":/icons/icon.svg"); 283 | #if defined Q_OS_LINUX 284 | m_tray_icon->setIcon(icon.pixmap(256, 256)); 285 | #else 286 | m_tray_icon->setIcon(icon); 287 | #endif 288 | setWindowIcon(icon); 289 | 290 | m_tray_icon->show(); 291 | } 292 | 293 | void MainWindow::windowFocus() 294 | { 295 | raise(); 296 | activateWindow(); 297 | } 298 | 299 | void MainWindow::windowShow() 300 | { 301 | showNormal(); 302 | #if defined(Q_OS_LINUX) 303 | restoreGeometry(m_old_geometry); 304 | #endif 305 | windowFocus(); 306 | } 307 | 308 | void MainWindow::windowHide() 309 | { 310 | #if defined(Q_OS_LINUX) 311 | m_old_geometry = saveGeometry(); 312 | #endif 313 | showMinimized(); 314 | setVisible(false); 315 | } 316 | 317 | void MainWindow::addTrackFromMedia(const QString& file_name) 318 | { 319 | QJsonObject json; 320 | json[JsonRW::FileNameTag] = file_name; 321 | auto* track = new TrackControls(json, QDir(), this); 322 | m_box_layout->addWidget(track); 323 | m_menu_info->hide(); 324 | } 325 | 326 | void MainWindow::saveTracksToJson(QFile& file) 327 | { 328 | if (!file.open(QIODevice::WriteOnly)) 329 | { 330 | const auto& title = tr("Save Tracks"); 331 | const auto& message = tr("couldn't open file: %1").arg(file.fileName()); 332 | QMessageBox::warning(this, title, message); 333 | return; 334 | } 335 | 336 | const QFileInfo file_info(file.fileName()); 337 | const auto& base_dir = file_info.dir(); 338 | QJsonObject data; 339 | QJsonArray tracks_data; 340 | for (int i = 0; i != m_box_layout->count(); ++i) 341 | { 342 | const auto* track_control = dynamic_cast(m_box_layout->itemAt(i)->widget()); 343 | if (track_control) { tracks_data.append(track_control->track()->toJsonObject(base_dir)); } 344 | } 345 | data[JsonRW::TracksTag] = tracks_data; 346 | file.write(QJsonDocument(data).toJson()); 347 | } 348 | 349 | void MainWindow::loadTracksFromJson(QFile& file) 350 | { 351 | if (!file.open(QIODevice::ReadOnly)) 352 | { 353 | const auto& title = tr("Load Tracks"); 354 | const auto& message = tr("couldn't open file: %1").arg(file.fileName()); 355 | QMessageBox::warning(this, title, message); 356 | return; 357 | } 358 | 359 | QFileInfo file_info(file.fileName()); 360 | const auto& base_dir = file_info.dir(); 361 | 362 | const auto& json_doc = QJsonDocument::fromJson(file.readAll()); 363 | const auto& json = json_doc.object(); 364 | 365 | if (json.contains(JsonRW::TracksTag) && json[JsonRW::TracksTag].isArray()) 366 | { 367 | const auto& tracks = json[JsonRW::TracksTag].toArray(); 368 | for (const auto& jdata : tracks) 369 | { 370 | auto* track = new TrackControls(jdata.toObject(), base_dir, this); 371 | m_box_layout->addWidget(track); 372 | } 373 | } 374 | 375 | if (m_box_layout->count() > 1) 376 | { 377 | m_menu_info->hide(); 378 | } 379 | } 380 | 381 | void MainWindow::showAbout() 382 | { 383 | QString info; 384 | info.append(QString("

%1

\n").arg(APP_TITLE)); 385 | info.append("

" + tr("Version: %1").arg(APP_VERSION) + "

" + "\n"); 386 | 387 | info.append(tr("open-source system-tray resident desktop application for playing soundscapes") + "
" + "\n"); 388 | info.append("
"); 389 | 390 | info.append(tr(R"(Website: %1)").arg(WEB_SITE) + "
" + "\n"); 391 | info.append(tr("Copyright: © 2022-2024 Denis Danilov and contributors") + "
" + "\n"); 392 | info.append(tr("License: GNU General Public License (GPL) version 3") + "
" + "\n"); 393 | info.append("
"); 394 | 395 | info.append(tr("Qt Version: %1").arg(qVersion()) + "\n"); 396 | info.append("
"); 397 | 398 | QMessageBox::about(this, tr("About"), info); 399 | } 400 | -------------------------------------------------------------------------------- /src/MainWindow.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | class TrackControls; 15 | 16 | class MainWindow : public QMainWindow 17 | { 18 | Q_OBJECT 19 | 20 | public: 21 | explicit MainWindow(bool disable_tray = false, QWidget* parent = nullptr); 22 | 23 | void start(const QString& file_name, bool hidden = false); 24 | 25 | void addPauseResumeItemsToMenu(QMenu* menu) const; 26 | void addTrackItemsToMenu(QMenu* menu) const; 27 | void addQuitItemToMenu(QMenu* menu) const; 28 | 29 | public slots: 30 | void trayIconAction(QSystemTrayIcon::ActivationReason reason); 31 | 32 | void addTrack(); 33 | void saveTrackList(); 34 | void loadTrackList(); 35 | 36 | void loadExample(const QString& name); 37 | void loadExampleRain(); 38 | void loadExampleRiver(); 39 | 40 | void moveTrackUp(const QString& id); 41 | void moveTrackDown(const QString& id); 42 | void removeTrack(const QString& id); 43 | 44 | void pausePlayingTracks(); 45 | void resumePausedTracks(); 46 | 47 | protected: 48 | void closeEvent(QCloseEvent* event) override; 49 | void mousePressEvent(QMouseEvent* event) override; 50 | 51 | private slots: 52 | void quit(); 53 | 54 | private: 55 | void setupTrayIcon(); 56 | void windowFocus(); 57 | void windowShow(); 58 | void windowHide(); 59 | 60 | void addTrackFromMedia(const QString& file_name); 61 | void saveTracksToJson(QFile& file); 62 | void loadTracksFromJson(QFile& file); 63 | 64 | void showAbout(); 65 | 66 | QAtomicInteger m_quit{false}; 67 | bool m_tray_available; 68 | QPointer m_tray_icon; 69 | QPointer m_tray_menu; 70 | QPointer m_mouse_menu; 71 | #if defined(Q_OS_LINUX) 72 | QByteArray m_old_geometry; 73 | #endif 74 | 75 | QPointer m_widget; 76 | QPointer m_box_layout; 77 | QPointer m_menu_info; 78 | 79 | friend class TestMainWindow; 80 | }; 81 | -------------------------------------------------------------------------------- /src/Player.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "Player.h" 5 | 6 | #include "Track.h" 7 | 8 | #include 9 | 10 | Player::Player(Track* parent) : 11 | QMediaPlayer(parent), 12 | m_track(parent), 13 | m_ready(false), 14 | m_active(false), 15 | m_next_media_player(nullptr), 16 | m_next_player_timer(new QTimer(this)) 17 | { 18 | setAudioOutput(new QAudioOutput(this)); 19 | audioOutput()->setVolume(0); 20 | 21 | connect(this, &QMediaPlayer::mediaStatusChanged, this, &Player::mediaPlayerStatusChanged); 22 | connect(this, &QMediaPlayer::positionChanged, this, &Player::mediaPlayerPositionChanged); 23 | connect(this, &QMediaPlayer::errorOccurred, m_track, &Track::playerErrorOccurred); 24 | 25 | m_next_player_timer->setSingleShot(true); 26 | } 27 | 28 | bool Player::isReady() const 29 | { 30 | return m_ready; 31 | } 32 | 33 | void Player::setNextPlayer(Player* mediaPlayer) 34 | { 35 | m_next_media_player = mediaPlayer; 36 | } 37 | 38 | bool Player::playActive(const bool force) 39 | { 40 | if (force) { m_active = true; } 41 | if (m_active) 42 | { 43 | play(); 44 | return true; 45 | } 46 | return false; 47 | } 48 | 49 | void Player::pauseActive() 50 | { 51 | m_next_player_timer->stop(); 52 | pause(); 53 | } 54 | 55 | bool Player::skipToStartActive(const bool with_pause) 56 | { 57 | if (with_pause) { pause(); } 58 | if (m_active) 59 | { 60 | setPosition(0); 61 | return true; 62 | } 63 | return false; 64 | } 65 | 66 | void Player::mediaPlayerStatusChanged(MediaStatus status) 67 | { 68 | if (!m_ready && status == QMediaPlayer::MediaStatus::LoadedMedia) 69 | { 70 | if (!hasAudio()) 71 | { 72 | emit errorOccurred(QMediaPlayer::Error::FormatError, QString("track has no audio")); 73 | return; 74 | } 75 | 76 | if (duration() <= 0) 77 | { 78 | emit errorOccurred(QMediaPlayer::Error::FormatError, QString("duration is 0")); 79 | return; 80 | } 81 | m_ready = true; 82 | emit playerLoaded(); 83 | setupNextPlayer(); 84 | return; 85 | } 86 | 87 | if (m_ready && status == QMediaPlayer::MediaStatus::EndOfMedia) 88 | { 89 | m_active = false; 90 | if (m_track->transition() == Transition::FadeOutIn || 91 | m_track->transition() == Transition::FadeOutGapIn) 92 | { 93 | startNextPlayerOutIn(); 94 | } 95 | pause(); 96 | setPosition(0); 97 | } 98 | } 99 | 100 | void Player::mediaPlayerPositionChanged(qint64 position) 101 | { 102 | if (!m_active) { return; } 103 | const auto volume = m_track->fadeVolume(position); 104 | audioOutput()->setVolume(volume); 105 | startNextPlayerCrossFade(position); 106 | } 107 | 108 | void Player::setupNextPlayer() 109 | { 110 | if (m_next_media_player) 111 | { 112 | connect(m_next_player_timer, &QTimer::timeout, m_next_media_player, [this]() { m_next_media_player->playActive(true); }); 113 | if (!m_next_media_player->isReady()) { m_next_media_player->setSource(source()); } 114 | } 115 | } 116 | 117 | void Player::startNextPlayerOutIn() 118 | { 119 | if (m_next_media_player->playbackState() == QMediaPlayer::PlaybackState::PlayingState) { return; } 120 | if (m_track->transition() == Transition::FadeOutGapIn) 121 | { 122 | m_next_player_timer->stop(); 123 | const auto delay = m_track->startDelay(); 124 | m_next_player_timer->setInterval(delay); 125 | m_next_player_timer->start(); 126 | } 127 | else 128 | { 129 | m_next_media_player->playActive(true); 130 | } 131 | } 132 | 133 | void Player::startNextPlayerCrossFade(qint64 position) 134 | { 135 | if (m_track->transition() != Transition::CrossFade) { return; } 136 | if (m_next_media_player->playbackState() == QMediaPlayer::PlaybackState::PlayingState) { return; } 137 | if (m_track->startNextPlayer(position)) 138 | { 139 | m_next_media_player->playActive(true); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Player.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | class Track; 11 | 12 | class Player : public QMediaPlayer 13 | { 14 | Q_OBJECT 15 | 16 | public: 17 | explicit Player(Track* parent); 18 | bool isReady() const; 19 | void setNextPlayer(Player* mediaPlayer); 20 | bool playActive(bool force = false); 21 | void pauseActive(); 22 | bool skipToStartActive(bool with_pause = false); 23 | 24 | signals: 25 | void playerLoaded(); 26 | 27 | private slots: 28 | void mediaPlayerStatusChanged(QMediaPlayer::MediaStatus status); 29 | void mediaPlayerPositionChanged(qint64 position); 30 | void setupNextPlayer(); 31 | 32 | private: 33 | void startNextPlayerOutIn(); 34 | void startNextPlayerCrossFade(qint64 position); 35 | 36 | const Track* m_track; 37 | 38 | bool m_ready; 39 | bool m_active; 40 | Player* m_next_media_player; 41 | QPointer m_next_player_timer; 42 | 43 | friend class TestPlayer; 44 | friend class TestTrack; 45 | friend class TestTrackControls; 46 | }; 47 | -------------------------------------------------------------------------------- /src/PositionLabel.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "PositionLabel.h" 5 | 6 | PositionLabel::PositionLabel(QWidget* parent) : 7 | QLabel(parent) 8 | { 9 | setTextFormat(Qt::PlainText); 10 | } 11 | 12 | void PositionLabel::setMax(double value) 13 | { 14 | QString s; 15 | s.setNum(value, 'f', m_precision); 16 | m_field_width = static_cast(s.size()); 17 | } 18 | 19 | void PositionLabel::setValue(double value) 20 | { 21 | setText(m_text_template.arg(value, m_field_width, 'f', m_precision, '0')); 22 | } 23 | -------------------------------------------------------------------------------- /src/PositionLabel.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | 8 | class PositionLabel : public QLabel 9 | { 10 | Q_OBJECT 11 | 12 | public: 13 | explicit PositionLabel(QWidget* parent = nullptr); 14 | void setMax(double value); 15 | void setValue(double value); 16 | 17 | private: 18 | const QString m_text_template{tr("%1 s")}; 19 | int m_field_width{3}; 20 | int m_precision{1}; 21 | 22 | friend class TestPositionLabel; 23 | }; 24 | -------------------------------------------------------------------------------- /src/PositionSlider.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "PositionSlider.h" 5 | 6 | PositionSlider::PositionSlider(QWidget* parent) : 7 | QSlider(parent) 8 | { 9 | setOrientation(Qt::Horizontal); 10 | setRange(0, 500); 11 | } 12 | -------------------------------------------------------------------------------- /src/PositionSlider.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | 8 | class PositionSlider : public QSlider 9 | { 10 | Q_OBJECT 11 | 12 | public: 13 | explicit PositionSlider(QWidget* parent = nullptr); 14 | }; 15 | -------------------------------------------------------------------------------- /src/Status.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "Status.h" 5 | 6 | #include 7 | 8 | Status::Status(QWidget* parent) : 9 | QCheckBox(parent) 10 | { 11 | QFile file(":/styles/status.css"); 12 | file.open(QIODevice::ReadOnly); 13 | QString style_template(file.readAll()); 14 | m_playing_style = style_template.arg(":/icons/switch-on.svg", ":/icons/switch-off.svg"); 15 | m_paused_style = style_template.arg(":/icons/switch-paused.svg", ":/icons/switch-off.svg"); 16 | 17 | const auto p = fontInfo().pixelSize(); 18 | const auto h = static_cast(2.0 * p); 19 | const auto w = 2 * h; 20 | 21 | m_playing_style.append(QString("QCheckBox::indicator {width: %1; height: %2; }").arg(w).arg(h)); 22 | m_paused_style.append(QString("QCheckBox::indicator {width: %1; height: %2; }").arg(w).arg(h)); 23 | 24 | setPlayingStyle(); 25 | 26 | updateToolTip(checkState()); 27 | #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) 28 | connect(this, &Status::checkStateChanged, this, &Status::updateToolTip); 29 | #else 30 | connect(this, &Status::stateChanged, this, &Status::updateToolTip); 31 | #endif 32 | } 33 | 34 | void Status::setPlayingStyle() 35 | { 36 | setStyleSheet(m_playing_style); 37 | } 38 | 39 | void Status::setPausedStyle() 40 | { 41 | setStyleSheet(m_paused_style); 42 | } 43 | 44 | void Status::updateToolTip(int /*value*/) 45 | { 46 | switch (checkState()) 47 | { 48 | case Qt::CheckState::Unchecked: 49 | setToolTip(tr("paused")); 50 | break; 51 | case Qt::CheckState::Checked: 52 | setToolTip(tr("playing")); 53 | break; 54 | default: 55 | return; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Status.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | 8 | class Status : public QCheckBox 9 | { 10 | Q_OBJECT 11 | 12 | public: 13 | explicit Status(QWidget* parent = nullptr); 14 | void setPlayingStyle(); 15 | void setPausedStyle(); 16 | 17 | private: 18 | void updateToolTip(int value); 19 | 20 | QString m_playing_style; 21 | QString m_paused_style; 22 | }; 23 | -------------------------------------------------------------------------------- /src/Track.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "Track.h" 5 | 6 | #include "JsonRW.h" 7 | #include "Player.h" 8 | 9 | #include 10 | 11 | Track::Track(QObject* parent) : 12 | QObject(parent), 13 | m_player_A(new Player(this)), 14 | m_player_B(new Player(this)), 15 | m_file_name(), 16 | m_volume(0.5), 17 | m_playing(true), 18 | m_track_duration(-1), 19 | m_fade_in_duration(-1), 20 | m_fade_out_duration(-1), 21 | m_transition(Transition::FadeOutIn), 22 | m_gap(10), 23 | m_gap_max(300), 24 | m_random_gap(false) 25 | { 26 | m_player_A->setNextPlayer(m_player_B); 27 | m_player_B->setNextPlayer(m_player_A); 28 | 29 | connect(m_player_A, &Player::playerLoaded, this, &Track::playerLoaded); 30 | } 31 | 32 | void Track::fromJsonObject(const QJsonObject& json, const QDir& base_dir) 33 | { 34 | m_file_name = JsonRW::readString(JsonRW::FileNameTag, json).value_or(m_file_name); 35 | m_volume = JsonRW::readDouble(JsonRW::VolumeTag, json).value_or(m_volume); 36 | m_playing = JsonRW::readBool(JsonRW::PlayingTag, json).value_or(m_playing); 37 | m_fade_in_duration = JsonRW::readInteger(JsonRW::FadeInDurationTag, json).value_or(m_fade_in_duration); 38 | m_fade_out_duration = JsonRW::readInteger(JsonRW::FadeOutDurationTag, json).value_or(m_fade_out_duration); 39 | m_transition = static_cast(JsonRW::readInteger(JsonRW::TransitionTag, json).value_or(static_cast(m_transition))); 40 | m_gap = JsonRW::readDouble(JsonRW::GapTag, json).value_or(m_gap); 41 | m_gap_max = JsonRW::readDouble(JsonRW::GapMaxTag, json).value_or(m_gap_max); 42 | m_random_gap = JsonRW::readBool(JsonRW::RandomGapTag, json).value_or(m_random_gap); 43 | 44 | if (!m_file_name.isEmpty()) 45 | { 46 | m_file_name = QDir::cleanPath(base_dir.absoluteFilePath(m_file_name)); 47 | const auto& source = QUrl::fromLocalFile(m_file_name); 48 | m_player_A->setSource(source); 49 | } 50 | } 51 | 52 | QJsonObject Track::toJsonObject(const QDir& base_dir) const 53 | { 54 | QJsonObject json; 55 | json[JsonRW::FileNameTag] = base_dir.relativeFilePath(m_file_name); 56 | json[JsonRW::VolumeTag] = m_volume; 57 | json[JsonRW::PlayingTag] = m_playing; 58 | json[JsonRW::FadeInDurationTag] = m_fade_in_duration; 59 | json[JsonRW::FadeOutDurationTag] = m_fade_out_duration; 60 | json[JsonRW::TransitionTag] = static_cast(m_transition); 61 | json[JsonRW::GapTag] = m_gap; 62 | json[JsonRW::GapMaxTag] = m_gap_max; 63 | json[JsonRW::RandomGapTag] = m_random_gap; 64 | return json; 65 | } 66 | 67 | QString Track::title() const 68 | { 69 | QFileInfo file_info(m_file_name); 70 | return file_info.baseName(); 71 | } 72 | 73 | QString Track::fileName() const 74 | { 75 | QFileInfo file_info(m_file_name); 76 | return file_info.fileName(); 77 | } 78 | 79 | double Track::volume() const 80 | { 81 | return m_volume; 82 | } 83 | 84 | void Track::setVolume(double volume) 85 | { 86 | m_volume = volume; 87 | } 88 | 89 | float Track::fadeVolume(qint64 position) const 90 | { 91 | const auto coeff = fade(position); 92 | auto volume = static_cast(m_volume); 93 | switch (m_transition) 94 | { 95 | case Transition::FadeOutIn: 96 | case Transition::FadeOutGapIn: 97 | volume = QAudio::convertVolume(coeff * volume, QAudio::VolumeScale::LogarithmicVolumeScale, QAudio::VolumeScale::LinearVolumeScale); 98 | break; 99 | case Transition::CrossFade: 100 | volume = coeff * QAudio::convertVolume(volume, QAudio::VolumeScale::LogarithmicVolumeScale, QAudio::VolumeScale::LinearVolumeScale); 101 | break; 102 | } 103 | return volume; 104 | } 105 | 106 | bool Track::isPlaying() const 107 | { 108 | return m_playing; 109 | } 110 | 111 | void Track::play() 112 | { 113 | m_playing = true; 114 | const auto a = m_player_A->playActive(); 115 | const auto b = m_player_B->playActive(); 116 | if (!(a || b)) { m_player_A->playActive(true); } 117 | } 118 | 119 | void Track::pause() 120 | { 121 | m_playing = false; 122 | m_player_A->pauseActive(); 123 | m_player_B->pauseActive(); 124 | } 125 | 126 | void Track::skipToStart() 127 | { 128 | const auto a = m_player_A->skipToStartActive(); 129 | m_player_B->skipToStartActive(a); 130 | } 131 | 132 | qint64 Track::duration() const 133 | { 134 | return m_track_duration; 135 | } 136 | 137 | qint64 Track::fadeInDuration() const 138 | { 139 | return m_fade_in_duration; 140 | } 141 | 142 | void Track::setFadeInDuration(qint64 value) 143 | { 144 | m_fade_in_duration = value; 145 | } 146 | 147 | qint64 Track::fadeOutDuration() const 148 | { 149 | return m_fade_out_duration; 150 | } 151 | 152 | void Track::setFadeOutDuration(qint64 value) 153 | { 154 | m_fade_out_duration = value; 155 | } 156 | 157 | Transition Track::transition() const 158 | { 159 | return m_transition; 160 | } 161 | 162 | void Track::setTransition(Transition transition) 163 | { 164 | m_transition = transition; 165 | } 166 | 167 | bool Track::startNextPlayer(qint64 position) const 168 | { 169 | if (!m_playing) { return false; } 170 | auto threshold = m_track_duration; 171 | if (m_transition == Transition::CrossFade) 172 | { 173 | threshold -= m_fade_out_duration; 174 | } 175 | return position >= threshold; 176 | } 177 | 178 | double Track::gap() const 179 | { 180 | return m_gap; 181 | } 182 | 183 | void Track::setGap(double value) 184 | { 185 | m_gap = value; 186 | } 187 | 188 | double Track::maxGap() const 189 | { 190 | return m_gap_max; 191 | } 192 | 193 | void Track::setMaxGap(double value) 194 | { 195 | m_gap_max = value; 196 | } 197 | 198 | bool Track::randomGap() const 199 | { 200 | return m_random_gap; 201 | } 202 | 203 | void Track::setRandomGap(bool value) 204 | { 205 | m_random_gap = value; 206 | } 207 | 208 | int Track::startDelay() const 209 | { 210 | auto d = m_gap; 211 | if (m_random_gap) 212 | { 213 | d += (m_gap_max - m_gap) * QRandomGenerator::global()->generateDouble(); 214 | } 215 | return static_cast(d * 1000); 216 | } 217 | 218 | Player* Track::playerA() const 219 | { 220 | return m_player_A; 221 | } 222 | 223 | Player* Track::playerB() const 224 | { 225 | return m_player_B; 226 | } 227 | 228 | const QList& Track::errors() const 229 | { 230 | return m_errors; 231 | } 232 | 233 | void Track::playerLoaded() 234 | { 235 | m_track_duration = m_player_A->duration(); 236 | 237 | constexpr qint64 duration_5sec = 5 * 1000; 238 | if (m_fade_in_duration < 0) { m_fade_in_duration = std::min(duration_5sec, m_track_duration / 4); } 239 | if (m_fade_out_duration < 0) { m_fade_out_duration = m_fade_in_duration; } 240 | 241 | if (m_playing) { play(); } 242 | 243 | emit loaded(); 244 | } 245 | 246 | void Track::playerErrorOccurred(QMediaPlayer::Error /*error*/, const QString& error_string) 247 | { 248 | pause(); 249 | m_errors.push_back(error_string); 250 | emit errorOccurred(); 251 | } 252 | 253 | float Track::fade(qint64 position) const 254 | { 255 | if (m_track_duration <= 0) 256 | { 257 | return 0.0; 258 | } 259 | 260 | if (position < 0 || position > m_track_duration) 261 | { 262 | return 0.0; 263 | } 264 | 265 | if (position < m_fade_in_duration) 266 | { 267 | return static_cast(position) / static_cast(m_fade_in_duration); 268 | } 269 | 270 | if (position > m_track_duration - m_fade_out_duration) 271 | { 272 | return static_cast(m_track_duration - position) / static_cast(m_fade_out_duration); 273 | } 274 | 275 | return 1.0; 276 | } 277 | -------------------------------------------------------------------------------- /src/Track.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include "Transition.h" 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | class Player; 14 | 15 | class Track : public QObject 16 | { 17 | Q_OBJECT 18 | 19 | public: 20 | explicit Track(QObject* parent = nullptr); 21 | 22 | void fromJsonObject(const QJsonObject& json, const QDir& base_dir); 23 | QJsonObject toJsonObject(const QDir& base_dir) const; 24 | 25 | QString title() const; 26 | QString fileName() const; 27 | 28 | double volume() const; 29 | void setVolume(double volume); 30 | float fadeVolume(qint64 position) const; 31 | 32 | bool isPlaying() const; 33 | void play(); 34 | void pause(); 35 | void skipToStart(); 36 | 37 | qint64 duration() const; 38 | 39 | qint64 fadeInDuration() const; 40 | void setFadeInDuration(qint64 value); 41 | qint64 fadeOutDuration() const; 42 | void setFadeOutDuration(qint64 value); 43 | 44 | Transition transition() const; 45 | void setTransition(Transition transition); 46 | bool startNextPlayer(qint64 position) const; 47 | 48 | double gap() const; 49 | void setGap(double value); 50 | double maxGap() const; 51 | void setMaxGap(double value); 52 | bool randomGap() const; 53 | void setRandomGap(bool value); 54 | int startDelay() const; 55 | 56 | Player* playerA() const; 57 | Player* playerB() const; 58 | 59 | const QList& errors() const; 60 | 61 | public slots: 62 | void playerErrorOccurred(QMediaPlayer::Error error, const QString& error_string); 63 | 64 | signals: 65 | void loaded(); 66 | void errorOccurred(); 67 | 68 | private slots: 69 | void playerLoaded(); 70 | 71 | private: 72 | float fade(qint64 position) const; 73 | 74 | QPointer m_player_A; 75 | QPointer m_player_B; 76 | 77 | QString m_file_name; 78 | double m_volume; 79 | bool m_playing; 80 | qint64 m_track_duration; 81 | qint64 m_fade_in_duration; 82 | qint64 m_fade_out_duration; 83 | Transition m_transition; 84 | double m_gap; 85 | double m_gap_max; 86 | bool m_random_gap; 87 | 88 | QList m_errors; 89 | 90 | friend class TestTrack; 91 | }; 92 | -------------------------------------------------------------------------------- /src/TrackControls.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "TrackControls.h" 5 | 6 | #include "MainWindow.h" 7 | #include "Status.h" 8 | #include "Track.h" 9 | #include "TrackSettings.h" 10 | #include "TransitionIcon.h" 11 | #include "Volume.h" 12 | 13 | #include 14 | #include 15 | #include 16 | 17 | TrackControls::TrackControls(const QJsonObject& json, const QDir& base_dir, MainWindow* parent) : 18 | QFrame(parent), 19 | m_main_window(parent), 20 | m_layout(new QHBoxLayout(this)), 21 | m_mouse_menu(new QMenu(this)), 22 | m_track(new Track(this)), 23 | m_volume_control(new Volume(this)), 24 | m_transition_control(new TransitionIcon(this)), 25 | m_status_control(new Status(this)), 26 | m_settings(new TrackSettings(this)) 27 | { 28 | setObjectName(QUuid::createUuid().toString()); 29 | 30 | auto spacing = fontInfo().pixelSize() / 2; 31 | QFile file(":/styles/track-controls.css"); 32 | file.open(QIODevice::OpenModeFlag::ReadOnly); 33 | const QString& style = QString(file.readAll()).arg(spacing).arg(spacing / 2); 34 | setStyleSheet(style); 35 | 36 | addItemsToMenu(m_mouse_menu); 37 | m_mouse_menu->addSeparator(); 38 | m_main_window->addPauseResumeItemsToMenu(m_mouse_menu); 39 | m_main_window->addTrackItemsToMenu(m_mouse_menu); 40 | m_main_window->addQuitItemToMenu(m_mouse_menu); 41 | 42 | connect(m_track, &Track::loaded, this, &TrackControls::trackLoaded); 43 | connect(m_track, &Track::errorOccurred, this, &TrackControls::playerError); 44 | 45 | setupControls(); 46 | disableControls(); 47 | 48 | m_track->fromJsonObject(json, base_dir); 49 | } 50 | 51 | Track* TrackControls::track() const 52 | { 53 | return m_track; 54 | } 55 | 56 | void TrackControls::pausePlaying() 57 | { 58 | if (m_status_control->isChecked() && m_track->isPlaying()) 59 | { 60 | m_track->pause(); 61 | m_status_control->setPausedStyle(); 62 | } 63 | } 64 | 65 | void TrackControls::resumePaused() 66 | { 67 | if (m_status_control->isChecked() && !m_track->isPlaying()) 68 | { 69 | m_track->play(); 70 | m_status_control->setPlayingStyle(); 71 | } 72 | } 73 | 74 | void TrackControls::moveUp() 75 | { 76 | m_main_window->moveTrackUp(objectName()); 77 | } 78 | 79 | void TrackControls::moveDown() 80 | { 81 | m_main_window->moveTrackDown(objectName()); 82 | } 83 | 84 | void TrackControls::remove() 85 | { 86 | m_main_window->removeTrack(objectName()); 87 | } 88 | 89 | void TrackControls::volumeChanged(int value) 90 | { 91 | const auto val = static_cast(value); 92 | const auto max = static_cast(m_volume_control->maximum()); 93 | const auto volume = val / max; 94 | m_track->setVolume(volume); 95 | } 96 | 97 | void TrackControls::transitionChanged(int state) 98 | { 99 | m_track->setTransition(convertTransition(static_cast(state))); 100 | } 101 | 102 | void TrackControls::statusChanged(int state) 103 | { 104 | switch (state) 105 | { 106 | case Qt::CheckState::Unchecked: 107 | m_track->pause(); 108 | break; 109 | case Qt::CheckState::Checked: 110 | m_track->play(); 111 | m_status_control->setPlayingStyle(); 112 | break; 113 | } 114 | } 115 | 116 | void TrackControls::skipToStart() 117 | { 118 | m_track->skipToStart(); 119 | } 120 | 121 | void TrackControls::trackLoaded() 122 | { 123 | if (m_track->errors().empty()) 124 | { 125 | updateControls(); 126 | enableControls(); 127 | emit updated(); 128 | } 129 | } 130 | 131 | void TrackControls::playerError() 132 | { 133 | if (m_track->errors().size() == 1) 134 | { 135 | disableControls(); 136 | updateControls(); 137 | 138 | const auto& error = QString("[error] %1: %2").arg(m_track->fileName(), m_track->errors().front()); 139 | m_volume_control->setToolTip(error); 140 | 141 | m_transition_control->setToolTip(error); 142 | m_transition_control->installEventFilter(this); 143 | 144 | m_status_control->setToolTip(error); 145 | m_status_control->installEventFilter(this); 146 | 147 | emit updated(); 148 | } 149 | } 150 | 151 | void TrackControls::mousePressEvent(QMouseEvent* event) 152 | { 153 | if (event->button() == Qt::MouseButton::RightButton) 154 | { 155 | m_mouse_menu->exec(QCursor::pos()); 156 | } 157 | } 158 | 159 | bool TrackControls::eventFilter(QObject* watched, QEvent* event) 160 | { 161 | if (watched == m_transition_control || 162 | watched == m_status_control) 163 | { 164 | if (event->type() == QEvent::Type::MouseButtonPress) 165 | { 166 | auto* mouseEvent = dynamic_cast(event); 167 | mousePressEvent(mouseEvent); 168 | return true; 169 | } 170 | return false; 171 | } 172 | 173 | return QFrame::eventFilter(watched, event); 174 | } 175 | 176 | void TrackControls::addItemsToMenu(QMenu* menu) const 177 | { 178 | auto* skip_to_start = menu->addAction(tr("Skip to start")); 179 | connect(skip_to_start, &QAction::triggered, this, &TrackControls::skipToStart); 180 | 181 | auto* show_settings = menu->addAction(tr("Edit Settings")); 182 | connect(show_settings, &QAction::triggered, this, [this]() { m_settings->show(); }); 183 | 184 | auto* move_track_up = menu->addAction(tr("Move Up")); 185 | connect(move_track_up, &QAction::triggered, this, &TrackControls::moveUp); 186 | 187 | auto* move_track_down = menu->addAction(tr("Move Down")); 188 | connect(move_track_down, &QAction::triggered, this, &TrackControls::moveDown); 189 | 190 | auto* remove_track = menu->addAction(tr("Remove")); 191 | connect(remove_track, &QAction::triggered, this, &TrackControls::remove); 192 | } 193 | 194 | void TrackControls::setupControls() 195 | { 196 | auto spacing = fontInfo().pixelSize() / 2; 197 | 198 | m_layout->setContentsMargins(0, 0, 0, 0); 199 | m_layout->addWidget(m_volume_control, 0, Qt::AlignmentFlag::AlignLeft); 200 | m_layout->addSpacing(spacing); 201 | m_layout->addWidget(m_transition_control, 0, Qt::AlignmentFlag::AlignLeft); 202 | m_layout->addSpacing(spacing); 203 | m_layout->addWidget(m_status_control, 1, Qt::AlignmentFlag::AlignLeft); 204 | 205 | setLayout(m_layout); 206 | 207 | connect(m_volume_control, &QDial::valueChanged, this, &TrackControls::volumeChanged); 208 | #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) 209 | connect(m_transition_control, &QCheckBox::checkStateChanged, this, &TrackControls::transitionChanged); 210 | connect(m_status_control, &QCheckBox::checkStateChanged, this, &TrackControls::statusChanged); 211 | #else 212 | connect(m_transition_control, &QCheckBox::stateChanged, this, &TrackControls::transitionChanged); 213 | connect(m_status_control, &QCheckBox::stateChanged, this, &TrackControls::statusChanged); 214 | #endif 215 | 216 | m_transition_control->setTristate(true); 217 | } 218 | 219 | void TrackControls::disableControls() 220 | { 221 | m_volume_control->setDisabled(true); 222 | m_transition_control->setDisabled(true); 223 | m_status_control->setDisabled(true); 224 | } 225 | 226 | void TrackControls::enableControls() 227 | { 228 | m_volume_control->setEnabled(true); 229 | m_transition_control->setEnabled(true); 230 | m_status_control->setEnabled(true); 231 | } 232 | 233 | void TrackControls::updateControls() 234 | { 235 | const auto volume = m_track->volume(); 236 | const auto max = static_cast(m_volume_control->maximum()); 237 | const auto val = static_cast(volume * max); 238 | m_volume_control->setValue(val); 239 | 240 | m_transition_control->setCheckState(convertTransition(m_track->transition())); 241 | 242 | m_status_control->setChecked(m_track->isPlaying()); 243 | m_status_control->setText(m_track->title()); 244 | } 245 | -------------------------------------------------------------------------------- /src/TrackControls.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | class MainWindow; 13 | class Status; 14 | class Track; 15 | class TrackSettings; 16 | class TransitionIcon; 17 | class Volume; 18 | 19 | class TrackControls : public QFrame 20 | { 21 | Q_OBJECT 22 | 23 | public: 24 | explicit TrackControls(const QJsonObject& json, const QDir& base_dir, MainWindow* parent); 25 | Track* track() const; 26 | void pausePlaying(); 27 | void resumePaused(); 28 | 29 | public slots: 30 | void moveUp(); 31 | void moveDown(); 32 | void remove(); 33 | void volumeChanged(int value); 34 | void transitionChanged(int state); 35 | void statusChanged(int state); 36 | void skipToStart(); 37 | void trackLoaded(); 38 | void playerError(); 39 | 40 | signals: 41 | void updated(); 42 | 43 | protected: 44 | void mousePressEvent(QMouseEvent* event) override; 45 | bool eventFilter(QObject* watched, QEvent* event) override; 46 | 47 | private: 48 | void addItemsToMenu(QMenu* menu) const; 49 | void setupControls(); 50 | void disableControls(); 51 | void enableControls(); 52 | void updateControls(); 53 | 54 | MainWindow* m_main_window; 55 | 56 | QPointer m_layout; 57 | QPointer m_mouse_menu; 58 | 59 | QPointer m_track; 60 | QPointer m_volume_control; 61 | QPointer m_transition_control; 62 | QPointer m_status_control; 63 | 64 | QPointer m_settings; 65 | 66 | friend class TestTrackControls; 67 | }; 68 | -------------------------------------------------------------------------------- /src/TrackSettings.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "TrackSettings.h" 5 | 6 | #include "IconLabel.h" 7 | #include "Player.h" 8 | #include "PositionLabel.h" 9 | #include "PositionSlider.h" 10 | #include "Track.h" 11 | #include "TrackControls.h" 12 | #include "Version.h" 13 | 14 | TrackSettings::TrackSettings(TrackControls* parent) : 15 | QDialog(parent), 16 | m_track_controls(parent), 17 | m_track(m_track_controls->track()), 18 | m_box_layout(new QVBoxLayout(this)), 19 | m_track_label(new QLabel), 20 | m_track_duration(new PositionLabel), 21 | m_fade_in_slider(new PositionSlider), 22 | m_fade_in_label(new PositionLabel), 23 | m_fade_out_slider(new PositionSlider), 24 | m_fade_out_label(new PositionLabel), 25 | m_position_slider_A(new PositionSlider), 26 | m_position_label_A(new PositionLabel), 27 | m_position_slider_B(new PositionSlider), 28 | m_position_label_B(new PositionLabel), 29 | m_gap_spin_box(new QDoubleSpinBox), 30 | m_random_gap_check_box(new QCheckBox), 31 | m_gap_max_spin_box(new QDoubleSpinBox) 32 | { 33 | connect(m_track_controls, &TrackControls::updated, this, &TrackSettings::trackLoaded); 34 | 35 | connect(m_track->playerA(), &Player::positionChanged, 36 | this, [this](qint64 pos) { if(isVisible()) { playerPositionChanged(pos, Slider::PlayerA); } }); 37 | connect(m_track->playerB(), &Player::positionChanged, 38 | this, [this](qint64 pos) { if(isVisible()) { playerPositionChanged(pos, Slider::PlayerB); } }); 39 | 40 | setModal(false); 41 | 42 | m_box_layout->setAlignment(Qt::AlignmentFlag::AlignTop); 43 | 44 | addTrackTitle(); 45 | addSlider(Slider::FadeIn); 46 | addSlider(Slider::FadeOut); 47 | addGap(); 48 | 49 | auto spacing = fontInfo().pixelSize(); 50 | m_box_layout->addSpacing(spacing); 51 | addSlider(Slider::PlayerA); 52 | addSlider(Slider::PlayerB); 53 | } 54 | 55 | void TrackSettings::trackLoaded() 56 | { 57 | setWindowTitle(QString("%1: %2").arg(APP_TITLE, m_track->title())); 58 | setTrackProperties(); 59 | setFade(Slider::FadeIn); 60 | setFade(Slider::FadeOut); 61 | } 62 | 63 | void TrackSettings::gapSpinBoxChanged(double value) 64 | { 65 | if (value > m_gap_max_spin_box->value()) 66 | { 67 | value = m_gap_max_spin_box->value(); 68 | m_gap_spin_box->setValue(value); 69 | } 70 | m_track->setGap(value); 71 | } 72 | 73 | void TrackSettings::randomGapCheckBoxChanged(int state) 74 | { 75 | m_track->setRandomGap(state == Qt::Checked); 76 | } 77 | 78 | void TrackSettings::gapMaxSpinBoxChanged(double value) 79 | { 80 | if (value < m_gap_spin_box->value()) 81 | { 82 | value = m_gap_spin_box->value(); 83 | m_gap_max_spin_box->setValue(value); 84 | } 85 | m_track->setMaxGap(value); 86 | } 87 | 88 | void TrackSettings::addTrackTitle() 89 | { 90 | m_track_label->setTextFormat(Qt::TextFormat::PlainText); 91 | m_track_duration->setToolTip(tr("track duration")); 92 | 93 | auto* widget = new QWidget; 94 | m_box_layout->addWidget(widget); 95 | auto* layout = new QHBoxLayout(widget); 96 | layout->addWidget(m_track_label); 97 | layout->addStretch(1); 98 | layout->addWidget(m_track_duration); 99 | } 100 | 101 | void TrackSettings::addSlider(Slider type) 102 | { 103 | PositionSlider* slider = nullptr; 104 | PositionLabel* label = nullptr; 105 | QString tip; 106 | QString icon; 107 | 108 | if (type == Slider::FadeIn) 109 | { 110 | slider = m_fade_in_slider; 111 | label = m_fade_in_label; 112 | tip = tr("fade-in"); 113 | icon = ":/icons/fade-in-label.svg"; 114 | } 115 | else if (type == Slider::FadeOut) 116 | { 117 | slider = m_fade_out_slider; 118 | label = m_fade_out_label; 119 | tip = tr("fade-out"); 120 | icon = ":/icons/fade-out-label.svg"; 121 | slider->setInvertedAppearance(true); 122 | } 123 | else if (type == Slider::PlayerA) 124 | { 125 | slider = m_position_slider_A; 126 | label = m_position_label_A; 127 | tip = tr("player A"); 128 | icon = ":/icons/position-label.svg"; 129 | } 130 | else if (type == Slider::PlayerB) 131 | { 132 | slider = m_position_slider_B; 133 | label = m_position_label_B; 134 | tip = tr("player B"); 135 | icon = ":/icons/position-label.svg"; 136 | } 137 | 138 | Q_CHECK_PTR(slider); 139 | Q_CHECK_PTR(label); 140 | 141 | if (type == Slider::FadeIn || type == Slider::FadeOut) 142 | { 143 | connect(slider, &QSlider::valueChanged, this, [this, type](int value) { fadeSliderChanged(value, type); }); 144 | } 145 | else 146 | { 147 | slider->setEnabled(false); 148 | } 149 | 150 | auto* icon_label = new IconLabel(icon); 151 | 152 | icon_label->setToolTip(tip); 153 | slider->setToolTip(tip); 154 | label->setToolTip(tip); 155 | 156 | auto* widget = new QWidget; 157 | m_box_layout->addWidget(widget); 158 | 159 | auto* layout = new QHBoxLayout(widget); 160 | layout->addWidget(icon_label); 161 | layout->addWidget(slider); 162 | layout->addWidget(label); 163 | } 164 | 165 | void TrackSettings::addGap() 166 | { 167 | auto* widget = new QWidget; 168 | m_box_layout->addWidget(widget); 169 | 170 | m_gap_spin_box->setDecimals(1); 171 | m_gap_spin_box->setMaximum(9999); 172 | 173 | m_gap_max_spin_box->setDecimals(1); 174 | m_gap_max_spin_box->setMaximum(9999); 175 | 176 | auto* layout = new QHBoxLayout(widget); 177 | layout->addWidget(new IconLabel(":/icons/gap-label.svg")); 178 | layout->addWidget(new QLabel(tr("gap"))); 179 | layout->addWidget(m_gap_spin_box); 180 | layout->addWidget(new QLabel(tr("s"))); 181 | 182 | layout->addWidget(new QLabel(" ")); 183 | layout->addWidget(m_random_gap_check_box); 184 | layout->addWidget(new QLabel(tr("random up to"))); 185 | layout->addWidget(m_gap_max_spin_box); 186 | layout->addWidget(new QLabel(tr("s"))); 187 | 188 | layout->addStretch(1); 189 | } 190 | 191 | void TrackSettings::playerPositionChanged(qint64 pos, Slider type) 192 | { 193 | PositionSlider* slider; 194 | PositionLabel* label; 195 | 196 | if (type == Slider::PlayerA) 197 | { 198 | slider = m_position_slider_A; 199 | label = m_position_label_A; 200 | } 201 | else if (type == Slider::PlayerB) 202 | { 203 | slider = m_position_slider_B; 204 | label = m_position_label_B; 205 | } 206 | else 207 | { 208 | return; 209 | } 210 | 211 | const auto v = trackToSliderPosition(pos, slider); 212 | slider->setValue(v); 213 | const auto p = static_cast(pos); 214 | const auto x = p / 1000; 215 | label->setValue(x); 216 | } 217 | 218 | void TrackSettings::setTrackProperties() 219 | { 220 | m_track_label->setText(m_track->fileName()); 221 | 222 | const auto d = static_cast(m_track->duration()) / 1000; 223 | m_track_duration->setMax(d); 224 | m_fade_in_label->setMax(d); 225 | m_fade_out_label->setMax(d); 226 | m_position_label_A->setMax(d); 227 | m_position_label_B->setMax(d); 228 | 229 | m_track_duration->setValue(d); 230 | m_position_label_A->setValue(0); 231 | m_position_label_B->setValue(0); 232 | 233 | m_gap_spin_box->setValue(m_track->gap()); 234 | m_gap_max_spin_box->setValue(m_track->maxGap()); 235 | m_random_gap_check_box->setChecked(m_track->randomGap()); 236 | 237 | connect(m_gap_spin_box, &QDoubleSpinBox::valueChanged, this, &TrackSettings::gapSpinBoxChanged); 238 | #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) 239 | connect(m_random_gap_check_box, &QCheckBox::checkStateChanged, this, &TrackSettings::randomGapCheckBoxChanged); 240 | #else 241 | connect(m_random_gap_check_box, &QCheckBox::stateChanged, this, &TrackSettings::randomGapCheckBoxChanged); 242 | #endif 243 | connect(m_gap_max_spin_box, &QDoubleSpinBox::valueChanged, this, &TrackSettings::gapMaxSpinBoxChanged); 244 | 245 | emit loaded(); 246 | } 247 | 248 | void TrackSettings::setFade(Slider type) 249 | { 250 | const qint64 duration = type == Slider::FadeIn ? m_track->fadeInDuration() : m_track->fadeOutDuration(); 251 | PositionSlider* slider = type == Slider::FadeIn ? m_fade_in_slider : m_fade_out_slider; 252 | PositionLabel* label = type == Slider::FadeIn ? m_fade_in_label : m_fade_out_label; 253 | 254 | const auto x = trackToSliderPosition(duration, slider); 255 | slider->setValue(x); 256 | 257 | const auto v = static_cast(duration) / 1000; 258 | label->setValue(v); 259 | } 260 | 261 | int TrackSettings::trackToSliderPosition(const qint64 pos, const PositionSlider* slider) const 262 | { 263 | const auto p = static_cast(pos); 264 | const auto d = static_cast(m_track->duration()); 265 | const auto m = static_cast(slider->maximum()); 266 | const auto x = static_cast(p / d * m); 267 | return x; 268 | } 269 | 270 | void TrackSettings::fadeSliderChanged(int value, Slider type) 271 | { 272 | PositionSlider* slider; 273 | PositionLabel* label; 274 | qint64 opposite_fade_duration; 275 | 276 | if (type == Slider::FadeIn) 277 | { 278 | slider = m_fade_in_slider; 279 | label = m_fade_in_label; 280 | opposite_fade_duration = m_track->fadeOutDuration(); 281 | } 282 | else if (type == Slider::FadeOut) 283 | { 284 | slider = m_fade_out_slider; 285 | label = m_fade_out_label; 286 | opposite_fade_duration = m_track->fadeInDuration(); 287 | } 288 | else 289 | { 290 | return; 291 | } 292 | 293 | const auto v = static_cast(value); 294 | const auto m = static_cast(slider->maximum()); 295 | const auto d = static_cast(m_track->duration()); 296 | const auto f = v / m * d; 297 | const auto p = static_cast(f); 298 | const auto p0 = m_track->duration() - opposite_fade_duration; 299 | if (p > p0) 300 | { 301 | const auto v1 = trackToSliderPosition(p0, slider); 302 | slider->setValue(v1); 303 | return; 304 | } 305 | label->setValue(f / 1000); 306 | 307 | if (type == Slider::FadeIn) 308 | { 309 | m_track->setFadeInDuration(p); 310 | } 311 | else if (type == Slider::FadeOut) 312 | { 313 | m_track->setFadeOutDuration(p); 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/TrackSettings.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | class Track; 14 | class PositionLabel; 15 | class PositionSlider; 16 | class TrackControls; 17 | 18 | enum class Slider 19 | { 20 | FadeIn, 21 | FadeOut, 22 | PlayerA, 23 | PlayerB, 24 | }; 25 | 26 | class TrackSettings : public QDialog 27 | { 28 | Q_OBJECT 29 | 30 | public: 31 | explicit TrackSettings(TrackControls* parent = nullptr); 32 | 33 | signals: 34 | void loaded(); 35 | 36 | private slots: 37 | void trackLoaded(); 38 | void gapSpinBoxChanged(double value); 39 | void randomGapCheckBoxChanged(int state); 40 | void gapMaxSpinBoxChanged(double value); 41 | 42 | private: 43 | void addTrackTitle(); 44 | void addSlider(Slider type); 45 | void addGap(); 46 | 47 | void playerPositionChanged(qint64 pos, Slider type); 48 | 49 | void setTrackProperties(); 50 | void setFade(Slider type); 51 | 52 | int trackToSliderPosition(qint64 pos, const PositionSlider* slider) const; 53 | 54 | void fadeSliderChanged(int value, Slider type); 55 | 56 | const TrackControls* m_track_controls; 57 | Track* m_track; 58 | 59 | QPointer m_box_layout; 60 | 61 | QPointer m_track_label; 62 | QPointer m_track_duration; 63 | 64 | QPointer m_fade_in_slider; 65 | QPointer m_fade_in_label; 66 | 67 | QPointer m_fade_out_slider; 68 | QPointer m_fade_out_label; 69 | 70 | QPointer m_position_slider_A; 71 | QPointer m_position_label_A; 72 | 73 | QPointer m_position_slider_B; 74 | QPointer m_position_label_B; 75 | 76 | QPointer m_gap_spin_box; 77 | QPointer m_random_gap_check_box; 78 | QPointer m_gap_max_spin_box; 79 | 80 | friend class TestTrackSettings; 81 | }; 82 | -------------------------------------------------------------------------------- /src/Transition.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "Transition.h" 5 | 6 | Transition convertTransition(Qt::CheckState state) 7 | { 8 | switch (state) 9 | { 10 | case Qt::CheckState::Unchecked: 11 | return Transition::FadeOutIn; 12 | case Qt::CheckState::PartiallyChecked: 13 | return Transition::CrossFade; 14 | case Qt::CheckState::Checked: 15 | return Transition::FadeOutGapIn; 16 | } 17 | return Transition::FadeOutIn; 18 | } 19 | 20 | Qt::CheckState convertTransition(Transition transition) 21 | { 22 | switch (transition) 23 | { 24 | case Transition::FadeOutIn: 25 | return Qt::CheckState::Unchecked; 26 | case Transition::CrossFade: 27 | return Qt::CheckState::PartiallyChecked; 28 | case Transition::FadeOutGapIn: 29 | return Qt::CheckState::Checked; 30 | } 31 | return Qt::CheckState::Unchecked; 32 | } 33 | -------------------------------------------------------------------------------- /src/Transition.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | 8 | enum class Transition 9 | { 10 | FadeOutIn = 0, 11 | CrossFade = 1, 12 | FadeOutGapIn = 2, 13 | }; 14 | 15 | Transition convertTransition(Qt::CheckState state); 16 | Qt::CheckState convertTransition(Transition transition); 17 | -------------------------------------------------------------------------------- /src/TransitionIcon.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "TransitionIcon.h" 5 | 6 | #include "Transition.h" 7 | 8 | #include 9 | 10 | TransitionIcon::TransitionIcon(QWidget* parent) : 11 | QCheckBox(parent) 12 | { 13 | QFile file(":/styles/transition-icon.css"); 14 | file.open(QIODevice::ReadOnly); 15 | QString style = QString(file.readAll()).arg(":/icons/fade-out-in.svg", ":/icons/cross-fade.svg", ":/icons/fade-gap.svg"); 16 | 17 | const auto p = fontInfo().pixelSize(); 18 | const auto h = static_cast(2.0 * p); 19 | const auto w = 2 * h; 20 | 21 | style.append(QString("QCheckBox::indicator {width: %1; height: %2; }").arg(w).arg(h)); 22 | setStyleSheet(style); 23 | 24 | updateToolTip(checkState()); 25 | #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) 26 | connect(this, &TransitionIcon::checkStateChanged, this, &TransitionIcon::updateToolTip); 27 | #else 28 | connect(this, &TransitionIcon::stateChanged, this, &TransitionIcon::updateToolTip); 29 | #endif 30 | } 31 | 32 | void TransitionIcon::updateToolTip(int /*value*/) 33 | { 34 | const auto transition = convertTransition(checkState()); 35 | switch (transition) 36 | { 37 | case Transition::FadeOutIn: 38 | setToolTip(tr("loop with fade-out/in")); 39 | break; 40 | case Transition::CrossFade: 41 | setToolTip(tr("loop with cross-fade")); 42 | break; 43 | case Transition::FadeOutGapIn: 44 | setToolTip(tr("loop with gap")); 45 | break; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/TransitionIcon.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | 8 | class TransitionIcon : public QCheckBox 9 | { 10 | Q_OBJECT 11 | 12 | public: 13 | explicit TransitionIcon(QWidget* parent = nullptr); 14 | 15 | private: 16 | void updateToolTip(int value); 17 | }; 18 | -------------------------------------------------------------------------------- /src/Version.h.in: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #cmakedefine APP_TITLE "@APP_TITLE@" 7 | #cmakedefine APP_VERSION "@APP_VERSION@" 8 | #cmakedefine WEB_SITE "@WEB_SITE@" 9 | 10 | #cmakedefine CMAKE_INSTALL_DATADIR "@CMAKE_INSTALL_DATADIR@" 11 | -------------------------------------------------------------------------------- /src/Volume.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "Volume.h" 5 | 6 | #include 7 | 8 | Volume::Volume(QWidget* parent) : 9 | QDial(parent), 10 | m_tool_tip_template(tr("Volume: %1 dB")), 11 | m_min_angle(40.0), 12 | m_max_angle(360.0 - m_min_angle) 13 | { 14 | setNotchesVisible(true); 15 | setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); 16 | 17 | setMinimum(0); 18 | setMaximum(100); 19 | 20 | updateToolTip(value()); 21 | connect(this, &Volume::valueChanged, this, &Volume::updateToolTip); 22 | } 23 | 24 | QSize Volume::sizeHint() const 25 | { 26 | return {10, 10}; 27 | } 28 | 29 | void Volume::paintEvent(QPaintEvent* /*event*/) 30 | { 31 | QPainter painter(this); 32 | painter.save(); 33 | painter.setRenderHint(QPainter::Antialiasing); 34 | painter.setBrush(Qt::NoBrush); 35 | 36 | const auto width = static_cast(rect().width()); 37 | const auto height = static_cast(rect().height()); 38 | const auto size = 0.8 * std::min(width, height); 39 | const auto radius = 0.5 * size; 40 | const auto thickness = std::max(0.05 * radius, 1.0); 41 | const auto start_angle = static_cast(-90.0 - m_min_angle); 42 | 43 | const auto& base_rect = QRectF((width - size) / 2, (height - size) / 2, size, size); 44 | const auto& base_center = base_rect.center(); 45 | 46 | painter.setPen(QPen(QColor(128, 128, 128), thickness, Qt::SolidLine, Qt::RoundCap)); 47 | painter.drawArc(base_rect, start_angle * 16, -static_cast(m_max_angle - m_min_angle) * 16); 48 | 49 | painter.setPen(QPen(QColor(0, 128, 0), 3.0 * thickness, Qt::SolidLine, Qt::RoundCap)); 50 | painter.drawArc(base_rect, start_angle * 16, -static_cast(arcLength() * 16)); 51 | 52 | const auto angle = (start_angle - arcLength()) * M_PI / 180; 53 | const auto& rt = (radius - thickness) * QPointF(std::cos(-angle), std::sin(-angle)); 54 | const auto& r1 = base_center + 0.6 * rt; 55 | const auto& r2 = base_center + 0.8 * rt; 56 | painter.drawLine(r1, r2); 57 | 58 | painter.restore(); 59 | } 60 | 61 | double Volume::fraction() const 62 | { 63 | return static_cast(sliderPosition() - minimum()) / (maximum() - minimum()); 64 | } 65 | 66 | double Volume::arcLength() const 67 | { 68 | return (m_max_angle - m_min_angle) * fraction(); 69 | } 70 | 71 | void Volume::updateToolTip(int /*value*/) 72 | { 73 | const auto volume = 20 * log10(fraction()); 74 | setToolTip(m_tool_tip_template.arg(volume, 0, 'f', 1)); 75 | } 76 | -------------------------------------------------------------------------------- /src/Volume.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #pragma once 5 | 6 | #include 7 | 8 | class Volume : public QDial 9 | { 10 | Q_OBJECT 11 | 12 | public: 13 | explicit Volume(QWidget* parent = nullptr); 14 | 15 | QSize sizeHint() const override; 16 | 17 | protected: 18 | void paintEvent(QPaintEvent* event) override; 19 | 20 | private: 21 | double fraction() const; 22 | double arcLength() const; 23 | void updateToolTip(int value); 24 | 25 | const QString m_tool_tip_template; 26 | double m_min_angle; 27 | double m_max_angle; 28 | }; 29 | -------------------------------------------------------------------------------- /src/icons/cross-fade.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /src/icons/fade-gap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /src/icons/fade-in-label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 63 | -------------------------------------------------------------------------------- /src/icons/fade-out-in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /src/icons/fade-out-label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 63 | -------------------------------------------------------------------------------- /src/icons/gap-label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 47 | -------------------------------------------------------------------------------- /src/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/src/icons/icon.ico -------------------------------------------------------------------------------- /src/icons/icon.svg: -------------------------------------------------------------------------------- 1 | ../../linux/icons/hicolor/scalable/apps/soundscape.svg -------------------------------------------------------------------------------- /src/icons/position-label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 63 | -------------------------------------------------------------------------------- /src/icons/switch-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 16 | 22 | 26 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/icons/switch-on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 16 | 22 | 26 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/icons/switch-paused.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 16 | 22 | 26 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/icons/win32.rc: -------------------------------------------------------------------------------- 1 | IDI_ICON1 ICON DISCARDABLE "icon.ico" 2 | -------------------------------------------------------------------------------- /src/styles/status.css: -------------------------------------------------------------------------------- 1 | QCheckBox::indicator:checked { image: url(%1); } 2 | QCheckBox::indicator:unchecked { image: url(%2); } 3 | -------------------------------------------------------------------------------- /src/styles/track-controls.css: -------------------------------------------------------------------------------- 1 | TrackControls 2 | { 3 | padding-left: %1px; 4 | padding-right: %1px; 5 | padding-top: %2px; 6 | padding-bottom: %2px; 7 | } 8 | 9 | TrackControls:hover 10 | { 11 | border: 1px solid #808080; 12 | border-radius: %1px; 13 | } 14 | -------------------------------------------------------------------------------- /src/styles/transition-icon.css: -------------------------------------------------------------------------------- 1 | QCheckBox::indicator:unchecked { image: url(%1); } 2 | QCheckBox::indicator:indeterminate { image: url(%2); } 3 | QCheckBox::indicator:checked { image: url(%3); } 4 | -------------------------------------------------------------------------------- /src/translations/.gitattributes: -------------------------------------------------------------------------------- 1 | *.ts eol=lf 2 | -------------------------------------------------------------------------------- /src/translations/soundscape_de.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Help 6 | 7 | 8 | Load track list from file. 9 | Spuren aus Datei laden. 10 | 11 | 12 | 13 | path to file 14 | Pfad zur Datei 15 | 16 | 17 | 18 | Minimize window to tray. 19 | Minimiert im Benachrichtigungsfeld starten. 20 | 21 | 22 | 23 | Enable tray icon. 24 | Benachrichtigungsfeld aktivieren. 25 | 26 | 27 | 28 | Disable tray icon. 29 | Benachrichtigungsfeld deaktivieren. 30 | 31 | 32 | 33 | MainWindow 34 | 35 | 36 | Use mouse right-click 37 | to access application menu 38 | Benutze rechte Maustaste 39 | für Anwendungsmenü 40 | 41 | 42 | 43 | 44 | 45 | 46 | About 47 | Über 48 | 49 | 50 | 51 | Pause playing tracks 52 | aktive Spuren pausieren 53 | 54 | 55 | 56 | Resume paused tracks 57 | pausierte Spuren fortsetzen 58 | 59 | 60 | 61 | Add track 62 | Neue Spur 63 | 64 | 65 | 66 | Save track list 67 | Spuren speichern 68 | 69 | 70 | 71 | Load track list 72 | Spuren laden 73 | 74 | 75 | 76 | Examples 77 | Beispiele 78 | 79 | 80 | 81 | Rain and Thunder 82 | Regen und Donner 83 | 84 | 85 | 86 | River 87 | Am Fluss 88 | 89 | 90 | 91 | Quit 92 | Beenden 93 | 94 | 95 | 96 | Show window 97 | Fenster zeigen 98 | 99 | 100 | 101 | Save Tracks 102 | Spuren speichern 103 | 104 | 105 | 106 | 107 | couldn't open file: %1 108 | die Datei %1 konnte nicht geöffnen werden 109 | 110 | 111 | 112 | Load Tracks 113 | Spuren laden 114 | 115 | 116 | 117 | Version: %1 118 | Version: %1 119 | 120 | 121 | 122 | open-source system-tray resident desktop application for playing soundscapes 123 | Desktop Anwendung mit offenem Quellcode für Wiedergabe von Klangschaften 124 | 125 | 126 | 127 | Website: <a href="%1">%1</a> 128 | Webseite: <a href="%1">%1</a> 129 | 130 | 131 | 132 | Copyright: © 2022-2024 Denis Danilov and contributors 133 | Copyright: © 2022-2024 Denis Danilov und Mitwirkende 134 | 135 | 136 | 137 | License: GNU General Public License (GPL) version 3 138 | Lizenz: GNU General Public License (GPL) version 3 139 | 140 | 141 | 142 | Qt Version: %1 143 | Qt Version: %1 144 | 145 | 146 | 147 | PositionLabel 148 | 149 | 150 | %1 s 151 | %1 s 152 | 153 | 154 | 155 | Status 156 | 157 | 158 | paused 159 | Pausiert 160 | 161 | 162 | 163 | playing 164 | Wiedergabe 165 | 166 | 167 | 168 | TrackControls 169 | 170 | 171 | Skip to start 172 | An den Anfang springen 173 | 174 | 175 | 176 | Edit Settings 177 | Spur-Einstellungen 178 | 179 | 180 | 181 | Move Up 182 | Spur hoch schieben 183 | 184 | 185 | 186 | Move Down 187 | Spur runter schieben 188 | 189 | 190 | 191 | Remove 192 | Spur entfernen 193 | 194 | 195 | 196 | TrackSettings 197 | 198 | 199 | track duration 200 | Spurdauer 201 | 202 | 203 | 204 | fade-in 205 | Einblenden 206 | 207 | 208 | 209 | fade-out 210 | Ausblenden 211 | 212 | 213 | 214 | player A 215 | Player A 216 | 217 | 218 | 219 | player B 220 | Player B 221 | 222 | 223 | 224 | gap 225 | Lücke 226 | 227 | 228 | 229 | 230 | s 231 | s 232 | 233 | 234 | 235 | random up to 236 | zufällig bis 237 | 238 | 239 | 240 | TransitionIcon 241 | 242 | 243 | loop with fade-out/in 244 | Schleife mit Ein/Aus-blenden 245 | 246 | 247 | 248 | loop with cross-fade 249 | Schleife mit Überblenden 250 | 251 | 252 | 253 | loop with gap 254 | Schleife mit Lücke 255 | 256 | 257 | 258 | Volume 259 | 260 | 261 | Volume: %1 dB 262 | Wiedergabepegel: %1 dB 263 | 264 | 265 | 266 | -------------------------------------------------------------------------------- /src/translations/soundscape_it.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Help 6 | 7 | 8 | Load track list from file. 9 | Carica l'elenco delle tracce da file. 10 | 11 | 12 | 13 | path to file 14 | percorso del file 15 | 16 | 17 | 18 | Minimize window to tray. 19 | Riduci a icona la finestra. 20 | 21 | 22 | 23 | Enable tray icon. 24 | Abilita l'icona nella barra delle applicazioni. 25 | 26 | 27 | 28 | Disable tray icon. 29 | Disattiva l'icona nella barra delle applicazioni. 30 | 31 | 32 | 33 | MainWindow 34 | 35 | 36 | Use mouse right-click 37 | to access application menu 38 | Usa il tasto destro del mouse 39 | 40 | 41 | 42 | 43 | 44 | 45 | About 46 | Informazioni 47 | 48 | 49 | 50 | Pause playing tracks 51 | Mettere in pausa la riproduzione delle tracce 52 | 53 | 54 | 55 | Resume paused tracks 56 | Riprendi le tracce in pausa 57 | 58 | 59 | 60 | Add track 61 | Aggiungi traccia audio 62 | 63 | 64 | 65 | Save track list 66 | Salva l'elenco delle tracce 67 | 68 | 69 | 70 | Load track list 71 | Carica la lista delle tracce 72 | 73 | 74 | 75 | Examples 76 | Esempi 77 | 78 | 79 | 80 | Rain and Thunder 81 | Pioggia e tuoni 82 | 83 | 84 | 85 | River 86 | Fiume 87 | 88 | 89 | 90 | Quit 91 | Esci 92 | 93 | 94 | 95 | Show window 96 | Mostra finestra 97 | 98 | 99 | 100 | Save Tracks 101 | Salva tracce audio 102 | 103 | 104 | 105 | 106 | couldn't open file: %1 107 | impossibile aprire il file: %1 108 | 109 | 110 | 111 | Load Tracks 112 | Carica tracce audio 113 | 114 | 115 | 116 | Version: %1 117 | Versione: %1 118 | 119 | 120 | 121 | open-source system-tray resident desktop application for playing soundscapes 122 | applicazione desktop open source residente nella barra delle applicazioni per la riproduzione di paesaggi sonori 123 | 124 | 125 | 126 | Website: <a href="%1">%1</a> 127 | Sito web: <a href="%1">%1</a> 128 | 129 | 130 | 131 | Copyright: © 2022-2024 Denis Danilov and contributors 132 | Copyright: © 2022-2024 Denis Danilov e contributori 133 | 134 | 135 | 136 | License: GNU General Public License (GPL) version 3 137 | Licenza: GNU General Public License (GPL) version 3 138 | 139 | 140 | 141 | Qt Version: %1 142 | Versione Qt: %1 143 | 144 | 145 | 146 | PositionLabel 147 | 148 | 149 | %1 s 150 | %1 s 151 | 152 | 153 | 154 | Status 155 | 156 | 157 | paused 158 | In pausa 159 | 160 | 161 | 162 | playing 163 | Avvio 164 | 165 | 166 | 167 | TrackControls 168 | 169 | 170 | Skip to start 171 | 172 | 173 | 174 | 175 | Edit Settings 176 | Modifica impostazioni 177 | 178 | 179 | 180 | Move Up 181 | Sposta in alto 182 | 183 | 184 | 185 | Move Down 186 | Sposta in basso 187 | 188 | 189 | 190 | Remove 191 | Rimuovi 192 | 193 | 194 | 195 | TrackSettings 196 | 197 | 198 | track duration 199 | durata della traccia 200 | 201 | 202 | 203 | fade-in 204 | dissolvenza in apertura 205 | 206 | 207 | 208 | fade-out 209 | dissolvenza in chiusura 210 | 211 | 212 | 213 | player A 214 | player A 215 | 216 | 217 | 218 | player B 219 | player B 220 | 221 | 222 | 223 | gap 224 | gap 225 | 226 | 227 | 228 | 229 | s 230 | s 231 | 232 | 233 | 234 | random up to 235 | casuale fino a 236 | 237 | 238 | 239 | TransitionIcon 240 | 241 | 242 | loop with fade-out/in 243 | loop con dissolvenza in chiusura 244 | 245 | 246 | 247 | loop with cross-fade 248 | loop con dissolvenza incrociata 249 | 250 | 251 | 252 | loop with gap 253 | loop con gap 254 | 255 | 256 | 257 | Volume 258 | 259 | 260 | Volume: %1 dB 261 | Volume: %1 dB 262 | 263 | 264 | 265 | -------------------------------------------------------------------------------- /src/translations/soundscape_ru.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Help 6 | 7 | 8 | Load track list from file. 9 | Загрузить дорожки из файла. 10 | 11 | 12 | 13 | path to file 14 | путь к файлу 15 | 16 | 17 | 18 | Minimize window to tray. 19 | Запустить свёрнутым в область уведомления (системный лоток). 20 | 21 | 22 | 23 | Enable tray icon. 24 | Включить область уведомления (системный лоток). 25 | 26 | 27 | 28 | Disable tray icon. 29 | Выключить область уведомления (системный лоток). 30 | 31 | 32 | 33 | MainWindow 34 | 35 | 36 | Use mouse right-click 37 | to access application menu 38 | Используйте правую кнопку мыши 39 | для доступа к меню 40 | 41 | 42 | 43 | 44 | 45 | 46 | About 47 | О программе 48 | 49 | 50 | 51 | Pause playing tracks 52 | Приостановить воспроизведение 53 | 54 | 55 | 56 | Resume paused tracks 57 | Возобновить воспроизведение 58 | 59 | 60 | 61 | Add track 62 | Добавить дорожку 63 | 64 | 65 | 66 | Save track list 67 | Сохранить список дорожек 68 | 69 | 70 | 71 | Load track list 72 | Загрузить список дорожек 73 | 74 | 75 | 76 | Examples 77 | Примеры 78 | 79 | 80 | 81 | Rain and Thunder 82 | Дождь с громом 83 | 84 | 85 | 86 | River 87 | У реки 88 | 89 | 90 | 91 | Quit 92 | Выход 93 | 94 | 95 | 96 | Show window 97 | Показать окно 98 | 99 | 100 | 101 | Save Tracks 102 | Сохранение дорожек 103 | 104 | 105 | 106 | 107 | couldn't open file: %1 108 | невозможно открыть файл %1 109 | 110 | 111 | 112 | Load Tracks 113 | Загрузка дорожек 114 | 115 | 116 | 117 | Version: %1 118 | Версия: %1 119 | 120 | 121 | 122 | open-source system-tray resident desktop application for playing soundscapes 123 | приложение для воспроизведения звуковых ландшафтов 124 | 125 | 126 | 127 | Website: <a href="%1">%1</a> 128 | Веб-сайт: <a href="%1">%1</a> 129 | 130 | 131 | 132 | Copyright: © 2022-2024 Denis Danilov and contributors 133 | Копирайт: © 2022-2024 Denis Danilov and contributors 134 | 135 | 136 | 137 | License: GNU General Public License (GPL) version 3 138 | Лицензия: GNU General Public License (GPL) version 3 139 | 140 | 141 | 142 | Qt Version: %1 143 | Версия Qt: %1 144 | 145 | 146 | 147 | PositionLabel 148 | 149 | 150 | %1 s 151 | %1 сек 152 | 153 | 154 | 155 | Status 156 | 157 | 158 | paused 159 | приостановлена 160 | 161 | 162 | 163 | playing 164 | воспроизводится 165 | 166 | 167 | 168 | TrackControls 169 | 170 | 171 | Skip to start 172 | Перейти на начало трека 173 | 174 | 175 | 176 | Edit Settings 177 | Параметры 178 | 179 | 180 | 181 | Move Up 182 | Переместить вверх 183 | 184 | 185 | 186 | Move Down 187 | Переместить вниз 188 | 189 | 190 | 191 | Remove 192 | Удалить 193 | 194 | 195 | 196 | TrackSettings 197 | 198 | 199 | track duration 200 | продолжительность дорожки 201 | 202 | 203 | 204 | fade-in 205 | нарастание 206 | 207 | 208 | 209 | fade-out 210 | затухание 211 | 212 | 213 | 214 | player A 215 | проигрыватель А 216 | 217 | 218 | 219 | player B 220 | проигрыватель Б 221 | 222 | 223 | 224 | gap 225 | промежуток 226 | 227 | 228 | 229 | 230 | s 231 | сек 232 | 233 | 234 | 235 | random up to 236 | случайный до 237 | 238 | 239 | 240 | TransitionIcon 241 | 242 | 243 | loop with fade-out/in 244 | затухание и 245 | нарастание 246 | 247 | 248 | 249 | loop with cross-fade 250 | перекрёстное 251 | затухание и 252 | нарастание 253 | 254 | 255 | 256 | loop with gap 257 | затухание и нарастание 258 | с перерывом 259 | 260 | 261 | 262 | Volume 263 | 264 | 265 | Volume: %1 dB 266 | громкость: %1 дБ 267 | 268 | 269 | 270 | -------------------------------------------------------------------------------- /tests/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | enable_testing(TRUE) 5 | 6 | include_directories(${CMAKE_SOURCE_DIR}/src) 7 | 8 | macro(unit_test SOURCE_FILE_NAME) 9 | get_filename_component(TESTNAME ${SOURCE_FILE_NAME} NAME_WE) 10 | add_executable(${TESTNAME} ${SOURCE_FILE_NAME} ${RESOURCES}) 11 | qt_add_resources(${TESTNAME} media_resources 12 | PREFIX "/media" 13 | BASE "media" 14 | FILES 15 | media/sound_0000.wav 16 | media/sound_0100.wav 17 | media/sound_XXXX.wav 18 | media/video_0100.webm 19 | ) 20 | target_link_libraries(${TESTNAME} PRIVATE ${APP_LIB} Qt::Test) 21 | add_test(NAME ${TESTNAME} COMMAND ${TESTNAME}) 22 | endmacro(unit_test) 23 | 24 | unit_test(TestJsonRW.cpp) 25 | unit_test(TestMainWindow.cpp) 26 | unit_test(TestPlayer.cpp) 27 | unit_test(TestPositionLabel.cpp) 28 | unit_test(TestTrack.cpp) 29 | unit_test(TestTrackControls.cpp) 30 | unit_test(TestTrackSettings.cpp) 31 | unit_test(TestTransition.cpp) 32 | -------------------------------------------------------------------------------- /tests/TestJsonRW.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "JsonRW.h" 5 | 6 | #include 7 | 8 | class TestJsonRW : public QObject 9 | { 10 | Q_OBJECT 11 | 12 | private slots: 13 | void testJsonTags(); 14 | void testReadString(); 15 | void testReadDouble(); 16 | void testReadBool(); 17 | void testReadInteger(); 18 | }; 19 | 20 | void TestJsonRW::testJsonTags() 21 | { 22 | QCOMPARE(JsonRW::TracksTag, "tracks"); 23 | QCOMPARE(JsonRW::FileNameTag, "fileName"); 24 | QCOMPARE(JsonRW::VolumeTag, "volume"); 25 | QCOMPARE(JsonRW::PlayingTag, "playing"); 26 | QCOMPARE(JsonRW::FadeInDurationTag, "fadeInDuration"); 27 | QCOMPARE(JsonRW::FadeOutDurationTag, "fadeOutDuration"); 28 | QCOMPARE(JsonRW::TransitionTag, "transition"); 29 | QCOMPARE(JsonRW::GapTag, "gap"); 30 | QCOMPARE(JsonRW::GapMaxTag, "gapMax"); 31 | QCOMPARE(JsonRW::RandomGapTag, "randomGap"); 32 | } 33 | 34 | void TestJsonRW::testReadString() 35 | { 36 | QJsonObject json; 37 | auto name = JsonRW::readString(JsonRW::FileNameTag, json); 38 | QVERIFY(!name.has_value()); 39 | 40 | const QString val("media_file.mp3"); 41 | json[JsonRW::FileNameTag] = val; 42 | name = JsonRW::readString(JsonRW::FileNameTag, json); 43 | QVERIFY(name.has_value()); 44 | QCOMPARE(name.value(), val); 45 | } 46 | 47 | void TestJsonRW::testReadDouble() 48 | { 49 | QJsonObject json; 50 | auto volume = JsonRW::readDouble(JsonRW::VolumeTag, json); 51 | QVERIFY(!volume.has_value()); 52 | 53 | const double val = 1.23; 54 | json[JsonRW::VolumeTag] = val; 55 | volume = JsonRW::readDouble(JsonRW::VolumeTag, json); 56 | QVERIFY(volume.has_value()); 57 | QCOMPARE(volume.value(), val); 58 | } 59 | 60 | void TestJsonRW::testReadBool() 61 | { 62 | QJsonObject json; 63 | auto playing = JsonRW::readBool(JsonRW::PlayingTag, json); 64 | QVERIFY(!playing.has_value()); 65 | 66 | const bool val = false; 67 | json[JsonRW::PlayingTag] = val; 68 | playing = JsonRW::readBool(JsonRW::PlayingTag, json); 69 | QVERIFY(playing.has_value()); 70 | QCOMPARE(playing.value(), val); 71 | } 72 | 73 | void TestJsonRW::testReadInteger() 74 | { 75 | QJsonObject json; 76 | auto position = JsonRW::readInteger(JsonRW::FadeInDurationTag, json); 77 | QVERIFY(!position.has_value()); 78 | 79 | const qint64 val = 123456789; 80 | json[JsonRW::FadeInDurationTag] = val; 81 | position = JsonRW::readDouble(JsonRW::FadeInDurationTag, json); 82 | QVERIFY(position.has_value()); 83 | QCOMPARE(position.value(), val); 84 | } 85 | 86 | QTEST_MAIN(TestJsonRW) 87 | #include "TestJsonRW.moc" 88 | -------------------------------------------------------------------------------- /tests/TestMainWindow.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "MainWindow.h" 5 | #include "Track.h" 6 | #include "TrackControls.h" 7 | 8 | #include 9 | #include 10 | 11 | class TestMainWindow : public QObject 12 | { 13 | Q_OBJECT 14 | 15 | private slots: 16 | void testTrackFromMedia(); 17 | void testSaveTracksToJson(); 18 | void testLoadTracksFromJson(); 19 | 20 | void testMoveTrackUp(); 21 | void testMoveTrackDown(); 22 | void testRemoveTrack(); 23 | 24 | void testMenu(); 25 | }; 26 | 27 | void TestMainWindow::testTrackFromMedia() 28 | { 29 | MainWindow window; 30 | QVERIFY(window.m_box_layout->count() == 1); 31 | // menu info is the first item in the box layout 32 | auto* menu_info = dynamic_cast(window.m_box_layout->itemAt(0)->widget()); 33 | QVERIFY(menu_info != nullptr); 34 | 35 | window.addTrackFromMedia(QString("/tmp/sound_01.mp3")); 36 | QVERIFY(window.m_box_layout->count() == 2); 37 | // menu info is still the first item in the box layout 38 | menu_info = dynamic_cast(window.m_box_layout->itemAt(0)->widget()); 39 | QVERIFY(menu_info != nullptr); 40 | // added track is the second item 41 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(1)->widget()); 42 | QVERIFY(track_control != nullptr); 43 | auto* track = track_control->track(); 44 | QCOMPARE(track->title(), "sound_01"); 45 | QCOMPARE(track->volume(), 0.50); 46 | #if QT_VERSION < QT_VERSION_CHECK(6, 5, 0) || QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) 47 | QVERIFY(track->isPlaying()); 48 | #endif 49 | 50 | window.addTrackFromMedia(QString("sound_02.mp3")); 51 | QVERIFY(window.m_box_layout->count() == 3); 52 | track_control = dynamic_cast(window.m_box_layout->itemAt(2)->widget()); 53 | track = track_control->track(); 54 | QVERIFY(track_control != nullptr); 55 | QCOMPARE(track->title(), "sound_02"); 56 | QCOMPARE(track->volume(), 0.50); 57 | #if QT_VERSION < QT_VERSION_CHECK(6, 5, 0) || QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) 58 | QVERIFY(track->isPlaying()); 59 | #endif 60 | } 61 | 62 | void TestMainWindow::testSaveTracksToJson() 63 | { 64 | #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) && QT_VERSION < QT_VERSION_CHECK(6, 6, 0) 65 | QSKIP("Test does not work"); 66 | #endif 67 | 68 | QTemporaryFile file; 69 | file.open(); 70 | file.write(QString("=== abc ===").toUtf8()); 71 | file.close(); 72 | const QFileInfo file_info(file.fileName()); 73 | const auto& base_dir = file_info.dir().absolutePath(); 74 | 75 | MainWindow window; 76 | window.addTrackFromMedia(QString(base_dir + "/sound_01.mp3")); 77 | window.addTrackFromMedia(QString(base_dir + "/../data1/sound_02.mp3")); 78 | window.addTrackFromMedia(QString(base_dir + "/../data2/sound_03.mp3")); 79 | 80 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(1)->widget()); 81 | auto* track = track_control->track(); 82 | track->setVolume(0.11); 83 | 84 | track_control = dynamic_cast(window.m_box_layout->itemAt(2)->widget()); 85 | track = track_control->track(); 86 | track->setVolume(0.21); 87 | 88 | track_control = dynamic_cast(window.m_box_layout->itemAt(3)->widget()); 89 | track = track_control->track(); 90 | track->setVolume(0.32); 91 | 92 | file.open(); 93 | window.saveTracksToJson(file); 94 | file.close(); 95 | 96 | file.open(); 97 | QString json = file.readAll(); 98 | file.close(); 99 | 100 | QString jsonExpected = R"({ 101 | "tracks": [ 102 | { 103 | "fadeInDuration": -1, 104 | "fadeOutDuration": -1, 105 | "fileName": "sound_01.mp3", 106 | "gap": 10, 107 | "gapMax": 300, 108 | "playing": true, 109 | "randomGap": false, 110 | "transition": 0, 111 | "volume": 0.11 112 | }, 113 | { 114 | "fadeInDuration": -1, 115 | "fadeOutDuration": -1, 116 | "fileName": "../data1/sound_02.mp3", 117 | "gap": 10, 118 | "gapMax": 300, 119 | "playing": true, 120 | "randomGap": false, 121 | "transition": 0, 122 | "volume": 0.21 123 | }, 124 | { 125 | "fadeInDuration": -1, 126 | "fadeOutDuration": -1, 127 | "fileName": "../data2/sound_03.mp3", 128 | "gap": 10, 129 | "gapMax": 300, 130 | "playing": true, 131 | "randomGap": false, 132 | "transition": 0, 133 | "volume": 0.32 134 | } 135 | ] 136 | } 137 | )"; 138 | QCOMPARE(json, jsonExpected); 139 | 140 | // move the last track up 141 | track_control = dynamic_cast(window.m_box_layout->itemAt(3)->widget()); 142 | track_control->moveUp(); 143 | 144 | file.open(); 145 | window.saveTracksToJson(file); 146 | file.close(); 147 | 148 | file.open(); 149 | json = file.readAll(); 150 | file.close(); 151 | 152 | jsonExpected = R"({ 153 | "tracks": [ 154 | { 155 | "fadeInDuration": -1, 156 | "fadeOutDuration": -1, 157 | "fileName": "sound_01.mp3", 158 | "gap": 10, 159 | "gapMax": 300, 160 | "playing": true, 161 | "randomGap": false, 162 | "transition": 0, 163 | "volume": 0.11 164 | }, 165 | { 166 | "fadeInDuration": -1, 167 | "fadeOutDuration": -1, 168 | "fileName": "../data2/sound_03.mp3", 169 | "gap": 10, 170 | "gapMax": 300, 171 | "playing": true, 172 | "randomGap": false, 173 | "transition": 0, 174 | "volume": 0.32 175 | }, 176 | { 177 | "fadeInDuration": -1, 178 | "fadeOutDuration": -1, 179 | "fileName": "../data1/sound_02.mp3", 180 | "gap": 10, 181 | "gapMax": 300, 182 | "playing": true, 183 | "randomGap": false, 184 | "transition": 0, 185 | "volume": 0.21 186 | } 187 | ] 188 | } 189 | )"; 190 | QCOMPARE(json, jsonExpected); 191 | } 192 | 193 | void TestMainWindow::testLoadTracksFromJson() 194 | { 195 | QTemporaryFile file; 196 | file.open(); 197 | QString json(R"({ 198 | "tracks": [ 199 | { 200 | "fileName": "sound_01.mp3", 201 | "playing": true, 202 | "volume": 0.51 203 | }, 204 | { 205 | "fileName": "../data1/sound_02.mp3", 206 | "playing": false, 207 | "volume": 0.52 208 | }, 209 | { 210 | "fileName": "../data2/sound_03.mp3", 211 | "volume": 0.53 212 | } 213 | ] 214 | } 215 | )"); 216 | file.write(json.toUtf8()); 217 | file.close(); 218 | 219 | MainWindow window; 220 | 221 | file.open(); 222 | window.loadTracksFromJson(file); 223 | file.close(); 224 | 225 | QVERIFY(window.m_box_layout->count() == 4); 226 | // menu info is the first item in the box layout 227 | auto* menu_info = dynamic_cast(window.m_box_layout->itemAt(0)->widget()); 228 | QVERIFY(menu_info != nullptr); 229 | // followed by the three tracks 230 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(1)->widget()); 231 | QVERIFY(track_control != nullptr); 232 | auto* track = track_control->track(); 233 | QCOMPARE(track->title(), "sound_01"); 234 | QCOMPARE(track->volume(), 0.51); 235 | #if QT_VERSION < QT_VERSION_CHECK(6, 5, 0) || QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) 236 | QVERIFY(track->isPlaying()); 237 | #endif 238 | // 239 | track_control = dynamic_cast(window.m_box_layout->itemAt(2)->widget()); 240 | QVERIFY(track_control != nullptr); 241 | track = track_control->track(); 242 | QCOMPARE(track->title(), "sound_02"); 243 | QCOMPARE(track->volume(), 0.52); 244 | QVERIFY(!track->isPlaying()); 245 | // 246 | track_control = dynamic_cast(window.m_box_layout->itemAt(3)->widget()); 247 | QVERIFY(track_control != nullptr); 248 | track = track_control->track(); 249 | QCOMPARE(track->title(), "sound_03"); 250 | QCOMPARE(track->volume(), 0.53); 251 | #if QT_VERSION < QT_VERSION_CHECK(6, 5, 0) || QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) 252 | QVERIFY(track->isPlaying()); 253 | #endif 254 | } 255 | 256 | void TestMainWindow::testMoveTrackUp() 257 | { 258 | MainWindow window; 259 | 260 | window.addTrackFromMedia(QString("track_01.mp3")); 261 | window.addTrackFromMedia(QString("track_02.mp3")); 262 | window.addTrackFromMedia(QString("track_03.mp3")); 263 | 264 | auto move_track = [&](int index) { 265 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(index)->widget()); 266 | track_control->moveUp(); 267 | }; 268 | 269 | auto check_track = [&](int index, const QString& title) { 270 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(index)->widget()); 271 | auto* track = track_control->track(); 272 | QCOMPARE(track->title(), title); 273 | }; 274 | 275 | check_track(1, "track_01"); 276 | check_track(2, "track_02"); 277 | check_track(3, "track_03"); 278 | 279 | move_track(1); 280 | check_track(1, "track_01"); 281 | check_track(2, "track_02"); 282 | check_track(3, "track_03"); 283 | 284 | move_track(2); 285 | check_track(1, "track_02"); 286 | check_track(2, "track_01"); 287 | check_track(3, "track_03"); 288 | 289 | move_track(3); 290 | check_track(1, "track_02"); 291 | check_track(2, "track_03"); 292 | check_track(3, "track_01"); 293 | } 294 | 295 | void TestMainWindow::testMoveTrackDown() 296 | { 297 | MainWindow window; 298 | 299 | window.addTrackFromMedia(QString("track_01.mp3")); 300 | window.addTrackFromMedia(QString("track_02.mp3")); 301 | window.addTrackFromMedia(QString("track_03.mp3")); 302 | 303 | auto move_track = [&](int index) { 304 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(index)->widget()); 305 | track_control->moveDown(); 306 | }; 307 | 308 | auto check_track = [&](int index, const QString& title) { 309 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(index)->widget()); 310 | auto* track = track_control->track(); 311 | QCOMPARE(track->title(), title); 312 | }; 313 | 314 | check_track(1, "track_01"); 315 | check_track(2, "track_02"); 316 | check_track(3, "track_03"); 317 | 318 | move_track(1); 319 | check_track(1, "track_02"); 320 | check_track(2, "track_01"); 321 | check_track(3, "track_03"); 322 | 323 | move_track(2); 324 | check_track(1, "track_02"); 325 | check_track(2, "track_03"); 326 | check_track(3, "track_01"); 327 | 328 | move_track(3); 329 | check_track(1, "track_02"); 330 | check_track(2, "track_03"); 331 | check_track(3, "track_01"); 332 | } 333 | 334 | void TestMainWindow::testRemoveTrack() 335 | { 336 | MainWindow window; 337 | 338 | window.addTrackFromMedia(QString("track_01.mp3")); 339 | window.addTrackFromMedia(QString("track_02.mp3")); 340 | window.addTrackFromMedia(QString("track_03.mp3")); 341 | 342 | auto remove_track = [&](int index) { 343 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(index)->widget()); 344 | track_control->remove(); 345 | }; 346 | 347 | auto check_track = [&](int index, const QString& title) { 348 | auto* track_control = dynamic_cast(window.m_box_layout->itemAt(index)->widget()); 349 | auto* track = track_control->track(); 350 | QCOMPARE(track->title(), title); 351 | }; 352 | 353 | check_track(1, "track_01"); 354 | check_track(2, "track_02"); 355 | check_track(3, "track_03"); 356 | QVERIFY(window.m_box_layout->count() == 3 + 1); 357 | 358 | remove_track(1); 359 | check_track(1, "track_02"); 360 | check_track(2, "track_03"); 361 | QVERIFY(window.m_box_layout->count() == 2 + 1); 362 | 363 | remove_track(2); 364 | check_track(1, "track_02"); 365 | QVERIFY(window.m_box_layout->count() == 1 + 1); 366 | 367 | remove_track(1); 368 | QVERIFY(window.m_box_layout->count() == 1); 369 | } 370 | 371 | void TestMainWindow::testMenu() 372 | { 373 | MainWindow window; 374 | 375 | auto actions = window.m_mouse_menu->actions(); 376 | int index = 0; 377 | QCOMPARE(actions.at(index++)->text(), "Pause playing tracks"); 378 | QCOMPARE(actions.at(index++)->text(), "Resume paused tracks"); 379 | QCOMPARE(actions.at(index++)->text(), "Add track"); 380 | QCOMPARE(actions.at(index++)->text(), "Save track list"); 381 | QCOMPARE(actions.at(index++)->text(), "Load track list"); 382 | QCOMPARE(actions.at(index++)->text(), "Examples"); 383 | QCOMPARE(actions.at(index++)->text(), ""); // separator 384 | #if !defined(Q_OS_MACOS) 385 | QCOMPARE(actions.at(index++)->text(), "About"); 386 | #endif 387 | QCOMPARE(actions.at(index++)->text(), "Quit"); 388 | 389 | if (window.m_tray_available) 390 | { 391 | actions = window.m_tray_menu->actions(); 392 | index = 0; 393 | QCOMPARE(actions.at(index++)->text(), "Show window"); 394 | QCOMPARE(actions.at(index++)->text(), "Pause playing tracks"); 395 | QCOMPARE(actions.at(index++)->text(), "Resume paused tracks"); 396 | QCOMPARE(actions.at(index++)->text(), ""); // separator 397 | #if !defined(Q_OS_MACOS) 398 | QCOMPARE(actions.at(index++)->text(), "About"); 399 | #endif 400 | QCOMPARE(actions.at(index++)->text(), "Quit"); 401 | } 402 | } 403 | 404 | QTEST_MAIN(TestMainWindow) 405 | #include "TestMainWindow.moc" 406 | -------------------------------------------------------------------------------- /tests/TestPlayer.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "Player.h" 5 | #include "Track.h" 6 | 7 | #include 8 | #include 9 | 10 | class TestPlayer : public QObject 11 | { 12 | Q_OBJECT 13 | 14 | public: 15 | explicit TestPlayer(QObject* parent = nullptr) : 16 | QObject(parent), 17 | base_dir(tmp_dir.path()), 18 | file_name_audio_ok("./"), 19 | file_name_audio_duration_zero("./"), 20 | file_name_audio_broken("./"), 21 | file_name_media_without_audio("./") 22 | {} 23 | 24 | private slots: 25 | void initTestCase(); 26 | 27 | void testAudioFileOk(); 28 | void testAudioFileDurationZero(); 29 | void testAudioFileBroken(); 30 | void testMediaFileWithoutAudio(); 31 | void testNextPlayerOutIn(); 32 | void testNextPlayerOutGapIn(); 33 | void testNextPlayerCrossFade(); 34 | void testPlayPauseActive(); 35 | void testSkipToStartActive(); 36 | 37 | private: 38 | const QTemporaryDir tmp_dir; 39 | const QDir base_dir; 40 | 41 | QString file_name_audio_ok; 42 | QString file_name_audio_duration_zero; 43 | QString file_name_audio_broken; 44 | QString file_name_media_without_audio; 45 | }; 46 | 47 | void TestPlayer::initTestCase() 48 | { 49 | QFile media_file_ok(":/media/sound_0100.wav"); 50 | file_name_audio_ok.append(QFileInfo(media_file_ok).fileName()); 51 | file_name_audio_ok = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_ok)); 52 | QVERIFY(media_file_ok.copy(file_name_audio_ok)); 53 | 54 | QFile media_file_duration_zero(":/media/sound_0000.wav"); 55 | file_name_audio_duration_zero.append(QFileInfo(media_file_duration_zero).fileName()); 56 | file_name_audio_duration_zero = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_duration_zero)); 57 | QVERIFY(media_file_duration_zero.copy(file_name_audio_duration_zero)); 58 | 59 | QFile media_file_broken(":/media/sound_XXXX.wav"); 60 | file_name_audio_broken.append(QFileInfo(media_file_broken).fileName()); 61 | file_name_audio_broken = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_broken)); 62 | QVERIFY(media_file_broken.copy(file_name_audio_broken)); 63 | 64 | QFile media_file_without_audio(":/media/video_0100.webm"); 65 | file_name_media_without_audio.append(QFileInfo(media_file_without_audio).fileName()); 66 | file_name_media_without_audio = QDir::cleanPath(base_dir.absoluteFilePath(file_name_media_without_audio)); 67 | QVERIFY(media_file_without_audio.copy(file_name_media_without_audio)); 68 | } 69 | 70 | void TestPlayer::testAudioFileOk() 71 | { 72 | Track track; 73 | auto* player = track.playerA(); 74 | QVERIFY(!player->isReady()); 75 | 76 | QSignalSpy player_loaded(player, &Player::playerLoaded); 77 | player->setSource(QUrl::fromLocalFile(file_name_audio_ok)); 78 | QVERIFY(player_loaded.wait()); 79 | QVERIFY(player->isReady()); 80 | } 81 | 82 | void TestPlayer::testAudioFileDurationZero() 83 | { 84 | Track track; 85 | auto* player = track.playerA(); 86 | QSignalSpy player_error(player, &QMediaPlayer::errorOccurred); 87 | player->setSource(QUrl::fromLocalFile(file_name_audio_duration_zero)); 88 | QVERIFY(player_error.wait()); 89 | const auto& arguments = player_error.takeFirst(); 90 | QCOMPARE(arguments.at(0).toInt(), QMediaPlayer::Error::FormatError); 91 | } 92 | 93 | void TestPlayer::testAudioFileBroken() 94 | { 95 | #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) && QT_VERSION < QT_VERSION_CHECK(6, 6, 0) 96 | QSKIP("Test does not work"); 97 | #endif 98 | 99 | Track track; 100 | auto* player = track.playerA(); 101 | QSignalSpy player_error(player, &QMediaPlayer::errorOccurred); 102 | player->setSource(QUrl::fromLocalFile(file_name_audio_broken)); 103 | QVERIFY(player_error.wait()); 104 | } 105 | 106 | void TestPlayer::testMediaFileWithoutAudio() 107 | { 108 | #if defined Q_OS_WIN && QT_VERSION < QT_VERSION_CHECK(6, 6, 0) 109 | QSKIP("Test does not work on Windows"); 110 | #endif 111 | 112 | Track track; 113 | auto* player = track.playerA(); 114 | QSignalSpy player_error(player, &QMediaPlayer::errorOccurred); 115 | player->setSource(QUrl::fromLocalFile(file_name_media_without_audio)); 116 | QVERIFY(player_error.wait()); 117 | const auto& arguments = player_error.takeFirst(); 118 | QCOMPARE(arguments.at(0).toInt(), QMediaPlayer::Error::FormatError); 119 | } 120 | 121 | void TestPlayer::testNextPlayerOutIn() 122 | { 123 | Track track; 124 | track.setTransition(Transition::FadeOutIn); 125 | 126 | auto* player_A = track.playerA(); 127 | auto* player_B = track.playerB(); 128 | QCOMPARE(player_A->m_next_media_player, player_B); 129 | QCOMPARE(player_B->m_next_media_player, player_A); 130 | 131 | QSignalSpy player_A_loaded(player_A, &Player::playerLoaded); 132 | QSignalSpy player_B_loaded(player_B, &Player::playerLoaded); 133 | player_A->setSource(QUrl::fromLocalFile(file_name_audio_ok)); 134 | QVERIFY(player_A_loaded.wait()); 135 | QVERIFY(player_B_loaded.wait()); 136 | QCOMPARE(player_B->source(), player_A->source()); 137 | 138 | auto test_transition = [&](auto* player_A, auto* player_B) { 139 | player_A->pauseActive(); 140 | player_B->pauseActive(); 141 | player_A->mediaPlayerStatusChanged(QMediaPlayer::MediaStatus::EndOfMedia); 142 | QVERIFY(player_A->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 143 | QVERIFY(player_B->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 144 | }; 145 | 146 | for (int i = 0; i < 3; ++i) 147 | { 148 | test_transition(player_A, player_B); 149 | test_transition(player_B, player_A); 150 | } 151 | } 152 | 153 | void TestPlayer::testNextPlayerOutGapIn() 154 | { 155 | Track track; 156 | track.setTransition(Transition::FadeOutGapIn); 157 | track.setGap(2.0); 158 | track.setRandomGap(false); 159 | 160 | auto* player_A = track.playerA(); 161 | auto* player_B = track.playerB(); 162 | QCOMPARE(player_A->m_next_media_player, player_B); 163 | QCOMPARE(player_B->m_next_media_player, player_A); 164 | 165 | QSignalSpy player_A_loaded(player_A, &Player::playerLoaded); 166 | QSignalSpy player_B_loaded(player_B, &Player::playerLoaded); 167 | player_A->setSource(QUrl::fromLocalFile(file_name_audio_ok)); 168 | QVERIFY(player_A_loaded.wait()); 169 | QVERIFY(player_B_loaded.wait()); 170 | QCOMPARE(player_B->source(), player_A->source()); 171 | 172 | auto test_transition = [&](auto* player_A, auto* player_B) { 173 | player_A->pauseActive(); 174 | player_B->pauseActive(); 175 | QSignalSpy player_B_state(player_B, &Player::playbackStateChanged); 176 | const auto t1_AB = QDateTime::currentDateTime(); 177 | player_A->mediaPlayerStatusChanged(QMediaPlayer::MediaStatus::EndOfMedia); 178 | QVERIFY(player_B_state.wait()); 179 | QVERIFY(player_B->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 180 | const auto t2_AB = QDateTime::currentDateTime(); 181 | QVERIFY(t1_AB.msecsTo(t2_AB) >= track.gap() * 1000 * 0.95); 182 | }; 183 | 184 | for (int i = 0; i < 3; ++i) 185 | { 186 | test_transition(player_A, player_B); 187 | test_transition(player_B, player_A); 188 | } 189 | } 190 | 191 | void TestPlayer::testNextPlayerCrossFade() 192 | { 193 | Track track; 194 | track.setTransition(Transition::CrossFade); 195 | track.setFadeInDuration(track.duration() / 4); 196 | track.setFadeOutDuration(track.duration() / 4); 197 | 198 | auto* player_A = track.playerA(); 199 | auto* player_B = track.playerB(); 200 | QCOMPARE(player_A->m_next_media_player, player_B); 201 | QCOMPARE(player_B->m_next_media_player, player_A); 202 | 203 | QSignalSpy player_A_loaded(player_A, &Player::playerLoaded); 204 | QSignalSpy player_B_loaded(player_B, &Player::playerLoaded); 205 | player_A->setSource(QUrl::fromLocalFile(file_name_audio_ok)); 206 | QVERIFY(player_A_loaded.wait()); 207 | QVERIFY(player_B_loaded.wait()); 208 | QCOMPARE(player_B->source(), player_A->source()); 209 | 210 | auto test_transition = [&](auto* player_A, auto* player_B) { 211 | player_A->pauseActive(); 212 | player_B->pauseActive(); 213 | player_A->mediaPlayerPositionChanged(player_A->duration() - track.fadeOutDuration() - 1); 214 | QVERIFY(player_B->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 215 | player_A->pauseActive(); 216 | player_B->pauseActive(); 217 | player_A->mediaPlayerPositionChanged(player_A->duration() - track.fadeOutDuration() + 1); 218 | QVERIFY(player_B->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 219 | }; 220 | 221 | for (int i = 0; i < 3; ++i) 222 | { 223 | test_transition(player_A, player_B); 224 | test_transition(player_B, player_A); 225 | } 226 | } 227 | 228 | void TestPlayer::testPlayPauseActive() 229 | { 230 | Track track; 231 | auto* player = track.playerA(); 232 | QSignalSpy player_loaded(player, &Player::playerLoaded); 233 | player->setSource(QUrl::fromLocalFile(file_name_audio_ok)); 234 | QVERIFY(player_loaded.wait()); 235 | player->m_active = false; 236 | player->stop(); 237 | 238 | QVERIFY(!player->playActive()); 239 | QVERIFY(!player->m_active); 240 | QVERIFY(player->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 241 | 242 | QVERIFY(player->playActive(true)); 243 | QVERIFY(player->m_active); 244 | QVERIFY(player->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 245 | 246 | player->m_next_player_timer->setInterval(10'000); 247 | player->m_next_player_timer->start(); 248 | QVERIFY(player->m_next_player_timer->isActive()); 249 | player->pauseActive(); 250 | QVERIFY(player->m_active); 251 | QVERIFY(player->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 252 | QVERIFY(!player->m_next_player_timer->isActive()); 253 | 254 | QVERIFY(player->playActive()); 255 | QVERIFY(player->m_active); 256 | QVERIFY(player->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 257 | } 258 | 259 | void TestPlayer::testSkipToStartActive() 260 | { 261 | Track track; 262 | auto* player = track.playerA(); 263 | QSignalSpy player_loaded(player, &Player::playerLoaded); 264 | player->setSource(QUrl::fromLocalFile(file_name_audio_ok)); 265 | QVERIFY(player_loaded.wait()); 266 | player->m_active = false; 267 | player->stop(); 268 | 269 | QVERIFY(!player->playActive()); 270 | QVERIFY(!player->skipToStartActive()); 271 | 272 | QVERIFY(player->playActive(true)); 273 | QVERIFY(player->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 274 | QVERIFY(player->skipToStartActive(true)); 275 | QVERIFY(player->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 276 | QVERIFY(player->position() == 0); 277 | } 278 | 279 | QTEST_MAIN(TestPlayer) 280 | #include "TestPlayer.moc" 281 | -------------------------------------------------------------------------------- /tests/TestPositionLabel.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "PositionLabel.h" 5 | 6 | #include 7 | 8 | class TestPositionLabel : public QObject 9 | { 10 | Q_OBJECT 11 | 12 | private slots: 13 | void testSetMax(); 14 | void testSetValue(); 15 | }; 16 | 17 | void TestPositionLabel::testSetMax() 18 | { 19 | PositionLabel label; 20 | QCOMPARE(label.m_precision, 1); 21 | QCOMPARE(label.m_field_width, 3); 22 | 23 | label.setMax(0); 24 | QCOMPARE(label.m_field_width, 3); 25 | 26 | label.setMax(10); 27 | QCOMPARE(label.m_field_width, 4); 28 | } 29 | 30 | void TestPositionLabel::testSetValue() 31 | { 32 | PositionLabel label; 33 | label.setMax(100); 34 | 35 | label.setValue(0.0); 36 | QCOMPARE(label.text(), "000.0 s"); 37 | 38 | label.setValue(0.05); 39 | QCOMPARE(label.text(), "000.1 s"); 40 | 41 | label.setValue(0.5); 42 | QCOMPARE(label.text(), "000.5 s"); 43 | 44 | label.setValue(10.5); 45 | QCOMPARE(label.text(), "010.5 s"); 46 | 47 | label.setValue(100.5); 48 | QCOMPARE(label.text(), "100.5 s"); 49 | 50 | label.setValue(1000.5); 51 | QCOMPARE(label.text(), "1000.5 s"); 52 | 53 | label.setValue(1.5); 54 | QCOMPARE(label.text(), "001.5 s"); 55 | } 56 | 57 | QTEST_MAIN(TestPositionLabel) 58 | #include "TestPositionLabel.moc" 59 | -------------------------------------------------------------------------------- /tests/TestTrack.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "JsonRW.h" 5 | #include "Player.h" 6 | #include "Track.h" 7 | 8 | #include 9 | #include 10 | 11 | class TestTrack : public QObject 12 | { 13 | Q_OBJECT 14 | 15 | public: 16 | explicit TestTrack(QObject* parent = nullptr) : 17 | QObject(parent), 18 | base_dir(tmp_dir.path()), 19 | file_name_audio_ok("./"), 20 | file_name_audio_duration_zero("./"), 21 | file_name_audio_broken("./"), 22 | file_name_media_without_audio("./") 23 | {} 24 | 25 | private slots: 26 | void initTestCase(); 27 | 28 | void testFromEmptyJson(); 29 | void testFromJson(); 30 | void testToJson(); 31 | void testFade(); 32 | void testFadeVolume(); 33 | void testAudioFileOk(); 34 | void testAudioFileDurationZero(); 35 | void testAudioFileBroken(); 36 | void testMediaWithoutAudio(); 37 | void testStartNextPlayer(); 38 | void testStartDelay(); 39 | 40 | private: 41 | static void compare_float_values(const float x, const float y, const float epsilon = 0.001) 42 | { 43 | QVERIFY(std::abs(x - y) < epsilon); 44 | } 45 | 46 | const QTemporaryDir tmp_dir; 47 | const QDir base_dir; 48 | 49 | QString file_name_audio_ok; 50 | QString file_name_audio_duration_zero; 51 | QString file_name_audio_broken; 52 | QString file_name_media_without_audio; 53 | }; 54 | 55 | void TestTrack::initTestCase() 56 | { 57 | QFile media_file_ok(":/media/sound_0100.wav"); 58 | file_name_audio_ok.append(QFileInfo(media_file_ok).fileName()); 59 | file_name_audio_ok = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_ok)); 60 | QVERIFY(media_file_ok.copy(file_name_audio_ok)); 61 | 62 | QFile media_file_duration_zero(":/media/sound_0000.wav"); 63 | file_name_audio_duration_zero.append(QFileInfo(media_file_duration_zero).fileName()); 64 | file_name_audio_duration_zero = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_duration_zero)); 65 | QVERIFY(media_file_duration_zero.copy(file_name_audio_duration_zero)); 66 | 67 | QFile media_file_broken(":/media/sound_XXXX.wav"); 68 | file_name_audio_broken.append(QFileInfo(media_file_broken).fileName()); 69 | file_name_audio_broken = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_broken)); 70 | QVERIFY(media_file_broken.copy(file_name_audio_broken)); 71 | 72 | QFile media_file_without_audio(":/media/video_0100.webm"); 73 | file_name_media_without_audio.append(QFileInfo(media_file_without_audio).fileName()); 74 | file_name_media_without_audio = QDir::cleanPath(base_dir.absoluteFilePath(file_name_media_without_audio)); 75 | QVERIFY(media_file_without_audio.copy(file_name_media_without_audio)); 76 | } 77 | 78 | void TestTrack::testFromEmptyJson() 79 | { 80 | QJsonObject json; 81 | 82 | Track track; 83 | track.fromJsonObject(json, QDir()); 84 | 85 | QCOMPARE(track.m_file_name, ""); 86 | QCOMPARE(track.m_volume, 0.5); 87 | QCOMPARE(track.m_playing, true); 88 | QCOMPARE(track.m_track_duration, -1); 89 | QCOMPARE(track.m_fade_in_duration, -1); 90 | QCOMPARE(track.m_fade_out_duration, -1); 91 | QCOMPARE(track.m_transition, Transition::FadeOutIn); 92 | QCOMPARE(track.m_gap, 10); 93 | QCOMPARE(track.m_gap_max, 300); 94 | QCOMPARE(track.m_random_gap, false); 95 | 96 | QCOMPARE(track.title(), ""); 97 | QCOMPARE(track.fileName(), ""); 98 | QCOMPARE(track.volume(), 0.5); 99 | QCOMPARE(track.isPlaying(), true); 100 | QCOMPARE(track.duration(), -1); 101 | QCOMPARE(track.fadeInDuration(), -1); 102 | QCOMPARE(track.fadeOutDuration(), -1); 103 | QCOMPARE(track.transition(), Transition::FadeOutIn); 104 | QCOMPARE(track.gap(), 10); 105 | QCOMPARE(track.maxGap(), 300); 106 | QCOMPARE(track.randomGap(), false); 107 | } 108 | 109 | void TestTrack::testFromJson() 110 | { 111 | QJsonObject json; 112 | json[JsonRW::FileNameTag] = "sound_01.mp3"; 113 | json[JsonRW::VolumeTag] = 0.37; 114 | json[JsonRW::PlayingTag] = false; 115 | json[JsonRW::FadeInDurationTag] = 11'000; 116 | json[JsonRW::FadeOutDurationTag] = 13'000; 117 | json[JsonRW::TransitionTag] = static_cast(Transition::CrossFade); 118 | json[JsonRW::GapTag] = 123; 119 | json[JsonRW::GapMaxTag] = 579; 120 | json[JsonRW::RandomGapTag] = true; 121 | 122 | Track track; 123 | track.fromJsonObject(json, base_dir); 124 | 125 | QCOMPARE(track.m_file_name, base_dir.absoluteFilePath("sound_01.mp3")); 126 | QCOMPARE(track.title(), "sound_01"); 127 | QCOMPARE(track.fileName(), "sound_01.mp3"); 128 | QCOMPARE(track.volume(), 0.37); 129 | QCOMPARE(track.isPlaying(), false); 130 | QCOMPARE(track.fadeInDuration(), 11'000); 131 | QCOMPARE(track.fadeOutDuration(), 13'000); 132 | QCOMPARE(track.transition(), Transition::CrossFade); 133 | QCOMPARE(track.gap(), 123); 134 | QCOMPARE(track.maxGap(), 579); 135 | QCOMPARE(track.randomGap(), true); 136 | } 137 | 138 | void TestTrack::testToJson() 139 | { 140 | Track track; 141 | track.m_file_name = base_dir.absoluteFilePath("../sound_01.mp3"); 142 | track.setVolume(0.15); 143 | track.m_playing = true; 144 | track.setFadeInDuration(123); 145 | track.setFadeOutDuration(456); 146 | track.setTransition(Transition::CrossFade); 147 | track.setGap(987); 148 | track.setMaxGap(13579); 149 | track.setRandomGap(true); 150 | 151 | const QJsonObject& json = track.toJsonObject(base_dir); 152 | 153 | QVERIFY(json[JsonRW::FileNameTag].isString()); 154 | QCOMPARE(json[JsonRW::FileNameTag].toString(), "../sound_01.mp3"); 155 | 156 | QVERIFY(json[JsonRW::VolumeTag].isDouble()); 157 | QCOMPARE(json[JsonRW::VolumeTag].toDouble(), 0.15); 158 | 159 | QVERIFY(json[JsonRW::PlayingTag].isBool()); 160 | QCOMPARE(json[JsonRW::PlayingTag].toBool(), true); 161 | 162 | QVERIFY(json[JsonRW::FadeInDurationTag].isDouble()); 163 | QCOMPARE(json[JsonRW::FadeInDurationTag].toInteger(), 123); 164 | 165 | QVERIFY(json[JsonRW::FadeOutDurationTag].isDouble()); 166 | QCOMPARE(json[JsonRW::FadeOutDurationTag].toInteger(), 456); 167 | 168 | QVERIFY(json[JsonRW::TransitionTag].isDouble()); 169 | QCOMPARE(json[JsonRW::TransitionTag].toInteger(), static_cast(Transition::CrossFade)); 170 | 171 | QVERIFY(json[JsonRW::GapTag].isDouble()); 172 | QCOMPARE(json[JsonRW::GapTag].toDouble(), 987); 173 | 174 | QVERIFY(json[JsonRW::GapMaxTag].isDouble()); 175 | QCOMPARE(json[JsonRW::GapMaxTag].toDouble(), 13579); 176 | 177 | QVERIFY(json[JsonRW::RandomGapTag].isBool()); 178 | QCOMPARE(json[JsonRW::RandomGapTag].toBool(), true); 179 | } 180 | 181 | void TestTrack::testFade() 182 | { 183 | Track track; 184 | 185 | QCOMPARE(track.fade(-1), 0.0); 186 | QCOMPARE(track.fade(0), 0.0); 187 | QCOMPARE(track.fade(1), 0.0); 188 | 189 | track.m_track_duration = 100; 190 | QCOMPARE(track.fade(-1), 0.0); 191 | QCOMPARE(track.fade(0), 1.0); 192 | QCOMPARE(track.fade(1), 1.0); 193 | QCOMPARE(track.fade(50), 1.0); 194 | QCOMPARE(track.fade(99), 1.0); 195 | QCOMPARE(track.fade(100), 1.0); 196 | QCOMPARE(track.fade(101), 0.0); 197 | 198 | track.setFadeInDuration(0); 199 | track.setFadeOutDuration(0); 200 | QCOMPARE(track.fade(-1), 0.0); 201 | QCOMPARE(track.fade(0), 1.0); 202 | QCOMPARE(track.fade(1), 1.0); 203 | QCOMPARE(track.fade(50), 1.0); 204 | QCOMPARE(track.fade(99), 1.0); 205 | QCOMPARE(track.fade(100), 1.0); 206 | QCOMPARE(track.fade(101), 0.0); 207 | 208 | track.setFadeInDuration(10); 209 | track.setFadeOutDuration(20); 210 | QCOMPARE(track.fade(-1), 0.0); 211 | QCOMPARE(track.fade(0), 0.0); 212 | 213 | compare_float_values(track.fade(1), 0.1); 214 | compare_float_values(track.fade(5), 0.5); 215 | 216 | QCOMPARE(track.fade(10), 1.0); 217 | QCOMPARE(track.fade(50), 1.0); 218 | QCOMPARE(track.fade(80), 1.0); 219 | 220 | compare_float_values(track.fade(95), 0.25); 221 | compare_float_values(track.fade(99), 0.05); 222 | 223 | QCOMPARE(track.fade(100), 0.0); 224 | QCOMPARE(track.fade(101), 0.0); 225 | } 226 | 227 | void TestTrack::testFadeVolume() 228 | { 229 | Track track; 230 | track.m_track_duration = 100; 231 | track.setFadeInDuration(10); 232 | track.setFadeOutDuration(20); 233 | track.m_volume = 1.0; 234 | 235 | { 236 | track.m_transition = Transition::FadeOutIn; 237 | QCOMPARE(track.fadeVolume(-1), 0.0); 238 | QCOMPARE(track.fadeVolume(0), 0.0); 239 | 240 | track.m_transition = Transition::FadeOutGapIn; 241 | QCOMPARE(track.fadeVolume(-1), 0.0); 242 | QCOMPARE(track.fadeVolume(0), 0.0); 243 | 244 | track.m_transition = Transition::CrossFade; 245 | QCOMPARE(track.fadeVolume(-1), 0.0); 246 | QCOMPARE(track.fadeVolume(0), 0.0); 247 | } 248 | 249 | { 250 | track.m_transition = Transition::FadeOutIn; 251 | compare_float_values(track.fadeVolume(track.fadeInDuration() / 2), 0.151); 252 | 253 | track.m_transition = Transition::FadeOutGapIn; 254 | compare_float_values(track.fadeVolume(track.fadeInDuration() / 2), 0.151); 255 | 256 | track.m_transition = Transition::CrossFade; 257 | compare_float_values(track.fadeVolume(track.fadeInDuration() / 2), 0.5); 258 | } 259 | 260 | { 261 | track.m_transition = Transition::FadeOutIn; 262 | compare_float_values(track.fadeVolume(track.fadeInDuration()), 1.0); 263 | 264 | track.m_transition = Transition::FadeOutGapIn; 265 | compare_float_values(track.fadeVolume(track.fadeInDuration()), 1.0); 266 | 267 | track.m_transition = Transition::CrossFade; 268 | compare_float_values(track.fadeVolume(track.fadeInDuration()), 1.0); 269 | } 270 | 271 | { 272 | track.m_transition = Transition::FadeOutIn; 273 | compare_float_values(track.fadeVolume(track.duration() - track.fadeOutDuration()), 1.0); 274 | 275 | track.m_transition = Transition::FadeOutGapIn; 276 | compare_float_values(track.fadeVolume(track.duration() - track.fadeOutDuration()), 1.0); 277 | 278 | track.m_transition = Transition::CrossFade; 279 | compare_float_values(track.fadeVolume(track.duration() - track.fadeOutDuration()), 1.0); 280 | } 281 | 282 | { 283 | track.m_transition = Transition::FadeOutIn; 284 | compare_float_values(track.fadeVolume(track.duration() - track.fadeOutDuration() / 2), 0.151); 285 | 286 | track.m_transition = Transition::FadeOutGapIn; 287 | compare_float_values(track.fadeVolume(track.duration() - track.fadeOutDuration() / 2), 0.151); 288 | 289 | track.m_transition = Transition::CrossFade; 290 | compare_float_values(track.fadeVolume(track.duration() - track.fadeOutDuration() / 2), 0.5); 291 | } 292 | 293 | { 294 | track.m_transition = Transition::FadeOutIn; 295 | QCOMPARE(track.fadeVolume(track.duration()), 0.0); 296 | QCOMPARE(track.fadeVolume(track.duration() + 1), 0.0); 297 | 298 | track.m_transition = Transition::FadeOutGapIn; 299 | QCOMPARE(track.fadeVolume(track.duration()), 0.0); 300 | QCOMPARE(track.fadeVolume(track.duration() + 1), 0.0); 301 | 302 | track.m_transition = Transition::CrossFade; 303 | QCOMPARE(track.fadeVolume(track.duration()), 0.0); 304 | QCOMPARE(track.fadeVolume(track.duration() + 1), 0.0); 305 | } 306 | } 307 | 308 | void TestTrack::testAudioFileOk() 309 | { 310 | Track track; 311 | QSignalSpy loaded(&track, &Track::loaded); 312 | 313 | QJsonObject json; 314 | json[JsonRW::FileNameTag] = file_name_audio_ok; 315 | track.fromJsonObject(json, base_dir); 316 | 317 | QVERIFY(loaded.wait()); 318 | QVERIFY(track.errors().empty()); 319 | 320 | QCOMPARE(track.duration(), 1000); 321 | QCOMPARE(track.fadeInDuration(), 250); 322 | QCOMPARE(track.fadeOutDuration(), 250); 323 | 324 | QCOMPARE(track.isPlaying(), true); 325 | track.pause(); 326 | QCOMPARE(track.isPlaying(), false); 327 | QVERIFY(track.playerA()->playbackState() == QMediaPlayer::PlaybackState::PausedState); 328 | QVERIFY(track.playerB()->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 329 | 330 | track.play(); 331 | QCOMPARE(track.isPlaying(), true); 332 | QVERIFY(track.playerA()->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 333 | QVERIFY(track.playerB()->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 334 | 335 | QSignalSpy playbackStateA(track.playerA(), &QMediaPlayer::playbackStateChanged); 336 | QSignalSpy playbackStateB(track.playerB(), &QMediaPlayer::playbackStateChanged); 337 | track.playerA()->mediaPlayerPositionChanged(track.duration()); 338 | track.playerA()->mediaPlayerStatusChanged(QMediaPlayer::MediaStatus::EndOfMedia); 339 | #if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) 340 | QVERIFY(playbackStateA.wait()); 341 | QVERIFY(playbackStateB.wait()); 342 | #endif 343 | 344 | QVERIFY(track.playerA()->playbackState() == QMediaPlayer::PlaybackState::PausedState); 345 | QVERIFY(track.playerB()->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 346 | 347 | track.pause(); 348 | QVERIFY(track.playerA()->playbackState() == QMediaPlayer::PlaybackState::PausedState); 349 | QVERIFY(track.playerB()->playbackState() == QMediaPlayer::PlaybackState::PausedState); 350 | 351 | track.play(); 352 | QVERIFY(track.playerA()->playbackState() == QMediaPlayer::PlaybackState::PausedState); 353 | QVERIFY(track.playerB()->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 354 | } 355 | 356 | void TestTrack::testAudioFileDurationZero() 357 | { 358 | Track track; 359 | QSignalSpy error(&track, &Track::errorOccurred); 360 | 361 | QJsonObject json; 362 | json[JsonRW::FileNameTag] = file_name_audio_duration_zero; 363 | track.fromJsonObject(json, base_dir); 364 | 365 | QVERIFY(error.wait()); 366 | QVERIFY(!track.errors().empty()); 367 | QVERIFY(!track.errors().front().isEmpty()); 368 | QCOMPARE(track.duration(), -1); 369 | } 370 | 371 | void TestTrack::testAudioFileBroken() 372 | { 373 | #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) && QT_VERSION < QT_VERSION_CHECK(6, 6, 0) 374 | QSKIP("Test does not work"); 375 | #endif 376 | 377 | Track track; 378 | QSignalSpy error(&track, &Track::errorOccurred); 379 | 380 | QJsonObject json; 381 | json[JsonRW::FileNameTag] = file_name_audio_broken; 382 | track.fromJsonObject(json, base_dir); 383 | 384 | QVERIFY(error.wait()); 385 | QVERIFY(!track.errors().empty()); 386 | QVERIFY(!track.errors().front().isEmpty()); 387 | QVERIFY(track.playerA()->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 388 | } 389 | 390 | void TestTrack::testMediaWithoutAudio() 391 | { 392 | Track track; 393 | QSignalSpy error(&track, &Track::errorOccurred); 394 | 395 | QJsonObject json; 396 | json[JsonRW::FileNameTag] = file_name_media_without_audio; 397 | track.fromJsonObject(json, base_dir); 398 | 399 | QVERIFY(error.wait()); 400 | QVERIFY(!track.errors().empty()); 401 | QVERIFY(track.playerA()->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 402 | } 403 | 404 | void TestTrack::testStartNextPlayer() 405 | { 406 | Track track; 407 | track.m_track_duration = 100; 408 | track.m_fade_in_duration = 10; 409 | track.m_fade_out_duration = 20; 410 | 411 | track.m_transition = Transition::FadeOutIn; 412 | QCOMPARE(track.startNextPlayer(-1), false); 413 | QCOMPARE(track.startNextPlayer(0), false); 414 | QCOMPARE(track.startNextPlayer(1), false); 415 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() - 1), false); 416 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() + 0), false); 417 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() + 1), false); 418 | QCOMPARE(track.startNextPlayer(track.duration() - 1), false); 419 | QCOMPARE(track.startNextPlayer(track.duration() + 0), true); 420 | QCOMPARE(track.startNextPlayer(track.duration() + 1), true); 421 | 422 | track.m_transition = Transition::FadeOutGapIn; 423 | QCOMPARE(track.startNextPlayer(-1), false); 424 | QCOMPARE(track.startNextPlayer(0), false); 425 | QCOMPARE(track.startNextPlayer(1), false); 426 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() - 1), false); 427 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() + 0), false); 428 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() + 1), false); 429 | QCOMPARE(track.startNextPlayer(track.duration() - 1), false); 430 | QCOMPARE(track.startNextPlayer(track.duration() + 0), true); 431 | QCOMPARE(track.startNextPlayer(track.duration() + 1), true); 432 | 433 | track.m_transition = Transition::CrossFade; 434 | QCOMPARE(track.startNextPlayer(-1), false); 435 | QCOMPARE(track.startNextPlayer(0), false); 436 | QCOMPARE(track.startNextPlayer(1), false); 437 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() - 1), false); 438 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() + 0), true); 439 | QCOMPARE(track.startNextPlayer(track.duration() - track.fadeOutDuration() + 1), true); 440 | QCOMPARE(track.startNextPlayer(track.duration() - 1), true); 441 | QCOMPARE(track.startNextPlayer(track.duration() + 0), true); 442 | QCOMPARE(track.startNextPlayer(track.duration() + 1), true); 443 | } 444 | 445 | void TestTrack::testStartDelay() 446 | { 447 | Track track; 448 | 449 | QCOMPARE(track.m_random_gap, false); 450 | QCOMPARE(track.startDelay(), track.m_gap * 1000); 451 | 452 | track.m_random_gap = true; 453 | const auto delay = track.startDelay(); 454 | QVERIFY(delay >= track.m_gap * 1000); 455 | QVERIFY(delay < track.m_gap_max * 1000); 456 | } 457 | 458 | QTEST_MAIN(TestTrack) 459 | #include "TestTrack.moc" 460 | -------------------------------------------------------------------------------- /tests/TestTrackControls.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "JsonRW.h" 5 | #include "MainWindow.h" 6 | #include "Player.h" 7 | #include "Status.h" 8 | #include "Track.h" 9 | #include "TrackControls.h" 10 | #include "TransitionIcon.h" 11 | #include "Volume.h" 12 | 13 | #include 14 | #include 15 | 16 | class TestTrackControls : public QObject 17 | { 18 | Q_OBJECT 19 | 20 | public: 21 | explicit TestTrackControls(QObject* parent = nullptr) : 22 | QObject(parent), 23 | base_dir(tmp_dir.path()), 24 | file_name_audio_ok("./"), 25 | file_name_audio_broken("./"), 26 | main_window(true) 27 | {} 28 | 29 | private slots: 30 | void initTestCase(); 31 | 32 | void testAudioFileOk(); 33 | void testAudioFileBroken(); 34 | void testMenu(); 35 | void testPauseAndResume(); 36 | 37 | private: 38 | const QTemporaryDir tmp_dir; 39 | const QDir base_dir; 40 | 41 | QString file_name_audio_ok; 42 | QString file_name_audio_broken; 43 | 44 | MainWindow main_window; 45 | }; 46 | 47 | void TestTrackControls::initTestCase() 48 | { 49 | QFile media_file_ok(":/media/sound_0100.wav"); 50 | file_name_audio_ok.append(QFileInfo(media_file_ok).fileName()); 51 | file_name_audio_ok = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_ok)); 52 | QVERIFY(media_file_ok.copy(file_name_audio_ok)); 53 | 54 | QFile media_file_broken(":/media/sound_XXXX.wav"); 55 | file_name_audio_broken.append(QFileInfo(media_file_broken).fileName()); 56 | file_name_audio_broken = QDir::cleanPath(base_dir.absoluteFilePath(file_name_audio_broken)); 57 | QVERIFY(media_file_broken.copy(file_name_audio_broken)); 58 | } 59 | 60 | void TestTrackControls::testAudioFileOk() 61 | { 62 | QJsonObject json; 63 | json[JsonRW::FileNameTag] = file_name_audio_ok; 64 | TrackControls track_controls(json, QDir(), &main_window); 65 | QSignalSpy updated(&track_controls, &TrackControls::updated); 66 | QVERIFY(updated.wait()); 67 | 68 | auto* track = track_controls.track(); 69 | 70 | auto volume_control = track_controls.m_volume_control; 71 | QCOMPARE(volume_control->value(), 50); 72 | 73 | auto transition_control = track_controls.m_transition_control; 74 | QCOMPARE(transition_control->isEnabled(), true); 75 | QCOMPARE(transition_control->checkState(), Qt::CheckState::Unchecked); 76 | QCOMPARE(track->transition(), Transition::FadeOutIn); 77 | 78 | // QTest::mouseClick(transition_control, Qt::MouseButton::LeftButton); 79 | transition_control->setCheckState(Qt::CheckState::PartiallyChecked); 80 | QCOMPARE(transition_control->checkState(), Qt::CheckState::PartiallyChecked); 81 | QCOMPARE(track->transition(), Transition::CrossFade); 82 | 83 | // QTest::mouseClick(transition_control, Qt::MouseButton::LeftButton); 84 | transition_control->setCheckState(Qt::CheckState::Checked); 85 | QCOMPARE(transition_control->checkState(), Qt::CheckState::Checked); 86 | QCOMPARE(track->transition(), Transition::FadeOutGapIn); 87 | 88 | auto status_control = track_controls.m_status_control; 89 | QCOMPARE(status_control->isEnabled(), true); 90 | QVERIFY(track->isPlaying()); 91 | QCOMPARE(track->playerA()->playbackState(), QMediaPlayer::PlaybackState::PlayingState); 92 | QVERIFY(status_control->isChecked()); 93 | QCOMPARE(status_control->text(), track->title()); 94 | 95 | QTest::mouseClick(status_control, Qt::MouseButton::LeftButton); 96 | QVERIFY(!track->isPlaying()); 97 | QCOMPARE(track->playerA()->playbackState(), QMediaPlayer::PlaybackState::PausedState); 98 | QVERIFY(!status_control->isChecked()); 99 | 100 | QTest::mouseClick(status_control, Qt::MouseButton::LeftButton); 101 | QVERIFY(track->isPlaying()); 102 | QCOMPARE(track->playerA()->playbackState(), QMediaPlayer::PlaybackState::PlayingState); 103 | QVERIFY(status_control->isChecked()); 104 | } 105 | 106 | void TestTrackControls::testAudioFileBroken() 107 | { 108 | #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) && QT_VERSION < QT_VERSION_CHECK(6, 6, 0) 109 | QSKIP("Test does not work"); 110 | #endif 111 | 112 | QJsonObject json; 113 | json[JsonRW::FileNameTag] = file_name_audio_broken; 114 | TrackControls track_controls(json, QDir(), &main_window); 115 | QSignalSpy updated(&track_controls, &TrackControls::updated); 116 | QVERIFY(updated.wait()); 117 | 118 | auto* track = track_controls.track(); 119 | 120 | auto volume_control = track_controls.m_volume_control; 121 | QCOMPARE(volume_control->value(), 50); 122 | 123 | auto status_control = track_controls.m_status_control; 124 | QCOMPARE(status_control->isEnabled(), false); 125 | QVERIFY(!track->isPlaying()); 126 | QCOMPARE(status_control->checkState(), Qt::CheckState::Unchecked); 127 | QCOMPARE(status_control->text(), track->title()); 128 | } 129 | 130 | void TestTrackControls::testMenu() 131 | { 132 | TrackControls track_controls(QJsonObject(), QDir(), &main_window); 133 | auto menu = track_controls.m_mouse_menu; 134 | auto actions = menu->actions(); 135 | QCOMPARE(actions.at(0)->text(), "Skip to start"); 136 | QCOMPARE(actions.at(1)->text(), "Edit Settings"); 137 | QCOMPARE(actions.at(2)->text(), "Move Up"); 138 | QCOMPARE(actions.at(3)->text(), "Move Down"); 139 | QCOMPARE(actions.at(4)->text(), "Remove"); 140 | } 141 | 142 | void TestTrackControls::testPauseAndResume() 143 | { 144 | QJsonObject json; 145 | json[JsonRW::FileNameTag] = file_name_audio_ok; 146 | TrackControls track_controls(json, QDir(), &main_window); 147 | QSignalSpy updated(&track_controls, &TrackControls::updated); 148 | QVERIFY(updated.wait()); 149 | 150 | auto* track = track_controls.track(); 151 | auto status_control = track_controls.m_status_control; 152 | 153 | auto test_playing_state = [&](auto* player_A, auto* player_B) { 154 | QCOMPARE(status_control->isChecked(), true); 155 | QCOMPARE(track->isPlaying(), true); 156 | QVERIFY(player_A->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 157 | QVERIFY(player_B->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 158 | 159 | track_controls.pausePlaying(); 160 | QCOMPARE(status_control->isChecked(), true); 161 | QCOMPARE(track->isPlaying(), false); 162 | QVERIFY(player_A->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 163 | QVERIFY(player_B->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 164 | 165 | track_controls.resumePaused(); 166 | QCOMPARE(status_control->isChecked(), true); 167 | QCOMPARE(track->isPlaying(), true); 168 | QVERIFY(player_A->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 169 | QVERIFY(player_B->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 170 | 171 | track_controls.pausePlaying(); 172 | QTest::mouseClick(status_control, Qt::MouseButton::LeftButton); 173 | track_controls.resumePaused(); 174 | QCOMPARE(status_control->isChecked(), false); 175 | QCOMPARE(track->isPlaying(), false); 176 | QVERIFY(player_A->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 177 | QVERIFY(player_B->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 178 | 179 | QTest::mouseClick(status_control, Qt::MouseButton::LeftButton); 180 | QCOMPARE(status_control->isChecked(), true); 181 | QCOMPARE(track->isPlaying(), true); 182 | QVERIFY(player_A->playbackState() == QMediaPlayer::PlaybackState::PlayingState); 183 | QVERIFY(player_B->playbackState() != QMediaPlayer::PlaybackState::PlayingState); 184 | }; 185 | 186 | // player A is active, player B is not 187 | test_playing_state(track->playerA(), track->playerB()); 188 | 189 | QSignalSpy playbackStateA(track->playerA(), &QMediaPlayer::playbackStateChanged); 190 | QSignalSpy playbackStateB(track->playerB(), &QMediaPlayer::playbackStateChanged); 191 | track->playerA()->mediaPlayerPositionChanged(track->duration()); 192 | track->playerA()->mediaPlayerStatusChanged(QMediaPlayer::MediaStatus::EndOfMedia); 193 | #if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) 194 | QVERIFY(playbackStateA.wait()); 195 | QVERIFY(playbackStateB.wait()); 196 | #endif 197 | // player B is active, player A is not 198 | test_playing_state(track->playerB(), track->playerA()); 199 | } 200 | 201 | QTEST_MAIN(TestTrackControls) 202 | #include "TestTrackControls.moc" 203 | -------------------------------------------------------------------------------- /tests/TestTrackSettings.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022-2024 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "JsonRW.h" 5 | #include "MainWindow.h" 6 | #include "PositionLabel.h" 7 | #include "PositionSlider.h" 8 | #include "Track.h" 9 | #include "TrackControls.h" 10 | #include "TrackSettings.h" 11 | 12 | #include 13 | #include 14 | #include 15 | 16 | class TestTrackSettings : public QObject 17 | { 18 | Q_OBJECT 19 | 20 | public: 21 | explicit TestTrackSettings(QObject* parent = nullptr) : 22 | QObject(parent), 23 | base_dir(tmp_dir.path()), 24 | file_name("./"), 25 | main_window(true) 26 | {} 27 | 28 | private slots: 29 | void initTestCase(); 30 | void init(); 31 | 32 | void testInitialTrackProperties(); 33 | void testPlayerPositionChanged(); 34 | void testTrackToSliderPosition(); 35 | void testFadeSliderChanged(); 36 | void testGap(); 37 | 38 | private: 39 | const QTemporaryDir tmp_dir; 40 | const QDir base_dir; 41 | QString file_name; 42 | MainWindow main_window; 43 | QPointer track_controls; 44 | QPointer track_settings; 45 | QPointer track; 46 | }; 47 | 48 | void TestTrackSettings::initTestCase() 49 | { 50 | QFile sound_file(":/media/sound_0100.wav"); 51 | file_name.append(QFileInfo(sound_file).fileName()); 52 | file_name = QDir::cleanPath(base_dir.absoluteFilePath(file_name)); 53 | QVERIFY(sound_file.copy(file_name)); 54 | } 55 | 56 | void TestTrackSettings::init() 57 | { 58 | QJsonObject json; 59 | json[JsonRW::FileNameTag] = file_name; 60 | track_controls = new TrackControls(json, QDir(), &main_window); 61 | track_settings = new TrackSettings(track_controls); 62 | QSignalSpy loaded(track_settings, &TrackSettings::loaded); 63 | QVERIFY(loaded.wait()); 64 | 65 | track = track_controls->track(); 66 | track->pause(); 67 | } 68 | 69 | void TestTrackSettings::testInitialTrackProperties() 70 | { 71 | QCOMPARE(track_settings->m_track_label->text(), "sound_0100.wav"); 72 | QCOMPARE(track_settings->m_track_duration->text(), "1.0 s"); 73 | 74 | QCOMPARE(track_settings->m_fade_in_slider->value(), 125); 75 | QCOMPARE(track_settings->m_fade_in_label->text(), "0.3 s"); 76 | 77 | QCOMPARE(track_settings->m_fade_out_slider->value(), 125); 78 | QCOMPARE(track_settings->m_fade_out_label->text(), "0.3 s"); 79 | 80 | QCOMPARE(track_settings->m_position_slider_A->value(), 0); 81 | QCOMPARE(track_settings->m_position_label_A->text(), "0.0 s"); 82 | 83 | QCOMPARE(track_settings->m_position_slider_B->value(), 0); 84 | QCOMPARE(track_settings->m_position_label_B->text(), "0.0 s"); 85 | 86 | QCOMPARE(track_settings->m_gap_spin_box->value(), 10); 87 | QCOMPARE(track_settings->m_random_gap_check_box->checkState(), Qt::CheckState::Unchecked); 88 | QCOMPARE(track_settings->m_gap_max_spin_box->value(), 300); 89 | } 90 | 91 | void TestTrackSettings::testPlayerPositionChanged() 92 | { 93 | // slider A 94 | auto slider_value = track_settings->m_position_slider_A->value(); 95 | QCOMPARE(slider_value, 0); 96 | auto slider_label = track_settings->m_position_label_A->text(); 97 | QCOMPARE(slider_label, "0.0 s"); 98 | 99 | auto position = track->duration(); 100 | track_settings->playerPositionChanged(position, Slider::PlayerA); 101 | slider_value = track_settings->m_position_slider_A->value(); 102 | QCOMPARE(slider_value, track_settings->m_fade_in_slider->maximum()); 103 | slider_label = track_settings->m_position_label_A->text(); 104 | QCOMPARE(slider_label, "1.0 s"); 105 | 106 | position = track->duration() / 3; 107 | track_settings->playerPositionChanged(position, Slider::PlayerA); 108 | slider_value = track_settings->m_position_slider_A->value(); 109 | QCOMPARE(slider_value, track_settings->m_fade_in_slider->maximum() / 3); 110 | slider_label = track_settings->m_position_label_A->text(); 111 | QCOMPARE(slider_label, "0.3 s"); 112 | 113 | position = 0; 114 | track_settings->playerPositionChanged(position, Slider::PlayerA); 115 | slider_value = track_settings->m_position_slider_A->value(); 116 | QCOMPARE(slider_value, 0); 117 | slider_label = track_settings->m_position_label_A->text(); 118 | QCOMPARE(slider_label, "0.0 s"); 119 | 120 | // slider B 121 | slider_value = track_settings->m_position_slider_B->value(); 122 | QCOMPARE(slider_value, 0); 123 | slider_label = track_settings->m_position_label_B->text(); 124 | QCOMPARE(slider_label, "0.0 s"); 125 | 126 | position = track->duration(); 127 | track_settings->playerPositionChanged(position, Slider::PlayerB); 128 | slider_value = track_settings->m_position_slider_B->value(); 129 | QCOMPARE(slider_value, track_settings->m_fade_in_slider->maximum()); 130 | slider_label = track_settings->m_position_label_B->text(); 131 | QCOMPARE(slider_label, "1.0 s"); 132 | 133 | position = track->duration() / 3; 134 | track_settings->playerPositionChanged(position, Slider::PlayerB); 135 | slider_value = track_settings->m_position_slider_B->value(); 136 | QCOMPARE(slider_value, track_settings->m_fade_in_slider->maximum() / 3); 137 | slider_label = track_settings->m_position_label_B->text(); 138 | QCOMPARE(slider_label, "0.3 s"); 139 | 140 | position = 0; 141 | track_settings->playerPositionChanged(position, Slider::PlayerB); 142 | slider_value = track_settings->m_position_slider_B->value(); 143 | QCOMPARE(slider_value, 0); 144 | slider_label = track_settings->m_position_label_B->text(); 145 | QCOMPARE(slider_label, "0.0 s"); 146 | } 147 | 148 | void TestTrackSettings::testTrackToSliderPosition() 149 | { 150 | auto position = track->duration(); 151 | auto slider_value = track_settings->trackToSliderPosition(position, track_settings->m_fade_in_slider); 152 | QCOMPARE(slider_value, track_settings->m_fade_in_slider->maximum()); 153 | 154 | position = track->duration() / 2; 155 | slider_value = track_settings->trackToSliderPosition(position, track_settings->m_fade_in_slider); 156 | QCOMPARE(slider_value, track_settings->m_fade_in_slider->maximum() / 2); 157 | 158 | position = 0; 159 | slider_value = track_settings->trackToSliderPosition(position, track_settings->m_fade_in_slider); 160 | QCOMPARE(slider_value, 0); 161 | } 162 | 163 | void TestTrackSettings::testFadeSliderChanged() 164 | { 165 | QCOMPARE(track_settings->m_fade_in_slider->value(), 125); 166 | QCOMPARE(track_settings->m_fade_in_label->text(), "0.3 s"); 167 | QCOMPARE(track->fadeInDuration(), 250); 168 | 169 | track_settings->m_fade_in_slider->setValue(0); 170 | QCOMPARE(track_settings->m_fade_in_slider->value(), 0); 171 | QCOMPARE(track_settings->m_fade_in_label->text(), "0.0 s"); 172 | QCOMPARE(track->fadeInDuration(), 0); 173 | 174 | track_settings->m_fade_in_slider->setValue(250); 175 | QCOMPARE(track_settings->m_fade_in_slider->value(), 250); 176 | QCOMPARE(track_settings->m_fade_in_label->text(), "0.5 s"); 177 | QCOMPARE(track->fadeInDuration(), 500); 178 | 179 | // overlap of fade-in/out is prevented 180 | track_settings->m_fade_in_slider->setValue(450); 181 | QCOMPARE(track_settings->m_fade_in_slider->value(), 375); 182 | QCOMPARE(track_settings->m_fade_in_label->text(), "0.8 s"); 183 | QCOMPARE(track->fadeInDuration(), 750); 184 | 185 | track_settings->m_fade_out_slider->setValue(100); 186 | QCOMPARE(track_settings->m_fade_out_slider->value(), 100); 187 | QCOMPARE(track_settings->m_fade_out_label->text(), "0.2 s"); 188 | QCOMPARE(track->fadeOutDuration(), 200); 189 | 190 | track_settings->m_fade_out_slider->setValue(200); 191 | QCOMPARE(track_settings->m_fade_out_slider->value(), 125); 192 | QCOMPARE(track_settings->m_fade_out_label->text(), "0.3 s"); 193 | QCOMPARE(track->fadeOutDuration(), 250); 194 | } 195 | 196 | void TestTrackSettings::testGap() 197 | { 198 | track_settings->m_gap_spin_box->setValue(20.0); 199 | QCOMPARE(track->gap(), 20.0); 200 | track_settings->m_gap_max_spin_box->setValue(200.0); 201 | QCOMPARE(track->maxGap(), 200.0); 202 | track_settings->m_random_gap_check_box->setChecked(true); 203 | QCOMPARE(track->randomGap(), true); 204 | track_settings->m_random_gap_check_box->setChecked(false); 205 | QCOMPARE(track->randomGap(), false); 206 | 207 | // gap > gapMax is prevented 208 | track_settings->m_gap_max_spin_box->setValue(100.0); 209 | auto value = track_settings->m_gap_max_spin_box->value() + 0.1; 210 | track_settings->m_gap_spin_box->setValue(value); 211 | QVERIFY(track_settings->m_gap_spin_box->value() != value); 212 | QCOMPARE(track_settings->m_gap_spin_box->value(), track_settings->m_gap_max_spin_box->value()); 213 | 214 | // gapMax < gap is prevented 215 | track_settings->m_gap_spin_box->setValue(10.0); 216 | value = track_settings->m_gap_spin_box->value() - 0.1; 217 | track_settings->m_gap_max_spin_box->setValue(value); 218 | QVERIFY(track_settings->m_gap_max_spin_box->value() != value); 219 | QCOMPARE(track_settings->m_gap_max_spin_box->value(), track_settings->m_gap_spin_box->value()); 220 | } 221 | 222 | QTEST_MAIN(TestTrackSettings) 223 | #include "TestTrackSettings.moc" 224 | -------------------------------------------------------------------------------- /tests/TestTransition.cpp: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Denis Danilov 2 | // SPDX-License-Identifier: GPL-3.0-only 3 | 4 | #include "Transition.h" 5 | 6 | #include 7 | 8 | class TestTransition : public QObject 9 | { 10 | Q_OBJECT 11 | 12 | private slots: 13 | void testConvertTransition(); 14 | }; 15 | 16 | void TestTransition::testConvertTransition() 17 | { 18 | QCOMPARE(convertTransition(Qt::CheckState::Unchecked), Transition::FadeOutIn); 19 | QCOMPARE(convertTransition(Transition::FadeOutIn), Qt::CheckState::Unchecked); 20 | 21 | QCOMPARE(convertTransition(Qt::CheckState::PartiallyChecked), Transition::CrossFade); 22 | QCOMPARE(convertTransition(Transition::CrossFade), Qt::CheckState::PartiallyChecked); 23 | 24 | QCOMPARE(convertTransition(Qt::CheckState::Checked), Transition::FadeOutGapIn); 25 | QCOMPARE(convertTransition(Transition::FadeOutGapIn), Qt::CheckState::Checked); 26 | } 27 | 28 | QTEST_MAIN(TestTransition) 29 | #include "TestTransition.moc" 30 | -------------------------------------------------------------------------------- /tests/media/sound_0000.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/tests/media/sound_0000.wav -------------------------------------------------------------------------------- /tests/media/sound_0100.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/tests/media/sound_0100.wav -------------------------------------------------------------------------------- /tests/media/sound_XXXX.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/tests/media/sound_XXXX.wav -------------------------------------------------------------------------------- /tests/media/video_0100.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddanilov/soundscape/cce3565e444d3168c11e36f18fd0c3989ed4a7e6/tests/media/video_0100.webm --------------------------------------------------------------------------------